Compare commits

..

No commits in common. "main" and "v0.5.1" have entirely different histories.
main ... v0.5.1

18 changed files with 725 additions and 886 deletions

View file

@ -2,11 +2,11 @@ name: release
on: on:
push: push:
tags: ["v*"] tags: ['v*']
tags-ignore: ['v*-rc.*']
jobs: jobs:
build: build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
steps: steps:
- name: checkout - name: checkout
@ -17,16 +17,7 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: | run: cd src && bash ci/build.sh cargo build --release --locked
set -euo pipefail
if [ ! -f src/ci/build.sh ]; then
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
exit 1
fi
cd src && bash ci/build.sh cargo build --release --locked || {
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
exit 1
}
- name: prepare artifacts - name: prepare artifacts
run: | run: |
@ -46,14 +37,8 @@ jobs:
ln -sfn "${VERSION}" "/srv/breadway-dl/breadpad/latest" ln -sfn "${VERSION}" "/srv/breadway-dl/breadpad/latest"
- name: regenerate index.json - name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh

1095
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ members = [
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.5.3" version = "0.5.1"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Breadway"] authors = ["Breadway"]
@ -24,10 +24,9 @@ chrono = { version = "0.4", features = ["serde"] }
rrule = "0.12" rrule = "0.12"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
zbus = { version = "4", default-features = false, features = ["tokio"] } zbus = { version = "4", default-features = false, features = ["tokio"] }
# WHY: bread-onnx's Provider enum references every EP type, so those ort ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] }
# features must be on in the consumer even if breadpad only requests MIGraphX. ndarray = "0.16"
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing", "api-24", "migraphx", "cuda", "openvino", "vitis", "load-dynamic"] } tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
tokenizers = { version = "0.23", default-features = false, features = ["http", "fancy-regex"] }
gtk4 = { version = "0.11", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8" gtk4-layer-shell = "0.8"
hyprland = "0.4.0-beta.3" hyprland = "0.4.0-beta.3"

View file

@ -8,11 +8,11 @@ specifically its "Namespaces" and "Integrating a bread\* app" sections — for
the general convention this follows. the general convention this follows.
App id: **`pad`**. Transport: `bread-utils`'s `bread_client` module App id: **`pad`**. Transport: `bread-utils`'s `bread_client` module
(feature `bread-client`) — the capture popup links it directly. One-shot (feature `bread-client`) — the capture popup links it directly. breadpad is
popup / `fire <id>` invocations each `emit` on their own short-lived short-lived (one popup, or one `fire <id>` invocation from the existing
connection. Command verbs are only received while `breadpad listen` is systemd user timer), so each `emit` is its own short-lived connection.
running — that process holds the `bread.command.pad.**` subscription There is no long-running breadpad daemon and therefore no command
open. subscription.
`breadman` (the viewer) does not emit or subscribe. Notes created or edited `breadman` (the viewer) does not emit or subscribe. Notes created or edited
there are not quick-capture, and it is not on the reminder-fire path. there are not quick-capture, and it is not on the reminder-fire path.
@ -23,8 +23,6 @@ there are not quick-capture, and it is not on the reminder-fire path.
|-------|------|------| |-------|------|------|
| `bread.pad.captured` | `{ "id": "<note id>" }` | The capture popup saved a note successfully (`Store::save_note` returned `Ok`). Not emitted when the field is empty, the window is dismissed, classification-only preview happens, or the write fails. | | `bread.pad.captured` | `{ "id": "<note id>" }` | The capture popup saved a note successfully (`Store::save_note` returned `Ok`). Not emitted when the field is empty, the window is dismissed, classification-only preview happens, or the write fails. |
| `bread.pad.reminder.due` | `{ "id": "<note id>" }` | `breadpad fire <id>` decided the reminder is due (`Scheduler::fire` returned true) and is about to show the reminder window. This is the existing in-process systemd-timer hook (`breadpad-reminder-<id>.timer``breadpad fire <id>`), not a new daemon. Not emitted when the note is missing, the fire is outside the missed-grace window, or the reminder window is opened as a `--screenshot` sample. | | `bread.pad.reminder.due` | `{ "id": "<note id>" }` | `breadpad fire <id>` decided the reminder is due (`Scheduler::fire` returned true) and is about to show the reminder window. This is the existing in-process systemd-timer hook (`breadpad-reminder-<id>.timer``breadpad fire <id>`), not a new daemon. Not emitted when the note is missing, the fire is outside the missed-grace window, or the reminder window is opened as a `--screenshot` sample. |
| `bread.pad.capture.done` | `{}` | `bread.command.pad.capture` was received and `breadpad` was spawned. This is the command confirmation, not proof the popup mapped — the spawned process is the same no-args invocation as the capture keybind. |
| `bread.pad.capture.failed` | `{ "error": "<message>" }` | `bread.command.pad.capture` was received but this binary could not be started. |
Note bodies are never included in the payload — only the local note id. Note bodies are never included in the payload — only the local note id.
Notes stay in `~/.local/share/breadpad/notes.jsonl`; the event bus is for Notes stay in `~/.local/share/breadpad/notes.jsonl`; the event bus is for
@ -33,38 +31,19 @@ content.
## Commands honored (`bread.command.pad.*`) ## Commands honored (`bread.command.pad.*`)
These are only received while `breadpad listen` is running. Publishing a None. breadpad has no persistent process that could subscribe, and the
command with no subscriber is a silent no-op — that is the documented actions a command verb would map to already exist as local CLI / keybind
bread convention, not a breadpad bug. paths (`breadpad` for capture, `breadpad fire <id>` for the reminder
window, `breadman` for viewing and editing). Stubbing `capture` / `snooze`
| Verb | Data | Effect | / `done` on the bus without a subscriber (or inventing a daemon just to
|------|------|--------| hold one) would be a no-op dressed up as an API. If breadpad later grows a
| `capture` | none | Same as running `breadpad` with no args: open the capture popup. Emits `bread.pad.capture.done` / `.failed`. | long-running piece that can honor a verb for real, add the verb then.
```lua
bread.spawn(function()
bread.emit("bread.command.pad.capture")
bread.wait("bread.pad.capture.done", { timeout = 5000 })
end)
```
### Not implemented: extra verbs
There is no `snooze` / `done` / `fire` command verb. Reminder fire
already exists as `breadpad fire <id>` (systemd user timer), and
viewing/editing lives in `breadman`. If/when a bus verb maps to real
extra behavior, add it then — do not stub one as a no-op ahead of it.
## Fail-safe behavior ## Fail-safe behavior
- If breadd isn't installed or isn't running, `emit` is a silent no-op - If breadd isn't installed or isn't running, `emit` is a silent no-op
(`BreadClient::emit` never blocks or errors the caller) and the (`BreadClient::emit` never blocks or errors the caller). Capture, save,
command subscription simply never receives anything. Capture, save,
systemd timers, and the reminder window are entirely unaffected. systemd timers, and the reminder window are entirely unaffected.
- If breadd restarts, the command subscription reconnects automatically - There is no command subscription to reconnect. Restarting breadd does
(`BreadClient::subscribe`'s background thread has its own backoff not require restarting breadpad; the next real capture or fire will emit
loop); no restart of `breadpad listen` is needed. again if breadd is reachable at that moment.
- If `breadpad listen` is not running, commands are a graceful no-op at
the bus (no subscriber). One-shot capture / `fire` still emit
`bread.pad.captured` / `bread.pad.reminder.due` on their own
short-lived connection.

View file

@ -13,10 +13,8 @@ path = "src/main.rs"
breadpad-shared = { path = "../breadpad-shared" } breadpad-shared = { path = "../breadpad-shared" }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs. # Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
# Shared `--screenshot` pair validation + settle delay (`screenshot_cli`). # `adw` implies `gtk`. Local chip/init shims remain in src/theme_widgets.rs.
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["adw"] }
# `adw` implies `gtk` (`chip`, `set_chip_active`, `adw::init`).
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["adw"] }
libadwaita = { version = "0.9", features = ["v1_7"] } libadwaita = { version = "0.9", features = ["v1_7"] }
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true

View file

@ -62,16 +62,16 @@ pub fn open_editor(
let type_row = libadwaita::ActionRow::builder().title("Type").build(); let type_row = libadwaita::ActionRow::builder().title("Type").build();
let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build(); let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(note.note_type.as_str().to_string())); let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(note.note_type.as_str().to_string()));
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin().iter().map(|&name| (bread_theme::gtk::chip(name), name)).collect(); let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin().iter().map(|&name| (crate::theme_widgets::chip(name), name)).collect();
for (btn, name) in &type_pills { for (btn, name) in &type_pills {
bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str()); crate::theme_widgets::set_chip_active(btn, *name == selected_type.borrow().as_str());
let sel = selected_type.clone(); let sel = selected_type.clone();
let name = *name; let name = *name;
let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect(); let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| { btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = name.to_string(); *sel.borrow_mut() = name.to_string();
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); } for b in &all_btns { crate::theme_widgets::set_chip_active(b, false); }
bread_theme::gtk::set_chip_active(clicked, true); crate::theme_widgets::set_chip_active(clicked, true);
}); });
type_pill_box.append(btn); type_pill_box.append(btn);
} }

View file

@ -14,14 +14,12 @@ use std::sync::Arc;
mod editor; mod editor;
mod screenshot; mod screenshot;
mod theme_widgets;
mod views; mod views;
// ── Args ───────────────────────────────────────────────────────────────────── // ── Args ─────────────────────────────────────────────────────────────────────
mod args { mod args {
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH};
use std::path::Path;
#[derive(Debug)] #[derive(Debug)]
pub struct Args { pub struct Args {
pub view: Option<String>, pub view: Option<String>,
@ -34,20 +32,18 @@ mod args {
} }
impl Args { impl Args {
/// `None` for a normal run. Exits the process with an error if the /// `None` for a normal run. Exits the process with an error if
/// `--screenshot` / `--output` pair is incomplete, before any GTK /// `--screenshot` was given without `--output`, before any GTK
/// setup happens. /// setup happens.
pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> { pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> {
if let Err(e) = validate_pair( let view = self.screenshot.clone()?;
self.screenshot.as_deref(), let Some(output) = self.output.clone() else {
self.output.as_deref().map(Path::new), eprintln!("breadman: --screenshot requires --output");
) {
eprintln!("breadman: {e}");
std::process::exit(1); std::process::exit(1);
} };
Some(crate::screenshot::ScreenshotRequest { Some(crate::screenshot::ScreenshotRequest {
view: self.screenshot.clone()?, view,
output: self.output.clone()?.into(), output: output.into(),
width: self.width, width: self.width,
height: self.height, height: self.height,
}) })
@ -61,8 +57,8 @@ mod args {
upcoming_plain: false, upcoming_plain: false,
screenshot: None, screenshot: None,
output: None, output: None,
width: DEFAULT_WIDTH, width: 1920,
height: DEFAULT_HEIGHT, height: 1080,
}; };
let raw: Vec<String> = std::env::args().skip(1).collect(); let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0; let mut i = 0;
@ -362,7 +358,7 @@ fn build_app_window(
// Needed once before constructing any adw:: widget (see views::settings) — // Needed once before constructing any adw:: widget (see views::settings) —
// also forces dark mode, since bread-theme's palette is a fixed dark base // also forces dark mode, since bread-theme's palette is a fixed dark base
// regardless of the system GTK preference. // regardless of the system GTK preference.
bread_theme::adw::init(); theme_widgets::init_adw();
let store = Arc::new(Store::new()?); let store = Arc::new(Store::new()?);
let notes = store.load_all()?; let notes = store.load_all()?;
@ -373,7 +369,6 @@ fn build_app_window(
.default_width(960) .default_width(960)
.default_height(640) .default_height(640)
.build(); .build();
bread_theme::gtk::bind_window_auto(&window);
let hbox = gtk4::Box::builder() let hbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal) .orientation(gtk4::Orientation::Horizontal)
@ -616,7 +611,6 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState, prese
.modal(true) .modal(true)
.default_width(500) .default_width(500)
.build(); .build();
bread_theme::gtk::bind_window_auto(&win);
let vbox = gtk4::Box::builder() let vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical) .orientation(gtk4::Orientation::Vertical)
@ -644,17 +638,17 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState, prese
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(preselect.clone())); let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(preselect.clone()));
let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin() let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin()
.iter() .iter()
.map(|&name| (bread_theme::gtk::chip(name), NoteType::from_str(name))) .map(|&name| (theme_widgets::chip(name), NoteType::from_str(name)))
.collect(); .collect();
for (btn, nt) in &chips { for (btn, nt) in &chips {
bread_theme::gtk::set_chip_active(btn, *nt == preselect); theme_widgets::set_chip_active(btn, *nt == preselect);
let sel = selected_type.clone(); let sel = selected_type.clone();
let nt_c = nt.clone(); let nt_c = nt.clone();
let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect(); let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| { btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = nt_c.clone(); *sel.borrow_mut() = nt_c.clone();
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); } for b in &all_btns { theme_widgets::set_chip_active(b, false); }
bread_theme::gtk::set_chip_active(clicked, true); theme_widgets::set_chip_active(clicked, true);
}); });
chip_box.append(btn); chip_box.append(btn);
} }

View file

@ -19,7 +19,6 @@
//! the button/click-handler entirely), with no-op save/delete/error //! the button/click-handler entirely), with no-op save/delete/error
//! callbacks since nothing here should actually persist a change. //! callbacks since nothing here should actually persist a change.
use bread_utils::screenshot_cli::SETTLE_DELAY;
use gtk4::prelude::*; use gtk4::prelude::*;
use libadwaita::prelude::*; use libadwaita::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
@ -27,10 +26,15 @@ use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
/// Extra settle time after `map` for the first frame to actually paint
/// before grim runs — `map` fires once the surface exists, not once
/// anything has been drawn into it.
const SETTLE_DELAY: Duration = Duration::from_millis(300);
/// Delay before popping the editor popover open — same reasoning as every /// Delay before popping the editor popover open — same reasoning as every
/// other app's PRE_POPUP_DELAY: the parent window's own layout needs a beat /// other app's PRE_POPUP_DELAY: the parent window's own layout needs a beat
/// to settle first. /// to settle first.
const PRE_POPUP_DELAY: Duration = SETTLE_DELAY; const PRE_POPUP_DELAY: Duration = Duration::from_millis(300);
#[derive(Clone)] #[derive(Clone)]
pub struct ScreenshotRequest { pub struct ScreenshotRequest {

View file

@ -0,0 +1,23 @@
//! Local stand-ins for `bread_theme::gtk::{chip, set_chip_active}` and
//! `bread_theme::adw::init`, which are not on bread-theme v0.7.1.
use gtk4::prelude::*;
pub fn chip(label: &str) -> gtk4::Button {
gtk4::Button::builder().label(label).css_classes(["chip"]).build()
}
pub fn set_chip_active(chip: &impl IsA<gtk4::Widget>, active: bool) {
if active {
chip.add_css_class("active");
} else {
chip.remove_css_class("active");
}
}
/// Initializes libadwaita and forces dark mode (bread-theme's palette is a
/// fixed dark base regardless of the system GTK preference).
pub fn init_adw() {
libadwaita::init().expect("failed to initialize libadwaita");
libadwaita::StyleManager::default().set_color_scheme(libadwaita::ColorScheme::ForceDark);
}

View file

@ -92,10 +92,10 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(cfg.settings.default_type.clone())); let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(cfg.settings.default_type.clone()));
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin() let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin()
.iter() .iter()
.map(|&name| (bread_theme::gtk::chip(name), name)) .map(|&name| (crate::theme_widgets::chip(name), name))
.collect(); .collect();
for (btn, name) in &type_pills { for (btn, name) in &type_pills {
bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str()); crate::theme_widgets::set_chip_active(btn, *name == selected_type.borrow().as_str());
type_pill_box.append(btn); type_pill_box.append(btn);
} }
general_list.append(&field_row("Default type", None, &type_pill_box)); general_list.append(&field_row("Default type", None, &type_pill_box));
@ -260,8 +260,8 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect(); let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| { btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = name.to_string(); *sel.borrow_mut() = name.to_string();
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); } for b in &all_btns { crate::theme_widgets::set_chip_active(b, false); }
bread_theme::gtk::set_chip_active(clicked, true); crate::theme_widgets::set_chip_active(clicked, true);
apply_now(); apply_now();
}); });
} }

View file

@ -7,8 +7,7 @@ authors.workspace = true
[dependencies] [dependencies]
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] }
gtk4.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
serde.workspace = true serde.workspace = true
@ -20,7 +19,7 @@ tokio.workspace = true
zbus.workspace = true zbus.workspace = true
ort.workspace = true ort.workspace = true
tokenizers.workspace = true tokenizers.workspace = true
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } ndarray.workspace = true
toml.workspace = true toml.workspace = true
dirs.workspace = true dirs.workspace = true
regex.workspace = true regex.workspace = true

View file

@ -2,8 +2,6 @@ use crate::ai::OllamaClient;
use crate::config::OllamaConfig; use crate::config::OllamaConfig;
use crate::parser::parse_rule_based; use crate::parser::parse_rule_based;
use crate::types::{ClassificationResult, NoteType}; use crate::types::{ClassificationResult, NoteType};
use bread_onnx::{build_session, Provider};
use ort::session::builder::GraphOptimizationLevel;
use std::path::PathBuf; use std::path::PathBuf;
/// Minimum Tier 1 confidence needed to skip Tier 2 entirely. /// Minimum Tier 1 confidence needed to skip Tier 2 entirely.
@ -18,7 +16,7 @@ pub enum ExecutionProvider {
impl ExecutionProvider { impl ExecutionProvider {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
ExecutionProvider::Gpu => "MIGraphX (iGPU)", ExecutionProvider::Gpu => "ROCm (iGPU)",
ExecutionProvider::Cpu => "CPU", ExecutionProvider::Cpu => "CPU",
} }
} }
@ -109,7 +107,9 @@ impl Classifier {
// ── Tier 2 ─────────────────────────────────────────────────────────── // ── Tier 2 ───────────────────────────────────────────────────────────
// ONNX model classifies the type only; Tier 1's time/rrule/body are kept. // ONNX model classifies the type only; Tier 1's time/rrule/body are kept.
let tier2 = if let (Some(session), Some(tokenizer)) = (&mut self.session, &self.tokenizer) { let tier2 = if let (Some(session), Some(tokenizer)) =
(&mut self.session, &self.tokenizer)
{
match run_onnx(session, tokenizer, text) { match run_onnx(session, tokenizer, text) {
Ok(r) => { Ok(r) => {
tracing::debug!("Tier 2: {:?} conf={:.2}", r.note_type, r.confidence); tracing::debug!("Tier 2: {:?} conf={:.2}", r.note_type, r.confidence);
@ -163,18 +163,9 @@ impl Classifier {
// entailment score across all five passes. // entailment score across all five passes.
const HYPOTHESES: [(&str, &str); 5] = [ const HYPOTHESES: [(&str, &str); 5] = [
("This note is a task or action item to complete.", "todo"), ("This note is a task or action item to complete.", "todo"),
( ("This note is a reminder with a specific time or deadline.", "reminder"),
"This note is a reminder with a specific time or deadline.", ("This note is an idea, suggestion, or creative thought.", "idea"),
"reminder", ("This note is a general observation or piece of information.", "note"),
),
(
"This note is an idea, suggestion, or creative thought.",
"idea",
),
(
"This note is a general observation or piece of information.",
"note",
),
("This note is a question that needs an answer.", "question"), ("This note is a question that needs an answer.", "question"),
]; ];
@ -193,17 +184,15 @@ fn run_onnx(
.map_err(|e| anyhow::anyhow!("tokenize: {}", e))?; .map_err(|e| anyhow::anyhow!("tokenize: {}", e))?;
let ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect(); let ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
let mask: Vec<i64> = encoding let mask: Vec<i64> = encoding.get_attention_mask().iter().map(|&x| x as i64).collect();
.get_attention_mask()
.iter()
.map(|&x| x as i64)
.collect();
let len = ids.len(); let len = ids.len();
let ids_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], ids)) let ids_tensor = ort::value::Tensor::<i64>::from_array(
.map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?; (vec![1i64, len as i64], ids)
let mask_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], mask)) ).map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
.map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?; let mask_tensor = ort::value::Tensor::<i64>::from_array(
(vec![1i64, len as i64], mask)
).map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
let inputs = ort::inputs![ let inputs = ort::inputs![
"input_ids" => ids_tensor, "input_ids" => ids_tensor,
@ -218,7 +207,10 @@ fn run_onnx(
.map_err(|e| anyhow::anyhow!("extract logits: {}", e))?; .map_err(|e| anyhow::anyhow!("extract logits: {}", e))?;
let (_, logits_slice) = logits; let (_, logits_slice) = logits;
entailment_scores[i] = logits_slice.get(ENTAILMENT_IDX).copied().unwrap_or(0.0); entailment_scores[i] = logits_slice
.get(ENTAILMENT_IDX)
.copied()
.unwrap_or(0.0);
} }
let best_idx = entailment_scores let best_idx = entailment_scores
@ -251,21 +243,24 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
exps[idx] / sum exps[idx] / sum
} }
fn try_load_session(path: &std::path::Path) -> (Option<ort::session::Session>, ExecutionProvider) { fn try_load_session(
// WHY: distro onnxruntime-rocm is MIGraphX, not classic ROCm; bread-onnx path: &std::path::Path,
// appends CPU so a missing GPU EP does not disable Tier 2. ) -> (Option<ort::session::Session>, ExecutionProvider) {
match build_session( // Try ROCm (iGPU) first, fall back to CPU.
path, let rocm_available = {
GraphOptimizationLevel::Level3, use ort::execution_providers::ExecutionProvider as _;
&[Provider::MiGraphX { device_id: 0 }], ort::ep::ROCm::default().is_available().unwrap_or(false)
) { };
if rocm_available {
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
Ok(s) => { Ok(s) => {
tracing::info!("ONNX session loaded (MIGraphX, CPU fallback)"); tracing::info!("ONNX session loaded (ROCm iGPU)");
(Some(s), ExecutionProvider::Gpu) return (Some(s), ExecutionProvider::Gpu);
} }
Err(e) => { Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
tracing::debug!("MIGraphX session failed: {}; trying CPU", e); }
match build_session(path, GraphOptimizationLevel::Level3, &[Provider::Cpu]) { }
match build_onnx_session(path, ort::ep::CPU::default().build()) {
Ok(s) => { Ok(s) => {
tracing::info!("ONNX session loaded (CPU)"); tracing::info!("ONNX session loaded (CPU)");
(Some(s), ExecutionProvider::Cpu) (Some(s), ExecutionProvider::Cpu)
@ -276,5 +271,14 @@ fn try_load_session(path: &std::path::Path) -> (Option<ort::session::Session>, E
} }
} }
} }
}
fn build_onnx_session(
path: &std::path::Path,
ep: ort::ep::ExecutionProviderDispatch,
) -> anyhow::Result<ort::session::Session> {
let mut builder = ort::session::Session::builder()
.map_err(|e| anyhow::anyhow!("builder: {}", e))?
.with_execution_providers([ep])
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
} }

View file

@ -13,11 +13,6 @@ pub fn apply_live() {
}); });
} }
/// Bind a window to the palette of the monitor it is rendered on.
pub fn bind_window(window: &impl gtk4::prelude::IsA<gtk4::Native>) {
bread_theme::gtk::bind_window_auto(window);
}
/// Generate the full breadpad/breadman CSS string. The base — `@define-color` /// Generate the full breadpad/breadman CSS string. The base — `@define-color`
/// palette, fonts, and generic widget styling — comes from the shared /// palette, fonts, and generic widget styling — comes from the shared
/// `bread_theme::stylesheet`, so breadpad and breadman look identical to the /// `bread_theme::stylesheet`, so breadpad and breadman look identical to the

View file

@ -1,24 +1,17 @@
use breadpad_shared::classifier::{Classifier, ExecutionProvider}; use breadpad_shared::classifier::{Classifier, ExecutionProvider};
use breadpad_shared::types::NoteType; use breadpad_shared::types::NoteType;
use chrono::Timelike; use chrono::Timelike;
use std::path::PathBuf;
/// Rule-based path only — a present `~/.local/share/breadpad/model` must not
/// change these assertions.
fn cl() -> Classifier { fn cl() -> Classifier {
Classifier::load_with_paths( Classifier::load("08:00")
"08:00",
PathBuf::from("/nonexistent/classifier.onnx"),
PathBuf::from("/nonexistent/tokenizer.json"),
)
} }
#[test] #[test]
fn active_provider_is_valid() { fn active_provider_is_valid() {
// The active provider depends on the host: a machine with the ONNX model present and // The active provider depends on the host: a machine with the ONNX model present and
// a working MIGraphX iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no // a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
// model is available we must be on CPU (no session => no GPU EP in use). // model is available we must be on CPU (no session => no GPU EP in use).
let c = Classifier::load("08:00"); let c = cl();
assert!(matches!( assert!(matches!(
c.active_provider, c.active_provider,
ExecutionProvider::Cpu | ExecutionProvider::Gpu ExecutionProvider::Cpu | ExecutionProvider::Gpu
@ -56,28 +49,19 @@ fn classify_reminder_via_fallback() {
#[test] #[test]
fn classify_idea_via_fallback() { fn classify_idea_via_fallback() {
let mut c = cl(); let mut c = cl();
assert_eq!( assert_eq!(c.classify("what if we added a calendar view").note_type, NoteType::Idea);
c.classify("what if we added a calendar view").note_type,
NoteType::Idea
);
} }
#[test] #[test]
fn classify_question_via_fallback() { fn classify_question_via_fallback() {
let mut c = cl(); let mut c = cl();
assert_eq!( assert_eq!(c.classify("why does this fail?").note_type, NoteType::Question);
c.classify("why does this fail?").note_type,
NoteType::Question
);
} }
#[test] #[test]
fn classify_note_via_fallback() { fn classify_note_via_fallback() {
let mut c = cl(); let mut c = cl();
assert_eq!( assert_eq!(c.classify("meeting went well today").note_type, NoteType::Note);
c.classify("meeting went well today").note_type,
NoteType::Note
);
} }
#[test] #[test]
@ -90,11 +74,7 @@ fn classify_recurrence_via_fallback() {
#[test] #[test]
fn classify_custom_morning_time() { fn classify_custom_morning_time() {
let mut c = Classifier::load_with_paths( let mut c = Classifier::load("07:15");
"07:15",
PathBuf::from("/nonexistent/classifier.onnx"),
PathBuf::from("/nonexistent/tokenizer.json"),
);
let r = c.classify("sync tomorrow morning"); let r = c.classify("sync tomorrow morning");
let t = r.time.expect("should have a time for tomorrow morning"); let t = r.time.expect("should have a time for tomorrow morning");
let local: chrono::DateTime<chrono::Local> = t.into(); let local: chrono::DateTime<chrono::Local> = t.into();
@ -134,16 +114,12 @@ fn classify_returns_cleaned_body() {
let mut c = cl(); let mut c = cl();
let r = c.classify("call mum at 6pm"); let r = c.classify("call mum at 6pm");
assert!(r.body.contains("call mum"), "body: {}", r.body); assert!(r.body.contains("call mum"), "body: {}", r.body);
assert!( assert!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body);
!r.body.contains("6pm"),
"time phrase should be stripped from body: {}",
r.body
);
} }
#[test] #[test]
fn model_path_points_to_expected_location() { fn model_path_points_to_expected_location() {
let c = Classifier::load("08:00"); let c = cl();
assert!( assert!(
c.model_path.to_str().unwrap().contains("breadpad"), c.model_path.to_str().unwrap().contains("breadpad"),
"model path: {:?}", "model path: {:?}",

View file

@ -9,18 +9,12 @@ use breadpad_shared::classifier::Classifier;
use breadpad_shared::store::Store; use breadpad_shared::store::Store;
use breadpad_shared::types::{Note, NoteType}; use breadpad_shared::types::{Note, NoteType};
use chrono::Timelike; use chrono::Timelike;
use std::path::PathBuf;
use tempfile::TempDir; use tempfile::TempDir;
// Mirrors commit_note() in breadpad/src/main.rs. // Mirrors commit_note() in breadpad/src/main.rs.
// `user_type` is the type the user selected in the chip row (default = NoteType::Note). // `user_type` is the type the user selected in the chip row (default = NoteType::Note).
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note { fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
// WHY: pipeline tests cover classify→save→reload, not a host ONNX model. let mut classifier = Classifier::load("08:00");
let mut classifier = Classifier::load_with_paths(
"08:00",
PathBuf::from("/nonexistent/classifier.onnx"),
PathBuf::from("/nonexistent/tokenizer.json"),
);
let result = classifier.classify(text); let result = classifier.classify(text);
let mut note = Note::new(text.into(), user_type.clone(), None); let mut note = Note::new(text.into(), user_type.clone(), None);
@ -67,11 +61,7 @@ fn todo_note_appears_in_store() {
#[test] #[test]
fn idea_note_appears_in_store() { fn idea_note_appears_in_store() {
let (dir, store) = setup(); let (dir, store) = setup();
capture( capture(&store, "what if we added dark mode", NoteType::from_str("note"));
&store,
"what if we added dark mode",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap(); let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len(), 1); assert_eq!(notes.len(), 1);
@ -81,11 +71,7 @@ fn idea_note_appears_in_store() {
#[test] #[test]
fn question_note_appears_in_store() { fn question_note_appears_in_store() {
let (dir, store) = setup(); let (dir, store) = setup();
capture( capture(&store, "why does the cache miss on cold start?", NoteType::from_str("note"));
&store,
"why does the cache miss on cold start?",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap(); let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len(), 1); assert_eq!(notes.len(), 1);
@ -111,10 +97,7 @@ fn reminder_has_time_set() {
let notes = breadman_store(&dir).load_all().unwrap(); let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder); assert_eq!(notes[0].note_type, NoteType::Reminder);
assert!( assert!(notes[0].time.is_some(), "reminder should have a scheduled time");
notes[0].time.is_some(),
"reminder should have a scheduled time"
);
let local: chrono::DateTime<chrono::Local> = notes[0].time.unwrap().into(); let local: chrono::DateTime<chrono::Local> = notes[0].time.unwrap().into();
assert_eq!(local.hour(), 18); assert_eq!(local.hour(), 18);
} }
@ -125,21 +108,14 @@ fn reminder_body_has_time_stripped() {
capture(&store, "call mum at 6pm", NoteType::from_str("note")); capture(&store, "call mum at 6pm", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap(); let notes = breadman_store(&dir).load_all().unwrap();
assert!( assert!(!notes[0].body.contains("6pm"), "time phrase should be removed from body");
!notes[0].body.contains("6pm"),
"time phrase should be removed from body"
);
assert!(notes[0].body.contains("call mum")); assert!(notes[0].body.contains("call mum"));
} }
#[test] #[test]
fn in_duration_reminder_has_time() { fn in_duration_reminder_has_time() {
let (dir, store) = setup(); let (dir, store) = setup();
capture( capture(&store, "check on the build in 30 minutes", NoteType::from_str("note"));
&store,
"check on the build in 30 minutes",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap(); let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder); assert_eq!(notes[0].note_type, NoteType::Reminder);
@ -151,11 +127,7 @@ fn in_duration_reminder_has_time() {
#[test] #[test]
fn recurring_reminder_has_rrule() { fn recurring_reminder_has_rrule() {
let (dir, store) = setup(); let (dir, store) = setup();
capture( capture(&store, "standup every monday at 9am", NoteType::from_str("note"));
&store,
"standup every monday at 9am",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap(); let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder); assert_eq!(notes[0].note_type, NoteType::Reminder);
@ -167,20 +139,11 @@ fn recurring_reminder_has_rrule() {
#[test] #[test]
fn daily_reminder_has_rrule() { fn daily_reminder_has_rrule() {
let (dir, store) = setup(); let (dir, store) = setup();
capture( capture(&store, "drink water every day at 8am", NoteType::from_str("note"));
&store,
"drink water every day at 8am",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap(); let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder); assert_eq!(notes[0].note_type, NoteType::Reminder);
assert!(notes[0] assert!(notes[0].rrule.as_ref().unwrap().as_str().contains("FREQ=DAILY"));
.rrule
.as_ref()
.unwrap()
.as_str()
.contains("FREQ=DAILY"));
} }
// ---- user-forced type is respected ---- // ---- user-forced type is respected ----
@ -192,11 +155,7 @@ fn user_selected_type_overrides_classifier() {
capture(&store, "fix the login bug", NoteType::Idea); capture(&store, "fix the login bug", NoteType::Idea);
let notes = breadman_store(&dir).load_all().unwrap(); let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!( assert_eq!(notes[0].note_type, NoteType::Idea, "user chip selection should win over classifier");
notes[0].note_type,
NoteType::Idea,
"user chip selection should win over classifier"
);
} }
#[test] #[test]
@ -214,11 +173,7 @@ fn user_selected_reminder_overrides_classifier() {
fn three_notes_all_visible_to_breadman() { fn three_notes_all_visible_to_breadman() {
let (dir, store) = setup(); let (dir, store) = setup();
capture(&store, "buy milk", NoteType::from_str("note")); capture(&store, "buy milk", NoteType::from_str("note"));
capture( capture(&store, "what if we rewrote in Zig", NoteType::from_str("note"));
&store,
"what if we rewrote in Zig",
NoteType::from_str("note"),
);
capture(&store, "team standup went well", NoteType::from_str("note")); capture(&store, "team standup went well", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap(); let notes = breadman_store(&dir).load_all().unwrap();

View file

@ -1,83 +0,0 @@
//! Long-running command subscription for `bread.command.pad.*`.
//!
//! `breadpad` is still a one-shot capture popup by default. `breadpad listen`
//! is the optional persistent process that can honor bus commands. See
//! `EVENTS.md`.
use anyhow::Result;
use bread_utils::bread_client::{BreadClient, BreadEvent};
/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`.
const APP_ID: &str = "pad";
/// Subscribe to `bread.command.pad.**` and block until the process is killed.
///
/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects
/// with backoff, and `on_event` simply isn't called until the daemon is up.
pub fn run() -> Result<()> {
let client = BreadClient::connect(APP_ID);
if client.health().is_none() {
tracing::warn!("breadd unreachable; command subscription will connect when it comes back");
}
let _commands = client.subscribe("bread.command.pad.**", |event| {
handle_command(&event);
});
tracing::info!("listening for bread.command.pad.**");
loop {
std::thread::park();
}
}
/// Reacts to `bread.command.pad.*` verbs. Only `capture` is honored today —
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
fn handle_command(event: &BreadEvent) {
let Some(verb) = command_verb(&event.event) else {
return;
};
match verb {
"capture" => handle_capture(),
other => {
tracing::debug!("ignoring unrecognized command verb '{other}'");
}
}
}
fn handle_capture() {
// Same as running `breadpad` with no args: open the capture popup.
let result = spawn_self();
let client = BreadClient::connect(APP_ID);
match result {
Ok(_) => client.emit("bread.pad.capture.done", serde_json::json!({})),
Err(e) => {
tracing::warn!("bread.command.pad.capture failed: {e}");
client.emit(
"bread.pad.capture.failed",
serde_json::json!({ "error": e.to_string() }),
);
}
}
}
fn spawn_self() -> std::io::Result<std::process::Child> {
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadpad"));
std::process::Command::new(exe).spawn()
}
fn command_verb(event_name: &str) -> Option<&str> {
event_name.strip_prefix("bread.command.pad.")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_verb_strips_pad_prefix() {
assert_eq!(command_verb("bread.command.pad.capture"), Some("capture"));
assert_eq!(command_verb("bread.command.pad.snooze"), Some("snooze"));
assert_eq!(command_verb("bread.command.box.open"), None);
assert_eq!(command_verb("bread.pad.captured"), None);
}
}

View file

@ -15,7 +15,6 @@ use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::{Arc, Once}; use std::sync::{Arc, Once};
mod listen;
mod screenshot; mod screenshot;
/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. Events are `bread.pad.*`. /// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. Events are `bread.pad.*`.
@ -51,9 +50,6 @@ fn init_ort_once(cfg: &Config) {
} }
mod args { mod args {
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH};
use std::path::Path;
#[derive(Debug)] #[derive(Debug)]
pub struct Args { pub struct Args {
pub note_type: Option<String>, pub note_type: Option<String>,
@ -68,24 +64,21 @@ mod args {
pub output: Option<String>, pub output: Option<String>,
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub listen: bool,
} }
impl Args { impl Args {
/// `None` for a normal run. Exits the process with an error if the /// `None` for a normal run. Exits the process with an error if
/// `--screenshot` / `--output` pair is incomplete, before any GTK /// `--screenshot` was given without `--output`, before any GTK
/// setup happens. /// setup happens.
pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> { pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> {
if let Err(e) = validate_pair( let view = self.screenshot.clone()?;
self.screenshot.as_deref(), let Some(output) = self.output.clone() else {
self.output.as_deref().map(Path::new), eprintln!("breadpad: --screenshot requires --output");
) {
eprintln!("breadpad: {e}");
std::process::exit(1); std::process::exit(1);
} };
Some(crate::screenshot::ScreenshotRequest { Some(crate::screenshot::ScreenshotRequest {
view: self.screenshot.clone()?, view,
output: self.output.clone()?.into(), output: output.into(),
width: self.width, width: self.width,
height: self.height, height: self.height,
}) })
@ -104,9 +97,8 @@ mod args {
calendar_list_uid: None, calendar_list_uid: None,
screenshot: None, screenshot: None,
output: None, output: None,
width: DEFAULT_WIDTH, width: 1920,
height: DEFAULT_HEIGHT, height: 1080,
listen: false,
}; };
let raw: Vec<String> = std::env::args().skip(1).collect(); let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0; let mut i = 0;
@ -120,7 +112,6 @@ mod args {
"--status" => args.status = true, "--status" => args.status = true,
"download-model" => args.download_model = true, "download-model" => args.download_model = true,
"model-info" => args.model_info = true, "model-info" => args.model_info = true,
"listen" => args.listen = true,
"fire" => { "fire" => {
i += 1; i += 1;
args.fire_id = raw.get(i).cloned(); args.fire_id = raw.get(i).cloned();
@ -174,9 +165,6 @@ fn main() -> Result<()> {
.init(); .init();
let args = args::parse(); let args = args::parse();
if args.listen {
return listen::run();
}
let cfg = Config::load()?; let cfg = Config::load()?;
if args.status { if args.status {
@ -451,7 +439,6 @@ fn build_reminder_window(
window.set_layer(Layer::Overlay); window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::Exclusive); window.set_keyboard_mode(KeyboardMode::Exclusive);
window.auto_exclusive_zone_enable(); window.auto_exclusive_zone_enable();
breadpad_shared::theme::bind_window(&window);
apply_css(&cfg); apply_css(&cfg);
@ -754,7 +741,6 @@ fn build_window(
window.set_anchor(Edge::Bottom, false); window.set_anchor(Edge::Bottom, false);
window.set_anchor(Edge::Left, false); window.set_anchor(Edge::Left, false);
window.set_anchor(Edge::Right, false); window.set_anchor(Edge::Right, false);
breadpad_shared::theme::bind_window(&window);
apply_css(&cfg); apply_css(&cfg);

View file

@ -18,15 +18,19 @@
//! entirely), and "reminder-snooze" (the same window with its snooze //! entirely), and "reminder-snooze" (the same window with its snooze
//! popover open). //! popover open).
use bread_utils::screenshot_cli::SETTLE_DELAY;
use gtk4::prelude::*; use gtk4::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
/// Extra settle time after `map` for the first frame to actually paint
/// before grim runs — `map` fires once the surface exists, not once
/// anything has been drawn into it.
const SETTLE_DELAY: Duration = Duration::from_millis(300);
/// Delay before popping the snooze popover open — same reasoning as every /// Delay before popping the snooze popover open — same reasoning as every
/// other app's PRE_POPUP_DELAY: the parent window's own layout needs a beat /// other app's PRE_POPUP_DELAY: the parent window's own layout needs a beat
/// to settle first. /// to settle first.
const PRE_POPUP_DELAY: Duration = SETTLE_DELAY; const PRE_POPUP_DELAY: Duration = Duration::from_millis(300);
#[derive(Clone)] #[derive(Clone)]
pub struct ScreenshotRequest { pub struct ScreenshotRequest {