Compare commits

..

9 commits
main ... v0.5.0

Author SHA1 Message Date
Breadway
710cb768ea Switch to tag-pinned bread-ecosystem deps; bump version to v0.5.0
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
release / build (push) Successful in 2m51s
Build and publish package / package (push) Successful in 3m30s
2026-07-19 03:52:59 +08:00
Breadway
6a06872f09 Fix silent ROCm-to-CPU fallback: switch to MIGraphX; fix XDG tilde bugs
This is the actual bug the night's ecosystem-utils audit was looking for.
classifier.rs::try_load_session requested ort::ep::ROCm (the classic
ROCMExecutionProvider) first. Per this machine's own breadsearch-gpu-
backends operator notes, that EP silently no-ops on this class of system:
distro ROCm onnxruntime builds (Arch's onnxruntime-rocm) are commonly
compiled with --use_migraphx, not --use_rocm, so ROCMExecutionProvider
never actually registers — active_provider could report "ROCm (iGPU)"
while every real inference secretly ran on CPU, with nothing surfacing
that fact anywhere. Switched to ort::ep::MIGraphX via
bread_onnx::build_session (path dependency for now, see the TODO in
Cargo.toml), matching breadsearch's own already-correct embed.rs.

Also fixed the same literal-tilde XDG fallback bug found across this
pass (breadclip-core, breadmon, breadarr-shared) in three more places:
classifier.rs::model_dir, config.rs::config_path, config.rs::style_css_path.

Bumped the workspace's tokenizers pin 0.21 -> 0.23 to unify with
bread-onnx's own requirement (breadarr already pins 0.23); verified via a
full workspace build + test pass, no API changes needed at any call site.

Validation, and an important finding: breadpad-shared's full test suite
(unit: 181/181, config: 26/26, classifier integration: 15/15) passes
clean. The pipeline.rs integration suite (16 tests, each building a real
classifier session) surfaced ONE genuine, 100%-reproducible failure:
plain_note_appears_in_store expects "retro went well today" to classify
as Note, but MIGraphX execution classifies it as Question (CPU execution
returns Note). This is not a regression from this change — it's proof the
fix works: the GPU path was never actually running before, so this
CPU-vs-GPU floating-point divergence on a borderline NLI classification
was always latent and simply never observable. Left the test as-is (its
failure now accurately reflects reality) rather than "fixing" it by
reverting to the broken EP or silently forcing a test-only CPU path —
that's a product/test-fixture decision for the owner, not something a
duplication-extraction pass should decide unilaterally. See bread-onnx's
companion fix (ORT_MIGRAPHX_MODEL_CACHE_PATH default) which cut this
suite's wall time from 905s to 112s by letting compiled kernels persist
across runs.
2026-07-17 13:53:00 +08:00
Breadway
369935515b breadpad-shared: fix stale test expectation from the bread-theme v0.2.10 bump
css_defines_bg_color still expected the pre-v0.2.10 Catppuccin Mocha
default (#1e1e2e). bread-theme v0.2.10 deliberately fixed background to
#0c0c0c (BOS's own dark theme, no longer pywal-derived — see
FIXED_BACKGROUND's doc comment in bread-theme's palette.rs) so a light or
muddy wallpaper can't wash out every bread app's background. The product
code was already correct; only this assertion was never updated for the
bump, so it started failing the moment breadpad's Cargo.toml pin moved to
v0.2.10 (confirmed via git stash: it fails identically on the original
commit).
2026-07-17 08:32:17 +08:00
Breadway
6e5448e853 breadpad: save note before window can close, add network timeouts, lock the JSONL store, fix monthly recurrence, restrict config permissions
- main.rs: hold the gtk4::Application (RAII guard from app.hold()) across
  the idle_add_local_once save callback, so closing the capture window
  can no longer let the process quit before the note is actually written
  to disk
- breadpad-shared/src/ai.rs: 10s timeout on the Ollama HTTP call (ureq had
  none, so a hung local endpoint could stall indefinitely)
- breadpad-shared/src/calendar.rs: 15s timeout on the CalDAV reqwest
  client (applies to every CalDAV request made through it)
- breadpad-shared/src/store.rs: exclusive flock (std::fs::File::lock, no
  new dependency needed at this Rust version) on a sidecar lock file
  around every read-modify-write span, guarding against breadpad/breadman/
  reminder-fire processes racing each other on notes.jsonl
- breadpad-shared/src/scheduler.rs: parse_next_from_rrule now handles
  FREQ=MONTHLY (previously fell into the catch-all None arm, so a monthly
  reminder fired once and never rescheduled); reachable today via
  breadman's free-text RRULE editor field
- breadpad-shared/src/config.rs: Config::save() chmods breadpad.toml to
  0600 after writing, since it can hold the CalDAV password in plaintext

Pre-existing, unrelated test failure noted: theme::tests::css_defines_bg_color
fails identically on the original commit (depends on this machine's live
pywal cache) — confirmed via git stash, not touched.
2026-07-17 08:28:19 +08:00
Breadway
b7aed8a37c breadpad: bump version to 0.4.1
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
release / build (push) Successful in 2m54s
Build and publish package / package (push) Successful in 3m42s
2026-07-16 18:04:39 +08:00
Breadway
7394e65da5 breadpad: bump bread-theme to v0.2.10 (fixes pywal-colored window background) 2026-07-16 17:52:05 +08:00
Breadway
6fea1af544 Release 0.4.0: GUI design pass + td:/rem:/note:/q: prefix grammar
All checks were successful
Mirror to GitHub / mirror (push) Successful in 1s
release / build (push) Successful in 2m10s
Build and publish package / package (push) Successful in 3m8s
Unifies the "selected" visual language across breadman's sidebar and
breadpad's type chips (ghost default, solid accent when active), gives
note cards a real surface instead of just a left border, caps the note
list width so type chips and action buttons sit next to the title they
belong to, and swaps the sidebar's full-color emoji for theme-safe
monochrome glyphs plus per-type colored dots.

breadman also gets live per-row sidebar counts, a note-count footer,
relative timestamps with absolute-time tooltips, and a de-emphasized
Errors row.

breadpad's popup gets an explicit short-prefix grammar (td:, rem:,
idea:, note:, q:) that live-highlights the matching chip as you type,
a keyboard-shortcut hint row, and a confirm button that's visually
separated from the chip row instead of reading as a sixth pill.
2026-07-04 10:53:52 +08:00
Breadway
b828f8ec79 CI: migrate release workflow from GitHub Actions to Forgejo Actions
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
GitHub Actions self-hosted runners need per-repo registration on a
personal account; Forgejo Actions' runner already serves every repo
with zero setup. Moves release publishing there (dl.breadway.dev stays
the primary bakery target; GitHub release upload is kept as the
fallback via an explicit token, since Forgejo Actions has no ambient
GITHUB_TOKEN) and adds a mirror workflow to keep GitHub in sync
automatically.
2026-07-03 14:10:07 +08:00
Breadway
df42aba1d3 CI: use /tmp for ecosystem clone, avoid permissions conflict
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
2026-06-19 08:38:40 +08:00
46 changed files with 1842 additions and 2515 deletions

View file

@ -1,24 +0,0 @@
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

@ -1,67 +0,0 @@
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

@ -0,0 +1,19 @@
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
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

@ -0,0 +1,40 @@
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

@ -1,51 +0,0 @@
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

@ -6,7 +6,6 @@ on:
jobs:
build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
@ -17,16 +16,7 @@ jobs:
"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
}
run: cd src && cargo build --release --locked
- name: prepare artifacts
run: |
@ -41,19 +31,12 @@ jobs:
> "${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

View file

@ -1,66 +0,0 @@
name: release
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
DL_DIR: /srv/breadway-dl
ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- uses: actions/checkout@v4
- name: install build deps
run: sudo apt-get install -y libgtk-4-dev libdbus-1-dev pkg-config 2>/dev/null || true
- name: build
run: cargo build --release --locked
- name: prepare artifacts
run: |
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/breadpad/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadpad breadman; do
cp "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 breadpad.example.toml "${PKG_DIR}/"
cp bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "${DL_DIR}/breadpad/latest"
- name: ensure bread-ecosystem
run: |
if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then
git -C "${ECOSYSTEM_DIR}" pull --ff-only
else
mkdir -p "$(dirname "${ECOSYSTEM_DIR}")"
git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}"
fi
- name: regenerate index.json
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh"
- name: upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/breadpad/${VERSION}"
gh release create "${GITHUB_REF_NAME}" \
--title "breadpad v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" \
"${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,6 +27,3 @@ svgs.txt
# Rust/Cargo
Cargo.lock
dist/
# Local knowledge-graph cache
graphify-out/

View file

@ -1,37 +0,0 @@
# 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.

View file

@ -1,97 +0,0 @@
# 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.

548
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.5.3"
version = "0.5.0"
edition = "2021"
license = "MIT"
authors = ["Breadway"]
@ -24,9 +24,12 @@ chrono = { version = "0.4", features = ["serde"] }
rrule = "0.12"
tokio = { version = "1", features = ["full"] }
zbus = { version = "4", default-features = false, features = ["tokio"] }
# WHY: bread-onnx's Provider enum references every EP type, so those ort
# features must be on in the consumer even if breadpad only requests MIGraphX.
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing", "api-24", "migraphx", "cuda", "openvino", "vitis", "load-dynamic"] }
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] }
ndarray = "0.16"
# Bumped 0.21 -> 0.23 to unify with bread-onnx's own tokenizers requirement
# (breadarr already pins 0.23) — the APIs this crate actually calls
# (Tokenizer::from_file, encode, get_ids/get_attention_mask) are unchanged
# between the two; verified via a full workspace build + test pass.
tokenizers = { version = "0.23", default-features = false, features = ["http", "fancy-regex"] }
gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8"

View file

@ -1,70 +0,0 @@
# 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
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.
Every note passes through a three-tier pipeline at capture time:
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 (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 (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.
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.
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.
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.
Manual override always available — the AI-assigned type is shown as a chip you can tap to change before saving.
@ -56,7 +56,7 @@ User-defined tags can be added freely on top of the built-in types.
- **One-off reminders** — natural language time ("at 7pm", "in 30 minutes", "tomorrow morning") parsed at classification time; scheduled via a systemd user timer
- **Recurring reminders** — "every Sunday at 9pm", "every weekday morning" — stored as an iCal-compatible RRULE and re-scheduled on each trigger
- **Snooze** — notification popup includes snooze actions: 15 min / 1 hour / tomorrow morning / custom; snoozing reschedules the timer without touching the original note
- **Snooze** — notification popup includes snooze actions drawn from `snooze_options` (default: 15 min / 1 hour / tomorrow morning); snoozing reschedules the timer without touching the original note
- **Missed reminders** — if the system was off or suspended at the scheduled time, the reminder fires on next login
### Viewer (`breadman`)
@ -104,11 +104,11 @@ Always runs. Handles:
Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped.
#### Tier 2 — Small local ONNX model (optional, not shipped)
#### Tier 2 — Small local ONNX model
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.
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.
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.
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`.
#### 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
```
**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.
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.
```bash
breadpad model-info # shows active EP and model path
@ -138,8 +138,8 @@ breadpad model-info # shows active EP and model path
- D-Bus session bus (for notifications)
- systemd user session (for timer-backed reminders)
- Rust 1.80+
- **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 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 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (e.g. `ollama pull fastflowlm`). Tier 3 is silently skipped if Ollama is not running.
---
@ -151,14 +151,10 @@ cd breadpad
cargo build --release
cp target/release/breadpad ~/.local/bin/
cp target/release/breadman ~/.local/bin/
```
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
# Place your ONNX classifier and tokenizer in the model directory
mkdir -p ~/.local/share/breadpad/model
# Drop your own classifier.onnx + tokenizer.json in that directory, then
# set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so
# Then set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so
```
On Arch Linux, install GTK4 dependencies first:
@ -187,7 +183,7 @@ ort_dylib_path = "" # optional: explicit path to libonnxruntime.so;
[model.ollama]
endpoint = "http://localhost:11434"
model = "llama3.2:3b" # any model you have pulled in Ollama
model = "fastflowlm" # any model you have pulled in Ollama
confidence_threshold = 0.6 # Tier 2 scores below this trigger Tier 3
enabled = true # set false to never call Ollama
@ -247,12 +243,15 @@ breadpad --no-classify
# Show model and storage status
breadpad --status
# Print expected model paths (does not download automatically)
breadpad download-model
```
Hyprland keybind (BOS default is Super+U; bind whatever you want):
Hyprland keybind:
```
bind = $mainMod, U, exec, breadpad
bind = $mainMod, N, exec, breadpad
```
### breadman (viewer)

View file

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

View file

@ -11,13 +11,6 @@ path = "src/main.rs"
[dependencies]
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
tracing.workspace = true
tracing-subscriber.workspace = true

View file

@ -1,8 +1,3 @@
//! 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::{
parser::parse_rule_based,
scheduler::Scheduler,
@ -11,73 +6,48 @@ use breadpad_shared::{
};
use chrono::{Local, TimeZone, Utc};
use gtk4::{glib, prelude::*};
use libadwaita::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
/// 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(
pub fn build_editor_popover(
note: &Note,
store: Arc<Store>,
morning: String,
on_save: Rc<dyn Fn(Note)>,
on_delete: Rc<dyn Fn()>,
on_error: Rc<dyn Fn(String)>,
) -> libadwaita::Dialog {
let dialog = libadwaita::Dialog::builder()
.title("Edit Note")
.content_width(480)
.content_height(520)
.build();
) -> gtk4::Popover {
let popover = gtk4::Popover::new();
popover.set_has_arrow(false);
let header = libadwaita::HeaderBar::new();
let toolbar_view = libadwaita::ToolbarView::new();
toolbar_view.add_top_bar(&header);
let content = gtk4::Box::builder()
let vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(16)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.spacing(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.width_request(420)
.build();
let group = libadwaita::PreferencesGroup::builder().title("Details").build();
vbox.append(&gtk4::Label::builder().label("Body").xalign(0.0).build());
let body_entry = gtk4::Entry::builder()
.text(&note.body)
.hexpand(true)
.build();
vbox.append(&body_entry);
let body_row = libadwaita::EntryRow::builder().title("Body").build();
body_row.set_text(&note.body);
group.add(&body_row);
let type_row = libadwaita::ActionRow::builder().title("Type").build();
let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(note.note_type.as_str().to_string()));
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin().iter().map(|&name| (bread_theme::gtk::chip(name), name)).collect();
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("Type").xalign(0.0).build());
let type_combo = gtk4::DropDown::from_strings(NoteType::all_builtin());
let current_idx = NoteType::all_builtin()
.iter()
.position(|&s| s == note.note_type.as_str())
.unwrap_or(3) as u32;
type_combo.set_selected(current_idx);
vbox.append(&type_combo);
vbox.append(&gtk4::Label::builder().label("Time").xalign(0.0).build());
let time_text = note
.time
.map(|t| {
@ -85,44 +55,38 @@ pub fn open_editor(
local.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or_default();
let time_row = libadwaita::EntryRow::builder().title("Time").build();
time_row.set_text(&time_text);
// EntryRow has no placeholder-text property of its own (unlike GtkEntry) -
// the title already communicates the field, so the example format goes in
// the group description instead of a placeholder that would otherwise
// vanish behind the title when empty.
group.add(&time_row);
let rrule_row = libadwaita::EntryRow::builder().title("Recurrence").build();
rrule_row.set_text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""));
group.add(&rrule_row);
content.append(&group);
let hint = gtk4::Label::builder()
.label(format!("Time: {TIME_PLACEHOLDER}\nRecurrence: {RRULE_PLACEHOLDER}"))
.css_classes(["dim-label"])
.xalign(0.0)
.wrap(true)
let time_entry = gtk4::Entry::builder()
.text(&time_text)
.placeholder_text("YYYY-MM-DD HH:MM or tomorrow 9am (blank = no time)")
.hexpand(true)
.build();
content.append(&hint);
vbox.append(&time_entry);
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();
vbox.append(&gtk4::Label::builder().label("Recurrence").xalign(0.0).build());
let rrule_entry = gtk4::Entry::builder()
.text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""))
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO (blank = none)")
.build();
vbox.append(&rrule_entry);
// Button row: [Delete] [Save]
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let delete_btn = gtk4::Button::builder()
.label("🗑 Delete")
.css_classes(["danger-btn"])
.build();
let save_btn = gtk4::Button::builder()
.label("Save")
.css_classes(["confirm-button"])
.hexpand(true)
.build();
btn_row.append(&delete_btn);
btn_row.append(&save_btn);
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));
vbox.append(&btn_row);
// Delete: two-click confirm
let confirming = Rc::new(RefCell::new(false));
@ -131,7 +95,7 @@ pub fn open_editor(
let delete_btn_label = delete_btn.clone();
let note_id = note.id.clone();
let store_del = store.clone();
let dialog_del = dialog.clone();
let popover_del = popover.clone();
let on_delete = Rc::clone(&on_delete);
let on_error = Rc::clone(&on_error);
@ -141,7 +105,7 @@ pub fn open_editor(
let id = note_id.clone();
let on_delete = Rc::clone(&on_delete);
let on_error = Rc::clone(&on_error);
let dialog = dialog_del.clone();
let popover = popover_del.clone();
spawn_bg(
move || -> anyhow::Result<()> {
store.delete_note(&id)?;
@ -155,7 +119,7 @@ pub fn open_editor(
Ok(()) => on_delete(),
Err(e) => on_error(format!("delete failed: {}", e)),
}
dialog.close();
popover.popdown();
},
);
} else {
@ -168,20 +132,33 @@ pub fn open_editor(
// Save
{
let note_clone = note.clone();
let dialog_save = dialog.clone();
let popover_save = popover.clone();
let on_error = Rc::clone(&on_error);
let selected_type = selected_type.clone();
save_btn.connect_clicked(move |_| {
// Read all field values on the main thread before handing off.
let mut updated = note_clone.clone();
updated.body = body_row.text().to_string();
updated.note_type = NoteType::from_str(&selected_type.borrow());
let time_str = time_row.text().to_string();
updated.time = if time_str.trim().is_empty() { None } else { parse_time_field(&time_str, &morning) };
let rrule_text = rrule_row.text().to_string();
updated.rrule = if rrule_text.trim().is_empty() { None } else { Some(RecurrenceRule::new(rrule_text)) };
updated.body = body_entry.text().to_string();
updated.note_type = NoteType::from_str(
NoteType::all_builtin()
.get(type_combo.selected() as usize)
.copied()
.unwrap_or("note"),
);
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))
};
dialog_save.close();
popover_save.popdown();
let store_bg = store.clone();
let on_save = Rc::clone(&on_save);
@ -205,7 +182,8 @@ pub fn open_editor(
});
}
dialog
popover.set_child(Some(&vbox));
popover
}
fn spawn_bg<F, T, C>(work: F, then: C)

View file

@ -6,52 +6,23 @@ use breadpad_shared::{
store::Store,
types::{Note, NoteType, RecurrenceRule},
};
use chrono::Local;
use chrono::{DateTime, Local, Utc};
use gtk4::{glib, prelude::*};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
mod editor;
mod screenshot;
mod views;
// ── Args ─────────────────────────────────────────────────────────────────────
mod args {
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH};
use std::path::Path;
#[derive(Debug)]
pub struct Args {
pub view: Option<String>,
pub done_id: Option<String>,
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 {
@ -59,10 +30,6 @@ mod args {
view: None,
done_id: None,
upcoming_plain: false,
screenshot: None,
output: None,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
};
let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
@ -83,26 +50,6 @@ mod args {
}
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;
@ -113,22 +60,30 @@ mod args {
// ── AppState ──────────────────────────────────────────────────────────────────
type ErrorLog = Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>;
/// Shared UI state, cheap to clone (all fields are Rc/Arc).
#[derive(Clone)]
struct AppState {
store: Arc<Store>,
notes: Rc<RefCell<Vec<Note>>>,
cfg: Rc<RefCell<Config>>,
errors: ErrorLog,
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
active_view: Rc<RefCell<String>>,
stack: gtk4::Stack,
window: gtk4::ApplicationWindow,
/// Sidebar row id ("all", "upcoming", "archive", or a note type name) ->
/// its count `Label`, so counts can be refreshed in place after every
/// `rebuild_stack` without rebuilding the sidebar itself.
sidebar_counts: Rc<RefCell<Vec<(String, gtk4::Label)>>>,
status_label: gtk4::Label,
}
impl AppState {
fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack, window: gtk4::ApplicationWindow) -> Self {
fn new(
store: Arc<Store>,
notes: Vec<Note>,
cfg: Config,
stack: gtk4::Stack,
status_label: gtk4::Label,
) -> Self {
AppState {
store,
notes: Rc::new(RefCell::new(notes)),
@ -136,7 +91,8 @@ impl AppState {
errors: Rc::new(RefCell::new(Vec::new())),
active_view: Rc::new(RefCell::new("all".to_string())),
stack,
window,
sidebar_counts: Rc::new(RefCell::new(Vec::new())),
status_label,
}
}
@ -202,7 +158,7 @@ fn rebuild_all_view(notes: &[Note], state: &AppState) {
if let Some(child) = state.stack.child_by_name("all") {
state.stack.remove(&child);
}
let scroll = build_note_list(notes, state.clone(), true, "No notes yet — jot something down to get started.", Some(NoteType::Note));
let scroll = build_note_list(notes, state.clone());
state.stack.add_named(&scroll, Some("all"));
}
@ -216,22 +172,14 @@ fn rebuild_stack(state: &AppState) {
let errors: Vec<_> = state.errors.borrow().clone();
// All
let all_scroll = build_note_list(&notes, state.clone(), true, "No notes yet — jot something down to get started.", Some(NoteType::Note));
let all_scroll = build_note_list(&notes, state.clone());
state.stack.add_named(&all_scroll, Some("all"));
// Upcoming
let upcoming = views::upcoming::build(&notes, state.clone());
let upcoming = views::upcoming::build(&notes);
state.stack.add_named(&upcoming, Some("upcoming"));
// 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() {
let nt = NoteType::from_str(type_name);
let filtered: Vec<Note> = notes
@ -239,7 +187,7 @@ fn rebuild_stack(state: &AppState) {
.filter(|n| n.note_type == nt && !n.done)
.cloned()
.collect();
let scroll = build_note_list(&filtered, state.clone(), false, empty_text(type_name), Some(nt));
let scroll = build_note_list(&filtered, state.clone());
state.stack.add_named(&scroll, Some(type_name));
}
@ -257,6 +205,40 @@ fn rebuild_stack(state: &AppState) {
// Errors
let errors_view = views::errors::build(&errors);
state.stack.add_named(&errors_view, Some("errors"));
update_counts_and_status(state);
}
/// Refreshes the sidebar's per-row counts and the content pane's footer
/// note count from the current `state.notes`. Cheap enough to call on every
/// rebuild — five type counts plus all/upcoming/archive over a note list
/// that in practice stays small.
fn update_counts_and_status(state: &AppState) {
let notes = state.notes.borrow();
let total = notes.iter().filter(|n| !n.done).count();
state
.status_label
.set_label(&format!("{total} note{}", if total == 1 { "" } else { "s" }));
for (key, label) in state.sidebar_counts.borrow().iter() {
let n = match key.as_str() {
"all" => total,
"upcoming" => notes
.iter()
.filter(|n| {
!n.done
&& matches!(n.note_type, NoteType::Reminder | NoteType::Todo)
&& n.effective_time().is_some()
})
.count(),
"archive" => notes.iter().filter(|n| n.done).count(),
other => {
let nt = NoteType::from_str(other);
notes.iter().filter(|n| !n.done && n.note_type == nt).count()
}
};
label.set_label(&n.to_string());
}
}
// ── main ─────────────────────────────────────────────────────────────────────
@ -279,9 +261,7 @@ fn main() -> Result<()> {
return cmd_upcoming_plain();
}
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)
run_app(args.view, cfg)
}
fn cmd_done(id: &str) -> Result<()> {
@ -317,20 +297,10 @@ fn cmd_upcoming_plain() -> Result<()> {
Ok(())
}
fn run_app(
initial_view: Option<String>,
cfg: Config,
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();
fn run_app(initial_view: Option<String>, cfg: Config) -> Result<()> {
let app = gtk4::Application::builder()
.application_id("com.breadway.breadman")
.build();
let cfg = Arc::new(cfg);
let initial_view = Arc::new(initial_view);
@ -338,7 +308,7 @@ fn run_app(
app.connect_activate(move |app| {
let cfg = cfg.as_ref().clone();
let initial_view = initial_view.as_deref().map(|s| s.to_string());
if let Err(e) = build_app_window(app, cfg, initial_view, screenshot_req.clone()) {
if let Err(e) = build_app_window(app, cfg, initial_view) {
tracing::error!("failed to build window: {}", e);
}
});
@ -356,13 +326,8 @@ fn build_app_window(
app: &gtk4::Application,
cfg: Config,
initial_view: Option<String>,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) -> Result<()> {
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 notes = store.load_all()?;
@ -373,7 +338,6 @@ fn build_app_window(
.default_width(960)
.default_height(640)
.build();
bread_theme::gtk::bind_window_auto(&window);
let hbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
@ -414,11 +378,12 @@ fn build_app_window(
));
row
};
// 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| {
// Returns the row plus its count `Label` when `counted` is set — callers
// collect these so counts can be kept live from `AppState.sidebar_counts`
// (monochrome geometric icons + a colored dot per type instead of the
// old full-color emoji, which always render in fixed colors no matter
// what the pywal palette says).
let make_item = |id: &str, icon: &str, icon_class: Option<&str>, label: &str, counted: bool| {
let row = gtk4::ListBoxRow::builder()
.css_classes(["sidebar-row"])
.build();
@ -427,7 +392,15 @@ fn build_app_window(
.orientation(gtk4::Orientation::Horizontal)
.spacing(10)
.build();
hbox.append(&gtk4::Image::builder().icon_name(icon_name).pixel_size(16).build());
let icon_label = gtk4::Label::builder()
.label(icon)
.width_chars(2)
.xalign(0.5)
.build();
if let Some(class) = icon_class {
icon_label.add_css_class(class);
}
hbox.append(&icon_label);
hbox.append(
&gtk4::Label::builder()
.label(label)
@ -435,23 +408,62 @@ fn build_app_window(
.hexpand(true)
.build(),
);
let count_label = if counted {
let l = gtk4::Label::builder()
.label("")
.css_classes(["sidebar-count"])
.build();
hbox.append(&l);
Some(l)
} else {
None
};
row.set_child(Some(&hbox));
row
(row, count_label)
};
let mut sidebar_counts: Vec<(String, gtk4::Label)> = Vec::new();
sidebar_list.append(&make_section("VIEWS"));
sidebar_list.append(&make_item("all", "view-list-symbolic", "All"));
sidebar_list.append(&make_item("upcoming", "x-office-calendar-symbolic", "Upcoming"));
{
let (row, count) = make_item("all", "", None, "All", true);
sidebar_counts.push(("all".into(), count.unwrap()));
sidebar_list.append(&row);
}
{
let (row, count) = make_item("upcoming", "", None, "Upcoming", true);
sidebar_counts.push(("upcoming".into(), count.unwrap()));
sidebar_list.append(&row);
}
sidebar_list.append(&make_section("TYPES"));
sidebar_list.append(&make_item("todo", "task-due-symbolic", "Todo"));
sidebar_list.append(&make_item("reminder", "appointment-soon-symbolic", "Reminder"));
sidebar_list.append(&make_item("idea", "emblem-important-symbolic", "Idea"));
sidebar_list.append(&make_item("note", "text-x-generic-symbolic", "Note"));
sidebar_list.append(&make_item("question", "dialog-question-symbolic", "Question"));
for (id, icon_class, label) in [
("todo", "icon-todo", "Todo"),
("reminder", "icon-reminder", "Reminder"),
("idea", "icon-idea", "Idea"),
("note", "icon-note", "Note"),
("question", "icon-question", "Question"),
] {
let (row, count) = make_item(id, "", Some(icon_class), label, true);
sidebar_counts.push((id.to_string(), count.unwrap()));
sidebar_list.append(&row);
}
sidebar_list.append(&make_section("MORE"));
sidebar_list.append(&make_item("archive", "folder-symbolic", "Archive"));
sidebar_list.append(&make_item("settings", "preferences-system-symbolic", "Settings"));
sidebar_list.append(&make_item("errors", "dialog-warning-symbolic", "Errors"));
{
let (row, count) = make_item("archive", "", None, "Archive", true);
sidebar_counts.push(("archive".into(), count.unwrap()));
sidebar_list.append(&row);
}
{
let (row, _) = make_item("settings", "", None, "Settings", false);
sidebar_list.append(&row);
}
{
// De-emphasized: session-only debug info, not part of the daily
// triage flow the rest of the sidebar serves.
let (row, _) = make_item("errors", "", None, "Errors", false);
row.add_css_class("sidebar-row-minor");
sidebar_list.append(&row);
}
sidebar_vbox.append(&sidebar_list);
// ── Content area ──────────────────────────────────────────────
@ -471,8 +483,19 @@ fn build_app_window(
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build();
let status_label = gtk4::Label::builder()
.label("0 notes")
.css_classes(["dim-label"])
.xalign(0.0)
.margin_start(12)
.margin_end(12)
.margin_top(6)
.margin_bottom(8)
.build();
content_vbox.append(&search_entry);
content_vbox.append(&stack);
content_vbox.append(&status_label);
hbox.append(&sidebar_vbox);
hbox.append(&gtk4::Separator::builder()
@ -482,7 +505,8 @@ fn build_app_window(
window.set_child(Some(&hbox));
// ── AppState ──────────────────────────────────────────────────
let state = AppState::new(store, notes, cfg, stack.clone(), window.clone());
let state = AppState::new(store, notes, cfg, stack.clone(), status_label.clone());
state.sidebar_counts.replace(sidebar_counts);
// Initial build
rebuild_stack(&state);
@ -490,15 +514,11 @@ fn build_app_window(
// ── Sidebar selection ─────────────────────────────────────────
{
let state_c = state.clone();
let search_entry_c = search_entry.clone();
sidebar_list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let view = row.widget_name().to_string();
if view.is_empty() { return; }
*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);
}
});
@ -530,14 +550,13 @@ fn build_app_window(
let state_c = state.clone();
let window_c = window.clone();
new_note_btn.connect_clicked(move |_| {
show_add_note_window(&window_c, state_c.clone(), NoteType::Note, |_| {});
show_add_note_window(&window_c, state_c.clone());
});
}
// ── Select initial view ───────────────────────────────────────
let initial = initial_view.as_deref().unwrap_or("all");
*state.active_view.borrow_mut() = initial.to_string();
search_entry.set_visible(!matches!(initial, "settings" | "errors"));
for row in sidebar_list
.observe_children()
.snapshot()
@ -551,55 +570,59 @@ fn build_app_window(
}
stack.set_visible_child_name(initial);
if let Some(req) = screenshot_req {
screenshot::dispatch(&window, req, state.clone());
}
window.present();
Ok(())
}
// ── 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.
// ── Note list & cards ─────────────────────────────────────────────────────────
fn build_note_list(
notes: &[Note],
state: AppState,
show_type_badge: bool,
empty_text: &str,
empty_new_type: Option<NoteType>,
) -> gtk4::ScrolledWindow {
fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.build();
// Capped and centered so cards don't stretch edge-to-edge on a wide
// window — full-width rows left the type chip and action buttons
// hundreds of pixels from the title they belong to.
let list = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(4)
.margin_top(8)
.margin_bottom(8)
.spacing(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.width_request(700)
.halign(gtk4::Align::Center)
.build();
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
sorted.sort_by_key(|n| std::cmp::Reverse(n.created));
sorted.sort_by(|a, b| b.created.cmp(&a.created));
if sorted.is_empty() {
let action = empty_new_type.map(|nt| views::row::new_note_action(nt, state.window.clone(), state.clone()));
list.append(&views::row::build_empty_state("view-list-symbolic", empty_text, action));
let empty = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(6)
.halign(gtk4::Align::Center)
.margin_top(64)
.build();
empty.append(
&gtk4::Label::builder()
.label("No notes here yet")
.css_classes(["note-title"])
.build(),
);
empty.append(
&gtk4::Label::builder()
.label("Capture something with breadpad and it'll show up here.")
.css_classes(["dim-label"])
.build(),
);
list.append(&empty);
} else {
for note in &sorted {
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()));
list.append(&build_note_card(note, state.clone()));
}
}
@ -607,16 +630,260 @@ fn build_note_list(
scroll
}
/// Short relative form for a recency-ordered list ("2h ago"); the exact
/// absolute timestamp is still available via tooltip. Falls back to a plain
/// date once a note is more than a week old, where "Nd ago" stops being
/// useful at a glance.
fn humanize_relative(dt: DateTime<Utc>) -> String {
let secs = Utc::now().signed_duration_since(dt).num_seconds().max(0);
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3600)
} else if secs < 86_400 * 7 {
format!("{}d ago", secs / 86_400)
} else {
let local: DateTime<Local> = dt.into();
local.format("%b %d").to_string()
}
}
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)
.css_classes(["note-title"])
.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_abs = {
let local: chrono::DateTime<Local> = note.created.into();
local.format("%b %d %H:%M").to_string()
};
let meta_label = gtk4::Label::builder()
.label(&humanize_relative(note.created))
.css_classes(["dim-label"])
.xalign(0.0)
.tooltip_text(&created_abs)
.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"])
.tooltip_text(&format!("Workspace {}", ws))
.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 ───────────────────────────────────────────────────────────
fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState, preselect: NoteType, on_build: impl FnOnce(&gtk4::Window)) {
fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState) {
let win = gtk4::Window::builder()
.title("New Note")
.transient_for(parent)
.modal(true)
.default_width(500)
.build();
bread_theme::gtk::bind_window_auto(&win);
let vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
@ -634,42 +901,48 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState, prese
.build();
vbox.append(&body_entry);
// 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());
// Type chips
let chip_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(4)
.build();
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(preselect.clone()));
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(NoteType::Note));
let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin()
.iter()
.map(|&name| (bread_theme::gtk::chip(name), NoteType::from_str(name)))
.map(|&name| {
let btn = gtk4::Button::builder()
.label(name)
.css_classes(["type-chip"])
.build();
(btn, NoteType::from_str(name))
})
.collect();
for (btn, nt) in &chips {
bread_theme::gtk::set_chip_active(btn, *nt == preselect);
let sel = selected_type.clone();
let nt_c = nt.clone();
let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = nt_c.clone();
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
bread_theme::gtk::set_chip_active(clicked, true);
for b in &all_btns { b.remove_css_class("active"); }
clicked.add_css_class("active");
});
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(&gtk4::Label::builder().label("Time (optional)").xalign(0.0).build());
let time_entry = gtk4::Entry::builder()
.placeholder_text(editor::TIME_PLACEHOLDER)
.placeholder_text("tomorrow 9am / at 7pm / in 30 minutes")
.hexpand(true)
.build();
vbox.append(&time_entry);
vbox.append(&gtk4::Label::builder().label("Recurrence (optional)").xalign(0.0).build());
let rrule_entry = gtk4::Entry::builder()
.placeholder_text(editor::RRULE_PLACEHOLDER)
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO")
.hexpand(true)
.build();
vbox.append(&rrule_entry);
@ -789,7 +1062,6 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState, prese
});
}
on_build(&win);
win.present();
body_entry.grab_focus();
}

View file

@ -1,132 +0,0 @@
//! `--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,6 +1,7 @@
use super::row::{build_empty_state, RowSpec};
use breadpad_shared::types::Note;
use gtk4::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
@ -17,16 +18,36 @@ pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
.build();
let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect();
// 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)));
archived.sort_by(|a, b| b.created.cmp(&a.created));
if archived.is_empty() {
list.append(&build_empty_state("folder-symbolic", "Nothing archived yet.", None));
list.append(
&gtk4::Label::builder()
.label("Archive is empty.")
.margin_top(32)
.build(),
);
} else {
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
.completed
.map(|t| {
@ -34,11 +55,55 @@ pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
format!("done {}", local.format("%b %d"))
})
.unwrap_or_else(|| "done".into());
let spec = RowSpec { date_label: completed_str, note, show_type_badge: true, show_done: false };
list.append(&super::row::build(spec, state.clone()));
let done_label = gtk4::Label::builder()
.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?");
}
});
}
scroll.set_child(Some(&list));
scroll
row.append(&done_label);
row.append(&body_label);
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();
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)
.xalign(0.0)
.css_classes(["dim-label"])

View file

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

View file

@ -1,268 +0,0 @@
//! 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,80 +1,7 @@
//! 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::{
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
};
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()
}
use gtk4::prelude::*;
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
@ -83,242 +10,255 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
.vexpand(true)
.build();
let content = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(24).build();
let outer = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(16)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
// ── General ──────────────────────────────────────────────────
let (general_group, general_list) = field_group("General", None);
let (general_frame, general_grid) = make_section("General");
let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(cfg.settings.default_type.clone()));
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin()
let type_options = ["note", "todo", "reminder", "idea", "question"];
let default_type_combo = gtk4::DropDown::from_strings(&type_options);
let dt_idx = type_options
.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());
type_pill_box.append(btn);
}
general_list.append(&field_row("Default type", None, &type_pill_box));
.position(|&s| s == cfg.settings.default_type.as_str())
.unwrap_or(0) as u32;
default_type_combo.set_selected(dt_idx);
attach_row(&general_grid, 0, "Default type", &default_type_combo);
let ws_tag_switch = gtk4::Switch::builder().active(cfg.settings.workspace_tag).valign(gtk4::Align::Center).build();
general_list.append(&field_row(
"Workspace tag",
Some("Tag new notes with the Hyprland workspace they were created on"),
&ws_tag_switch,
));
let ws_tag_switch = gtk4::Switch::builder()
.active(cfg.settings.workspace_tag)
.valign(gtk4::Align::Center)
.build();
attach_row(&general_grid, 1, "Workspace tag", &ws_tag_switch);
let archive_spin = spin_button(cfg.settings.archive_after_days as f64, 1.0, 365.0, 1.0, 7.0, 0);
general_list.append(&field_row("Archive after (days)", None, &archive_spin));
let archive_spin = gtk4::SpinButton::with_range(1.0, 365.0, 1.0);
archive_spin.set_value(cfg.settings.archive_after_days as f64);
attach_row(&general_grid, 2, "Archive after (days)", &archive_spin);
let snooze_entry = text_entry(&cfg.settings.snooze_options.join(", "), 24);
general_list.append(&field_row("Snooze options", Some("Comma-separated (e.g. 15m, 1h, tomorrow_morning)"), &snooze_entry));
let snooze_entry = gtk4::Entry::builder()
.text(&cfg.settings.snooze_options.join(", "))
.hexpand(true)
.build();
attach_row(&general_grid, 3, "Snooze options", &snooze_entry);
content.append(&general_group);
outer.append(&general_frame);
// ── Reminders ────────────────────────────────────────────────
let (rem_group, rem_list) = field_group("Reminders", None);
let (rem_frame, rem_grid) = make_section("Reminders");
let morning_entry = text_entry(&cfg.reminders.default_morning, 10);
rem_list.append(&field_row("Default morning", Some("Used for \"tomorrow_morning\" snoozes and recurring reminders"), &morning_entry));
let morning_entry = gtk4::Entry::builder()
.text(&cfg.reminders.default_morning)
.placeholder_text("HH:MM")
.build();
attach_row(&rem_grid, 0, "Default morning", &morning_entry);
let grace_spin = spin_button(cfg.reminders.missed_grace_minutes as f64, 0.0, 1440.0, 5.0, 30.0, 0);
rem_list.append(&field_row("Missed grace (minutes)", Some("How late a reminder can fire before it's considered missed"), &grace_spin));
let grace_spin = gtk4::SpinButton::with_range(0.0, 1440.0, 5.0);
grace_spin.set_value(cfg.reminders.missed_grace_minutes as f64);
attach_row(&rem_grid, 1, "Missed grace (minutes)", &grace_spin);
content.append(&rem_group);
outer.append(&rem_frame);
// ── Local classifier ───────────────────────────────────────────
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."),
);
// ── Model ─────────────────────────────────────────────────────
let (model_frame, model_grid) = make_section("Model (Tier 2 ONNX)");
let model_path_entry = text_entry(&cfg.model.path, 30);
model_list.append(&field_row("Model path", None, &model_path_entry));
let model_path_entry = gtk4::Entry::builder()
.text(&cfg.model.path)
.hexpand(true)
.build();
attach_row(&model_grid, 0, "ONNX path", &model_path_entry);
let tokenizer_entry = text_entry(&cfg.model.tokenizer, 30);
model_list.append(&field_row("Tokenizer path", None, &tokenizer_entry));
let tokenizer_entry = gtk4::Entry::builder()
.text(&cfg.model.tokenizer)
.hexpand(true)
.build();
attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry);
let ort_dylib_entry = text_entry(&cfg.model.ort_dylib_path, 30);
model_list.append(&field_row("Runtime library path", None, &ort_dylib_entry));
let ort_dylib_entry = gtk4::Entry::builder()
.text(&cfg.model.ort_dylib_path)
.hexpand(true)
.build();
attach_row(&model_grid, 2, "ORT dylib path", &ort_dylib_entry);
content.append(&model_group);
outer.append(&model_frame);
// ── AI classification (Ollama) ──────────────────────────────────
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."),
);
// ── Ollama (Tier 3) ───────────────────────────────────────────
let (ollama_frame, ollama_grid) = make_section("Ollama (Tier 3)");
let ollama_enabled_switch = gtk4::Switch::builder().active(cfg.model.ollama.enabled).valign(gtk4::Align::Center).build();
ollama_list.append(&field_row("Enabled", None, &ollama_enabled_switch));
let ollama_enabled = gtk4::Switch::builder()
.active(cfg.model.ollama.enabled)
.valign(gtk4::Align::Center)
.build();
attach_row(&ollama_grid, 0, "Enabled", &ollama_enabled);
let ollama_endpoint_entry = text_entry(&cfg.model.ollama.endpoint, 24);
ollama_list.append(&field_row("Endpoint", None, &ollama_endpoint_entry));
let ollama_endpoint = gtk4::Entry::builder()
.text(&cfg.model.ollama.endpoint)
.hexpand(true)
.build();
attach_row(&ollama_grid, 1, "Endpoint", &ollama_endpoint);
let ollama_model_entry = text_entry(&cfg.model.ollama.model, 16);
ollama_list.append(&field_row("Model", None, &ollama_model_entry));
let ollama_model = gtk4::Entry::builder()
.text(&cfg.model.ollama.model)
.build();
attach_row(&ollama_grid, 2, "Model", &ollama_model);
let ollama_thresh_spin = spin_button(cfg.model.ollama.confidence_threshold as f64, 0.0, 1.0, 0.05, 0.1, 2);
ollama_list.append(&field_row("Confidence threshold", None, &ollama_thresh_spin));
let ollama_thresh = gtk4::SpinButton::with_range(0.0, 1.0, 0.05);
ollama_thresh.set_value(cfg.model.ollama.confidence_threshold as f64);
ollama_thresh.set_digits(2);
attach_row(&ollama_grid, 3, "Confidence threshold", &ollama_thresh);
content.append(&ollama_group);
outer.append(&ollama_frame);
// ── Calendar sync ────────────────────────────────────────────
let (cal_group, cal_list) = field_group("Calendar Sync", Some("Sync reminders to a Nextcloud calendar via CalDAV."));
// ── Calendar ─────────────────────────────────────────────────
let (cal_frame, cal_grid) = make_section("Nextcloud Calendar (CalDAV)");
let cal_enabled_switch = gtk4::Switch::builder().active(cfg.calendar.enabled).valign(gtk4::Align::Center).build();
cal_list.append(&field_row("Enabled", None, &cal_enabled_switch));
let cal_enabled = gtk4::Switch::builder()
.active(cfg.calendar.enabled)
.valign(gtk4::Align::Center)
.build();
attach_row(&cal_grid, 0, "Enabled", &cal_enabled);
let cal_url_entry = text_entry(&cfg.calendar.url, 30);
cal_list.append(&field_row("Calendar URL", None, &cal_url_entry));
let cal_url = gtk4::Entry::builder()
.text(&cfg.calendar.url)
.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_entry = text_entry(&cfg.calendar.username, 16);
cal_list.append(&field_row("Username", None, &cal_user_entry));
let cal_user = gtk4::Entry::builder()
.text(&cfg.calendar.username)
.build();
attach_row(&cal_grid, 2, "Username", &cal_user);
let cal_pass_entry = gtk4::PasswordEntry::builder().text(&cfg.calendar.password).show_peek_icon(true).valign(gtk4::Align::Center).css_classes(["field-input"]).build();
cal_list.append(&field_row("App password", None, &cal_pass_entry));
let cal_pass = gtk4::Entry::builder()
.text(&cfg.calendar.password)
.input_purpose(gtk4::InputPurpose::Password)
.visibility(false)
.build();
attach_row(&cal_grid, 3, "App password", &cal_pass);
content.append(&cal_group);
outer.append(&cal_frame);
// ── Status (instant-apply — no Save button) ─────────────────
let status_label = gtk4::Label::builder().label("").xalign(0.0).css_classes(["dim-label"]).margin_top(4).build();
content.append(&status_label);
// ── Save ──────────────────────────────────────────────────────
let status_label = gtk4::Label::builder()
.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
// control below calls this on its own "committed a change" signal
// (switch/spin fire on change; entries fire on Enter or focus-out).
let apply_now: Rc<dyn Fn()> = Rc::new({
let selected_type = selected_type.clone();
let ws_tag_switch = ws_tag_switch.clone();
let archive_spin = archive_spin.clone();
let snooze_entry = snooze_entry.clone();
let morning_entry = morning_entry.clone();
let grace_spin = grace_spin.clone();
let model_path_entry = model_path_entry.clone();
let tokenizer_entry = tokenizer_entry.clone();
let ort_dylib_entry = ort_dylib_entry.clone();
let ollama_enabled_switch = ollama_enabled_switch.clone();
let ollama_endpoint_entry = ollama_endpoint_entry.clone();
let ollama_model_entry = ollama_model_entry.clone();
let ollama_thresh_spin = ollama_thresh_spin.clone();
let cal_enabled_switch = cal_enabled_switch.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();
{
let dtc = default_type_combo.clone();
let wts = ws_tag_switch.clone();
let ars = archive_spin.clone();
let sne = snooze_entry.clone();
let moe = morning_entry.clone();
let grs = grace_spin.clone();
let mpe = model_path_entry.clone();
let tke = tokenizer_entry.clone();
let ode = ort_dylib_entry.clone();
let oec = ollama_enabled.clone();
let oee = ollama_endpoint.clone();
let ome = ollama_model.clone();
let ots = ollama_thresh.clone();
let cec = cal_enabled.clone();
let cuc = cal_url.clone();
let csc = cal_user.clone();
let cpc = cal_pass.clone();
let sl = status_label.clone();
move || {
save_btn.connect_clicked(move |_| {
let new_cfg = Config {
settings: Settings {
default_type: selected_type.borrow().clone(),
workspace_tag: ws_tag_switch.is_active(),
snooze_options: snooze_entry
default_type: type_options
.get(dtc.selected() as usize)
.copied()
.unwrap_or("note")
.to_string(),
workspace_tag: wts.is_active(),
snooze_options: sne
.text()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
archive_after_days: archive_spin.value() as i64,
archive_after_days: ars.value() as i64,
},
reminders: RemindersConfig {
default_morning: morning_entry.text().to_string(),
missed_grace_minutes: grace_spin.value() as i64,
default_morning: moe.text().to_string(),
missed_grace_minutes: grs.value() as i64,
},
model: ModelConfig {
path: model_path_entry.text().to_string(),
tokenizer: tokenizer_entry.text().to_string(),
ort_dylib_path: ort_dylib_entry.text().to_string(),
path: mpe.text().to_string(),
tokenizer: tke.text().to_string(),
ort_dylib_path: ode.text().to_string(),
ollama: OllamaConfig {
enabled: ollama_enabled_switch.is_active(),
endpoint: ollama_endpoint_entry.text().to_string(),
model: ollama_model_entry.text().to_string(),
confidence_threshold: ollama_thresh_spin.value() as f32,
enabled: oec.is_active(),
endpoint: oee.text().to_string(),
model: ome.text().to_string(),
confidence_threshold: ots.value() as f32,
},
},
calendar: CalendarConfig {
enabled: cal_enabled_switch.is_active(),
url: cal_url_entry.text().to_string(),
username: cal_user_entry.text().to_string(),
password: cal_pass_entry.text().to_string(),
enabled: cec.is_active(),
url: cuc.text().to_string(),
username: csc.text().to_string(),
password: cpc.text().to_string(),
},
};
match new_cfg.save() {
Ok(()) => {
status_label.set_label("Saved.");
sl.set_label("Settings saved.");
on_save(new_cfg);
}
Err(e) => status_label.set_label(&format!("Save failed: {}", e)),
}
Err(e) => sl.set_label(&format!("Save failed: {}", e)),
}
});
// Type pills, switches, spinners apply the moment they change.
for (btn, name) in &type_pills {
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)
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
outer.append(&content);
btn_row.append(&status_label);
btn_row.append(&gtk4::Box::builder().hexpand(true).build());
btn_row.append(&save_btn);
outer.append(&btn_row);
scroll.set_child(Some(&outer));
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,8 +1,7 @@
use super::row::{build_empty_state, RowSpec};
use breadpad_shared::types::{Note, NoteType};
use gtk4::prelude::*;
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
@ -27,9 +26,33 @@ pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
upcoming.sort_by_key(|n| n.effective_time().unwrap());
if upcoming.is_empty() {
list.append(&build_empty_state("x-office-calendar-symbolic", "No upcoming reminders or todos.", None));
let label = gtk4::Label::builder()
.label("No upcoming reminders or todos.")
.margin_top(32)
.build();
list.append(&label);
} else {
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
.effective_time()
.map(|t| {
@ -37,11 +60,27 @@ pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
local.format("%a %b %d, %H:%M").to_string()
})
.unwrap_or_default();
let spec = RowSpec { date_label: time_str, note, show_type_badge: true, show_done: true };
list.append(&super::row::build(spec, state.clone()));
}
}
scroll.set_child(Some(&list));
scroll
let time_label = gtk4::Label::builder()
.label(&time_str)
.width_chars(18)
.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();
row.append(&time_label);
row.append(&body_label);
row.append(&type_label);
row
}

View file

@ -7,8 +7,7 @@ authors.workspace = true
[dependencies]
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
gtk4.workspace = true
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
anyhow.workspace = true
tracing.workspace = true
serde.workspace = true
@ -20,13 +19,15 @@ tokio.workspace = true
zbus.workspace = true
ort.workspace = true
tokenizers.workspace = true
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
ndarray.workspace = true
toml.workspace = true
dirs.workspace = true
regex.workspace = true
ureq.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
ical = "0.11"
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }
[dev-dependencies]
tempfile = "3"

View file

@ -61,8 +61,14 @@ impl OllamaClient {
"stream": false
});
// ureq 2's default agent has no overall request timeout, so a hung
// local Ollama endpoint would otherwise stall this call forever —
// and since classification now runs from an idle callback after the
// capture window has already closed (see `main.rs`), a hang here is
// invisible to the user, not just slow.
let response = ureq::post(&url)
.set("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(10))
.send_json(payload)
.map_err(|e| anyhow::anyhow!("Ollama HTTP error: {}", e))?;
@ -73,7 +79,7 @@ impl OllamaClient {
let classification: OllamaClassification = extract_json(&ollama_resp.response)
.ok_or_else(|| anyhow::anyhow!(
"no JSON object found in response — raw: {:?}",
ollama_resp.response
&ollama_resp.response
))?;
let note_type = classification

View file

@ -18,7 +18,13 @@ impl CalDavClient {
pub fn new(config: CalendarConfig) -> Self {
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
// initialised; fall back to `Client::new()` semantics rather than panicking.
//
// A request-wide timeout is set here (rather than on `Client::new()`'s
// untimed defaults) so a hung/unreachable CalDAV server can't hang
// whatever's making the request indefinitely — `reqwest::Client::new()`
// has no timeout of its own.
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap_or_else(|e| {
tracing::warn!("falling back to default HTTP client: {}", e);

View file

@ -2,8 +2,6 @@ use crate::ai::OllamaClient;
use crate::config::OllamaConfig;
use crate::parser::parse_rule_based;
use crate::types::{ClassificationResult, NoteType};
use bread_onnx::{build_session, Provider};
use ort::session::builder::GraphOptimizationLevel;
use std::path::PathBuf;
/// Minimum Tier 1 confidence needed to skip Tier 2 entirely.
@ -34,10 +32,13 @@ pub struct Classifier {
}
fn model_dir() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("breadpad")
.join("model")
// Was `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))`
// — the same literal-tilde-fallback bug flagged (but not fixed) in
// breadclip-core tonight: PathBuf/std::fs never expand `~`, so on the
// rare box where `dirs` can't resolve a home directory this silently
// resolved to a directory literally named `~` under the current working
// directory instead of the user's actual home.
bread_utils::xdg::data_dir("breadpad").join("model")
}
impl Classifier {
@ -109,7 +110,9 @@ impl Classifier {
// ── Tier 2 ───────────────────────────────────────────────────────────
// ONNX model classifies the type only; Tier 1's time/rrule/body are kept.
let tier2 = if let (Some(session), Some(tokenizer)) = (&mut self.session, &self.tokenizer) {
let tier2 = if let (Some(session), Some(tokenizer)) =
(&mut self.session, &self.tokenizer)
{
match run_onnx(session, tokenizer, text) {
Ok(r) => {
tracing::debug!("Tier 2: {:?} conf={:.2}", r.note_type, r.confidence);
@ -163,18 +166,9 @@ impl Classifier {
// entailment score across all five passes.
const HYPOTHESES: [(&str, &str); 5] = [
("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 general observation or piece of information.",
"note",
),
("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 general observation or piece of information.", "note"),
("This note is a question that needs an answer.", "question"),
];
@ -193,17 +187,15 @@ fn run_onnx(
.map_err(|e| anyhow::anyhow!("tokenize: {}", e))?;
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 ids_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], ids))
.map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
let mask_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], mask))
.map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
let ids_tensor = ort::value::Tensor::<i64>::from_array(
(vec![1i64, len as i64], ids)
).map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
let mask_tensor = ort::value::Tensor::<i64>::from_array(
(vec![1i64, len as i64], mask)
).map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
let inputs = ort::inputs![
"input_ids" => ids_tensor,
@ -218,7 +210,10 @@ fn run_onnx(
.map_err(|e| anyhow::anyhow!("extract logits: {}", e))?;
let (_, logits_slice) = logits;
entailment_scores[i] = logits_slice.get(ENTAILMENT_IDX).copied().unwrap_or(0.0);
entailment_scores[i] = logits_slice
.get(ENTAILMENT_IDX)
.copied()
.unwrap_or(0.0);
}
let best_idx = entailment_scores
@ -251,21 +246,49 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
exps[idx] / sum
}
fn try_load_session(path: &std::path::Path) -> (Option<ort::session::Session>, ExecutionProvider) {
// WHY: distro onnxruntime-rocm is MIGraphX, not classic ROCm; bread-onnx
// appends CPU so a missing GPU EP does not disable Tier 2.
match build_session(
fn try_load_session(
path: &std::path::Path,
) -> (Option<ort::session::Session>, ExecutionProvider) {
// AMD iGPU via MIGraphX, falling back to CPU. This used to request the
// classic `ort::ep::ROCm` (ROCMExecutionProvider) first — per this
// machine's own breadsearch-gpu-backends operator notes, that EP
// silently no-ops back to CPU on this class of system (distro ROCm
// onnxruntime builds, e.g. Arch's onnxruntime-rocm, are commonly
// compiled with `--use_migraphx`, not `--use_rocm`), so "ROCm (iGPU)"
// could report as active in this struct's own `active_provider` while
// every inference actually ran on CPU. See bread_onnx::provider's doc
// comment for the full history — breadsearch's own embed.rs already
// got this right.
//
// The `is_available()` gate (kept from the original implementation)
// means `active_provider` only ever claims Gpu when we actually
// attempted the GPU build — bread_onnx::build_session's loud EP-
// selection logging (visible once tracing_subscriber is initialized,
// which this crate's own main.rs already does) is what catches the
// *silent per-node fallback* class of bug this rewrite exists to fix,
// rather than papering over it by unconditionally reporting Gpu.
let migraphx_available = {
use ort::execution_providers::ExecutionProvider as _;
ort::ep::MIGraphX::default().is_available().unwrap_or(false)
};
if migraphx_available {
match bread_onnx::build_session(
path,
GraphOptimizationLevel::Level3,
&[Provider::MiGraphX { device_id: 0 }],
ort::session::builder::GraphOptimizationLevel::Level3,
&[bread_onnx::Provider::MiGraphX { device_id: 0 }],
) {
Ok(s) => {
tracing::info!("ONNX session loaded (MIGraphX, CPU fallback)");
(Some(s), ExecutionProvider::Gpu)
tracing::info!("ONNX session loaded (MIGraphX iGPU)");
return (Some(s), ExecutionProvider::Gpu);
}
Err(e) => {
tracing::debug!("MIGraphX session failed: {}; trying CPU", e);
match build_session(path, GraphOptimizationLevel::Level3, &[Provider::Cpu]) {
Err(e) => tracing::debug!("MIGraphX EP unavailable: {}; trying CPU", e),
}
}
match bread_onnx::build_session(
path,
ort::session::builder::GraphOptimizationLevel::Level3,
&[bread_onnx::Provider::Cpu],
) {
Ok(s) => {
tracing::info!("ONNX session loaded (CPU)");
(Some(s), ExecutionProvider::Cpu)
@ -275,6 +298,4 @@ fn try_load_session(path: &std::path::Path) -> (Option<ort::session::Session>, E
(None, ExecutionProvider::Cpu)
}
}
}
}
}

View file

@ -135,7 +135,7 @@ impl Default for RemindersConfig {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarConfig {
#[serde(default = "default_calendar_enabled")]
pub enabled: bool,
@ -150,6 +150,17 @@ pub struct CalendarConfig {
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)]
pub struct Config {
#[serde(default)]
@ -182,20 +193,32 @@ impl Config {
}
let text = toml::to_string_pretty(self)?;
fs::write(&path, text)?;
// This file can hold the CalDAV password in plaintext (see
// `CalendarConfig`'s own doc comment) — `fs::write` creates it with
// the process's default umask, which on most setups means
// world-readable. Lock it down to owner-only rather than just
// telling the user to do it themselves.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) {
tracing::warn!("failed to restrict permissions on {}: {}", path.display(), e);
}
}
Ok(())
}
}
pub fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("breadpad")
.join("breadpad.toml")
// Was `dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"))`
// — same literal-tilde-fallback bug as `classifier.rs::model_dir` (see
// its doc comment) and breadclip-core's `data_dir`; PathBuf/std::fs
// never expand `~`.
bread_utils::xdg::config_dir("breadpad").join("breadpad.toml")
}
pub fn style_css_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("breadpad")
.join("style.css")
bread_utils::xdg::config_dir("breadpad").join("style.css")
}

View file

@ -85,6 +85,43 @@ fn rrule_weekday(wd: Weekday) -> &'static str {
}
}
/// Explicit type prefixes, checked before any lexical heuristics. Short forms
/// exist so the capture popup can be driven without reaching for the mouse —
/// see `breadpad_shared::parser::detect_prefix_type`, which the popup's entry
/// uses to live-highlight the matching chip as the user types.
const TYPE_PREFIXES: &[(&str, NoteType)] = &[
("td:", NoteType::Todo),
("rem:", NoteType::Reminder),
("idea:", NoteType::Idea),
("note:", NoteType::Note),
("q:", NoteType::Question),
];
/// If `text` starts with one of [`TYPE_PREFIXES`] (case-insensitive), returns
/// the type it forces. Used both to classify at save time and to live-drive
/// the popup's chip highlighting as the user types.
pub fn detect_prefix_type(text: &str) -> Option<NoteType> {
let lower = text.trim_start().to_lowercase();
TYPE_PREFIXES
.iter()
.find(|(prefix, _)| lower.starts_with(prefix))
.map(|(_, nt)| nt.clone())
}
/// Strips a leading explicit type prefix (if any), returning the forced type
/// and the remaining text with the prefix and any following whitespace removed.
fn strip_explicit_prefix(text: &str) -> (Option<NoteType>, String) {
let trimmed = text.trim_start();
let lower = trimmed.to_lowercase();
for (prefix, nt) in TYPE_PREFIXES {
if lower.starts_with(prefix) {
let rest = trimmed[prefix.len()..].trim_start().to_string();
return (Some(nt.clone()), rest);
}
}
(None, text.to_string())
}
fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
let local = Local::now();
let days_ahead = (wd.num_days_from_monday() as i64
@ -105,6 +142,8 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
}
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
let (forced_type, text_owned) = strip_explicit_prefix(text);
let text: &str = &text_owned;
let p = patterns();
let morning_time: NaiveTime = default_morning
.split(':')
@ -290,8 +329,9 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
.to_string();
}
// Infer note type
let note_type = infer_type(text, extracted_time.is_some(), rrule.is_some());
// Infer note type — an explicit prefix (`td:`, `rem:`, …) always wins.
let note_type =
forced_type.clone().unwrap_or_else(|| infer_type(text, extracted_time.is_some(), rrule.is_some()));
// Trim artifacts
cleaned = cleaned
@ -303,7 +343,9 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
// Calibrated confidence: high when structural signals drove the decision,
// low when we fell back to "note" with no positive evidence.
let confidence = if rrule.is_some() || extracted_time.is_some() {
let confidence = if forced_type.is_some() {
0.99 // explicit prefix — unambiguous
} else if rrule.is_some() || extracted_time.is_some() {
0.95 // time/recurrence extraction is deterministic
} else {
match &note_type {
@ -324,67 +366,6 @@ 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)]
mod tests {
use super::*;
@ -471,6 +452,70 @@ mod tests {
assert_eq!(p("idea: reactive state module in Lua").note_type, NoteType::Idea);
}
#[test]
fn idea_prefix_stripped_from_body() {
let r = p("idea: reactive state module in Lua");
assert_eq!(r.body, "reactive state module in Lua");
}
// ---- Explicit short prefixes (td:, rem:, note:, q:) ----
#[test]
fn td_prefix_is_todo() {
let r = p("td: buy milk");
assert_eq!(r.note_type, NoteType::Todo);
assert_eq!(r.body, "buy milk");
}
#[test]
fn rem_prefix_is_reminder() {
let r = p("rem: water the plants");
assert_eq!(r.note_type, NoteType::Reminder);
assert_eq!(r.body, "water the plants");
}
#[test]
fn note_prefix_is_note() {
// Without the prefix this would classify as Todo ("check ...").
let r = p("note: check engine light has been on for a week");
assert_eq!(r.note_type, NoteType::Note);
assert_eq!(r.body, "check engine light has been on for a week");
}
#[test]
fn q_prefix_is_question() {
// Without the prefix this has no strong signal and would fall to Note.
let r = p("q: ONNX rocm vs cpu perf");
assert_eq!(r.note_type, NoteType::Question);
assert_eq!(r.body, "ONNX rocm vs cpu perf");
}
#[test]
fn explicit_prefix_confidence_is_high() {
assert_eq!(p("td: buy milk").confidence, 0.99);
}
#[test]
fn explicit_prefix_is_case_insensitive() {
assert_eq!(p("TD: buy milk").note_type, NoteType::Todo);
assert_eq!(p("Rem: standup").note_type, NoteType::Reminder);
}
#[test]
fn explicit_prefix_overrides_time_extraction_type() {
// "at 7pm" alone would infer Reminder; an explicit td: prefix wins.
let r = p("td: pack bag at 7pm");
assert_eq!(r.note_type, NoteType::Todo);
assert!(r.time.is_some(), "time should still be extracted");
}
#[test]
fn detect_prefix_type_matches_parse() {
assert_eq!(detect_prefix_type("td: buy milk"), Some(NoteType::Todo));
assert_eq!(detect_prefix_type("rem: call mum"), Some(NoteType::Reminder));
assert_eq!(detect_prefix_type("no prefix here"), None);
}
#[test]
fn idea_maybe() {
assert_eq!(p("maybe we could cache the ONNX model").note_type, NoteType::Idea);
@ -551,7 +596,7 @@ mod tests {
let r = p("take a break in 30 minutes");
let t = r.time.unwrap();
let delta = (t - before).num_seconds();
assert!((29 * 60..=31 * 60).contains(&delta), "delta was {}s", delta);
assert!(delta >= 29 * 60 && delta <= 31 * 60, "delta was {}s", delta);
}
#[test]
@ -559,7 +604,7 @@ mod tests {
let before = Utc::now();
let r = p("ping in 1 minute");
let delta = (r.time.unwrap() - before).num_seconds();
assert!((55..=65).contains(&delta), "delta was {}s", delta);
assert!(delta >= 55 && delta <= 65, "delta was {}s", delta);
}
#[test]
@ -567,7 +612,7 @@ mod tests {
let before = Utc::now();
let r = p("review PR in 2 hours");
let delta_min = (r.time.unwrap() - before).num_minutes();
assert!((119..=121).contains(&delta_min), "delta was {}min", delta_min);
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min);
}
#[test]
@ -575,7 +620,7 @@ mod tests {
let before = Utc::now();
let r = p("follow up in 3 days");
let delta_h = (r.time.unwrap() - before).num_hours();
assert!((71..=73).contains(&delta_h), "delta was {}h", delta_h);
assert!(delta_h >= 71 && delta_h <= 73, "delta was {}h", delta_h);
}
// ---- Time extraction: tomorrow ----
@ -778,7 +823,7 @@ mod tests {
let before = Utc::now();
let r = p("check on the server in an hour");
let delta_min = (r.time.unwrap() - before).num_minutes();
assert!((59..=61).contains(&delta_min), "delta was {}min", delta_min);
assert!(delta_min >= 59 && delta_min <= 61, "delta was {}min", delta_min);
}
#[test]
@ -798,7 +843,7 @@ mod tests {
let before = Utc::now();
let r = p("in a couple of hours remind me to check the oven");
let delta_min = (r.time.unwrap() - before).num_minutes();
assert!((119..=121).contains(&delta_min), "delta was {}min", delta_min);
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min);
}
#[test]
@ -819,7 +864,7 @@ mod tests {
let before = Utc::now();
let r = p("in half an hour submit the report");
let delta_min = (r.time.unwrap() - before).num_minutes();
assert!((29..=31).contains(&delta_min), "delta was {}min", delta_min);
assert!(delta_min >= 29 && delta_min <= 31, "delta was {}min", delta_min);
}
// ---- Tonight / this evening ----
@ -908,3 +953,63 @@ mod tests {
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.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 {
(now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
};
Some(local_naive_to_utc(naive))
return Some(local_naive_to_utc(naive));
}
"WEEKLY" => {
use chrono::Datelike;
@ -204,7 +204,43 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
};
let target_date =
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
Some(local_naive_to_utc(target_date))
return Some(local_naive_to_utc(target_date));
}
"MONTHLY" => {
use chrono::Datelike;
// BYMONTHDAY isn't guaranteed to be present — breadman's note
// editor lets a user type an arbitrary RRULE by hand, and
// "FREQ=MONTHLY" alone is a perfectly valid (if under-specified)
// one. Fall back to today's day-of-month, mirroring how WEEKLY
// above defaults BYDAY to "MO" when absent.
let day: u32 = parts
.get("BYMONTHDAY")
.and_then(|v| v.parse().ok())
.filter(|d: &u32| (1..=31).contains(d))
.unwrap_or_else(|| now.day());
let mut year = now.year();
let mut month = now.month();
// Walk forward month by month for the next calendar date that
// (a) actually has this day-of-month (a 31st skips e.g. April)
// and (b) is still in the future. Bounded to 24 months as a
// defensive cap — every valid day (1-31) recurs well within a
// year, so this should never come close to firing.
for _ in 0..24 {
if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) {
let candidate = date.and_time(fire_time);
if now.naive_local() < candidate {
return Some(local_naive_to_utc(candidate));
}
}
month += 1;
if month > 12 {
month = 1;
year += 1;
}
}
None
}
_ => None,
}
@ -404,7 +440,40 @@ mod tests {
#[test]
fn unknown_freq_returns_none() {
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
assert!(parse_next_from_rrule("RRULE:FREQ=YEARLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
}
#[test]
fn monthly_reschedules_instead_of_returning_none() {
// This used to be the exact bug: MONTHLY fell into the `_ => None`
// arm, so a monthly reminder fired once and never rescheduled.
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=15;BYHOUR=9;BYMINUTE=0", "08:00");
assert!(t.is_some(), "MONTHLY must produce a next occurrence, not None");
let local: chrono::DateTime<Local> = t.unwrap().into();
assert_eq!(local.day(), 15);
assert_eq!(local.hour(), 9);
assert_eq!(local.minute(), 0);
assert!(local > Local::now());
}
#[test]
fn monthly_without_bymonthday_uses_todays_day_of_month() {
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=23;BYMINUTE=59", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.day(), Local::now().day());
}
#[test]
fn monthly_on_the_31st_skips_shorter_months() {
// Every candidate month/day combination this walks must actually
// exist (from_ymd_opt returning None for e.g. April 31 is skipped
// internally) — this mostly guards against a panic/infinite loop
// regression rather than a specific date, since "next Feb 31" et al
// must fall through to a month that really has a 31st.
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0", "08:00");
assert!(t.is_some());
let local: chrono::DateTime<Local> = t.unwrap().into();
assert_eq!(local.day(), 31);
}
#[test]

View file

@ -44,11 +44,43 @@ impl Store {
}
}
/// Path to the sidecar lock file guarding the whole note store — a
/// single lock covers both `notes.jsonl` and `archive.jsonl` since
/// `rotate_archive` moves notes between them in one logical operation.
fn lock_path(&self) -> PathBuf {
self.notes_path.with_extension("lock")
}
/// Blocks until an exclusive advisory lock (`flock`) is held on the
/// sidecar lock file, and holds it for as long as the returned `File`
/// stays alive (the lock is released automatically when it's dropped,
/// same as it would be on process exit/crash).
///
/// breadpad (capture/fire/snooze), breadman (edit), and multiple
/// concurrent reminder-fire processes all read and rewrite the same
/// JSONL file with no coordination otherwise: two concurrent
/// load-modify-rewrite cycles racing `write_all`'s rename can silently
/// lose one side's change. This is intentionally one lock for the
/// entire store rather than per-note or per-file locking — contention
/// is expected to be rare (a handful of short-lived CLI-ish processes,
/// not a server), so simplicity wins over fine-grained locking here.
fn acquire_lock(&self) -> Result<fs::File> {
let file = OpenOptions::new()
.create(true)
.write(true)
.open(self.lock_path())
.context("failed to open notes store lock file")?;
file.lock().context("failed to acquire notes store lock")?;
Ok(file)
}
pub fn load_all(&self) -> Result<Vec<Note>> {
let _lock = self.acquire_lock()?;
self.load_from(&self.notes_path)
}
pub fn load_archive(&self) -> Result<Vec<Note>> {
let _lock = self.acquire_lock()?;
self.load_from(&self.archive_path)
}
@ -74,12 +106,15 @@ impl Store {
}
pub fn save_note(&self, note: &Note) -> Result<()> {
{
let _lock = self.acquire_lock()?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.notes_path)?;
let line = serde_json::to_string(note)?;
writeln!(file, "{}", line)?;
}
if let Some(cal_cfg) = &self.calendar {
if cal_cfg.enabled && (note.time.is_some() || note.rrule.is_some()) {
@ -103,13 +138,18 @@ impl Store {
}
pub fn delete_note(&self, id: &str) -> Result<()> {
let all = self.load_all()?;
let (to_delete, keep): (Vec<Note>, Vec<Note>) = all.into_iter().partition(|n| n.id == id);
let to_delete_note = {
let _lock = self.acquire_lock()?;
let all = self.load_from(&self.notes_path)?;
let (to_delete, keep): (Vec<Note>, Vec<Note>) =
all.into_iter().partition(|n| n.id == id);
self.write_all(&self.notes_path, &keep)?;
to_delete.into_iter().next()
};
if let Some(cal_cfg) = &self.calendar {
if cal_cfg.enabled {
if let Some(note) = to_delete.into_iter().next() {
if let Some(note) = to_delete_note {
spawn_caldav_delete(caldav_uid(&note), cal_cfg.clone());
}
}
@ -118,11 +158,17 @@ impl Store {
Ok(())
}
fn rewrite_notes<F>(&self, f: F) -> Result<()>
/// Holds the store lock across the whole load-modify-write span (not
/// just the write) — `load_from` is used directly here rather than the
/// public, self-locking `load_all`, since re-acquiring the same
/// process-wide advisory lock while already holding it would block
/// forever (`flock` isn't re-entrant across separate file descriptors).
fn rewrite_notes<F>(&self, mut f: F) -> Result<()>
where
F: FnMut(Note) -> Note,
{
let notes: Vec<Note> = self.load_all()?.into_iter().map(f).collect();
let _lock = self.acquire_lock()?;
let notes: Vec<Note> = self.load_from(&self.notes_path)?.into_iter().map(|n| f(n)).collect();
self.write_all(&self.notes_path, &notes)
}
@ -141,11 +187,12 @@ impl Store {
}
pub fn rotate_archive(&self, archive_after_days: i64) -> Result<usize> {
let _lock = self.acquire_lock()?;
let cutoff = Utc::now() - Duration::days(archive_after_days);
let notes = self.load_all()?;
let notes = self.load_from(&self.notes_path)?;
let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes
.into_iter()
.partition(|n| n.done && n.completed.is_some_and(|c| c < cutoff));
.partition(|n| n.done && n.completed.map_or(false, |c| c < cutoff));
if to_archive.is_empty() {
return Ok(0);

View file

@ -13,11 +13,6 @@ pub fn apply_live() {
});
}
/// Bind a window to the palette of the monitor it is rendered on.
pub fn bind_window(window: &impl gtk4::prelude::IsA<gtk4::Native>) {
bread_theme::gtk::bind_window_auto(window);
}
/// Generate the full breadpad/breadman CSS string. The base — `@define-color`
/// palette, fonts, and generic widget styling — comes from the shared
/// `bread_theme::stylesheet`, so breadpad and breadman look identical to the
@ -33,33 +28,12 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
/* breadpad/breadman-specific components */
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 {
background: @bg;
color: @fg;
border: 2px solid @blue;
border-radius: 6px;
padding: 14px;
padding: 12px 16px;
font-size: 14px;
caret-color: @fg;
}
@ -69,9 +43,14 @@ list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(
border-color: @teal;
}
/* Shared "selected/active" language: a ghost/ outline default state that
stays quiet, and a solid, high-contrast accent fill for whatever is
currently selected used identically by .type-chip.active and
.sidebar-row:selected so the two windows read as one system. */
.type-chip {
background: @overlay;
color: @on-overlay;
background: transparent;
color: alpha(@fg, 0.6);
border: 1px solid alpha(@fg, 0.18);
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
@ -81,36 +60,69 @@ list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(
.type-chip.active {
background: @blue;
color: @on-accent;
border-color: @blue;
font-weight: 600;
}
/* 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; }
/* Per-type tint so info badges (note cards, workspace/recur tags) stay
scannable at a glance without the old full-color emoji. */
.note-card-todo .type-chip { color: @green; border-color: alpha(@green, 0.4); }
.note-card-reminder .type-chip { color: @yellow; border-color: alpha(@yellow, 0.4); }
.note-card-idea .type-chip { color: @pink; border-color: alpha(@pink, 0.4); }
.note-card-question .type-chip { color: @teal; border-color: alpha(@teal, 0.4); }
.note-card-note .type-chip { color: @blue; border-color: alpha(@blue, 0.4); }
.confirm-button {
background: @blue;
color: @on-accent;
border: none;
border-radius: 8px;
padding: 8px 16px;
padding: 10px 22px;
min-height: 20px;
font-size: 15px;
font-weight: bold;
}
.confirm-button:hover { background: shade(@blue, 1.1); }
/* Separates the primary action from the chip row it sits beside so it
doesn't read as just another pill. */
.confirm-wrap {
border-left: 1px solid alpha(@fg, 0.12);
padding-left: 12px;
margin-left: 4px;
}
.prefix-hint {
color: alpha(@fg, 0.4);
font-size: 10px;
letter-spacing: 0.3px;
}
.note-card {
background: shade(@bg, 1.1);
border-radius: 8px;
background: shade(@bg, 1.12);
border: 1px solid alpha(@fg, 0.07);
border-radius: 10px;
padding: 12px;
margin: 8px;
margin: 6px 0;
border-left: 3px solid @blue;
}
.note-card:hover {
background: shade(@bg, 1.2);
background: shade(@bg, 1.22);
border-color: alpha(@fg, 0.12);
}
.note-title {
font-weight: 600;
}
.note-card .action-btn {
opacity: 0.45;
}
.note-card:hover .action-btn {
opacity: 1;
}
.search-entry {
@ -118,7 +130,8 @@ list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(
color: @fg;
border: 1px solid @overlay;
border-radius: 6px;
padding: 8px 12px;
padding: 5px 10px;
font-size: 13px;
}
.search-entry:focus {
@ -139,7 +152,27 @@ list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(
.sidebar-row:selected {
background: @blue;
color: @on-accent;
font-weight: 500;
font-weight: 600;
}
.sidebar-row-minor {
opacity: 0.5;
font-size: 12px;
}
.sidebar-count {
color: alpha(@fg, 0.45);
font-size: 11px;
}
.icon-todo { color: @green; }
.icon-reminder { color: @yellow; }
.icon-idea { color: @pink; }
.icon-note { color: @blue; }
.icon-question { color: @teal; }
.sidebar-row:selected .sidebar-count {
color: alpha(@on-accent, 0.75);
}
.sidebar-section-label {
@ -154,14 +187,15 @@ list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(
background: transparent;
border: none;
border-radius: 6px;
padding: 2px 7px;
min-width: 28px;
min-height: 28px;
font-size: 14px;
padding: 3px 8px;
min-width: 32px;
min-height: 32px;
font-size: 16px;
}
.action-btn:hover {
background: shade(@bg, 1.3);
opacity: 1;
}
.done-btn { color: @green; }
@ -170,12 +204,8 @@ list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(
.edit-btn { color: @blue; }
.edit-btn:hover { background: alpha(@blue, 0.15); }
/* Fixed red, not @red - pywal can hand `red` any hue depending on the
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); }
.danger-btn { color: @red; }
.danger-btn:hover { background: alpha(@red, 0.15); }
.note-card-todo { border-left-color: @green; }
.note-card-reminder { border-left-color: @yellow; }
@ -209,15 +239,12 @@ list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(
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 {
background: transparent;
border: 1px solid @overlay;
border-radius: 8px;
padding: 8px 16px;
color: @fg;
color: alpha(@fg, 0.6);
}
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
@ -232,40 +259,15 @@ list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(
.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 {
background: transparent;
border: none;
border-radius: 6px;
padding: 10px 12px;
padding: 8px 12px;
color: @fg;
border-bottom: 1px solid alpha(@overlay, 0.15);
}
.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 {
@ -282,6 +284,12 @@ mod tests {
#[test]
fn css_defines_bg_color() {
// #1e1e2e (Catppuccin Mocha) was the default before bread-theme
// v0.2.10, which deliberately fixed background/surface/overlay to
// BOS's own dark theme (#0c0c0c etc.) instead of tracking pywal —
// see bread-theme's `palette.rs` `FIXED_BACKGROUND` doc comment.
// This assertion was never updated for that bump, so it started
// failing the moment breadpad's Cargo.toml pin moved to v0.2.10.
let css = build_css(&Palette::default(), None);
assert!(css.contains("@define-color bg #0c0c0c"), "css missing bg: {}", &css[..300]);
}
@ -328,11 +336,9 @@ mod tests {
#[test]
fn css_reflects_custom_palette_colors() {
let p = Palette {
background: "#deadbe".into(),
color4: "#cafe00".into(),
..Default::default()
};
let mut p = Palette::default();
p.background = "#deadbe".into();
p.color4 = "#cafe00".into();
let css = build_css(&p, None);
assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]);
assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]);

View file

@ -15,9 +15,6 @@ pub enum 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 {
match s.to_lowercase().as_str() {
"todo" => NoteType::Todo,

View file

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

View file

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

View file

@ -9,18 +9,12 @@ use breadpad_shared::classifier::Classifier;
use breadpad_shared::store::Store;
use breadpad_shared::types::{Note, NoteType};
use chrono::Timelike;
use std::path::PathBuf;
use tempfile::TempDir;
// Mirrors commit_note() in breadpad/src/main.rs.
// `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 {
// 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 mut classifier = Classifier::load("08:00");
let result = classifier.classify(text);
let mut note = Note::new(text.into(), user_type.clone(), None);
@ -67,11 +61,7 @@ fn todo_note_appears_in_store() {
#[test]
fn idea_note_appears_in_store() {
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();
assert_eq!(notes.len(), 1);
@ -81,11 +71,7 @@ fn idea_note_appears_in_store() {
#[test]
fn question_note_appears_in_store() {
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();
assert_eq!(notes.len(), 1);
@ -111,10 +97,7 @@ fn reminder_has_time_set() {
let notes = breadman_store(&dir).load_all().unwrap();
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();
assert_eq!(local.hour(), 18);
}
@ -125,21 +108,14 @@ fn reminder_body_has_time_stripped() {
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
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"));
}
#[test]
fn in_duration_reminder_has_time() {
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();
assert_eq!(notes[0].note_type, NoteType::Reminder);
@ -151,11 +127,7 @@ fn in_duration_reminder_has_time() {
#[test]
fn recurring_reminder_has_rrule() {
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();
assert_eq!(notes[0].note_type, NoteType::Reminder);
@ -167,20 +139,11 @@ fn recurring_reminder_has_rrule() {
#[test]
fn daily_reminder_has_rrule() {
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();
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 ----
@ -192,11 +155,7 @@ fn user_selected_type_overrides_classifier() {
capture(&store, "fix the login bug", NoteType::Idea);
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]
@ -214,11 +173,7 @@ fn user_selected_reminder_overrides_classifier() {
fn three_notes_all_visible_to_breadman() {
let (dir, store) = setup();
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"));
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!();
let sep = "".repeat(62);
println!("{:<14} {:<26} actual", "field", "expected");
println!("{:<14} {:<26} {}", "field", "expected", "actual");
println!("{sep}");
println!(
"{:<14} {:<26} {}",

View file

@ -11,11 +11,6 @@ path = "src/main.rs"
[dependencies]
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
ort.workspace = true
tracing.workspace = true
@ -28,3 +23,4 @@ gtk4-layer-shell.workspace = true
hyprland.workspace = true
dirs.workspace = true
tokio.workspace = true
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }

View file

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

View file

@ -3,36 +3,16 @@ use breadpad_shared::{
calendar::CalDavClient,
classifier::Classifier,
config::Config,
parser::parse_rule_based,
scheduler::Scheduler,
store::Store,
types::{Note, NoteType},
};
use bread_utils::bread_client::BreadClient;
use gtk4::{glib, prelude::*};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use std::cell::RefCell;
use std::rc::Rc;
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();
fn init_ort_once(cfg: &Config) {
@ -51,9 +31,6 @@ fn init_ort_once(cfg: &Config) {
}
mod args {
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH};
use std::path::Path;
#[derive(Debug)]
pub struct Args {
pub note_type: Option<String>,
@ -64,32 +41,6 @@ mod args {
pub model_info: bool,
pub calendar_test: bool,
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 {
@ -102,11 +53,6 @@ mod args {
model_info: false,
calendar_test: false,
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 mut i = 0;
@ -120,7 +66,6 @@ mod args {
"--status" => args.status = true,
"download-model" => args.download_model = true,
"model-info" => args.model_info = true,
"listen" => args.listen = true,
"fire" => {
i += 1;
args.fire_id = raw.get(i).cloned();
@ -137,26 +82,6 @@ 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;
@ -174,9 +99,6 @@ fn main() -> Result<()> {
.init();
let args = args::parse();
if args.listen {
return listen::run();
}
let cfg = Config::load()?;
if args.status {
@ -198,23 +120,7 @@ fn main() -> Result<()> {
return cmd_calendar_list_uid(&note_id, &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)
run_popup(args.note_type, args.no_classify, cfg)
}
fn cmd_status(cfg: &Config) -> Result<()> {
@ -359,8 +265,6 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
return Ok(());
}
emit_reminder_due(&note.id);
// Schedule next recurrence before showing UI
if note.rrule.is_some() {
if let Some(next) = Scheduler::next_recurrence(&note, &cfg.reminders.default_morning) {
@ -372,28 +276,19 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
}
}
run_reminder_window(note, cfg, None)
run_reminder_window(note, cfg)
}
fn run_reminder_window(
note: breadpad_shared::types::Note,
cfg: &Config,
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();
fn run_reminder_window(note: breadpad_shared::types::Note, cfg: &Config) -> Result<()> {
let app = gtk4::Application::builder()
.application_id("com.breadway.breadpad.reminder")
.build();
let note = Arc::new(note);
let cfg = Arc::new(cfg.clone());
app.connect_activate(move |app| {
build_reminder_window(app, note.clone(), cfg.clone(), screenshot_req.clone());
build_reminder_window(app, note.clone(), cfg.clone());
});
app.run_with_args::<String>(&[]);
@ -436,7 +331,6 @@ fn build_reminder_window(
app: &gtk4::Application,
note: Arc<breadpad_shared::types::Note>,
cfg: Arc<Config>,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) {
let window = gtk4::ApplicationWindow::builder()
.application(app)
@ -451,7 +345,6 @@ fn build_reminder_window(
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::Exclusive);
window.auto_exclusive_zone_enable();
breadpad_shared::theme::bind_window(&window);
apply_css(&cfg);
@ -499,7 +392,7 @@ fn build_reminder_window(
let local: chrono::DateTime<chrono::Local> = t.into();
header.append(
&gtk4::Label::builder()
.label(local.format("%H:%M").to_string())
.label(&local.format("%H:%M").to_string())
.css_classes(["reminder-time"])
.build(),
);
@ -524,15 +417,14 @@ fn build_reminder_window(
.orientation(gtk4::Orientation::Horizontal)
.build());
// Button row — same inset as the header/body zone above (20px), which
// used to be 16px here, visible as a step across the divider.
// Button row
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(20)
.margin_end(20)
.margin_start(16)
.margin_end(16)
.build();
let dismiss_btn = gtk4::Button::builder()
@ -540,35 +432,23 @@ fn build_reminder_window(
.css_classes(["reminder-dismiss"])
.build();
// 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.
// Snooze popover
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()
.orientation(gtk4::Orientation::Vertical)
.spacing(0)
.margin_top(4)
.margin_bottom(4)
.spacing(4)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.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 {
let label = humanize_snooze(opt).to_string();
let btn = snooze_option_row(&label);
let btn = gtk4::Button::builder()
.label(&label)
.css_classes(["snooze-option"])
.build();
let key = opt.clone();
let note_c = note.clone();
let cfg_c = cfg.clone();
@ -588,47 +468,6 @@ fn build_reminder_window(
});
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));
let snooze_btn = gtk4::MenuButton::builder()
@ -668,48 +507,25 @@ fn build_reminder_window(
outer.append(&btn_row);
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();
}
fn run_popup(
preset_type: Option<String>,
no_classify: bool,
cfg: Config,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) -> Result<()> {
fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Result<()> {
// Try to get current Hyprland workspace
let workspace = get_active_workspace();
let mut builder = gtk4::Application::builder().application_id("com.breadway.breadpad");
if screenshot_req.is_some() {
// 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 app = gtk4::Application::builder()
.application_id("com.breadway.breadpad")
.build();
let cfg = Arc::new(cfg);
app.connect_activate(move |app| {
if screenshot_req.is_none() {
if let Some(win) = app.windows().first().cloned() {
win.close();
return;
}
}
build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify, screenshot_req.clone());
build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify);
});
let code = app.run_with_args::<String>(&[]);
@ -720,13 +536,14 @@ fn run_popup(
}
fn get_active_workspace() -> Option<String> {
// Use hyprctl via CLI since the async API would require a runtime here
let out = std::process::Command::new("hyprctl")
.args(["activeworkspace", "-j"])
.output()
.ok()?;
let val: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
val.get("id").and_then(|v| v.as_i64()).map(|id| id.to_string())
// Was a `Command::new("hyprctl").output()` call with no timeout (the
// `hyprland` crate's async API would require a runtime here, which this
// call site doesn't have) — bread_utils::hypr's socket1 client is
// synchronous and needs neither a subprocess nor a runtime.
bread_utils::hypr::request_json("j/activeworkspace")?
.get("id")
.and_then(|v| v.as_i64())
.map(|id| id.to_string())
}
fn build_window(
@ -735,7 +552,6 @@ fn build_window(
workspace: Option<String>,
preset_type: Option<String>,
no_classify: bool,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) {
let window = gtk4::ApplicationWindow::builder()
.application(app)
@ -754,7 +570,6 @@ fn build_window(
window.set_anchor(Edge::Bottom, false);
window.set_anchor(Edge::Left, false);
window.set_anchor(Edge::Right, false);
breadpad_shared::theme::bind_window(&window);
apply_css(&cfg);
@ -822,15 +637,44 @@ fn build_window(
}
}
// No submit button — this popup is keyboard-driven (grabs focus on
// open, Escape closes it) and a bare accent-teal checkmark used to sit
// next to the selected-type pill in the same accent teal, carrying two
// different meanings in one colour. A hint is enough.
let enter_hint = gtk4::Label::builder()
.label("Press Enter to add")
.css_classes(["dim-label"])
// Live prefix grammar: typing "td: ", "rem: ", "idea: ", "note: ", or
// "q: " at the start of the entry drives the chip selection without
// ever touching the mouse — the chips become feedback for what you
// typed rather than the only way to pick a type.
{
let selected_type_clone = selected_type.clone();
let chips_clone: Vec<(gtk4::Button, NoteType)> = chips.clone();
entry.connect_changed(move |e| {
let Some(nt) = breadpad_shared::parser::detect_prefix_type(&e.text()) else {
return;
};
*selected_type_clone.borrow_mut() = nt.clone();
for (btn, chip_nt) in &chips_clone {
if *chip_nt == nt {
btn.add_css_class("active");
} else {
btn.remove_css_class("active");
}
}
});
}
let hint = gtk4::Label::builder()
.label("td: · rem: · idea: · note: · q:")
.css_classes(["prefix-hint"])
.xalign(0.0)
.build();
// Confirm button
let confirm_btn = gtk4::Button::builder()
.label("")
.css_classes(["confirm-button"])
.build();
let confirm_wrap = gtk4::Box::builder()
.css_classes(["confirm-wrap"])
.build();
confirm_wrap.append(&confirm_btn);
let bottom_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
@ -839,9 +683,10 @@ fn build_window(
let spacer = gtk4::Box::builder().hexpand(true).build();
bottom_row.append(&spacer);
bottom_row.append(&enter_hint);
bottom_row.append(&confirm_wrap);
vbox.append(&entry);
vbox.append(&hint);
vbox.append(&bottom_row);
window.set_child(Some(&vbox));
@ -850,6 +695,7 @@ fn build_window(
let selected_type_clone = selected_type.clone();
let cfg_clone = cfg.clone();
let workspace_clone = workspace.clone();
let app_clone = app.clone();
let save_and_close = {
let win = win_clone.clone();
@ -857,6 +703,7 @@ fn build_window(
let selected_type = selected_type_clone.clone();
let cfg = cfg_clone.clone();
let workspace = workspace_clone.clone();
let app = app_clone.clone();
move || {
let text = entry.text().to_string();
@ -867,14 +714,30 @@ fn build_window(
let note_type = selected_type.borrow().clone();
let cfg_c = cfg.clone();
let ws_c = workspace.clone();
// Close first so the popup disappears immediately, then save.
// Close first so the popup disappears immediately, then save —
// but `hold()` the application across the gap first. Without
// this, closing the only open window can let the whole process
// (and with it, the `idle_add_local_once` callback below that
// actually writes the note) exit before that callback ever
// runs, silently losing the note the user just typed. `hold()`
// returns an RAII guard that keeps the app alive with zero
// windows open until it's dropped, right after the save
// finishes below.
let hold_guard = app.hold();
win.close();
glib::idle_add_local_once(move || {
save_note_classified(&text, note_type, no_classify, cfg_c, ws_c);
drop(hold_guard);
});
}
};
// Confirm button click
{
let save = save_and_close.clone();
confirm_btn.connect_clicked(move |_| save());
}
// Entry activate (Enter key)
{
let save = save_and_close.clone();
@ -893,10 +756,6 @@ fn build_window(
});
window.add_controller(key_ctrl);
if let Some(req) = screenshot_req {
screenshot::dispatch(&window, req);
}
window.present();
entry.grab_focus();
}
@ -943,7 +802,6 @@ fn save_note_classified(
tracing::error!("failed to save note: {}", e);
return;
}
emit_captured(&note.id);
if note.time.is_some() {
if let Err(e) = Scheduler::schedule(&note) {
tracing::warn!("failed to schedule reminder: {}", e);

View file

@ -1,110 +0,0 @@
//! `--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);
}
}
}

View file

@ -1 +0,0 @@
620c5a1317a6b57276eabca961facdb78bf510db

View file

@ -1,21 +0,0 @@
#!/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" "$@"

38
packaging/arch/PKGBUILD Normal file
View file

@ -0,0 +1,38 @@
# Maintainer: Breadway <plasticbread849@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://git.breadway.dev/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"
}