Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e26a1b9f | ||
|
|
51338af7eb | ||
|
|
37713eee9e | ||
|
|
852a771651 | ||
|
|
c929517b49 | ||
|
|
54eba3d73a |
18 changed files with 876 additions and 715 deletions
|
|
@ -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,7 +17,16 @@ jobs:
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: cd src && bash ci/build.sh cargo build --release --locked
|
run: |
|
||||||
|
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: |
|
||||||
|
|
@ -37,8 +46,14 @@ 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
|
||||||
|
|
|
||||||
1075
Cargo.lock
generated
1075
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,7 @@ members = [
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.1"
|
version = "0.5.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Breadway"]
|
authors = ["Breadway"]
|
||||||
|
|
@ -24,9 +24,10 @@ 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"] }
|
||||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] }
|
# WHY: bread-onnx's Provider enum references every EP type, so those ort
|
||||||
ndarray = "0.16"
|
# features must be on in the consumer even if breadpad only requests MIGraphX.
|
||||||
tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
|
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing", "api-24", "migraphx", "cuda", "openvino", "vitis", "load-dynamic"] }
|
||||||
|
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"
|
||||||
|
|
|
||||||
53
EVENTS.md
53
EVENTS.md
|
|
@ -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. breadpad is
|
(feature `bread-client`) — the capture popup links it directly. One-shot
|
||||||
short-lived (one popup, or one `fire <id>` invocation from the existing
|
popup / `fire <id>` invocations each `emit` on their own short-lived
|
||||||
systemd user timer), so each `emit` is its own short-lived connection.
|
connection. Command verbs are only received while `breadpad listen` is
|
||||||
There is no long-running breadpad daemon and therefore no command
|
running — that process holds the `bread.command.pad.**` subscription
|
||||||
subscription.
|
open.
|
||||||
|
|
||||||
`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,6 +23,8 @@ 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
|
||||||
|
|
@ -31,19 +33,38 @@ content.
|
||||||
|
|
||||||
## Commands honored (`bread.command.pad.*`)
|
## Commands honored (`bread.command.pad.*`)
|
||||||
|
|
||||||
None. breadpad has no persistent process that could subscribe, and the
|
These are only received while `breadpad listen` is running. Publishing a
|
||||||
actions a command verb would map to already exist as local CLI / keybind
|
command with no subscriber is a silent no-op — that is the documented
|
||||||
paths (`breadpad` for capture, `breadpad fire <id>` for the reminder
|
bread convention, not a breadpad bug.
|
||||||
window, `breadman` for viewing and editing). Stubbing `capture` / `snooze`
|
|
||||||
/ `done` on the bus without a subscriber (or inventing a daemon just to
|
| Verb | Data | Effect |
|
||||||
hold one) would be a no-op dressed up as an API. If breadpad later grows a
|
|------|------|--------|
|
||||||
long-running piece that can honor a verb for real, add the verb then.
|
| `capture` | none | Same as running `breadpad` with no args: open the capture popup. Emits `bread.pad.capture.done` / `.failed`. |
|
||||||
|
|
||||||
|
```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). Capture, save,
|
(`BreadClient::emit` never blocks or errors the caller) and the
|
||||||
|
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.
|
||||||
- There is no command subscription to reconnect. Restarting breadd does
|
- If breadd restarts, the command subscription reconnects automatically
|
||||||
not require restarting breadpad; the next real capture or fire will emit
|
(`BreadClient::subscribe`'s background thread has its own backoff
|
||||||
again if breadd is reachable at that moment.
|
loop); no restart of `breadpad listen` is needed.
|
||||||
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ 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" }
|
||||||
# `adw` implies `gtk`. Local chip/init shims remain in src/theme_widgets.rs.
|
# Shared `--screenshot` pair validation + settle delay (`screenshot_cli`).
|
||||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["adw"] }
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
|
# `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
|
||||||
|
|
|
||||||
|
|
@ -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| (crate::theme_widgets::chip(name), name)).collect();
|
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin().iter().map(|&name| (bread_theme::gtk::chip(name), name)).collect();
|
||||||
for (btn, name) in &type_pills {
|
for (btn, name) in &type_pills {
|
||||||
crate::theme_widgets::set_chip_active(btn, *name == selected_type.borrow().as_str());
|
bread_theme::gtk::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 { crate::theme_widgets::set_chip_active(b, false); }
|
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
|
||||||
crate::theme_widgets::set_chip_active(clicked, true);
|
bread_theme::gtk::set_chip_active(clicked, true);
|
||||||
});
|
});
|
||||||
type_pill_box.append(btn);
|
type_pill_box.append(btn);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,14 @@ 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>,
|
||||||
|
|
@ -32,18 +34,20 @@ mod args {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Args {
|
impl Args {
|
||||||
/// `None` for a normal run. Exits the process with an error if
|
/// `None` for a normal run. Exits the process with an error if the
|
||||||
/// `--screenshot` was given without `--output`, before any GTK
|
/// `--screenshot` / `--output` pair is incomplete, 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> {
|
||||||
let view = self.screenshot.clone()?;
|
if let Err(e) = validate_pair(
|
||||||
let Some(output) = self.output.clone() else {
|
self.screenshot.as_deref(),
|
||||||
eprintln!("breadman: --screenshot requires --output");
|
self.output.as_deref().map(Path::new),
|
||||||
|
) {
|
||||||
|
eprintln!("breadman: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
};
|
}
|
||||||
Some(crate::screenshot::ScreenshotRequest {
|
Some(crate::screenshot::ScreenshotRequest {
|
||||||
view,
|
view: self.screenshot.clone()?,
|
||||||
output: output.into(),
|
output: self.output.clone()?.into(),
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
})
|
})
|
||||||
|
|
@ -57,8 +61,8 @@ mod args {
|
||||||
upcoming_plain: false,
|
upcoming_plain: false,
|
||||||
screenshot: None,
|
screenshot: None,
|
||||||
output: None,
|
output: None,
|
||||||
width: 1920,
|
width: DEFAULT_WIDTH,
|
||||||
height: 1080,
|
height: DEFAULT_HEIGHT,
|
||||||
};
|
};
|
||||||
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;
|
||||||
|
|
@ -358,7 +362,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.
|
||||||
theme_widgets::init_adw();
|
bread_theme::adw::init();
|
||||||
|
|
||||||
let store = Arc::new(Store::new()?);
|
let store = Arc::new(Store::new()?);
|
||||||
let notes = store.load_all()?;
|
let notes = store.load_all()?;
|
||||||
|
|
@ -369,6 +373,7 @@ 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)
|
||||||
|
|
@ -611,6 +616,7 @@ fn show_add_note_window(parent: >k4::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)
|
||||||
|
|
@ -638,17 +644,17 @@ fn show_add_note_window(parent: >k4::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| (theme_widgets::chip(name), NoteType::from_str(name)))
|
.map(|&name| (bread_theme::gtk::chip(name), NoteType::from_str(name)))
|
||||||
.collect();
|
.collect();
|
||||||
for (btn, nt) in &chips {
|
for (btn, nt) in &chips {
|
||||||
theme_widgets::set_chip_active(btn, *nt == preselect);
|
bread_theme::gtk::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 { theme_widgets::set_chip_active(b, false); }
|
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
|
||||||
theme_widgets::set_chip_active(clicked, true);
|
bread_theme::gtk::set_chip_active(clicked, true);
|
||||||
});
|
});
|
||||||
chip_box.append(btn);
|
chip_box.append(btn);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
//! 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;
|
||||||
|
|
@ -26,15 +27,10 @@ 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 = Duration::from_millis(300);
|
const PRE_POPUP_DELAY: Duration = SETTLE_DELAY;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ScreenshotRequest {
|
pub struct ScreenshotRequest {
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
//! 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);
|
|
||||||
}
|
|
||||||
|
|
@ -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| (crate::theme_widgets::chip(name), name))
|
.map(|&name| (bread_theme::gtk::chip(name), name))
|
||||||
.collect();
|
.collect();
|
||||||
for (btn, name) in &type_pills {
|
for (btn, name) in &type_pills {
|
||||||
crate::theme_widgets::set_chip_active(btn, *name == selected_type.borrow().as_str());
|
bread_theme::gtk::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 { crate::theme_widgets::set_chip_active(b, false); }
|
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
|
||||||
crate::theme_widgets::set_chip_active(clicked, true);
|
bread_theme::gtk::set_chip_active(clicked, true);
|
||||||
apply_now();
|
apply_now();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ authors.workspace = true
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] }
|
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
|
||||||
|
gtk4.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
@ -19,7 +20,7 @@ tokio.workspace = true
|
||||||
zbus.workspace = true
|
zbus.workspace = true
|
||||||
ort.workspace = true
|
ort.workspace = true
|
||||||
tokenizers.workspace = true
|
tokenizers.workspace = true
|
||||||
ndarray.workspace = true
|
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ 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.
|
||||||
|
|
@ -16,7 +18,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 => "ROCm (iGPU)",
|
ExecutionProvider::Gpu => "MIGraphX (iGPU)",
|
||||||
ExecutionProvider::Cpu => "CPU",
|
ExecutionProvider::Cpu => "CPU",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,9 +109,7 @@ 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)) =
|
let tier2 = if let (Some(session), Some(tokenizer)) = (&mut self.session, &self.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,9 +163,18 @@ 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 an idea, suggestion, or creative thought.", "idea"),
|
"This note is a reminder with a specific time or deadline.",
|
||||||
("This note is a general observation or piece of information.", "note"),
|
"reminder",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -184,15 +193,17 @@ 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.get_attention_mask().iter().map(|&x| x as i64).collect();
|
let mask: Vec<i64> = encoding
|
||||||
|
.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(
|
let ids_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], ids))
|
||||||
(vec![1i64, len as i64], ids)
|
.map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
|
||||||
).map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
|
let mask_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], mask))
|
||||||
let mask_tensor = ort::value::Tensor::<i64>::from_array(
|
.map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
|
||||||
(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,
|
||||||
|
|
@ -207,10 +218,7 @@ 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
|
entailment_scores[i] = logits_slice.get(ENTAILMENT_IDX).copied().unwrap_or(0.0);
|
||||||
.get(ENTAILMENT_IDX)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let best_idx = entailment_scores
|
let best_idx = entailment_scores
|
||||||
|
|
@ -243,24 +251,21 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
|
||||||
exps[idx] / sum
|
exps[idx] / sum
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_load_session(
|
fn try_load_session(path: &std::path::Path) -> (Option<ort::session::Session>, ExecutionProvider) {
|
||||||
path: &std::path::Path,
|
// WHY: distro onnxruntime-rocm is MIGraphX, not classic ROCm; bread-onnx
|
||||||
) -> (Option<ort::session::Session>, ExecutionProvider) {
|
// appends CPU so a missing GPU EP does not disable Tier 2.
|
||||||
// Try ROCm (iGPU) first, fall back to CPU.
|
match build_session(
|
||||||
let rocm_available = {
|
path,
|
||||||
use ort::execution_providers::ExecutionProvider as _;
|
GraphOptimizationLevel::Level3,
|
||||||
ort::ep::ROCm::default().is_available().unwrap_or(false)
|
&[Provider::MiGraphX { device_id: 0 }],
|
||||||
};
|
) {
|
||||||
if rocm_available {
|
|
||||||
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
|
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
tracing::info!("ONNX session loaded (ROCm iGPU)");
|
tracing::info!("ONNX session loaded (MIGraphX, CPU fallback)");
|
||||||
return (Some(s), ExecutionProvider::Gpu);
|
(Some(s), ExecutionProvider::Gpu)
|
||||||
}
|
}
|
||||||
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
|
Err(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)
|
||||||
|
|
@ -270,15 +275,6 @@ fn try_load_session(
|
||||||
(None, ExecutionProvider::Cpu)
|
(None, ExecutionProvider::Cpu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
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("08:00")
|
Classifier::load_with_paths(
|
||||||
|
"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 ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
|
// a working MIGraphX 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 = cl();
|
let c = Classifier::load("08:00");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
c.active_provider,
|
c.active_provider,
|
||||||
ExecutionProvider::Cpu | ExecutionProvider::Gpu
|
ExecutionProvider::Cpu | ExecutionProvider::Gpu
|
||||||
|
|
@ -49,19 +56,28 @@ 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!(c.classify("what if we added a calendar view").note_type, NoteType::Idea);
|
assert_eq!(
|
||||||
|
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!(c.classify("why does this fail?").note_type, NoteType::Question);
|
assert_eq!(
|
||||||
|
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!(c.classify("meeting went well today").note_type, NoteType::Note);
|
assert_eq!(
|
||||||
|
c.classify("meeting went well today").note_type,
|
||||||
|
NoteType::Note
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -74,7 +90,11 @@ fn classify_recurrence_via_fallback() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_custom_morning_time() {
|
fn classify_custom_morning_time() {
|
||||||
let mut c = Classifier::load("07:15");
|
let mut c = Classifier::load_with_paths(
|
||||||
|
"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();
|
||||||
|
|
@ -114,12 +134,16 @@ 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!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body);
|
assert!(
|
||||||
|
!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 = cl();
|
let c = Classifier::load("08:00");
|
||||||
assert!(
|
assert!(
|
||||||
c.model_path.to_str().unwrap().contains("breadpad"),
|
c.model_path.to_str().unwrap().contains("breadpad"),
|
||||||
"model path: {:?}",
|
"model path: {:?}",
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,18 @@ 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 {
|
||||||
let mut classifier = Classifier::load("08:00");
|
// WHY: pipeline tests cover classify→save→reload, not a host ONNX model.
|
||||||
|
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);
|
||||||
|
|
@ -61,7 +67,11 @@ 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(&store, "what if we added dark mode", NoteType::from_str("note"));
|
capture(
|
||||||
|
&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);
|
||||||
|
|
@ -71,7 +81,11 @@ 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(&store, "why does the cache miss on cold start?", NoteType::from_str("note"));
|
capture(
|
||||||
|
&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);
|
||||||
|
|
@ -97,7 +111,10 @@ 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!(notes[0].time.is_some(), "reminder should have a scheduled time");
|
assert!(
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -108,14 +125,21 @@ 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!(!notes[0].body.contains("6pm"), "time phrase should be removed from body");
|
assert!(
|
||||||
|
!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(&store, "check on the build in 30 minutes", NoteType::from_str("note"));
|
capture(
|
||||||
|
&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);
|
||||||
|
|
@ -127,7 +151,11 @@ 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(&store, "standup every monday at 9am", NoteType::from_str("note"));
|
capture(
|
||||||
|
&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);
|
||||||
|
|
@ -139,11 +167,20 @@ 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(&store, "drink water every day at 8am", NoteType::from_str("note"));
|
capture(
|
||||||
|
&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].rrule.as_ref().unwrap().as_str().contains("FREQ=DAILY"));
|
assert!(notes[0]
|
||||||
|
.rrule
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.contains("FREQ=DAILY"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- user-forced type is respected ----
|
// ---- user-forced type is respected ----
|
||||||
|
|
@ -155,7 +192,11 @@ 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!(notes[0].note_type, NoteType::Idea, "user chip selection should win over classifier");
|
assert_eq!(
|
||||||
|
notes[0].note_type,
|
||||||
|
NoteType::Idea,
|
||||||
|
"user chip selection should win over classifier"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -173,7 +214,11 @@ 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(&store, "what if we rewrote in Zig", NoteType::from_str("note"));
|
capture(
|
||||||
|
&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();
|
||||||
|
|
|
||||||
83
breadpad/src/listen.rs
Normal file
83
breadpad/src/listen.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ 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.*`.
|
||||||
|
|
@ -50,6 +51,9 @@ 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>,
|
||||||
|
|
@ -64,21 +68,24 @@ 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
|
/// `None` for a normal run. Exits the process with an error if the
|
||||||
/// `--screenshot` was given without `--output`, before any GTK
|
/// `--screenshot` / `--output` pair is incomplete, 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> {
|
||||||
let view = self.screenshot.clone()?;
|
if let Err(e) = validate_pair(
|
||||||
let Some(output) = self.output.clone() else {
|
self.screenshot.as_deref(),
|
||||||
eprintln!("breadpad: --screenshot requires --output");
|
self.output.as_deref().map(Path::new),
|
||||||
|
) {
|
||||||
|
eprintln!("breadpad: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
};
|
}
|
||||||
Some(crate::screenshot::ScreenshotRequest {
|
Some(crate::screenshot::ScreenshotRequest {
|
||||||
view,
|
view: self.screenshot.clone()?,
|
||||||
output: output.into(),
|
output: self.output.clone()?.into(),
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
})
|
})
|
||||||
|
|
@ -97,8 +104,9 @@ mod args {
|
||||||
calendar_list_uid: None,
|
calendar_list_uid: None,
|
||||||
screenshot: None,
|
screenshot: None,
|
||||||
output: None,
|
output: None,
|
||||||
width: 1920,
|
width: DEFAULT_WIDTH,
|
||||||
height: 1080,
|
height: DEFAULT_HEIGHT,
|
||||||
|
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;
|
||||||
|
|
@ -112,6 +120,7 @@ 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();
|
||||||
|
|
@ -165,6 +174,9 @@ 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 {
|
||||||
|
|
@ -439,6 +451,7 @@ 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);
|
||||||
|
|
||||||
|
|
@ -741,6 +754,7 @@ 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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,15 @@
|
||||||
//! 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 = Duration::from_millis(300);
|
const PRE_POPUP_DELAY: Duration = SETTLE_DELAY;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ScreenshotRequest {
|
pub struct ScreenshotRequest {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue