Compare commits
51 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e26a1b9f | ||
|
|
51338af7eb | ||
|
|
37713eee9e | ||
|
|
852a771651 | ||
|
|
c929517b49 | ||
|
|
54eba3d73a | ||
|
|
2f57f432a0 | ||
|
|
17e9381fc6 | ||
|
|
9dcc6cfc94 | ||
|
|
b3e49dc753 | ||
|
|
2f737abbf6 | ||
|
|
b9c2702168 | ||
|
|
fde2c7571d | ||
|
|
520245a6be | ||
|
|
c2807a59f0 | ||
|
|
e64a525104 | ||
|
|
96a954cc15 | ||
|
|
fe2724220a | ||
|
|
3225f49a93 | ||
|
|
2b5ad272a5 | ||
|
|
80941518f7 | ||
|
|
58309ba005 | ||
|
|
bd18aa2a3a | ||
|
|
c462f89934 | ||
|
|
c60f3abf07 | ||
|
|
d46fb98321 | ||
|
|
72f86f0675 | ||
|
|
561dd46dcf | ||
|
|
18a6e645e6 | ||
|
|
0f6eb65cdf | ||
|
|
39d082d0bf | ||
|
|
18bdba052d | ||
|
|
ae296c7154 | ||
|
|
26cb31354f | ||
|
|
de7965edae | ||
|
|
926d351949 | ||
|
|
b85d682721 | ||
|
|
b8a37cbb85 | ||
|
|
47a4613b5a | ||
|
|
77b402833f | ||
|
|
55f5b6c3ae | ||
|
|
c13b7b0599 | ||
|
|
879ec1e7f4 | ||
|
|
1289c05c47 | ||
|
|
f13e091bd0 | ||
|
|
261431728e | ||
|
|
676c0fedd1 | ||
|
|
7fb9e018bf | ||
|
|
e43675b672 | ||
|
|
bf55b812d2 | ||
|
|
3131cdea17 |
45 changed files with 2757 additions and 1926 deletions
24
.forgejo/workflows/check.yml
Normal file
24
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: check
|
||||||
|
|
||||||
|
# Fast-fail lint/test on short-lived work branches, before it ever reaches
|
||||||
|
# main and triggers a dev-track release build.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['feature/**', 'fix/**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: clippy
|
||||||
|
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cd src && bash ci/build.sh cargo test --workspace --locked
|
||||||
67
.forgejo/workflows/dev-release.yml
Normal file
67
.forgejo/workflows/dev-release.yml
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
name: dev release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['main']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch main --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||||
|
|
||||||
|
- name: compute dev version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src
|
||||||
|
LATEST_TAG="$(git ls-remote --tags --refs \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
||||||
|
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
|
||||||
|
if [ -n "${LATEST_TAG}" ]; then
|
||||||
|
CUR="${LATEST_TAG}"
|
||||||
|
else
|
||||||
|
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||||
|
fi
|
||||||
|
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||||
|
SHA="$(git rev-parse --short HEAD)"
|
||||||
|
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||||
|
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PKG_DIR="/srv/breadway-dl/dev/breadpad/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
for bin in breadpad breadman; do
|
||||||
|
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
strip "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
||||||
|
done
|
||||||
|
cp src/breadpad.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/LICENSE "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadpad/latest"
|
||||||
|
|
||||||
|
- name: regenerate dev index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||||
|
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||||
|
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||||
|
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||||
|
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||||
|
|
@ -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/*'
|
|
||||||
|
|
@ -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"
|
|
||||||
51
.forgejo/workflows/rc-release.yml
Normal file
51
.forgejo/workflows/rc-release.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
name: beta (rc) release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*-rc.*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/beta/breadpad/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
for bin in breadpad breadman; do
|
||||||
|
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
strip "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
||||||
|
done
|
||||||
|
cp src/breadpad.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/LICENSE "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadpad/latest"
|
||||||
|
|
||||||
|
- name: regenerate beta index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||||
|
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||||
|
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||||
|
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||||
|
|
@ -6,6 +6,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||||
runs-on: [self-hosted, hestia]
|
runs-on: [self-hosted, hestia]
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
|
|
@ -16,7 +17,16 @@ jobs:
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: cd src && cargo build --release --locked
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ ! -f src/ci/build.sh ]; then
|
||||||
|
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd src && bash ci/build.sh cargo build --release --locked || {
|
||||||
|
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
- name: prepare artifacts
|
- name: prepare artifacts
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -31,12 +41,19 @@ jobs:
|
||||||
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
||||||
done
|
done
|
||||||
cp src/breadpad.example.toml "${PKG_DIR}/"
|
cp src/breadpad.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/LICENSE "${PKG_DIR}/"
|
||||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
ln -sfn "${VERSION}" "/srv/breadway-dl/breadpad/latest"
|
ln -sfn "${VERSION}" "/srv/breadway-dl/breadpad/latest"
|
||||||
|
|
||||||
- name: regenerate index.json
|
- name: regenerate index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
rm -rf /tmp/bread-ecosystem-ci
|
rm -rf /tmp/bread-ecosystem-ci
|
||||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||||
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||||
|
|
|
||||||
66
.github/workflows/release.yml
vendored
Normal file
66
.github/workflows/release.yml
vendored
Normal file
|
|
@ -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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -27,3 +27,6 @@ svgs.txt
|
||||||
# Rust/Cargo
|
# Rust/Cargo
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Local knowledge-graph cache
|
||||||
|
graphify-out/
|
||||||
|
|
|
||||||
37
AGENTS.md
Normal file
37
AGENTS.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# AGENTS.md — Repo hygiene
|
||||||
|
|
||||||
|
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
|
||||||
|
|
||||||
|
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
|
||||||
|
— read and follow it for any git, branch, or release work here (the
|
||||||
|
single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work,
|
||||||
|
etc). Don't improvise a different workflow. The short version: there is one
|
||||||
|
long-lived branch, `main` — no `dev` or `beta` branch exists. `main`
|
||||||
|
auto-publishes a dev-track build on every push. "Beta" and "stable" are both
|
||||||
|
just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track
|
||||||
|
build, push a plain `vX.Y.Z` tag to cut the signed stable release.
|
||||||
|
"Freezing" for stabilization means pausing pushes to `main`, not moving a
|
||||||
|
branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model
|
||||||
|
after `main` was found to have silently rotted out of sync with `dev`/`beta`
|
||||||
|
across most repos in this ecosystem.
|
||||||
|
|
||||||
|
## Remotes
|
||||||
|
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
||||||
|
- `github` — GitHub mirror. Push both when publishing.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`.
|
||||||
|
- `dev-release.yml` — triggers on push to `main`.
|
||||||
|
- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push.
|
||||||
|
- `release.yml` — triggers on any other `v*` tag push.
|
||||||
|
|
||||||
|
All four run on a self-hosted runner (`hestia`) inside a pinned Arch
|
||||||
|
container — not the host's native environment. The Containerfile/build
|
||||||
|
script are shared across bread-ecosystem products and live in
|
||||||
|
`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the
|
||||||
|
sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and
|
||||||
|
delegates to it. Nothing runs automatically on plain commits or PRs
|
||||||
|
beyond what's listed.
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||||
97
CONTRIBUTING.md
Normal file
97
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
`breadpad` — Quick-capture scratchpad and note viewer (breadman) with AI classification.
|
||||||
|
|
||||||
|
Part of the bread ecosystem; this repo follows the same branch/release
|
||||||
|
workflow as every other ecosystem product.
|
||||||
|
|
||||||
|
## Branches
|
||||||
|
|
||||||
|
There is one long-lived branch: **`main`**. All day-to-day work lands here.
|
||||||
|
Every push to `main` automatically builds and publishes a **dev-track**
|
||||||
|
build (see Tracks below) — a real install you can test before cutting
|
||||||
|
anything more formal.
|
||||||
|
|
||||||
|
New work — features and bug fixes alike — goes on a short-lived branch:
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/<short-name>
|
||||||
|
fix/<issue-number-or-short-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
|
||||||
|
branches get deleted on merge — they never accumulate the kind of drift a
|
||||||
|
second long-lived branch does.
|
||||||
|
|
||||||
|
## The release cycle
|
||||||
|
|
||||||
|
There's no separate `beta` or release branch — "stable" and "beta" are both
|
||||||
|
just **tags** on `main`, not branches that need to be kept in sync:
|
||||||
|
|
||||||
|
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
|
||||||
|
auto-publishes a dev build — install it with `bakery track set dev` and
|
||||||
|
`bakery update --all`, then fix anything broken with another push.
|
||||||
|
2. When you want to stabilize before a real release, tag a release
|
||||||
|
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
|
||||||
|
both remotes). That tag alone triggers a beta-track build —
|
||||||
|
"freezing" is just pausing pushes to `main` while you test it, not a
|
||||||
|
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
|
||||||
|
3. Once an RC has gone without issues, tag the real release:
|
||||||
|
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
|
||||||
|
signed stable release build.
|
||||||
|
|
||||||
|
## Tracks, from a user's perspective
|
||||||
|
|
||||||
|
```
|
||||||
|
bakery track show # what you're currently on (defaults to stable)
|
||||||
|
bakery track set dev # or beta, or stable
|
||||||
|
bakery update --all # pull the latest build on your current track
|
||||||
|
```
|
||||||
|
|
||||||
|
| Track | What it is | Published from |
|
||||||
|
|--------|-----------|-----------------|
|
||||||
|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
|
||||||
|
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
|
||||||
|
| `dev` | Bleeding edge | `main`, on every push |
|
||||||
|
|
||||||
|
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
|
||||||
|
latest published stable tag, so they always sort as newer than what you
|
||||||
|
have installed — no manual version bumping needed. Beta versions are just
|
||||||
|
the RC tag itself (already valid semver, already sorts below the real
|
||||||
|
release it's a candidate for).
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release --workspace
|
||||||
|
cargo test --release --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
- `check.yml` — clippy + test, triggered on push to `feature/**`/`fix/**`.
|
||||||
|
Fast-fail before anything reaches `main`.
|
||||||
|
- `dev-release.yml` — triggered on push to `main`.
|
||||||
|
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
|
||||||
|
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
|
||||||
|
stable release.
|
||||||
|
|
||||||
|
All of these build inside a pinned Arch Linux container on a self-hosted
|
||||||
|
runner — not the runner host's native environment. Arch's repos carry
|
||||||
|
current `gtk4`/`libadwaita`/`gtk4-layer-shell` as prebuilt packages, so
|
||||||
|
there's no from-source library build to go stale. The Containerfile and
|
||||||
|
build script are shared across bread-ecosystem GTK4 products, living in
|
||||||
|
`bread-ecosystem/ci/`; `ci/build.sh` here is a thin wrapper that clones
|
||||||
|
that repo at the commit pinned in `ci/bread-ecosystem.rev` (not `main` —
|
||||||
|
an unrelated change there shouldn't silently affect this repo's release
|
||||||
|
builds) and delegates to it. Bump the pin deliberately when you want the
|
||||||
|
shared image or build logic updated. A `ci/deps.txt` here (currently
|
||||||
|
absent — breadpad needs nothing beyond the shared base) would layer on
|
||||||
|
extra pacman packages if that ever changes. Nothing runs automatically on
|
||||||
|
plain commits or PRs beyond the jobs listed above. See
|
||||||
|
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
|
||||||
|
for the full policy, including how a new product gets wired onto these tracks.
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
Open an issue on this repo's Forgejo tracker.
|
||||||
1113
Cargo.lock
generated
1113
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,7 @@ members = [
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.0"
|
version = "0.5.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Breadway"]
|
authors = ["Breadway"]
|
||||||
|
|
@ -24,9 +24,10 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||||
rrule = "0.12"
|
rrule = "0.12"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
||||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] }
|
# WHY: bread-onnx's Provider enum references every EP type, so those ort
|
||||||
ndarray = "0.16"
|
# features must be on in the consumer even if breadpad only requests MIGraphX.
|
||||||
tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
|
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing", "api-24", "migraphx", "cuda", "openvino", "vitis", "load-dynamic"] }
|
||||||
|
tokenizers = { version = "0.23", default-features = false, features = ["http", "fancy-regex"] }
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
gtk4-layer-shell = "0.8"
|
gtk4-layer-shell = "0.8"
|
||||||
hyprland = "0.4.0-beta.3"
|
hyprland = "0.4.0-beta.3"
|
||||||
|
|
|
||||||
70
EVENTS.md
Normal file
70
EVENTS.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# breadpad — bread event integration
|
||||||
|
|
||||||
|
breadpad is a standalone capture popup: it works exactly the same with or
|
||||||
|
without `breadd` running. When breadd *is* present, the `breadpad` binary
|
||||||
|
publishes events into the shared bread automation fabric after actions that
|
||||||
|
already happened. See the parent `bread` repo's `Documentation.md` —
|
||||||
|
specifically its "Namespaces" and "Integrating a bread\* app" sections — for
|
||||||
|
the general convention this follows.
|
||||||
|
|
||||||
|
App id: **`pad`**. Transport: `bread-utils`'s `bread_client` module
|
||||||
|
(feature `bread-client`) — the capture popup links it directly. One-shot
|
||||||
|
popup / `fire <id>` invocations each `emit` on their own short-lived
|
||||||
|
connection. Command verbs are only received while `breadpad listen` is
|
||||||
|
running — that process holds the `bread.command.pad.**` subscription
|
||||||
|
open.
|
||||||
|
|
||||||
|
`breadman` (the viewer) does not emit or subscribe. Notes created or edited
|
||||||
|
there are not quick-capture, and it is not on the reminder-fire path.
|
||||||
|
|
||||||
|
## Events published (`bread.pad.*`)
|
||||||
|
|
||||||
|
| Event | Data | When |
|
||||||
|
|-------|------|------|
|
||||||
|
| `bread.pad.captured` | `{ "id": "<note id>" }` | The capture popup saved a note successfully (`Store::save_note` returned `Ok`). Not emitted when the field is empty, the window is dismissed, classification-only preview happens, or the write fails. |
|
||||||
|
| `bread.pad.reminder.due` | `{ "id": "<note id>" }` | `breadpad fire <id>` decided the reminder is due (`Scheduler::fire` returned true) and is about to show the reminder window. This is the existing in-process systemd-timer hook (`breadpad-reminder-<id>.timer` → `breadpad fire <id>`), not a new daemon. Not emitted when the note is missing, the fire is outside the missed-grace window, or the reminder window is opened as a `--screenshot` sample. |
|
||||||
|
| `bread.pad.capture.done` | `{}` | `bread.command.pad.capture` was received and `breadpad` was spawned. This is the command confirmation, not proof the popup mapped — the spawned process is the same no-args invocation as the capture keybind. |
|
||||||
|
| `bread.pad.capture.failed` | `{ "error": "<message>" }` | `bread.command.pad.capture` was received but this binary could not be started. |
|
||||||
|
|
||||||
|
Note bodies are never included in the payload — only the local note id.
|
||||||
|
Notes stay in `~/.local/share/breadpad/notes.jsonl`; the event bus is for
|
||||||
|
*notifications about* capture and due reminders, not a channel for note
|
||||||
|
content.
|
||||||
|
|
||||||
|
## Commands honored (`bread.command.pad.*`)
|
||||||
|
|
||||||
|
These are only received while `breadpad listen` is running. Publishing a
|
||||||
|
command with no subscriber is a silent no-op — that is the documented
|
||||||
|
bread convention, not a breadpad bug.
|
||||||
|
|
||||||
|
| Verb | Data | Effect |
|
||||||
|
|------|------|--------|
|
||||||
|
| `capture` | none | Same as running `breadpad` with no args: open the capture popup. Emits `bread.pad.capture.done` / `.failed`. |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.spawn(function()
|
||||||
|
bread.emit("bread.command.pad.capture")
|
||||||
|
bread.wait("bread.pad.capture.done", { timeout = 5000 })
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not implemented: extra verbs
|
||||||
|
|
||||||
|
There is no `snooze` / `done` / `fire` command verb. Reminder fire
|
||||||
|
already exists as `breadpad fire <id>` (systemd user timer), and
|
||||||
|
viewing/editing lives in `breadman`. If/when a bus verb maps to real
|
||||||
|
extra behavior, add it then — do not stub one as a no-op ahead of it.
|
||||||
|
|
||||||
|
## Fail-safe behavior
|
||||||
|
|
||||||
|
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||||
|
(`BreadClient::emit` never blocks or errors the caller) and the
|
||||||
|
command subscription simply never receives anything. Capture, save,
|
||||||
|
systemd timers, and the reminder window are entirely unaffected.
|
||||||
|
- If breadd restarts, the command subscription reconnects automatically
|
||||||
|
(`BreadClient::subscribe`'s background thread has its own backoff
|
||||||
|
loop); no restart of `breadpad listen` is needed.
|
||||||
|
- If `breadpad listen` is not running, commands are a graceful no-op at
|
||||||
|
the bus (no subscriber). One-shot capture / `fire` still emit
|
||||||
|
`bread.pad.captured` / `bread.pad.reminder.due` on their own
|
||||||
|
short-lived connection.
|
||||||
41
README.md
41
README.md
|
|
@ -32,11 +32,11 @@ breadman GTK4 note viewer / manager
|
||||||
|
|
||||||
### Classification
|
### Classification
|
||||||
|
|
||||||
Every note passes through a three-tier pipeline at capture time:
|
Every note passes through a three-tier pipeline at capture time. **Tier 1 is the only tier that ships with breadpad** — the ONNX classifier model is not bundled, and Ollama is optional. Capture works without either.
|
||||||
|
|
||||||
1. **Rule-based parser** — always runs first; handles time extraction ("at 7pm", "in 30 minutes", "tomorrow morning", "next Friday"), recurrence ("every Sunday at 9pm", "every weekday morning"), and strong type signals ("?" → question, "idea:" prefix → idea, action verbs → todo). High-confidence results skip the remaining tiers entirely.
|
1. **Rule-based parser** — always runs first; handles time extraction ("at 7pm", "in 30 minutes", "tomorrow morning", "next Friday"), recurrence ("every Sunday at 9pm", "every weekday morning"), and strong type signals ("?" → question, "idea:" prefix → idea, action verbs → todo). High-confidence results skip the remaining tiers entirely. This is the default path; no model files required.
|
||||||
2. **Small local ONNX model** — runs when Tier 1 can't confidently assign a type. Responsible for type classification only; Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
|
2. **Small local ONNX model (optional, not shipped)** — runs when Tier 1 can't confidently assign a type *and* you have dropped in your own classifier. Responsible for type classification only; Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
|
||||||
3. **Large local model via Ollama** — runs only when Tier 2 confidence falls below a configurable threshold. Communicates with a locally running Ollama instance over HTTP. If Ollama is unreachable, the Tier 2 result is used. No cloud APIs are involved.
|
3. **Large local model via Ollama (optional)** — runs only when Tier 2 confidence falls below a configurable threshold. Communicates with a locally running Ollama instance over HTTP. If Ollama is unreachable, the previous tier's result is used. No cloud APIs are involved.
|
||||||
|
|
||||||
Manual override always available — the AI-assigned type is shown as a chip you can tap to change before saving.
|
Manual override always available — the AI-assigned type is shown as a chip you can tap to change before saving.
|
||||||
|
|
||||||
|
|
@ -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
|
- **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
|
- **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
|
- **Missed reminders** — if the system was off or suspended at the scheduled time, the reminder fires on next login
|
||||||
|
|
||||||
### Viewer (`breadman`)
|
### Viewer (`breadman`)
|
||||||
|
|
@ -104,11 +104,11 @@ Always runs. Handles:
|
||||||
|
|
||||||
Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped.
|
Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped.
|
||||||
|
|
||||||
#### Tier 2 — Small local ONNX model
|
#### Tier 2 — Small local ONNX model (optional, not shipped)
|
||||||
|
|
||||||
Runs when Tier 1 confidence is below threshold. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
|
Runs when Tier 1 confidence is below threshold **and** a compatible `classifier.onnx` + `tokenizer.json` are present. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
|
||||||
|
|
||||||
Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`.
|
Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`. Without a model file or runtime library, Tier 2 is skipped and Tier 1 (plus optional Tier 3) still works.
|
||||||
|
|
||||||
#### Tier 3 — Large local model via Ollama
|
#### Tier 3 — Large local model via Ollama
|
||||||
|
|
||||||
|
|
@ -123,7 +123,7 @@ If Ollama is unreachable or returns an invalid response, breadpad logs a warning
|
||||||
~/.local/share/breadpad/model/tokenizer.json
|
~/.local/share/breadpad/model/tokenizer.json
|
||||||
```
|
```
|
||||||
|
|
||||||
breadpad ships without a bundled model. Drop a compatible ONNX classifier and `tokenizer.json` at those paths, then configure `model.ort_dylib_path` to point at your ONNX Runtime library.
|
**breadpad does not ship a classifier model.** Tier 1 rules work with no extra files. If you want Tier 2, drop a compatible ONNX classifier and `tokenizer.json` at those paths and point `model.ort_dylib_path` at your ONNX Runtime library.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
breadpad model-info # shows active EP and model path
|
breadpad model-info # shows active EP and model path
|
||||||
|
|
@ -138,23 +138,27 @@ breadpad model-info # shows active EP and model path
|
||||||
- D-Bus session bus (for notifications)
|
- D-Bus session bus (for notifications)
|
||||||
- systemd user session (for timer-backed reminders)
|
- systemd user session (for timer-backed reminders)
|
||||||
- Rust 1.80+
|
- Rust 1.80+
|
||||||
- **Tier 2 (ONNX classifier):** An external `libonnxruntime.so`. Set `model.ort_dylib_path` in `breadpad.toml`, or set `ORT_DYLIB_PATH` in your environment. Without a library, Tier 2 is disabled; Tier 1 + 3 still work.
|
- **Tier 2 (ONNX classifier, optional):** A model you supply yourself (`classifier.onnx` + `tokenizer.json`) and an external `libonnxruntime.so`. Set `model.ort_dylib_path` in `breadpad.toml`, or set `ORT_DYLIB_PATH` in your environment. Neither the model nor the runtime is shipped. Without them, Tier 2 is disabled; Tier 1 (and Tier 3, if Ollama is running) still work.
|
||||||
- **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (e.g. `ollama pull fastflowlm`). Tier 3 is silently skipped if Ollama is not running.
|
- **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (`ollama pull llama3.2:3b`). Tier 3 is silently skipped if Ollama is not running.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/breadway/breadpad
|
git clone https://git.breadway.dev/Breadway/breadpad
|
||||||
cd breadpad
|
cd breadpad
|
||||||
cargo build --release
|
cargo build --release
|
||||||
cp target/release/breadpad ~/.local/bin/
|
cp target/release/breadpad ~/.local/bin/
|
||||||
cp target/release/breadman ~/.local/bin/
|
cp target/release/breadman ~/.local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
# Place your ONNX classifier and tokenizer in the model directory
|
That's enough for capture + viewing. Tier 1 classification works out of the box. The ONNX classifier is **not** included — only add it if you want Tier 2:
|
||||||
|
|
||||||
|
```bash
|
||||||
mkdir -p ~/.local/share/breadpad/model
|
mkdir -p ~/.local/share/breadpad/model
|
||||||
# Then set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so
|
# Drop your own classifier.onnx + tokenizer.json in that directory, then
|
||||||
|
# set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so
|
||||||
```
|
```
|
||||||
|
|
||||||
On Arch Linux, install GTK4 dependencies first:
|
On Arch Linux, install GTK4 dependencies first:
|
||||||
|
|
@ -183,7 +187,7 @@ ort_dylib_path = "" # optional: explicit path to libonnxruntime.so;
|
||||||
|
|
||||||
[model.ollama]
|
[model.ollama]
|
||||||
endpoint = "http://localhost:11434"
|
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
|
confidence_threshold = 0.6 # Tier 2 scores below this trigger Tier 3
|
||||||
enabled = true # set false to never call Ollama
|
enabled = true # set false to never call Ollama
|
||||||
|
|
||||||
|
|
@ -243,15 +247,12 @@ breadpad --no-classify
|
||||||
|
|
||||||
# Show model and storage status
|
# Show model and storage status
|
||||||
breadpad --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)
|
### breadman (viewer)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ binaries = ["breadpad", "breadman"]
|
||||||
system_deps = ["gtk4", "gtk4-layer-shell"]
|
system_deps = ["gtk4", "gtk4-layer-shell"]
|
||||||
optional_system_deps = ["rocm-hip-runtime", "ollama", "hyprland"]
|
optional_system_deps = ["rocm-hip-runtime", "ollama", "hyprland"]
|
||||||
bread_deps = []
|
bread_deps = []
|
||||||
|
license_file = "LICENSE"
|
||||||
|
|
||||||
[config]
|
[config]
|
||||||
dir = "~/.config/breadpad"
|
dir = "~/.config/breadpad"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
breadpad-shared = { path = "../breadpad-shared" }
|
breadpad-shared = { path = "../breadpad-shared" }
|
||||||
|
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
||||||
|
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
|
# Shared `--screenshot` pair validation + settle delay (`screenshot_cli`).
|
||||||
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
|
# `adw` implies `gtk` (`chip`, `set_chip_active`, `adw::init`).
|
||||||
|
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["adw"] }
|
||||||
|
libadwaita = { version = "0.9", features = ["v1_7"] }
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
//! Note editor, presented as an AdwDialog (was a bare GtkPopover with no
|
||||||
|
//! scrim, no title, anchored wherever the triggering button happened to be -
|
||||||
|
//! flagged in design review as the weakest surface in the app). AdwDialog
|
||||||
|
//! gives us the scrim, the title, and correct modal anchoring for free.
|
||||||
|
|
||||||
use breadpad_shared::{
|
use breadpad_shared::{
|
||||||
parser::parse_rule_based,
|
parser::parse_rule_based,
|
||||||
scheduler::Scheduler,
|
scheduler::Scheduler,
|
||||||
|
|
@ -6,48 +11,73 @@ use breadpad_shared::{
|
||||||
};
|
};
|
||||||
use chrono::{Local, TimeZone, Utc};
|
use chrono::{Local, TimeZone, Utc};
|
||||||
use gtk4::{glib, prelude::*};
|
use gtk4::{glib, prelude::*};
|
||||||
|
use libadwaita::prelude::*;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub fn build_editor_popover(
|
/// Same wording used by `main::show_add_note_window`'s New Note dialog - the
|
||||||
|
/// two surfaces used to teach the user two different input languages for
|
||||||
|
/// the same fields.
|
||||||
|
pub const TIME_PLACEHOLDER: &str = "tomorrow 9am / at 7pm / 2026-08-01 09:00";
|
||||||
|
pub const RRULE_PLACEHOLDER: &str = "RRULE:FREQ=WEEKLY;BYDAY=MO";
|
||||||
|
|
||||||
|
/// Builds the dialog but does not present it - callers that need to hook
|
||||||
|
/// its `map` signal (screenshot mode) must connect before presenting or
|
||||||
|
/// they miss the signal entirely; interactive callers present immediately
|
||||||
|
/// with `dialog.present(Some(parent))`.
|
||||||
|
pub fn open_editor(
|
||||||
note: &Note,
|
note: &Note,
|
||||||
store: Arc<Store>,
|
store: Arc<Store>,
|
||||||
morning: String,
|
morning: String,
|
||||||
on_save: Rc<dyn Fn(Note)>,
|
on_save: Rc<dyn Fn(Note)>,
|
||||||
on_delete: Rc<dyn Fn()>,
|
on_delete: Rc<dyn Fn()>,
|
||||||
on_error: Rc<dyn Fn(String)>,
|
on_error: Rc<dyn Fn(String)>,
|
||||||
) -> gtk4::Popover {
|
) -> libadwaita::Dialog {
|
||||||
let popover = gtk4::Popover::new();
|
let dialog = libadwaita::Dialog::builder()
|
||||||
popover.set_has_arrow(false);
|
.title("Edit Note")
|
||||||
|
.content_width(480)
|
||||||
|
.content_height(520)
|
||||||
|
.build();
|
||||||
|
|
||||||
let vbox = gtk4::Box::builder()
|
let header = libadwaita::HeaderBar::new();
|
||||||
|
let toolbar_view = libadwaita::ToolbarView::new();
|
||||||
|
toolbar_view.add_top_bar(&header);
|
||||||
|
|
||||||
|
let content = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
.spacing(8)
|
.spacing(16)
|
||||||
.margin_top(12)
|
.margin_top(16)
|
||||||
.margin_bottom(12)
|
.margin_bottom(16)
|
||||||
.margin_start(12)
|
.margin_start(16)
|
||||||
.margin_end(12)
|
.margin_end(16)
|
||||||
.width_request(420)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Body").xalign(0.0).build());
|
let group = libadwaita::PreferencesGroup::builder().title("Details").build();
|
||||||
let body_entry = gtk4::Entry::builder()
|
|
||||||
.text(¬e.body)
|
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
vbox.append(&body_entry);
|
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Type").xalign(0.0).build());
|
let body_row = libadwaita::EntryRow::builder().title("Body").build();
|
||||||
let type_combo = gtk4::DropDown::from_strings(NoteType::all_builtin());
|
body_row.set_text(¬e.body);
|
||||||
let current_idx = NoteType::all_builtin()
|
group.add(&body_row);
|
||||||
.iter()
|
|
||||||
.position(|&s| s == note.note_type.as_str())
|
let type_row = libadwaita::ActionRow::builder().title("Type").build();
|
||||||
.unwrap_or(3) as u32;
|
let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
|
||||||
type_combo.set_selected(current_idx);
|
let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(note.note_type.as_str().to_string()));
|
||||||
vbox.append(&type_combo);
|
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin().iter().map(|&name| (bread_theme::gtk::chip(name), name)).collect();
|
||||||
|
for (btn, name) in &type_pills {
|
||||||
|
bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str());
|
||||||
|
let sel = selected_type.clone();
|
||||||
|
let name = *name;
|
||||||
|
let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect();
|
||||||
|
btn.connect_clicked(move |clicked| {
|
||||||
|
*sel.borrow_mut() = name.to_string();
|
||||||
|
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
|
||||||
|
bread_theme::gtk::set_chip_active(clicked, true);
|
||||||
|
});
|
||||||
|
type_pill_box.append(btn);
|
||||||
|
}
|
||||||
|
type_row.add_suffix(&type_pill_box);
|
||||||
|
group.add(&type_row);
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Time").xalign(0.0).build());
|
|
||||||
let time_text = note
|
let time_text = note
|
||||||
.time
|
.time
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
|
|
@ -55,38 +85,44 @@ pub fn build_editor_popover(
|
||||||
local.format("%Y-%m-%d %H:%M").to_string()
|
local.format("%Y-%m-%d %H:%M").to_string()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let time_entry = gtk4::Entry::builder()
|
let time_row = libadwaita::EntryRow::builder().title("Time").build();
|
||||||
.text(&time_text)
|
time_row.set_text(&time_text);
|
||||||
.placeholder_text("YYYY-MM-DD HH:MM or tomorrow 9am (blank = no time)")
|
// EntryRow has no placeholder-text property of its own (unlike GtkEntry) -
|
||||||
.hexpand(true)
|
// the title already communicates the field, so the example format goes in
|
||||||
.build();
|
// the group description instead of a placeholder that would otherwise
|
||||||
vbox.append(&time_entry);
|
// vanish behind the title when empty.
|
||||||
|
group.add(&time_row);
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Recurrence").xalign(0.0).build());
|
let rrule_row = libadwaita::EntryRow::builder().title("Recurrence").build();
|
||||||
let rrule_entry = gtk4::Entry::builder()
|
rrule_row.set_text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""));
|
||||||
.text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""))
|
group.add(&rrule_row);
|
||||||
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO (blank = none)")
|
|
||||||
.build();
|
|
||||||
vbox.append(&rrule_entry);
|
|
||||||
|
|
||||||
// Button row: [Delete] [Save]
|
content.append(&group);
|
||||||
let btn_row = gtk4::Box::builder()
|
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
|
||||||
.spacing(8)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let delete_btn = gtk4::Button::builder()
|
let hint = gtk4::Label::builder()
|
||||||
.label("🗑 Delete")
|
.label(format!("Time: {TIME_PLACEHOLDER}\nRecurrence: {RRULE_PLACEHOLDER}"))
|
||||||
.css_classes(["danger-btn"])
|
.css_classes(["dim-label"])
|
||||||
.build();
|
.xalign(0.0)
|
||||||
let save_btn = gtk4::Button::builder()
|
.wrap(true)
|
||||||
.label("Save")
|
|
||||||
.css_classes(["confirm-button"])
|
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
.build();
|
||||||
|
content.append(&hint);
|
||||||
|
|
||||||
|
let btn_row = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(8).build();
|
||||||
|
let delete_btn = gtk4::Button::builder().label("Delete").css_classes(["destructive-action"]).build();
|
||||||
|
let save_btn = gtk4::Button::builder().label("Save").css_classes(["confirm-button"]).hexpand(true).build();
|
||||||
btn_row.append(&delete_btn);
|
btn_row.append(&delete_btn);
|
||||||
btn_row.append(&save_btn);
|
btn_row.append(&save_btn);
|
||||||
vbox.append(&btn_row);
|
content.append(&btn_row);
|
||||||
|
|
||||||
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||||
|
.vexpand(true)
|
||||||
|
.min_content_height(400)
|
||||||
|
.build();
|
||||||
|
scroll.set_child(Some(&content));
|
||||||
|
toolbar_view.set_content(Some(&scroll));
|
||||||
|
dialog.set_child(Some(&toolbar_view));
|
||||||
|
|
||||||
// Delete: two-click confirm
|
// Delete: two-click confirm
|
||||||
let confirming = Rc::new(RefCell::new(false));
|
let confirming = Rc::new(RefCell::new(false));
|
||||||
|
|
@ -95,7 +131,7 @@ pub fn build_editor_popover(
|
||||||
let delete_btn_label = delete_btn.clone();
|
let delete_btn_label = delete_btn.clone();
|
||||||
let note_id = note.id.clone();
|
let note_id = note.id.clone();
|
||||||
let store_del = store.clone();
|
let store_del = store.clone();
|
||||||
let popover_del = popover.clone();
|
let dialog_del = dialog.clone();
|
||||||
let on_delete = Rc::clone(&on_delete);
|
let on_delete = Rc::clone(&on_delete);
|
||||||
let on_error = Rc::clone(&on_error);
|
let on_error = Rc::clone(&on_error);
|
||||||
|
|
||||||
|
|
@ -105,7 +141,7 @@ pub fn build_editor_popover(
|
||||||
let id = note_id.clone();
|
let id = note_id.clone();
|
||||||
let on_delete = Rc::clone(&on_delete);
|
let on_delete = Rc::clone(&on_delete);
|
||||||
let on_error = Rc::clone(&on_error);
|
let on_error = Rc::clone(&on_error);
|
||||||
let popover = popover_del.clone();
|
let dialog = dialog_del.clone();
|
||||||
spawn_bg(
|
spawn_bg(
|
||||||
move || -> anyhow::Result<()> {
|
move || -> anyhow::Result<()> {
|
||||||
store.delete_note(&id)?;
|
store.delete_note(&id)?;
|
||||||
|
|
@ -119,7 +155,7 @@ pub fn build_editor_popover(
|
||||||
Ok(()) => on_delete(),
|
Ok(()) => on_delete(),
|
||||||
Err(e) => on_error(format!("delete failed: {}", e)),
|
Err(e) => on_error(format!("delete failed: {}", e)),
|
||||||
}
|
}
|
||||||
popover.popdown();
|
dialog.close();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -132,33 +168,20 @@ pub fn build_editor_popover(
|
||||||
// Save
|
// Save
|
||||||
{
|
{
|
||||||
let note_clone = note.clone();
|
let note_clone = note.clone();
|
||||||
let popover_save = popover.clone();
|
let dialog_save = dialog.clone();
|
||||||
let on_error = Rc::clone(&on_error);
|
let on_error = Rc::clone(&on_error);
|
||||||
|
let selected_type = selected_type.clone();
|
||||||
|
|
||||||
save_btn.connect_clicked(move |_| {
|
save_btn.connect_clicked(move |_| {
|
||||||
// Read all field values on the main thread before handing off.
|
|
||||||
let mut updated = note_clone.clone();
|
let mut updated = note_clone.clone();
|
||||||
updated.body = body_entry.text().to_string();
|
updated.body = body_row.text().to_string();
|
||||||
updated.note_type = NoteType::from_str(
|
updated.note_type = NoteType::from_str(&selected_type.borrow());
|
||||||
NoteType::all_builtin()
|
let time_str = time_row.text().to_string();
|
||||||
.get(type_combo.selected() as usize)
|
updated.time = if time_str.trim().is_empty() { None } else { parse_time_field(&time_str, &morning) };
|
||||||
.copied()
|
let rrule_text = rrule_row.text().to_string();
|
||||||
.unwrap_or("note"),
|
updated.rrule = if rrule_text.trim().is_empty() { None } else { Some(RecurrenceRule::new(rrule_text)) };
|
||||||
);
|
|
||||||
let time_str = time_entry.text().to_string();
|
|
||||||
updated.time = if time_str.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
parse_time_field(&time_str, &morning)
|
|
||||||
};
|
|
||||||
let rrule_text = rrule_entry.text().to_string();
|
|
||||||
updated.rrule = if rrule_text.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(RecurrenceRule::new(rrule_text))
|
|
||||||
};
|
|
||||||
|
|
||||||
popover_save.popdown();
|
dialog_save.close();
|
||||||
|
|
||||||
let store_bg = store.clone();
|
let store_bg = store.clone();
|
||||||
let on_save = Rc::clone(&on_save);
|
let on_save = Rc::clone(&on_save);
|
||||||
|
|
@ -182,8 +205,7 @@ pub fn build_editor_popover(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
popover.set_child(Some(&vbox));
|
dialog
|
||||||
popover
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_bg<F, T, C>(work: F, then: C)
|
fn spawn_bg<F, T, C>(work: F, then: C)
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,52 @@ use breadpad_shared::{
|
||||||
store::Store,
|
store::Store,
|
||||||
types::{Note, NoteType, RecurrenceRule},
|
types::{Note, NoteType, RecurrenceRule},
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Local, Utc};
|
use chrono::Local;
|
||||||
use gtk4::{glib, prelude::*};
|
use gtk4::{glib, prelude::*};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
mod editor;
|
mod editor;
|
||||||
|
mod screenshot;
|
||||||
mod views;
|
mod views;
|
||||||
|
|
||||||
// ── Args ─────────────────────────────────────────────────────────────────────
|
// ── Args ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
mod args {
|
mod args {
|
||||||
|
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
pub view: Option<String>,
|
pub view: Option<String>,
|
||||||
pub done_id: Option<String>,
|
pub done_id: Option<String>,
|
||||||
pub upcoming_plain: bool,
|
pub upcoming_plain: bool,
|
||||||
|
pub screenshot: Option<String>,
|
||||||
|
pub output: Option<String>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
/// `None` for a normal run. Exits the process with an error if the
|
||||||
|
/// `--screenshot` / `--output` pair is incomplete, before any GTK
|
||||||
|
/// setup happens.
|
||||||
|
pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> {
|
||||||
|
if let Err(e) = validate_pair(
|
||||||
|
self.screenshot.as_deref(),
|
||||||
|
self.output.as_deref().map(Path::new),
|
||||||
|
) {
|
||||||
|
eprintln!("breadman: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Some(crate::screenshot::ScreenshotRequest {
|
||||||
|
view: self.screenshot.clone()?,
|
||||||
|
output: self.output.clone()?.into(),
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse() -> Args {
|
pub fn parse() -> Args {
|
||||||
|
|
@ -30,6 +59,10 @@ mod args {
|
||||||
view: None,
|
view: None,
|
||||||
done_id: None,
|
done_id: None,
|
||||||
upcoming_plain: false,
|
upcoming_plain: false,
|
||||||
|
screenshot: None,
|
||||||
|
output: None,
|
||||||
|
width: DEFAULT_WIDTH,
|
||||||
|
height: DEFAULT_HEIGHT,
|
||||||
};
|
};
|
||||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
@ -50,6 +83,26 @@ mod args {
|
||||||
}
|
}
|
||||||
args.view = Some("upcoming".into());
|
args.view = Some("upcoming".into());
|
||||||
}
|
}
|
||||||
|
"--screenshot" => {
|
||||||
|
i += 1;
|
||||||
|
args.screenshot = raw.get(i).cloned();
|
||||||
|
}
|
||||||
|
"--output" => {
|
||||||
|
i += 1;
|
||||||
|
args.output = raw.get(i).cloned();
|
||||||
|
}
|
||||||
|
"--width" => {
|
||||||
|
i += 1;
|
||||||
|
if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) {
|
||||||
|
args.width = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--height" => {
|
||||||
|
i += 1;
|
||||||
|
if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) {
|
||||||
|
args.height = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
@ -60,30 +113,22 @@ mod args {
|
||||||
|
|
||||||
// ── AppState ──────────────────────────────────────────────────────────────────
|
// ── AppState ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ErrorLog = Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>;
|
||||||
|
|
||||||
/// Shared UI state, cheap to clone (all fields are Rc/Arc).
|
/// Shared UI state, cheap to clone (all fields are Rc/Arc).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
store: Arc<Store>,
|
store: Arc<Store>,
|
||||||
notes: Rc<RefCell<Vec<Note>>>,
|
notes: Rc<RefCell<Vec<Note>>>,
|
||||||
cfg: Rc<RefCell<Config>>,
|
cfg: Rc<RefCell<Config>>,
|
||||||
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
|
errors: ErrorLog,
|
||||||
active_view: Rc<RefCell<String>>,
|
active_view: Rc<RefCell<String>>,
|
||||||
stack: gtk4::Stack,
|
stack: gtk4::Stack,
|
||||||
/// Sidebar row id ("all", "upcoming", "archive", or a note type name) ->
|
window: gtk4::ApplicationWindow,
|
||||||
/// its count `Label`, so counts can be refreshed in place after every
|
|
||||||
/// `rebuild_stack` without rebuilding the sidebar itself.
|
|
||||||
sidebar_counts: Rc<RefCell<Vec<(String, gtk4::Label)>>>,
|
|
||||||
status_label: gtk4::Label,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
fn new(
|
fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack, window: gtk4::ApplicationWindow) -> Self {
|
||||||
store: Arc<Store>,
|
|
||||||
notes: Vec<Note>,
|
|
||||||
cfg: Config,
|
|
||||||
stack: gtk4::Stack,
|
|
||||||
status_label: gtk4::Label,
|
|
||||||
) -> Self {
|
|
||||||
AppState {
|
AppState {
|
||||||
store,
|
store,
|
||||||
notes: Rc::new(RefCell::new(notes)),
|
notes: Rc::new(RefCell::new(notes)),
|
||||||
|
|
@ -91,8 +136,7 @@ impl AppState {
|
||||||
errors: Rc::new(RefCell::new(Vec::new())),
|
errors: Rc::new(RefCell::new(Vec::new())),
|
||||||
active_view: Rc::new(RefCell::new("all".to_string())),
|
active_view: Rc::new(RefCell::new("all".to_string())),
|
||||||
stack,
|
stack,
|
||||||
sidebar_counts: Rc::new(RefCell::new(Vec::new())),
|
window,
|
||||||
status_label,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +202,7 @@ fn rebuild_all_view(notes: &[Note], state: &AppState) {
|
||||||
if let Some(child) = state.stack.child_by_name("all") {
|
if let Some(child) = state.stack.child_by_name("all") {
|
||||||
state.stack.remove(&child);
|
state.stack.remove(&child);
|
||||||
}
|
}
|
||||||
let scroll = build_note_list(notes, state.clone());
|
let scroll = build_note_list(notes, state.clone(), true, "No notes yet — jot something down to get started.", Some(NoteType::Note));
|
||||||
state.stack.add_named(&scroll, Some("all"));
|
state.stack.add_named(&scroll, Some("all"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,14 +216,22 @@ fn rebuild_stack(state: &AppState) {
|
||||||
let errors: Vec<_> = state.errors.borrow().clone();
|
let errors: Vec<_> = state.errors.borrow().clone();
|
||||||
|
|
||||||
// All
|
// All
|
||||||
let all_scroll = build_note_list(¬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"));
|
state.stack.add_named(&all_scroll, Some("all"));
|
||||||
|
|
||||||
// Upcoming
|
// Upcoming
|
||||||
let upcoming = views::upcoming::build(¬es);
|
let upcoming = views::upcoming::build(¬es, state.clone());
|
||||||
state.stack.add_named(&upcoming, Some("upcoming"));
|
state.stack.add_named(&upcoming, Some("upcoming"));
|
||||||
|
|
||||||
// Per-type
|
// Per-type
|
||||||
|
let empty_text = |type_name: &str| match type_name {
|
||||||
|
"todo" => "No todos yet.",
|
||||||
|
"reminder" => "No reminders yet.",
|
||||||
|
"idea" => "No ideas captured yet.",
|
||||||
|
"note" => "No notes yet.",
|
||||||
|
"question" => "No open questions yet.",
|
||||||
|
_ => "Nothing here yet.",
|
||||||
|
};
|
||||||
for type_name in NoteType::all_builtin() {
|
for type_name in NoteType::all_builtin() {
|
||||||
let nt = NoteType::from_str(type_name);
|
let nt = NoteType::from_str(type_name);
|
||||||
let filtered: Vec<Note> = notes
|
let filtered: Vec<Note> = notes
|
||||||
|
|
@ -187,7 +239,7 @@ fn rebuild_stack(state: &AppState) {
|
||||||
.filter(|n| n.note_type == nt && !n.done)
|
.filter(|n| n.note_type == nt && !n.done)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
let scroll = build_note_list(&filtered, state.clone());
|
let scroll = build_note_list(&filtered, state.clone(), false, empty_text(type_name), Some(nt));
|
||||||
state.stack.add_named(&scroll, Some(type_name));
|
state.stack.add_named(&scroll, Some(type_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,40 +257,6 @@ fn rebuild_stack(state: &AppState) {
|
||||||
// Errors
|
// Errors
|
||||||
let errors_view = views::errors::build(&errors);
|
let errors_view = views::errors::build(&errors);
|
||||||
state.stack.add_named(&errors_view, Some("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 ─────────────────────────────────────────────────────────────────────
|
// ── main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -261,7 +279,9 @@ fn main() -> Result<()> {
|
||||||
return cmd_upcoming_plain();
|
return cmd_upcoming_plain();
|
||||||
}
|
}
|
||||||
|
|
||||||
run_app(args.view, cfg)
|
let screenshot_req = args.screenshot_request();
|
||||||
|
let view = screenshot_req.as_ref().map(|r| r.view.clone()).or(args.view);
|
||||||
|
run_app(view, cfg, screenshot_req)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_done(id: &str) -> Result<()> {
|
fn cmd_done(id: &str) -> Result<()> {
|
||||||
|
|
@ -297,10 +317,20 @@ fn cmd_upcoming_plain() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_app(initial_view: Option<String>, cfg: Config) -> Result<()> {
|
fn run_app(
|
||||||
let app = gtk4::Application::builder()
|
initial_view: Option<String>,
|
||||||
.application_id("com.breadway.breadman")
|
cfg: Config,
|
||||||
.build();
|
screenshot_req: Option<screenshot::ScreenshotRequest>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut builder = gtk4::Application::builder().application_id("com.breadway.breadman");
|
||||||
|
if screenshot_req.is_some() {
|
||||||
|
// GApplication is single-instance by default; this machine typically
|
||||||
|
// already has a real breadman instance, so without this a
|
||||||
|
// screenshot run would activate the *existing* instance instead of
|
||||||
|
// starting a fresh one that ever sees `screenshot_req`.
|
||||||
|
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
|
||||||
|
}
|
||||||
|
let app = builder.build();
|
||||||
|
|
||||||
let cfg = Arc::new(cfg);
|
let cfg = Arc::new(cfg);
|
||||||
let initial_view = Arc::new(initial_view);
|
let initial_view = Arc::new(initial_view);
|
||||||
|
|
@ -308,7 +338,7 @@ fn run_app(initial_view: Option<String>, cfg: Config) -> Result<()> {
|
||||||
app.connect_activate(move |app| {
|
app.connect_activate(move |app| {
|
||||||
let cfg = cfg.as_ref().clone();
|
let cfg = cfg.as_ref().clone();
|
||||||
let initial_view = initial_view.as_deref().map(|s| s.to_string());
|
let initial_view = initial_view.as_deref().map(|s| s.to_string());
|
||||||
if let Err(e) = build_app_window(app, cfg, initial_view) {
|
if let Err(e) = build_app_window(app, cfg, initial_view, screenshot_req.clone()) {
|
||||||
tracing::error!("failed to build window: {}", e);
|
tracing::error!("failed to build window: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -326,8 +356,13 @@ fn build_app_window(
|
||||||
app: >k4::Application,
|
app: >k4::Application,
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
initial_view: Option<String>,
|
initial_view: Option<String>,
|
||||||
|
screenshot_req: Option<screenshot::ScreenshotRequest>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
apply_css(&cfg);
|
apply_css(&cfg);
|
||||||
|
// Needed once before constructing any adw:: widget (see views::settings) —
|
||||||
|
// also forces dark mode, since bread-theme's palette is a fixed dark base
|
||||||
|
// regardless of the system GTK preference.
|
||||||
|
bread_theme::adw::init();
|
||||||
|
|
||||||
let store = Arc::new(Store::new()?);
|
let store = Arc::new(Store::new()?);
|
||||||
let notes = store.load_all()?;
|
let notes = store.load_all()?;
|
||||||
|
|
@ -338,6 +373,7 @@ fn build_app_window(
|
||||||
.default_width(960)
|
.default_width(960)
|
||||||
.default_height(640)
|
.default_height(640)
|
||||||
.build();
|
.build();
|
||||||
|
bread_theme::gtk::bind_window_auto(&window);
|
||||||
|
|
||||||
let hbox = gtk4::Box::builder()
|
let hbox = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
|
@ -378,12 +414,11 @@ fn build_app_window(
|
||||||
));
|
));
|
||||||
row
|
row
|
||||||
};
|
};
|
||||||
// Returns the row plus its count `Label` when `counted` is set — callers
|
// One icon language throughout (was 8 full-colour emoji + 2 thin
|
||||||
// collect these so counts can be kept live from `AppState.sidebar_counts`
|
// monochrome glyphs for Settings/Errors + a third style on row action
|
||||||
// (monochrome geometric icons + a colored dot per type instead of the
|
// buttons) — every sidebar entry and row action now uses a real GTK
|
||||||
// old full-color emoji, which always render in fixed colors no matter
|
// symbolic icon.
|
||||||
// what the pywal palette says).
|
let make_item = |id: &str, icon_name: &str, label: &str| {
|
||||||
let make_item = |id: &str, icon: &str, icon_class: Option<&str>, label: &str, counted: bool| {
|
|
||||||
let row = gtk4::ListBoxRow::builder()
|
let row = gtk4::ListBoxRow::builder()
|
||||||
.css_classes(["sidebar-row"])
|
.css_classes(["sidebar-row"])
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -392,15 +427,7 @@ fn build_app_window(
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.build();
|
.build();
|
||||||
let icon_label = gtk4::Label::builder()
|
hbox.append(>k4::Image::builder().icon_name(icon_name).pixel_size(16).build());
|
||||||
.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(
|
hbox.append(
|
||||||
>k4::Label::builder()
|
>k4::Label::builder()
|
||||||
.label(label)
|
.label(label)
|
||||||
|
|
@ -408,62 +435,23 @@ fn build_app_window(
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.build(),
|
.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.set_child(Some(&hbox));
|
||||||
(row, count_label)
|
row
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut sidebar_counts: Vec<(String, gtk4::Label)> = Vec::new();
|
|
||||||
|
|
||||||
sidebar_list.append(&make_section("VIEWS"));
|
sidebar_list.append(&make_section("VIEWS"));
|
||||||
{
|
sidebar_list.append(&make_item("all", "view-list-symbolic", "All"));
|
||||||
let (row, count) = make_item("all", "▦", None, "All", true);
|
sidebar_list.append(&make_item("upcoming", "x-office-calendar-symbolic", "Upcoming"));
|
||||||
sidebar_counts.push(("all".into(), count.unwrap()));
|
|
||||||
sidebar_list.append(&row);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let (row, count) = make_item("upcoming", "◷", None, "Upcoming", true);
|
|
||||||
sidebar_counts.push(("upcoming".into(), count.unwrap()));
|
|
||||||
sidebar_list.append(&row);
|
|
||||||
}
|
|
||||||
sidebar_list.append(&make_section("TYPES"));
|
sidebar_list.append(&make_section("TYPES"));
|
||||||
for (id, icon_class, label) in [
|
sidebar_list.append(&make_item("todo", "task-due-symbolic", "Todo"));
|
||||||
("todo", "icon-todo", "Todo"),
|
sidebar_list.append(&make_item("reminder", "appointment-soon-symbolic", "Reminder"));
|
||||||
("reminder", "icon-reminder", "Reminder"),
|
sidebar_list.append(&make_item("idea", "emblem-important-symbolic", "Idea"));
|
||||||
("idea", "icon-idea", "Idea"),
|
sidebar_list.append(&make_item("note", "text-x-generic-symbolic", "Note"));
|
||||||
("note", "icon-note", "Note"),
|
sidebar_list.append(&make_item("question", "dialog-question-symbolic", "Question"));
|
||||||
("question", "icon-question", "Question"),
|
|
||||||
] {
|
|
||||||
let (row, count) = make_item(id, "●", Some(icon_class), label, true);
|
|
||||||
sidebar_counts.push((id.to_string(), count.unwrap()));
|
|
||||||
sidebar_list.append(&row);
|
|
||||||
}
|
|
||||||
sidebar_list.append(&make_section("MORE"));
|
sidebar_list.append(&make_section("MORE"));
|
||||||
{
|
sidebar_list.append(&make_item("archive", "folder-symbolic", "Archive"));
|
||||||
let (row, count) = make_item("archive", "▢", None, "Archive", true);
|
sidebar_list.append(&make_item("settings", "preferences-system-symbolic", "Settings"));
|
||||||
sidebar_counts.push(("archive".into(), count.unwrap()));
|
sidebar_list.append(&make_item("errors", "dialog-warning-symbolic", "Errors"));
|
||||||
sidebar_list.append(&row);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let (row, _) = make_item("settings", "⚙", None, "Settings", false);
|
|
||||||
sidebar_list.append(&row);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
// De-emphasized: session-only debug info, not part of the daily
|
|
||||||
// triage flow the rest of the sidebar serves.
|
|
||||||
let (row, _) = make_item("errors", "⚠", None, "Errors", false);
|
|
||||||
row.add_css_class("sidebar-row-minor");
|
|
||||||
sidebar_list.append(&row);
|
|
||||||
}
|
|
||||||
sidebar_vbox.append(&sidebar_list);
|
sidebar_vbox.append(&sidebar_list);
|
||||||
|
|
||||||
// ── Content area ──────────────────────────────────────────────
|
// ── Content area ──────────────────────────────────────────────
|
||||||
|
|
@ -483,19 +471,8 @@ fn build_app_window(
|
||||||
|
|
||||||
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build();
|
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(&search_entry);
|
||||||
content_vbox.append(&stack);
|
content_vbox.append(&stack);
|
||||||
content_vbox.append(&status_label);
|
|
||||||
|
|
||||||
hbox.append(&sidebar_vbox);
|
hbox.append(&sidebar_vbox);
|
||||||
hbox.append(>k4::Separator::builder()
|
hbox.append(>k4::Separator::builder()
|
||||||
|
|
@ -505,8 +482,7 @@ fn build_app_window(
|
||||||
window.set_child(Some(&hbox));
|
window.set_child(Some(&hbox));
|
||||||
|
|
||||||
// ── AppState ──────────────────────────────────────────────────
|
// ── AppState ──────────────────────────────────────────────────
|
||||||
let state = AppState::new(store, notes, cfg, stack.clone(), status_label.clone());
|
let state = AppState::new(store, notes, cfg, stack.clone(), window.clone());
|
||||||
state.sidebar_counts.replace(sidebar_counts);
|
|
||||||
|
|
||||||
// Initial build
|
// Initial build
|
||||||
rebuild_stack(&state);
|
rebuild_stack(&state);
|
||||||
|
|
@ -514,11 +490,15 @@ fn build_app_window(
|
||||||
// ── Sidebar selection ─────────────────────────────────────────
|
// ── Sidebar selection ─────────────────────────────────────────
|
||||||
{
|
{
|
||||||
let state_c = state.clone();
|
let state_c = state.clone();
|
||||||
|
let search_entry_c = search_entry.clone();
|
||||||
sidebar_list.connect_row_selected(move |_, row| {
|
sidebar_list.connect_row_selected(move |_, row| {
|
||||||
if let Some(row) = row {
|
if let Some(row) = row {
|
||||||
let view = row.widget_name().to_string();
|
let view = row.widget_name().to_string();
|
||||||
if view.is_empty() { return; }
|
if view.is_empty() { return; }
|
||||||
*state_c.active_view.borrow_mut() = view.clone();
|
*state_c.active_view.borrow_mut() = view.clone();
|
||||||
|
// The search bar only means anything on note-list views —
|
||||||
|
// it used to render (uselessly) on Settings and Errors too.
|
||||||
|
search_entry_c.set_visible(!matches!(view.as_str(), "settings" | "errors"));
|
||||||
refresh(&state_c);
|
refresh(&state_c);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -550,13 +530,14 @@ fn build_app_window(
|
||||||
let state_c = state.clone();
|
let state_c = state.clone();
|
||||||
let window_c = window.clone();
|
let window_c = window.clone();
|
||||||
new_note_btn.connect_clicked(move |_| {
|
new_note_btn.connect_clicked(move |_| {
|
||||||
show_add_note_window(&window_c, state_c.clone());
|
show_add_note_window(&window_c, state_c.clone(), NoteType::Note, |_| {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Select initial view ───────────────────────────────────────
|
// ── Select initial view ───────────────────────────────────────
|
||||||
let initial = initial_view.as_deref().unwrap_or("all");
|
let initial = initial_view.as_deref().unwrap_or("all");
|
||||||
*state.active_view.borrow_mut() = initial.to_string();
|
*state.active_view.borrow_mut() = initial.to_string();
|
||||||
|
search_entry.set_visible(!matches!(initial, "settings" | "errors"));
|
||||||
for row in sidebar_list
|
for row in sidebar_list
|
||||||
.observe_children()
|
.observe_children()
|
||||||
.snapshot()
|
.snapshot()
|
||||||
|
|
@ -570,59 +551,55 @@ fn build_app_window(
|
||||||
}
|
}
|
||||||
stack.set_visible_child_name(initial);
|
stack.set_visible_child_name(initial);
|
||||||
|
|
||||||
|
if let Some(req) = screenshot_req {
|
||||||
|
screenshot::dispatch(&window, req, state.clone());
|
||||||
|
}
|
||||||
|
|
||||||
window.present();
|
window.present();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Note list & cards ─────────────────────────────────────────────────────────
|
// ── Note list ─────────────────────────────────────────────────────────────────
|
||||||
|
// Row rendering itself lives in views::row (shared with Upcoming/Archive) —
|
||||||
|
// design review found the old two-line card here wasted enormous horizontal
|
||||||
|
// space (title and actions separated by ~800-1000px of dead middle) compared
|
||||||
|
// to Archive's tighter single-line layout, so all list views now share one
|
||||||
|
// row template.
|
||||||
|
|
||||||
fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
fn build_note_list(
|
||||||
|
notes: &[Note],
|
||||||
|
state: AppState,
|
||||||
|
show_type_badge: bool,
|
||||||
|
empty_text: &str,
|
||||||
|
empty_new_type: Option<NoteType>,
|
||||||
|
) -> gtk4::ScrolledWindow {
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.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()
|
let list = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
.spacing(8)
|
.spacing(4)
|
||||||
.margin_top(12)
|
.margin_top(8)
|
||||||
.margin_bottom(12)
|
.margin_bottom(8)
|
||||||
.margin_start(12)
|
|
||||||
.margin_end(12)
|
|
||||||
.width_request(700)
|
|
||||||
.halign(gtk4::Align::Center)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
|
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
|
||||||
sorted.sort_by(|a, b| b.created.cmp(&a.created));
|
sorted.sort_by_key(|n| std::cmp::Reverse(n.created));
|
||||||
|
|
||||||
if sorted.is_empty() {
|
if sorted.is_empty() {
|
||||||
let empty = gtk4::Box::builder()
|
let action = empty_new_type.map(|nt| views::row::new_note_action(nt, state.window.clone(), state.clone()));
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
list.append(&views::row::build_empty_state("view-list-symbolic", empty_text, action));
|
||||||
.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);
|
|
||||||
} else {
|
} else {
|
||||||
for note in &sorted {
|
for note in &sorted {
|
||||||
list.append(&build_note_card(note, state.clone()));
|
let created_str = {
|
||||||
|
let local: chrono::DateTime<Local> = note.created.into();
|
||||||
|
local.format("%b %d %H:%M").to_string()
|
||||||
|
};
|
||||||
|
let spec = views::row::RowSpec { date_label: created_str, note, show_type_badge, show_done: true };
|
||||||
|
list.append(&views::row::build(spec, state.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -630,260 +607,16 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
||||||
scroll
|
scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short relative form for a recency-ordered list ("2h ago"); the exact
|
|
||||||
/// absolute timestamp is still available via tooltip. Falls back to a plain
|
|
||||||
/// date once a note is more than a week old, where "Nd ago" stops being
|
|
||||||
/// useful at a glance.
|
|
||||||
fn humanize_relative(dt: DateTime<Utc>) -> String {
|
|
||||||
let secs = Utc::now().signed_duration_since(dt).num_seconds().max(0);
|
|
||||||
if secs < 60 {
|
|
||||||
"just now".to_string()
|
|
||||||
} else if secs < 3600 {
|
|
||||||
format!("{}m ago", secs / 60)
|
|
||||||
} else if secs < 86_400 {
|
|
||||||
format!("{}h ago", secs / 3600)
|
|
||||||
} else if secs < 86_400 * 7 {
|
|
||||||
format!("{}d ago", secs / 86_400)
|
|
||||||
} else {
|
|
||||||
let local: DateTime<Local> = dt.into();
|
|
||||||
local.format("%b %d").to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
|
||||||
let card = gtk4::Box::builder()
|
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
|
||||||
.spacing(8)
|
|
||||||
.margin_start(0)
|
|
||||||
.margin_end(0)
|
|
||||||
.margin_top(0)
|
|
||||||
.margin_bottom(0)
|
|
||||||
.css_classes(["note-card"])
|
|
||||||
.build();
|
|
||||||
card.add_css_class(&format!("note-card-{}", note.note_type.as_str()));
|
|
||||||
|
|
||||||
// Top row: body + type chip
|
|
||||||
let top_row = gtk4::Box::builder()
|
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
|
||||||
.spacing(8)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let body_label = gtk4::Label::builder()
|
|
||||||
.label(¬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<Local> = note.created.into();
|
|
||||||
local.format("%b %d %H:%M").to_string()
|
|
||||||
};
|
|
||||||
let meta_label = gtk4::Label::builder()
|
|
||||||
.label(&humanize_relative(note.created))
|
|
||||||
.css_classes(["dim-label"])
|
|
||||||
.xalign(0.0)
|
|
||||||
.tooltip_text(&created_abs)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Date first, then chips
|
|
||||||
bottom_row.append(&meta_label);
|
|
||||||
if let Some(ws) = ¬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<Local> = 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<Vec<Note>> {
|
|
||||||
if let Some(mut n) = store.get_by_id(&id)? {
|
|
||||||
n.mark_done();
|
|
||||||
store.update_note(&n)?;
|
|
||||||
}
|
|
||||||
store.load_all()
|
|
||||||
},
|
|
||||||
move |result| {
|
|
||||||
match result {
|
|
||||||
Ok(fresh) => {
|
|
||||||
*state.notes.borrow_mut() = fresh;
|
|
||||||
rebuild_stack(&state);
|
|
||||||
let active = state.active_view.borrow().clone();
|
|
||||||
state.stack.set_visible_child_name(&active);
|
|
||||||
}
|
|
||||||
Err(e) => state.log_error(format!("mark done failed: {}", e)),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
bottom_row.append(&done_btn);
|
|
||||||
|
|
||||||
// ✎ Edit button
|
|
||||||
let edit_btn = gtk4::Button::builder()
|
|
||||||
.label("✎")
|
|
||||||
.css_classes(["action-btn", "edit-btn"])
|
|
||||||
.tooltip_text("Edit")
|
|
||||||
.build();
|
|
||||||
{
|
|
||||||
let note_c = note.clone();
|
|
||||||
let state_c = state.clone();
|
|
||||||
let body_label_c = body_label.clone();
|
|
||||||
let card_c = card.clone();
|
|
||||||
|
|
||||||
edit_btn.connect_clicked(move |btn| {
|
|
||||||
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
|
|
||||||
let store = Arc::new(state_c.write_store());
|
|
||||||
|
|
||||||
let state_save = state_c.clone();
|
|
||||||
let body_label_save = body_label_c.clone();
|
|
||||||
let state_del = state_c.clone();
|
|
||||||
let card_del = card_c.clone();
|
|
||||||
let state_err = state_c.clone();
|
|
||||||
|
|
||||||
let popover = editor::build_editor_popover(
|
|
||||||
¬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<Vec<Note>> {
|
|
||||||
store.delete_note(&id)?;
|
|
||||||
if let Err(e) = Scheduler::cancel(&id) {
|
|
||||||
tracing::warn!("failed to cancel timer for {}: {}", id, e);
|
|
||||||
}
|
|
||||||
store.load_all()
|
|
||||||
},
|
|
||||||
move |result| {
|
|
||||||
match result {
|
|
||||||
Ok(fresh) => {
|
|
||||||
*state.notes.borrow_mut() = fresh;
|
|
||||||
rebuild_stack(&state);
|
|
||||||
let active = state.active_view.borrow().clone();
|
|
||||||
state.stack.set_visible_child_name(&active);
|
|
||||||
}
|
|
||||||
Err(e) => state.log_error(format!("delete failed: {}", e)),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
*confirming.borrow_mut() = true;
|
|
||||||
btn_c.set_label("Sure?");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
bottom_row.append(&delete_btn);
|
|
||||||
|
|
||||||
card.append(&top_row);
|
|
||||||
card.append(&bottom_row);
|
|
||||||
card
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Add note window ───────────────────────────────────────────────────────────
|
// ── Add note window ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn show_add_note_window(parent: >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()
|
let win = gtk4::Window::builder()
|
||||||
.title("New Note")
|
.title("New Note")
|
||||||
.transient_for(parent)
|
.transient_for(parent)
|
||||||
.modal(true)
|
.modal(true)
|
||||||
.default_width(500)
|
.default_width(500)
|
||||||
.build();
|
.build();
|
||||||
|
bread_theme::gtk::bind_window_auto(&win);
|
||||||
|
|
||||||
let vbox = gtk4::Box::builder()
|
let vbox = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
|
|
@ -901,48 +634,42 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) {
|
||||||
.build();
|
.build();
|
||||||
vbox.append(&body_entry);
|
vbox.append(&body_entry);
|
||||||
|
|
||||||
// Type chips
|
// Type pills — same chip widget the editor dialog and settings screen
|
||||||
|
// use, instead of three different type-picker widgets across the app.
|
||||||
|
vbox.append(>k4::Label::builder().label("Type").xalign(0.0).build());
|
||||||
let chip_box = gtk4::Box::builder()
|
let chip_box = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.build();
|
.build();
|
||||||
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(NoteType::Note));
|
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(preselect.clone()));
|
||||||
let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin()
|
let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&name| {
|
.map(|&name| (bread_theme::gtk::chip(name), NoteType::from_str(name)))
|
||||||
let btn = gtk4::Button::builder()
|
|
||||||
.label(name)
|
|
||||||
.css_classes(["type-chip"])
|
|
||||||
.build();
|
|
||||||
(btn, NoteType::from_str(name))
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
for (btn, nt) in &chips {
|
for (btn, nt) in &chips {
|
||||||
|
bread_theme::gtk::set_chip_active(btn, *nt == preselect);
|
||||||
let sel = selected_type.clone();
|
let sel = selected_type.clone();
|
||||||
let nt_c = nt.clone();
|
let nt_c = nt.clone();
|
||||||
let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
|
let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
|
||||||
btn.connect_clicked(move |clicked| {
|
btn.connect_clicked(move |clicked| {
|
||||||
*sel.borrow_mut() = nt_c.clone();
|
*sel.borrow_mut() = nt_c.clone();
|
||||||
for b in &all_btns { b.remove_css_class("active"); }
|
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
|
||||||
clicked.add_css_class("active");
|
bread_theme::gtk::set_chip_active(clicked, true);
|
||||||
});
|
});
|
||||||
chip_box.append(btn);
|
chip_box.append(btn);
|
||||||
}
|
}
|
||||||
if let Some((btn, _)) = chips.iter().find(|(_, nt)| *nt == NoteType::Note) {
|
|
||||||
btn.add_css_class("active");
|
|
||||||
}
|
|
||||||
vbox.append(&chip_box);
|
vbox.append(&chip_box);
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Time (optional)").xalign(0.0).build());
|
vbox.append(>k4::Label::builder().label("Time (optional)").xalign(0.0).build());
|
||||||
let time_entry = gtk4::Entry::builder()
|
let time_entry = gtk4::Entry::builder()
|
||||||
.placeholder_text("tomorrow 9am / at 7pm / in 30 minutes")
|
.placeholder_text(editor::TIME_PLACEHOLDER)
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.build();
|
.build();
|
||||||
vbox.append(&time_entry);
|
vbox.append(&time_entry);
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Recurrence (optional)").xalign(0.0).build());
|
vbox.append(>k4::Label::builder().label("Recurrence (optional)").xalign(0.0).build());
|
||||||
let rrule_entry = gtk4::Entry::builder()
|
let rrule_entry = gtk4::Entry::builder()
|
||||||
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO")
|
.placeholder_text(editor::RRULE_PLACEHOLDER)
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.build();
|
.build();
|
||||||
vbox.append(&rrule_entry);
|
vbox.append(&rrule_entry);
|
||||||
|
|
@ -1062,6 +789,7 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
on_build(&win);
|
||||||
win.present();
|
win.present();
|
||||||
body_entry.grab_focus();
|
body_entry.grab_focus();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
breadman/src/screenshot.rs
Normal file
132
breadman/src/screenshot.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
//! `--screenshot` CLI mode: render the named view, capture it via
|
||||||
|
//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s
|
||||||
|
//! `bread-capture` orchestrator, or run standalone for one-off captures.
|
||||||
|
//!
|
||||||
|
//! No clap here, same reasoning as breadpad: extends breadman's own
|
||||||
|
//! hand-rolled `mod args` instead of bolting on a second parser that would
|
||||||
|
//! reject its real flags (`--view`, `done`, `upcoming --plain`).
|
||||||
|
//!
|
||||||
|
//! `--screenshot <view>` doubles as the view selector for every named stack
|
||||||
|
//! page ("all", "upcoming", "todo", ...) — it's passed through as
|
||||||
|
//! `initial_view` (the same field `--view` already sets) rather than
|
||||||
|
//! needing a separate mechanism, since breadman already supports opening
|
||||||
|
//! directly to a named stack page.
|
||||||
|
//!
|
||||||
|
//! One view isn't a stack page at all: "editor" opens the per-note editor
|
||||||
|
//! dialog (`editor::open_editor`), normally only reachable by clicking a
|
||||||
|
//! real note row's edit button. Screenshot mode calls the same builder
|
||||||
|
//! function directly against the first real note in the store (bypassing
|
||||||
|
//! the button/click-handler entirely), with no-op save/delete/error
|
||||||
|
//! callbacks since nothing here should actually persist a change.
|
||||||
|
|
||||||
|
use bread_utils::screenshot_cli::SETTLE_DELAY;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use libadwaita::prelude::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Delay before popping the editor popover open — same reasoning as every
|
||||||
|
/// other app's PRE_POPUP_DELAY: the parent window's own layout needs a beat
|
||||||
|
/// to settle first.
|
||||||
|
const PRE_POPUP_DELAY: Duration = SETTLE_DELAY;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ScreenshotRequest {
|
||||||
|
pub view: String,
|
||||||
|
pub output: PathBuf,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire up the given view's screenshot sequence against an already-built,
|
||||||
|
/// not-yet-presented window. Every path here ends by exiting the process —
|
||||||
|
/// it never returns control to the normal note-manager UI.
|
||||||
|
///
|
||||||
|
/// Unlike the other apps' `dispatch`, this doesn't validate `req.view`
|
||||||
|
/// against a known-views list for the stack-page case — an invalid name
|
||||||
|
/// just falls through to breadman's own `unwrap_or("all")` default (see
|
||||||
|
/// `build_app_window`), same as `--view` already behaves for a normal run.
|
||||||
|
pub fn dispatch(
|
||||||
|
window: >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::<gtk4::Widget>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.connect_map(move |_| {
|
||||||
|
let output = output.clone();
|
||||||
|
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
|
||||||
|
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(result: anyhow::Result<()>) {
|
||||||
|
match result {
|
||||||
|
Ok(()) => std::process::exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("breadman: screenshot capture failed: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use super::row::{build_empty_state, RowSpec};
|
||||||
use breadpad_shared::types::Note;
|
use breadpad_shared::types::Note;
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
|
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
|
@ -18,92 +17,28 @@ pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect();
|
let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect();
|
||||||
archived.sort_by(|a, b| b.created.cmp(&a.created));
|
// Sort by completion time, not creation time - the previous sort used
|
||||||
|
// `created` while the row displayed `completed` ("done {date}"), which
|
||||||
|
// is why the last row could appear out of order against the visible
|
||||||
|
// dates.
|
||||||
|
archived.sort_by_key(|n| std::cmp::Reverse(n.completed.unwrap_or(n.created)));
|
||||||
|
|
||||||
if archived.is_empty() {
|
if archived.is_empty() {
|
||||||
list.append(
|
list.append(&build_empty_state("folder-symbolic", "Nothing archived yet.", None));
|
||||||
>k4::Label::builder()
|
|
||||||
.label("Archive is empty.")
|
|
||||||
.margin_top(32)
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
for note in archived {
|
for note in archived {
|
||||||
list.append(&build_archive_card(note, state.clone()));
|
let completed_str = note
|
||||||
|
.completed
|
||||||
|
.map(|t| {
|
||||||
|
let local: chrono::DateTime<chrono::Local> = 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.set_child(Some(&list));
|
||||||
scroll
|
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<chrono::Local> = 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ pub fn build(entries: &[(DateTime<chrono::Local>, String)]) -> gtk4::ScrolledWin
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let time_label = gtk4::Label::builder()
|
let time_label = gtk4::Label::builder()
|
||||||
.label(&ts.format("%H:%M:%S").to_string())
|
.label(ts.format("%H:%M:%S").to_string())
|
||||||
.width_chars(10)
|
.width_chars(10)
|
||||||
.xalign(0.0)
|
.xalign(0.0)
|
||||||
.css_classes(["dim-label"])
|
.css_classes(["dim-label"])
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
|
pub mod row;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod upcoming;
|
pub mod upcoming;
|
||||||
|
|
|
||||||
268
breadman/src/views/row.rs
Normal file
268
breadman/src/views/row.rs
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
//! Shared single-line note row, used by every list view (All/Upcoming/
|
||||||
|
//! per-type/Archive) instead of each view hand-rolling its own card. Design
|
||||||
|
//! review found the two-line card (title/badge top, huge dead gap, actions
|
||||||
|
//! bottom-right) used by the active views wasted enormous horizontal space
|
||||||
|
//! compared to Archive's tighter aligned-column layout - this ports that
|
||||||
|
//! layout everywhere and unifies the row template (including the edit
|
||||||
|
//! affordance, previously pencil-in-active / click-row-in-archive).
|
||||||
|
|
||||||
|
use breadpad_shared::types::{Note, NoteType};
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use libadwaita::prelude::*;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
pub struct RowSpec<'a> {
|
||||||
|
pub date_label: String,
|
||||||
|
pub note: &'a Note,
|
||||||
|
pub show_type_badge: bool,
|
||||||
|
pub show_done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type-tinted badge class matching the `note-card-{type}` accent-bar colors
|
||||||
|
/// already established in breadpad-shared's theme (todo=green,
|
||||||
|
/// reminder=yellow, idea=pink, question=teal, note=blue).
|
||||||
|
fn type_chip_class(note_type: &NoteType) -> &'static str {
|
||||||
|
match note_type {
|
||||||
|
NoteType::Todo => "type-chip-todo",
|
||||||
|
NoteType::Reminder => "type-chip-reminder",
|
||||||
|
NoteType::Idea => "type-chip-idea",
|
||||||
|
NoteType::Note => "type-chip-note",
|
||||||
|
NoteType::Question => "type-chip-question",
|
||||||
|
NoteType::Tag(_) => "type-chip",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(spec: RowSpec, state: crate::AppState) -> gtk4::Box {
|
||||||
|
let note = spec.note;
|
||||||
|
let row = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.spacing(8)
|
||||||
|
.margin_start(8)
|
||||||
|
.margin_end(8)
|
||||||
|
.margin_top(2)
|
||||||
|
.margin_bottom(2)
|
||||||
|
.css_classes(["note-card"])
|
||||||
|
.build();
|
||||||
|
row.add_css_class(&format!("note-card-{}", note.note_type.as_str()));
|
||||||
|
|
||||||
|
let date_label = gtk4::Label::builder()
|
||||||
|
.label(&spec.date_label)
|
||||||
|
.width_chars(16)
|
||||||
|
.xalign(0.0)
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.build();
|
||||||
|
row.append(&date_label);
|
||||||
|
|
||||||
|
let body_label = gtk4::Label::builder()
|
||||||
|
.label(¬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<Vec<Note>> {
|
||||||
|
if let Some(mut n) = store.get_by_id(&id)? {
|
||||||
|
n.mark_done();
|
||||||
|
store.update_note(&n)?;
|
||||||
|
}
|
||||||
|
store.load_all()
|
||||||
|
},
|
||||||
|
move |result| match result {
|
||||||
|
Ok(fresh) => {
|
||||||
|
*state.notes.borrow_mut() = fresh;
|
||||||
|
crate::rebuild_stack(&state);
|
||||||
|
let active = state.active_view.borrow().clone();
|
||||||
|
state.stack.set_visible_child_name(&active);
|
||||||
|
}
|
||||||
|
Err(e) => state.log_error(format!("mark done failed: {}", e)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
row.append(&done_btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let edit_btn = gtk4::Button::builder()
|
||||||
|
.icon_name("document-edit-symbolic")
|
||||||
|
.css_classes(["action-btn", "edit-btn"])
|
||||||
|
.tooltip_text("Edit")
|
||||||
|
.build();
|
||||||
|
{
|
||||||
|
let note_c = note.clone();
|
||||||
|
let state_c = state.clone();
|
||||||
|
let body_label_c = body_label.clone();
|
||||||
|
let row_c = row.clone();
|
||||||
|
|
||||||
|
edit_btn.connect_clicked(move |btn| {
|
||||||
|
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
|
||||||
|
let store = std::sync::Arc::new(state_c.write_store());
|
||||||
|
|
||||||
|
let state_save = state_c.clone();
|
||||||
|
let body_label_save = body_label_c.clone();
|
||||||
|
let state_del = state_c.clone();
|
||||||
|
let row_del = row_c.clone();
|
||||||
|
let state_err = state_c.clone();
|
||||||
|
|
||||||
|
let dialog = crate::editor::open_editor(
|
||||||
|
¬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::<gtk4::Widget>()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
row.append(&edit_btn);
|
||||||
|
|
||||||
|
let delete_btn = gtk4::Button::builder()
|
||||||
|
.icon_name("user-trash-symbolic")
|
||||||
|
.css_classes(["action-btn", "danger-btn"])
|
||||||
|
.tooltip_text("Delete")
|
||||||
|
.build();
|
||||||
|
{
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
let confirming = Rc::new(RefCell::new(false));
|
||||||
|
let note_id = note.id.clone();
|
||||||
|
let row_c = row.clone();
|
||||||
|
let state_c = state.clone();
|
||||||
|
let btn_c = delete_btn.clone();
|
||||||
|
|
||||||
|
delete_btn.connect_clicked(move |_| {
|
||||||
|
if *confirming.borrow() {
|
||||||
|
row_c.set_visible(false); // optimistic hide
|
||||||
|
let store = state_c.write_store();
|
||||||
|
let id = note_id.clone();
|
||||||
|
let state = state_c.clone();
|
||||||
|
crate::spawn_bg(
|
||||||
|
move || -> anyhow::Result<Vec<Note>> {
|
||||||
|
store.delete_note(&id)?;
|
||||||
|
if let Err(e) = breadpad_shared::scheduler::Scheduler::cancel(&id) {
|
||||||
|
tracing::warn!("failed to cancel timer for {}: {}", id, e);
|
||||||
|
}
|
||||||
|
store.load_all()
|
||||||
|
},
|
||||||
|
move |result| match result {
|
||||||
|
Ok(fresh) => {
|
||||||
|
*state.notes.borrow_mut() = fresh;
|
||||||
|
crate::rebuild_stack(&state);
|
||||||
|
let active = state.active_view.borrow().clone();
|
||||||
|
state.stack.set_visible_child_name(&active);
|
||||||
|
}
|
||||||
|
Err(e) => state.log_error(format!("delete failed: {}", e)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
*confirming.borrow_mut() = true;
|
||||||
|
btn_c.set_icon_name("edit-delete-symbolic");
|
||||||
|
btn_c.set_tooltip_text(Some("Click again to delete permanently"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
row.append(&delete_btn);
|
||||||
|
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centered "nothing here" state with type-specific copy and (optionally) a
|
||||||
|
/// direct affordance to act on - the empty states were all top-anchored
|
||||||
|
/// generic text with no icon or action.
|
||||||
|
pub fn build_empty_state(icon_name: &str, text: &str, action: Option<(String, Rc<dyn Fn()>)>) -> gtk4::Widget {
|
||||||
|
let outer = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let icon = gtk4::Image::builder()
|
||||||
|
.icon_name(icon_name)
|
||||||
|
.pixel_size(32)
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.build();
|
||||||
|
outer.append(&icon);
|
||||||
|
|
||||||
|
let label = gtk4::Label::builder()
|
||||||
|
.label(text)
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.justify(gtk4::Justification::Center)
|
||||||
|
.build();
|
||||||
|
outer.append(&label);
|
||||||
|
|
||||||
|
if let Some((label_text, on_click)) = action {
|
||||||
|
let btn = gtk4::Button::builder()
|
||||||
|
.label(&label_text)
|
||||||
|
.css_classes(["confirm-button"])
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
btn.connect_clicked(move |_| on_click());
|
||||||
|
outer.append(&btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
outer.upcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience wrapper for the note-list views: a "+ New {type}" button that
|
||||||
|
/// opens the New Note window preselected to `note_type`.
|
||||||
|
pub fn new_note_action(note_type: NoteType, window: gtk4::ApplicationWindow, state: crate::AppState) -> (String, Rc<dyn Fn()>) {
|
||||||
|
let label = format!("+ New {}", note_type.as_str());
|
||||||
|
let action: Rc<dyn Fn()> = Rc::new(move || {
|
||||||
|
crate::show_add_note_window(&window, state.clone(), note_type.clone(), |_| {});
|
||||||
|
});
|
||||||
|
(label, action)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,80 @@
|
||||||
|
//! Settings screen. Deliberately plain GTK4, not libadwaita's AdwActionRow/
|
||||||
|
//! AdwSpinRow/AdwEntryRow family — those ran noticeably taller than the rest
|
||||||
|
//! of the app and don't expose a way to constrain the internal spin
|
||||||
|
//! button's width from the outside (it stretches to fill whatever room the
|
||||||
|
//! row has, leaving the digits and +/- buttons stranded behind a huge empty
|
||||||
|
//! bordered box once the row is wider than libadwaita's usual ~400-600px
|
||||||
|
//! home turf). Instead this mirrors bos-settings' own Row.svelte /
|
||||||
|
//! NumberField.svelte / TextField.svelte design exactly — same tokens
|
||||||
|
//! (12/16px row padding, ch-width inputs, transparent-at-rest border) — so
|
||||||
|
//! the two settings screens in the ecosystem actually agree with each other.
|
||||||
|
|
||||||
use breadpad_shared::config::{
|
use breadpad_shared::config::{
|
||||||
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
|
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
|
||||||
};
|
};
|
||||||
use gtk4::prelude::*;
|
use breadpad_shared::types::NoteType;
|
||||||
|
use gtk4::{glib, prelude::*};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
/// A titled group: heading, optional description, then a `.boxed-list` of
|
||||||
|
/// `field_row`s (native GTK4 rounded-corner-run + divider styling).
|
||||||
|
fn field_group(title: &str, description: Option<&str>) -> (gtk4::Box, gtk4::ListBox) {
|
||||||
|
let outer = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(8).build();
|
||||||
|
|
||||||
|
let heading = gtk4::Label::builder().label(title).xalign(0.0).css_classes(["heading"]).build();
|
||||||
|
outer.append(&heading);
|
||||||
|
|
||||||
|
if let Some(desc) = description {
|
||||||
|
let desc_label = gtk4::Label::builder()
|
||||||
|
.label(desc)
|
||||||
|
.xalign(0.0)
|
||||||
|
.wrap(true)
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.build();
|
||||||
|
outer.append(&desc_label);
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = gtk4::ListBox::builder()
|
||||||
|
.selection_mode(gtk4::SelectionMode::None)
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
outer.append(&list);
|
||||||
|
|
||||||
|
(outer, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single row: label (+ optional subtitle) on the left, one control on
|
||||||
|
/// the right — same shape as bos-settings' `Row.svelte`.
|
||||||
|
fn field_row(label: &str, subtitle: Option<&str>, control: &impl IsA<gtk4::Widget>) -> gtk4::ListBoxRow {
|
||||||
|
let row = gtk4::ListBoxRow::builder()
|
||||||
|
.selectable(false)
|
||||||
|
.activatable(false)
|
||||||
|
.css_classes(["field-row"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let hbox = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(16).build();
|
||||||
|
|
||||||
|
let label_box = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).hexpand(true).valign(gtk4::Align::Center).build();
|
||||||
|
label_box.append(>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 {
|
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
|
@ -10,255 +83,242 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let outer = gtk4::Box::builder()
|
let content = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(24).build();
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
|
||||||
.spacing(16)
|
|
||||||
.margin_top(16)
|
|
||||||
.margin_bottom(16)
|
|
||||||
.margin_start(16)
|
|
||||||
.margin_end(16)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// ── General ──────────────────────────────────────────────────
|
// ── General ──────────────────────────────────────────────────
|
||||||
let (general_frame, general_grid) = make_section("General");
|
let (general_group, general_list) = field_group("General", None);
|
||||||
|
|
||||||
let type_options = ["note", "todo", "reminder", "idea", "question"];
|
let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
|
||||||
let default_type_combo = gtk4::DropDown::from_strings(&type_options);
|
let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(cfg.settings.default_type.clone()));
|
||||||
let dt_idx = type_options
|
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin()
|
||||||
.iter()
|
.iter()
|
||||||
.position(|&s| s == cfg.settings.default_type.as_str())
|
.map(|&name| (bread_theme::gtk::chip(name), name))
|
||||||
.unwrap_or(0) as u32;
|
.collect();
|
||||||
default_type_combo.set_selected(dt_idx);
|
for (btn, name) in &type_pills {
|
||||||
attach_row(&general_grid, 0, "Default type", &default_type_combo);
|
bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str());
|
||||||
|
type_pill_box.append(btn);
|
||||||
|
}
|
||||||
|
general_list.append(&field_row("Default type", None, &type_pill_box));
|
||||||
|
|
||||||
let ws_tag_switch = gtk4::Switch::builder()
|
let ws_tag_switch = gtk4::Switch::builder().active(cfg.settings.workspace_tag).valign(gtk4::Align::Center).build();
|
||||||
.active(cfg.settings.workspace_tag)
|
general_list.append(&field_row(
|
||||||
.valign(gtk4::Align::Center)
|
"Workspace tag",
|
||||||
.build();
|
Some("Tag new notes with the Hyprland workspace they were created on"),
|
||||||
attach_row(&general_grid, 1, "Workspace tag", &ws_tag_switch);
|
&ws_tag_switch,
|
||||||
|
));
|
||||||
|
|
||||||
let archive_spin = gtk4::SpinButton::with_range(1.0, 365.0, 1.0);
|
let archive_spin = spin_button(cfg.settings.archive_after_days as f64, 1.0, 365.0, 1.0, 7.0, 0);
|
||||||
archive_spin.set_value(cfg.settings.archive_after_days as f64);
|
general_list.append(&field_row("Archive after (days)", None, &archive_spin));
|
||||||
attach_row(&general_grid, 2, "Archive after (days)", &archive_spin);
|
|
||||||
|
|
||||||
let snooze_entry = gtk4::Entry::builder()
|
let snooze_entry = text_entry(&cfg.settings.snooze_options.join(", "), 24);
|
||||||
.text(&cfg.settings.snooze_options.join(", "))
|
general_list.append(&field_row("Snooze options", Some("Comma-separated (e.g. 15m, 1h, tomorrow_morning)"), &snooze_entry));
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
attach_row(&general_grid, 3, "Snooze options", &snooze_entry);
|
|
||||||
|
|
||||||
outer.append(&general_frame);
|
content.append(&general_group);
|
||||||
|
|
||||||
// ── Reminders ────────────────────────────────────────────────
|
// ── Reminders ────────────────────────────────────────────────
|
||||||
let (rem_frame, rem_grid) = make_section("Reminders");
|
let (rem_group, rem_list) = field_group("Reminders", None);
|
||||||
|
|
||||||
let morning_entry = gtk4::Entry::builder()
|
let morning_entry = text_entry(&cfg.reminders.default_morning, 10);
|
||||||
.text(&cfg.reminders.default_morning)
|
rem_list.append(&field_row("Default morning", Some("Used for \"tomorrow_morning\" snoozes and recurring reminders"), &morning_entry));
|
||||||
.placeholder_text("HH:MM")
|
|
||||||
.build();
|
|
||||||
attach_row(&rem_grid, 0, "Default morning", &morning_entry);
|
|
||||||
|
|
||||||
let grace_spin = gtk4::SpinButton::with_range(0.0, 1440.0, 5.0);
|
let grace_spin = spin_button(cfg.reminders.missed_grace_minutes as f64, 0.0, 1440.0, 5.0, 30.0, 0);
|
||||||
grace_spin.set_value(cfg.reminders.missed_grace_minutes as f64);
|
rem_list.append(&field_row("Missed grace (minutes)", Some("How late a reminder can fire before it's considered missed"), &grace_spin));
|
||||||
attach_row(&rem_grid, 1, "Missed grace (minutes)", &grace_spin);
|
|
||||||
|
|
||||||
outer.append(&rem_frame);
|
content.append(&rem_group);
|
||||||
|
|
||||||
// ── Model ─────────────────────────────────────────────────────
|
// ── Local classifier ───────────────────────────────────────────
|
||||||
let (model_frame, model_grid) = make_section("Model (Tier 2 ONNX)");
|
let (model_group, model_list) = field_group(
|
||||||
|
"Local Classifier",
|
||||||
|
Some("Optional local ONNX model for classifying note type/time without a network round-trip. These paths are shared with breadpad — both apps read the same model files."),
|
||||||
|
);
|
||||||
|
|
||||||
let model_path_entry = gtk4::Entry::builder()
|
let model_path_entry = text_entry(&cfg.model.path, 30);
|
||||||
.text(&cfg.model.path)
|
model_list.append(&field_row("Model path", None, &model_path_entry));
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
attach_row(&model_grid, 0, "ONNX path", &model_path_entry);
|
|
||||||
|
|
||||||
let tokenizer_entry = gtk4::Entry::builder()
|
let tokenizer_entry = text_entry(&cfg.model.tokenizer, 30);
|
||||||
.text(&cfg.model.tokenizer)
|
model_list.append(&field_row("Tokenizer path", None, &tokenizer_entry));
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry);
|
|
||||||
|
|
||||||
let ort_dylib_entry = gtk4::Entry::builder()
|
let ort_dylib_entry = text_entry(&cfg.model.ort_dylib_path, 30);
|
||||||
.text(&cfg.model.ort_dylib_path)
|
model_list.append(&field_row("Runtime library path", None, &ort_dylib_entry));
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
attach_row(&model_grid, 2, "ORT dylib path", &ort_dylib_entry);
|
|
||||||
|
|
||||||
outer.append(&model_frame);
|
content.append(&model_group);
|
||||||
|
|
||||||
// ── Ollama (Tier 3) ───────────────────────────────────────────
|
// ── AI classification (Ollama) ──────────────────────────────────
|
||||||
let (ollama_frame, ollama_grid) = make_section("Ollama (Tier 3)");
|
let (ollama_group, ollama_list) = field_group(
|
||||||
|
"AI Classification",
|
||||||
|
Some("Uses a local Ollama model as a fallback classifier when the ONNX model is unavailable or unsure."),
|
||||||
|
);
|
||||||
|
|
||||||
let ollama_enabled = gtk4::Switch::builder()
|
let ollama_enabled_switch = gtk4::Switch::builder().active(cfg.model.ollama.enabled).valign(gtk4::Align::Center).build();
|
||||||
.active(cfg.model.ollama.enabled)
|
ollama_list.append(&field_row("Enabled", None, &ollama_enabled_switch));
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
attach_row(&ollama_grid, 0, "Enabled", &ollama_enabled);
|
|
||||||
|
|
||||||
let ollama_endpoint = gtk4::Entry::builder()
|
let ollama_endpoint_entry = text_entry(&cfg.model.ollama.endpoint, 24);
|
||||||
.text(&cfg.model.ollama.endpoint)
|
ollama_list.append(&field_row("Endpoint", None, &ollama_endpoint_entry));
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
attach_row(&ollama_grid, 1, "Endpoint", &ollama_endpoint);
|
|
||||||
|
|
||||||
let ollama_model = gtk4::Entry::builder()
|
let ollama_model_entry = text_entry(&cfg.model.ollama.model, 16);
|
||||||
.text(&cfg.model.ollama.model)
|
ollama_list.append(&field_row("Model", None, &ollama_model_entry));
|
||||||
.build();
|
|
||||||
attach_row(&ollama_grid, 2, "Model", &ollama_model);
|
|
||||||
|
|
||||||
let ollama_thresh = gtk4::SpinButton::with_range(0.0, 1.0, 0.05);
|
let ollama_thresh_spin = spin_button(cfg.model.ollama.confidence_threshold as f64, 0.0, 1.0, 0.05, 0.1, 2);
|
||||||
ollama_thresh.set_value(cfg.model.ollama.confidence_threshold as f64);
|
ollama_list.append(&field_row("Confidence threshold", None, &ollama_thresh_spin));
|
||||||
ollama_thresh.set_digits(2);
|
|
||||||
attach_row(&ollama_grid, 3, "Confidence threshold", &ollama_thresh);
|
|
||||||
|
|
||||||
outer.append(&ollama_frame);
|
content.append(&ollama_group);
|
||||||
|
|
||||||
// ── Calendar ─────────────────────────────────────────────────
|
// ── Calendar sync ────────────────────────────────────────────
|
||||||
let (cal_frame, cal_grid) = make_section("Nextcloud Calendar (CalDAV)");
|
let (cal_group, cal_list) = field_group("Calendar Sync", Some("Sync reminders to a Nextcloud calendar via CalDAV."));
|
||||||
|
|
||||||
let cal_enabled = gtk4::Switch::builder()
|
let cal_enabled_switch = gtk4::Switch::builder().active(cfg.calendar.enabled).valign(gtk4::Align::Center).build();
|
||||||
.active(cfg.calendar.enabled)
|
cal_list.append(&field_row("Enabled", None, &cal_enabled_switch));
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
attach_row(&cal_grid, 0, "Enabled", &cal_enabled);
|
|
||||||
|
|
||||||
let cal_url = gtk4::Entry::builder()
|
let cal_url_entry = text_entry(&cfg.calendar.url, 30);
|
||||||
.text(&cfg.calendar.url)
|
cal_list.append(&field_row("Calendar URL", None, &cal_url_entry));
|
||||||
.placeholder_text("https://nextcloud.example.com/remote.php/dav/calendars/you/personal/")
|
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
attach_row(&cal_grid, 1, "Calendar URL", &cal_url);
|
|
||||||
|
|
||||||
let cal_user = gtk4::Entry::builder()
|
let cal_user_entry = text_entry(&cfg.calendar.username, 16);
|
||||||
.text(&cfg.calendar.username)
|
cal_list.append(&field_row("Username", None, &cal_user_entry));
|
||||||
.build();
|
|
||||||
attach_row(&cal_grid, 2, "Username", &cal_user);
|
|
||||||
|
|
||||||
let cal_pass = gtk4::Entry::builder()
|
let cal_pass_entry = gtk4::PasswordEntry::builder().text(&cfg.calendar.password).show_peek_icon(true).valign(gtk4::Align::Center).css_classes(["field-input"]).build();
|
||||||
.text(&cfg.calendar.password)
|
cal_list.append(&field_row("App password", None, &cal_pass_entry));
|
||||||
.input_purpose(gtk4::InputPurpose::Password)
|
|
||||||
.visibility(false)
|
|
||||||
.build();
|
|
||||||
attach_row(&cal_grid, 3, "App password", &cal_pass);
|
|
||||||
|
|
||||||
outer.append(&cal_frame);
|
content.append(&cal_group);
|
||||||
|
|
||||||
// ── Save ──────────────────────────────────────────────────────
|
// ── Status (instant-apply — no Save button) ─────────────────
|
||||||
let status_label = gtk4::Label::builder()
|
let status_label = gtk4::Label::builder().label("").xalign(0.0).css_classes(["dim-label"]).margin_top(4).build();
|
||||||
.label("")
|
content.append(&status_label);
|
||||||
.xalign(0.0)
|
|
||||||
.css_classes(["dim-label"])
|
|
||||||
.build();
|
|
||||||
let save_btn = gtk4::Button::builder()
|
|
||||||
.label("Save Settings")
|
|
||||||
.css_classes(["confirm-button"])
|
|
||||||
.halign(gtk4::Align::End)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
{
|
// Reads every widget's current value and persists immediately. Every
|
||||||
let dtc = default_type_combo.clone();
|
// control below calls this on its own "committed a change" signal
|
||||||
let wts = ws_tag_switch.clone();
|
// (switch/spin fire on change; entries fire on Enter or focus-out).
|
||||||
let ars = archive_spin.clone();
|
let apply_now: Rc<dyn Fn()> = Rc::new({
|
||||||
let sne = snooze_entry.clone();
|
let selected_type = selected_type.clone();
|
||||||
let moe = morning_entry.clone();
|
let ws_tag_switch = ws_tag_switch.clone();
|
||||||
let grs = grace_spin.clone();
|
let archive_spin = archive_spin.clone();
|
||||||
let mpe = model_path_entry.clone();
|
let snooze_entry = snooze_entry.clone();
|
||||||
let tke = tokenizer_entry.clone();
|
let morning_entry = morning_entry.clone();
|
||||||
let ode = ort_dylib_entry.clone();
|
let grace_spin = grace_spin.clone();
|
||||||
let oec = ollama_enabled.clone();
|
let model_path_entry = model_path_entry.clone();
|
||||||
let oee = ollama_endpoint.clone();
|
let tokenizer_entry = tokenizer_entry.clone();
|
||||||
let ome = ollama_model.clone();
|
let ort_dylib_entry = ort_dylib_entry.clone();
|
||||||
let ots = ollama_thresh.clone();
|
let ollama_enabled_switch = ollama_enabled_switch.clone();
|
||||||
let cec = cal_enabled.clone();
|
let ollama_endpoint_entry = ollama_endpoint_entry.clone();
|
||||||
let cuc = cal_url.clone();
|
let ollama_model_entry = ollama_model_entry.clone();
|
||||||
let csc = cal_user.clone();
|
let ollama_thresh_spin = ollama_thresh_spin.clone();
|
||||||
let cpc = cal_pass.clone();
|
let cal_enabled_switch = cal_enabled_switch.clone();
|
||||||
let sl = status_label.clone();
|
let cal_url_entry = cal_url_entry.clone();
|
||||||
|
let cal_user_entry = cal_user_entry.clone();
|
||||||
|
let cal_pass_entry = cal_pass_entry.clone();
|
||||||
|
let status_label = status_label.clone();
|
||||||
|
|
||||||
save_btn.connect_clicked(move |_| {
|
move || {
|
||||||
let new_cfg = Config {
|
let new_cfg = Config {
|
||||||
settings: Settings {
|
settings: Settings {
|
||||||
default_type: type_options
|
default_type: selected_type.borrow().clone(),
|
||||||
.get(dtc.selected() as usize)
|
workspace_tag: ws_tag_switch.is_active(),
|
||||||
.copied()
|
snooze_options: snooze_entry
|
||||||
.unwrap_or("note")
|
|
||||||
.to_string(),
|
|
||||||
workspace_tag: wts.is_active(),
|
|
||||||
snooze_options: sne
|
|
||||||
.text()
|
.text()
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect(),
|
.collect(),
|
||||||
archive_after_days: ars.value() as i64,
|
archive_after_days: archive_spin.value() as i64,
|
||||||
},
|
},
|
||||||
reminders: RemindersConfig {
|
reminders: RemindersConfig {
|
||||||
default_morning: moe.text().to_string(),
|
default_morning: morning_entry.text().to_string(),
|
||||||
missed_grace_minutes: grs.value() as i64,
|
missed_grace_minutes: grace_spin.value() as i64,
|
||||||
},
|
},
|
||||||
model: ModelConfig {
|
model: ModelConfig {
|
||||||
path: mpe.text().to_string(),
|
path: model_path_entry.text().to_string(),
|
||||||
tokenizer: tke.text().to_string(),
|
tokenizer: tokenizer_entry.text().to_string(),
|
||||||
ort_dylib_path: ode.text().to_string(),
|
ort_dylib_path: ort_dylib_entry.text().to_string(),
|
||||||
ollama: OllamaConfig {
|
ollama: OllamaConfig {
|
||||||
enabled: oec.is_active(),
|
enabled: ollama_enabled_switch.is_active(),
|
||||||
endpoint: oee.text().to_string(),
|
endpoint: ollama_endpoint_entry.text().to_string(),
|
||||||
model: ome.text().to_string(),
|
model: ollama_model_entry.text().to_string(),
|
||||||
confidence_threshold: ots.value() as f32,
|
confidence_threshold: ollama_thresh_spin.value() as f32,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
calendar: CalendarConfig {
|
calendar: CalendarConfig {
|
||||||
enabled: cec.is_active(),
|
enabled: cal_enabled_switch.is_active(),
|
||||||
url: cuc.text().to_string(),
|
url: cal_url_entry.text().to_string(),
|
||||||
username: csc.text().to_string(),
|
username: cal_user_entry.text().to_string(),
|
||||||
password: cpc.text().to_string(),
|
password: cal_pass_entry.text().to_string(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
match new_cfg.save() {
|
match new_cfg.save() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
sl.set_label("Settings saved.");
|
status_label.set_label("Saved.");
|
||||||
on_save(new_cfg);
|
on_save(new_cfg);
|
||||||
}
|
}
|
||||||
Err(e) => sl.set_label(&format!("Save failed: {}", e)),
|
Err(e) => status_label.set_label(&format!("Save failed: {}", e)),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type pills, switches, spinners apply the moment they change.
|
||||||
|
for (btn, name) in &type_pills {
|
||||||
|
let apply_now = apply_now.clone();
|
||||||
|
let sel = selected_type.clone();
|
||||||
|
let name = *name;
|
||||||
|
let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect();
|
||||||
|
btn.connect_clicked(move |clicked| {
|
||||||
|
*sel.borrow_mut() = name.to_string();
|
||||||
|
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
|
||||||
|
bread_theme::gtk::set_chip_active(clicked, true);
|
||||||
|
apply_now();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
macro_rules! apply_on_active {
|
||||||
|
($sw:expr) => {
|
||||||
|
let apply_now = apply_now.clone();
|
||||||
|
$sw.connect_state_set(move |_, _| { apply_now(); glib::Propagation::Proceed });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
apply_on_active!(ws_tag_switch);
|
||||||
|
apply_on_active!(ollama_enabled_switch);
|
||||||
|
apply_on_active!(cal_enabled_switch);
|
||||||
|
macro_rules! apply_on_value_changed {
|
||||||
|
($spin:expr) => {
|
||||||
|
let apply_now = apply_now.clone();
|
||||||
|
$spin.connect_value_changed(move |_| apply_now());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
apply_on_value_changed!(archive_spin);
|
||||||
|
apply_on_value_changed!(grace_spin);
|
||||||
|
apply_on_value_changed!(ollama_thresh_spin);
|
||||||
|
|
||||||
let btn_row = gtk4::Box::builder()
|
// Entries: apply on Enter, and on focus-out so a click-away doesn't
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
// silently discard the edit.
|
||||||
.spacing(8)
|
macro_rules! apply_on_entry {
|
||||||
|
($entry:expr) => {
|
||||||
|
let apply_now_activate = apply_now.clone();
|
||||||
|
$entry.connect_activate(move |_| apply_now_activate());
|
||||||
|
let apply_now_focus = apply_now.clone();
|
||||||
|
let focus = gtk4::EventControllerFocus::new();
|
||||||
|
focus.connect_leave(move |_| apply_now_focus());
|
||||||
|
$entry.add_controller(focus);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
apply_on_entry!(snooze_entry);
|
||||||
|
apply_on_entry!(morning_entry);
|
||||||
|
apply_on_entry!(model_path_entry);
|
||||||
|
apply_on_entry!(tokenizer_entry);
|
||||||
|
apply_on_entry!(ort_dylib_entry);
|
||||||
|
apply_on_entry!(ollama_endpoint_entry);
|
||||||
|
apply_on_entry!(ollama_model_entry);
|
||||||
|
apply_on_entry!(cal_url_entry);
|
||||||
|
apply_on_entry!(cal_user_entry);
|
||||||
|
apply_on_entry!(cal_pass_entry);
|
||||||
|
|
||||||
|
content.set_halign(gtk4::Align::Start);
|
||||||
|
content.set_size_request(900, -1);
|
||||||
|
|
||||||
|
let outer = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(16)
|
||||||
.build();
|
.build();
|
||||||
btn_row.append(&status_label);
|
outer.append(&content);
|
||||||
btn_row.append(>k4::Box::builder().hexpand(true).build());
|
|
||||||
btn_row.append(&save_btn);
|
|
||||||
outer.append(&btn_row);
|
|
||||||
|
|
||||||
scroll.set_child(Some(&outer));
|
scroll.set_child(Some(&outer));
|
||||||
scroll
|
scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_section(title: &str) -> (gtk4::Frame, gtk4::Grid) {
|
|
||||||
let frame = gtk4::Frame::builder().label(title).build();
|
|
||||||
let grid = gtk4::Grid::builder()
|
|
||||||
.row_spacing(8)
|
|
||||||
.column_spacing(16)
|
|
||||||
.margin_top(8)
|
|
||||||
.margin_bottom(8)
|
|
||||||
.margin_start(8)
|
|
||||||
.margin_end(8)
|
|
||||||
.build();
|
|
||||||
frame.set_child(Some(&grid));
|
|
||||||
(frame, grid)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attach_row(grid: >k4::Grid, row: i32, label: &str, widget: &impl gtk4::prelude::IsA<gtk4::Widget>) {
|
|
||||||
let lbl = gtk4::Label::builder()
|
|
||||||
.label(label)
|
|
||||||
.xalign(0.0)
|
|
||||||
.hexpand(false)
|
|
||||||
.width_chars(24)
|
|
||||||
.build();
|
|
||||||
grid.attach(&lbl, 0, row, 1, 1);
|
|
||||||
grid.attach(widget, 1, row, 1, 1);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
use super::row::{build_empty_state, RowSpec};
|
||||||
use breadpad_shared::types::{Note, NoteType};
|
use breadpad_shared::types::{Note, NoteType};
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
|
|
||||||
pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow {
|
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||||
|
|
@ -26,61 +27,21 @@ pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow {
|
||||||
upcoming.sort_by_key(|n| n.effective_time().unwrap());
|
upcoming.sort_by_key(|n| n.effective_time().unwrap());
|
||||||
|
|
||||||
if upcoming.is_empty() {
|
if upcoming.is_empty() {
|
||||||
let label = gtk4::Label::builder()
|
list.append(&build_empty_state("x-office-calendar-symbolic", "No upcoming reminders or todos.", None));
|
||||||
.label("No upcoming reminders or todos.")
|
|
||||||
.margin_top(32)
|
|
||||||
.build();
|
|
||||||
list.append(&label);
|
|
||||||
} else {
|
} else {
|
||||||
for note in upcoming {
|
for note in upcoming {
|
||||||
let card = build_upcoming_card(note);
|
let time_str = note
|
||||||
list.append(&card);
|
.effective_time()
|
||||||
|
.map(|t| {
|
||||||
|
let local: chrono::DateTime<chrono::Local> = 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.set_child(Some(&list));
|
||||||
scroll
|
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<chrono::Local> = 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ authors.workspace = true
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
|
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
|
||||||
|
gtk4.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
@ -19,7 +20,7 @@ tokio.workspace = true
|
||||||
zbus.workspace = true
|
zbus.workspace = true
|
||||||
ort.workspace = true
|
ort.workspace = true
|
||||||
tokenizers.workspace = true
|
tokenizers.workspace = true
|
||||||
ndarray.workspace = true
|
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ impl OllamaClient {
|
||||||
let classification: OllamaClassification = extract_json(&ollama_resp.response)
|
let classification: OllamaClassification = extract_json(&ollama_resp.response)
|
||||||
.ok_or_else(|| anyhow::anyhow!(
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
"no JSON object found in response — raw: {:?}",
|
"no JSON object found in response — raw: {:?}",
|
||||||
&ollama_resp.response
|
ollama_resp.response
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let note_type = classification
|
let note_type = classification
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ use crate::ai::OllamaClient;
|
||||||
use crate::config::OllamaConfig;
|
use crate::config::OllamaConfig;
|
||||||
use crate::parser::parse_rule_based;
|
use crate::parser::parse_rule_based;
|
||||||
use crate::types::{ClassificationResult, NoteType};
|
use crate::types::{ClassificationResult, NoteType};
|
||||||
|
use bread_onnx::{build_session, Provider};
|
||||||
|
use ort::session::builder::GraphOptimizationLevel;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Minimum Tier 1 confidence needed to skip Tier 2 entirely.
|
/// Minimum Tier 1 confidence needed to skip Tier 2 entirely.
|
||||||
|
|
@ -16,7 +18,7 @@ pub enum ExecutionProvider {
|
||||||
impl ExecutionProvider {
|
impl ExecutionProvider {
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
ExecutionProvider::Gpu => "ROCm (iGPU)",
|
ExecutionProvider::Gpu => "MIGraphX (iGPU)",
|
||||||
ExecutionProvider::Cpu => "CPU",
|
ExecutionProvider::Cpu => "CPU",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,9 +109,7 @@ impl Classifier {
|
||||||
|
|
||||||
// ── Tier 2 ───────────────────────────────────────────────────────────
|
// ── Tier 2 ───────────────────────────────────────────────────────────
|
||||||
// ONNX model classifies the type only; Tier 1's time/rrule/body are kept.
|
// ONNX model classifies the type only; Tier 1's time/rrule/body are kept.
|
||||||
let tier2 = if let (Some(session), Some(tokenizer)) =
|
let tier2 = if let (Some(session), Some(tokenizer)) = (&mut self.session, &self.tokenizer) {
|
||||||
(&mut self.session, &self.tokenizer)
|
|
||||||
{
|
|
||||||
match run_onnx(session, tokenizer, text) {
|
match run_onnx(session, tokenizer, text) {
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
tracing::debug!("Tier 2: {:?} conf={:.2}", r.note_type, r.confidence);
|
tracing::debug!("Tier 2: {:?} conf={:.2}", r.note_type, r.confidence);
|
||||||
|
|
@ -163,9 +163,18 @@ impl Classifier {
|
||||||
// entailment score across all five passes.
|
// entailment score across all five passes.
|
||||||
const HYPOTHESES: [(&str, &str); 5] = [
|
const HYPOTHESES: [(&str, &str); 5] = [
|
||||||
("This note is a task or action item to complete.", "todo"),
|
("This note is a task or action item to complete.", "todo"),
|
||||||
("This note is a reminder with a specific time or deadline.", "reminder"),
|
(
|
||||||
("This note is an idea, suggestion, or creative thought.", "idea"),
|
"This note is a reminder with a specific time or deadline.",
|
||||||
("This note is a general observation or piece of information.", "note"),
|
"reminder",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"This note is an idea, suggestion, or creative thought.",
|
||||||
|
"idea",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"This note is a general observation or piece of information.",
|
||||||
|
"note",
|
||||||
|
),
|
||||||
("This note is a question that needs an answer.", "question"),
|
("This note is a question that needs an answer.", "question"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -184,15 +193,17 @@ fn run_onnx(
|
||||||
.map_err(|e| anyhow::anyhow!("tokenize: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("tokenize: {}", e))?;
|
||||||
|
|
||||||
let ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
|
let ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
|
||||||
let mask: Vec<i64> = encoding.get_attention_mask().iter().map(|&x| x as i64).collect();
|
let mask: Vec<i64> = encoding
|
||||||
|
.get_attention_mask()
|
||||||
|
.iter()
|
||||||
|
.map(|&x| x as i64)
|
||||||
|
.collect();
|
||||||
let len = ids.len();
|
let len = ids.len();
|
||||||
|
|
||||||
let ids_tensor = ort::value::Tensor::<i64>::from_array(
|
let ids_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], ids))
|
||||||
(vec![1i64, len as i64], ids)
|
.map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
|
||||||
).map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
|
let mask_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], mask))
|
||||||
let mask_tensor = ort::value::Tensor::<i64>::from_array(
|
.map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
|
||||||
(vec![1i64, len as i64], mask)
|
|
||||||
).map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
|
|
||||||
|
|
||||||
let inputs = ort::inputs![
|
let inputs = ort::inputs![
|
||||||
"input_ids" => ids_tensor,
|
"input_ids" => ids_tensor,
|
||||||
|
|
@ -207,10 +218,7 @@ fn run_onnx(
|
||||||
.map_err(|e| anyhow::anyhow!("extract logits: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("extract logits: {}", e))?;
|
||||||
let (_, logits_slice) = logits;
|
let (_, logits_slice) = logits;
|
||||||
|
|
||||||
entailment_scores[i] = logits_slice
|
entailment_scores[i] = logits_slice.get(ENTAILMENT_IDX).copied().unwrap_or(0.0);
|
||||||
.get(ENTAILMENT_IDX)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let best_idx = entailment_scores
|
let best_idx = entailment_scores
|
||||||
|
|
@ -243,42 +251,30 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
|
||||||
exps[idx] / sum
|
exps[idx] / sum
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_load_session(
|
fn try_load_session(path: &std::path::Path) -> (Option<ort::session::Session>, ExecutionProvider) {
|
||||||
path: &std::path::Path,
|
// WHY: distro onnxruntime-rocm is MIGraphX, not classic ROCm; bread-onnx
|
||||||
) -> (Option<ort::session::Session>, ExecutionProvider) {
|
// appends CPU so a missing GPU EP does not disable Tier 2.
|
||||||
// Try ROCm (iGPU) first, fall back to CPU.
|
match build_session(
|
||||||
let rocm_available = {
|
path,
|
||||||
use ort::execution_providers::ExecutionProvider as _;
|
GraphOptimizationLevel::Level3,
|
||||||
ort::ep::ROCm::default().is_available().unwrap_or(false)
|
&[Provider::MiGraphX { device_id: 0 }],
|
||||||
};
|
) {
|
||||||
if rocm_available {
|
|
||||||
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
|
|
||||||
Ok(s) => {
|
|
||||||
tracing::info!("ONNX session loaded (ROCm iGPU)");
|
|
||||||
return (Some(s), ExecutionProvider::Gpu);
|
|
||||||
}
|
|
||||||
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match build_onnx_session(path, ort::ep::CPU::default().build()) {
|
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
tracing::info!("ONNX session loaded (CPU)");
|
tracing::info!("ONNX session loaded (MIGraphX, CPU fallback)");
|
||||||
(Some(s), ExecutionProvider::Cpu)
|
(Some(s), ExecutionProvider::Gpu)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e);
|
tracing::debug!("MIGraphX session failed: {}; trying CPU", e);
|
||||||
(None, ExecutionProvider::Cpu)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_onnx_session(
|
|
||||||
path: &std::path::Path,
|
|
||||||
ep: ort::ep::ExecutionProviderDispatch,
|
|
||||||
) -> anyhow::Result<ort::session::Session> {
|
|
||||||
let mut builder = ort::session::Session::builder()
|
|
||||||
.map_err(|e| anyhow::anyhow!("builder: {}", e))?
|
|
||||||
.with_execution_providers([ep])
|
|
||||||
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
|
|
||||||
builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ impl Default for RemindersConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct CalendarConfig {
|
pub struct CalendarConfig {
|
||||||
#[serde(default = "default_calendar_enabled")]
|
#[serde(default = "default_calendar_enabled")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|
@ -150,17 +150,6 @@ pub struct CalendarConfig {
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CalendarConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
CalendarConfig {
|
|
||||||
enabled: false,
|
|
||||||
url: String::new(),
|
|
||||||
username: String::new(),
|
|
||||||
password: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
|
||||||
|
|
@ -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<NoteType> {
|
|
||||||
let lower = text.trim_start().to_lowercase();
|
|
||||||
TYPE_PREFIXES
|
|
||||||
.iter()
|
|
||||||
.find(|(prefix, _)| lower.starts_with(prefix))
|
|
||||||
.map(|(_, nt)| nt.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strips a leading explicit type prefix (if any), returning the forced type
|
|
||||||
/// and the remaining text with the prefix and any following whitespace removed.
|
|
||||||
fn strip_explicit_prefix(text: &str) -> (Option<NoteType>, String) {
|
|
||||||
let trimmed = text.trim_start();
|
|
||||||
let lower = trimmed.to_lowercase();
|
|
||||||
for (prefix, nt) in TYPE_PREFIXES {
|
|
||||||
if lower.starts_with(prefix) {
|
|
||||||
let rest = trimmed[prefix.len()..].trim_start().to_string();
|
|
||||||
return (Some(nt.clone()), rest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, text.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
||||||
let local = Local::now();
|
let local = Local::now();
|
||||||
let days_ahead = (wd.num_days_from_monday() as i64
|
let days_ahead = (wd.num_days_from_monday() as i64
|
||||||
|
|
@ -142,8 +105,6 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
|
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 p = patterns();
|
||||||
let morning_time: NaiveTime = default_morning
|
let morning_time: NaiveTime = default_morning
|
||||||
.split(':')
|
.split(':')
|
||||||
|
|
@ -329,9 +290,8 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
||||||
.to_string();
|
.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infer note type — an explicit prefix (`td:`, `rem:`, …) always wins.
|
// Infer note type
|
||||||
let note_type =
|
let note_type = infer_type(text, extracted_time.is_some(), rrule.is_some());
|
||||||
forced_type.clone().unwrap_or_else(|| infer_type(text, extracted_time.is_some(), rrule.is_some()));
|
|
||||||
|
|
||||||
// Trim artifacts
|
// Trim artifacts
|
||||||
cleaned = cleaned
|
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,
|
// Calibrated confidence: high when structural signals drove the decision,
|
||||||
// low when we fell back to "note" with no positive evidence.
|
// low when we fell back to "note" with no positive evidence.
|
||||||
let confidence = if forced_type.is_some() {
|
let confidence = if rrule.is_some() || extracted_time.is_some() {
|
||||||
0.99 // explicit prefix — unambiguous
|
|
||||||
} else if rrule.is_some() || extracted_time.is_some() {
|
|
||||||
0.95 // time/recurrence extraction is deterministic
|
0.95 // time/recurrence extraction is deterministic
|
||||||
} else {
|
} else {
|
||||||
match ¬e_type {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -452,70 +471,6 @@ mod tests {
|
||||||
assert_eq!(p("idea: reactive state module in Lua").note_type, NoteType::Idea);
|
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]
|
#[test]
|
||||||
fn idea_maybe() {
|
fn idea_maybe() {
|
||||||
assert_eq!(p("maybe we could cache the ONNX model").note_type, NoteType::Idea);
|
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 r = p("take a break in 30 minutes");
|
||||||
let t = r.time.unwrap();
|
let t = r.time.unwrap();
|
||||||
let delta = (t - before).num_seconds();
|
let delta = (t - before).num_seconds();
|
||||||
assert!(delta >= 29 * 60 && delta <= 31 * 60, "delta was {}s", delta);
|
assert!((29 * 60..=31 * 60).contains(&delta), "delta was {}s", delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -604,7 +559,7 @@ mod tests {
|
||||||
let before = Utc::now();
|
let before = Utc::now();
|
||||||
let r = p("ping in 1 minute");
|
let r = p("ping in 1 minute");
|
||||||
let delta = (r.time.unwrap() - before).num_seconds();
|
let delta = (r.time.unwrap() - before).num_seconds();
|
||||||
assert!(delta >= 55 && delta <= 65, "delta was {}s", delta);
|
assert!((55..=65).contains(&delta), "delta was {}s", delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -612,7 +567,7 @@ mod tests {
|
||||||
let before = Utc::now();
|
let before = Utc::now();
|
||||||
let r = p("review PR in 2 hours");
|
let r = p("review PR in 2 hours");
|
||||||
let delta_min = (r.time.unwrap() - before).num_minutes();
|
let delta_min = (r.time.unwrap() - before).num_minutes();
|
||||||
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min);
|
assert!((119..=121).contains(&delta_min), "delta was {}min", delta_min);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -620,7 +575,7 @@ mod tests {
|
||||||
let before = Utc::now();
|
let before = Utc::now();
|
||||||
let r = p("follow up in 3 days");
|
let r = p("follow up in 3 days");
|
||||||
let delta_h = (r.time.unwrap() - before).num_hours();
|
let delta_h = (r.time.unwrap() - before).num_hours();
|
||||||
assert!(delta_h >= 71 && delta_h <= 73, "delta was {}h", delta_h);
|
assert!((71..=73).contains(&delta_h), "delta was {}h", delta_h);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Time extraction: tomorrow ----
|
// ---- Time extraction: tomorrow ----
|
||||||
|
|
@ -823,7 +778,7 @@ mod tests {
|
||||||
let before = Utc::now();
|
let before = Utc::now();
|
||||||
let r = p("check on the server in an hour");
|
let r = p("check on the server in an hour");
|
||||||
let delta_min = (r.time.unwrap() - before).num_minutes();
|
let delta_min = (r.time.unwrap() - before).num_minutes();
|
||||||
assert!(delta_min >= 59 && delta_min <= 61, "delta was {}min", delta_min);
|
assert!((59..=61).contains(&delta_min), "delta was {}min", delta_min);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -843,7 +798,7 @@ mod tests {
|
||||||
let before = Utc::now();
|
let before = Utc::now();
|
||||||
let r = p("in a couple of hours remind me to check the oven");
|
let r = p("in a couple of hours remind me to check the oven");
|
||||||
let delta_min = (r.time.unwrap() - before).num_minutes();
|
let delta_min = (r.time.unwrap() - before).num_minutes();
|
||||||
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min);
|
assert!((119..=121).contains(&delta_min), "delta was {}min", delta_min);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -864,7 +819,7 @@ mod tests {
|
||||||
let before = Utc::now();
|
let before = Utc::now();
|
||||||
let r = p("in half an hour submit the report");
|
let r = p("in half an hour submit the report");
|
||||||
let delta_min = (r.time.unwrap() - before).num_minutes();
|
let delta_min = (r.time.unwrap() - before).num_minutes();
|
||||||
assert!(delta_min >= 29 && delta_min <= 31, "delta was {}min", delta_min);
|
assert!((29..=31).contains(&delta_min), "delta was {}min", delta_min);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Tonight / this evening ----
|
// ---- Tonight / this evening ----
|
||||||
|
|
@ -953,63 +908,3 @@ mod tests {
|
||||||
assert!(rule.as_str().contains("BYHOUR=16"), "rule: {}", rule.as_str());
|
assert!(rule.as_str().contains("BYHOUR=16"), "rule: {}", rule.as_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|
|
||||||
let lower = text.to_lowercase();
|
|
||||||
if has_rrule || has_time {
|
|
||||||
return NoteType::Reminder;
|
|
||||||
}
|
|
||||||
if lower.contains("buy ")
|
|
||||||
|| lower.contains("pick up")
|
|
||||||
|| lower.contains("clean ")
|
|
||||||
|| lower.starts_with("call ")
|
|
||||||
|| lower.starts_with("email ")
|
|
||||||
|| lower.starts_with("fix ")
|
|
||||||
|| lower.starts_with("check ")
|
|
||||||
|| lower.starts_with("finish ")
|
|
||||||
|| lower.starts_with("write ")
|
|
||||||
|| lower.starts_with("update ")
|
|
||||||
|| lower.starts_with("prepare ")
|
|
||||||
|| lower.starts_with("schedule ")
|
|
||||||
|| lower.starts_with("organize ")
|
|
||||||
|| lower.starts_with("deploy ")
|
|
||||||
|| lower.starts_with("install ")
|
|
||||||
|| lower.starts_with("send ")
|
|
||||||
|| lower.starts_with("submit ")
|
|
||||||
|| lower.starts_with("create ")
|
|
||||||
|| lower.starts_with("setup ")
|
|
||||||
|| lower.starts_with("restore ")
|
|
||||||
|| lower.starts_with("archive ")
|
|
||||||
|| lower.starts_with("export ")
|
|
||||||
|| lower.starts_with("import ")
|
|
||||||
|| lower.starts_with("approve ")
|
|
||||||
|| lower.starts_with("configure ")
|
|
||||||
|| lower.starts_with("refactor ")
|
|
||||||
|| lower.starts_with("review ")
|
|
||||||
{
|
|
||||||
return NoteType::Todo;
|
|
||||||
}
|
|
||||||
if lower.starts_with("what if ")
|
|
||||||
|| lower.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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
|
||||||
} else {
|
} else {
|
||||||
(now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
|
(now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
|
||||||
};
|
};
|
||||||
return Some(local_naive_to_utc(naive));
|
Some(local_naive_to_utc(naive))
|
||||||
}
|
}
|
||||||
"WEEKLY" => {
|
"WEEKLY" => {
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
|
|
@ -204,7 +204,7 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
|
||||||
};
|
};
|
||||||
let target_date =
|
let target_date =
|
||||||
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
|
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
|
||||||
return Some(local_naive_to_utc(target_date));
|
Some(local_naive_to_utc(target_date))
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,11 +118,11 @@ impl Store {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rewrite_notes<F>(&self, mut f: F) -> Result<()>
|
fn rewrite_notes<F>(&self, f: F) -> Result<()>
|
||||||
where
|
where
|
||||||
F: FnMut(Note) -> Note,
|
F: FnMut(Note) -> Note,
|
||||||
{
|
{
|
||||||
let notes: Vec<Note> = self.load_all()?.into_iter().map(|n| f(n)).collect();
|
let notes: Vec<Note> = self.load_all()?.into_iter().map(f).collect();
|
||||||
self.write_all(&self.notes_path, ¬es)
|
self.write_all(&self.notes_path, ¬es)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +145,7 @@ impl Store {
|
||||||
let notes = self.load_all()?;
|
let notes = self.load_all()?;
|
||||||
let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes
|
let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.partition(|n| n.done && n.completed.map_or(false, |c| c < cutoff));
|
.partition(|n| n.done && n.completed.is_some_and(|c| c < cutoff));
|
||||||
|
|
||||||
if to_archive.is_empty() {
|
if to_archive.is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ pub fn apply_live() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bind a window to the palette of the monitor it is rendered on.
|
||||||
|
pub fn bind_window(window: &impl gtk4::prelude::IsA<gtk4::Native>) {
|
||||||
|
bread_theme::gtk::bind_window_auto(window);
|
||||||
|
}
|
||||||
|
|
||||||
/// Generate the full breadpad/breadman CSS string. The base — `@define-color`
|
/// Generate the full breadpad/breadman CSS string. The base — `@define-color`
|
||||||
/// palette, fonts, and generic widget styling — comes from the shared
|
/// palette, fonts, and generic widget styling — comes from the shared
|
||||||
/// `bread_theme::stylesheet`, so breadpad and breadman look identical to the
|
/// `bread_theme::stylesheet`, so breadpad and breadman look identical to the
|
||||||
|
|
@ -28,12 +33,33 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
|
||||||
/* breadpad/breadman-specific components */
|
/* breadpad/breadman-specific components */
|
||||||
window { border-radius: 8px; }
|
window { border-radius: 8px; }
|
||||||
|
|
||||||
|
/* breadman/views/settings.rs — matches bos-settings' Row.svelte/
|
||||||
|
NumberField.svelte/TextField.svelte exactly (same design tokens: 12/16px
|
||||||
|
row padding, transparent-at-rest input border, ch-width inputs) rather
|
||||||
|
than libadwaita's own AdwActionRow/AdwSpinRow padding and internal
|
||||||
|
spin-button sizing, which run noticeably taller/wider and don't expose a
|
||||||
|
way to constrain from the outside. `list.boxed-list` already gets its
|
||||||
|
surface fill + radius from the shared stylesheet; this only adds the
|
||||||
|
compact row padding and the divider between rows. */
|
||||||
|
list.boxed-list row.field-row { padding: 12px 16px; min-height: 0; }
|
||||||
|
list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(@on-surface, 0.08); }
|
||||||
|
.field-row-subtitle { opacity: 0.6; font-size: 12px; }
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
background-color: @bg;
|
||||||
|
color: @on-surface;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.field-input:focus-within { border-color: @accent; outline: none; }
|
||||||
|
|
||||||
.popup-entry {
|
.popup-entry {
|
||||||
background: @bg;
|
background: @bg;
|
||||||
color: @fg;
|
color: @fg;
|
||||||
border: 2px solid @blue;
|
border: 2px solid @blue;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px 16px;
|
padding: 14px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
caret-color: @fg;
|
caret-color: @fg;
|
||||||
}
|
}
|
||||||
|
|
@ -43,14 +69,9 @@ window { border-radius: 8px; }
|
||||||
border-color: @teal;
|
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 {
|
.type-chip {
|
||||||
background: transparent;
|
background: @overlay;
|
||||||
color: alpha(@fg, 0.6);
|
color: @on-overlay;
|
||||||
border: 1px solid alpha(@fg, 0.18);
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
@ -60,69 +81,36 @@ window { border-radius: 8px; }
|
||||||
.type-chip.active {
|
.type-chip.active {
|
||||||
background: @blue;
|
background: @blue;
|
||||||
color: @on-accent;
|
color: @on-accent;
|
||||||
border-color: @blue;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Per-type tint so info badges (note cards, workspace/recur tags) stay
|
/* Per-type tint, matching the note-card-{type} accent-bar colors below -
|
||||||
scannable at a glance without the old full-color emoji. */
|
the flat cream badge was the same high-contrast fill for every type,
|
||||||
.note-card-todo .type-chip { color: @green; border-color: alpha(@green, 0.4); }
|
out-shouting note body text while telling you nothing extra. */
|
||||||
.note-card-reminder .type-chip { color: @yellow; border-color: alpha(@yellow, 0.4); }
|
.type-chip-todo { background: alpha(@green, 0.18); color: @green; }
|
||||||
.note-card-idea .type-chip { color: @pink; border-color: alpha(@pink, 0.4); }
|
.type-chip-reminder { background: alpha(@yellow, 0.18); color: @yellow; }
|
||||||
.note-card-question .type-chip { color: @teal; border-color: alpha(@teal, 0.4); }
|
.type-chip-idea { background: alpha(@pink, 0.18); color: @pink; }
|
||||||
.note-card-note .type-chip { color: @blue; border-color: alpha(@blue, 0.4); }
|
.type-chip-question { background: alpha(@teal, 0.18); color: @teal; }
|
||||||
|
.type-chip-note { background: alpha(@blue, 0.18); color: @blue; }
|
||||||
|
|
||||||
.confirm-button {
|
.confirm-button {
|
||||||
background: @blue;
|
background: @blue;
|
||||||
color: @on-accent;
|
color: @on-accent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 22px;
|
padding: 8px 16px;
|
||||||
min-height: 20px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: bold;
|
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 {
|
.note-card {
|
||||||
background: shade(@bg, 1.12);
|
background: shade(@bg, 1.1);
|
||||||
border: 1px solid alpha(@fg, 0.07);
|
border-radius: 8px;
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin: 6px 0;
|
margin: 8px;
|
||||||
border-left: 3px solid @blue;
|
border-left: 3px solid @blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-card:hover {
|
.note-card:hover {
|
||||||
background: shade(@bg, 1.22);
|
background: shade(@bg, 1.2);
|
||||||
border-color: alpha(@fg, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-title {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-card .action-btn {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-card:hover .action-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-entry {
|
.search-entry {
|
||||||
|
|
@ -130,8 +118,7 @@ window { border-radius: 8px; }
|
||||||
color: @fg;
|
color: @fg;
|
||||||
border: 1px solid @overlay;
|
border: 1px solid @overlay;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 5px 10px;
|
padding: 8px 12px;
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-entry:focus {
|
.search-entry:focus {
|
||||||
|
|
@ -152,27 +139,7 @@ window { border-radius: 8px; }
|
||||||
.sidebar-row:selected {
|
.sidebar-row:selected {
|
||||||
background: @blue;
|
background: @blue;
|
||||||
color: @on-accent;
|
color: @on-accent;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-row-minor {
|
|
||||||
opacity: 0.5;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-count {
|
|
||||||
color: alpha(@fg, 0.45);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-todo { color: @green; }
|
|
||||||
.icon-reminder { color: @yellow; }
|
|
||||||
.icon-idea { color: @pink; }
|
|
||||||
.icon-note { color: @blue; }
|
|
||||||
.icon-question { color: @teal; }
|
|
||||||
|
|
||||||
.sidebar-row:selected .sidebar-count {
|
|
||||||
color: alpha(@on-accent, 0.75);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section-label {
|
.sidebar-section-label {
|
||||||
|
|
@ -187,15 +154,14 @@ window { border-radius: 8px; }
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 3px 8px;
|
padding: 2px 7px;
|
||||||
min-width: 32px;
|
min-width: 28px;
|
||||||
min-height: 32px;
|
min-height: 28px;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.action-btn:hover {
|
||||||
background: shade(@bg, 1.3);
|
background: shade(@bg, 1.3);
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.done-btn { color: @green; }
|
.done-btn { color: @green; }
|
||||||
|
|
@ -204,8 +170,12 @@ window { border-radius: 8px; }
|
||||||
.edit-btn { color: @blue; }
|
.edit-btn { color: @blue; }
|
||||||
.edit-btn:hover { background: alpha(@blue, 0.15); }
|
.edit-btn:hover { background: alpha(@blue, 0.15); }
|
||||||
|
|
||||||
.danger-btn { color: @red; }
|
/* Fixed red, not @red - pywal can hand `red` any hue depending on the
|
||||||
.danger-btn:hover { background: alpha(@red, 0.15); }
|
wallpaper (see bread-theme's button.destructive-action for the same
|
||||||
|
reasoning), which would make delete indistinguishable from a normal
|
||||||
|
accent action. */
|
||||||
|
.danger-btn { color: #e01b24; }
|
||||||
|
.danger-btn:hover { background: alpha(#e01b24, 0.15); }
|
||||||
|
|
||||||
.note-card-todo { border-left-color: @green; }
|
.note-card-todo { border-left-color: @green; }
|
||||||
.note-card-reminder { border-left-color: @yellow; }
|
.note-card-reminder { border-left-color: @yellow; }
|
||||||
|
|
@ -239,12 +209,15 @@ window { border-radius: 8px; }
|
||||||
color: @fg;
|
color: @fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dismiss and Snooze are both secondary/outline actions and should read at
|
||||||
|
equal weight - Dismiss used to sit at 0.6 alpha next to Snooze's full
|
||||||
|
opacity, which made the button that closes the reminder look disabled. */
|
||||||
.reminder-dismiss {
|
.reminder-dismiss {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid @overlay;
|
border: 1px solid @overlay;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
color: alpha(@fg, 0.6);
|
color: @fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
|
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
|
||||||
|
|
@ -259,15 +232,40 @@ window { border-radius: 8px; }
|
||||||
|
|
||||||
.reminder-snooze:hover { background: shade(@bg, 1.1); }
|
.reminder-snooze:hover { background: shade(@bg, 1.1); }
|
||||||
|
|
||||||
|
/* Left-aligned (the button's child label sets xalign itself), full-width
|
||||||
|
row with a hairline divider so the list reads as distinct clickable rows
|
||||||
|
even before hover - a hover tint alone doesn't show up in a static
|
||||||
|
reading of the popover's default state. */
|
||||||
.snooze-option {
|
.snooze-option {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 12px;
|
padding: 10px 12px;
|
||||||
color: @fg;
|
color: @fg;
|
||||||
|
border-bottom: 1px solid alpha(@overlay, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.snooze-option:hover { background: shade(@bg, 1.2); }
|
.snooze-option:hover { background: shade(@bg, 1.2); }
|
||||||
|
|
||||||
|
.snooze-custom-entry {
|
||||||
|
background: @bg;
|
||||||
|
color: @fg;
|
||||||
|
border: 1px solid @overlay;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-custom-entry:focus-within { border-color: @blue; outline: none; }
|
||||||
|
|
||||||
|
/* Matches the reminder alert card's flat-bordered elevation (1px border,
|
||||||
|
8px radius, no shadow) instead of GTK's default arrow+drop-shadow popover
|
||||||
|
chrome - the two surfaces used to speak two different elevation
|
||||||
|
languages. */
|
||||||
|
popover.snooze-popover > contents {
|
||||||
|
border: 1px solid @overlay;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
"#);
|
"#);
|
||||||
|
|
||||||
if let Some(extra) = user_css {
|
if let Some(extra) = user_css {
|
||||||
|
|
@ -285,7 +283,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn css_defines_bg_color() {
|
fn css_defines_bg_color() {
|
||||||
let css = build_css(&Palette::default(), None);
|
let css = build_css(&Palette::default(), None);
|
||||||
assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]);
|
assert!(css.contains("@define-color bg #0c0c0c"), "css missing bg: {}", &css[..300]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -330,9 +328,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn css_reflects_custom_palette_colors() {
|
fn css_reflects_custom_palette_colors() {
|
||||||
let mut p = Palette::default();
|
let p = Palette {
|
||||||
p.background = "#deadbe".into();
|
background: "#deadbe".into(),
|
||||||
p.color4 = "#cafe00".into();
|
color4: "#cafe00".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let css = build_css(&p, None);
|
let css = build_css(&p, None);
|
||||||
assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]);
|
assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]);
|
||||||
assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]);
|
assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ pub enum NoteType {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NoteType {
|
impl NoteType {
|
||||||
|
// Not std::str::FromStr — infallible, returns Self directly rather than
|
||||||
|
// Result, and used across 30+ call sites as NoteType::from_str(..).
|
||||||
|
#[allow(clippy::should_implement_trait)]
|
||||||
pub fn from_str(s: &str) -> Self {
|
pub fn from_str(s: &str) -> Self {
|
||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"todo" => NoteType::Todo,
|
"todo" => NoteType::Todo,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
use breadpad_shared::classifier::{Classifier, ExecutionProvider};
|
use breadpad_shared::classifier::{Classifier, ExecutionProvider};
|
||||||
use breadpad_shared::types::NoteType;
|
use breadpad_shared::types::NoteType;
|
||||||
use chrono::Timelike;
|
use chrono::Timelike;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Rule-based path only — a present `~/.local/share/breadpad/model` must not
|
||||||
|
/// change these assertions.
|
||||||
fn cl() -> Classifier {
|
fn cl() -> Classifier {
|
||||||
Classifier::load("08:00")
|
Classifier::load_with_paths(
|
||||||
|
"08:00",
|
||||||
|
PathBuf::from("/nonexistent/classifier.onnx"),
|
||||||
|
PathBuf::from("/nonexistent/tokenizer.json"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn active_provider_is_valid() {
|
fn active_provider_is_valid() {
|
||||||
// The active provider depends on the host: a machine with the ONNX model present and
|
// The active provider depends on the host: a machine with the ONNX model present and
|
||||||
// a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
|
// a working MIGraphX iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
|
||||||
// model is available we must be on CPU (no session => no GPU EP in use).
|
// model is available we must be on CPU (no session => no GPU EP in use).
|
||||||
let c = cl();
|
let c = Classifier::load("08:00");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
c.active_provider,
|
c.active_provider,
|
||||||
ExecutionProvider::Cpu | ExecutionProvider::Gpu
|
ExecutionProvider::Cpu | ExecutionProvider::Gpu
|
||||||
|
|
@ -49,19 +56,28 @@ fn classify_reminder_via_fallback() {
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_idea_via_fallback() {
|
fn classify_idea_via_fallback() {
|
||||||
let mut c = cl();
|
let mut c = cl();
|
||||||
assert_eq!(c.classify("what if we added a calendar view").note_type, NoteType::Idea);
|
assert_eq!(
|
||||||
|
c.classify("what if we added a calendar view").note_type,
|
||||||
|
NoteType::Idea
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_question_via_fallback() {
|
fn classify_question_via_fallback() {
|
||||||
let mut c = cl();
|
let mut c = cl();
|
||||||
assert_eq!(c.classify("why does this fail?").note_type, NoteType::Question);
|
assert_eq!(
|
||||||
|
c.classify("why does this fail?").note_type,
|
||||||
|
NoteType::Question
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_note_via_fallback() {
|
fn classify_note_via_fallback() {
|
||||||
let mut c = cl();
|
let mut c = cl();
|
||||||
assert_eq!(c.classify("meeting went well today").note_type, NoteType::Note);
|
assert_eq!(
|
||||||
|
c.classify("meeting went well today").note_type,
|
||||||
|
NoteType::Note
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -74,7 +90,11 @@ fn classify_recurrence_via_fallback() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_custom_morning_time() {
|
fn classify_custom_morning_time() {
|
||||||
let mut c = Classifier::load("07:15");
|
let mut c = Classifier::load_with_paths(
|
||||||
|
"07:15",
|
||||||
|
PathBuf::from("/nonexistent/classifier.onnx"),
|
||||||
|
PathBuf::from("/nonexistent/tokenizer.json"),
|
||||||
|
);
|
||||||
let r = c.classify("sync tomorrow morning");
|
let r = c.classify("sync tomorrow morning");
|
||||||
let t = r.time.expect("should have a time for tomorrow morning");
|
let t = r.time.expect("should have a time for tomorrow morning");
|
||||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||||
|
|
@ -114,12 +134,16 @@ fn classify_returns_cleaned_body() {
|
||||||
let mut c = cl();
|
let mut c = cl();
|
||||||
let r = c.classify("call mum at 6pm");
|
let r = c.classify("call mum at 6pm");
|
||||||
assert!(r.body.contains("call mum"), "body: {}", r.body);
|
assert!(r.body.contains("call mum"), "body: {}", r.body);
|
||||||
assert!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body);
|
assert!(
|
||||||
|
!r.body.contains("6pm"),
|
||||||
|
"time phrase should be stripped from body: {}",
|
||||||
|
r.body
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_path_points_to_expected_location() {
|
fn model_path_points_to_expected_location() {
|
||||||
let c = cl();
|
let c = Classifier::load("08:00");
|
||||||
assert!(
|
assert!(
|
||||||
c.model_path.to_str().unwrap().contains("breadpad"),
|
c.model_path.to_str().unwrap().contains("breadpad"),
|
||||||
"model path: {:?}",
|
"model path: {:?}",
|
||||||
|
|
|
||||||
|
|
@ -279,15 +279,19 @@ fn resolved_ort_dylib_empty_returns_none() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolved_ort_dylib_whitespace_only_returns_none() {
|
fn resolved_ort_dylib_whitespace_only_returns_none() {
|
||||||
let mut m = ModelConfig::default();
|
let m = ModelConfig {
|
||||||
m.ort_dylib_path = " ".into();
|
ort_dylib_path: " ".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(m.resolved_ort_dylib_path().is_none());
|
assert!(m.resolved_ort_dylib_path().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolved_ort_dylib_set_returns_some() {
|
fn resolved_ort_dylib_set_returns_some() {
|
||||||
let mut m = ModelConfig::default();
|
let m = ModelConfig {
|
||||||
m.ort_dylib_path = "/usr/lib/libonnxruntime.so".into();
|
ort_dylib_path: "/usr/lib/libonnxruntime.so".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
m.resolved_ort_dylib_path().unwrap().to_str().unwrap(),
|
m.resolved_ort_dylib_path().unwrap().to_str().unwrap(),
|
||||||
"/usr/lib/libonnxruntime.so"
|
"/usr/lib/libonnxruntime.so"
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,18 @@ use breadpad_shared::classifier::Classifier;
|
||||||
use breadpad_shared::store::Store;
|
use breadpad_shared::store::Store;
|
||||||
use breadpad_shared::types::{Note, NoteType};
|
use breadpad_shared::types::{Note, NoteType};
|
||||||
use chrono::Timelike;
|
use chrono::Timelike;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
// Mirrors commit_note() in breadpad/src/main.rs.
|
// Mirrors commit_note() in breadpad/src/main.rs.
|
||||||
// `user_type` is the type the user selected in the chip row (default = NoteType::Note).
|
// `user_type` is the type the user selected in the chip row (default = NoteType::Note).
|
||||||
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
|
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
|
||||||
let mut classifier = Classifier::load("08:00");
|
// WHY: pipeline tests cover classify→save→reload, not a host ONNX model.
|
||||||
|
let mut classifier = Classifier::load_with_paths(
|
||||||
|
"08:00",
|
||||||
|
PathBuf::from("/nonexistent/classifier.onnx"),
|
||||||
|
PathBuf::from("/nonexistent/tokenizer.json"),
|
||||||
|
);
|
||||||
let result = classifier.classify(text);
|
let result = classifier.classify(text);
|
||||||
|
|
||||||
let mut note = Note::new(text.into(), user_type.clone(), None);
|
let mut note = Note::new(text.into(), user_type.clone(), None);
|
||||||
|
|
@ -61,7 +67,11 @@ fn todo_note_appears_in_store() {
|
||||||
#[test]
|
#[test]
|
||||||
fn idea_note_appears_in_store() {
|
fn idea_note_appears_in_store() {
|
||||||
let (dir, store) = setup();
|
let (dir, store) = setup();
|
||||||
capture(&store, "what if we added dark mode", NoteType::from_str("note"));
|
capture(
|
||||||
|
&store,
|
||||||
|
"what if we added dark mode",
|
||||||
|
NoteType::from_str("note"),
|
||||||
|
);
|
||||||
|
|
||||||
let notes = breadman_store(&dir).load_all().unwrap();
|
let notes = breadman_store(&dir).load_all().unwrap();
|
||||||
assert_eq!(notes.len(), 1);
|
assert_eq!(notes.len(), 1);
|
||||||
|
|
@ -71,7 +81,11 @@ fn idea_note_appears_in_store() {
|
||||||
#[test]
|
#[test]
|
||||||
fn question_note_appears_in_store() {
|
fn question_note_appears_in_store() {
|
||||||
let (dir, store) = setup();
|
let (dir, store) = setup();
|
||||||
capture(&store, "why does the cache miss on cold start?", NoteType::from_str("note"));
|
capture(
|
||||||
|
&store,
|
||||||
|
"why does the cache miss on cold start?",
|
||||||
|
NoteType::from_str("note"),
|
||||||
|
);
|
||||||
|
|
||||||
let notes = breadman_store(&dir).load_all().unwrap();
|
let notes = breadman_store(&dir).load_all().unwrap();
|
||||||
assert_eq!(notes.len(), 1);
|
assert_eq!(notes.len(), 1);
|
||||||
|
|
@ -97,7 +111,10 @@ fn reminder_has_time_set() {
|
||||||
|
|
||||||
let notes = breadman_store(&dir).load_all().unwrap();
|
let notes = breadman_store(&dir).load_all().unwrap();
|
||||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||||
assert!(notes[0].time.is_some(), "reminder should have a scheduled time");
|
assert!(
|
||||||
|
notes[0].time.is_some(),
|
||||||
|
"reminder should have a scheduled time"
|
||||||
|
);
|
||||||
let local: chrono::DateTime<chrono::Local> = notes[0].time.unwrap().into();
|
let local: chrono::DateTime<chrono::Local> = notes[0].time.unwrap().into();
|
||||||
assert_eq!(local.hour(), 18);
|
assert_eq!(local.hour(), 18);
|
||||||
}
|
}
|
||||||
|
|
@ -108,14 +125,21 @@ fn reminder_body_has_time_stripped() {
|
||||||
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
|
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
|
||||||
|
|
||||||
let notes = breadman_store(&dir).load_all().unwrap();
|
let notes = breadman_store(&dir).load_all().unwrap();
|
||||||
assert!(!notes[0].body.contains("6pm"), "time phrase should be removed from body");
|
assert!(
|
||||||
|
!notes[0].body.contains("6pm"),
|
||||||
|
"time phrase should be removed from body"
|
||||||
|
);
|
||||||
assert!(notes[0].body.contains("call mum"));
|
assert!(notes[0].body.contains("call mum"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn in_duration_reminder_has_time() {
|
fn in_duration_reminder_has_time() {
|
||||||
let (dir, store) = setup();
|
let (dir, store) = setup();
|
||||||
capture(&store, "check on the build in 30 minutes", NoteType::from_str("note"));
|
capture(
|
||||||
|
&store,
|
||||||
|
"check on the build in 30 minutes",
|
||||||
|
NoteType::from_str("note"),
|
||||||
|
);
|
||||||
|
|
||||||
let notes = breadman_store(&dir).load_all().unwrap();
|
let notes = breadman_store(&dir).load_all().unwrap();
|
||||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||||
|
|
@ -127,7 +151,11 @@ fn in_duration_reminder_has_time() {
|
||||||
#[test]
|
#[test]
|
||||||
fn recurring_reminder_has_rrule() {
|
fn recurring_reminder_has_rrule() {
|
||||||
let (dir, store) = setup();
|
let (dir, store) = setup();
|
||||||
capture(&store, "standup every monday at 9am", NoteType::from_str("note"));
|
capture(
|
||||||
|
&store,
|
||||||
|
"standup every monday at 9am",
|
||||||
|
NoteType::from_str("note"),
|
||||||
|
);
|
||||||
|
|
||||||
let notes = breadman_store(&dir).load_all().unwrap();
|
let notes = breadman_store(&dir).load_all().unwrap();
|
||||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||||
|
|
@ -139,11 +167,20 @@ fn recurring_reminder_has_rrule() {
|
||||||
#[test]
|
#[test]
|
||||||
fn daily_reminder_has_rrule() {
|
fn daily_reminder_has_rrule() {
|
||||||
let (dir, store) = setup();
|
let (dir, store) = setup();
|
||||||
capture(&store, "drink water every day at 8am", NoteType::from_str("note"));
|
capture(
|
||||||
|
&store,
|
||||||
|
"drink water every day at 8am",
|
||||||
|
NoteType::from_str("note"),
|
||||||
|
);
|
||||||
|
|
||||||
let notes = breadman_store(&dir).load_all().unwrap();
|
let notes = breadman_store(&dir).load_all().unwrap();
|
||||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||||
assert!(notes[0].rrule.as_ref().unwrap().as_str().contains("FREQ=DAILY"));
|
assert!(notes[0]
|
||||||
|
.rrule
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.contains("FREQ=DAILY"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- user-forced type is respected ----
|
// ---- user-forced type is respected ----
|
||||||
|
|
@ -155,7 +192,11 @@ fn user_selected_type_overrides_classifier() {
|
||||||
capture(&store, "fix the login bug", NoteType::Idea);
|
capture(&store, "fix the login bug", NoteType::Idea);
|
||||||
|
|
||||||
let notes = breadman_store(&dir).load_all().unwrap();
|
let notes = breadman_store(&dir).load_all().unwrap();
|
||||||
assert_eq!(notes[0].note_type, NoteType::Idea, "user chip selection should win over classifier");
|
assert_eq!(
|
||||||
|
notes[0].note_type,
|
||||||
|
NoteType::Idea,
|
||||||
|
"user chip selection should win over classifier"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -173,7 +214,11 @@ fn user_selected_reminder_overrides_classifier() {
|
||||||
fn three_notes_all_visible_to_breadman() {
|
fn three_notes_all_visible_to_breadman() {
|
||||||
let (dir, store) = setup();
|
let (dir, store) = setup();
|
||||||
capture(&store, "buy milk", NoteType::from_str("note"));
|
capture(&store, "buy milk", NoteType::from_str("note"));
|
||||||
capture(&store, "what if we rewrote in Zig", NoteType::from_str("note"));
|
capture(
|
||||||
|
&store,
|
||||||
|
"what if we rewrote in Zig",
|
||||||
|
NoteType::from_str("note"),
|
||||||
|
);
|
||||||
capture(&store, "team standup went well", NoteType::from_str("note"));
|
capture(&store, "team standup went well", NoteType::from_str("note"));
|
||||||
|
|
||||||
let notes = breadman_store(&dir).load_all().unwrap();
|
let notes = breadman_store(&dir).load_all().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -389,7 +389,7 @@ fn cmd_show(index: usize, corpus_path: &Path, tier: &TierArg) -> Result<()> {
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let sep = "─".repeat(62);
|
let sep = "─".repeat(62);
|
||||||
println!("{:<14} {:<26} {}", "field", "expected", "actual");
|
println!("{:<14} {:<26} actual", "field", "expected");
|
||||||
println!("{sep}");
|
println!("{sep}");
|
||||||
println!(
|
println!(
|
||||||
"{:<14} {:<26} {}",
|
"{:<14} {:<26} {}",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
breadpad-shared = { path = "../breadpad-shared" }
|
breadpad-shared = { path = "../breadpad-shared" }
|
||||||
|
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
||||||
|
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
|
# Bread event bus: emit after a real capture/reminder fire. Fail-silent if
|
||||||
|
# breadd is down. See EVENTS.md.
|
||||||
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
ort.workspace = true
|
ort.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
|
||||||
83
breadpad/src/listen.rs
Normal file
83
breadpad/src/listen.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
//! Long-running command subscription for `bread.command.pad.*`.
|
||||||
|
//!
|
||||||
|
//! `breadpad` is still a one-shot capture popup by default. `breadpad listen`
|
||||||
|
//! is the optional persistent process that can honor bus commands. See
|
||||||
|
//! `EVENTS.md`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
||||||
|
|
||||||
|
/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`.
|
||||||
|
const APP_ID: &str = "pad";
|
||||||
|
|
||||||
|
/// Subscribe to `bread.command.pad.**` and block until the process is killed.
|
||||||
|
///
|
||||||
|
/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects
|
||||||
|
/// with backoff, and `on_event` simply isn't called until the daemon is up.
|
||||||
|
pub fn run() -> Result<()> {
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
if client.health().is_none() {
|
||||||
|
tracing::warn!("breadd unreachable; command subscription will connect when it comes back");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _commands = client.subscribe("bread.command.pad.**", |event| {
|
||||||
|
handle_command(&event);
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!("listening for bread.command.pad.**");
|
||||||
|
loop {
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to `bread.command.pad.*` verbs. Only `capture` is honored today —
|
||||||
|
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
||||||
|
fn handle_command(event: &BreadEvent) {
|
||||||
|
let Some(verb) = command_verb(&event.event) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match verb {
|
||||||
|
"capture" => handle_capture(),
|
||||||
|
other => {
|
||||||
|
tracing::debug!("ignoring unrecognized command verb '{other}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_capture() {
|
||||||
|
// Same as running `breadpad` with no args: open the capture popup.
|
||||||
|
let result = spawn_self();
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
match result {
|
||||||
|
Ok(_) => client.emit("bread.pad.capture.done", serde_json::json!({})),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("bread.command.pad.capture failed: {e}");
|
||||||
|
client.emit(
|
||||||
|
"bread.pad.capture.failed",
|
||||||
|
serde_json::json!({ "error": e.to_string() }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_self() -> std::io::Result<std::process::Child> {
|
||||||
|
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadpad"));
|
||||||
|
std::process::Command::new(exe).spawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_verb(event_name: &str) -> Option<&str> {
|
||||||
|
event_name.strip_prefix("bread.command.pad.")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_verb_strips_pad_prefix() {
|
||||||
|
assert_eq!(command_verb("bread.command.pad.capture"), Some("capture"));
|
||||||
|
assert_eq!(command_verb("bread.command.pad.snooze"), Some("snooze"));
|
||||||
|
assert_eq!(command_verb("bread.command.box.open"), None);
|
||||||
|
assert_eq!(command_verb("bread.pad.captured"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,16 +3,36 @@ use breadpad_shared::{
|
||||||
calendar::CalDavClient,
|
calendar::CalDavClient,
|
||||||
classifier::Classifier,
|
classifier::Classifier,
|
||||||
config::Config,
|
config::Config,
|
||||||
|
parser::parse_rule_based,
|
||||||
scheduler::Scheduler,
|
scheduler::Scheduler,
|
||||||
store::Store,
|
store::Store,
|
||||||
types::{Note, NoteType},
|
types::{Note, NoteType},
|
||||||
};
|
};
|
||||||
|
use bread_utils::bread_client::BreadClient;
|
||||||
use gtk4::{glib, prelude::*};
|
use gtk4::{glib, prelude::*};
|
||||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::{Arc, Once};
|
use std::sync::{Arc, Once};
|
||||||
|
|
||||||
|
mod listen;
|
||||||
|
mod screenshot;
|
||||||
|
|
||||||
|
/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. Events are `bread.pad.*`.
|
||||||
|
const APP_ID: &str = "pad";
|
||||||
|
|
||||||
|
/// `bread.pad.captured` after a successful quick-capture save. Fire-and-forget:
|
||||||
|
/// `BreadClient::emit` is a silent no-op when breadd is down or missing.
|
||||||
|
fn emit_captured(id: &str) {
|
||||||
|
BreadClient::connect(APP_ID).emit("bread.pad.captured", serde_json::json!({ "id": id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `bread.pad.reminder.due` when `breadpad fire <id>` actually shows a reminder
|
||||||
|
/// (the existing systemd-timer hook — not a new daemon). Same fail-silent emit.
|
||||||
|
fn emit_reminder_due(id: &str) {
|
||||||
|
BreadClient::connect(APP_ID).emit("bread.pad.reminder.due", serde_json::json!({ "id": id }));
|
||||||
|
}
|
||||||
|
|
||||||
static ORT_INIT: Once = Once::new();
|
static ORT_INIT: Once = Once::new();
|
||||||
|
|
||||||
fn init_ort_once(cfg: &Config) {
|
fn init_ort_once(cfg: &Config) {
|
||||||
|
|
@ -31,6 +51,9 @@ fn init_ort_once(cfg: &Config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
mod args {
|
mod args {
|
||||||
|
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
pub note_type: Option<String>,
|
pub note_type: Option<String>,
|
||||||
|
|
@ -41,6 +64,32 @@ mod args {
|
||||||
pub model_info: bool,
|
pub model_info: bool,
|
||||||
pub calendar_test: bool,
|
pub calendar_test: bool,
|
||||||
pub calendar_list_uid: Option<String>,
|
pub calendar_list_uid: Option<String>,
|
||||||
|
pub screenshot: Option<String>,
|
||||||
|
pub output: Option<String>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub listen: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
/// `None` for a normal run. Exits the process with an error if the
|
||||||
|
/// `--screenshot` / `--output` pair is incomplete, before any GTK
|
||||||
|
/// setup happens.
|
||||||
|
pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> {
|
||||||
|
if let Err(e) = validate_pair(
|
||||||
|
self.screenshot.as_deref(),
|
||||||
|
self.output.as_deref().map(Path::new),
|
||||||
|
) {
|
||||||
|
eprintln!("breadpad: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Some(crate::screenshot::ScreenshotRequest {
|
||||||
|
view: self.screenshot.clone()?,
|
||||||
|
output: self.output.clone()?.into(),
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse() -> Args {
|
pub fn parse() -> Args {
|
||||||
|
|
@ -53,6 +102,11 @@ mod args {
|
||||||
model_info: false,
|
model_info: false,
|
||||||
calendar_test: false,
|
calendar_test: false,
|
||||||
calendar_list_uid: None,
|
calendar_list_uid: None,
|
||||||
|
screenshot: None,
|
||||||
|
output: None,
|
||||||
|
width: DEFAULT_WIDTH,
|
||||||
|
height: DEFAULT_HEIGHT,
|
||||||
|
listen: false,
|
||||||
};
|
};
|
||||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
@ -66,6 +120,7 @@ mod args {
|
||||||
"--status" => args.status = true,
|
"--status" => args.status = true,
|
||||||
"download-model" => args.download_model = true,
|
"download-model" => args.download_model = true,
|
||||||
"model-info" => args.model_info = true,
|
"model-info" => args.model_info = true,
|
||||||
|
"listen" => args.listen = true,
|
||||||
"fire" => {
|
"fire" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
args.fire_id = raw.get(i).cloned();
|
args.fire_id = raw.get(i).cloned();
|
||||||
|
|
@ -82,6 +137,26 @@ mod args {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--screenshot" => {
|
||||||
|
i += 1;
|
||||||
|
args.screenshot = raw.get(i).cloned();
|
||||||
|
}
|
||||||
|
"--output" => {
|
||||||
|
i += 1;
|
||||||
|
args.output = raw.get(i).cloned();
|
||||||
|
}
|
||||||
|
"--width" => {
|
||||||
|
i += 1;
|
||||||
|
if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) {
|
||||||
|
args.width = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--height" => {
|
||||||
|
i += 1;
|
||||||
|
if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) {
|
||||||
|
args.height = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
@ -99,6 +174,9 @@ fn main() -> Result<()> {
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let args = args::parse();
|
let args = args::parse();
|
||||||
|
if args.listen {
|
||||||
|
return listen::run();
|
||||||
|
}
|
||||||
let cfg = Config::load()?;
|
let cfg = Config::load()?;
|
||||||
|
|
||||||
if args.status {
|
if args.status {
|
||||||
|
|
@ -120,7 +198,23 @@ fn main() -> Result<()> {
|
||||||
return cmd_calendar_list_uid(¬e_id, &cfg);
|
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 <id>`, above) needs a real due note from
|
||||||
|
// the Store. A screenshot doesn't have one to work with — and
|
||||||
|
// shouldn't wait for one — so it builds a throwaway sample
|
||||||
|
// instead, never touching the Store at all.
|
||||||
|
let mut sample = Note::new(
|
||||||
|
"Sample reminder text".into(),
|
||||||
|
NoteType::from_str("reminder"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
sample.time = Some(chrono::Utc::now());
|
||||||
|
return run_reminder_window(sample, &cfg, screenshot_req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_popup(args.note_type, args.no_classify, cfg, screenshot_req)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_status(cfg: &Config) -> Result<()> {
|
fn cmd_status(cfg: &Config) -> Result<()> {
|
||||||
|
|
@ -265,6 +359,8 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit_reminder_due(¬e.id);
|
||||||
|
|
||||||
// Schedule next recurrence before showing UI
|
// Schedule next recurrence before showing UI
|
||||||
if note.rrule.is_some() {
|
if note.rrule.is_some() {
|
||||||
if let Some(next) = Scheduler::next_recurrence(¬e, &cfg.reminders.default_morning) {
|
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<()> {
|
fn run_reminder_window(
|
||||||
let app = gtk4::Application::builder()
|
note: breadpad_shared::types::Note,
|
||||||
.application_id("com.breadway.breadpad.reminder")
|
cfg: &Config,
|
||||||
.build();
|
screenshot_req: Option<screenshot::ScreenshotRequest>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut builder = gtk4::Application::builder().application_id("com.breadway.breadpad.reminder");
|
||||||
|
if screenshot_req.is_some() {
|
||||||
|
// Same reasoning as run_popup's NON_UNIQUE: a screenshot run must
|
||||||
|
// get its own fresh window, never activate a real reminder that
|
||||||
|
// happens to already be showing.
|
||||||
|
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
|
||||||
|
}
|
||||||
|
let app = builder.build();
|
||||||
|
|
||||||
let note = Arc::new(note);
|
let note = Arc::new(note);
|
||||||
let cfg = Arc::new(cfg.clone());
|
let cfg = Arc::new(cfg.clone());
|
||||||
|
|
||||||
app.connect_activate(move |app| {
|
app.connect_activate(move |app| {
|
||||||
build_reminder_window(app, note.clone(), cfg.clone());
|
build_reminder_window(app, note.clone(), cfg.clone(), screenshot_req.clone());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.run_with_args::<String>(&[]);
|
app.run_with_args::<String>(&[]);
|
||||||
|
|
@ -331,6 +436,7 @@ fn build_reminder_window(
|
||||||
app: >k4::Application,
|
app: >k4::Application,
|
||||||
note: Arc<breadpad_shared::types::Note>,
|
note: Arc<breadpad_shared::types::Note>,
|
||||||
cfg: Arc<Config>,
|
cfg: Arc<Config>,
|
||||||
|
screenshot_req: Option<screenshot::ScreenshotRequest>,
|
||||||
) {
|
) {
|
||||||
let window = gtk4::ApplicationWindow::builder()
|
let window = gtk4::ApplicationWindow::builder()
|
||||||
.application(app)
|
.application(app)
|
||||||
|
|
@ -345,6 +451,7 @@ fn build_reminder_window(
|
||||||
window.set_layer(Layer::Overlay);
|
window.set_layer(Layer::Overlay);
|
||||||
window.set_keyboard_mode(KeyboardMode::Exclusive);
|
window.set_keyboard_mode(KeyboardMode::Exclusive);
|
||||||
window.auto_exclusive_zone_enable();
|
window.auto_exclusive_zone_enable();
|
||||||
|
breadpad_shared::theme::bind_window(&window);
|
||||||
|
|
||||||
apply_css(&cfg);
|
apply_css(&cfg);
|
||||||
|
|
||||||
|
|
@ -392,7 +499,7 @@ fn build_reminder_window(
|
||||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||||
header.append(
|
header.append(
|
||||||
>k4::Label::builder()
|
>k4::Label::builder()
|
||||||
.label(&local.format("%H:%M").to_string())
|
.label(local.format("%H:%M").to_string())
|
||||||
.css_classes(["reminder-time"])
|
.css_classes(["reminder-time"])
|
||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
@ -417,14 +524,15 @@ fn build_reminder_window(
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
// Button row
|
// Button row — same inset as the header/body zone above (20px), which
|
||||||
|
// used to be 16px here, visible as a step across the divider.
|
||||||
let btn_row = gtk4::Box::builder()
|
let btn_row = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.margin_top(12)
|
.margin_top(12)
|
||||||
.margin_bottom(12)
|
.margin_bottom(12)
|
||||||
.margin_start(16)
|
.margin_start(20)
|
||||||
.margin_end(16)
|
.margin_end(20)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let dismiss_btn = gtk4::Button::builder()
|
let dismiss_btn = gtk4::Button::builder()
|
||||||
|
|
@ -432,23 +540,35 @@ fn build_reminder_window(
|
||||||
.css_classes(["reminder-dismiss"])
|
.css_classes(["reminder-dismiss"])
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Snooze popover
|
// Snooze popover. No arrow and a matching flat border (see
|
||||||
|
// popover.snooze-popover in the shared theme) so it reads as the same
|
||||||
|
// elevation language as the reminder card, instead of GTK's default
|
||||||
|
// arrow+drop-shadow chrome next to the card's flat 1px border.
|
||||||
let snooze_popover = gtk4::Popover::new();
|
let snooze_popover = gtk4::Popover::new();
|
||||||
|
snooze_popover.set_has_arrow(false);
|
||||||
|
snooze_popover.add_css_class("snooze-popover");
|
||||||
let snooze_vbox = gtk4::Box::builder()
|
let snooze_vbox = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
.spacing(4)
|
.spacing(0)
|
||||||
.margin_top(8)
|
.margin_top(4)
|
||||||
.margin_bottom(8)
|
.margin_bottom(4)
|
||||||
.margin_start(8)
|
|
||||||
.margin_end(8)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Left-aligned row: a bare Button::builder().label() centers its text,
|
||||||
|
// so each option gets an explicit xalign(0.0) label as its child and
|
||||||
|
// hexpand(true) so the row fills the popover's full width instead of
|
||||||
|
// shrinking to the longest label.
|
||||||
|
let snooze_option_row = |label: &str| {
|
||||||
|
gtk4::Button::builder()
|
||||||
|
.child(>k4::Label::builder().label(label).xalign(0.0).build())
|
||||||
|
.css_classes(["snooze-option"])
|
||||||
|
.hexpand(true)
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
|
||||||
for opt in &cfg.settings.snooze_options {
|
for opt in &cfg.settings.snooze_options {
|
||||||
let label = humanize_snooze(opt).to_string();
|
let label = humanize_snooze(opt).to_string();
|
||||||
let btn = gtk4::Button::builder()
|
let btn = snooze_option_row(&label);
|
||||||
.label(&label)
|
|
||||||
.css_classes(["snooze-option"])
|
|
||||||
.build();
|
|
||||||
let key = opt.clone();
|
let key = opt.clone();
|
||||||
let note_c = note.clone();
|
let note_c = note.clone();
|
||||||
let cfg_c = cfg.clone();
|
let cfg_c = cfg.clone();
|
||||||
|
|
@ -468,6 +588,47 @@ fn build_reminder_window(
|
||||||
});
|
});
|
||||||
snooze_vbox.append(&btn);
|
snooze_vbox.append(&btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom… — reuses the same free-form time parsing breadman's dialogs
|
||||||
|
// already use, rather than inventing a separate time-picker widget.
|
||||||
|
let custom_entry = gtk4::Entry::builder()
|
||||||
|
.placeholder_text("tomorrow 9am / in 45 minutes")
|
||||||
|
.css_classes(["snooze-custom-entry"])
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
{
|
||||||
|
let note_c = note.clone();
|
||||||
|
let cfg_c = cfg.clone();
|
||||||
|
let win_c = window.clone();
|
||||||
|
let popover_c = snooze_popover.clone();
|
||||||
|
let entry_c = custom_entry.clone();
|
||||||
|
custom_entry.connect_activate(move |_| {
|
||||||
|
let text = entry_c.text().to_string();
|
||||||
|
let parsed = parse_rule_based(&text, &cfg_c.reminders.default_morning);
|
||||||
|
if let Some(until) = parsed.time {
|
||||||
|
if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) {
|
||||||
|
let mut updated = note_c.as_ref().clone();
|
||||||
|
updated.snoozed_until = Some(until);
|
||||||
|
let _ = store.update_note(&updated);
|
||||||
|
let _ = Scheduler::schedule(&updated);
|
||||||
|
}
|
||||||
|
popover_c.popdown();
|
||||||
|
win_c.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let custom_btn = snooze_option_row("Custom\u{2026}");
|
||||||
|
{
|
||||||
|
let custom_entry_c = custom_entry.clone();
|
||||||
|
custom_btn.connect_clicked(move |btn| {
|
||||||
|
btn.set_visible(false);
|
||||||
|
custom_entry_c.set_visible(true);
|
||||||
|
custom_entry_c.grab_focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
snooze_vbox.append(&custom_btn);
|
||||||
|
snooze_vbox.append(&custom_entry);
|
||||||
|
|
||||||
snooze_popover.set_child(Some(&snooze_vbox));
|
snooze_popover.set_child(Some(&snooze_vbox));
|
||||||
|
|
||||||
let snooze_btn = gtk4::MenuButton::builder()
|
let snooze_btn = gtk4::MenuButton::builder()
|
||||||
|
|
@ -507,25 +668,48 @@ fn build_reminder_window(
|
||||||
outer.append(&btn_row);
|
outer.append(&btn_row);
|
||||||
|
|
||||||
window.set_child(Some(&outer));
|
window.set_child(Some(&outer));
|
||||||
|
|
||||||
|
if let Some(req) = screenshot_req {
|
||||||
|
if req.view == "reminder-snooze" {
|
||||||
|
screenshot::capture_with_snooze_open(&window, &req, snooze_popover.clone());
|
||||||
|
} else {
|
||||||
|
screenshot::capture_window(&window, &req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.present();
|
window.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Result<()> {
|
fn run_popup(
|
||||||
|
preset_type: Option<String>,
|
||||||
|
no_classify: bool,
|
||||||
|
cfg: Config,
|
||||||
|
screenshot_req: Option<screenshot::ScreenshotRequest>,
|
||||||
|
) -> Result<()> {
|
||||||
// Try to get current Hyprland workspace
|
// Try to get current Hyprland workspace
|
||||||
let workspace = get_active_workspace();
|
let workspace = get_active_workspace();
|
||||||
|
|
||||||
let app = gtk4::Application::builder()
|
let mut builder = gtk4::Application::builder().application_id("com.breadway.breadpad");
|
||||||
.application_id("com.breadway.breadpad")
|
if screenshot_req.is_some() {
|
||||||
.build();
|
// GApplication is single-instance by default; this machine typically
|
||||||
|
// already has a real breadpad instance, so without this a
|
||||||
|
// screenshot run would just toggle-close the *existing* instance's
|
||||||
|
// window instead of starting a fresh one that ever sees
|
||||||
|
// `screenshot_req` (see the `app.windows().first()` toggle below).
|
||||||
|
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
|
||||||
|
}
|
||||||
|
let app = builder.build();
|
||||||
|
|
||||||
let cfg = Arc::new(cfg);
|
let cfg = Arc::new(cfg);
|
||||||
|
|
||||||
app.connect_activate(move |app| {
|
app.connect_activate(move |app| {
|
||||||
if let Some(win) = app.windows().first().cloned() {
|
if screenshot_req.is_none() {
|
||||||
win.close();
|
if let Some(win) = app.windows().first().cloned() {
|
||||||
return;
|
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::<String>(&[]);
|
let code = app.run_with_args::<String>(&[]);
|
||||||
|
|
@ -551,6 +735,7 @@ fn build_window(
|
||||||
workspace: Option<String>,
|
workspace: Option<String>,
|
||||||
preset_type: Option<String>,
|
preset_type: Option<String>,
|
||||||
no_classify: bool,
|
no_classify: bool,
|
||||||
|
screenshot_req: Option<screenshot::ScreenshotRequest>,
|
||||||
) {
|
) {
|
||||||
let window = gtk4::ApplicationWindow::builder()
|
let window = gtk4::ApplicationWindow::builder()
|
||||||
.application(app)
|
.application(app)
|
||||||
|
|
@ -569,6 +754,7 @@ fn build_window(
|
||||||
window.set_anchor(Edge::Bottom, false);
|
window.set_anchor(Edge::Bottom, false);
|
||||||
window.set_anchor(Edge::Left, false);
|
window.set_anchor(Edge::Left, false);
|
||||||
window.set_anchor(Edge::Right, false);
|
window.set_anchor(Edge::Right, false);
|
||||||
|
breadpad_shared::theme::bind_window(&window);
|
||||||
|
|
||||||
apply_css(&cfg);
|
apply_css(&cfg);
|
||||||
|
|
||||||
|
|
@ -636,44 +822,15 @@ fn build_window(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live prefix grammar: typing "td: ", "rem: ", "idea: ", "note: ", or
|
// No submit button — this popup is keyboard-driven (grabs focus on
|
||||||
// "q: " at the start of the entry drives the chip selection without
|
// open, Escape closes it) and a bare accent-teal checkmark used to sit
|
||||||
// ever touching the mouse — the chips become feedback for what you
|
// next to the selected-type pill in the same accent teal, carrying two
|
||||||
// typed rather than the only way to pick a type.
|
// different meanings in one colour. A hint is enough.
|
||||||
{
|
let enter_hint = gtk4::Label::builder()
|
||||||
let selected_type_clone = selected_type.clone();
|
.label("Press Enter to add")
|
||||||
let chips_clone: Vec<(gtk4::Button, NoteType)> = chips.clone();
|
.css_classes(["dim-label"])
|
||||||
entry.connect_changed(move |e| {
|
|
||||||
let Some(nt) = breadpad_shared::parser::detect_prefix_type(&e.text()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
*selected_type_clone.borrow_mut() = nt.clone();
|
|
||||||
for (btn, chip_nt) in &chips_clone {
|
|
||||||
if *chip_nt == nt {
|
|
||||||
btn.add_css_class("active");
|
|
||||||
} else {
|
|
||||||
btn.remove_css_class("active");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let hint = gtk4::Label::builder()
|
|
||||||
.label("td: · rem: · idea: · note: · q:")
|
|
||||||
.css_classes(["prefix-hint"])
|
|
||||||
.xalign(0.0)
|
|
||||||
.build();
|
.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()
|
let bottom_row = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
|
|
@ -682,10 +839,9 @@ fn build_window(
|
||||||
|
|
||||||
let spacer = gtk4::Box::builder().hexpand(true).build();
|
let spacer = gtk4::Box::builder().hexpand(true).build();
|
||||||
bottom_row.append(&spacer);
|
bottom_row.append(&spacer);
|
||||||
bottom_row.append(&confirm_wrap);
|
bottom_row.append(&enter_hint);
|
||||||
|
|
||||||
vbox.append(&entry);
|
vbox.append(&entry);
|
||||||
vbox.append(&hint);
|
|
||||||
vbox.append(&bottom_row);
|
vbox.append(&bottom_row);
|
||||||
window.set_child(Some(&vbox));
|
window.set_child(Some(&vbox));
|
||||||
|
|
||||||
|
|
@ -719,12 +875,6 @@ fn build_window(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Confirm button click
|
|
||||||
{
|
|
||||||
let save = save_and_close.clone();
|
|
||||||
confirm_btn.connect_clicked(move |_| save());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entry activate (Enter key)
|
// Entry activate (Enter key)
|
||||||
{
|
{
|
||||||
let save = save_and_close.clone();
|
let save = save_and_close.clone();
|
||||||
|
|
@ -743,6 +893,10 @@ fn build_window(
|
||||||
});
|
});
|
||||||
window.add_controller(key_ctrl);
|
window.add_controller(key_ctrl);
|
||||||
|
|
||||||
|
if let Some(req) = screenshot_req {
|
||||||
|
screenshot::dispatch(&window, req);
|
||||||
|
}
|
||||||
|
|
||||||
window.present();
|
window.present();
|
||||||
entry.grab_focus();
|
entry.grab_focus();
|
||||||
}
|
}
|
||||||
|
|
@ -789,6 +943,7 @@ fn save_note_classified(
|
||||||
tracing::error!("failed to save note: {}", e);
|
tracing::error!("failed to save note: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
emit_captured(¬e.id);
|
||||||
if note.time.is_some() {
|
if note.time.is_some() {
|
||||||
if let Err(e) = Scheduler::schedule(¬e) {
|
if let Err(e) = Scheduler::schedule(¬e) {
|
||||||
tracing::warn!("failed to schedule reminder: {}", e);
|
tracing::warn!("failed to schedule reminder: {}", e);
|
||||||
|
|
|
||||||
110
breadpad/src/screenshot.rs
Normal file
110
breadpad/src/screenshot.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
//! `--screenshot` CLI mode: render breadpad's compose popup, capture it via
|
||||||
|
//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s
|
||||||
|
//! `bread-capture` orchestrator, or run standalone for one-off captures.
|
||||||
|
//!
|
||||||
|
//! No clap here (unlike breadbar/breadbox/breadclip/breadsearch): breadpad
|
||||||
|
//! already has its own small hand-rolled flag parser (`mod args`) covering
|
||||||
|
//! `--type`/`--no-classify`/`--status`/`fire`/`calendar`/etc, and clap's
|
||||||
|
//! default "reject unknown flags" behavior would break every one of those
|
||||||
|
//! if bolted on as a second, separate parser. `--screenshot`/`--output`/
|
||||||
|
//! `--width`/`--height` are just three more fields on that same `Args`
|
||||||
|
//! struct instead.
|
||||||
|
//!
|
||||||
|
//! Three views: "popup" (the compose window from `run_popup`), "reminder"
|
||||||
|
//! (the alert window from `run_reminder_window`/`build_reminder_window`,
|
||||||
|
//! normally only reachable via a real due note through `fire <id>`, built
|
||||||
|
//! here against a fabricated sample `Note` instead — see `main`'s
|
||||||
|
//! `screenshot_req.view == "reminder"` branch, which skips the Store lookup
|
||||||
|
//! entirely), and "reminder-snooze" (the same window with its snooze
|
||||||
|
//! popover open).
|
||||||
|
|
||||||
|
use bread_utils::screenshot_cli::SETTLE_DELAY;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Delay before popping the snooze popover open — same reasoning as every
|
||||||
|
/// other app's PRE_POPUP_DELAY: the parent window's own layout needs a beat
|
||||||
|
/// to settle first.
|
||||||
|
const PRE_POPUP_DELAY: Duration = SETTLE_DELAY;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ScreenshotRequest {
|
||||||
|
pub view: String,
|
||||||
|
pub output: PathBuf,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire up the given view's screenshot sequence against an already-built,
|
||||||
|
/// not-yet-presented window. Every path here ends by exiting the process —
|
||||||
|
/// it never returns control to the normal popup UI.
|
||||||
|
pub fn dispatch(window: >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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ci/bread-ecosystem.rev
Normal file
1
ci/bread-ecosystem.rev
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
620c5a1317a6b57276eabca961facdb78bf510db
|
||||||
21
ci/build.sh
Executable file
21
ci/build.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
|
||||||
|
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
|
||||||
|
# files now affect every product's release pipeline, so bumping the pin
|
||||||
|
# is a deliberate act instead of silent drift (see the bread-theme test
|
||||||
|
# that broke here for exactly that reason, before it was pinned by rev).
|
||||||
|
#
|
||||||
|
# Usage: ci/build.sh cargo build --release --locked
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
|
||||||
|
|
||||||
|
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
|
||||||
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-*
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
|
||||||
|
git -C "$CACHE_DIR" checkout --quiet "$REV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash "${CACHE_DIR}/ci/build.sh" breadpad "$ROOT" "$@"
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
|
||||||
|
|
||||||
pkgname=breadpad
|
|
||||||
pkgver=0.3.1
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc="Quick-capture scratchpad and note viewer with AI classification"
|
|
||||||
arch=('x86_64')
|
|
||||||
url="https://github.com/Breadway/breadpad"
|
|
||||||
license=('MIT')
|
|
||||||
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
|
|
||||||
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
|
|
||||||
# causing undefined-symbol errors. Disable LTO.
|
|
||||||
options=(!lto !debug)
|
|
||||||
depends=('gtk4' 'gtk4-layer-shell')
|
|
||||||
optdepends=(
|
|
||||||
'ollama: local AI note classification'
|
|
||||||
'hyprland: scratchpad window integration'
|
|
||||||
)
|
|
||||||
makedepends=('rust' 'cargo')
|
|
||||||
source=("${pkgname}-${pkgver}.tar.gz")
|
|
||||||
sha256sums=('SKIP')
|
|
||||||
|
|
||||||
build() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
cargo build --release --locked
|
|
||||||
}
|
|
||||||
|
|
||||||
check() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
cargo test --release --locked --workspace
|
|
||||||
}
|
|
||||||
|
|
||||||
package() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
install -Dm755 target/release/breadpad "${pkgdir}/usr/bin/breadpad"
|
|
||||||
install -Dm755 target/release/breadman "${pkgdir}/usr/bin/breadman"
|
|
||||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue