Compare commits

...

51 commits
v0.4.0 ... main

Author SHA1 Message Date
Breadway
39e26a1b9f Switch classifier sessions onto bread-onnx v0.7.2
All checks were successful
check / check (push) Successful in 3m42s
dev release / build (push) Successful in 2m28s
Replace local ort Session::builder/ROCm wiring with bread_onnx::build_session
(MIGraphX + CPU fallback, same pin as breadmill). Drop the breadman
chip/init_adw shim now that bread-theme v0.7.4 exports those helpers.
2026-08-23 14:42:57 +08:00
Breadway
51338af7eb Bump version to v0.5.3
All checks were successful
dev release / build (push) Successful in 2m52s
release / build (push) Successful in 2m52s
2026-08-16 14:09:36 +08:00
Breadway
37713eee9e Bind breadpad/breadman windows to the current monitor palette
All checks were successful
dev release / build (push) Successful in 9m28s
Pin bread-theme to v0.7.4.
2026-08-16 13:23:18 +08:00
Breadway
852a771651 CI: fix v* tag trigger and refuse unsigned bakery index
Some checks failed
check / check (push) Failing after 2s
dev release / build (push) Failing after 2s
release / build (push) Successful in 3m0s
2026-08-16 00:50:22 +08:00
Breadway
c929517b49 Adopt bread_utils::screenshot_cli for --screenshot flags
Some checks failed
check / check (push) Failing after 1s
dev release / build (push) Failing after 1s
Use the crate for pair validation, settle delay, and canvas defaults
in breadpad and breadman. breadman now depends on bread-utils v0.7.2
for screenshot_cli; existing hand-rolled argv parsing stays.
2026-08-16 00:26:07 +08:00
Breadway
54eba3d73a Honor bread.command.pad.capture via breadpad listen
Some checks failed
dev release / build (push) Failing after 1s
2026-08-15 23:11:28 +08:00
Breadway
2f57f432a0 Bump version to v0.5.1
Some checks failed
dev release / build (push) Failing after 1s
release / build (push) Failing after 1s
2026-08-15 23:05:47 +08:00
Breadway
17e9381fc6 Pin bread-ecosystem crates to v0.7.2
Some checks failed
dev release / build (push) Failing after 1s
Repoint bread-theme, bread-utils, and bread-screenshots at tag v0.7.2
on git.breadway.dev. breadman enables the adw feature (implies gtk).
Drop the 69ce2d67 rev pin now that screenshots is on a tagged release.
2026-08-15 22:53:46 +08:00
Breadway
9dcc6cfc94 Wire breadpad into the bread event bus
Some checks failed
check / check (push) Failing after 1s
dev release / build (push) Failing after 1s
Emit bread.pad.captured after a successful quick-capture save and
bread.pad.reminder.due when breadpad fire actually shows a reminder.
Fail-silent if breadd is down. No command stubs.

App id: pad. bread-utils bread-client at v0.7.1.
2026-08-15 22:17:21 +08:00
Breadway
b3e49dc753 Remove CLAUDE.md (renamed to AGENTS.md)
Some checks failed
check / check (push) Failing after 2s
2026-08-15 22:04:00 +08:00
Breadway
2f737abbf6 Rename CLAUDE.md to AGENTS.md 2026-08-15 22:03:30 +08:00
Breadway
b9c2702168 Pin bread-theme to v0.7.1; document Super+U and unshipped ONNX model
Some checks failed
check / check (push) Failing after 1s
bread-screenshots is not on the v0.7.1 tag, so it is rev-pinned instead
of floating on branch=main. bread-theme v0.7.1 has no adw/chip helpers;
breadman keeps local shims. CLAUDE.md records the single-trunk workflow.
2026-08-15 21:41:26 +08:00
Breadway
fde2c7571d Merge fix/ci-explicit-product-name: avoid CI image/cache collisions
All checks were successful
dev release / build (push) Successful in 2m17s
2026-08-05 13:38:09 +08:00
Breadway
520245a6be ci: pass product name explicitly to shared build script
All checks were successful
check / check (push) Successful in 2m7s
bread-ecosystem's ci/build.sh used to derive the image tag and cache
volume name from basename(repo_root) — but every product's CI checks
out into a directory literally named "src", so in real CI runs this
resolved to "bread-ci:src" for every product, not "bread-ci:breadpad".
Harmless while breadpad was the only consumer; would have silently
collided (shared image tag, shared cargo-target cache) the moment a
second product started using it. Fixed upstream, bumping the pin here
to pick it up and passing "breadpad" explicitly.
2026-08-05 09:28:52 +08:00
Breadway
c2807a59f0 Merge fix/use-shared-ecosystem-ci: use bread-ecosystem's shared Arch CI image
All checks were successful
dev release / build (push) Successful in 2m17s
2026-08-05 08:51:43 +08:00
Breadway
e64a525104 ci: use bread-ecosystem's shared Arch build image/script
All checks were successful
check / check (push) Successful in 3m29s
Moves the Containerfile and build.sh added in the previous CI fix up
into bread-ecosystem/ci/, so other GTK4 layer-shell products in the
ecosystem can share it instead of each carrying their own copy. This
repo's ci/build.sh is now a thin wrapper that clones bread-ecosystem
at the commit pinned in ci/bread-ecosystem.rev and delegates to its
ci/build.sh — pinned by sha rather than tracking main, so an unrelated
change over there can't silently break this repo's release builds
(the failure mode the bread-theme test fix a moment ago was caused by).

Verified locally: clippy, test, and release build all pass through
the new delegator path.
2026-08-05 08:47:40 +08:00
Breadway
96a954cc15 Merge fix/ci-arch-container: pinned Arch CI + clippy/test fixes
All checks were successful
dev release / build (push) Successful in 2m9s
Replaces the from-source libadwaita build (repeatedly broken by
version drift) with a pinned Arch container that gets everything
prebuilt via pacman, adds a fast-fail check.yml for feature/fix
branches, and fixes the clippy/test debt that check.yml surfaced now
that it's enforced for the first time.
2026-08-04 17:54:47 +08:00
Breadway
fe2724220a Fix clippy warnings and a stale test assertion surfaced by check.yml
All checks were successful
check / check (push) Successful in 1m17s
check.yml runs clippy -D warnings and cargo test for the first time in
this repo's history, surfacing pre-existing lint debt across all four
workspace crates plus one stale test assertion (breadpad-shared's
Palette default-background test still expected bread-theme's old
#1e1e2e; the upstream design system moved to #0c0c0c a while back and
nothing caught the drift since tests never ran in CI).

All fixes are mechanical: needless returns/closures/borrows, manual
Default impls replaceable by #[derive], map_or -> is_some_and, manual
range checks -> RangeInclusive::contains, a non-idiomatic sort_by ->
sort_by_key, an overly complex inline type given an alias, and moving
a function that ended up defined after its own test module. The one
exception is NoteType::from_str, which clippy flags for shadowing
std::str::FromStr's name — left as `#[allow(...)]` with a comment
rather than renamed, since it has 30+ call sites across every crate
in the workspace and returns Self directly rather than Result.
2026-08-04 17:52:45 +08:00
Breadway
3225f49a93 CI: replace from-source libadwaita build with pinned Arch container
Some checks failed
check / check (push) Failing after 1m57s
The dev/rc/release workflows rebuilt libadwaita from source inside an
uncached Fedora container on every single run, because Ubuntu 24.04's
packaged libadwaita was too old for gtk4 0.11's v4_12 feature. That
from-source build broke repeatedly on drift (rust dep versions,
libadwaita ABI, and finally a removed meson option), producing eight
straight failed CI runs.

Arch's repos already carry current gtk4/libadwaita/gtk4-layer-shell as
prebuilt packages, and breadpad only targets BOS/Arch, so there's no
reason to build anything from source. Swap the container base to a
digest-pinned archlinux image (ci/Containerfile) with those packages
installed via pacman, and extract the shared "build the image, run
cargo inside it" logic into ci/build.sh so it isn't duplicated three
times across the workflows. Cargo's registry/git/target caches persist
in named docker volumes across runs.

Also:
- Add check.yml: clippy + test on feature/**/fix/** pushes, so lint/
  build breakage surfaces before it reaches main and triggers a
  dev-track release.
- Fix release.yml's tag trigger (tags-ignore) so an rc tag push no
  longer also spawns a skipped release.yml run alongside rc-release.yml.

Verified locally: the container builds cleanly via pacman (no source
compilation), and `cargo build --release --locked --workspace` inside
it produces working breadpad/breadman binaries linked against
libgtk4-layer-shell.so.0 and libadwaita-1.so.0 respectively.
2026-08-04 17:31:19 +08:00
Breadway
2b5ad272a5 please work 😢
Some checks failed
dev release / build (push) Failing after 2m56s
2026-08-03 13:22:35 +08:00
Breadway
80941518f7 swap to docker
Some checks failed
dev release / build (push) Failing after 1m37s
2026-08-03 13:19:15 +08:00
Breadway
58309ba005 Please work
Some checks failed
dev release / build (push) Failing after 1s
2026-08-03 13:17:44 +08:00
Breadway
bd18aa2a3a fixed CI rust dep version mismatch
Some checks failed
dev release / build (push) Failing after 2m0s
2026-08-03 13:10:57 +08:00
Breadway
c462f89934 fixed CI rust dep version mismatch
Some checks failed
dev release / build (push) Failing after 8m50s
2026-08-03 12:59:00 +08:00
Breadway
c60f3abf07 fixed CI rust dep version mismatch
Some checks failed
dev release / build (push) Failing after 1s
2026-08-03 12:58:08 +08:00
Breadway
d46fb98321 fixed CI libadwaita version mismatch
Some checks failed
dev release / build (push) Failing after 3m2s
2026-08-03 11:48:42 +08:00
Breadway
72f86f0675 fixed CI libadwaita version mismatch
Some checks failed
dev release / build (push) Failing after 1s
2026-08-03 11:43:19 +08:00
Breadway
561dd46dcf Repoint bread-ecosystem git deps from dev to main branch
Some checks failed
dev release / build (push) Failing after 23s
2026-07-31 14:30:01 +08:00
Breadway
18a6e645e6 CONTRIBUTING.md: document single-trunk + RC-tag release model
Some checks failed
dev release / build (push) Failing after 1m16s
2026-07-31 11:08:41 +08:00
Breadway
0f6eb65cdf CI: single-trunk model — dev triggers on main, beta becomes RC-tag-triggered
Replaces the dev/beta branch split with one trunk (main): dev-track
builds still publish on every push, but the beta track now publishes
from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained
beta branch. Removes the branch nobody reliably kept in sync.
2026-07-31 11:05:29 +08:00
Breadway
39d082d0bf Merge feature/opus-critique-fixes into dev
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
dev release / build (push) Failing after 1m8s
2026-07-31 09:12:24 +08:00
Breadway
18bdba052d Update Cargo.lock to bread-ecosystem's latest merged dev commit
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
2026-07-31 09:11:54 +08:00
Breadway
ae296c7154 breadman: rebuild settings as plain GTK4, fix AdwDialog sizing, present-ordering bug
The libadwaita-based settings screen (previous commit) had two real bugs a
closer look (and a screenshot from the real running app) caught:

- AdwSpinRow's internal GtkSpinButton has no width constraint of its own,
  so once the row was widened to 900px, the spin button stretched to fill
  it - the digits and +/- buttons ended up stranded behind a huge empty
  bordered box, the exact bug the design review flagged, just worse.
- bread-theme's shared `entry, spinbutton` rule outlines every field with
  an always-visible @overlay border, which on a light-cream overlay colour
  reads as a stark white outline against the dark theme.

Rather than keep fighting libadwaita's internal row/spin-button opinions
(there's no supported way to reach in and constrain them), settings.rs is
now plain GTK4 mirroring bos-settings' own Row.svelte/NumberField.svelte/
TextField.svelte design exactly: same tokens (12/16px row padding, ch-width
inputs, transparent-at-rest border, accent border only on focus), built on
a plain `list.boxed-list` for the native rounded-corner-run + divider
styling. Full control over sizing, no internal widget to hunt for.

Also fixed a real ordering bug in the editor AdwDialog conversion:
`open_editor` used to call `dialog.present()` internally before returning,
so callers that connected `dialog.connect_map` afterward (screenshot mode)
missed the signal entirely - it can fire synchronously inside `present`.
Presentation now happens at each call site, after wiring `connect_map`.
Also gave the dialog's content an explicit height/vexpand + min-content-
height, since the ScrolledWindow had none and the whole dialog was
collapsing to just its header bar.

breadpad-shared's bread-theme dependency also gets fixed here: it was
still pinned to an old GitHub-mirror tag (v0.2.8) while breadman pinned
the same crate to git.breadway.dev's dev branch - two different copies of
bread-theme compiled into the same binary, so breadman's actual runtime
CSS (built through breadpad_shared::theme) never saw any of the shared
stylesheet fixes above regardless of what breadman's own direct
dependency resolved to.
2026-07-31 08:58:36 +08:00
Breadway
26cb31354f breadpad: fix reminder alert, snooze popover, and compose popup
Reminder alert:
- Dismiss now matches Snooze's full-opacity text color instead of 0.6
  alpha, which read as a disabled button.
- Button row inset brought to 20px to match the header/body zone above
  it (was 16px, visible as a step across the divider).

Snooze popover:
- Options are left-aligned (each button's label now sets its own
  xalign) and span the full row width instead of centering as a
  ragged-left stack of labels.
- Added a hairline divider between options so the list reads as
  distinct rows even in the popover's static/non-hover state.
- Added a "Custom…" option that reveals a free-form time entry,
  reusing the same rule-based time parsing breadman's dialogs use,
  instead of only offering the three fixed presets.
- Popover now matches the reminder card's flat-bordered elevation
  (no arrow, 1px border, no shadow) instead of GTK's default
  arrow+drop-shadow popover chrome.

Compose popup: dropped the bare accent-teal checkmark submit button
(same colour as the selected type pill, unlabeled) in favor of a
"Press Enter to add" hint, since the popup already grabs focus and is
keyboard-driven. Also made the entry's CSS padding uniform (14px on
all sides, was 12px/16px).
2026-07-31 07:21:13 +08:00
Breadway
de7965edae breadman: address design-review findings (settings, note list, editor)
Settings screen:
- Widen the AdwClamp to 900px, left-aligned to the note-list gutter,
  instead of PreferencesPage's own ~600px clamp that made the screen
  read as a narrower, bolted-on app.
- Remove the floating Save button; every control now applies instantly
  (switches/combo/spin on change, entry rows via show_apply_button),
  matching what AdwSwitchRow's own design language already implies.
- Replace ActionRow+suffix-Entry with real AdwEntryRow/AdwPasswordEntryRow
  so fields are row-width instead of sized by leftover label space.
- Default type now uses the same pill-row widget as the New Note dialog
  and editor, instead of a fourth type-picker variant.
- Local Classifier group description notes the paths are shared with
  breadpad.

Note list (All/Upcoming/Todo/Idea/Note/Question):
- Replaced the two-line card (huge dead gap between title and actions)
  with Archive's tighter single-line row layout, shared via the new
  views::row module. Unifies the edit affordance (pencil in both active
  and archive rows) and drops the type badge in views already filtered
  to one type.
- Type badges now tint per-type (matching the existing note-card-{type}
  accent-bar colors) instead of one flat cream fill for every type.
- Empty states are centered with type-specific copy and a "+ New X"
  action instead of top-anchored generic text.
- Search bar is now scoped to note-list views (was rendering uselessly
  on Settings/Errors).
- Archive sort bug: it was sorting by `created` while displaying
  `completed` ("done {date}"), which is why rows could appear out of
  order. Now sorts by `completed`.
- Sidebar and row-action icons are now real GTK symbolic icons instead
  of a mix of emoji, Unicode glyphs, and thin monochrome characters.

Editor: converted from a bare GtkPopover (no scrim, no title, anchored
wherever the triggering button happened to be) to an AdwDialog with
AdwEntryRow fields, a shared header bar, and copy/placeholders unified
with the New Note dialog. Delete uses destructive-action styling.

Also: destructive-action buttons (breadman's row/editor delete, and
breadpad-shared's .danger-btn) now use a hardcoded red instead of
pywal's @red - a blue-toned wallpaper's "red" palette slot can itself
render blue, which made delete indistinguishable from confirm/accent
buttons regardless of which CSS class was already applied.
2026-07-31 07:18:03 +08:00
Breadway
926d351949 Merge feature/breadman-libadwaita-settings: rebuild settings with libadwaita
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
dev release / build (push) Failing after 1m56s
2026-07-30 18:41:56 +08:00
Breadway
b85d682721 breadman: rebuild settings with libadwaita components
Proof-of-concept for the ecosystem-wide design-system work: every setting
is now an AdwPreferencesGroup/SwitchRow/SpinRow/ActionRow/ComboRow via
bread-theme::adw, instead of a hand-rolled GtkGrid of labels + raw widgets.

This directly fixes the two worst defects a design critique found here:
- Toggle switches rendered ~1400px wide, like progress bars — caused by
  GtkGrid sizing a switch's column to match a sibling entry's hexpand in
  the same grid, and Switch has no default that resists filling that
  width. AdwSwitchRow owns its own layout, so this class of bug can't
  recur.
- Spin buttons were ~1300px from the label they modified, for the same
  grid-column-sizing reason. AdwSpinRow docks the spinner to its title.

Also softened the jargon-forward section titles the critique flagged
("Model (Tier 2 ONNX)", "Ollama (Tier 3)") — the tier/implementation
detail moved from the title (loud, unavoidable) to each group's
description (quieter, still there for anyone who wants it).

bread-theme now needs a direct dependency here (branch=dev, `adw`
feature) since breadpad-shared's own bread-theme dependency is tag-pinned
to a release before this feature existed.
2026-07-30 18:39:56 +08:00
Breadway
b8a37cbb85 Merge feature/breadman-full-views: breadman full view coverage + breadpad reminder window
All checks were successful
dev release / build (push) Successful in 3m8s
Mirror to GitHub / mirror (push) Successful in 2s
2026-07-29 21:58:57 +08:00
Breadway
47a4613b5a breadpad: capture the reminder window + its snooze popover
Two more views: "reminder" (the alert window from run_reminder_window,
normally only reachable via a real due note through `fire <id>`) and
"reminder-snooze" (same window, snooze popover open).

Screenshot mode builds a throwaway sample Note directly instead of going
through cmd_fire's Store lookup — main() checks for these two view names
before ever reaching run_popup's dispatch, since the reminder window is
a completely separate GApplication entry point, not reachable through
the compose-popup path screenshot mode already covered.

Same NON_UNIQUE-on-screenshot-mode fix as run_popup already had, applied
to the reminder window's own Application builder.
2026-07-29 21:32:36 +08:00
Breadway
77b402833f breadman: capture every stack page + the editor and new-note windows
--view (and now --screenshot) already opened any named stack page
directly, so the other 9 pages (upcoming/todo/reminder/idea/note/
question/archive/settings/errors) needed zero new code — just registry
entries in bread-capture.

Two more real windows needed actual wiring: the per-note editor popover
(editor::build_editor_popover, normally only reachable via a specific
note card's edit button — screenshot mode calls the builder directly
against the first real note in the store instead, since there's no
button handle to synthesize a click on) and the "New Note" modal
(show_add_note_window, same on_build-hook pattern already used for
breadbar's wifi add-network dialog).

The editor popover's first attempt parented it to the whole window,
which put it at GTK4 Popover's default Top position — entirely above the
window, clipped off the canvas in every capture despite mapping
successfully (no error, just invisible). Anchoring to a real button
(new_note_btn) with an explicit Bottom position fixed it.
2026-07-29 17:27:05 +08:00
Breadway
55f5b6c3ae Merge feature/screenshot-mode: add --screenshot CLI mode for automated capture (breadpad + breadman)
All checks were successful
dev release / build (push) Successful in 3m21s
Mirror to GitHub / mirror (push) Successful in 2s
2026-07-29 16:39:54 +08:00
Breadway
c13b7b0599 breadman: add --screenshot CLI mode for automated capture
Same reasoning as breadpad, extending breadman's own hand-rolled arg
parser rather than adding clap. --screenshot <view> doubles as the view
selector — breadman already supports opening directly to a named stack
page via --view/initial_view, so this just feeds that same mechanism
instead of needing a separate one. Full known-size canvas capture, plain
top-level window (not layer-shell).

Also fixes the same NON_UNIQUE footgun as breadpad/breadbox/etc.
2026-07-29 16:37:01 +08:00
Breadway
879ec1e7f4 breadpad: add --screenshot CLI mode for automated capture
Same reasoning as breadbar/breadbox/breadclip/breadsearch, but plumbed
through breadpad's own hand-rolled arg parser (mod args) instead of clap
— breadpad doesn't use clap at all, and adding a second, separate clap
parser alongside the existing one would reject every one of breadpad's
real flags (--type, --no-classify, --status, fire, calendar, ...) the
moment it saw one clap didn't recognize. --screenshot/--output/--width/
--height are just three more fields on the same Args struct.

Only the "popup" view (the compose window) is wired up — the reminder
window needs a real stored Note to render, not worth fabricating one
just for a screenshot pass.

Also fixes the same single-instance footgun as the other apps: without
NON_UNIQUE, a screenshot run would just toggle-close the operator's real
already-open popup instead of getting its own window.
2026-07-29 11:43:55 +08:00
Breadway
1289c05c47 Drop pacman packaging, bakery-only distribution
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
dev release / build (push) Successful in 1m31s
bakery already fully covers what the PKGBUILD provided (binary, systemd
--user service where applicable, dependency declarations) except a LICENSE
copy, which bakery.toml's new license_file field now closes. Removes
packaging/arch/ and .forgejo/workflows/package.yml; adds the LICENSE
artifact to each release/dev-release/beta-release workflow's prepare
step. Not pacman-installed inside BOS today (BOS already consumes these
apps exclusively via build-local.sh's skel-staging), so this only removes
the option to `pacman -S` outside of BOS/bakery.
2026-07-23 10:25:12 +08:00
Breadway
f13e091bd0 docs: add CONTRIBUTING.md
All checks were successful
dev release / build (push) Successful in 1m30s
Mirror to GitHub / mirror (push) Successful in 16s
Documents the dev/beta/main branch and release-track workflow shared
across the bread ecosystem. See bread-ecosystem's docs/release-channels.md
for the full policy this implements.
2026-07-22 19:40:56 +08:00
Breadway
261431728e ci: make beta a branch-triggered freeze track, not a one-off tag
All checks were successful
dev release / build (push) Successful in 1m57s
Mirror to GitHub / mirror (push) Successful in 12s
beta release / build (push) Successful in 2m34s
Beta is now a real stabilization branch: publishes on every push to
`beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.<ts>+<sha>,
base version from the latest published tag) instead of a manual
beta-v* tag. Fixes made during the freeze land via fix/<issue> branches
merged into `beta` directly. The gen-index.sh clone for beta pulls
bread-ecosystem's default branch (main) rather than pinning to dev,
since beta is the more stable track and main now carries the
TRACK-aware script.
2026-07-22 18:37:30 +08:00
Breadway
676c0fedd1 ci: base dev version on the latest published tag, not Cargo.toml
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
dev release / build (push) Successful in 1m52s
Cargo.toml can drift stale relative to the actual last release (observed
on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped
dev version sort as OLDER than what's already installed — bakery's semver
check correctly refused those "updates". Deriving the base version from
git ls-remote --tags instead is self-healing regardless of Cargo.toml
drift, with a Cargo.toml fallback only for a repo with no tags yet.
2026-07-22 13:52:27 +08:00
Breadway
7fb9e018bf random commit message, read it yourself
All checks were successful
dev release / build (push) Successful in 2m38s
Mirror to GitHub / mirror (push) Successful in 2s
2026-07-22 11:56:02 +08:00
Breadway
e43675b672 ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
dev release / build (push) Successful in 2m20s
The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta
workflows run close together on the same self-hosted runner — one job's
rm -rf/clone can stomp another's in-progress checkout, causing the
regenerate-index step to fail intermittently. Switch to mktemp -d.
2026-07-22 10:24:28 +08:00
Breadway
bf55b812d2 ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
dev release / build (push) Successful in 3m5s
2026-07-22 10:10:08 +08:00
Breadway
3131cdea17 ci: add dev/beta build track workflows
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
dev release / build (push) Failing after 1m24s
Adds dev-release.yml (publishes on every push to dev) and beta-release.yml
(publishes on a beta-v* tag), mirroring the pattern landing in
bread-ecosystem/bread. See bread-ecosystem/docs/release-channels.md for
the three-track policy.
2026-07-22 09:58:20 +08:00
44 changed files with 2728 additions and 1551 deletions

View file

@ -0,0 +1,24 @@
name: check
# Fast-fail lint/test on short-lived work branches, before it ever reaches
# main and triggers a dev-track release build.
on:
push:
branches: ['feature/**', 'fix/**']
jobs:
check:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: clippy
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings
- name: test
run: cd src && bash ci/build.sh cargo test --workspace --locked

View file

@ -0,0 +1,67 @@
name: dev release
on:
push:
branches: ['main']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: compute dev version
run: |
set -euo pipefail
cd src
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/breadpad/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadpad breadman; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp src/breadpad.example.toml "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadpad/latest"
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -1,21 +0,0 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
# --prune deletes GitHub refs that no longer exist on Forgejo.
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -1,40 +0,0 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="breadpad-${VERSION}/" HEAD \
> packaging/arch/breadpad-${VERSION}.tar.gz
SHA=$(sha256sum packaging/arch/breadpad-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

View file

@ -0,0 +1,51 @@
name: beta (rc) release
on:
push:
tags: ['v*-rc.*']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/beta/breadpad/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadpad breadman; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp src/breadpad.example.toml "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadpad/latest"
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -0,0 +1,75 @@
name: release
on:
push:
tags: ["v*"]
jobs:
build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
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
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/breadpad/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadpad breadman; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp src/breadpad.example.toml "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/breadpad/latest"
- name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
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
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
- name: upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/breadpad/${VERSION}"
gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadpad \
--title "breadpad v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadpad \
"${PKG_DIR}/breadpad-x86_64" \
"${PKG_DIR}/breadman-x86_64" \
"${PKG_DIR}/breadpad-x86_64.sha256" \
"${PKG_DIR}/breadman-x86_64.sha256" \
--clobber

3
.gitignore vendored
View file

@ -27,3 +27,6 @@ svgs.txt
# Rust/Cargo # Rust/Cargo
Cargo.lock Cargo.lock
dist/ dist/
# Local knowledge-graph cache
graphify-out/

37
AGENTS.md Normal file
View file

@ -0,0 +1,37 @@
# AGENTS.md — Repo hygiene
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
— read and follow it for any git, branch, or release work here (the
single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work,
etc). Don't improvise a different workflow. The short version: there is one
long-lived branch, `main` — no `dev` or `beta` branch exists. `main`
auto-publishes a dev-track build on every push. "Beta" and "stable" are both
just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track
build, push a plain `vX.Y.Z` tag to cut the signed stable release.
"Freezing" for stabilization means pausing pushes to `main`, not moving a
branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model
after `main` was found to have silently rotted out of sync with `dev`/`beta`
across most repos in this ecosystem.
## Remotes
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
- `github` — GitHub mirror. Push both when publishing.
## CI
- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`.
- `dev-release.yml` — triggers on push to `main`.
- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push.
- `release.yml` — triggers on any other `v*` tag push.
All four run on a self-hosted runner (`hestia`) inside a pinned Arch
container — not the host's native environment. The Containerfile/build
script are shared across bread-ecosystem products and live in
`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the
sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and
delegates to it. Nothing runs automatically on plain commits or PRs
beyond what's listed.
## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only.

97
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,97 @@
# Contributing
`breadpad` — Quick-capture scratchpad and note viewer (breadman) with AI classification.
Part of the bread ecosystem; this repo follows the same branch/release
workflow as every other ecosystem product.
## Branches
There is one long-lived branch: **`main`**. All day-to-day work lands here.
Every push to `main` automatically builds and publishes a **dev-track**
build (see Tracks below) — a real install you can test before cutting
anything more formal.
New work — features and bug fixes alike — goes on a short-lived branch:
```
feature/<short-name>
fix/<issue-number-or-short-name>
```
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
branches get deleted on merge — they never accumulate the kind of drift a
second long-lived branch does.
## The release cycle
There's no separate `beta` or release branch — "stable" and "beta" are both
just **tags** on `main`, not branches that need to be kept in sync:
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
auto-publishes a dev build — install it with `bakery track set dev` and
`bakery update --all`, then fix anything broken with another push.
2. When you want to stabilize before a real release, tag a release
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
both remotes). That tag alone triggers a beta-track build —
"freezing" is just pausing pushes to `main` while you test it, not a
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
3. Once an RC has gone without issues, tag the real release:
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
signed stable release build.
## Tracks, from a user's perspective
```
bakery track show # what you're currently on (defaults to stable)
bakery track set dev # or beta, or stable
bakery update --all # pull the latest build on your current track
```
| Track | What it is | Published from |
|--------|-----------|-----------------|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
| `dev` | Bleeding edge | `main`, on every push |
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
latest published stable tag, so they always sort as newer than what you
have installed — no manual version bumping needed. Beta versions are just
the RC tag itself (already valid semver, already sorts below the real
release it's a candidate for).
## Local development
```sh
cargo build --release --workspace
cargo test --release --workspace
```
## CI
- `check.yml` — clippy + test, triggered on push to `feature/**`/`fix/**`.
Fast-fail before anything reaches `main`.
- `dev-release.yml` — triggered on push to `main`.
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
stable release.
All of these build inside a pinned Arch Linux container on a self-hosted
runner — not the runner host's native environment. Arch's repos carry
current `gtk4`/`libadwaita`/`gtk4-layer-shell` as prebuilt packages, so
there's no from-source library build to go stale. The Containerfile and
build script are shared across bread-ecosystem GTK4 products, living in
`bread-ecosystem/ci/`; `ci/build.sh` here is a thin wrapper that clones
that repo at the commit pinned in `ci/bread-ecosystem.rev` (not `main`
an unrelated change there shouldn't silently affect this repo's release
builds) and delegates to it. Bump the pin deliberately when you want the
shared image or build logic updated. A `ci/deps.txt` here (currently
absent — breadpad needs nothing beyond the shared base) would layer on
extra pacman packages if that ever changes. Nothing runs automatically on
plain commits or PRs beyond the jobs listed above. See
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
for the full policy, including how a new product gets wired onto these tracks.
## Questions
Open an issue on this repo's Forgejo tracker.

1113
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.3.4" 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"

70
EVENTS.md Normal file
View file

@ -0,0 +1,70 @@
# breadpad — bread event integration
breadpad is a standalone capture popup: it works exactly the same with or
without `breadd` running. When breadd *is* present, the `breadpad` binary
publishes events into the shared bread automation fabric after actions that
already happened. See the parent `bread` repo's `Documentation.md`
specifically its "Namespaces" and "Integrating a bread\* app" sections — for
the general convention this follows.
App id: **`pad`**. Transport: `bread-utils`'s `bread_client` module
(feature `bread-client`) — the capture popup links it directly. One-shot
popup / `fire <id>` invocations each `emit` on their own short-lived
connection. Command verbs are only received while `breadpad listen` is
running — that process holds the `bread.command.pad.**` subscription
open.
`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.
## Events published (`bread.pad.*`)
| Event | Data | When |
|-------|------|------|
| `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.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.
Notes stay in `~/.local/share/breadpad/notes.jsonl`; the event bus is for
*notifications about* capture and due reminders, not a channel for note
content.
## Commands honored (`bread.command.pad.*`)
These are only received while `breadpad listen` is running. Publishing a
command with no subscriber is a silent no-op — that is the documented
bread convention, not a breadpad bug.
| Verb | Data | Effect |
|------|------|--------|
| `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
- 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
command subscription simply never receives anything. Capture, save,
systemd timers, and the reminder window are entirely unaffected.
- If breadd restarts, the command subscription reconnects automatically
(`BreadClient::subscribe`'s background thread has its own backoff
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.

View file

@ -32,11 +32,11 @@ breadman GTK4 note viewer / manager
### Classification ### Classification
Every note passes through a three-tier pipeline at capture time: Every note passes through a three-tier pipeline at capture time. **Tier 1 is the only tier that ships with breadpad** — the ONNX classifier model is not bundled, and Ollama is optional. Capture works without either.
1. **Rule-based parser** — always runs first; handles time extraction ("at 7pm", "in 30 minutes", "tomorrow morning", "next Friday"), recurrence ("every Sunday at 9pm", "every weekday morning"), and strong type signals ("?" → question, "idea:" prefix → idea, action verbs → todo). High-confidence results skip the remaining tiers entirely. 1. **Rule-based parser** — always runs first; handles time extraction ("at 7pm", "in 30 minutes", "tomorrow morning", "next Friday"), recurrence ("every Sunday at 9pm", "every weekday morning"), and strong type signals ("?" → question, "idea:" prefix → idea, action verbs → todo). High-confidence results skip the remaining tiers entirely. This is the default path; no model files required.
2. **Small local ONNX model** — runs when Tier 1 can't confidently assign a type. Responsible for type classification only; Tier 1's extracted time, recurrence rule, and cleaned body are always preserved. 2. **Small local ONNX model (optional, not shipped)** — runs when Tier 1 can't confidently assign a type *and* you have dropped in your own classifier. Responsible for type classification only; Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
3. **Large local model via Ollama** — runs only when Tier 2 confidence falls below a configurable threshold. Communicates with a locally running Ollama instance over HTTP. If Ollama is unreachable, the Tier 2 result is used. No cloud APIs are involved. 3. **Large local model via Ollama (optional)** — runs only when Tier 2 confidence falls below a configurable threshold. Communicates with a locally running Ollama instance over HTTP. If Ollama is unreachable, the previous tier's result is used. No cloud APIs are involved.
Manual override always available — the AI-assigned type is shown as a chip you can tap to change before saving. Manual override always available — the AI-assigned type is shown as a chip you can tap to change before saving.
@ -104,11 +104,11 @@ Always runs. Handles:
Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped. Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped.
#### Tier 2 — Small local ONNX model #### Tier 2 — Small local ONNX model (optional, not shipped)
Runs when Tier 1 confidence is below threshold. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved. Runs when Tier 1 confidence is below threshold **and** a compatible `classifier.onnx` + `tokenizer.json` are present. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`. Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`. Without a model file or runtime library, Tier 2 is skipped and Tier 1 (plus optional Tier 3) still works.
#### Tier 3 — Large local model via Ollama #### Tier 3 — Large local model via Ollama
@ -123,7 +123,7 @@ If Ollama is unreachable or returns an invalid response, breadpad logs a warning
~/.local/share/breadpad/model/tokenizer.json ~/.local/share/breadpad/model/tokenizer.json
``` ```
breadpad ships without a bundled model. Drop a compatible ONNX classifier and `tokenizer.json` at those paths, then configure `model.ort_dylib_path` to point at your ONNX Runtime library. **breadpad does not ship a classifier model.** Tier 1 rules work with no extra files. If you want Tier 2, drop a compatible ONNX classifier and `tokenizer.json` at those paths and point `model.ort_dylib_path` at your ONNX Runtime library.
```bash ```bash
breadpad model-info # shows active EP and model path breadpad model-info # shows active EP and model path
@ -138,7 +138,7 @@ breadpad model-info # shows active EP and model path
- D-Bus session bus (for notifications) - D-Bus session bus (for notifications)
- systemd user session (for timer-backed reminders) - systemd user session (for timer-backed reminders)
- Rust 1.80+ - Rust 1.80+
- **Tier 2 (ONNX classifier):** An external `libonnxruntime.so`. Set `model.ort_dylib_path` in `breadpad.toml`, or set `ORT_DYLIB_PATH` in your environment. Without a library, Tier 2 is disabled; Tier 1 + 3 still work. - **Tier 2 (ONNX classifier, optional):** A model you supply yourself (`classifier.onnx` + `tokenizer.json`) and an external `libonnxruntime.so`. Set `model.ort_dylib_path` in `breadpad.toml`, or set `ORT_DYLIB_PATH` in your environment. Neither the model nor the runtime is shipped. Without them, Tier 2 is disabled; Tier 1 (and Tier 3, if Ollama is running) still work.
- **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (`ollama pull llama3.2:3b`). Tier 3 is silently skipped if Ollama is not running. - **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (`ollama pull llama3.2:3b`). Tier 3 is silently skipped if Ollama is not running.
--- ---
@ -146,15 +146,19 @@ breadpad model-info # shows active EP and model path
## Installation ## Installation
```bash ```bash
git clone https://github.com/breadway/breadpad git clone https://git.breadway.dev/Breadway/breadpad
cd breadpad cd breadpad
cargo build --release cargo build --release
cp target/release/breadpad ~/.local/bin/ cp target/release/breadpad ~/.local/bin/
cp target/release/breadman ~/.local/bin/ cp target/release/breadman ~/.local/bin/
```
# Place your ONNX classifier and tokenizer in the model directory That's enough for capture + viewing. Tier 1 classification works out of the box. The ONNX classifier is **not** included — only add it if you want Tier 2:
```bash
mkdir -p ~/.local/share/breadpad/model mkdir -p ~/.local/share/breadpad/model
# Then set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so # Drop your own classifier.onnx + tokenizer.json in that directory, then
# set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so
``` ```
On Arch Linux, install GTK4 dependencies first: On Arch Linux, install GTK4 dependencies first:
@ -245,10 +249,10 @@ breadpad --no-classify
breadpad --status breadpad --status
``` ```
Hyprland keybind: Hyprland keybind (BOS default is Super+U; bind whatever you want):
``` ```
bind = $mainMod, N, exec, breadpad bind = $mainMod, U, exec, breadpad
``` ```
### breadman (viewer) ### breadman (viewer)

View file

@ -4,6 +4,7 @@ binaries = ["breadpad", "breadman"]
system_deps = ["gtk4", "gtk4-layer-shell"] system_deps = ["gtk4", "gtk4-layer-shell"]
optional_system_deps = ["rocm-hip-runtime", "ollama", "hyprland"] optional_system_deps = ["rocm-hip-runtime", "ollama", "hyprland"]
bread_deps = [] bread_deps = []
license_file = "LICENSE"
[config] [config]
dir = "~/.config/breadpad" dir = "~/.config/breadpad"

View file

@ -11,6 +11,13 @@ path = "src/main.rs"
[dependencies] [dependencies]
breadpad-shared = { path = "../breadpad-shared" } breadpad-shared = { path = "../breadpad-shared" }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
# Shared `--screenshot` pair validation + settle delay (`screenshot_cli`).
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"] }
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

View file

@ -1,3 +1,8 @@
//! Note editor, presented as an AdwDialog (was a bare GtkPopover with no
//! scrim, no title, anchored wherever the triggering button happened to be -
//! flagged in design review as the weakest surface in the app). AdwDialog
//! gives us the scrim, the title, and correct modal anchoring for free.
use breadpad_shared::{ use breadpad_shared::{
parser::parse_rule_based, parser::parse_rule_based,
scheduler::Scheduler, scheduler::Scheduler,
@ -6,48 +11,73 @@ use breadpad_shared::{
}; };
use chrono::{Local, TimeZone, Utc}; use chrono::{Local, TimeZone, Utc};
use gtk4::{glib, prelude::*}; use gtk4::{glib, prelude::*};
use libadwaita::prelude::*;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
pub fn build_editor_popover( /// Same wording used by `main::show_add_note_window`'s New Note dialog - the
/// two surfaces used to teach the user two different input languages for
/// the same fields.
pub const TIME_PLACEHOLDER: &str = "tomorrow 9am / at 7pm / 2026-08-01 09:00";
pub const RRULE_PLACEHOLDER: &str = "RRULE:FREQ=WEEKLY;BYDAY=MO";
/// Builds the dialog but does not present it - callers that need to hook
/// its `map` signal (screenshot mode) must connect before presenting or
/// they miss the signal entirely; interactive callers present immediately
/// with `dialog.present(Some(parent))`.
pub fn open_editor(
note: &Note, note: &Note,
store: Arc<Store>, store: Arc<Store>,
morning: String, morning: String,
on_save: Rc<dyn Fn(Note)>, on_save: Rc<dyn Fn(Note)>,
on_delete: Rc<dyn Fn()>, on_delete: Rc<dyn Fn()>,
on_error: Rc<dyn Fn(String)>, on_error: Rc<dyn Fn(String)>,
) -> gtk4::Popover { ) -> libadwaita::Dialog {
let popover = gtk4::Popover::new(); let dialog = libadwaita::Dialog::builder()
popover.set_has_arrow(false); .title("Edit Note")
.content_width(480)
.content_height(520)
.build();
let vbox = gtk4::Box::builder() let header = libadwaita::HeaderBar::new();
let toolbar_view = libadwaita::ToolbarView::new();
toolbar_view.add_top_bar(&header);
let content = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical) .orientation(gtk4::Orientation::Vertical)
.spacing(8) .spacing(16)
.margin_top(12) .margin_top(16)
.margin_bottom(12) .margin_bottom(16)
.margin_start(12) .margin_start(16)
.margin_end(12) .margin_end(16)
.width_request(420)
.build(); .build();
vbox.append(&gtk4::Label::builder().label("Body").xalign(0.0).build()); let group = libadwaita::PreferencesGroup::builder().title("Details").build();
let body_entry = gtk4::Entry::builder()
.text(&note.body)
.hexpand(true)
.build();
vbox.append(&body_entry);
vbox.append(&gtk4::Label::builder().label("Type").xalign(0.0).build()); let body_row = libadwaita::EntryRow::builder().title("Body").build();
let type_combo = gtk4::DropDown::from_strings(NoteType::all_builtin()); body_row.set_text(&note.body);
let current_idx = NoteType::all_builtin() group.add(&body_row);
.iter()
.position(|&s| s == note.note_type.as_str()) let type_row = libadwaita::ActionRow::builder().title("Type").build();
.unwrap_or(3) as u32; let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
type_combo.set_selected(current_idx); let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(note.note_type.as_str().to_string()));
vbox.append(&type_combo); 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 {
bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str());
let sel = selected_type.clone();
let name = *name;
let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = name.to_string();
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
bread_theme::gtk::set_chip_active(clicked, true);
});
type_pill_box.append(btn);
}
type_row.add_suffix(&type_pill_box);
group.add(&type_row);
vbox.append(&gtk4::Label::builder().label("Time").xalign(0.0).build());
let time_text = note let time_text = note
.time .time
.map(|t| { .map(|t| {
@ -55,38 +85,44 @@ pub fn build_editor_popover(
local.format("%Y-%m-%d %H:%M").to_string() local.format("%Y-%m-%d %H:%M").to_string()
}) })
.unwrap_or_default(); .unwrap_or_default();
let time_entry = gtk4::Entry::builder() let time_row = libadwaita::EntryRow::builder().title("Time").build();
.text(&time_text) time_row.set_text(&time_text);
.placeholder_text("YYYY-MM-DD HH:MM or tomorrow 9am (blank = no time)") // EntryRow has no placeholder-text property of its own (unlike GtkEntry) -
.hexpand(true) // the title already communicates the field, so the example format goes in
.build(); // the group description instead of a placeholder that would otherwise
vbox.append(&time_entry); // vanish behind the title when empty.
group.add(&time_row);
vbox.append(&gtk4::Label::builder().label("Recurrence").xalign(0.0).build()); let rrule_row = libadwaita::EntryRow::builder().title("Recurrence").build();
let rrule_entry = gtk4::Entry::builder() rrule_row.set_text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""));
.text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or("")) group.add(&rrule_row);
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO (blank = none)")
.build();
vbox.append(&rrule_entry);
// Button row: [Delete] [Save] content.append(&group);
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let delete_btn = gtk4::Button::builder() let hint = gtk4::Label::builder()
.label("🗑 Delete") .label(format!("Time: {TIME_PLACEHOLDER}\nRecurrence: {RRULE_PLACEHOLDER}"))
.css_classes(["danger-btn"]) .css_classes(["dim-label"])
.build(); .xalign(0.0)
let save_btn = gtk4::Button::builder() .wrap(true)
.label("Save")
.css_classes(["confirm-button"])
.hexpand(true)
.build(); .build();
content.append(&hint);
let btn_row = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(8).build();
let delete_btn = gtk4::Button::builder().label("Delete").css_classes(["destructive-action"]).build();
let save_btn = gtk4::Button::builder().label("Save").css_classes(["confirm-button"]).hexpand(true).build();
btn_row.append(&delete_btn); btn_row.append(&delete_btn);
btn_row.append(&save_btn); btn_row.append(&save_btn);
vbox.append(&btn_row); content.append(&btn_row);
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.min_content_height(400)
.build();
scroll.set_child(Some(&content));
toolbar_view.set_content(Some(&scroll));
dialog.set_child(Some(&toolbar_view));
// Delete: two-click confirm // Delete: two-click confirm
let confirming = Rc::new(RefCell::new(false)); let confirming = Rc::new(RefCell::new(false));
@ -95,7 +131,7 @@ pub fn build_editor_popover(
let delete_btn_label = delete_btn.clone(); let delete_btn_label = delete_btn.clone();
let note_id = note.id.clone(); let note_id = note.id.clone();
let store_del = store.clone(); let store_del = store.clone();
let popover_del = popover.clone(); let dialog_del = dialog.clone();
let on_delete = Rc::clone(&on_delete); let on_delete = Rc::clone(&on_delete);
let on_error = Rc::clone(&on_error); let on_error = Rc::clone(&on_error);
@ -105,7 +141,7 @@ pub fn build_editor_popover(
let id = note_id.clone(); let id = note_id.clone();
let on_delete = Rc::clone(&on_delete); let on_delete = Rc::clone(&on_delete);
let on_error = Rc::clone(&on_error); let on_error = Rc::clone(&on_error);
let popover = popover_del.clone(); let dialog = dialog_del.clone();
spawn_bg( spawn_bg(
move || -> anyhow::Result<()> { move || -> anyhow::Result<()> {
store.delete_note(&id)?; store.delete_note(&id)?;
@ -119,7 +155,7 @@ pub fn build_editor_popover(
Ok(()) => on_delete(), Ok(()) => on_delete(),
Err(e) => on_error(format!("delete failed: {}", e)), Err(e) => on_error(format!("delete failed: {}", e)),
} }
popover.popdown(); dialog.close();
}, },
); );
} else { } else {
@ -132,33 +168,20 @@ pub fn build_editor_popover(
// Save // Save
{ {
let note_clone = note.clone(); let note_clone = note.clone();
let popover_save = popover.clone(); let dialog_save = dialog.clone();
let on_error = Rc::clone(&on_error); let on_error = Rc::clone(&on_error);
let selected_type = selected_type.clone();
save_btn.connect_clicked(move |_| { save_btn.connect_clicked(move |_| {
// Read all field values on the main thread before handing off.
let mut updated = note_clone.clone(); let mut updated = note_clone.clone();
updated.body = body_entry.text().to_string(); updated.body = body_row.text().to_string();
updated.note_type = NoteType::from_str( updated.note_type = NoteType::from_str(&selected_type.borrow());
NoteType::all_builtin() let time_str = time_row.text().to_string();
.get(type_combo.selected() as usize) updated.time = if time_str.trim().is_empty() { None } else { parse_time_field(&time_str, &morning) };
.copied() let rrule_text = rrule_row.text().to_string();
.unwrap_or("note"), updated.rrule = if rrule_text.trim().is_empty() { None } else { Some(RecurrenceRule::new(rrule_text)) };
);
let time_str = time_entry.text().to_string();
updated.time = if time_str.trim().is_empty() {
None
} else {
parse_time_field(&time_str, &morning)
};
let rrule_text = rrule_entry.text().to_string();
updated.rrule = if rrule_text.trim().is_empty() {
None
} else {
Some(RecurrenceRule::new(rrule_text))
};
popover_save.popdown(); dialog_save.close();
let store_bg = store.clone(); let store_bg = store.clone();
let on_save = Rc::clone(&on_save); let on_save = Rc::clone(&on_save);
@ -182,8 +205,7 @@ pub fn build_editor_popover(
}); });
} }
popover.set_child(Some(&vbox)); dialog
popover
} }
fn spawn_bg<F, T, C>(work: F, then: C) fn spawn_bg<F, T, C>(work: F, then: C)

View file

@ -13,16 +13,45 @@ use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
mod editor; mod editor;
mod screenshot;
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>,
pub done_id: Option<String>, pub done_id: Option<String>,
pub upcoming_plain: bool, pub upcoming_plain: bool,
pub screenshot: Option<String>,
pub output: Option<String>,
pub width: u32,
pub height: u32,
}
impl Args {
/// `None` for a normal run. Exits the process with an error if the
/// `--screenshot` / `--output` pair is incomplete, before any GTK
/// setup happens.
pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> {
if let Err(e) = validate_pair(
self.screenshot.as_deref(),
self.output.as_deref().map(Path::new),
) {
eprintln!("breadman: {e}");
std::process::exit(1);
}
Some(crate::screenshot::ScreenshotRequest {
view: self.screenshot.clone()?,
output: self.output.clone()?.into(),
width: self.width,
height: self.height,
})
}
} }
pub fn parse() -> Args { pub fn parse() -> Args {
@ -30,6 +59,10 @@ mod args {
view: None, view: None,
done_id: None, done_id: None,
upcoming_plain: false, upcoming_plain: false,
screenshot: None,
output: None,
width: DEFAULT_WIDTH,
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;
@ -50,6 +83,26 @@ mod args {
} }
args.view = Some("upcoming".into()); args.view = Some("upcoming".into());
} }
"--screenshot" => {
i += 1;
args.screenshot = raw.get(i).cloned();
}
"--output" => {
i += 1;
args.output = raw.get(i).cloned();
}
"--width" => {
i += 1;
if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) {
args.width = v;
}
}
"--height" => {
i += 1;
if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) {
args.height = v;
}
}
_ => {} _ => {}
} }
i += 1; i += 1;
@ -60,19 +113,22 @@ mod args {
// ── AppState ────────────────────────────────────────────────────────────────── // ── AppState ──────────────────────────────────────────────────────────────────
type ErrorLog = Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>;
/// Shared UI state, cheap to clone (all fields are Rc/Arc). /// Shared UI state, cheap to clone (all fields are Rc/Arc).
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
store: Arc<Store>, store: Arc<Store>,
notes: Rc<RefCell<Vec<Note>>>, notes: Rc<RefCell<Vec<Note>>>,
cfg: Rc<RefCell<Config>>, cfg: Rc<RefCell<Config>>,
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>, errors: ErrorLog,
active_view: Rc<RefCell<String>>, active_view: Rc<RefCell<String>>,
stack: gtk4::Stack, stack: gtk4::Stack,
window: gtk4::ApplicationWindow,
} }
impl AppState { impl AppState {
fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack) -> Self { fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack, window: gtk4::ApplicationWindow) -> Self {
AppState { AppState {
store, store,
notes: Rc::new(RefCell::new(notes)), notes: Rc::new(RefCell::new(notes)),
@ -80,6 +136,7 @@ impl AppState {
errors: Rc::new(RefCell::new(Vec::new())), errors: Rc::new(RefCell::new(Vec::new())),
active_view: Rc::new(RefCell::new("all".to_string())), active_view: Rc::new(RefCell::new("all".to_string())),
stack, stack,
window,
} }
} }
@ -145,7 +202,7 @@ fn rebuild_all_view(notes: &[Note], state: &AppState) {
if let Some(child) = state.stack.child_by_name("all") { if let Some(child) = state.stack.child_by_name("all") {
state.stack.remove(&child); state.stack.remove(&child);
} }
let scroll = build_note_list(notes, state.clone()); let scroll = build_note_list(notes, state.clone(), true, "No notes yet — jot something down to get started.", Some(NoteType::Note));
state.stack.add_named(&scroll, Some("all")); state.stack.add_named(&scroll, Some("all"));
} }
@ -159,14 +216,22 @@ fn rebuild_stack(state: &AppState) {
let errors: Vec<_> = state.errors.borrow().clone(); let errors: Vec<_> = state.errors.borrow().clone();
// All // All
let all_scroll = build_note_list(&notes, state.clone()); let all_scroll = build_note_list(&notes, state.clone(), true, "No notes yet — jot something down to get started.", Some(NoteType::Note));
state.stack.add_named(&all_scroll, Some("all")); state.stack.add_named(&all_scroll, Some("all"));
// Upcoming // Upcoming
let upcoming = views::upcoming::build(&notes); let upcoming = views::upcoming::build(&notes, state.clone());
state.stack.add_named(&upcoming, Some("upcoming")); state.stack.add_named(&upcoming, Some("upcoming"));
// Per-type // Per-type
let empty_text = |type_name: &str| match type_name {
"todo" => "No todos yet.",
"reminder" => "No reminders yet.",
"idea" => "No ideas captured yet.",
"note" => "No notes yet.",
"question" => "No open questions yet.",
_ => "Nothing here yet.",
};
for type_name in NoteType::all_builtin() { for type_name in NoteType::all_builtin() {
let nt = NoteType::from_str(type_name); let nt = NoteType::from_str(type_name);
let filtered: Vec<Note> = notes let filtered: Vec<Note> = notes
@ -174,7 +239,7 @@ fn rebuild_stack(state: &AppState) {
.filter(|n| n.note_type == nt && !n.done) .filter(|n| n.note_type == nt && !n.done)
.cloned() .cloned()
.collect(); .collect();
let scroll = build_note_list(&filtered, state.clone()); let scroll = build_note_list(&filtered, state.clone(), false, empty_text(type_name), Some(nt));
state.stack.add_named(&scroll, Some(type_name)); state.stack.add_named(&scroll, Some(type_name));
} }
@ -214,7 +279,9 @@ fn main() -> Result<()> {
return cmd_upcoming_plain(); return cmd_upcoming_plain();
} }
run_app(args.view, cfg) let screenshot_req = args.screenshot_request();
let view = screenshot_req.as_ref().map(|r| r.view.clone()).or(args.view);
run_app(view, cfg, screenshot_req)
} }
fn cmd_done(id: &str) -> Result<()> { fn cmd_done(id: &str) -> Result<()> {
@ -250,10 +317,20 @@ fn cmd_upcoming_plain() -> Result<()> {
Ok(()) Ok(())
} }
fn run_app(initial_view: Option<String>, cfg: Config) -> Result<()> { fn run_app(
let app = gtk4::Application::builder() initial_view: Option<String>,
.application_id("com.breadway.breadman") cfg: Config,
.build(); screenshot_req: Option<screenshot::ScreenshotRequest>,
) -> Result<()> {
let mut builder = gtk4::Application::builder().application_id("com.breadway.breadman");
if screenshot_req.is_some() {
// GApplication is single-instance by default; this machine typically
// already has a real breadman instance, so without this a
// screenshot run would activate the *existing* instance instead of
// starting a fresh one that ever sees `screenshot_req`.
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
}
let app = builder.build();
let cfg = Arc::new(cfg); let cfg = Arc::new(cfg);
let initial_view = Arc::new(initial_view); let initial_view = Arc::new(initial_view);
@ -261,7 +338,7 @@ fn run_app(initial_view: Option<String>, cfg: Config) -> Result<()> {
app.connect_activate(move |app| { app.connect_activate(move |app| {
let cfg = cfg.as_ref().clone(); let cfg = cfg.as_ref().clone();
let initial_view = initial_view.as_deref().map(|s| s.to_string()); let initial_view = initial_view.as_deref().map(|s| s.to_string());
if let Err(e) = build_app_window(app, cfg, initial_view) { if let Err(e) = build_app_window(app, cfg, initial_view, screenshot_req.clone()) {
tracing::error!("failed to build window: {}", e); tracing::error!("failed to build window: {}", e);
} }
}); });
@ -279,8 +356,13 @@ fn build_app_window(
app: &gtk4::Application, app: &gtk4::Application,
cfg: Config, cfg: Config,
initial_view: Option<String>, initial_view: Option<String>,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) -> Result<()> { ) -> Result<()> {
apply_css(&cfg); apply_css(&cfg);
// Needed once before constructing any adw:: widget (see views::settings) —
// also forces dark mode, since bread-theme's palette is a fixed dark base
// regardless of the system GTK preference.
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()?;
@ -291,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)
@ -331,7 +414,11 @@ fn build_app_window(
)); ));
row row
}; };
let make_item = |id: &str, icon: &str, label: &str| { // One icon language throughout (was 8 full-colour emoji + 2 thin
// monochrome glyphs for Settings/Errors + a third style on row action
// buttons) — every sidebar entry and row action now uses a real GTK
// symbolic icon.
let make_item = |id: &str, icon_name: &str, label: &str| {
let row = gtk4::ListBoxRow::builder() let row = gtk4::ListBoxRow::builder()
.css_classes(["sidebar-row"]) .css_classes(["sidebar-row"])
.build(); .build();
@ -340,13 +427,7 @@ fn build_app_window(
.orientation(gtk4::Orientation::Horizontal) .orientation(gtk4::Orientation::Horizontal)
.spacing(10) .spacing(10)
.build(); .build();
hbox.append( hbox.append(&gtk4::Image::builder().icon_name(icon_name).pixel_size(16).build());
&gtk4::Label::builder()
.label(icon)
.width_chars(2)
.xalign(0.5)
.build(),
);
hbox.append( hbox.append(
&gtk4::Label::builder() &gtk4::Label::builder()
.label(label) .label(label)
@ -359,18 +440,18 @@ fn build_app_window(
}; };
sidebar_list.append(&make_section("VIEWS")); sidebar_list.append(&make_section("VIEWS"));
sidebar_list.append(&make_item("all", "📋", "All")); sidebar_list.append(&make_item("all", "view-list-symbolic", "All"));
sidebar_list.append(&make_item("upcoming", "📅", "Upcoming")); sidebar_list.append(&make_item("upcoming", "x-office-calendar-symbolic", "Upcoming"));
sidebar_list.append(&make_section("TYPES")); sidebar_list.append(&make_section("TYPES"));
sidebar_list.append(&make_item("todo", "", "Todo")); sidebar_list.append(&make_item("todo", "task-due-symbolic", "Todo"));
sidebar_list.append(&make_item("reminder", "🔔", "Reminder")); sidebar_list.append(&make_item("reminder", "appointment-soon-symbolic", "Reminder"));
sidebar_list.append(&make_item("idea", "💡", "Idea")); sidebar_list.append(&make_item("idea", "emblem-important-symbolic", "Idea"));
sidebar_list.append(&make_item("note", "📝", "Note")); sidebar_list.append(&make_item("note", "text-x-generic-symbolic", "Note"));
sidebar_list.append(&make_item("question", "", "Question")); sidebar_list.append(&make_item("question", "dialog-question-symbolic", "Question"));
sidebar_list.append(&make_section("MORE")); sidebar_list.append(&make_section("MORE"));
sidebar_list.append(&make_item("archive", "📦", "Archive")); sidebar_list.append(&make_item("archive", "folder-symbolic", "Archive"));
sidebar_list.append(&make_item("settings", "", "Settings")); sidebar_list.append(&make_item("settings", "preferences-system-symbolic", "Settings"));
sidebar_list.append(&make_item("errors", "", "Errors")); sidebar_list.append(&make_item("errors", "dialog-warning-symbolic", "Errors"));
sidebar_vbox.append(&sidebar_list); sidebar_vbox.append(&sidebar_list);
// ── Content area ────────────────────────────────────────────── // ── Content area ──────────────────────────────────────────────
@ -401,7 +482,7 @@ fn build_app_window(
window.set_child(Some(&hbox)); window.set_child(Some(&hbox));
// ── AppState ────────────────────────────────────────────────── // ── AppState ──────────────────────────────────────────────────
let state = AppState::new(store, notes, cfg, stack.clone()); let state = AppState::new(store, notes, cfg, stack.clone(), window.clone());
// Initial build // Initial build
rebuild_stack(&state); rebuild_stack(&state);
@ -409,11 +490,15 @@ fn build_app_window(
// ── Sidebar selection ───────────────────────────────────────── // ── Sidebar selection ─────────────────────────────────────────
{ {
let state_c = state.clone(); let state_c = state.clone();
let search_entry_c = search_entry.clone();
sidebar_list.connect_row_selected(move |_, row| { sidebar_list.connect_row_selected(move |_, row| {
if let Some(row) = row { if let Some(row) = row {
let view = row.widget_name().to_string(); let view = row.widget_name().to_string();
if view.is_empty() { return; } if view.is_empty() { return; }
*state_c.active_view.borrow_mut() = view.clone(); *state_c.active_view.borrow_mut() = view.clone();
// The search bar only means anything on note-list views —
// it used to render (uselessly) on Settings and Errors too.
search_entry_c.set_visible(!matches!(view.as_str(), "settings" | "errors"));
refresh(&state_c); refresh(&state_c);
} }
}); });
@ -445,13 +530,14 @@ fn build_app_window(
let state_c = state.clone(); let state_c = state.clone();
let window_c = window.clone(); let window_c = window.clone();
new_note_btn.connect_clicked(move |_| { new_note_btn.connect_clicked(move |_| {
show_add_note_window(&window_c, state_c.clone()); show_add_note_window(&window_c, state_c.clone(), NoteType::Note, |_| {});
}); });
} }
// ── Select initial view ─────────────────────────────────────── // ── Select initial view ───────────────────────────────────────
let initial = initial_view.as_deref().unwrap_or("all"); let initial = initial_view.as_deref().unwrap_or("all");
*state.active_view.borrow_mut() = initial.to_string(); *state.active_view.borrow_mut() = initial.to_string();
search_entry.set_visible(!matches!(initial, "settings" | "errors"));
for row in sidebar_list for row in sidebar_list
.observe_children() .observe_children()
.snapshot() .snapshot()
@ -465,13 +551,28 @@ fn build_app_window(
} }
stack.set_visible_child_name(initial); stack.set_visible_child_name(initial);
if let Some(req) = screenshot_req {
screenshot::dispatch(&window, req, state.clone());
}
window.present(); window.present();
Ok(()) Ok(())
} }
// ── Note list & cards ───────────────────────────────────────────────────────── // ── Note list ─────────────────────────────────────────────────────────────────
// Row rendering itself lives in views::row (shared with Upcoming/Archive) —
// design review found the old two-line card here wasted enormous horizontal
// space (title and actions separated by ~800-1000px of dead middle) compared
// to Archive's tighter single-line layout, so all list views now share one
// row template.
fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow { fn build_note_list(
notes: &[Note],
state: AppState,
show_type_badge: bool,
empty_text: &str,
empty_new_type: Option<NoteType>,
) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never) .hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic) .vscrollbar_policy(gtk4::PolicyType::Automatic)
@ -480,26 +581,25 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
let list = gtk4::Box::builder() let list = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical) .orientation(gtk4::Orientation::Vertical)
.spacing(8) .spacing(4)
.margin_top(12) .margin_top(8)
.margin_bottom(12) .margin_bottom(8)
.margin_start(12)
.margin_end(12)
.build(); .build();
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect(); let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
sorted.sort_by(|a, b| b.created.cmp(&a.created)); sorted.sort_by_key(|n| std::cmp::Reverse(n.created));
if sorted.is_empty() { if sorted.is_empty() {
list.append( let action = empty_new_type.map(|nt| views::row::new_note_action(nt, state.window.clone(), state.clone()));
&gtk4::Label::builder() list.append(&views::row::build_empty_state("view-list-symbolic", empty_text, action));
.label("No notes here yet.")
.margin_top(32)
.build(),
);
} else { } else {
for note in &sorted { for note in &sorted {
list.append(&build_note_card(note, state.clone())); let created_str = {
let local: chrono::DateTime<Local> = note.created.into();
local.format("%b %d %H:%M").to_string()
};
let spec = views::row::RowSpec { date_label: created_str, note, show_type_badge, show_done: true };
list.append(&views::row::build(spec, state.clone()));
} }
} }
@ -507,237 +607,16 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
scroll scroll
} }
fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
let card = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(8)
.margin_start(0)
.margin_end(0)
.margin_top(0)
.margin_bottom(0)
.css_classes(["note-card"])
.build();
card.add_css_class(&format!("note-card-{}", note.note_type.as_str()));
// Top row: body + type chip
let top_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.wrap(true)
.build();
let type_chip = gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip"])
.build();
top_row.append(&body_label);
top_row.append(&type_chip);
// Bottom row: metadata + action buttons
let bottom_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let created_str = {
let local: chrono::DateTime<Local> = note.created.into();
local.format("%b %d %H:%M").to_string()
};
let meta_label = gtk4::Label::builder()
.label(&created_str)
.css_classes(["dim-label"])
.xalign(0.0)
.build();
// Date first, then chips
bottom_row.append(&meta_label);
if let Some(ws) = &note.workspace {
bottom_row.append(
&gtk4::Label::builder()
.label(&format!("ws:{}", ws))
.css_classes(["type-chip"])
.build(),
);
}
if let Some(t) = note.time {
let local: chrono::DateTime<Local> = t.into();
bottom_row.append(
&gtk4::Label::builder()
.label(&local.format("⏰ %b %d %H:%M").to_string())
.css_classes(["dim-label"])
.build(),
);
}
if note.rrule.is_some() {
bottom_row.append(
&gtk4::Label::builder()
.label("")
.css_classes(["type-chip"])
.build(),
);
}
bottom_row.append(&gtk4::Box::builder().hexpand(true).build());
// ✓ Done button
let done_btn = gtk4::Button::builder()
.label("")
.css_classes(["action-btn", "done-btn"])
.tooltip_text("Mark done")
.build();
{
let note_id = note.id.clone();
let card_c = card.clone();
let state_c = state.clone();
done_btn.connect_clicked(move |_| {
card_c.set_visible(false); // optimistic hide
let store = state_c.write_store();
let id = note_id.clone();
let state = state_c.clone();
spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
if let Some(mut n) = store.get_by_id(&id)? {
n.mark_done();
store.update_note(&n)?;
}
store.load_all()
},
move |result| {
match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
Err(e) => state.log_error(format!("mark done failed: {}", e)),
}
},
);
});
}
bottom_row.append(&done_btn);
// ✎ Edit button
let edit_btn = gtk4::Button::builder()
.label("")
.css_classes(["action-btn", "edit-btn"])
.tooltip_text("Edit")
.build();
{
let note_c = note.clone();
let state_c = state.clone();
let body_label_c = body_label.clone();
let card_c = card.clone();
edit_btn.connect_clicked(move |btn| {
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
let store = Arc::new(state_c.write_store());
let state_save = state_c.clone();
let body_label_save = body_label_c.clone();
let state_del = state_c.clone();
let card_del = card_c.clone();
let state_err = state_c.clone();
let popover = editor::build_editor_popover(
&note_c,
store,
morning,
Rc::new(move |updated: Note| {
body_label_save.set_label(&updated.body);
state_save.reload_notes();
rebuild_stack(&state_save);
let active = state_save.active_view.borrow().clone();
state_save.stack.set_visible_child_name(&active);
}),
Rc::new(move || {
card_del.set_visible(false);
state_del.reload_notes();
rebuild_stack(&state_del);
let active = state_del.active_view.borrow().clone();
state_del.stack.set_visible_child_name(&active);
}),
Rc::new(move |e: String| {
state_err.log_error(e);
}),
);
popover.set_parent(btn);
popover.popup();
});
}
bottom_row.append(&edit_btn);
// 🗑 Delete button — two-click confirm: first click → "Sure?", second → delete
let delete_btn = gtk4::Button::builder()
.label("🗑")
.css_classes(["action-btn", "danger-btn"])
.tooltip_text("Delete")
.build();
{
use std::cell::RefCell;
use std::rc::Rc;
let confirming = Rc::new(RefCell::new(false));
let note_id = note.id.clone();
let card_c = card.clone();
let state_c = state.clone();
let btn_c = delete_btn.clone();
delete_btn.connect_clicked(move |_| {
if *confirming.borrow() {
card_c.set_visible(false); // optimistic hide
let store = state_c.write_store();
let id = note_id.clone();
let state = state_c.clone();
spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
store.delete_note(&id)?;
if let Err(e) = Scheduler::cancel(&id) {
tracing::warn!("failed to cancel timer for {}: {}", id, e);
}
store.load_all()
},
move |result| {
match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
Err(e) => state.log_error(format!("delete failed: {}", e)),
}
},
);
} else {
*confirming.borrow_mut() = true;
btn_c.set_label("Sure?");
}
});
}
bottom_row.append(&delete_btn);
card.append(&top_row);
card.append(&bottom_row);
card
}
// ── Add note window ─────────────────────────────────────────────────────────── // ── Add note window ───────────────────────────────────────────────────────────
fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState) { fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState, preselect: NoteType, on_build: impl FnOnce(&gtk4::Window)) {
let win = gtk4::Window::builder() let win = gtk4::Window::builder()
.title("New Note") .title("New Note")
.transient_for(parent) .transient_for(parent)
.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)
@ -755,48 +634,42 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState) {
.build(); .build();
vbox.append(&body_entry); vbox.append(&body_entry);
// Type chips // Type pills — same chip widget the editor dialog and settings screen
// use, instead of three different type-picker widgets across the app.
vbox.append(&gtk4::Label::builder().label("Type").xalign(0.0).build());
let chip_box = gtk4::Box::builder() let chip_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal) .orientation(gtk4::Orientation::Horizontal)
.spacing(4) .spacing(4)
.build(); .build();
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(NoteType::Note)); 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| { .map(|&name| (bread_theme::gtk::chip(name), NoteType::from_str(name)))
let btn = gtk4::Button::builder()
.label(name)
.css_classes(["type-chip"])
.build();
(btn, 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);
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 { b.remove_css_class("active"); } for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
clicked.add_css_class("active"); bread_theme::gtk::set_chip_active(clicked, true);
}); });
chip_box.append(btn); chip_box.append(btn);
} }
if let Some((btn, _)) = chips.iter().find(|(_, nt)| *nt == NoteType::Note) {
btn.add_css_class("active");
}
vbox.append(&chip_box); vbox.append(&chip_box);
vbox.append(&gtk4::Label::builder().label("Time (optional)").xalign(0.0).build()); vbox.append(&gtk4::Label::builder().label("Time (optional)").xalign(0.0).build());
let time_entry = gtk4::Entry::builder() let time_entry = gtk4::Entry::builder()
.placeholder_text("tomorrow 9am / at 7pm / in 30 minutes") .placeholder_text(editor::TIME_PLACEHOLDER)
.hexpand(true) .hexpand(true)
.build(); .build();
vbox.append(&time_entry); vbox.append(&time_entry);
vbox.append(&gtk4::Label::builder().label("Recurrence (optional)").xalign(0.0).build()); vbox.append(&gtk4::Label::builder().label("Recurrence (optional)").xalign(0.0).build());
let rrule_entry = gtk4::Entry::builder() let rrule_entry = gtk4::Entry::builder()
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO") .placeholder_text(editor::RRULE_PLACEHOLDER)
.hexpand(true) .hexpand(true)
.build(); .build();
vbox.append(&rrule_entry); vbox.append(&rrule_entry);
@ -916,6 +789,7 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState) {
}); });
} }
on_build(&win);
win.present(); win.present();
body_entry.grab_focus(); body_entry.grab_focus();
} }

132
breadman/src/screenshot.rs Normal file
View file

@ -0,0 +1,132 @@
//! `--screenshot` CLI mode: render the named view, capture it via
//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s
//! `bread-capture` orchestrator, or run standalone for one-off captures.
//!
//! No clap here, same reasoning as breadpad: extends breadman's own
//! hand-rolled `mod args` instead of bolting on a second parser that would
//! reject its real flags (`--view`, `done`, `upcoming --plain`).
//!
//! `--screenshot <view>` doubles as the view selector for every named stack
//! page ("all", "upcoming", "todo", ...) — it's passed through as
//! `initial_view` (the same field `--view` already sets) rather than
//! needing a separate mechanism, since breadman already supports opening
//! directly to a named stack page.
//!
//! One view isn't a stack page at all: "editor" opens the per-note editor
//! dialog (`editor::open_editor`), normally only reachable by clicking a
//! real note row's edit button. Screenshot mode calls the same builder
//! function directly against the first real note in the store (bypassing
//! the button/click-handler entirely), with no-op save/delete/error
//! callbacks since nothing here should actually persist a change.
use bread_utils::screenshot_cli::SETTLE_DELAY;
use gtk4::prelude::*;
use libadwaita::prelude::*;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
/// 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
/// to settle first.
const PRE_POPUP_DELAY: Duration = SETTLE_DELAY;
#[derive(Clone)]
pub struct ScreenshotRequest {
pub view: String,
pub output: PathBuf,
pub width: u32,
pub height: u32,
}
/// Wire up the given view's screenshot sequence against an already-built,
/// not-yet-presented window. Every path here ends by exiting the process —
/// it never returns control to the normal note-manager UI.
///
/// Unlike the other apps' `dispatch`, this doesn't validate `req.view`
/// against a known-views list for the stack-page case — an invalid name
/// just falls through to breadman's own `unwrap_or("all")` default (see
/// `build_app_window`), same as `--view` already behaves for a normal run.
pub fn dispatch(
window: &gtk4::ApplicationWindow,
req: ScreenshotRequest,
state: crate::AppState,
) {
let output = req.output;
let (width, height) = (req.width as i32, req.height as i32);
if req.view == "new-note" {
window.connect_map(move |root| {
let output = output.clone();
let root = root.clone();
let state = state.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
crate::show_add_note_window(&root, state, breadpad_shared::types::NoteType::Note, move |dialog| {
let output = output.clone();
dialog.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
});
});
});
return;
}
if req.view == "editor" {
window.connect_map(move |root| {
let output = output.clone();
let state = state.clone();
let root = root.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
let Some(note) = state.notes.borrow().first().cloned() else {
eprintln!("breadman: no notes in the store to build the editor view from");
std::process::exit(1);
};
let morning = state.cfg.borrow().reminders.default_morning.clone();
let store = Arc::new(state.write_store());
// AdwDialog handles its own presentation/centering - no more
// manual popover anchor/position/autohide juggling. Must
// connect `map` BEFORE presenting, or the signal (which can
// fire synchronously inside `present`) is missed entirely.
let dialog = crate::editor::open_editor(
&note,
store,
morning,
Rc::new(|_| {}),
Rc::new(|| {}),
Rc::new(|_| {}),
);
let output = output.clone();
dialog.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
dialog.present(Some(root.upcast_ref::<gtk4::Widget>()));
});
});
return;
}
window.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
}
fn finish(result: anyhow::Result<()>) {
match result {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("breadman: screenshot capture failed: {e}");
std::process::exit(1);
}
}
}

View file

@ -1,7 +1,6 @@
use super::row::{build_empty_state, RowSpec};
use breadpad_shared::types::Note; use breadpad_shared::types::Note;
use gtk4::prelude::*; use gtk4::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow { pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
@ -18,36 +17,16 @@ pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
.build(); .build();
let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect(); let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect();
archived.sort_by(|a, b| b.created.cmp(&a.created)); // Sort by completion time, not creation time - the previous sort used
// `created` while the row displayed `completed` ("done {date}"), which
// is why the last row could appear out of order against the visible
// dates.
archived.sort_by_key(|n| std::cmp::Reverse(n.completed.unwrap_or(n.created)));
if archived.is_empty() { if archived.is_empty() {
list.append( list.append(&build_empty_state("folder-symbolic", "Nothing archived yet.", None));
&gtk4::Label::builder()
.label("Archive is empty.")
.margin_top(32)
.build(),
);
} else { } else {
for note in archived { for note in archived {
list.append(&build_archive_card(note, state.clone()));
}
}
scroll.set_child(Some(&list));
scroll
}
fn build_archive_card(note: &Note, state: crate::AppState) -> gtk4::Box {
let row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_start(8)
.margin_end(8)
.margin_top(2)
.margin_bottom(2)
.css_classes(["note-card"])
.build();
let completed_str = note let completed_str = note
.completed .completed
.map(|t| { .map(|t| {
@ -55,55 +34,11 @@ fn build_archive_card(note: &Note, state: crate::AppState) -> gtk4::Box {
format!("done {}", local.format("%b %d")) format!("done {}", local.format("%b %d"))
}) })
.unwrap_or_else(|| "done".into()); .unwrap_or_else(|| "done".into());
let spec = RowSpec { date_label: completed_str, note, show_type_badge: true, show_done: false };
let done_label = gtk4::Label::builder() list.append(&super::row::build(spec, state.clone()));
.label(&completed_str)
.width_chars(12)
.xalign(0.0)
.build();
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let type_label = gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip"])
.build();
// 🗑 Delete — two-click confirm
let delete_btn = gtk4::Button::builder()
.label("🗑")
.css_classes(["action-btn", "danger-btn"])
.tooltip_text("Delete permanently")
.build();
{
let confirming = Rc::new(RefCell::new(false));
let note_id = note.id.clone();
let row_c = row.clone();
let btn_c = delete_btn.clone();
delete_btn.connect_clicked(move |_| {
if *confirming.borrow() {
let store = state.write_store();
if let Err(e) = store.delete_note(&note_id) {
state.log_error(format!("delete failed: {}", e));
} }
row_c.set_visible(false);
state.reload_notes();
} else {
*confirming.borrow_mut() = true;
btn_c.set_label("Sure?");
}
});
} }
row.append(&done_label); scroll.set_child(Some(&list));
row.append(&body_label); scroll
row.append(&type_label);
row.append(&delete_btn);
row
} }

View file

@ -35,7 +35,7 @@ pub fn build(entries: &[(DateTime<chrono::Local>, String)]) -> gtk4::ScrolledWin
.build(); .build();
let time_label = gtk4::Label::builder() let time_label = gtk4::Label::builder()
.label(&ts.format("%H:%M:%S").to_string()) .label(ts.format("%H:%M:%S").to_string())
.width_chars(10) .width_chars(10)
.xalign(0.0) .xalign(0.0)
.css_classes(["dim-label"]) .css_classes(["dim-label"])

View file

@ -1,4 +1,5 @@
pub mod archive; pub mod archive;
pub mod errors; pub mod errors;
pub mod row;
pub mod settings; pub mod settings;
pub mod upcoming; pub mod upcoming;

268
breadman/src/views/row.rs Normal file
View file

@ -0,0 +1,268 @@
//! Shared single-line note row, used by every list view (All/Upcoming/
//! per-type/Archive) instead of each view hand-rolling its own card. Design
//! review found the two-line card (title/badge top, huge dead gap, actions
//! bottom-right) used by the active views wasted enormous horizontal space
//! compared to Archive's tighter aligned-column layout - this ports that
//! layout everywhere and unifies the row template (including the edit
//! affordance, previously pencil-in-active / click-row-in-archive).
use breadpad_shared::types::{Note, NoteType};
use gtk4::prelude::*;
use libadwaita::prelude::*;
use std::rc::Rc;
pub struct RowSpec<'a> {
pub date_label: String,
pub note: &'a Note,
pub show_type_badge: bool,
pub show_done: bool,
}
/// Type-tinted badge class matching the `note-card-{type}` accent-bar colors
/// already established in breadpad-shared's theme (todo=green,
/// reminder=yellow, idea=pink, question=teal, note=blue).
fn type_chip_class(note_type: &NoteType) -> &'static str {
match note_type {
NoteType::Todo => "type-chip-todo",
NoteType::Reminder => "type-chip-reminder",
NoteType::Idea => "type-chip-idea",
NoteType::Note => "type-chip-note",
NoteType::Question => "type-chip-question",
NoteType::Tag(_) => "type-chip",
}
}
pub fn build(spec: RowSpec, state: crate::AppState) -> gtk4::Box {
let note = spec.note;
let row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_start(8)
.margin_end(8)
.margin_top(2)
.margin_bottom(2)
.css_classes(["note-card"])
.build();
row.add_css_class(&format!("note-card-{}", note.note_type.as_str()));
let date_label = gtk4::Label::builder()
.label(&spec.date_label)
.width_chars(16)
.xalign(0.0)
.css_classes(["dim-label"])
.build();
row.append(&date_label);
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
row.append(&body_label);
if let Some(ws) = &note.workspace {
row.append(
&gtk4::Label::builder()
.label(format!("ws:{}", ws))
.css_classes(["type-chip"])
.build(),
);
}
if note.rrule.is_some() {
row.append(&gtk4::Label::builder().label("\u{21bb}").css_classes(["dim-label"]).build());
}
if spec.show_type_badge {
row.append(
&gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip", type_chip_class(&note.note_type)])
.build(),
);
}
if spec.show_done {
let done_btn = gtk4::Button::builder()
.icon_name("object-select-symbolic")
.css_classes(["action-btn", "done-btn"])
.tooltip_text("Mark done")
.build();
{
let note_id = note.id.clone();
let row_c = row.clone();
let state_c = state.clone();
done_btn.connect_clicked(move |_| {
row_c.set_visible(false); // optimistic hide
let store = state_c.write_store();
let id = note_id.clone();
let state = state_c.clone();
crate::spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
if let Some(mut n) = store.get_by_id(&id)? {
n.mark_done();
store.update_note(&n)?;
}
store.load_all()
},
move |result| match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
crate::rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
Err(e) => state.log_error(format!("mark done failed: {}", e)),
},
);
});
}
row.append(&done_btn);
}
let edit_btn = gtk4::Button::builder()
.icon_name("document-edit-symbolic")
.css_classes(["action-btn", "edit-btn"])
.tooltip_text("Edit")
.build();
{
let note_c = note.clone();
let state_c = state.clone();
let body_label_c = body_label.clone();
let row_c = row.clone();
edit_btn.connect_clicked(move |btn| {
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
let store = std::sync::Arc::new(state_c.write_store());
let state_save = state_c.clone();
let body_label_save = body_label_c.clone();
let state_del = state_c.clone();
let row_del = row_c.clone();
let state_err = state_c.clone();
let dialog = crate::editor::open_editor(
&note_c,
store,
morning,
std::rc::Rc::new(move |updated: Note| {
body_label_save.set_label(&updated.body);
state_save.reload_notes();
crate::rebuild_stack(&state_save);
let active = state_save.active_view.borrow().clone();
state_save.stack.set_visible_child_name(&active);
}),
std::rc::Rc::new(move || {
row_del.set_visible(false);
state_del.reload_notes();
crate::rebuild_stack(&state_del);
let active = state_del.active_view.borrow().clone();
state_del.stack.set_visible_child_name(&active);
}),
std::rc::Rc::new(move |e: String| {
state_err.log_error(e);
}),
);
dialog.present(Some(btn.upcast_ref::<gtk4::Widget>()));
});
}
row.append(&edit_btn);
let delete_btn = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.css_classes(["action-btn", "danger-btn"])
.tooltip_text("Delete")
.build();
{
use std::cell::RefCell;
use std::rc::Rc;
let confirming = Rc::new(RefCell::new(false));
let note_id = note.id.clone();
let row_c = row.clone();
let state_c = state.clone();
let btn_c = delete_btn.clone();
delete_btn.connect_clicked(move |_| {
if *confirming.borrow() {
row_c.set_visible(false); // optimistic hide
let store = state_c.write_store();
let id = note_id.clone();
let state = state_c.clone();
crate::spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
store.delete_note(&id)?;
if let Err(e) = breadpad_shared::scheduler::Scheduler::cancel(&id) {
tracing::warn!("failed to cancel timer for {}: {}", id, e);
}
store.load_all()
},
move |result| match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
crate::rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
Err(e) => state.log_error(format!("delete failed: {}", e)),
},
);
} else {
*confirming.borrow_mut() = true;
btn_c.set_icon_name("edit-delete-symbolic");
btn_c.set_tooltip_text(Some("Click again to delete permanently"));
}
});
}
row.append(&delete_btn);
row
}
/// Centered "nothing here" state with type-specific copy and (optionally) a
/// direct affordance to act on - the empty states were all top-anchored
/// generic text with no icon or action.
pub fn build_empty_state(icon_name: &str, text: &str, action: Option<(String, Rc<dyn Fn()>)>) -> gtk4::Widget {
let outer = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(12)
.valign(gtk4::Align::Center)
.halign(gtk4::Align::Center)
.vexpand(true)
.build();
let icon = gtk4::Image::builder()
.icon_name(icon_name)
.pixel_size(32)
.css_classes(["dim-label"])
.build();
outer.append(&icon);
let label = gtk4::Label::builder()
.label(text)
.css_classes(["dim-label"])
.justify(gtk4::Justification::Center)
.build();
outer.append(&label);
if let Some((label_text, on_click)) = action {
let btn = gtk4::Button::builder()
.label(&label_text)
.css_classes(["confirm-button"])
.halign(gtk4::Align::Center)
.build();
btn.connect_clicked(move |_| on_click());
outer.append(&btn);
}
outer.upcast()
}
/// Convenience wrapper for the note-list views: a "+ New {type}" button that
/// opens the New Note window preselected to `note_type`.
pub fn new_note_action(note_type: NoteType, window: gtk4::ApplicationWindow, state: crate::AppState) -> (String, Rc<dyn Fn()>) {
let label = format!("+ New {}", note_type.as_str());
let action: Rc<dyn Fn()> = Rc::new(move || {
crate::show_add_note_window(&window, state.clone(), note_type.clone(), |_| {});
});
(label, action)
}

View file

@ -1,7 +1,80 @@
//! Settings screen. Deliberately plain GTK4, not libadwaita's AdwActionRow/
//! AdwSpinRow/AdwEntryRow family — those ran noticeably taller than the rest
//! of the app and don't expose a way to constrain the internal spin
//! button's width from the outside (it stretches to fill whatever room the
//! row has, leaving the digits and +/- buttons stranded behind a huge empty
//! bordered box once the row is wider than libadwaita's usual ~400-600px
//! home turf). Instead this mirrors bos-settings' own Row.svelte /
//! NumberField.svelte / TextField.svelte design exactly — same tokens
//! (12/16px row padding, ch-width inputs, transparent-at-rest border) — so
//! the two settings screens in the ecosystem actually agree with each other.
use breadpad_shared::config::{ use breadpad_shared::config::{
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings, CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
}; };
use gtk4::prelude::*; use breadpad_shared::types::NoteType;
use gtk4::{glib, prelude::*};
use std::cell::RefCell;
use std::rc::Rc;
/// A titled group: heading, optional description, then a `.boxed-list` of
/// `field_row`s (native GTK4 rounded-corner-run + divider styling).
fn field_group(title: &str, description: Option<&str>) -> (gtk4::Box, gtk4::ListBox) {
let outer = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(8).build();
let heading = gtk4::Label::builder().label(title).xalign(0.0).css_classes(["heading"]).build();
outer.append(&heading);
if let Some(desc) = description {
let desc_label = gtk4::Label::builder()
.label(desc)
.xalign(0.0)
.wrap(true)
.css_classes(["dim-label"])
.build();
outer.append(&desc_label);
}
let list = gtk4::ListBox::builder()
.selection_mode(gtk4::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
outer.append(&list);
(outer, list)
}
/// A single row: label (+ optional subtitle) on the left, one control on
/// the right — same shape as bos-settings' `Row.svelte`.
fn field_row(label: &str, subtitle: Option<&str>, control: &impl IsA<gtk4::Widget>) -> gtk4::ListBoxRow {
let row = gtk4::ListBoxRow::builder()
.selectable(false)
.activatable(false)
.css_classes(["field-row"])
.build();
let hbox = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(16).build();
let label_box = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).hexpand(true).valign(gtk4::Align::Center).build();
label_box.append(&gtk4::Label::builder().label(label).xalign(0.0).build());
if let Some(sub) = subtitle {
label_box.append(&gtk4::Label::builder().label(sub).xalign(0.0).wrap(true).css_classes(["field-row-subtitle"]).build());
}
hbox.append(&label_box);
hbox.append(control);
row.set_child(Some(&hbox));
row
}
fn text_entry(text: &str, width_chars: i32) -> gtk4::Entry {
gtk4::Entry::builder().text(text).width_chars(width_chars).valign(gtk4::Align::Center).css_classes(["field-input"]).build()
}
fn spin_button(value: f64, min: f64, max: f64, step: f64, page: f64, digits: u32) -> gtk4::SpinButton {
let adj = gtk4::Adjustment::new(value, min, max, step, page, 0.0);
gtk4::SpinButton::builder().adjustment(&adj).digits(digits).width_chars(8).valign(gtk4::Align::Center).css_classes(["field-input"]).build()
}
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow { pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
@ -10,255 +83,242 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
.vexpand(true) .vexpand(true)
.build(); .build();
let outer = gtk4::Box::builder() let content = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(24).build();
.orientation(gtk4::Orientation::Vertical)
.spacing(16)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
// ── General ────────────────────────────────────────────────── // ── General ──────────────────────────────────────────────────
let (general_frame, general_grid) = make_section("General"); let (general_group, general_list) = field_group("General", None);
let type_options = ["note", "todo", "reminder", "idea", "question"]; let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
let default_type_combo = gtk4::DropDown::from_strings(&type_options); let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(cfg.settings.default_type.clone()));
let dt_idx = type_options let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin()
.iter() .iter()
.position(|&s| s == cfg.settings.default_type.as_str()) .map(|&name| (bread_theme::gtk::chip(name), name))
.unwrap_or(0) as u32; .collect();
default_type_combo.set_selected(dt_idx); for (btn, name) in &type_pills {
attach_row(&general_grid, 0, "Default type", &default_type_combo); bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str());
type_pill_box.append(btn);
}
general_list.append(&field_row("Default type", None, &type_pill_box));
let ws_tag_switch = gtk4::Switch::builder() let ws_tag_switch = gtk4::Switch::builder().active(cfg.settings.workspace_tag).valign(gtk4::Align::Center).build();
.active(cfg.settings.workspace_tag) general_list.append(&field_row(
.valign(gtk4::Align::Center) "Workspace tag",
.build(); Some("Tag new notes with the Hyprland workspace they were created on"),
attach_row(&general_grid, 1, "Workspace tag", &ws_tag_switch); &ws_tag_switch,
));
let archive_spin = gtk4::SpinButton::with_range(1.0, 365.0, 1.0); let archive_spin = spin_button(cfg.settings.archive_after_days as f64, 1.0, 365.0, 1.0, 7.0, 0);
archive_spin.set_value(cfg.settings.archive_after_days as f64); general_list.append(&field_row("Archive after (days)", None, &archive_spin));
attach_row(&general_grid, 2, "Archive after (days)", &archive_spin);
let snooze_entry = gtk4::Entry::builder() let snooze_entry = text_entry(&cfg.settings.snooze_options.join(", "), 24);
.text(&cfg.settings.snooze_options.join(", ")) general_list.append(&field_row("Snooze options", Some("Comma-separated (e.g. 15m, 1h, tomorrow_morning)"), &snooze_entry));
.hexpand(true)
.build();
attach_row(&general_grid, 3, "Snooze options", &snooze_entry);
outer.append(&general_frame); content.append(&general_group);
// ── Reminders ──────────────────────────────────────────────── // ── Reminders ────────────────────────────────────────────────
let (rem_frame, rem_grid) = make_section("Reminders"); let (rem_group, rem_list) = field_group("Reminders", None);
let morning_entry = gtk4::Entry::builder() let morning_entry = text_entry(&cfg.reminders.default_morning, 10);
.text(&cfg.reminders.default_morning) rem_list.append(&field_row("Default morning", Some("Used for \"tomorrow_morning\" snoozes and recurring reminders"), &morning_entry));
.placeholder_text("HH:MM")
.build();
attach_row(&rem_grid, 0, "Default morning", &morning_entry);
let grace_spin = gtk4::SpinButton::with_range(0.0, 1440.0, 5.0); let grace_spin = spin_button(cfg.reminders.missed_grace_minutes as f64, 0.0, 1440.0, 5.0, 30.0, 0);
grace_spin.set_value(cfg.reminders.missed_grace_minutes as f64); rem_list.append(&field_row("Missed grace (minutes)", Some("How late a reminder can fire before it's considered missed"), &grace_spin));
attach_row(&rem_grid, 1, "Missed grace (minutes)", &grace_spin);
outer.append(&rem_frame); content.append(&rem_group);
// ── Model ───────────────────────────────────────────────────── // ── Local classifier ───────────────────────────────────────────
let (model_frame, model_grid) = make_section("Model (Tier 2 ONNX)"); let (model_group, model_list) = field_group(
"Local Classifier",
Some("Optional local ONNX model for classifying note type/time without a network round-trip. These paths are shared with breadpad — both apps read the same model files."),
);
let model_path_entry = gtk4::Entry::builder() let model_path_entry = text_entry(&cfg.model.path, 30);
.text(&cfg.model.path) model_list.append(&field_row("Model path", None, &model_path_entry));
.hexpand(true)
.build();
attach_row(&model_grid, 0, "ONNX path", &model_path_entry);
let tokenizer_entry = gtk4::Entry::builder() let tokenizer_entry = text_entry(&cfg.model.tokenizer, 30);
.text(&cfg.model.tokenizer) model_list.append(&field_row("Tokenizer path", None, &tokenizer_entry));
.hexpand(true)
.build();
attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry);
let ort_dylib_entry = gtk4::Entry::builder() let ort_dylib_entry = text_entry(&cfg.model.ort_dylib_path, 30);
.text(&cfg.model.ort_dylib_path) model_list.append(&field_row("Runtime library path", None, &ort_dylib_entry));
.hexpand(true)
.build();
attach_row(&model_grid, 2, "ORT dylib path", &ort_dylib_entry);
outer.append(&model_frame); content.append(&model_group);
// ── Ollama (Tier 3) ─────────────────────────────────────────── // ── AI classification (Ollama) ──────────────────────────────────
let (ollama_frame, ollama_grid) = make_section("Ollama (Tier 3)"); let (ollama_group, ollama_list) = field_group(
"AI Classification",
Some("Uses a local Ollama model as a fallback classifier when the ONNX model is unavailable or unsure."),
);
let ollama_enabled = gtk4::Switch::builder() let ollama_enabled_switch = gtk4::Switch::builder().active(cfg.model.ollama.enabled).valign(gtk4::Align::Center).build();
.active(cfg.model.ollama.enabled) ollama_list.append(&field_row("Enabled", None, &ollama_enabled_switch));
.valign(gtk4::Align::Center)
.build();
attach_row(&ollama_grid, 0, "Enabled", &ollama_enabled);
let ollama_endpoint = gtk4::Entry::builder() let ollama_endpoint_entry = text_entry(&cfg.model.ollama.endpoint, 24);
.text(&cfg.model.ollama.endpoint) ollama_list.append(&field_row("Endpoint", None, &ollama_endpoint_entry));
.hexpand(true)
.build();
attach_row(&ollama_grid, 1, "Endpoint", &ollama_endpoint);
let ollama_model = gtk4::Entry::builder() let ollama_model_entry = text_entry(&cfg.model.ollama.model, 16);
.text(&cfg.model.ollama.model) ollama_list.append(&field_row("Model", None, &ollama_model_entry));
.build();
attach_row(&ollama_grid, 2, "Model", &ollama_model);
let ollama_thresh = gtk4::SpinButton::with_range(0.0, 1.0, 0.05); let ollama_thresh_spin = spin_button(cfg.model.ollama.confidence_threshold as f64, 0.0, 1.0, 0.05, 0.1, 2);
ollama_thresh.set_value(cfg.model.ollama.confidence_threshold as f64); ollama_list.append(&field_row("Confidence threshold", None, &ollama_thresh_spin));
ollama_thresh.set_digits(2);
attach_row(&ollama_grid, 3, "Confidence threshold", &ollama_thresh);
outer.append(&ollama_frame); content.append(&ollama_group);
// ── Calendar ───────────────────────────────────────────────── // ── Calendar sync ────────────────────────────────────────────
let (cal_frame, cal_grid) = make_section("Nextcloud Calendar (CalDAV)"); let (cal_group, cal_list) = field_group("Calendar Sync", Some("Sync reminders to a Nextcloud calendar via CalDAV."));
let cal_enabled = gtk4::Switch::builder() let cal_enabled_switch = gtk4::Switch::builder().active(cfg.calendar.enabled).valign(gtk4::Align::Center).build();
.active(cfg.calendar.enabled) cal_list.append(&field_row("Enabled", None, &cal_enabled_switch));
.valign(gtk4::Align::Center)
.build();
attach_row(&cal_grid, 0, "Enabled", &cal_enabled);
let cal_url = gtk4::Entry::builder() let cal_url_entry = text_entry(&cfg.calendar.url, 30);
.text(&cfg.calendar.url) cal_list.append(&field_row("Calendar URL", None, &cal_url_entry));
.placeholder_text("https://nextcloud.example.com/remote.php/dav/calendars/you/personal/")
.hexpand(true)
.build();
attach_row(&cal_grid, 1, "Calendar URL", &cal_url);
let cal_user = gtk4::Entry::builder() let cal_user_entry = text_entry(&cfg.calendar.username, 16);
.text(&cfg.calendar.username) cal_list.append(&field_row("Username", None, &cal_user_entry));
.build();
attach_row(&cal_grid, 2, "Username", &cal_user);
let cal_pass = gtk4::Entry::builder() let cal_pass_entry = gtk4::PasswordEntry::builder().text(&cfg.calendar.password).show_peek_icon(true).valign(gtk4::Align::Center).css_classes(["field-input"]).build();
.text(&cfg.calendar.password) cal_list.append(&field_row("App password", None, &cal_pass_entry));
.input_purpose(gtk4::InputPurpose::Password)
.visibility(false)
.build();
attach_row(&cal_grid, 3, "App password", &cal_pass);
outer.append(&cal_frame); content.append(&cal_group);
// ── Save ────────────────────────────────────────────────────── // ── Status (instant-apply — no Save button) ─────────────────
let status_label = gtk4::Label::builder() let status_label = gtk4::Label::builder().label("").xalign(0.0).css_classes(["dim-label"]).margin_top(4).build();
.label("") content.append(&status_label);
.xalign(0.0)
.css_classes(["dim-label"])
.build();
let save_btn = gtk4::Button::builder()
.label("Save Settings")
.css_classes(["confirm-button"])
.halign(gtk4::Align::End)
.build();
{ // Reads every widget's current value and persists immediately. Every
let dtc = default_type_combo.clone(); // control below calls this on its own "committed a change" signal
let wts = ws_tag_switch.clone(); // (switch/spin fire on change; entries fire on Enter or focus-out).
let ars = archive_spin.clone(); let apply_now: Rc<dyn Fn()> = Rc::new({
let sne = snooze_entry.clone(); let selected_type = selected_type.clone();
let moe = morning_entry.clone(); let ws_tag_switch = ws_tag_switch.clone();
let grs = grace_spin.clone(); let archive_spin = archive_spin.clone();
let mpe = model_path_entry.clone(); let snooze_entry = snooze_entry.clone();
let tke = tokenizer_entry.clone(); let morning_entry = morning_entry.clone();
let ode = ort_dylib_entry.clone(); let grace_spin = grace_spin.clone();
let oec = ollama_enabled.clone(); let model_path_entry = model_path_entry.clone();
let oee = ollama_endpoint.clone(); let tokenizer_entry = tokenizer_entry.clone();
let ome = ollama_model.clone(); let ort_dylib_entry = ort_dylib_entry.clone();
let ots = ollama_thresh.clone(); let ollama_enabled_switch = ollama_enabled_switch.clone();
let cec = cal_enabled.clone(); let ollama_endpoint_entry = ollama_endpoint_entry.clone();
let cuc = cal_url.clone(); let ollama_model_entry = ollama_model_entry.clone();
let csc = cal_user.clone(); let ollama_thresh_spin = ollama_thresh_spin.clone();
let cpc = cal_pass.clone(); let cal_enabled_switch = cal_enabled_switch.clone();
let sl = status_label.clone(); let cal_url_entry = cal_url_entry.clone();
let cal_user_entry = cal_user_entry.clone();
let cal_pass_entry = cal_pass_entry.clone();
let status_label = status_label.clone();
save_btn.connect_clicked(move |_| { move || {
let new_cfg = Config { let new_cfg = Config {
settings: Settings { settings: Settings {
default_type: type_options default_type: selected_type.borrow().clone(),
.get(dtc.selected() as usize) workspace_tag: ws_tag_switch.is_active(),
.copied() snooze_options: snooze_entry
.unwrap_or("note")
.to_string(),
workspace_tag: wts.is_active(),
snooze_options: sne
.text() .text()
.split(',') .split(',')
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect(), .collect(),
archive_after_days: ars.value() as i64, archive_after_days: archive_spin.value() as i64,
}, },
reminders: RemindersConfig { reminders: RemindersConfig {
default_morning: moe.text().to_string(), default_morning: morning_entry.text().to_string(),
missed_grace_minutes: grs.value() as i64, missed_grace_minutes: grace_spin.value() as i64,
}, },
model: ModelConfig { model: ModelConfig {
path: mpe.text().to_string(), path: model_path_entry.text().to_string(),
tokenizer: tke.text().to_string(), tokenizer: tokenizer_entry.text().to_string(),
ort_dylib_path: ode.text().to_string(), ort_dylib_path: ort_dylib_entry.text().to_string(),
ollama: OllamaConfig { ollama: OllamaConfig {
enabled: oec.is_active(), enabled: ollama_enabled_switch.is_active(),
endpoint: oee.text().to_string(), endpoint: ollama_endpoint_entry.text().to_string(),
model: ome.text().to_string(), model: ollama_model_entry.text().to_string(),
confidence_threshold: ots.value() as f32, confidence_threshold: ollama_thresh_spin.value() as f32,
}, },
}, },
calendar: CalendarConfig { calendar: CalendarConfig {
enabled: cec.is_active(), enabled: cal_enabled_switch.is_active(),
url: cuc.text().to_string(), url: cal_url_entry.text().to_string(),
username: csc.text().to_string(), username: cal_user_entry.text().to_string(),
password: cpc.text().to_string(), password: cal_pass_entry.text().to_string(),
}, },
}; };
match new_cfg.save() { match new_cfg.save() {
Ok(()) => { Ok(()) => {
sl.set_label("Settings saved."); status_label.set_label("Saved.");
on_save(new_cfg); on_save(new_cfg);
} }
Err(e) => sl.set_label(&format!("Save failed: {}", e)), Err(e) => status_label.set_label(&format!("Save failed: {}", e)),
}
} }
}); });
}
let btn_row = gtk4::Box::builder() // Type pills, switches, spinners apply the moment they change.
.orientation(gtk4::Orientation::Horizontal) for (btn, name) in &type_pills {
.spacing(8) let apply_now = apply_now.clone();
let sel = selected_type.clone();
let name = *name;
let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = name.to_string();
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
bread_theme::gtk::set_chip_active(clicked, true);
apply_now();
});
}
macro_rules! apply_on_active {
($sw:expr) => {
let apply_now = apply_now.clone();
$sw.connect_state_set(move |_, _| { apply_now(); glib::Propagation::Proceed });
};
}
apply_on_active!(ws_tag_switch);
apply_on_active!(ollama_enabled_switch);
apply_on_active!(cal_enabled_switch);
macro_rules! apply_on_value_changed {
($spin:expr) => {
let apply_now = apply_now.clone();
$spin.connect_value_changed(move |_| apply_now());
};
}
apply_on_value_changed!(archive_spin);
apply_on_value_changed!(grace_spin);
apply_on_value_changed!(ollama_thresh_spin);
// Entries: apply on Enter, and on focus-out so a click-away doesn't
// silently discard the edit.
macro_rules! apply_on_entry {
($entry:expr) => {
let apply_now_activate = apply_now.clone();
$entry.connect_activate(move |_| apply_now_activate());
let apply_now_focus = apply_now.clone();
let focus = gtk4::EventControllerFocus::new();
focus.connect_leave(move |_| apply_now_focus());
$entry.add_controller(focus);
};
}
apply_on_entry!(snooze_entry);
apply_on_entry!(morning_entry);
apply_on_entry!(model_path_entry);
apply_on_entry!(tokenizer_entry);
apply_on_entry!(ort_dylib_entry);
apply_on_entry!(ollama_endpoint_entry);
apply_on_entry!(ollama_model_entry);
apply_on_entry!(cal_url_entry);
apply_on_entry!(cal_user_entry);
apply_on_entry!(cal_pass_entry);
content.set_halign(gtk4::Align::Start);
content.set_size_request(900, -1);
let outer = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.margin_start(12)
.margin_end(12)
.margin_top(12)
.margin_bottom(16)
.build(); .build();
btn_row.append(&status_label); outer.append(&content);
btn_row.append(&gtk4::Box::builder().hexpand(true).build());
btn_row.append(&save_btn);
outer.append(&btn_row);
scroll.set_child(Some(&outer)); scroll.set_child(Some(&outer));
scroll scroll
} }
fn make_section(title: &str) -> (gtk4::Frame, gtk4::Grid) {
let frame = gtk4::Frame::builder().label(title).build();
let grid = gtk4::Grid::builder()
.row_spacing(8)
.column_spacing(16)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.build();
frame.set_child(Some(&grid));
(frame, grid)
}
fn attach_row(grid: &gtk4::Grid, row: i32, label: &str, widget: &impl gtk4::prelude::IsA<gtk4::Widget>) {
let lbl = gtk4::Label::builder()
.label(label)
.xalign(0.0)
.hexpand(false)
.width_chars(24)
.build();
grid.attach(&lbl, 0, row, 1, 1);
grid.attach(widget, 1, row, 1, 1);
}

View file

@ -1,7 +1,8 @@
use super::row::{build_empty_state, RowSpec};
use breadpad_shared::types::{Note, NoteType}; use breadpad_shared::types::{Note, NoteType};
use gtk4::prelude::*; use gtk4::prelude::*;
pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow { pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never) .hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic) .vscrollbar_policy(gtk4::PolicyType::Automatic)
@ -26,33 +27,9 @@ pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow {
upcoming.sort_by_key(|n| n.effective_time().unwrap()); upcoming.sort_by_key(|n| n.effective_time().unwrap());
if upcoming.is_empty() { if upcoming.is_empty() {
let label = gtk4::Label::builder() list.append(&build_empty_state("x-office-calendar-symbolic", "No upcoming reminders or todos.", None));
.label("No upcoming reminders or todos.")
.margin_top(32)
.build();
list.append(&label);
} else { } else {
for note in upcoming { for note in upcoming {
let card = build_upcoming_card(note);
list.append(&card);
}
}
scroll.set_child(Some(&list));
scroll
}
fn build_upcoming_card(note: &Note) -> gtk4::Box {
let row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_start(8)
.margin_end(8)
.margin_top(4)
.margin_bottom(4)
.css_classes(["note-card"])
.build();
let time_str = note let time_str = note
.effective_time() .effective_time()
.map(|t| { .map(|t| {
@ -60,27 +37,11 @@ fn build_upcoming_card(note: &Note) -> gtk4::Box {
local.format("%a %b %d, %H:%M").to_string() local.format("%a %b %d, %H:%M").to_string()
}) })
.unwrap_or_default(); .unwrap_or_default();
let spec = RowSpec { date_label: time_str, note, show_type_badge: true, show_done: true };
let time_label = gtk4::Label::builder() list.append(&super::row::build(spec, state.clone()));
.label(&time_str) }
.width_chars(18) }
.xalign(0.0)
.build(); scroll.set_child(Some(&list));
scroll
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let type_label = gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip"])
.build();
row.append(&time_label);
row.append(&body_label);
row.append(&type_label);
row
} }

View file

@ -7,7 +7,8 @@ authors.workspace = true
[dependencies] [dependencies]
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", 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

View file

@ -73,7 +73,7 @@ impl OllamaClient {
let classification: OllamaClassification = extract_json(&ollama_resp.response) let classification: OllamaClassification = extract_json(&ollama_resp.response)
.ok_or_else(|| anyhow::anyhow!( .ok_or_else(|| anyhow::anyhow!(
"no JSON object found in response — raw: {:?}", "no JSON object found in response — raw: {:?}",
&ollama_resp.response ollama_resp.response
))?; ))?;
let note_type = classification let note_type = classification

View file

@ -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)
@ -271,14 +276,5 @@ fn try_load_session(
} }
} }
} }
}
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

@ -135,7 +135,7 @@ impl Default for RemindersConfig {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CalendarConfig { pub struct CalendarConfig {
#[serde(default = "default_calendar_enabled")] #[serde(default = "default_calendar_enabled")]
pub enabled: bool, pub enabled: bool,
@ -150,17 +150,6 @@ pub struct CalendarConfig {
pub password: String, pub password: String,
} }
impl Default for CalendarConfig {
fn default() -> Self {
CalendarConfig {
enabled: false,
url: String::new(),
username: String::new(),
password: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config { pub struct Config {
#[serde(default)] #[serde(default)]

View file

@ -324,6 +324,67 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
} }
} }
fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
let lower = text.to_lowercase();
if has_rrule || has_time {
return NoteType::Reminder;
}
if lower.contains("buy ")
|| lower.contains("pick up")
|| lower.contains("clean ")
|| lower.starts_with("call ")
|| lower.starts_with("email ")
|| lower.starts_with("fix ")
|| lower.starts_with("check ")
|| lower.starts_with("finish ")
|| lower.starts_with("write ")
|| lower.starts_with("update ")
|| lower.starts_with("prepare ")
|| lower.starts_with("schedule ")
|| lower.starts_with("organize ")
|| lower.starts_with("deploy ")
|| lower.starts_with("install ")
|| lower.starts_with("send ")
|| lower.starts_with("submit ")
|| lower.starts_with("create ")
|| lower.starts_with("setup ")
|| lower.starts_with("restore ")
|| lower.starts_with("archive ")
|| lower.starts_with("export ")
|| lower.starts_with("import ")
|| lower.starts_with("approve ")
|| lower.starts_with("configure ")
|| lower.starts_with("refactor ")
|| lower.starts_with("review ")
{
return NoteType::Todo;
}
if lower.starts_with("what if ")
|| lower.starts_with("idea:")
|| lower.contains("could ")
|| lower.contains("maybe ")
|| lower.starts_with("should we ")
{
return NoteType::Idea;
}
if lower.starts_with("why ")
|| lower.starts_with("how ")
|| (lower.starts_with("what ") && !lower.starts_with("what if "))
|| lower.starts_with("when ")
|| lower.starts_with("where ")
|| lower.starts_with("who ")
|| lower.starts_with("will ")
|| lower.starts_with("is ")
|| lower.starts_with("are ")
|| lower.starts_with("did ")
|| lower.starts_with("does ")
|| lower.ends_with('?')
{
return NoteType::Question;
}
NoteType::Note
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -490,7 +551,7 @@ mod tests {
let r = p("take a break in 30 minutes"); let r = p("take a break in 30 minutes");
let t = r.time.unwrap(); let t = r.time.unwrap();
let delta = (t - before).num_seconds(); let delta = (t - before).num_seconds();
assert!(delta >= 29 * 60 && delta <= 31 * 60, "delta was {}s", delta); assert!((29 * 60..=31 * 60).contains(&delta), "delta was {}s", delta);
} }
#[test] #[test]
@ -498,7 +559,7 @@ mod tests {
let before = Utc::now(); let before = Utc::now();
let r = p("ping in 1 minute"); let r = p("ping in 1 minute");
let delta = (r.time.unwrap() - before).num_seconds(); let delta = (r.time.unwrap() - before).num_seconds();
assert!(delta >= 55 && delta <= 65, "delta was {}s", delta); assert!((55..=65).contains(&delta), "delta was {}s", delta);
} }
#[test] #[test]
@ -506,7 +567,7 @@ mod tests {
let before = Utc::now(); let before = Utc::now();
let r = p("review PR in 2 hours"); let r = p("review PR in 2 hours");
let delta_min = (r.time.unwrap() - before).num_minutes(); let delta_min = (r.time.unwrap() - before).num_minutes();
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min); assert!((119..=121).contains(&delta_min), "delta was {}min", delta_min);
} }
#[test] #[test]
@ -514,7 +575,7 @@ mod tests {
let before = Utc::now(); let before = Utc::now();
let r = p("follow up in 3 days"); let r = p("follow up in 3 days");
let delta_h = (r.time.unwrap() - before).num_hours(); let delta_h = (r.time.unwrap() - before).num_hours();
assert!(delta_h >= 71 && delta_h <= 73, "delta was {}h", delta_h); assert!((71..=73).contains(&delta_h), "delta was {}h", delta_h);
} }
// ---- Time extraction: tomorrow ---- // ---- Time extraction: tomorrow ----
@ -717,7 +778,7 @@ mod tests {
let before = Utc::now(); let before = Utc::now();
let r = p("check on the server in an hour"); let r = p("check on the server in an hour");
let delta_min = (r.time.unwrap() - before).num_minutes(); let delta_min = (r.time.unwrap() - before).num_minutes();
assert!(delta_min >= 59 && delta_min <= 61, "delta was {}min", delta_min); assert!((59..=61).contains(&delta_min), "delta was {}min", delta_min);
} }
#[test] #[test]
@ -737,7 +798,7 @@ mod tests {
let before = Utc::now(); let before = Utc::now();
let r = p("in a couple of hours remind me to check the oven"); let r = p("in a couple of hours remind me to check the oven");
let delta_min = (r.time.unwrap() - before).num_minutes(); let delta_min = (r.time.unwrap() - before).num_minutes();
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min); assert!((119..=121).contains(&delta_min), "delta was {}min", delta_min);
} }
#[test] #[test]
@ -758,7 +819,7 @@ mod tests {
let before = Utc::now(); let before = Utc::now();
let r = p("in half an hour submit the report"); let r = p("in half an hour submit the report");
let delta_min = (r.time.unwrap() - before).num_minutes(); let delta_min = (r.time.unwrap() - before).num_minutes();
assert!(delta_min >= 29 && delta_min <= 31, "delta was {}min", delta_min); assert!((29..=31).contains(&delta_min), "delta was {}min", delta_min);
} }
// ---- Tonight / this evening ---- // ---- Tonight / this evening ----
@ -847,64 +908,3 @@ mod tests {
assert!(rule.as_str().contains("BYHOUR=16"), "rule: {}", rule.as_str()); assert!(rule.as_str().contains("BYHOUR=16"), "rule: {}", rule.as_str());
} }
} }
fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
let lower = text.to_lowercase();
if has_rrule || has_time {
return NoteType::Reminder;
}
if lower.contains("buy ")
|| lower.contains("pick up")
|| lower.contains("clean ")
|| lower.starts_with("call ")
|| lower.starts_with("email ")
|| lower.starts_with("fix ")
|| lower.starts_with("check ")
|| lower.starts_with("finish ")
|| lower.starts_with("write ")
|| lower.starts_with("update ")
|| lower.starts_with("prepare ")
|| lower.starts_with("schedule ")
|| lower.starts_with("organize ")
|| lower.starts_with("deploy ")
|| lower.starts_with("install ")
|| lower.starts_with("send ")
|| lower.starts_with("submit ")
|| lower.starts_with("create ")
|| lower.starts_with("setup ")
|| lower.starts_with("restore ")
|| lower.starts_with("archive ")
|| lower.starts_with("export ")
|| lower.starts_with("import ")
|| lower.starts_with("approve ")
|| lower.starts_with("configure ")
|| lower.starts_with("refactor ")
|| lower.starts_with("review ")
{
return NoteType::Todo;
}
if lower.starts_with("what if ")
|| lower.starts_with("idea:")
|| lower.contains("could ")
|| lower.contains("maybe ")
|| lower.starts_with("should we ")
{
return NoteType::Idea;
}
if lower.starts_with("why ")
|| lower.starts_with("how ")
|| (lower.starts_with("what ") && !lower.starts_with("what if "))
|| lower.starts_with("when ")
|| lower.starts_with("where ")
|| lower.starts_with("who ")
|| lower.starts_with("will ")
|| lower.starts_with("is ")
|| lower.starts_with("are ")
|| lower.starts_with("did ")
|| lower.starts_with("does ")
|| lower.ends_with('?')
{
return NoteType::Question;
}
NoteType::Note
}

View file

@ -176,7 +176,7 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
} else { } else {
(now.date_naive() + chrono::Duration::days(1)).and_time(fire_time) (now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
}; };
return Some(local_naive_to_utc(naive)); Some(local_naive_to_utc(naive))
} }
"WEEKLY" => { "WEEKLY" => {
use chrono::Datelike; use chrono::Datelike;
@ -204,7 +204,7 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
}; };
let target_date = let target_date =
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time); (now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
return Some(local_naive_to_utc(target_date)); Some(local_naive_to_utc(target_date))
} }
_ => None, _ => None,
} }

View file

@ -118,11 +118,11 @@ impl Store {
Ok(()) Ok(())
} }
fn rewrite_notes<F>(&self, mut f: F) -> Result<()> fn rewrite_notes<F>(&self, f: F) -> Result<()>
where where
F: FnMut(Note) -> Note, F: FnMut(Note) -> Note,
{ {
let notes: Vec<Note> = self.load_all()?.into_iter().map(|n| f(n)).collect(); let notes: Vec<Note> = self.load_all()?.into_iter().map(f).collect();
self.write_all(&self.notes_path, &notes) self.write_all(&self.notes_path, &notes)
} }
@ -145,7 +145,7 @@ impl Store {
let notes = self.load_all()?; let notes = self.load_all()?;
let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes
.into_iter() .into_iter()
.partition(|n| n.done && n.completed.map_or(false, |c| c < cutoff)); .partition(|n| n.done && n.completed.is_some_and(|c| c < cutoff));
if to_archive.is_empty() { if to_archive.is_empty() {
return Ok(0); return Ok(0);

View file

@ -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
@ -28,12 +33,33 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
/* breadpad/breadman-specific components */ /* breadpad/breadman-specific components */
window { border-radius: 8px; } window { border-radius: 8px; }
/* breadman/views/settings.rs — matches bos-settings' Row.svelte/
NumberField.svelte/TextField.svelte exactly (same design tokens: 12/16px
row padding, transparent-at-rest input border, ch-width inputs) rather
than libadwaita's own AdwActionRow/AdwSpinRow padding and internal
spin-button sizing, which run noticeably taller/wider and don't expose a
way to constrain from the outside. `list.boxed-list` already gets its
surface fill + radius from the shared stylesheet; this only adds the
compact row padding and the divider between rows. */
list.boxed-list row.field-row { padding: 12px 16px; min-height: 0; }
list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(@on-surface, 0.08); }
.field-row-subtitle { opacity: 0.6; font-size: 12px; }
.field-input {
background-color: @bg;
color: @on-surface;
border: 1px solid transparent;
border-radius: 6px;
padding: 4px 8px;
}
.field-input:focus-within { border-color: @accent; outline: none; }
.popup-entry { .popup-entry {
background: @bg; background: @bg;
color: @fg; color: @fg;
border: 2px solid @blue; border: 2px solid @blue;
border-radius: 6px; border-radius: 6px;
padding: 12px 16px; padding: 14px;
font-size: 14px; font-size: 14px;
caret-color: @fg; caret-color: @fg;
} }
@ -57,6 +83,15 @@ window { border-radius: 8px; }
color: @on-accent; color: @on-accent;
} }
/* Per-type tint, matching the note-card-{type} accent-bar colors below -
the flat cream badge was the same high-contrast fill for every type,
out-shouting note body text while telling you nothing extra. */
.type-chip-todo { background: alpha(@green, 0.18); color: @green; }
.type-chip-reminder { background: alpha(@yellow, 0.18); color: @yellow; }
.type-chip-idea { background: alpha(@pink, 0.18); color: @pink; }
.type-chip-question { background: alpha(@teal, 0.18); color: @teal; }
.type-chip-note { background: alpha(@blue, 0.18); color: @blue; }
.confirm-button { .confirm-button {
background: @blue; background: @blue;
color: @on-accent; color: @on-accent;
@ -135,8 +170,12 @@ window { border-radius: 8px; }
.edit-btn { color: @blue; } .edit-btn { color: @blue; }
.edit-btn:hover { background: alpha(@blue, 0.15); } .edit-btn:hover { background: alpha(@blue, 0.15); }
.danger-btn { color: @red; } /* Fixed red, not @red - pywal can hand `red` any hue depending on the
.danger-btn:hover { background: alpha(@red, 0.15); } wallpaper (see bread-theme's button.destructive-action for the same
reasoning), which would make delete indistinguishable from a normal
accent action. */
.danger-btn { color: #e01b24; }
.danger-btn:hover { background: alpha(#e01b24, 0.15); }
.note-card-todo { border-left-color: @green; } .note-card-todo { border-left-color: @green; }
.note-card-reminder { border-left-color: @yellow; } .note-card-reminder { border-left-color: @yellow; }
@ -170,12 +209,15 @@ window { border-radius: 8px; }
color: @fg; color: @fg;
} }
/* Dismiss and Snooze are both secondary/outline actions and should read at
equal weight - Dismiss used to sit at 0.6 alpha next to Snooze's full
opacity, which made the button that closes the reminder look disabled. */
.reminder-dismiss { .reminder-dismiss {
background: transparent; background: transparent;
border: 1px solid @overlay; border: 1px solid @overlay;
border-radius: 8px; border-radius: 8px;
padding: 8px 16px; padding: 8px 16px;
color: alpha(@fg, 0.6); color: @fg;
} }
.reminder-dismiss:hover { background: shade(@bg, 1.1); } .reminder-dismiss:hover { background: shade(@bg, 1.1); }
@ -190,15 +232,40 @@ window { border-radius: 8px; }
.reminder-snooze:hover { background: shade(@bg, 1.1); } .reminder-snooze:hover { background: shade(@bg, 1.1); }
/* Left-aligned (the button's child label sets xalign itself), full-width
row with a hairline divider so the list reads as distinct clickable rows
even before hover - a hover tint alone doesn't show up in a static
reading of the popover's default state. */
.snooze-option { .snooze-option {
background: transparent; background: transparent;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
padding: 8px 12px; padding: 10px 12px;
color: @fg; color: @fg;
border-bottom: 1px solid alpha(@overlay, 0.15);
} }
.snooze-option:hover { background: shade(@bg, 1.2); } .snooze-option:hover { background: shade(@bg, 1.2); }
.snooze-custom-entry {
background: @bg;
color: @fg;
border: 1px solid @overlay;
border-radius: 6px;
padding: 8px 12px;
margin: 4px;
}
.snooze-custom-entry:focus-within { border-color: @blue; outline: none; }
/* Matches the reminder alert card's flat-bordered elevation (1px border,
8px radius, no shadow) instead of GTK's default arrow+drop-shadow popover
chrome - the two surfaces used to speak two different elevation
languages. */
popover.snooze-popover > contents {
border: 1px solid @overlay;
box-shadow: none;
}
"#); "#);
if let Some(extra) = user_css { if let Some(extra) = user_css {
@ -216,7 +283,7 @@ mod tests {
#[test] #[test]
fn css_defines_bg_color() { fn css_defines_bg_color() {
let css = build_css(&Palette::default(), None); let css = build_css(&Palette::default(), None);
assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]); assert!(css.contains("@define-color bg #0c0c0c"), "css missing bg: {}", &css[..300]);
} }
#[test] #[test]
@ -261,9 +328,11 @@ mod tests {
#[test] #[test]
fn css_reflects_custom_palette_colors() { fn css_reflects_custom_palette_colors() {
let mut p = Palette::default(); let p = Palette {
p.background = "#deadbe".into(); background: "#deadbe".into(),
p.color4 = "#cafe00".into(); color4: "#cafe00".into(),
..Default::default()
};
let css = build_css(&p, None); let css = build_css(&p, None);
assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]); assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]);
assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]); assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]);

View file

@ -15,6 +15,9 @@ pub enum NoteType {
} }
impl NoteType { impl NoteType {
// Not std::str::FromStr — infallible, returns Self directly rather than
// Result, and used across 30+ call sites as NoteType::from_str(..).
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self { pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"todo" => NoteType::Todo, "todo" => NoteType::Todo,

View file

@ -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: {:?}",

View file

@ -279,15 +279,19 @@ fn resolved_ort_dylib_empty_returns_none() {
#[test] #[test]
fn resolved_ort_dylib_whitespace_only_returns_none() { fn resolved_ort_dylib_whitespace_only_returns_none() {
let mut m = ModelConfig::default(); let m = ModelConfig {
m.ort_dylib_path = " ".into(); ort_dylib_path: " ".into(),
..Default::default()
};
assert!(m.resolved_ort_dylib_path().is_none()); assert!(m.resolved_ort_dylib_path().is_none());
} }
#[test] #[test]
fn resolved_ort_dylib_set_returns_some() { fn resolved_ort_dylib_set_returns_some() {
let mut m = ModelConfig::default(); let m = ModelConfig {
m.ort_dylib_path = "/usr/lib/libonnxruntime.so".into(); ort_dylib_path: "/usr/lib/libonnxruntime.so".into(),
..Default::default()
};
assert_eq!( assert_eq!(
m.resolved_ort_dylib_path().unwrap().to_str().unwrap(), m.resolved_ort_dylib_path().unwrap().to_str().unwrap(),
"/usr/lib/libonnxruntime.so" "/usr/lib/libonnxruntime.so"

View file

@ -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();

View file

@ -389,7 +389,7 @@ fn cmd_show(index: usize, corpus_path: &Path, tier: &TierArg) -> Result<()> {
println!(); println!();
let sep = "".repeat(62); let sep = "".repeat(62);
println!("{:<14} {:<26} {}", "field", "expected", "actual"); println!("{:<14} {:<26} actual", "field", "expected");
println!("{sep}"); println!("{sep}");
println!( println!(
"{:<14} {:<26} {}", "{:<14} {:<26} {}",

View file

@ -11,6 +11,11 @@ path = "src/main.rs"
[dependencies] [dependencies]
breadpad-shared = { path = "../breadpad-shared" } breadpad-shared = { path = "../breadpad-shared" }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
# Bread event bus: emit after a real capture/reminder fire. Fail-silent if
# breadd is down. See EVENTS.md.
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
anyhow.workspace = true anyhow.workspace = true
ort.workspace = true ort.workspace = true
tracing.workspace = true tracing.workspace = true

83
breadpad/src/listen.rs Normal file
View 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);
}
}

View file

@ -3,16 +3,36 @@ use breadpad_shared::{
calendar::CalDavClient, calendar::CalDavClient,
classifier::Classifier, classifier::Classifier,
config::Config, config::Config,
parser::parse_rule_based,
scheduler::Scheduler, scheduler::Scheduler,
store::Store, store::Store,
types::{Note, NoteType}, types::{Note, NoteType},
}; };
use bread_utils::bread_client::BreadClient;
use gtk4::{glib, prelude::*}; use gtk4::{glib, prelude::*};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use std::cell::RefCell; 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;
/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. Events are `bread.pad.*`.
const APP_ID: &str = "pad";
/// `bread.pad.captured` after a successful quick-capture save. Fire-and-forget:
/// `BreadClient::emit` is a silent no-op when breadd is down or missing.
fn emit_captured(id: &str) {
BreadClient::connect(APP_ID).emit("bread.pad.captured", serde_json::json!({ "id": id }));
}
/// `bread.pad.reminder.due` when `breadpad fire <id>` actually shows a reminder
/// (the existing systemd-timer hook — not a new daemon). Same fail-silent emit.
fn emit_reminder_due(id: &str) {
BreadClient::connect(APP_ID).emit("bread.pad.reminder.due", serde_json::json!({ "id": id }));
}
static ORT_INIT: Once = Once::new(); static ORT_INIT: Once = Once::new();
fn init_ort_once(cfg: &Config) { fn init_ort_once(cfg: &Config) {
@ -31,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>,
@ -41,6 +64,32 @@ mod args {
pub model_info: bool, pub model_info: bool,
pub calendar_test: bool, pub calendar_test: bool,
pub calendar_list_uid: Option<String>, pub calendar_list_uid: Option<String>,
pub screenshot: Option<String>,
pub output: Option<String>,
pub width: u32,
pub height: u32,
pub listen: bool,
}
impl Args {
/// `None` for a normal run. Exits the process with an error if the
/// `--screenshot` / `--output` pair is incomplete, before any GTK
/// setup happens.
pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> {
if let Err(e) = validate_pair(
self.screenshot.as_deref(),
self.output.as_deref().map(Path::new),
) {
eprintln!("breadpad: {e}");
std::process::exit(1);
}
Some(crate::screenshot::ScreenshotRequest {
view: self.screenshot.clone()?,
output: self.output.clone()?.into(),
width: self.width,
height: self.height,
})
}
} }
pub fn parse() -> Args { pub fn parse() -> Args {
@ -53,6 +102,11 @@ mod args {
model_info: false, model_info: false,
calendar_test: false, calendar_test: false,
calendar_list_uid: None, calendar_list_uid: None,
screenshot: None,
output: None,
width: DEFAULT_WIDTH,
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;
@ -66,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();
@ -82,6 +137,26 @@ mod args {
_ => {} _ => {}
} }
} }
"--screenshot" => {
i += 1;
args.screenshot = raw.get(i).cloned();
}
"--output" => {
i += 1;
args.output = raw.get(i).cloned();
}
"--width" => {
i += 1;
if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) {
args.width = v;
}
}
"--height" => {
i += 1;
if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) {
args.height = v;
}
}
_ => {} _ => {}
} }
i += 1; i += 1;
@ -99,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 {
@ -120,7 +198,23 @@ fn main() -> Result<()> {
return cmd_calendar_list_uid(&note_id, &cfg); return cmd_calendar_list_uid(&note_id, &cfg);
} }
run_popup(args.note_type, args.no_classify, cfg) let screenshot_req = args.screenshot_request();
if let Some(req) = &screenshot_req {
if req.view == "reminder" || req.view == "reminder-snooze" {
// The real path (`fire <id>`, above) needs a real due note from
// the Store. A screenshot doesn't have one to work with — and
// shouldn't wait for one — so it builds a throwaway sample
// instead, never touching the Store at all.
let mut sample = Note::new(
"Sample reminder text".into(),
NoteType::from_str("reminder"),
None,
);
sample.time = Some(chrono::Utc::now());
return run_reminder_window(sample, &cfg, screenshot_req);
}
}
run_popup(args.note_type, args.no_classify, cfg, screenshot_req)
} }
fn cmd_status(cfg: &Config) -> Result<()> { fn cmd_status(cfg: &Config) -> Result<()> {
@ -265,6 +359,8 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
return Ok(()); return Ok(());
} }
emit_reminder_due(&note.id);
// Schedule next recurrence before showing UI // Schedule next recurrence before showing UI
if note.rrule.is_some() { if note.rrule.is_some() {
if let Some(next) = Scheduler::next_recurrence(&note, &cfg.reminders.default_morning) { if let Some(next) = Scheduler::next_recurrence(&note, &cfg.reminders.default_morning) {
@ -276,19 +372,28 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
} }
} }
run_reminder_window(note, cfg) run_reminder_window(note, cfg, None)
} }
fn run_reminder_window(note: breadpad_shared::types::Note, cfg: &Config) -> Result<()> { fn run_reminder_window(
let app = gtk4::Application::builder() note: breadpad_shared::types::Note,
.application_id("com.breadway.breadpad.reminder") cfg: &Config,
.build(); screenshot_req: Option<screenshot::ScreenshotRequest>,
) -> Result<()> {
let mut builder = gtk4::Application::builder().application_id("com.breadway.breadpad.reminder");
if screenshot_req.is_some() {
// Same reasoning as run_popup's NON_UNIQUE: a screenshot run must
// get its own fresh window, never activate a real reminder that
// happens to already be showing.
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
}
let app = builder.build();
let note = Arc::new(note); let note = Arc::new(note);
let cfg = Arc::new(cfg.clone()); let cfg = Arc::new(cfg.clone());
app.connect_activate(move |app| { app.connect_activate(move |app| {
build_reminder_window(app, note.clone(), cfg.clone()); build_reminder_window(app, note.clone(), cfg.clone(), screenshot_req.clone());
}); });
app.run_with_args::<String>(&[]); app.run_with_args::<String>(&[]);
@ -331,6 +436,7 @@ fn build_reminder_window(
app: &gtk4::Application, app: &gtk4::Application,
note: Arc<breadpad_shared::types::Note>, note: Arc<breadpad_shared::types::Note>,
cfg: Arc<Config>, cfg: Arc<Config>,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) { ) {
let window = gtk4::ApplicationWindow::builder() let window = gtk4::ApplicationWindow::builder()
.application(app) .application(app)
@ -345,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);
@ -392,7 +499,7 @@ fn build_reminder_window(
let local: chrono::DateTime<chrono::Local> = t.into(); let local: chrono::DateTime<chrono::Local> = t.into();
header.append( header.append(
&gtk4::Label::builder() &gtk4::Label::builder()
.label(&local.format("%H:%M").to_string()) .label(local.format("%H:%M").to_string())
.css_classes(["reminder-time"]) .css_classes(["reminder-time"])
.build(), .build(),
); );
@ -417,14 +524,15 @@ fn build_reminder_window(
.orientation(gtk4::Orientation::Horizontal) .orientation(gtk4::Orientation::Horizontal)
.build()); .build());
// Button row // Button row — same inset as the header/body zone above (20px), which
// used to be 16px here, visible as a step across the divider.
let btn_row = gtk4::Box::builder() let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal) .orientation(gtk4::Orientation::Horizontal)
.spacing(8) .spacing(8)
.margin_top(12) .margin_top(12)
.margin_bottom(12) .margin_bottom(12)
.margin_start(16) .margin_start(20)
.margin_end(16) .margin_end(20)
.build(); .build();
let dismiss_btn = gtk4::Button::builder() let dismiss_btn = gtk4::Button::builder()
@ -432,23 +540,35 @@ fn build_reminder_window(
.css_classes(["reminder-dismiss"]) .css_classes(["reminder-dismiss"])
.build(); .build();
// Snooze popover // Snooze popover. No arrow and a matching flat border (see
// popover.snooze-popover in the shared theme) so it reads as the same
// elevation language as the reminder card, instead of GTK's default
// arrow+drop-shadow chrome next to the card's flat 1px border.
let snooze_popover = gtk4::Popover::new(); let snooze_popover = gtk4::Popover::new();
snooze_popover.set_has_arrow(false);
snooze_popover.add_css_class("snooze-popover");
let snooze_vbox = gtk4::Box::builder() let snooze_vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical) .orientation(gtk4::Orientation::Vertical)
.spacing(4) .spacing(0)
.margin_top(8) .margin_top(4)
.margin_bottom(8) .margin_bottom(4)
.margin_start(8)
.margin_end(8)
.build(); .build();
// Left-aligned row: a bare Button::builder().label() centers its text,
// so each option gets an explicit xalign(0.0) label as its child and
// hexpand(true) so the row fills the popover's full width instead of
// shrinking to the longest label.
let snooze_option_row = |label: &str| {
gtk4::Button::builder()
.child(&gtk4::Label::builder().label(label).xalign(0.0).build())
.css_classes(["snooze-option"])
.hexpand(true)
.build()
};
for opt in &cfg.settings.snooze_options { for opt in &cfg.settings.snooze_options {
let label = humanize_snooze(opt).to_string(); let label = humanize_snooze(opt).to_string();
let btn = gtk4::Button::builder() let btn = snooze_option_row(&label);
.label(&label)
.css_classes(["snooze-option"])
.build();
let key = opt.clone(); let key = opt.clone();
let note_c = note.clone(); let note_c = note.clone();
let cfg_c = cfg.clone(); let cfg_c = cfg.clone();
@ -468,6 +588,47 @@ fn build_reminder_window(
}); });
snooze_vbox.append(&btn); snooze_vbox.append(&btn);
} }
// Custom… — reuses the same free-form time parsing breadman's dialogs
// already use, rather than inventing a separate time-picker widget.
let custom_entry = gtk4::Entry::builder()
.placeholder_text("tomorrow 9am / in 45 minutes")
.css_classes(["snooze-custom-entry"])
.visible(false)
.build();
{
let note_c = note.clone();
let cfg_c = cfg.clone();
let win_c = window.clone();
let popover_c = snooze_popover.clone();
let entry_c = custom_entry.clone();
custom_entry.connect_activate(move |_| {
let text = entry_c.text().to_string();
let parsed = parse_rule_based(&text, &cfg_c.reminders.default_morning);
if let Some(until) = parsed.time {
if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) {
let mut updated = note_c.as_ref().clone();
updated.snoozed_until = Some(until);
let _ = store.update_note(&updated);
let _ = Scheduler::schedule(&updated);
}
popover_c.popdown();
win_c.close();
}
});
}
let custom_btn = snooze_option_row("Custom\u{2026}");
{
let custom_entry_c = custom_entry.clone();
custom_btn.connect_clicked(move |btn| {
btn.set_visible(false);
custom_entry_c.set_visible(true);
custom_entry_c.grab_focus();
});
}
snooze_vbox.append(&custom_btn);
snooze_vbox.append(&custom_entry);
snooze_popover.set_child(Some(&snooze_vbox)); snooze_popover.set_child(Some(&snooze_vbox));
let snooze_btn = gtk4::MenuButton::builder() let snooze_btn = gtk4::MenuButton::builder()
@ -507,25 +668,48 @@ fn build_reminder_window(
outer.append(&btn_row); outer.append(&btn_row);
window.set_child(Some(&outer)); window.set_child(Some(&outer));
if let Some(req) = screenshot_req {
if req.view == "reminder-snooze" {
screenshot::capture_with_snooze_open(&window, &req, snooze_popover.clone());
} else {
screenshot::capture_window(&window, &req);
}
}
window.present(); window.present();
} }
fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Result<()> { fn run_popup(
preset_type: Option<String>,
no_classify: bool,
cfg: Config,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) -> Result<()> {
// Try to get current Hyprland workspace // Try to get current Hyprland workspace
let workspace = get_active_workspace(); let workspace = get_active_workspace();
let app = gtk4::Application::builder() let mut builder = gtk4::Application::builder().application_id("com.breadway.breadpad");
.application_id("com.breadway.breadpad") if screenshot_req.is_some() {
.build(); // GApplication is single-instance by default; this machine typically
// already has a real breadpad instance, so without this a
// screenshot run would just toggle-close the *existing* instance's
// window instead of starting a fresh one that ever sees
// `screenshot_req` (see the `app.windows().first()` toggle below).
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
}
let app = builder.build();
let cfg = Arc::new(cfg); let cfg = Arc::new(cfg);
app.connect_activate(move |app| { app.connect_activate(move |app| {
if screenshot_req.is_none() {
if let Some(win) = app.windows().first().cloned() { if let Some(win) = app.windows().first().cloned() {
win.close(); win.close();
return; return;
} }
build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify); }
build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify, screenshot_req.clone());
}); });
let code = app.run_with_args::<String>(&[]); let code = app.run_with_args::<String>(&[]);
@ -551,6 +735,7 @@ fn build_window(
workspace: Option<String>, workspace: Option<String>,
preset_type: Option<String>, preset_type: Option<String>,
no_classify: bool, no_classify: bool,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) { ) {
let window = gtk4::ApplicationWindow::builder() let window = gtk4::ApplicationWindow::builder()
.application(app) .application(app)
@ -569,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);
@ -636,10 +822,13 @@ fn build_window(
} }
} }
// Confirm button // No submit button — this popup is keyboard-driven (grabs focus on
let confirm_btn = gtk4::Button::builder() // open, Escape closes it) and a bare accent-teal checkmark used to sit
.label("") // next to the selected-type pill in the same accent teal, carrying two
.css_classes(["confirm-button"]) // different meanings in one colour. A hint is enough.
let enter_hint = gtk4::Label::builder()
.label("Press Enter to add")
.css_classes(["dim-label"])
.build(); .build();
let bottom_row = gtk4::Box::builder() let bottom_row = gtk4::Box::builder()
@ -650,7 +839,7 @@ fn build_window(
let spacer = gtk4::Box::builder().hexpand(true).build(); let spacer = gtk4::Box::builder().hexpand(true).build();
bottom_row.append(&spacer); bottom_row.append(&spacer);
bottom_row.append(&confirm_btn); bottom_row.append(&enter_hint);
vbox.append(&entry); vbox.append(&entry);
vbox.append(&bottom_row); vbox.append(&bottom_row);
@ -686,12 +875,6 @@ fn build_window(
} }
}; };
// Confirm button click
{
let save = save_and_close.clone();
confirm_btn.connect_clicked(move |_| save());
}
// Entry activate (Enter key) // Entry activate (Enter key)
{ {
let save = save_and_close.clone(); let save = save_and_close.clone();
@ -710,6 +893,10 @@ fn build_window(
}); });
window.add_controller(key_ctrl); window.add_controller(key_ctrl);
if let Some(req) = screenshot_req {
screenshot::dispatch(&window, req);
}
window.present(); window.present();
entry.grab_focus(); entry.grab_focus();
} }
@ -756,6 +943,7 @@ fn save_note_classified(
tracing::error!("failed to save note: {}", e); tracing::error!("failed to save note: {}", e);
return; return;
} }
emit_captured(&note.id);
if note.time.is_some() { if note.time.is_some() {
if let Err(e) = Scheduler::schedule(&note) { if let Err(e) = Scheduler::schedule(&note) {
tracing::warn!("failed to schedule reminder: {}", e); tracing::warn!("failed to schedule reminder: {}", e);

110
breadpad/src/screenshot.rs Normal file
View file

@ -0,0 +1,110 @@
//! `--screenshot` CLI mode: render breadpad's compose popup, capture it via
//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s
//! `bread-capture` orchestrator, or run standalone for one-off captures.
//!
//! No clap here (unlike breadbar/breadbox/breadclip/breadsearch): breadpad
//! already has its own small hand-rolled flag parser (`mod args`) covering
//! `--type`/`--no-classify`/`--status`/`fire`/`calendar`/etc, and clap's
//! default "reject unknown flags" behavior would break every one of those
//! if bolted on as a second, separate parser. `--screenshot`/`--output`/
//! `--width`/`--height` are just three more fields on that same `Args`
//! struct instead.
//!
//! Three views: "popup" (the compose window from `run_popup`), "reminder"
//! (the alert window from `run_reminder_window`/`build_reminder_window`,
//! normally only reachable via a real due note through `fire <id>`, built
//! here against a fabricated sample `Note` instead — see `main`'s
//! `screenshot_req.view == "reminder"` branch, which skips the Store lookup
//! entirely), and "reminder-snooze" (the same window with its snooze
//! popover open).
use bread_utils::screenshot_cli::SETTLE_DELAY;
use gtk4::prelude::*;
use std::path::PathBuf;
use std::time::Duration;
/// 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
/// to settle first.
const PRE_POPUP_DELAY: Duration = SETTLE_DELAY;
#[derive(Clone)]
pub struct ScreenshotRequest {
pub view: String,
pub output: PathBuf,
pub width: u32,
pub height: u32,
}
/// Wire up the given view's screenshot sequence against an already-built,
/// not-yet-presented window. Every path here ends by exiting the process —
/// it never returns control to the normal popup UI.
pub fn dispatch(window: &gtk4::ApplicationWindow, req: ScreenshotRequest) {
match req.view.as_str() {
"popup" => {
let output = req.output;
let (width, height) = (req.width as i32, req.height as i32);
window.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
}
other => {
eprintln!("breadpad: unknown screenshot view '{other}' (known: popup, reminder, reminder-snooze)");
std::process::exit(1);
}
}
}
/// Same shape as `dispatch`'s "popup" arm, for the reminder window itself
/// (view "reminder") — pulled out since `build_reminder_window` calls this
/// directly rather than going through `dispatch` (the reminder window is
/// built via a completely separate `run_reminder_window` entry point, not
/// `run_popup`'s).
pub fn capture_window(window: &gtk4::ApplicationWindow, req: &ScreenshotRequest) {
let output = req.output.clone();
let (width, height) = (req.width as i32, req.height as i32);
window.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
}
/// View "reminder-snooze": force the snooze popover open shortly after the
/// window maps, then capture once *it* maps.
pub fn capture_with_snooze_open(
window: &gtk4::ApplicationWindow,
req: &ScreenshotRequest,
snooze_popover: gtk4::Popover,
) {
let output = req.output.clone();
let (width, height) = (req.width as i32, req.height as i32);
let popover_to_open = snooze_popover.clone();
window.connect_map(move |_| {
popover_to_open.set_autohide(false);
let popover_to_open = popover_to_open.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
popover_to_open.popup();
});
});
snooze_popover.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
}
fn finish(result: anyhow::Result<()>) {
match result {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("breadpad: screenshot capture failed: {e}");
std::process::exit(1);
}
}
}

1
ci/bread-ecosystem.rev Normal file
View file

@ -0,0 +1 @@
620c5a1317a6b57276eabca961facdb78bf510db

21
ci/build.sh Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
# files now affect every product's release pipeline, so bumping the pin
# is a deliberate act instead of silent drift (see the bread-theme test
# that broke here for exactly that reason, before it was pinned by rev).
#
# Usage: ci/build.sh cargo build --release --locked
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
if [ ! -d "$CACHE_DIR" ]; then
rm -rf /tmp/bread-ecosystem-ci-*
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
git -C "$CACHE_DIR" checkout --quiet "$REV"
fi
bash "${CACHE_DIR}/ci/build.sh" breadpad "$ROOT" "$@"

View file

@ -1,38 +0,0 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=breadpad
pkgver=0.3.1
pkgrel=1
pkgdesc="Quick-capture scratchpad and note viewer with AI classification"
arch=('x86_64')
url="https://github.com/Breadway/breadpad"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
# causing undefined-symbol errors. Disable LTO.
options=(!lto !debug)
depends=('gtk4' 'gtk4-layer-shell')
optdepends=(
'ollama: local AI note classification'
'hyprland: scratchpad window integration'
)
makedepends=('rust' 'cargo')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked
}
check() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo test --release --locked --workspace
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/breadpad "${pkgdir}/usr/bin/breadpad"
install -Dm755 target/release/breadman "${pkgdir}/usr/bin/breadman"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}