diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..b547c34 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +# Fast-fail lint/test on short-lived work branches, before it ever reaches +# main and triggers a dev-track release build. +on: + push: + branches: ['feature/**', 'fix/**'] + +jobs: + check: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: clippy + run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: test + run: cd src && bash ci/build.sh cargo test --workspace --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..11f75d2 --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,67 @@ +name: dev release + +on: + push: + branches: ['main'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch main --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && bash ci/build.sh cargo build --release --locked + + - name: compute dev version + run: | + set -euo pipefail + cd src + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/breadpad/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadpad breadman; do + cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp src/breadpad.example.toml "${PKG_DIR}/" + cp src/LICENSE "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadpad/latest" + + - name: regenerate dev index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 01b9bef..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml deleted file mode 100644 index fdfdef9..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build and publish package - -on: - push: - tags: ['v*'] - -jobs: - package: - runs-on: [self-hosted, hestia] - container: - image: archlinux:latest - steps: - # Note: no actions/checkout — the archlinux image has no Node, which JS - # actions require. Everything runs as shell steps and clones manually. - - name: Build and publish - env: - PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell - useradd -m builder - git config --global --add safe.directory '*' - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src - cd /home/builder/src - git archive --format=tar.gz --prefix="breadpad-${VERSION}/" HEAD \ - > packaging/arch/breadpad-${VERSION}.tar.gz - SHA=$(sha256sum packaging/arch/breadpad-${VERSION}.tar.gz | awk '{print $1}') - sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD - sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - chown -R builder:builder /home/builder/src - # --nocheck: packaging builds the artifact; tests belong in a CI job. - su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" - PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) - curl -fsS -X PUT \ - -H "Authorization: token ${PUBLISH_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${PKG}" \ - "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml new file mode 100644 index 0000000..aad2f82 --- /dev/null +++ b/.forgejo/workflows/rc-release.yml @@ -0,0 +1,51 @@ +name: beta (rc) release + +on: + push: + tags: ['v*-rc.*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && bash ci/build.sh cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/beta/breadpad/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadpad breadman; do + cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp src/breadpad.example.toml "${PKG_DIR}/" + cp src/LICENSE "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadpad/latest" + + - name: regenerate beta index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index c878cf4..bd7569b 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -16,7 +17,16 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: prepare artifacts run: | @@ -31,12 +41,19 @@ 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8df4368 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +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 diff --git a/.gitignore b/.gitignore index 9168541..3b0b041 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ svgs.txt # Rust/Cargo Cargo.lock dist/ + +# Local knowledge-graph cache +graphify-out/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dd2f2fd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# AGENTS.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push both when publishing. + +## CI +- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`. +- `dev-release.yml` — triggers on push to `main`. +- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggers on any other `v*` tag push. + +All four run on a self-hosted runner (`hestia`) inside a pinned Arch +container — not the host's native environment. The Containerfile/build +script are shared across bread-ecosystem products and live in +`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the +sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and +delegates to it. Nothing runs automatically on plain commits or PRs +beyond what's listed. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ac30b30 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,97 @@ +# Contributing + +`breadpad` — Quick-capture scratchpad and note viewer (breadman) with AI classification. + +Part of the bread ecosystem; this repo follows the same branch/release +workflow as every other ecosystem product. + +## Branches + +There is one long-lived branch: **`main`**. All day-to-day work lands here. +Every push to `main` automatically builds and publishes a **dev-track** +build (see Tracks below) — a real install you can test before cutting +anything more formal. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +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.+`) 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. diff --git a/Cargo.lock b/Cargo.lock index 147ec8a..4d85626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,18 +24,18 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -92,9 +92,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-broadcast" @@ -175,7 +175,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -215,7 +215,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -226,13 +226,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -304,8 +304,8 @@ dependencies = [ [[package]] name = "bread-onnx" -version = "0.3.0" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "anyhow", "bread-utils", @@ -317,22 +317,45 @@ dependencies = [ "ureq", ] +[[package]] +name = "bread-screenshots" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +dependencies = [ + "anyhow", + "bread-utils", + "tracing", +] + +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs 5.0.1", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "bread-theme" -version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs 5.0.1", "gtk4", + "libadwaita", "serde", "serde_json", ] [[package]] name = "bread-utils" -version = "0.3.0" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ + "bread-shared", "dirs 5.0.1", "serde", "serde_json", @@ -340,14 +363,18 @@ dependencies = [ [[package]] name = "breadman" -version = "0.5.0" +version = "0.5.3" dependencies = [ "anyhow", + "bread-screenshots", + "bread-theme", + "bread-utils", "breadpad-shared", "chrono", "dirs 5.0.1", "futures-channel", "gtk4", + "libadwaita", "serde", "serde_json", "tokio", @@ -357,9 +384,10 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.5.0" +version = "0.5.3" dependencies = [ "anyhow", + "bread-screenshots", "bread-utils", "breadpad-shared", "chrono", @@ -377,16 +405,15 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.5.0" +version = "0.5.3" dependencies = [ "anyhow", "bread-onnx", "bread-theme", - "bread-utils", "chrono", "dirs 5.0.1", + "gtk4", "ical", - "ndarray 0.16.1", "ort", "regex", "reqwest", @@ -405,7 +432,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.5.0" +version = "0.5.3" dependencies = [ "anyhow", "breadpad-shared", @@ -457,7 +484,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -471,9 +498,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.3.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex", @@ -550,9 +577,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -560,9 +587,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -572,14 +599,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -791,7 +818,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -802,7 +829,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -832,7 +859,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -842,7 +869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.119", ] [[package]] @@ -864,7 +891,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", "unicode-xid", ] @@ -922,13 +949,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -942,9 +969,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "encode_unicode" @@ -976,7 +1003,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1006,11 +1033,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1038,9 +1064,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "field-offset" @@ -1054,9 +1080,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -1085,24 +1111,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1111,9 +1137,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1130,32 +1156,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -1187,7 +1213,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1220,7 +1246,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1298,7 +1324,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", "windows-sys 0.61.2", ] @@ -1352,7 +1378,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1362,7 +1388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1373,7 +1399,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1394,7 +1420,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1425,7 +1451,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1451,9 +1477,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" dependencies = [ "bitflags", "gdk4", @@ -1466,15 +1492,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps", + "system-deps 8.0.0", ] [[package]] @@ -1486,7 +1512,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1505,7 +1531,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1546,16 +1572,16 @@ dependencies = [ "rand 0.9.5", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "ureq", "windows-sys 0.60.2", ] [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1573,9 +1599,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1592,9 +1618,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1660,7 +1686,7 @@ dependencies = [ "either", "futures-lite", "hyprland-macros", - "pastey", + "pastey 0.1.1", "serde", "serde_json", "serde_repr", @@ -1675,7 +1701,7 @@ checksum = "31157e6ccefbad4b0cd7e549db6696691a70c11b108f26bf6bf76eef26af8c10" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1713,9 +1739,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1727,9 +1753,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1740,9 +1766,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1754,16 +1780,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1774,15 +1801,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -1858,9 +1885,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -1885,9 +1912,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1907,10 +1934,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "libc" -version = "0.2.186" +name = "libadwaita" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "85b9900e67182a4b5b1f157b448d94f0715c8b9770cce21cf000801917f53bfa" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3c27642b389852aa99341bd4a4c19ec6f8a2b63ebdd7f5ba1952198079ccd" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps 7.0.8", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -1924,9 +1981,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -1939,9 +1996,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -1972,19 +2029,19 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "macro_rules_attribute" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" dependencies = [ "macro_rules_attribute-proc_macro", - "paste", + "pastey 0.2.3", ] [[package]] name = "macro_rules_attribute-proc_macro" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" [[package]] name = "matchers" @@ -2066,22 +2123,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "ndarray" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", + "syn 2.0.119", ] [[package]] @@ -2142,9 +2184,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -2216,12 +2258,12 @@ dependencies = [ [[package]] name = "ort" -version = "2.0.0-rc.12" +version = "2.0.0-rc.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b" dependencies = [ "libloading", - "ndarray 0.17.2", + "ndarray", "ort-sys", "smallvec", "tracing", @@ -2229,9 +2271,9 @@ dependencies = [ [[package]] name = "ort-sys" -version = "2.0.0-rc.12" +version = "2.0.0-rc.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" +checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872" [[package]] name = "pango" @@ -2253,7 +2295,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -2306,6 +2348,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2369,9 +2417,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "polling" @@ -2389,9 +2437,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -2404,9 +2452,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2431,9 +2479,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2452,7 +2500,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -2474,7 +2522,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -2496,9 +2544,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2665,7 +2713,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -2682,9 +2730,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2793,9 +2841,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", @@ -2808,9 +2856,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2818,9 +2866,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "ring", "rustls-pki-types", @@ -2853,9 +2901,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2863,29 +2911,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2896,13 +2944,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -3074,6 +3122,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3091,7 +3150,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3103,7 +3162,20 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "system-deps" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", "version-compare", ] @@ -3137,11 +3209,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -3152,18 +3224,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -3177,9 +3249,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -3230,7 +3302,7 @@ dependencies = [ "serde", "serde_json", "spm_precompiled", - "thiserror 2.0.18", + "thiserror 2.0.20", "unicode-normalization-alignments", "unicode-segmentation", "unicode_categories", @@ -3238,9 +3310,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3256,13 +3328,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -3289,9 +3361,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -3348,9 +3420,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -3431,7 +3503,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3592,9 +3664,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -3645,9 +3717,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -3658,9 +3730,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -3668,9 +3740,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3678,31 +3750,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -3779,7 +3851,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3790,7 +3862,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4074,9 +4146,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xdg-home" @@ -4090,9 +4162,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "yoke" @@ -4113,7 +4185,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -4159,7 +4231,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zvariant_utils", ] @@ -4176,22 +4248,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4211,7 +4283,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -4223,9 +4295,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -4234,9 +4306,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -4245,13 +4317,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -4282,7 +4354,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zvariant_utils", ] @@ -4294,5 +4366,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] diff --git a/Cargo.toml b/Cargo.toml index 2790c28..b9508be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.5.0" +version = "0.5.3" edition = "2021" license = "MIT" authors = ["Breadway"] @@ -24,12 +24,9 @@ chrono = { version = "0.4", features = ["serde"] } rrule = "0.12" tokio = { version = "1", features = ["full"] } zbus = { version = "4", default-features = false, features = ["tokio"] } -ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] } -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. +# 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"] } tokenizers = { version = "0.23", default-features = false, features = ["http", "fancy-regex"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..428599c --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,70 @@ +# breadpad — bread event integration + +breadpad is a standalone capture popup: it works exactly the same with or +without `breadd` running. When breadd *is* present, the `breadpad` binary +publishes events into the shared bread automation fabric after actions that +already happened. See the parent `bread` repo's `Documentation.md` — +specifically its "Namespaces" and "Integrating a bread\* app" sections — for +the general convention this follows. + +App id: **`pad`**. Transport: `bread-utils`'s `bread_client` module +(feature `bread-client`) — the capture popup links it directly. One-shot +popup / `fire ` 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": "" }` | 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": "" }` | `breadpad fire ` 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-.timer` → `breadpad fire `), 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": "" }` | `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 ` (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. diff --git a/README.md b/README.md index c7797ce..e9f4498 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ breadman GTK4 note viewer / manager ### Classification -Every note passes through a three-tier pipeline at capture time: +Every note passes through a three-tier pipeline at capture time. **Tier 1 is the only tier that ships with breadpad** — the ONNX classifier model is not bundled, and Ollama is optional. Capture works without either. -1. **Rule-based parser** — always runs first; handles time extraction ("at 7pm", "in 30 minutes", "tomorrow morning", "next Friday"), recurrence ("every Sunday at 9pm", "every weekday morning"), and strong type signals ("?" → question, "idea:" prefix → idea, action verbs → todo). High-confidence results skip the remaining tiers entirely. -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. +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. 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 drawn from `snooze_options` (default: 15 min / 1 hour / tomorrow morning); snoozing reschedules the timer without touching the original note +- **Snooze** — notification popup includes snooze actions: 15 min / 1 hour / tomorrow morning / custom; 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 +#### Tier 2 — Small local ONNX model (optional, not shipped) -Runs when Tier 1 confidence is below threshold. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved. +Runs when Tier 1 confidence is below threshold **and** a compatible `classifier.onnx` + `tokenizer.json` are present. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved. -Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`. +Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`. Without a model file or runtime library, Tier 2 is skipped and Tier 1 (plus optional Tier 3) still works. #### Tier 3 — Large local model via Ollama @@ -123,7 +123,7 @@ If Ollama is unreachable or returns an invalid response, breadpad logs a warning ~/.local/share/breadpad/model/tokenizer.json ``` -breadpad ships without a bundled model. Drop a compatible ONNX classifier and `tokenizer.json` at those paths, then configure `model.ort_dylib_path` to point at your ONNX Runtime library. +**breadpad does not ship a classifier model.** Tier 1 rules work with no extra files. If you want Tier 2, drop a compatible ONNX classifier and `tokenizer.json` at those paths and point `model.ort_dylib_path` at your ONNX Runtime library. ```bash 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):** 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. +- **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. --- @@ -151,10 +151,14 @@ cd breadpad cargo build --release cp target/release/breadpad ~/.local/bin/ cp target/release/breadman ~/.local/bin/ +``` -# Place your ONNX classifier and tokenizer in the model directory +That's enough for capture + viewing. Tier 1 classification works out of the box. The ONNX classifier is **not** included — only add it if you want Tier 2: + +```bash mkdir -p ~/.local/share/breadpad/model -# Then set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so +# Drop your own classifier.onnx + tokenizer.json in that directory, then +# set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so ``` On Arch Linux, install GTK4 dependencies first: @@ -183,7 +187,7 @@ ort_dylib_path = "" # optional: explicit path to libonnxruntime.so; [model.ollama] endpoint = "http://localhost:11434" -model = "fastflowlm" # any model you have pulled in Ollama +model = "llama3.2:3b" # 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 @@ -243,15 +247,12 @@ breadpad --no-classify # Show model and storage status breadpad --status - -# Print expected model paths (does not download automatically) -breadpad download-model ``` -Hyprland keybind: +Hyprland keybind (BOS default is Super+U; bind whatever you want): ``` -bind = $mainMod, N, exec, breadpad +bind = $mainMod, U, exec, breadpad ``` ### breadman (viewer) diff --git a/bakery.toml b/bakery.toml index 25e9033..f7a339e 100644 --- a/bakery.toml +++ b/bakery.toml @@ -4,6 +4,7 @@ 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" diff --git a/breadman/Cargo.toml b/breadman/Cargo.toml index 577f684..d47a586 100644 --- a/breadman/Cargo.toml +++ b/breadman/Cargo.toml @@ -11,6 +11,13 @@ 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 diff --git a/breadman/src/editor.rs b/breadman/src/editor.rs index a01dfa5..e39b11c 100644 --- a/breadman/src/editor.rs +++ b/breadman/src/editor.rs @@ -1,3 +1,8 @@ +//! Note editor, presented as an AdwDialog (was a bare GtkPopover with no +//! scrim, no title, anchored wherever the triggering button happened to be - +//! flagged in design review as the weakest surface in the app). AdwDialog +//! gives us the scrim, the title, and correct modal anchoring for free. + use breadpad_shared::{ parser::parse_rule_based, scheduler::Scheduler, @@ -6,48 +11,73 @@ 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; -pub fn build_editor_popover( +/// Same wording used by `main::show_add_note_window`'s New Note dialog - the +/// two surfaces used to teach the user two different input languages for +/// the same fields. +pub const TIME_PLACEHOLDER: &str = "tomorrow 9am / at 7pm / 2026-08-01 09:00"; +pub const RRULE_PLACEHOLDER: &str = "RRULE:FREQ=WEEKLY;BYDAY=MO"; + +/// Builds the dialog but does not present it - callers that need to hook +/// its `map` signal (screenshot mode) must connect before presenting or +/// they miss the signal entirely; interactive callers present immediately +/// with `dialog.present(Some(parent))`. +pub fn open_editor( note: &Note, store: Arc, morning: String, on_save: Rc, on_delete: Rc, on_error: Rc, -) -> gtk4::Popover { - let popover = gtk4::Popover::new(); - popover.set_has_arrow(false); +) -> libadwaita::Dialog { + let dialog = libadwaita::Dialog::builder() + .title("Edit Note") + .content_width(480) + .content_height(520) + .build(); - let vbox = gtk4::Box::builder() + let header = libadwaita::HeaderBar::new(); + let toolbar_view = libadwaita::ToolbarView::new(); + toolbar_view.add_top_bar(&header); + + let content = gtk4::Box::builder() .orientation(gtk4::Orientation::Vertical) - .spacing(8) - .margin_top(12) - .margin_bottom(12) - .margin_start(12) - .margin_end(12) - .width_request(420) + .spacing(16) + .margin_top(16) + .margin_bottom(16) + .margin_start(16) + .margin_end(16) .build(); - vbox.append(>k4::Label::builder().label("Body").xalign(0.0).build()); - let body_entry = gtk4::Entry::builder() - .text(¬e.body) - .hexpand(true) - .build(); - vbox.append(&body_entry); + let group = libadwaita::PreferencesGroup::builder().title("Details").build(); - vbox.append(>k4::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); + let body_row = libadwaita::EntryRow::builder().title("Body").build(); + body_row.set_text(¬e.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> = 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 = 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(>k4::Label::builder().label("Time").xalign(0.0).build()); let time_text = note .time .map(|t| { @@ -55,38 +85,44 @@ pub fn build_editor_popover( local.format("%Y-%m-%d %H:%M").to_string() }) .unwrap_or_default(); - 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(); - vbox.append(&time_entry); + 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); - vbox.append(>k4::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); + 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); - // Button row: [Delete] [Save] - let btn_row = gtk4::Box::builder() - .orientation(gtk4::Orientation::Horizontal) - .spacing(8) - .build(); + content.append(&group); - 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) + let hint = gtk4::Label::builder() + .label(format!("Time: {TIME_PLACEHOLDER}\nRecurrence: {RRULE_PLACEHOLDER}")) + .css_classes(["dim-label"]) + .xalign(0.0) + .wrap(true) .build(); + content.append(&hint); + + let btn_row = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(8).build(); + let delete_btn = gtk4::Button::builder().label("Delete").css_classes(["destructive-action"]).build(); + let save_btn = gtk4::Button::builder().label("Save").css_classes(["confirm-button"]).hexpand(true).build(); btn_row.append(&delete_btn); btn_row.append(&save_btn); - vbox.append(&btn_row); + content.append(&btn_row); + + let scroll = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .vscrollbar_policy(gtk4::PolicyType::Automatic) + .vexpand(true) + .min_content_height(400) + .build(); + scroll.set_child(Some(&content)); + toolbar_view.set_content(Some(&scroll)); + dialog.set_child(Some(&toolbar_view)); // Delete: two-click confirm let confirming = Rc::new(RefCell::new(false)); @@ -95,7 +131,7 @@ pub fn build_editor_popover( let delete_btn_label = delete_btn.clone(); let note_id = note.id.clone(); let store_del = store.clone(); - let popover_del = popover.clone(); + let dialog_del = dialog.clone(); let on_delete = Rc::clone(&on_delete); let on_error = Rc::clone(&on_error); @@ -105,7 +141,7 @@ pub fn build_editor_popover( let id = note_id.clone(); let on_delete = Rc::clone(&on_delete); let on_error = Rc::clone(&on_error); - let popover = popover_del.clone(); + let dialog = dialog_del.clone(); spawn_bg( move || -> anyhow::Result<()> { store.delete_note(&id)?; @@ -119,7 +155,7 @@ pub fn build_editor_popover( Ok(()) => on_delete(), Err(e) => on_error(format!("delete failed: {}", e)), } - popover.popdown(); + dialog.close(); }, ); } else { @@ -132,33 +168,20 @@ pub fn build_editor_popover( // Save { let note_clone = note.clone(); - let popover_save = popover.clone(); + let dialog_save = dialog.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_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)) - }; + 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)) }; - popover_save.popdown(); + dialog_save.close(); let store_bg = store.clone(); let on_save = Rc::clone(&on_save); @@ -182,8 +205,7 @@ pub fn build_editor_popover( }); } - popover.set_child(Some(&vbox)); - popover + dialog } fn spawn_bg(work: F, then: C) diff --git a/breadman/src/main.rs b/breadman/src/main.rs index e132a80..efd5b3f 100644 --- a/breadman/src/main.rs +++ b/breadman/src/main.rs @@ -6,23 +6,52 @@ use breadpad_shared::{ store::Store, types::{Note, NoteType, RecurrenceRule}, }; -use chrono::{DateTime, Local, Utc}; +use chrono::Local; 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, pub done_id: Option, pub upcoming_plain: bool, + pub screenshot: Option, + pub output: Option, + 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 { + 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 { @@ -30,6 +59,10 @@ mod args { view: None, done_id: None, upcoming_plain: false, + screenshot: None, + output: None, + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, }; let raw: Vec = std::env::args().skip(1).collect(); let mut i = 0; @@ -50,6 +83,26 @@ 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; @@ -60,30 +113,22 @@ mod args { // ── AppState ────────────────────────────────────────────────────────────────── +type ErrorLog = Rc, String)>>>; + /// Shared UI state, cheap to clone (all fields are Rc/Arc). #[derive(Clone)] struct AppState { store: Arc, notes: Rc>>, cfg: Rc>, - errors: Rc, String)>>>, + errors: ErrorLog, active_view: Rc>, stack: gtk4::Stack, - /// 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>>, - status_label: gtk4::Label, + window: gtk4::ApplicationWindow, } impl AppState { - fn new( - store: Arc, - notes: Vec, - cfg: Config, - stack: gtk4::Stack, - status_label: gtk4::Label, - ) -> Self { + fn new(store: Arc, notes: Vec, cfg: Config, stack: gtk4::Stack, window: gtk4::ApplicationWindow) -> Self { AppState { store, notes: Rc::new(RefCell::new(notes)), @@ -91,8 +136,7 @@ impl AppState { errors: Rc::new(RefCell::new(Vec::new())), active_view: Rc::new(RefCell::new("all".to_string())), stack, - sidebar_counts: Rc::new(RefCell::new(Vec::new())), - status_label, + window, } } @@ -158,7 +202,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()); + let scroll = build_note_list(notes, state.clone(), true, "No notes yet — jot something down to get started.", Some(NoteType::Note)); state.stack.add_named(&scroll, Some("all")); } @@ -172,14 +216,22 @@ fn rebuild_stack(state: &AppState) { let errors: Vec<_> = state.errors.borrow().clone(); // All - let all_scroll = build_note_list(¬es, state.clone()); + let all_scroll = build_note_list(¬es, state.clone(), true, "No notes yet — jot something down to get started.", Some(NoteType::Note)); state.stack.add_named(&all_scroll, Some("all")); // Upcoming - let upcoming = views::upcoming::build(¬es); + let upcoming = views::upcoming::build(¬es, state.clone()); 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 = notes @@ -187,7 +239,7 @@ fn rebuild_stack(state: &AppState) { .filter(|n| n.note_type == nt && !n.done) .cloned() .collect(); - let scroll = build_note_list(&filtered, state.clone()); + let scroll = build_note_list(&filtered, state.clone(), false, empty_text(type_name), Some(nt)); state.stack.add_named(&scroll, Some(type_name)); } @@ -205,40 +257,6 @@ 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 ───────────────────────────────────────────────────────────────────── @@ -261,7 +279,9 @@ fn main() -> Result<()> { return cmd_upcoming_plain(); } - run_app(args.view, cfg) + let screenshot_req = args.screenshot_request(); + let view = screenshot_req.as_ref().map(|r| r.view.clone()).or(args.view); + run_app(view, cfg, screenshot_req) } fn cmd_done(id: &str) -> Result<()> { @@ -297,10 +317,20 @@ fn cmd_upcoming_plain() -> Result<()> { Ok(()) } -fn run_app(initial_view: Option, cfg: Config) -> Result<()> { - let app = gtk4::Application::builder() - .application_id("com.breadway.breadman") - .build(); +fn run_app( + initial_view: Option, + cfg: Config, + screenshot_req: Option, +) -> Result<()> { + let mut builder = gtk4::Application::builder().application_id("com.breadway.breadman"); + if screenshot_req.is_some() { + // GApplication is single-instance by default; this machine typically + // already has a real breadman instance, so without this a + // screenshot run would activate the *existing* instance instead of + // starting a fresh one that ever sees `screenshot_req`. + builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE); + } + let app = builder.build(); let cfg = Arc::new(cfg); let initial_view = Arc::new(initial_view); @@ -308,7 +338,7 @@ fn run_app(initial_view: Option, cfg: Config) -> Result<()> { 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) { + if let Err(e) = build_app_window(app, cfg, initial_view, screenshot_req.clone()) { tracing::error!("failed to build window: {}", e); } }); @@ -326,8 +356,13 @@ fn build_app_window( app: >k4::Application, cfg: Config, initial_view: Option, + screenshot_req: Option, ) -> 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()?; @@ -338,6 +373,7 @@ 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) @@ -378,12 +414,11 @@ fn build_app_window( )); row }; - // 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| { + // One icon language throughout (was 8 full-colour emoji + 2 thin + // monochrome glyphs for Settings/Errors + a third style on row action + // buttons) — every sidebar entry and row action now uses a real GTK + // symbolic icon. + let make_item = |id: &str, icon_name: &str, label: &str| { let row = gtk4::ListBoxRow::builder() .css_classes(["sidebar-row"]) .build(); @@ -392,15 +427,7 @@ fn build_app_window( .orientation(gtk4::Orientation::Horizontal) .spacing(10) .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(>k4::Image::builder().icon_name(icon_name).pixel_size(16).build()); hbox.append( >k4::Label::builder() .label(label) @@ -408,62 +435,23 @@ 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, count_label) + row }; - let mut sidebar_counts: Vec<(String, gtk4::Label)> = Vec::new(); - sidebar_list.append(&make_section("VIEWS")); - { - 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_item("all", "view-list-symbolic", "All")); + sidebar_list.append(&make_item("upcoming", "x-office-calendar-symbolic", "Upcoming")); sidebar_list.append(&make_section("TYPES")); - 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_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")); sidebar_list.append(&make_section("MORE")); - { - 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_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")); sidebar_vbox.append(&sidebar_list); // ── Content area ────────────────────────────────────────────── @@ -483,19 +471,8 @@ 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(>k4::Separator::builder() @@ -505,8 +482,7 @@ fn build_app_window( window.set_child(Some(&hbox)); // ── AppState ────────────────────────────────────────────────── - let state = AppState::new(store, notes, cfg, stack.clone(), status_label.clone()); - state.sidebar_counts.replace(sidebar_counts); + let state = AppState::new(store, notes, cfg, stack.clone(), window.clone()); // Initial build rebuild_stack(&state); @@ -514,11 +490,15 @@ 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); } }); @@ -550,13 +530,14 @@ 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()); + show_add_note_window(&window_c, state_c.clone(), NoteType::Note, |_| {}); }); } // ── 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() @@ -570,59 +551,55 @@ 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 & cards ───────────────────────────────────────────────────────── +// ── Note list ───────────────────────────────────────────────────────────────── +// Row rendering itself lives in views::row (shared with Upcoming/Archive) — +// design review found the old two-line card here wasted enormous horizontal +// space (title and actions separated by ~800-1000px of dead middle) compared +// to Archive's tighter single-line layout, so all list views now share one +// row template. -fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow { +fn build_note_list( + notes: &[Note], + state: AppState, + show_type_badge: bool, + empty_text: &str, + empty_new_type: Option, +) -> 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(8) - .margin_top(12) - .margin_bottom(12) - .margin_start(12) - .margin_end(12) - .width_request(700) - .halign(gtk4::Align::Center) + .spacing(4) + .margin_top(8) + .margin_bottom(8) .build(); let mut sorted: Vec = notes.iter().filter(|n| !n.done).cloned().collect(); - sorted.sort_by(|a, b| b.created.cmp(&a.created)); + sorted.sort_by_key(|n| std::cmp::Reverse(n.created)); if sorted.is_empty() { - let empty = gtk4::Box::builder() - .orientation(gtk4::Orientation::Vertical) - .spacing(6) - .halign(gtk4::Align::Center) - .margin_top(64) - .build(); - empty.append( - >k4::Label::builder() - .label("No notes here yet") - .css_classes(["note-title"]) - .build(), - ); - empty.append( - >k4::Label::builder() - .label("Capture something with breadpad and it'll show up here.") - .css_classes(["dim-label"]) - .build(), - ); - list.append(&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)); } else { for note in &sorted { - list.append(&build_note_card(note, state.clone())); + let created_str = { + let local: chrono::DateTime = 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())); } } @@ -630,260 +607,16 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow { 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) -> 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 = 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(¬e.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 = 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) = ¬e.workspace { - bottom_row.append( - >k4::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 = t.into(); - bottom_row.append( - >k4::Label::builder() - .label(&local.format("⏰ %b %d %H:%M").to_string()) - .css_classes(["dim-label"]) - .build(), - ); - } - if note.rrule.is_some() { - bottom_row.append( - >k4::Label::builder() - .label("↻") - .css_classes(["type-chip"]) - .build(), - ); - } - - bottom_row.append(>k4::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> { - 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( - ¬e_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> { - 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: >k4::ApplicationWindow, state: AppState) { +fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState, preselect: NoteType, on_build: impl FnOnce(>k4::Window)) { 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) @@ -901,48 +634,42 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) { .build(); vbox.append(&body_entry); - // Type chips + // Type pills — same chip widget the editor dialog and settings screen + // use, instead of three different type-picker widgets across the app. + vbox.append(>k4::Label::builder().label("Type").xalign(0.0).build()); let chip_box = gtk4::Box::builder() .orientation(gtk4::Orientation::Horizontal) .spacing(4) .build(); - let selected_type: Rc> = Rc::new(RefCell::new(NoteType::Note)); + let selected_type: Rc> = Rc::new(RefCell::new(preselect.clone())); let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin() .iter() - .map(|&name| { - let btn = gtk4::Button::builder() - .label(name) - .css_classes(["type-chip"]) - .build(); - (btn, NoteType::from_str(name)) - }) + .map(|&name| (bread_theme::gtk::chip(name), 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 = chips.iter().map(|(b, _)| b.clone()).collect(); btn.connect_clicked(move |clicked| { *sel.borrow_mut() = nt_c.clone(); - for b in &all_btns { b.remove_css_class("active"); } - clicked.add_css_class("active"); + for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); } + bread_theme::gtk::set_chip_active(clicked, true); }); 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(>k4::Label::builder().label("Time (optional)").xalign(0.0).build()); let time_entry = gtk4::Entry::builder() - .placeholder_text("tomorrow 9am / at 7pm / in 30 minutes") + .placeholder_text(editor::TIME_PLACEHOLDER) .hexpand(true) .build(); vbox.append(&time_entry); vbox.append(>k4::Label::builder().label("Recurrence (optional)").xalign(0.0).build()); let rrule_entry = gtk4::Entry::builder() - .placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO") + .placeholder_text(editor::RRULE_PLACEHOLDER) .hexpand(true) .build(); vbox.append(&rrule_entry); @@ -1062,6 +789,7 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) { }); } + on_build(&win); win.present(); body_entry.grab_focus(); } diff --git a/breadman/src/screenshot.rs b/breadman/src/screenshot.rs new file mode 100644 index 0000000..a335369 --- /dev/null +++ b/breadman/src/screenshot.rs @@ -0,0 +1,132 @@ +//! `--screenshot` CLI mode: render the named view, capture it via +//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s +//! `bread-capture` orchestrator, or run standalone for one-off captures. +//! +//! No clap here, same reasoning as breadpad: extends breadman's own +//! hand-rolled `mod args` instead of bolting on a second parser that would +//! reject its real flags (`--view`, `done`, `upcoming --plain`). +//! +//! `--screenshot ` 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: >k4::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( + ¬e, + 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::())); + }); + }); + 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); + } + } +} diff --git a/breadman/src/views/archive.rs b/breadman/src/views/archive.rs index b4cd75f..baa4fb1 100644 --- a/breadman/src/views/archive.rs +++ b/breadman/src/views/archive.rs @@ -1,7 +1,6 @@ +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() @@ -18,92 +17,28 @@ pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow { .build(); let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect(); - archived.sort_by(|a, b| b.created.cmp(&a.created)); + // Sort by completion time, not creation time - the previous sort used + // `created` while the row displayed `completed` ("done {date}"), which + // is why the last row could appear out of order against the visible + // dates. + archived.sort_by_key(|n| std::cmp::Reverse(n.completed.unwrap_or(n.created))); if archived.is_empty() { - list.append( - >k4::Label::builder() - .label("Archive is empty.") - .margin_top(32) - .build(), - ); + list.append(&build_empty_state("folder-symbolic", "Nothing archived yet.", None)); } else { for note in archived { - list.append(&build_archive_card(note, state.clone())); + let completed_str = note + .completed + .map(|t| { + let local: chrono::DateTime = t.into(); + 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())); } } 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| { - let local: chrono::DateTime = t.into(); - format!("done {}", local.format("%b %d")) - }) - .unwrap_or_else(|| "done".into()); - - let done_label = gtk4::Label::builder() - .label(&completed_str) - .width_chars(12) - .xalign(0.0) - .build(); - - let body_label = gtk4::Label::builder() - .label(¬e.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(¬e_id) { - state.log_error(format!("delete failed: {}", e)); - } - row_c.set_visible(false); - state.reload_notes(); - } else { - *confirming.borrow_mut() = true; - btn_c.set_label("Sure?"); - } - }); - } - - row.append(&done_label); - row.append(&body_label); - row.append(&type_label); - row.append(&delete_btn); - row -} diff --git a/breadman/src/views/errors.rs b/breadman/src/views/errors.rs index 1be64e0..cedb9ff 100644 --- a/breadman/src/views/errors.rs +++ b/breadman/src/views/errors.rs @@ -35,7 +35,7 @@ pub fn build(entries: &[(DateTime, 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"]) diff --git a/breadman/src/views/mod.rs b/breadman/src/views/mod.rs index d4cde84..43a6788 100644 --- a/breadman/src/views/mod.rs +++ b/breadman/src/views/mod.rs @@ -1,4 +1,5 @@ pub mod archive; pub mod errors; +pub mod row; pub mod settings; pub mod upcoming; diff --git a/breadman/src/views/row.rs b/breadman/src/views/row.rs new file mode 100644 index 0000000..501b8a0 --- /dev/null +++ b/breadman/src/views/row.rs @@ -0,0 +1,268 @@ +//! Shared single-line note row, used by every list view (All/Upcoming/ +//! per-type/Archive) instead of each view hand-rolling its own card. Design +//! review found the two-line card (title/badge top, huge dead gap, actions +//! bottom-right) used by the active views wasted enormous horizontal space +//! compared to Archive's tighter aligned-column layout - this ports that +//! layout everywhere and unifies the row template (including the edit +//! affordance, previously pencil-in-active / click-row-in-archive). + +use breadpad_shared::types::{Note, NoteType}; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use std::rc::Rc; + +pub struct RowSpec<'a> { + pub date_label: String, + pub note: &'a Note, + pub show_type_badge: bool, + pub show_done: bool, +} + +/// Type-tinted badge class matching the `note-card-{type}` accent-bar colors +/// already established in breadpad-shared's theme (todo=green, +/// reminder=yellow, idea=pink, question=teal, note=blue). +fn type_chip_class(note_type: &NoteType) -> &'static str { + match note_type { + NoteType::Todo => "type-chip-todo", + NoteType::Reminder => "type-chip-reminder", + NoteType::Idea => "type-chip-idea", + NoteType::Note => "type-chip-note", + NoteType::Question => "type-chip-question", + NoteType::Tag(_) => "type-chip", + } +} + +pub fn build(spec: RowSpec, state: crate::AppState) -> gtk4::Box { + let note = spec.note; + let row = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(8) + .margin_start(8) + .margin_end(8) + .margin_top(2) + .margin_bottom(2) + .css_classes(["note-card"]) + .build(); + row.add_css_class(&format!("note-card-{}", note.note_type.as_str())); + + let date_label = gtk4::Label::builder() + .label(&spec.date_label) + .width_chars(16) + .xalign(0.0) + .css_classes(["dim-label"]) + .build(); + row.append(&date_label); + + let body_label = gtk4::Label::builder() + .label(¬e.body) + .hexpand(true) + .xalign(0.0) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + row.append(&body_label); + + if let Some(ws) = ¬e.workspace { + row.append( + >k4::Label::builder() + .label(format!("ws:{}", ws)) + .css_classes(["type-chip"]) + .build(), + ); + } + if note.rrule.is_some() { + row.append(>k4::Label::builder().label("\u{21bb}").css_classes(["dim-label"]).build()); + } + + if spec.show_type_badge { + row.append( + >k4::Label::builder() + .label(note.note_type.as_str()) + .css_classes(["type-chip", type_chip_class(¬e.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> { + 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( + ¬e_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::())); + }); + } + 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> { + 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)>) -> 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) { + let label = format!("+ New {}", note_type.as_str()); + let action: Rc = Rc::new(move || { + crate::show_add_note_window(&window, state.clone(), note_type.clone(), |_| {}); + }); + (label, action) +} diff --git a/breadman/src/views/settings.rs b/breadman/src/views/settings.rs index 8cf49c8..6090274 100644 --- a/breadman/src/views/settings.rs +++ b/breadman/src/views/settings.rs @@ -1,7 +1,80 @@ +//! Settings screen. Deliberately plain GTK4, not libadwaita's AdwActionRow/ +//! AdwSpinRow/AdwEntryRow family — those ran noticeably taller than the rest +//! of the app and don't expose a way to constrain the internal spin +//! button's width from the outside (it stretches to fill whatever room the +//! row has, leaving the digits and +/- buttons stranded behind a huge empty +//! bordered box once the row is wider than libadwaita's usual ~400-600px +//! home turf). Instead this mirrors bos-settings' own Row.svelte / +//! NumberField.svelte / TextField.svelte design exactly — same tokens +//! (12/16px row padding, ch-width inputs, transparent-at-rest border) — so +//! the two settings screens in the ecosystem actually agree with each other. + use breadpad_shared::config::{ CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings, }; -use gtk4::prelude::*; +use breadpad_shared::types::NoteType; +use gtk4::{glib, prelude::*}; +use std::cell::RefCell; +use std::rc::Rc; + +/// A titled group: heading, optional description, then a `.boxed-list` of +/// `field_row`s (native GTK4 rounded-corner-run + divider styling). +fn field_group(title: &str, description: Option<&str>) -> (gtk4::Box, gtk4::ListBox) { + let outer = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(8).build(); + + let heading = gtk4::Label::builder().label(title).xalign(0.0).css_classes(["heading"]).build(); + outer.append(&heading); + + if let Some(desc) = description { + let desc_label = gtk4::Label::builder() + .label(desc) + .xalign(0.0) + .wrap(true) + .css_classes(["dim-label"]) + .build(); + outer.append(&desc_label); + } + + let list = gtk4::ListBox::builder() + .selection_mode(gtk4::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + outer.append(&list); + + (outer, list) +} + +/// A single row: label (+ optional subtitle) on the left, one control on +/// the right — same shape as bos-settings' `Row.svelte`. +fn field_row(label: &str, subtitle: Option<&str>, control: &impl IsA) -> gtk4::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(>k4::Label::builder().label(label).xalign(0.0).build()); + if let Some(sub) = subtitle { + label_box.append(>k4::Label::builder().label(sub).xalign(0.0).wrap(true).css_classes(["field-row-subtitle"]).build()); + } + hbox.append(&label_box); + hbox.append(control); + + row.set_child(Some(&hbox)); + row +} + +fn text_entry(text: &str, width_chars: i32) -> gtk4::Entry { + gtk4::Entry::builder().text(text).width_chars(width_chars).valign(gtk4::Align::Center).css_classes(["field-input"]).build() +} + +fn spin_button(value: f64, min: f64, max: f64, step: f64, page: f64, digits: u32) -> gtk4::SpinButton { + let adj = gtk4::Adjustment::new(value, min, max, step, page, 0.0); + gtk4::SpinButton::builder().adjustment(&adj).digits(digits).width_chars(8).valign(gtk4::Align::Center).css_classes(["field-input"]).build() +} pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow { let scroll = gtk4::ScrolledWindow::builder() @@ -10,255 +83,242 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled .vexpand(true) .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(); + let content = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(24).build(); // ── General ────────────────────────────────────────────────── - let (general_frame, general_grid) = make_section("General"); + let (general_group, general_list) = field_group("General", None); - let type_options = ["note", "todo", "reminder", "idea", "question"]; - let default_type_combo = gtk4::DropDown::from_strings(&type_options); - let dt_idx = type_options + let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build(); + let selected_type: Rc> = Rc::new(RefCell::new(cfg.settings.default_type.clone())); + let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin() .iter() - .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); + .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)); - 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 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 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 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 snooze_entry = gtk4::Entry::builder() - .text(&cfg.settings.snooze_options.join(", ")) - .hexpand(true) - .build(); - attach_row(&general_grid, 3, "Snooze options", &snooze_entry); + 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)); - outer.append(&general_frame); + content.append(&general_group); // ── Reminders ──────────────────────────────────────────────── - let (rem_frame, rem_grid) = make_section("Reminders"); + let (rem_group, rem_list) = field_group("Reminders", None); - 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 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 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); + 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)); - outer.append(&rem_frame); + content.append(&rem_group); - // ── Model ───────────────────────────────────────────────────── - let (model_frame, model_grid) = make_section("Model (Tier 2 ONNX)"); + // ── 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."), + ); - 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 model_path_entry = text_entry(&cfg.model.path, 30); + model_list.append(&field_row("Model path", None, &model_path_entry)); - let tokenizer_entry = gtk4::Entry::builder() - .text(&cfg.model.tokenizer) - .hexpand(true) - .build(); - attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry); + let tokenizer_entry = text_entry(&cfg.model.tokenizer, 30); + model_list.append(&field_row("Tokenizer path", None, &tokenizer_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); + let ort_dylib_entry = text_entry(&cfg.model.ort_dylib_path, 30); + model_list.append(&field_row("Runtime library path", None, &ort_dylib_entry)); - outer.append(&model_frame); + content.append(&model_group); - // ── Ollama (Tier 3) ─────────────────────────────────────────── - let (ollama_frame, ollama_grid) = make_section("Ollama (Tier 3)"); + // ── 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."), + ); - 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_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_endpoint = gtk4::Entry::builder() - .text(&cfg.model.ollama.endpoint) - .hexpand(true) - .build(); - attach_row(&ollama_grid, 1, "Endpoint", &ollama_endpoint); + let ollama_endpoint_entry = text_entry(&cfg.model.ollama.endpoint, 24); + ollama_list.append(&field_row("Endpoint", None, &ollama_endpoint_entry)); - let ollama_model = gtk4::Entry::builder() - .text(&cfg.model.ollama.model) - .build(); - attach_row(&ollama_grid, 2, "Model", &ollama_model); + let ollama_model_entry = text_entry(&cfg.model.ollama.model, 16); + ollama_list.append(&field_row("Model", None, &ollama_model_entry)); - 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); + 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)); - outer.append(&ollama_frame); + content.append(&ollama_group); - // ── Calendar ───────────────────────────────────────────────── - let (cal_frame, cal_grid) = make_section("Nextcloud Calendar (CalDAV)"); + // ── Calendar sync ──────────────────────────────────────────── + let (cal_group, cal_list) = field_group("Calendar Sync", Some("Sync reminders to a Nextcloud calendar via CalDAV.")); - 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_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_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_url_entry = text_entry(&cfg.calendar.url, 30); + cal_list.append(&field_row("Calendar URL", None, &cal_url_entry)); - let cal_user = gtk4::Entry::builder() - .text(&cfg.calendar.username) - .build(); - attach_row(&cal_grid, 2, "Username", &cal_user); + let cal_user_entry = text_entry(&cfg.calendar.username, 16); + cal_list.append(&field_row("Username", None, &cal_user_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); + 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)); - outer.append(&cal_frame); + content.append(&cal_group); - // ── 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(); + // ── 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); - { - 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(); + // 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 = 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(); - save_btn.connect_clicked(move |_| { + move || { let new_cfg = Config { settings: Settings { - default_type: type_options - .get(dtc.selected() as usize) - .copied() - .unwrap_or("note") - .to_string(), - workspace_tag: wts.is_active(), - snooze_options: sne + default_type: selected_type.borrow().clone(), + workspace_tag: ws_tag_switch.is_active(), + snooze_options: snooze_entry .text() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(), - archive_after_days: ars.value() as i64, + archive_after_days: archive_spin.value() as i64, }, reminders: RemindersConfig { - default_morning: moe.text().to_string(), - missed_grace_minutes: grs.value() as i64, + default_morning: morning_entry.text().to_string(), + missed_grace_minutes: grace_spin.value() as i64, }, model: ModelConfig { - path: mpe.text().to_string(), - tokenizer: tke.text().to_string(), - ort_dylib_path: ode.text().to_string(), + path: model_path_entry.text().to_string(), + tokenizer: tokenizer_entry.text().to_string(), + ort_dylib_path: ort_dylib_entry.text().to_string(), ollama: OllamaConfig { - enabled: oec.is_active(), - endpoint: oee.text().to_string(), - model: ome.text().to_string(), - confidence_threshold: ots.value() as f32, + 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, }, }, calendar: CalendarConfig { - enabled: cec.is_active(), - url: cuc.text().to_string(), - username: csc.text().to_string(), - password: cpc.text().to_string(), + 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(), }, }; match new_cfg.save() { Ok(()) => { - sl.set_label("Settings saved."); + status_label.set_label("Saved."); on_save(new_cfg); } - Err(e) => sl.set_label(&format!("Save failed: {}", e)), + Err(e) => status_label.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 = 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); - let btn_row = gtk4::Box::builder() - .orientation(gtk4::Orientation::Horizontal) - .spacing(8) + // Entries: apply on Enter, and on focus-out so a click-away doesn't + // silently discard the edit. + macro_rules! apply_on_entry { + ($entry:expr) => { + let apply_now_activate = apply_now.clone(); + $entry.connect_activate(move |_| apply_now_activate()); + let apply_now_focus = apply_now.clone(); + let focus = gtk4::EventControllerFocus::new(); + focus.connect_leave(move |_| apply_now_focus()); + $entry.add_controller(focus); + }; + } + apply_on_entry!(snooze_entry); + apply_on_entry!(morning_entry); + apply_on_entry!(model_path_entry); + apply_on_entry!(tokenizer_entry); + apply_on_entry!(ort_dylib_entry); + apply_on_entry!(ollama_endpoint_entry); + apply_on_entry!(ollama_model_entry); + apply_on_entry!(cal_url_entry); + apply_on_entry!(cal_user_entry); + apply_on_entry!(cal_pass_entry); + + content.set_halign(gtk4::Align::Start); + content.set_size_request(900, -1); + + let outer = gtk4::Box::builder() + .orientation(gtk4::Orientation::Vertical) + .margin_start(12) + .margin_end(12) + .margin_top(12) + .margin_bottom(16) .build(); - btn_row.append(&status_label); - btn_row.append(>k4::Box::builder().hexpand(true).build()); - btn_row.append(&save_btn); - outer.append(&btn_row); + outer.append(&content); 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: >k4::Grid, row: i32, label: &str, widget: &impl gtk4::prelude::IsA) { - 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); -} diff --git a/breadman/src/views/upcoming.rs b/breadman/src/views/upcoming.rs index 35480c0..be66376 100644 --- a/breadman/src/views/upcoming.rs +++ b/breadman/src/views/upcoming.rs @@ -1,7 +1,8 @@ +use super::row::{build_empty_state, RowSpec}; use breadpad_shared::types::{Note, NoteType}; use gtk4::prelude::*; -pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow { +pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow { let scroll = gtk4::ScrolledWindow::builder() .hscrollbar_policy(gtk4::PolicyType::Never) .vscrollbar_policy(gtk4::PolicyType::Automatic) @@ -26,61 +27,21 @@ pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow { upcoming.sort_by_key(|n| n.effective_time().unwrap()); if upcoming.is_empty() { - let label = gtk4::Label::builder() - .label("No upcoming reminders or todos.") - .margin_top(32) - .build(); - list.append(&label); + list.append(&build_empty_state("x-office-calendar-symbolic", "No upcoming reminders or todos.", None)); } else { for note in upcoming { - let card = build_upcoming_card(note); - list.append(&card); + let time_str = note + .effective_time() + .map(|t| { + let local: chrono::DateTime = t.into(); + 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 } - -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| { - let local: chrono::DateTime = t.into(); - local.format("%a %b %d, %H:%M").to_string() - }) - .unwrap_or_default(); - - let time_label = gtk4::Label::builder() - .label(&time_str) - .width_chars(18) - .xalign(0.0) - .build(); - - let body_label = gtk4::Label::builder() - .label(¬e.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 -} diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index da15c41..a56c187 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,7 +7,8 @@ authors.workspace = true [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } +gtk4.workspace = true anyhow.workspace = true tracing.workspace = true serde.workspace = true @@ -19,15 +20,13 @@ tokio.workspace = true zbus.workspace = true ort.workspace = true tokenizers.workspace = true -ndarray.workspace = true +bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } toml.workspace = true 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" diff --git a/breadpad-shared/src/ai.rs b/breadpad-shared/src/ai.rs index b6afa17..24f2bd4 100644 --- a/breadpad-shared/src/ai.rs +++ b/breadpad-shared/src/ai.rs @@ -61,14 +61,8 @@ 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))?; @@ -79,7 +73,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 diff --git a/breadpad-shared/src/calendar.rs b/breadpad-shared/src/calendar.rs index 99df780..479ad05 100644 --- a/breadpad-shared/src/calendar.rs +++ b/breadpad-shared/src/calendar.rs @@ -18,13 +18,7 @@ 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); diff --git a/breadpad-shared/src/classifier.rs b/breadpad-shared/src/classifier.rs index 18efb83..35c2650 100644 --- a/breadpad-shared/src/classifier.rs +++ b/breadpad-shared/src/classifier.rs @@ -2,6 +2,8 @@ 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. @@ -32,13 +34,10 @@ pub struct Classifier { } fn model_dir() -> PathBuf { - // 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") + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) + .join("breadpad") + .join("model") } impl Classifier { @@ -110,9 +109,7 @@ 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); @@ -166,9 +163,18 @@ 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"), ]; @@ -187,15 +193,17 @@ fn run_onnx( .map_err(|e| anyhow::anyhow!("tokenize: {}", e))?; let ids: Vec = encoding.get_ids().iter().map(|&x| x as i64).collect(); - let mask: Vec = encoding.get_attention_mask().iter().map(|&x| x as i64).collect(); + let mask: Vec = encoding + .get_attention_mask() + .iter() + .map(|&x| x as i64) + .collect(); let len = ids.len(); - let ids_tensor = ort::value::Tensor::::from_array( - (vec![1i64, len as i64], ids) - ).map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?; - let mask_tensor = ort::value::Tensor::::from_array( - (vec![1i64, len as i64], mask) - ).map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?; + let ids_tensor = ort::value::Tensor::::from_array((vec![1i64, len as i64], ids)) + .map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?; + let mask_tensor = ort::value::Tensor::::from_array((vec![1i64, len as i64], mask)) + .map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?; let inputs = ort::inputs![ "input_ids" => ids_tensor, @@ -210,10 +218,7 @@ 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 @@ -246,56 +251,30 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 { exps[idx] / sum } -fn try_load_session( - path: &std::path::Path, -) -> (Option, 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, - ort::session::builder::GraphOptimizationLevel::Level3, - &[bread_onnx::Provider::MiGraphX { device_id: 0 }], - ) { - Ok(s) => { - tracing::info!("ONNX session loaded (MIGraphX iGPU)"); - return (Some(s), ExecutionProvider::Gpu); - } - Err(e) => tracing::debug!("MIGraphX EP unavailable: {}; trying CPU", e), - } - } - match bread_onnx::build_session( +fn try_load_session(path: &std::path::Path) -> (Option, 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( path, - ort::session::builder::GraphOptimizationLevel::Level3, - &[bread_onnx::Provider::Cpu], + GraphOptimizationLevel::Level3, + &[Provider::MiGraphX { device_id: 0 }], ) { Ok(s) => { - tracing::info!("ONNX session loaded (CPU)"); - (Some(s), ExecutionProvider::Cpu) + tracing::info!("ONNX session loaded (MIGraphX, CPU fallback)"); + (Some(s), ExecutionProvider::Gpu) } Err(e) => { - tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e); - (None, ExecutionProvider::Cpu) + tracing::debug!("MIGraphX session failed: {}; trying CPU", e); + match build_session(path, GraphOptimizationLevel::Level3, &[Provider::Cpu]) { + Ok(s) => { + tracing::info!("ONNX session loaded (CPU)"); + (Some(s), ExecutionProvider::Cpu) + } + Err(e) => { + tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e); + (None, ExecutionProvider::Cpu) + } + } } } } diff --git a/breadpad-shared/src/config.rs b/breadpad-shared/src/config.rs index 12702ec..e0f9bcf 100644 --- a/breadpad-shared/src/config.rs +++ b/breadpad-shared/src/config.rs @@ -135,7 +135,7 @@ impl Default for RemindersConfig { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CalendarConfig { #[serde(default = "default_calendar_enabled")] pub enabled: bool, @@ -150,17 +150,6 @@ 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)] @@ -193,32 +182,20 @@ 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 { - // 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") + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("breadpad") + .join("breadpad.toml") } pub fn style_css_path() -> PathBuf { - bread_utils::xdg::config_dir("breadpad").join("style.css") + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("breadpad") + .join("style.css") } diff --git a/breadpad-shared/src/parser.rs b/breadpad-shared/src/parser.rs index e398e73..27f63b9 100644 --- a/breadpad-shared/src/parser.rs +++ b/breadpad-shared/src/parser.rs @@ -85,43 +85,6 @@ 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 { - 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, 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 { let local = Local::now(); let days_ahead = (wd.num_days_from_monday() as i64 @@ -142,8 +105,6 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime { } 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(':') @@ -329,9 +290,8 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu .to_string(); } - // 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())); + // Infer note type + let note_type = infer_type(text, extracted_time.is_some(), rrule.is_some()); // Trim artifacts cleaned = cleaned @@ -343,9 +303,7 @@ 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 forced_type.is_some() { - 0.99 // explicit prefix — unambiguous - } else if rrule.is_some() || extracted_time.is_some() { + let confidence = if rrule.is_some() || extracted_time.is_some() { 0.95 // time/recurrence extraction is deterministic } else { match ¬e_type { @@ -366,6 +324,67 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu } } +fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType { + let lower = text.to_lowercase(); + if has_rrule || has_time { + return NoteType::Reminder; + } + if lower.contains("buy ") + || lower.contains("pick up") + || lower.contains("clean ") + || lower.starts_with("call ") + || lower.starts_with("email ") + || lower.starts_with("fix ") + || lower.starts_with("check ") + || lower.starts_with("finish ") + || lower.starts_with("write ") + || lower.starts_with("update ") + || lower.starts_with("prepare ") + || lower.starts_with("schedule ") + || lower.starts_with("organize ") + || lower.starts_with("deploy ") + || lower.starts_with("install ") + || lower.starts_with("send ") + || lower.starts_with("submit ") + || lower.starts_with("create ") + || lower.starts_with("setup ") + || lower.starts_with("restore ") + || lower.starts_with("archive ") + || lower.starts_with("export ") + || lower.starts_with("import ") + || lower.starts_with("approve ") + || lower.starts_with("configure ") + || lower.starts_with("refactor ") + || lower.starts_with("review ") + { + return NoteType::Todo; + } + if lower.starts_with("what if ") + || lower.starts_with("idea:") + || lower.contains("could ") + || lower.contains("maybe ") + || lower.starts_with("should we ") + { + return NoteType::Idea; + } + if lower.starts_with("why ") + || lower.starts_with("how ") + || (lower.starts_with("what ") && !lower.starts_with("what if ")) + || lower.starts_with("when ") + || lower.starts_with("where ") + || lower.starts_with("who ") + || lower.starts_with("will ") + || lower.starts_with("is ") + || lower.starts_with("are ") + || lower.starts_with("did ") + || lower.starts_with("does ") + || lower.ends_with('?') + { + return NoteType::Question; + } + NoteType::Note +} + #[cfg(test)] mod tests { use super::*; @@ -452,70 +471,6 @@ 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); @@ -596,7 +551,7 @@ mod tests { let r = p("take a break in 30 minutes"); let t = r.time.unwrap(); let delta = (t - before).num_seconds(); - assert!(delta >= 29 * 60 && delta <= 31 * 60, "delta was {}s", delta); + assert!((29 * 60..=31 * 60).contains(&delta), "delta was {}s", delta); } #[test] @@ -604,7 +559,7 @@ mod tests { let before = Utc::now(); let r = p("ping in 1 minute"); let delta = (r.time.unwrap() - before).num_seconds(); - assert!(delta >= 55 && delta <= 65, "delta was {}s", delta); + assert!((55..=65).contains(&delta), "delta was {}s", delta); } #[test] @@ -612,7 +567,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!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min); + assert!((119..=121).contains(&delta_min), "delta was {}min", delta_min); } #[test] @@ -620,7 +575,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!(delta_h >= 71 && delta_h <= 73, "delta was {}h", delta_h); + assert!((71..=73).contains(&delta_h), "delta was {}h", delta_h); } // ---- Time extraction: tomorrow ---- @@ -823,7 +778,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!(delta_min >= 59 && delta_min <= 61, "delta was {}min", delta_min); + assert!((59..=61).contains(&delta_min), "delta was {}min", delta_min); } #[test] @@ -843,7 +798,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!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min); + assert!((119..=121).contains(&delta_min), "delta was {}min", delta_min); } #[test] @@ -864,7 +819,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!(delta_min >= 29 && delta_min <= 31, "delta was {}min", delta_min); + assert!((29..=31).contains(&delta_min), "delta was {}min", delta_min); } // ---- Tonight / this evening ---- @@ -953,63 +908,3 @@ 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 -} diff --git a/breadpad-shared/src/scheduler.rs b/breadpad-shared/src/scheduler.rs index 98d9f2b..580e247 100644 --- a/breadpad-shared/src/scheduler.rs +++ b/breadpad-shared/src/scheduler.rs @@ -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) }; - return Some(local_naive_to_utc(naive)); + Some(local_naive_to_utc(naive)) } "WEEKLY" => { use chrono::Datelike; @@ -204,43 +204,7 @@ 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); - 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 + Some(local_naive_to_utc(target_date)) } _ => None, } @@ -440,40 +404,7 @@ mod tests { #[test] fn unknown_freq_returns_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 = 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 = 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 = t.unwrap().into(); - assert_eq!(local.day(), 31); + assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none()); } #[test] diff --git a/breadpad-shared/src/store.rs b/breadpad-shared/src/store.rs index 653eff8..a8f52c8 100644 --- a/breadpad-shared/src/store.rs +++ b/breadpad-shared/src/store.rs @@ -44,43 +44,11 @@ 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 { - 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> { - let _lock = self.acquire_lock()?; self.load_from(&self.notes_path) } pub fn load_archive(&self) -> Result> { - let _lock = self.acquire_lock()?; self.load_from(&self.archive_path) } @@ -106,15 +74,12 @@ 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)?; - } + 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()) { @@ -138,18 +103,13 @@ impl Store { } pub fn delete_note(&self, id: &str) -> Result<()> { - let to_delete_note = { - let _lock = self.acquire_lock()?; - let all = self.load_from(&self.notes_path)?; - let (to_delete, keep): (Vec, Vec) = - all.into_iter().partition(|n| n.id == id); - self.write_all(&self.notes_path, &keep)?; - to_delete.into_iter().next() - }; + let all = self.load_all()?; + let (to_delete, keep): (Vec, Vec) = all.into_iter().partition(|n| n.id == id); + self.write_all(&self.notes_path, &keep)?; if let Some(cal_cfg) = &self.calendar { if cal_cfg.enabled { - if let Some(note) = to_delete_note { + if let Some(note) = to_delete.into_iter().next() { spawn_caldav_delete(caldav_uid(¬e), cal_cfg.clone()); } } @@ -158,17 +118,11 @@ impl Store { Ok(()) } - /// 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(&self, mut f: F) -> Result<()> + fn rewrite_notes(&self, f: F) -> Result<()> where F: FnMut(Note) -> Note, { - let _lock = self.acquire_lock()?; - let notes: Vec = self.load_from(&self.notes_path)?.into_iter().map(|n| f(n)).collect(); + let notes: Vec = self.load_all()?.into_iter().map(f).collect(); self.write_all(&self.notes_path, ¬es) } @@ -187,12 +141,11 @@ impl Store { } pub fn rotate_archive(&self, archive_after_days: i64) -> Result { - let _lock = self.acquire_lock()?; let cutoff = Utc::now() - Duration::days(archive_after_days); - let notes = self.load_from(&self.notes_path)?; + let notes = self.load_all()?; let (to_archive, keep): (Vec, Vec) = notes .into_iter() - .partition(|n| n.done && n.completed.map_or(false, |c| c < cutoff)); + .partition(|n| n.done && n.completed.is_some_and(|c| c < cutoff)); if to_archive.is_empty() { return Ok(0); diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index 1b6cb7f..f7e2177 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -13,6 +13,11 @@ pub fn apply_live() { }); } +/// Bind a window to the palette of the monitor it is rendered on. +pub fn bind_window(window: &impl gtk4::prelude::IsA) { + 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 @@ -28,12 +33,33 @@ 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: 12px 16px; + padding: 14px; font-size: 14px; caret-color: @fg; } @@ -43,14 +69,9 @@ window { border-radius: 8px; } 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: transparent; - color: alpha(@fg, 0.6); - border: 1px solid alpha(@fg, 0.18); + background: @overlay; + color: @on-overlay; border-radius: 999px; padding: 4px 12px; font-size: 12px; @@ -60,69 +81,36 @@ window { border-radius: 8px; } .type-chip.active { background: @blue; color: @on-accent; - border-color: @blue; - font-weight: 600; } -/* 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); } +/* Per-type tint, matching the note-card-{type} accent-bar colors below - + the flat cream badge was the same high-contrast fill for every type, + out-shouting note body text while telling you nothing extra. */ +.type-chip-todo { background: alpha(@green, 0.18); color: @green; } +.type-chip-reminder { background: alpha(@yellow, 0.18); color: @yellow; } +.type-chip-idea { background: alpha(@pink, 0.18); color: @pink; } +.type-chip-question { background: alpha(@teal, 0.18); color: @teal; } +.type-chip-note { background: alpha(@blue, 0.18); color: @blue; } .confirm-button { background: @blue; color: @on-accent; border: none; border-radius: 8px; - padding: 10px 22px; - min-height: 20px; - font-size: 15px; + padding: 8px 16px; 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.12); - border: 1px solid alpha(@fg, 0.07); - border-radius: 10px; + background: shade(@bg, 1.1); + border-radius: 8px; padding: 12px; - margin: 6px 0; + margin: 8px; border-left: 3px solid @blue; } .note-card:hover { - 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; + background: shade(@bg, 1.2); } .search-entry { @@ -130,8 +118,7 @@ window { border-radius: 8px; } color: @fg; border: 1px solid @overlay; border-radius: 6px; - padding: 5px 10px; - font-size: 13px; + padding: 8px 12px; } .search-entry:focus { @@ -152,27 +139,7 @@ window { border-radius: 8px; } .sidebar-row:selected { background: @blue; color: @on-accent; - 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); + font-weight: 500; } .sidebar-section-label { @@ -187,15 +154,14 @@ window { border-radius: 8px; } background: transparent; border: none; border-radius: 6px; - padding: 3px 8px; - min-width: 32px; - min-height: 32px; - font-size: 16px; + padding: 2px 7px; + min-width: 28px; + min-height: 28px; + font-size: 14px; } .action-btn:hover { background: shade(@bg, 1.3); - opacity: 1; } .done-btn { color: @green; } @@ -204,8 +170,12 @@ window { border-radius: 8px; } .edit-btn { color: @blue; } .edit-btn:hover { background: alpha(@blue, 0.15); } -.danger-btn { color: @red; } -.danger-btn:hover { background: alpha(@red, 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); } .note-card-todo { border-left-color: @green; } .note-card-reminder { border-left-color: @yellow; } @@ -239,12 +209,15 @@ window { border-radius: 8px; } 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: alpha(@fg, 0.6); + color: @fg; } .reminder-dismiss:hover { background: shade(@bg, 1.1); } @@ -259,15 +232,40 @@ window { border-radius: 8px; } .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: 8px 12px; + padding: 10px 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 { @@ -284,12 +282,6 @@ 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]); } @@ -336,9 +328,11 @@ mod tests { #[test] fn css_reflects_custom_palette_colors() { - let mut p = Palette::default(); - p.background = "#deadbe".into(); - p.color4 = "#cafe00".into(); + let p = Palette { + background: "#deadbe".into(), + color4: "#cafe00".into(), + ..Default::default() + }; 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]); diff --git a/breadpad-shared/src/types.rs b/breadpad-shared/src/types.rs index 5be0b54..e1d5729 100644 --- a/breadpad-shared/src/types.rs +++ b/breadpad-shared/src/types.rs @@ -15,6 +15,9 @@ 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, diff --git a/breadpad-shared/tests/classifier.rs b/breadpad-shared/tests/classifier.rs index 3b5527c..7f0294a 100644 --- a/breadpad-shared/tests/classifier.rs +++ b/breadpad-shared/tests/classifier.rs @@ -1,17 +1,24 @@ 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("08:00") + Classifier::load_with_paths( + "08:00", + PathBuf::from("/nonexistent/classifier.onnx"), + PathBuf::from("/nonexistent/tokenizer.json"), + ) } #[test] fn active_provider_is_valid() { // The active provider depends on the host: a machine with the ONNX model present and - // a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no + // a working MIGraphX iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no // model is available we must be on CPU (no session => no GPU EP in use). - let c = cl(); + let c = Classifier::load("08:00"); assert!(matches!( c.active_provider, ExecutionProvider::Cpu | ExecutionProvider::Gpu @@ -49,19 +56,28 @@ 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] @@ -74,7 +90,11 @@ fn classify_recurrence_via_fallback() { #[test] fn classify_custom_morning_time() { - let mut c = Classifier::load("07:15"); + let mut c = Classifier::load_with_paths( + "07:15", + PathBuf::from("/nonexistent/classifier.onnx"), + PathBuf::from("/nonexistent/tokenizer.json"), + ); let r = c.classify("sync tomorrow morning"); let t = r.time.expect("should have a time for tomorrow morning"); let local: chrono::DateTime = t.into(); @@ -114,12 +134,16 @@ 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 = cl(); + let c = Classifier::load("08:00"); assert!( c.model_path.to_str().unwrap().contains("breadpad"), "model path: {:?}", diff --git a/breadpad-shared/tests/config.rs b/breadpad-shared/tests/config.rs index 6e057e5..462afe6 100644 --- a/breadpad-shared/tests/config.rs +++ b/breadpad-shared/tests/config.rs @@ -279,15 +279,19 @@ fn resolved_ort_dylib_empty_returns_none() { #[test] fn resolved_ort_dylib_whitespace_only_returns_none() { - let mut m = ModelConfig::default(); - m.ort_dylib_path = " ".into(); + let m = ModelConfig { + ort_dylib_path: " ".into(), + ..Default::default() + }; assert!(m.resolved_ort_dylib_path().is_none()); } #[test] fn resolved_ort_dylib_set_returns_some() { - let mut m = ModelConfig::default(); - m.ort_dylib_path = "/usr/lib/libonnxruntime.so".into(); + let m = ModelConfig { + ort_dylib_path: "/usr/lib/libonnxruntime.so".into(), + ..Default::default() + }; assert_eq!( m.resolved_ort_dylib_path().unwrap().to_str().unwrap(), "/usr/lib/libonnxruntime.so" diff --git a/breadpad-shared/tests/pipeline.rs b/breadpad-shared/tests/pipeline.rs index 84cc850..603a199 100644 --- a/breadpad-shared/tests/pipeline.rs +++ b/breadpad-shared/tests/pipeline.rs @@ -9,12 +9,18 @@ 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 { - let mut classifier = Classifier::load("08:00"); + // WHY: pipeline tests cover classify→save→reload, not a host ONNX model. + let mut classifier = Classifier::load_with_paths( + "08:00", + PathBuf::from("/nonexistent/classifier.onnx"), + PathBuf::from("/nonexistent/tokenizer.json"), + ); let result = classifier.classify(text); let mut note = Note::new(text.into(), user_type.clone(), None); @@ -61,7 +67,11 @@ 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); @@ -71,7 +81,11 @@ 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); @@ -97,7 +111,10 @@ 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 = notes[0].time.unwrap().into(); assert_eq!(local.hour(), 18); } @@ -108,14 +125,21 @@ fn reminder_body_has_time_stripped() { capture(&store, "call mum at 6pm", NoteType::from_str("note")); 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); @@ -127,7 +151,11 @@ 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); @@ -139,11 +167,20 @@ 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 ---- @@ -155,7 +192,11 @@ 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] @@ -173,7 +214,11 @@ 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(); diff --git a/breadpad-test/src/main.rs b/breadpad-test/src/main.rs index 54a7d03..4c96a43 100644 --- a/breadpad-test/src/main.rs +++ b/breadpad-test/src/main.rs @@ -389,7 +389,7 @@ fn cmd_show(index: usize, corpus_path: &Path, tier: &TierArg) -> Result<()> { println!(); let sep = "─".repeat(62); - println!("{:<14} {:<26} {}", "field", "expected", "actual"); + println!("{:<14} {:<26} actual", "field", "expected"); println!("{sep}"); println!( "{:<14} {:<26} {}", diff --git a/breadpad/Cargo.toml b/breadpad/Cargo.toml index aa840d2..1465ac4 100644 --- a/breadpad/Cargo.toml +++ b/breadpad/Cargo.toml @@ -11,6 +11,11 @@ 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 @@ -23,4 +28,3 @@ 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" } diff --git a/breadpad/src/listen.rs b/breadpad/src/listen.rs new file mode 100644 index 0000000..e8649a5 --- /dev/null +++ b/breadpad/src/listen.rs @@ -0,0 +1,83 @@ +//! Long-running command subscription for `bread.command.pad.*`. +//! +//! `breadpad` is still a one-shot capture popup by default. `breadpad listen` +//! is the optional persistent process that can honor bus commands. See +//! `EVENTS.md`. + +use anyhow::Result; +use bread_utils::bread_client::{BreadClient, BreadEvent}; + +/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. +const APP_ID: &str = "pad"; + +/// Subscribe to `bread.command.pad.**` and block until the process is killed. +/// +/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects +/// with backoff, and `on_event` simply isn't called until the daemon is up. +pub fn run() -> Result<()> { + let client = BreadClient::connect(APP_ID); + if client.health().is_none() { + tracing::warn!("breadd unreachable; command subscription will connect when it comes back"); + } + + let _commands = client.subscribe("bread.command.pad.**", |event| { + handle_command(&event); + }); + + tracing::info!("listening for bread.command.pad.**"); + loop { + std::thread::park(); + } +} + +/// Reacts to `bread.command.pad.*` verbs. Only `capture` is honored today — +/// other verbs are ignored, not stubbed as no-ops that pretend to succeed. +fn handle_command(event: &BreadEvent) { + let Some(verb) = command_verb(&event.event) else { + return; + }; + match verb { + "capture" => handle_capture(), + other => { + tracing::debug!("ignoring unrecognized command verb '{other}'"); + } + } +} + +fn handle_capture() { + // Same as running `breadpad` with no args: open the capture popup. + let result = spawn_self(); + let client = BreadClient::connect(APP_ID); + match result { + Ok(_) => client.emit("bread.pad.capture.done", serde_json::json!({})), + Err(e) => { + tracing::warn!("bread.command.pad.capture failed: {e}"); + client.emit( + "bread.pad.capture.failed", + serde_json::json!({ "error": e.to_string() }), + ); + } + } +} + +fn spawn_self() -> std::io::Result { + 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); + } +} diff --git a/breadpad/src/main.rs b/breadpad/src/main.rs index 3d946f2..db05e04 100644 --- a/breadpad/src/main.rs +++ b/breadpad/src/main.rs @@ -3,16 +3,36 @@ 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 ` 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) { @@ -31,6 +51,9 @@ 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, @@ -41,6 +64,32 @@ mod args { pub model_info: bool, pub calendar_test: bool, pub calendar_list_uid: Option, + pub screenshot: Option, + pub output: Option, + 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 { + 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 { @@ -53,6 +102,11 @@ 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 = std::env::args().skip(1).collect(); let mut i = 0; @@ -66,6 +120,7 @@ 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(); @@ -82,6 +137,26 @@ mod args { _ => {} } } + "--screenshot" => { + i += 1; + args.screenshot = raw.get(i).cloned(); + } + "--output" => { + i += 1; + args.output = raw.get(i).cloned(); + } + "--width" => { + i += 1; + if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) { + args.width = v; + } + } + "--height" => { + i += 1; + if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) { + args.height = v; + } + } _ => {} } i += 1; @@ -99,6 +174,9 @@ fn main() -> Result<()> { .init(); let args = args::parse(); + if args.listen { + return listen::run(); + } let cfg = Config::load()?; if args.status { @@ -120,7 +198,23 @@ fn main() -> Result<()> { return cmd_calendar_list_uid(¬e_id, &cfg); } - run_popup(args.note_type, args.no_classify, cfg) + let screenshot_req = args.screenshot_request(); + if let Some(req) = &screenshot_req { + if req.view == "reminder" || req.view == "reminder-snooze" { + // The real path (`fire `, above) needs a real due note from + // the Store. A screenshot doesn't have one to work with — and + // shouldn't wait for one — so it builds a throwaway sample + // instead, never touching the Store at all. + let mut sample = Note::new( + "Sample reminder text".into(), + NoteType::from_str("reminder"), + None, + ); + sample.time = Some(chrono::Utc::now()); + return run_reminder_window(sample, &cfg, screenshot_req); + } + } + run_popup(args.note_type, args.no_classify, cfg, screenshot_req) } fn cmd_status(cfg: &Config) -> Result<()> { @@ -265,6 +359,8 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> { return Ok(()); } + emit_reminder_due(¬e.id); + // Schedule next recurrence before showing UI if note.rrule.is_some() { if let Some(next) = Scheduler::next_recurrence(¬e, &cfg.reminders.default_morning) { @@ -276,19 +372,28 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> { } } - run_reminder_window(note, cfg) + run_reminder_window(note, cfg, None) } -fn run_reminder_window(note: breadpad_shared::types::Note, cfg: &Config) -> Result<()> { - let app = gtk4::Application::builder() - .application_id("com.breadway.breadpad.reminder") - .build(); +fn run_reminder_window( + note: breadpad_shared::types::Note, + cfg: &Config, + screenshot_req: Option, +) -> Result<()> { + let mut builder = gtk4::Application::builder().application_id("com.breadway.breadpad.reminder"); + if screenshot_req.is_some() { + // Same reasoning as run_popup's NON_UNIQUE: a screenshot run must + // get its own fresh window, never activate a real reminder that + // happens to already be showing. + builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE); + } + let app = builder.build(); let note = Arc::new(note); let cfg = Arc::new(cfg.clone()); app.connect_activate(move |app| { - build_reminder_window(app, note.clone(), cfg.clone()); + build_reminder_window(app, note.clone(), cfg.clone(), screenshot_req.clone()); }); app.run_with_args::(&[]); @@ -331,6 +436,7 @@ fn build_reminder_window( app: >k4::Application, note: Arc, cfg: Arc, + screenshot_req: Option, ) { let window = gtk4::ApplicationWindow::builder() .application(app) @@ -345,6 +451,7 @@ 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); @@ -392,7 +499,7 @@ fn build_reminder_window( let local: chrono::DateTime = t.into(); header.append( >k4::Label::builder() - .label(&local.format("%H:%M").to_string()) + .label(local.format("%H:%M").to_string()) .css_classes(["reminder-time"]) .build(), ); @@ -417,14 +524,15 @@ fn build_reminder_window( .orientation(gtk4::Orientation::Horizontal) .build()); - // Button row + // Button row — same inset as the header/body zone above (20px), which + // used to be 16px here, visible as a step across the divider. let btn_row = gtk4::Box::builder() .orientation(gtk4::Orientation::Horizontal) .spacing(8) .margin_top(12) .margin_bottom(12) - .margin_start(16) - .margin_end(16) + .margin_start(20) + .margin_end(20) .build(); let dismiss_btn = gtk4::Button::builder() @@ -432,23 +540,35 @@ fn build_reminder_window( .css_classes(["reminder-dismiss"]) .build(); - // Snooze popover + // Snooze popover. No arrow and a matching flat border (see + // popover.snooze-popover in the shared theme) so it reads as the same + // elevation language as the reminder card, instead of GTK's default + // arrow+drop-shadow chrome next to the card's flat 1px border. let snooze_popover = gtk4::Popover::new(); + snooze_popover.set_has_arrow(false); + snooze_popover.add_css_class("snooze-popover"); let snooze_vbox = gtk4::Box::builder() .orientation(gtk4::Orientation::Vertical) - .spacing(4) - .margin_top(8) - .margin_bottom(8) - .margin_start(8) - .margin_end(8) + .spacing(0) + .margin_top(4) + .margin_bottom(4) .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(>k4::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 = gtk4::Button::builder() - .label(&label) - .css_classes(["snooze-option"]) - .build(); + let btn = snooze_option_row(&label); let key = opt.clone(); let note_c = note.clone(); let cfg_c = cfg.clone(); @@ -468,6 +588,47 @@ 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() @@ -507,25 +668,48 @@ 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, no_classify: bool, cfg: Config) -> Result<()> { +fn run_popup( + preset_type: Option, + no_classify: bool, + cfg: Config, + screenshot_req: Option, +) -> Result<()> { // Try to get current Hyprland workspace let workspace = get_active_workspace(); - let app = gtk4::Application::builder() - .application_id("com.breadway.breadpad") - .build(); + 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 cfg = Arc::new(cfg); app.connect_activate(move |app| { - if let Some(win) = app.windows().first().cloned() { - win.close(); - return; + 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); + build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify, screenshot_req.clone()); }); let code = app.run_with_args::(&[]); @@ -536,14 +720,13 @@ fn run_popup(preset_type: Option, no_classify: bool, cfg: Config) -> Res } fn get_active_workspace() -> Option { - // 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()) + // 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()) } fn build_window( @@ -552,6 +735,7 @@ fn build_window( workspace: Option, preset_type: Option, no_classify: bool, + screenshot_req: Option, ) { let window = gtk4::ApplicationWindow::builder() .application(app) @@ -570,6 +754,7 @@ 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); @@ -637,44 +822,15 @@ fn build_window( } } - // 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) + // 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"]) .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) @@ -683,10 +839,9 @@ fn build_window( let spacer = gtk4::Box::builder().hexpand(true).build(); bottom_row.append(&spacer); - bottom_row.append(&confirm_wrap); + bottom_row.append(&enter_hint); vbox.append(&entry); - vbox.append(&hint); vbox.append(&bottom_row); window.set_child(Some(&vbox)); @@ -695,7 +850,6 @@ 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(); @@ -703,7 +857,6 @@ 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(); @@ -714,30 +867,14 @@ 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 — - // 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(); + // Close first so the popup disappears immediately, then save. 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(); @@ -756,6 +893,10 @@ fn build_window( }); window.add_controller(key_ctrl); + if let Some(req) = screenshot_req { + screenshot::dispatch(&window, req); + } + window.present(); entry.grab_focus(); } @@ -802,6 +943,7 @@ fn save_note_classified( tracing::error!("failed to save note: {}", e); return; } + emit_captured(¬e.id); if note.time.is_some() { if let Err(e) = Scheduler::schedule(¬e) { tracing::warn!("failed to schedule reminder: {}", e); diff --git a/breadpad/src/screenshot.rs b/breadpad/src/screenshot.rs new file mode 100644 index 0000000..98f5ab0 --- /dev/null +++ b/breadpad/src/screenshot.rs @@ -0,0 +1,110 @@ +//! `--screenshot` CLI mode: render breadpad's compose popup, capture it via +//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s +//! `bread-capture` orchestrator, or run standalone for one-off captures. +//! +//! No clap here (unlike breadbar/breadbox/breadclip/breadsearch): breadpad +//! already has its own small hand-rolled flag parser (`mod args`) covering +//! `--type`/`--no-classify`/`--status`/`fire`/`calendar`/etc, and clap's +//! default "reject unknown flags" behavior would break every one of those +//! if bolted on as a second, separate parser. `--screenshot`/`--output`/ +//! `--width`/`--height` are just three more fields on that same `Args` +//! struct instead. +//! +//! Three views: "popup" (the compose window from `run_popup`), "reminder" +//! (the alert window from `run_reminder_window`/`build_reminder_window`, +//! normally only reachable via a real due note through `fire `, 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: >k4::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: >k4::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: >k4::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); + } + } +} diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..474f1fd --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +620c5a1317a6b57276eabca961facdb78bf510db diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..4056cce --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Delegates to bread-ecosystem's shared CI build image/script, pinned to +# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI +# files now affect every product's release pipeline, so bumping the pin +# is a deliberate act instead of silent drift (see the bread-theme test +# that broke here for exactly that reason, before it was pinned by rev). +# +# Usage: ci/build.sh cargo build --release --locked +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" + +CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" +if [ ! -d "$CACHE_DIR" ]; then + rm -rf /tmp/bread-ecosystem-ci-* + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" + git -C "$CACHE_DIR" checkout --quiet "$REV" +fi + +bash "${CACHE_DIR}/ci/build.sh" breadpad "$ROOT" "$@" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD deleted file mode 100644 index d4d5b93..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,38 +0,0 @@ -# Maintainer: Breadway - -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" -}