From baf145db8ab4819a0f4a990d5b6b0cd1d686e898 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:42:40 +0800 Subject: [PATCH 01/10] fix: look for bakery.toml in version_dir, not top-level package dir --- scripts/gen-index.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh index f085898..86d3f40 100755 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -77,14 +77,15 @@ build_package_json() { binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')" done - # Locate bakery.toml: the release workflow copies it to DL_DIR alongside the - # binaries. Fall back to a sibling repo checkout for local dev use. - local bakery_toml="${DL_DIR}/${name}/bakery.toml" + # Locate bakery.toml. The release workflow copies it into the version dir + # alongside the binaries (${version_dir}/bakery.toml). Fall back to a + # sibling repo checkout for local dev use. + local bakery_toml="${version_dir}/bakery.toml" if [[ ! -f "${bakery_toml}" ]]; then bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" fi if [[ ! -f "${bakery_toml}" ]]; then - echo "ERROR: bakery.toml not found for ${name} — release.yml must upload it to ${DL_DIR}/${name}/bakery.toml" >&2 + echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2 return 1 fi From ddfba38fc52593d82ee83e1188892b186c3f20fc Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:40:51 +0800 Subject: [PATCH 02/10] Add Arch packaging and Forgejo workflows for bakery - packaging/arch/PKGBUILD: builds the bakery CLI from the workspace - .forgejo/workflows/package.yml: publishes to the [breadway] Arch registry on tag - .forgejo/workflows/mirror.yml: mirrors to GitHub --- .forgejo/workflows/mirror.yml | 21 ++++++++++++++++++ .forgejo/workflows/package.yml | 40 ++++++++++++++++++++++++++++++++++ packaging/arch/PKGBUILD | 24 ++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/package.yml create mode 100644 packaging/arch/PKGBUILD diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..2128050 --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,21 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - name: Mirror to GitHub + run: | + set -euo pipefail + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); + # --prune deletes GitHub refs that no longer exist on Forgejo. + git push --prune \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread-ecosystem.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..37f2a48 --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,40 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + steps: + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo + 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="bakery-${VERSION}/" HEAD \ + > packaging/arch/bakery-${VERSION}.tar.gz + SHA=$(sha256sum packaging/arch/bakery-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD + chown -R builder:builder /home/builder/src + # --nocheck: packaging builds the artifact; tests belong in a CI job. + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" + PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..e71dfdb --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,24 @@ +# Maintainer: Breadway + +pkgname=bakery +pkgver=0.2.3 +pkgrel=1 +pkgdesc="Package manager for the bread ecosystem" +arch=('x86_64') +url="https://github.com/Breadway/bread-ecosystem" +license=('MIT') +depends=('glibc' 'gcc-libs') +makedepends=('rust' 'cargo') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo build --release --locked -p bakery +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + install -Dm755 target/release/bakery "${pkgdir}/usr/bin/bakery" + install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md" +} From b3a3b0609b50a55d159bf62ab8be169b36060f58 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 17:06:52 +0800 Subject: [PATCH 03/10] Disable LTO in PKGBUILD (vendored ring/mlua static libs vs makepkg -flto) --- packaging/arch/PKGBUILD | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index e71dfdb..b6078f3 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,10 @@ pkgdesc="Package manager for the bread ecosystem" arch=('x86_64') url="https://github.com/Breadway/bread-ecosystem" 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) depends=('glibc' 'gcc-libs') makedepends=('rust' 'cargo') source=("${pkgname}-${pkgver}.tar.gz") From 8b659bf83a4d8e92898725f28c53535996b1caf0 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 22:55:39 +0800 Subject: [PATCH 04/10] Use REGISTRY_TOKEN (scoped write:package) for registry publish --- .forgejo/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 37f2a48..6725e22 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,7 +14,7 @@ jobs: # actions require. Everything runs as shell steps and clones manually. - name: Build and publish env: - PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" From 578067183bd63a2ef8d21204b5fed4dbf748d469 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 23:00:47 +0800 Subject: [PATCH 05/10] Disable debug package so the main package publishes correctly makepkg's debug split produced a -debug pkg; the upload's head -1 could grab it instead of the main package. !debug yields a single package. --- packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index b6078f3..793c187 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -10,7 +10,7 @@ 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) +options=(!lto !debug) depends=('glibc' 'gcc-libs') makedepends=('rust' 'cargo') source=("${pkgname}-${pkgver}.tar.gz") From 8305b4a58bb55e27ac3ddd3e1fe2f120731cc55a Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 16:43:09 +0800 Subject: [PATCH 06/10] bread-theme: shared component stylesheet + generator CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the single source of truth for bread GUI styling so the apps stop each re-implementing (and drifting on) component CSS: - stylesheet(&Palette): full component sheet (buttons, entries, switches, dropdowns, lists/rows/sidebars, cards, chips, scrollbars, headings) built from the design tokens + a canonical @define-color block (surface=color0, overlay=color7, accent=color4). - render() / shared_css_path() / write_shared_css(): render for the current pywal palette and write to $XDG_RUNTIME_DIR/bread/theme.css. - gtk::apply_shared(): load that file (or a rendered fallback) at APPLICATION priority and watch it, so every app recolours live with no rebuild. - new `bread-theme` CLI (generate|path|print) — gtk-free, light. Run at session start and on palette change; apps pick it up via the file watch. The contract is a CSS *file*, so apps stay decoupled from this crate's gtk4 version. Tests cover the stylesheet, path, and render helpers. --- bread-theme/Cargo.toml | 6 ++ bread-theme/src/bin/bread-theme.rs | 50 ++++++++++ bread-theme/src/gtk.rs | 34 +++++++ bread-theme/src/lib.rs | 146 +++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 bread-theme/src/bin/bread-theme.rs diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 39930a1..8dc41e7 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -18,3 +18,9 @@ gtk4 = { version = "0.11", features = ["v4_12"], optional = true } # Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this). # bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature. gtk = ["dep:gtk4"] + +# The generator CLI. It only touches the gtk-free lib API (render + write), so +# it builds without the gtk feature and stays light. +[[bin]] +name = "bread-theme" +path = "src/bin/bread-theme.rs" diff --git a/bread-theme/src/bin/bread-theme.rs b/bread-theme/src/bin/bread-theme.rs new file mode 100644 index 0000000..e79d5c0 --- /dev/null +++ b/bread-theme/src/bin/bread-theme.rs @@ -0,0 +1,50 @@ +//! `bread-theme` — generates the ecosystem's shared GTK stylesheet from the +//! current pywal palette and writes it to the canonical path that every bread +//! GUI loads. Run it at session start, and again after the wallpaper/palette +//! changes (e.g. from a pywal hook); apps watch the file and recolour live. +//! +//! bread-theme # same as `generate` +//! bread-theme generate # render + write the shared stylesheet +//! bread-theme path # print the stylesheet path +//! bread-theme print # render to stdout (no write) + +use std::process::ExitCode; + +fn main() -> ExitCode { + let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into()); + match cmd.as_str() { + "path" => { + println!("{}", bread_theme::shared_css_path().display()); + ExitCode::SUCCESS + } + "print" => { + print!("{}", bread_theme::render()); + ExitCode::SUCCESS + } + "generate" => match bread_theme::write_shared_css() { + Ok(path) => { + eprintln!("bread-theme: wrote {}", path.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("bread-theme: failed to write stylesheet: {e}"); + ExitCode::FAILURE + } + }, + "-h" | "--help" | "help" => { + eprintln!( + "bread-theme — shared stylesheet generator\n\n\ + USAGE:\n bread-theme [generate|path|print]\n\n\ + generate render the pywal palette to the shared stylesheet (default)\n\ + path print the stylesheet path ({})\n\ + print render to stdout without writing", + bread_theme::shared_css_path().display() + ); + ExitCode::SUCCESS + } + other => { + eprintln!("bread-theme: unknown command '{other}' (try generate|path|print)"); + ExitCode::FAILURE + } + } +} diff --git a/bread-theme/src/gtk.rs b/bread-theme/src/gtk.rs index 6e62cb4..71f5306 100644 --- a/bread-theme/src/gtk.rs +++ b/bread-theme/src/gtk.rs @@ -1,7 +1,41 @@ +use gtk4::gio; +use gtk4::prelude::*; use gtk4::CssProvider; use std::cell::RefCell; use std::path::Path; +thread_local! { + static SHARED_PROVIDER: RefCell> = const { RefCell::new(None) }; + static SHARED_MONITOR: RefCell> = const { RefCell::new(None) }; +} + +fn reload_shared() { + let css = std::fs::read_to_string(crate::shared_css_path()) + .unwrap_or_else(|_| crate::render()); + SHARED_PROVIDER.with(|cell| apply_css(&css, cell)); +} + +/// Load the ecosystem's shared stylesheet (the file written by +/// `bread-theme generate`, or a freshly rendered fallback if absent) at +/// APPLICATION priority, and watch the file so the whole UI recolours live when +/// the palette changes — no app rebuild or restart needed. +/// +/// Call once at startup; then add the app's own CSS provider *after* this so +/// app-specific rules win on equal specificity. +pub fn apply_shared() { + reload_shared(); + SHARED_MONITOR.with(|cell| { + if cell.borrow().is_some() { + return; + } + let file = gio::File::for_path(crate::shared_css_path()); + if let Ok(monitor) = file.monitor_file(gio::FileMonitorFlags::NONE, gio::Cancellable::NONE) { + monitor.connect_changed(|_, _, _, _| reload_shared()); + *cell.borrow_mut() = Some(monitor); + } + }); +} + /// Apply a CSS string to the default display at APPLICATION priority. /// Re-uses an existing provider if one is passed in (for SIGHUP reloads). pub fn apply_css(css: &str, provider: &RefCell>) { diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 7d649e8..37fbf4e 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -54,6 +54,128 @@ pub fn css_vars(p: &Palette) -> String { ) } +/// Canonical `@define-color` block: the single naming all bread apps share. +/// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and +/// `accent` = color4. Apps must use these names, not raw palette slots, so the +/// whole ecosystem recolours together. +fn define_colors(p: &Palette) -> String { + format!( + "@define-color bg {bg};\n\ + @define-color fg {fg};\n\ + @define-color surface {c0};\n\ + @define-color overlay {c7};\n\ + @define-color accent {c4};\n\ + @define-color red {c1};\n\ + @define-color green {c2};\n\ + @define-color yellow {c3};\n\ + @define-color blue {c4};\n\ + @define-color pink {c5};\n\ + @define-color teal {c6};\n", + bg = p.background, fg = p.foreground, + c0 = p.color0, c1 = p.color1, c2 = p.color2, c3 = p.color3, + c4 = p.color4, c5 = p.color5, c6 = p.color6, c7 = p.color7, + ) +} + +/// The full shared component stylesheet — the single source of truth for how +/// every bread GUI (bos-settings, breadbar, breadbox, breadpad, breadman) styles +/// common widgets. Apps load this, then append only their own *layout* rules. +/// +/// Built entirely from the design tokens (font, spacing, radii) and the +/// `@define-color` palette, so changing the palette recolours every app. +pub fn stylesheet(p: &Palette) -> String { + use tokens::*; + format!( + "{vars}\ + * {{ font-family: '{font}'; font-size: {base}px; }}\n\ + window {{ background-color: @bg; color: @fg; }}\n\ + label {{ color: @fg; }}\n\ + .dim-label, .dim {{ color: @fg; opacity: 0.6; font-size: {sec}px; }}\n\ + .title {{ font-size: 1.4em; font-weight: bold; color: @fg; }}\n\ + .heading {{ font-weight: bold; color: @fg; opacity: 0.85; }}\n\ + .subtitle {{ color: @fg; opacity: 0.7; font-size: {sec}px; }}\n\ + button {{ background-color: @surface; color: @fg; border: none;\ + border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\ + button:hover {{ background-color: alpha(@fg, 0.14); }}\n\ + button:active {{ background-color: alpha(@fg, 0.20); }}\n\ + button:disabled {{ opacity: 0.5; }}\n\ + button.flat {{ background-color: transparent; }}\n\ + button.suggested-action {{ background-color: @accent; color: @bg; }}\n\ + button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\ + button.destructive-action {{ background-color: @red; color: @bg; }}\n\ + button.destructive-action:hover {{ background-color: alpha(@red, 0.85); }}\n\ + entry, spinbutton {{ background-color: @surface; color: @fg;\ + border: 1px solid @overlay; border-radius: {r2}px;\ + padding: {xs}px {sm}px; caret-color: @fg; }}\n\ + entry:focus-within, spinbutton:focus-within {{ border-color: @accent; outline: none; }}\n\ + entry image, spinbutton button {{ color: @fg; }}\n\ + dropdown > button {{ background-color: @surface; border-radius: {r2}px; }}\n\ + popover > contents {{ background-color: @surface; color: @fg; border-radius: {r1}px; }}\n\ + switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\ + switch:checked {{ background-color: @accent; }}\n\ + switch slider {{ background-color: @fg; border-radius: {pill}px; }}\n\ + list, listbox {{ background-color: transparent; }}\n\ + row {{ border-radius: {r2}px; }}\n\ + row:selected, list row:selected {{ background-color: @accent; color: @bg; }}\n\ + .sidebar {{ background-color: @surface; }}\n\ + .sidebar row {{ padding: {sm}px {md}px; color: @fg; }}\n\ + .sidebar row:selected {{ background-color: @accent; color: @bg; }}\n\ + .sidebar .section-header {{ padding: {md}px {md}px {xs}px {md}px;\ + font-size: {sec}px; font-weight: bold; color: @fg; opacity: 0.55; }}\n\ + .card {{ background-color: @surface; border-radius: {r1}px; padding: {md}px; }}\n\ + .chip, .pill {{ background-color: @overlay; color: @fg; border-radius: {pill}px;\ + padding: {xs}px {md}px; font-size: {sec}px; }}\n\ + .chip.active, .pill.active {{ background-color: @accent; color: @bg; }}\n\ + scrollbar {{ background-color: transparent; }}\n\ + scrollbar slider {{ background-color: alpha(@fg, 0.25); border-radius: {pill}px;\ + min-width: 6px; min-height: 6px; }}\n\ + scrollbar slider:hover {{ background-color: alpha(@fg, 0.45); }}\n\ + textview, .mono {{ font-family: monospace; }}\n\ + textview text {{ background-color: @surface; color: @fg; }}\n", + vars = define_colors(p), + font = FONT_FAMILY, + base = FONT_SIZE_BASE, + sec = FONT_SIZE_SECONDARY, + xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, + r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, pill = RADIUS_PILL, + ) +} + +/// Render the shared stylesheet for the current (pywal) palette. Used by the +/// `bread-theme` generator and as the in-app fallback when the generated file +/// isn't present yet. +pub fn render() -> String { + stylesheet(&load_palette()) +} + +/// Canonical path of the generated shared stylesheet. Apps load it; the +/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`, +/// falling back to the cache dir. +pub fn shared_css_path() -> std::path::PathBuf { + if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") { + if !rt.is_empty() { + return std::path::PathBuf::from(rt).join("bread").join("theme.css"); + } + } + dirs::cache_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) + .join("bread") + .join("theme.css") +} + +/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns +/// the path written. Used by the `bread-theme` CLI. +pub fn write_shared_css() -> std::io::Result { + let path = shared_css_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("css.tmp"); + std::fs::write(&tmp, render())?; + std::fs::rename(&tmp, &path)?; + Ok(path) +} + /// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`. pub fn hex_to_rgba(hex: &str, alpha: f32) -> String { let h = hex.trim_start_matches('#'); @@ -82,6 +204,30 @@ mod tests { assert!(css.contains("14px")); } + #[test] + fn stylesheet_defines_canonical_colors_and_components() { + let css = stylesheet(&Palette::default()); + for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] { + assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + } + // a representative spread of the shared component selectors + for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".title"] { + assert!(css.contains(sel), "stylesheet missing selector: {sel}"); + } + assert!(css.contains("Varela Round")); + } + + #[test] + fn shared_css_path_uses_runtime_dir() { + std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234"); + assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css")); + } + + #[test] + fn render_is_nonempty_css() { + assert!(render().contains("@define-color bg ")); + } + #[test] fn hex_to_rgba_known_value() { assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)"); From 46db2c23cdabc34d37487d3b65774addbc4ebe5e Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 17:07:03 +0800 Subject: [PATCH 07/10] docs: ecosystem overview, keybinds, shared-theming, design system README: add recommended keybinds and a Theming section documenting the bread-theme generator + live-reloaded shared stylesheet. Add BREAD_DESIGN_SYSTEM.md to the repo (the README links it) and update it to describe the single-source-of-truth architecture and the migrated apps (incl. bos-settings). --- BREAD_DESIGN_SYSTEM.md | 118 +++++++++++++++++++++++++++++++++++++++++ README.md | 30 +++++++++++ 2 files changed, 148 insertions(+) create mode 100644 BREAD_DESIGN_SYSTEM.md diff --git a/BREAD_DESIGN_SYSTEM.md b/BREAD_DESIGN_SYSTEM.md new file mode 100644 index 0000000..942f7a2 --- /dev/null +++ b/BREAD_DESIGN_SYSTEM.md @@ -0,0 +1,118 @@ +# Bread Design System + +Unified visual identity for breadbar, breadbox, breadpad/breadman, and +bos-settings. + +## Architecture (single source of truth) + +The tokens below are implemented once in the **`bread-theme`** crate as +`stylesheet(&Palette)` — the full component stylesheet (buttons, entries, +switches, lists/rows/sidebars, cards, chips, scrollbars, headings) over a +canonical `@define-color` palette (`surface`=color0, `overlay`=color7, +`accent`=color4). + +- The `bread-theme` **CLI** renders it from the live pywal palette to + `$XDG_RUNTIME_DIR/bread/theme.css` (run at login and from a pywal hook). +- Every GUI loads that file via `bread_theme::gtk::apply_shared()` and + **live-reloads** it, then layers on only its own app-specific rules. + +Result: one definition, no per-app drift, and palette changes recolour the +whole desktop with no rebuilds. Apps reference the shared `@define-color` +names rather than raw palette slots. + +## Typography + +- **Font Family**: Varela Round, sans-serif +- **Base Size**: 14px +- **Secondary**: 12px (metadata, helper text, secondary labels) +- **Font Weight**: Normal (400) for body, Bold (700) for emphasis + +## Spacing Scale (4px units) + +Use these values consistently across all projects: + +- **xs**: 4px (small gaps, internal padding) +- **sm**: 8px (default spacing between elements) +- **md**: 12px (medium spacing, main padding) +- **lg**: 16px (large padding, major spacing) +- **xl**: 20px (extra large spacing, section breaks) + +## Border Radius + +Establish a visual hierarchy with consistent rounding: + +- **Primary** (buttons, cards, main containers): **8px** +- **Secondary** (input fields, chips, entries): **6px** +- **Tertiary** (small interactive elements): **4px** +- **Pill** (fully rounded buttons, badges): **999px** + +## Color System + +All projects use **pywal dynamic theming** with **Catppuccin Mocha** as the fallback palette: + +- **Background**: `#1e1e2e` (Catppuccin) +- **Foreground**: `#cdd6f4` (Catppuccin) +- **Surface**: `#181825` (Catppuccin) +- **Accent**: Dynamic (from pywal) + +Color palette slots (via wal): +- color0–color7: ANSI colors +- Semantic: red, green, yellow, blue, pink, teal + +## Component Standards + +### Buttons +- Border Radius: 8px +- Padding: 8px 16px (primary), 4px 8px (secondary) +- Font Size: 14px +- Background: Theme accent color + +### Input Fields +- Border Radius: 6px +- Padding: 12px 16px +- Font Size: 14px +- Border: 1px or 2px solid (blue on focus) + +### Cards +- Border Radius: 8px +- Padding: 12px +- Margin: 8px +- Box Shadow: Optional, for depth + +### Stat Labels +- Font Size: 14px +- Margin Right (between icon/text): 5px +- Group Margin Right: 12px + +### Notification Cards +- Border Radius: 8px +- Padding: 12px +- Margin Bottom: 8px +- Font Size: 14px (summary), 12px (body) + +## Current Implementation + +All GUI apps load `bread_theme::stylesheet` (via the generated shared file) and +add only app-specific rules: + +- **breadbar** — shared base + bar window, workspace buttons, stats, notification + and OSD cards. +- **breadbox** — shared base + launcher panel, search entry, result rows. +- **breadpad / breadman** — shared base + capture popup, type chips, note cards, + reminder window, sidebar rows. +- **bos-settings** — shared base + content padding only (was previously a + hardcoded Nord palette; migrated to the shared stylesheet). +- **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling. + +> Palette note: the fallback is Catppuccin Mocha, but installs (e.g. BOS) drive +> the real palette from pywal — BOS ships a black-base palette. + +## Future Consistency Checks + +When adding new components or updating existing ones: +1. Use Varela Round for all text +2. Set base font size to 14px (12px for secondary) +3. Use spacing scale (4px units: 4, 8, 12, 16, 20) +4. Use border radius from this system (8px default, 6px secondary) +5. Leverage pywal colors for dynamic theming +6. Keep margins/padding consistent across similar components diff --git a/README.md b/README.md index 405c193..1ec4cff 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,36 @@ bakery install breadbar | `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon | | `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) | +## Recommended keybinds + +The ecosystem assumes a Hyprland setup with `SUPER` as the modifier. The +conventional bindings (used by BOS and recommended for any install): + +| Keys | Action | +|------|--------| +| `SUPER+Space` | `breadbox` — app launcher | +| `SUPER+U` | `breadpad` — quick-capture notes/reminders | +| `SUPER+M` | `breadman` — note viewer / manager | +| `SUPER+,` | settings (`bos-settings`, where installed) | + +`breadbar` and `breadd` are services started at login (`exec-once`), not bound +to keys. + +## Theming + +All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the +component stylesheet from your pywal palette (Catppuccin Mocha fallback) to +`$XDG_RUNTIME_DIR/bread/theme.css`; every app loads that file and **live-reloads** +it, so changing your wallpaper recolours the whole ecosystem with no rebuilds: + +```sh +wal -i ~/Pictures/wall.png # regenerate pywal palette +bread-theme generate # render the shared stylesheet (run from a wal hook) +``` + +See [`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the tokens (fonts, +spacing, radii, colour roles) the stylesheet is built from. + ## Installing bakery `bakery` is the package manager for the ecosystem. Install it with the bootstrap script: From 049465080511b93b5fc30f2258a58c1c6aee155d Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:35:03 +0800 Subject: [PATCH 08/10] bread-theme 0.2.7: luminance-picked ink + live reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readability: pywal can emit a light value in any palette slot, and the shared sheet assumed dark backgrounds (white text), so text vanished on light surfaces/ accents. Add ink_on() — a WCAG-luminance pick of near-black/near-white per background — exposed as @on-bg/@on-surface/@on-accent/@on-red/@on-overlay. The component sheet now sets colour on containers and lets labels inherit (de-emphasis via opacity), dropping the blanket `label { color }` rule that overrode coloured-background text. pywal hues are untouched. Hot reload: add gtk::apply_app_css(closure) — applies an app's own CSS now and re-runs the closure whenever the shared theme file is rewritten, so apps recolour in place. New `bread-theme reload` verb rewrites the file (atomic rename trips every running GUI's monitor) — the command to run after changing pywal colours. --- bread-theme/src/bin/bread-theme.rs | 36 +++++--- bread-theme/src/gtk.rs | 40 +++++++++ bread-theme/src/lib.rs | 133 ++++++++++++++++++++++------- 3 files changed, 167 insertions(+), 42 deletions(-) diff --git a/bread-theme/src/bin/bread-theme.rs b/bread-theme/src/bin/bread-theme.rs index e79d5c0..266ea9c 100644 --- a/bread-theme/src/bin/bread-theme.rs +++ b/bread-theme/src/bin/bread-theme.rs @@ -5,11 +5,26 @@ //! //! bread-theme # same as `generate` //! bread-theme generate # render + write the shared stylesheet +//! bread-theme reload # re-render from the current pywal palette and +//! # signal every running bread GUI to recolour //! bread-theme path # print the stylesheet path //! bread-theme print # render to stdout (no write) use std::process::ExitCode; +fn write_and_report(verb: &str) -> ExitCode { + match bread_theme::write_shared_css() { + Ok(path) => { + eprintln!("bread-theme: {verb} {}", path.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("bread-theme: failed to write stylesheet: {e}"); + ExitCode::FAILURE + } + } +} + fn main() -> ExitCode { let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into()); match cmd.as_str() { @@ -21,21 +36,18 @@ fn main() -> ExitCode { print!("{}", bread_theme::render()); ExitCode::SUCCESS } - "generate" => match bread_theme::write_shared_css() { - Ok(path) => { - eprintln!("bread-theme: wrote {}", path.display()); - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("bread-theme: failed to write stylesheet: {e}"); - ExitCode::FAILURE - } - }, + "generate" => write_and_report("wrote"), + // `reload` is `generate` from the caller's view, but it's the verb to use + // after changing pywal colours: rewriting the file (atomic rename) trips + // the file monitor in every running bread GUI, so they all re-read the + // palette and recolour live — shared widgets *and* each app's own rules. + "reload" => write_and_report("reloaded"), "-h" | "--help" | "help" => { eprintln!( "bread-theme — shared stylesheet generator\n\n\ - USAGE:\n bread-theme [generate|path|print]\n\n\ + USAGE:\n bread-theme [generate|reload|path|print]\n\n\ generate render the pywal palette to the shared stylesheet (default)\n\ + reload re-render and signal running bread GUIs to recolour live\n\ path print the stylesheet path ({})\n\ print render to stdout without writing", bread_theme::shared_css_path().display() @@ -43,7 +55,7 @@ fn main() -> ExitCode { ExitCode::SUCCESS } other => { - eprintln!("bread-theme: unknown command '{other}' (try generate|path|print)"); + eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)"); ExitCode::FAILURE } } diff --git a/bread-theme/src/gtk.rs b/bread-theme/src/gtk.rs index 71f5306..bc68dc2 100644 --- a/bread-theme/src/gtk.rs +++ b/bread-theme/src/gtk.rs @@ -7,6 +7,10 @@ use std::path::Path; thread_local! { static SHARED_PROVIDER: RefCell> = const { RefCell::new(None) }; static SHARED_MONITOR: RefCell> = const { RefCell::new(None) }; + static APP_PROVIDER: RefCell> = const { RefCell::new(None) }; + static APP_MONITOR: RefCell> = const { RefCell::new(None) }; + #[allow(clippy::type_complexity)] + static APP_BUILDER: RefCell String>>> = const { RefCell::new(None) }; } fn reload_shared() { @@ -15,6 +19,42 @@ fn reload_shared() { SHARED_PROVIDER.with(|cell| apply_css(&css, cell)); } +fn reload_app() { + let css = APP_BUILDER.with(|b| b.borrow().as_ref().map(|f| f())); + if let Some(css) = css { + APP_PROVIDER.with(|cell| apply_css(&css, cell)); + } +} + +/// Apply an app's *own* stylesheet and keep it live across palette changes. +/// +/// `build` is called now to produce the app-specific CSS, and again every time +/// the shared theme file is rewritten — i.e. whenever `bread-theme reload` (or +/// `generate`) runs after pywal changes. The app recolours in place, no restart. +/// +/// This is the counterpart to [`apply_shared`]: that hot-reloads the *shared* +/// component sheet; this hot-reloads the app's *own* rules (which are built from +/// the palette, so they'd otherwise be frozen at startup). Apps that build their +/// CSS from [`crate::stylesheet`] themselves can use this alone; apps that layer +/// on top of [`apply_shared`] call both. +/// +/// Call once at startup. The closure should read the current palette +/// ([`crate::load_palette`]) each time so it picks up the new colours. +pub fn apply_app_css String + 'static>(build: F) { + APP_BUILDER.with(|b| *b.borrow_mut() = Some(Box::new(build))); + reload_app(); + APP_MONITOR.with(|cell| { + if cell.borrow().is_some() { + return; + } + let file = gio::File::for_path(crate::shared_css_path()); + if let Ok(monitor) = file.monitor_file(gio::FileMonitorFlags::NONE, gio::Cancellable::NONE) { + monitor.connect_changed(|_, _, _, _| reload_app()); + *cell.borrow_mut() = Some(monitor); + } + }); +} + /// Load the ecosystem's shared stylesheet (the file written by /// `bread-theme generate`, or a freshly rendered fallback if absent) at /// APPLICATION priority, and watch the file so the whole UI recolours live when diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 37fbf4e..2ee2307 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -54,10 +54,34 @@ pub fn css_vars(p: &Palette) -> String { ) } +/// Relative luminance (WCAG, sRGB) of a `#rrggbb` colour, 0.0 (black) – 1.0 (white). +pub fn luminance(hex: &str) -> f32 { + let h = hex.trim_start_matches('#'); + let lin = |i: usize| -> f32 { + let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0; + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4) +} + +/// Pick a legible ink (near-black or near-white) for text drawn on `hex`. +/// 0.179 is the WCAG crossover where contrast against black equals contrast +/// against white — so whichever side we pick always wins. This is what keeps +/// text readable no matter how light or dark pywal makes a given palette slot, +/// without altering the palette colours themselves. +pub fn ink_on(hex: &str) -> &'static str { + if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" } +} + /// Canonical `@define-color` block: the single naming all bread apps share. /// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and /// `accent` = color4. Apps must use these names, not raw palette slots, so the /// whole ecosystem recolours together. +/// +/// The `on-*` colours are computed ink (black/white) guaranteed to be legible on +/// the matching background — use `@on-surface` for text on a `@surface` panel, +/// `@on-accent` on an `@accent` button, etc. They exist because pywal can emit a +/// light value in any slot, and white text on a light surface disappears. fn define_colors(p: &Palette) -> String { format!( "@define-color bg {bg};\n\ @@ -70,10 +94,20 @@ fn define_colors(p: &Palette) -> String { @define-color yellow {c3};\n\ @define-color blue {c4};\n\ @define-color pink {c5};\n\ - @define-color teal {c6};\n", + @define-color teal {c6};\n\ + @define-color on-bg {on_bg};\n\ + @define-color on-surface {on_surface};\n\ + @define-color on-accent {on_accent};\n\ + @define-color on-red {on_red};\n\ + @define-color on-overlay {on_overlay};\n", bg = p.background, fg = p.foreground, c0 = p.color0, c1 = p.color1, c2 = p.color2, c3 = p.color3, c4 = p.color4, c5 = p.color5, c6 = p.color6, c7 = p.color7, + on_bg = ink_on(&p.background), + on_surface = ink_on(&p.color0), + on_accent = ink_on(&p.color4), + on_red = ink_on(&p.color1), + on_overlay = ink_on(&p.color7), ) } @@ -88,50 +122,53 @@ pub fn stylesheet(p: &Palette) -> String { format!( "{vars}\ * {{ font-family: '{font}'; font-size: {base}px; }}\n\ - window {{ background-color: @bg; color: @fg; }}\n\ - label {{ color: @fg; }}\n\ - .dim-label, .dim {{ color: @fg; opacity: 0.6; font-size: {sec}px; }}\n\ - .title {{ font-size: 1.4em; font-weight: bold; color: @fg; }}\n\ - .heading {{ font-weight: bold; color: @fg; opacity: 0.85; }}\n\ - .subtitle {{ color: @fg; opacity: 0.7; font-size: {sec}px; }}\n\ - button {{ background-color: @surface; color: @fg; border: none;\ + /* Colour is set on containers; labels inherit it, so text on any panel,\ + button, or accent is always the legible ink for that background. Bare\ + `label {{ color }}` is deliberately avoided — as a type selector it\ + would override a container's colour on its own child labels. */\n\ + window {{ background-color: @bg; color: @on-bg; }}\n\ + .dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\ + .title {{ font-size: 1.4em; font-weight: bold; }}\n\ + .heading {{ font-weight: bold; opacity: 0.85; }}\n\ + .subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\ + button {{ background-color: @surface; color: @on-surface; border: none;\ border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\ - button:hover {{ background-color: alpha(@fg, 0.14); }}\n\ - button:active {{ background-color: alpha(@fg, 0.20); }}\n\ + button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\ + button:active {{ background-color: alpha(@on-surface, 0.20); }}\n\ button:disabled {{ opacity: 0.5; }}\n\ - button.flat {{ background-color: transparent; }}\n\ - button.suggested-action {{ background-color: @accent; color: @bg; }}\n\ + button.flat {{ background-color: transparent; color: @on-bg; }}\n\ + button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\ button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\ - button.destructive-action {{ background-color: @red; color: @bg; }}\n\ + button.destructive-action {{ background-color: @red; color: @on-red; }}\n\ button.destructive-action:hover {{ background-color: alpha(@red, 0.85); }}\n\ - entry, spinbutton {{ background-color: @surface; color: @fg;\ + entry, spinbutton {{ background-color: @surface; color: @on-surface;\ border: 1px solid @overlay; border-radius: {r2}px;\ - padding: {xs}px {sm}px; caret-color: @fg; }}\n\ + padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\ entry:focus-within, spinbutton:focus-within {{ border-color: @accent; outline: none; }}\n\ - entry image, spinbutton button {{ color: @fg; }}\n\ - dropdown > button {{ background-color: @surface; border-radius: {r2}px; }}\n\ - popover > contents {{ background-color: @surface; color: @fg; border-radius: {r1}px; }}\n\ + entry image, spinbutton button {{ color: @on-surface; }}\n\ + dropdown > button {{ background-color: @surface; color: @on-surface; border-radius: {r2}px; }}\n\ + popover > contents {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; }}\n\ switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\ switch:checked {{ background-color: @accent; }}\n\ - switch slider {{ background-color: @fg; border-radius: {pill}px; }}\n\ + switch slider {{ background-color: @on-surface; border-radius: {pill}px; }}\n\ list, listbox {{ background-color: transparent; }}\n\ row {{ border-radius: {r2}px; }}\n\ - row:selected, list row:selected {{ background-color: @accent; color: @bg; }}\n\ - .sidebar {{ background-color: @surface; }}\n\ - .sidebar row {{ padding: {sm}px {md}px; color: @fg; }}\n\ - .sidebar row:selected {{ background-color: @accent; color: @bg; }}\n\ + row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\ + .sidebar {{ background-color: @surface; color: @on-surface; }}\n\ + .sidebar row {{ padding: {sm}px {md}px; }}\n\ + .sidebar row:selected {{ background-color: @accent; color: @on-accent; }}\n\ .sidebar .section-header {{ padding: {md}px {md}px {xs}px {md}px;\ - font-size: {sec}px; font-weight: bold; color: @fg; opacity: 0.55; }}\n\ - .card {{ background-color: @surface; border-radius: {r1}px; padding: {md}px; }}\n\ - .chip, .pill {{ background-color: @overlay; color: @fg; border-radius: {pill}px;\ + font-size: {sec}px; font-weight: bold; opacity: 0.55; }}\n\ + .card {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; padding: {md}px; }}\n\ + .chip, .pill {{ background-color: @overlay; color: @on-overlay; border-radius: {pill}px;\ padding: {xs}px {md}px; font-size: {sec}px; }}\n\ - .chip.active, .pill.active {{ background-color: @accent; color: @bg; }}\n\ + .chip.active, .pill.active {{ background-color: @accent; color: @on-accent; }}\n\ scrollbar {{ background-color: transparent; }}\n\ - scrollbar slider {{ background-color: alpha(@fg, 0.25); border-radius: {pill}px;\ + scrollbar slider {{ background-color: alpha(@on-bg, 0.25); border-radius: {pill}px;\ min-width: 6px; min-height: 6px; }}\n\ - scrollbar slider:hover {{ background-color: alpha(@fg, 0.45); }}\n\ + scrollbar slider:hover {{ background-color: alpha(@on-bg, 0.45); }}\n\ textview, .mono {{ font-family: monospace; }}\n\ - textview text {{ background-color: @surface; color: @fg; }}\n", + textview text {{ background-color: @surface; color: @on-surface; }}\n", vars = define_colors(p), font = FONT_FAMILY, base = FONT_SIZE_BASE, @@ -217,6 +254,42 @@ mod tests { assert!(css.contains("Varela Round")); } + #[test] + fn luminance_black_and_white_are_extremes() { + assert!(luminance("#000000") < 0.01); + assert!(luminance("#ffffff") > 0.99); + } + + #[test] + fn ink_on_picks_dark_text_for_light_backgrounds() { + // Light pywal slots (the case that made white text vanish) get dark ink. + assert_eq!(ink_on("#ffffff"), "#11111b"); + assert_eq!(ink_on("#f9e2af"), "#11111b"); // pale yellow + assert_eq!(ink_on("#a6e3a1"), "#11111b"); // pale green + } + + #[test] + fn ink_on_picks_light_text_for_dark_backgrounds() { + assert_eq!(ink_on("#000000"), "#f5f5f5"); + assert_eq!(ink_on("#1e1e2e"), "#f5f5f5"); // catppuccin base + } + + #[test] + fn stylesheet_defines_on_colors() { + let css = stylesheet(&Palette::default()); + for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] { + assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + } + } + + #[test] + fn stylesheet_has_no_blanket_label_color_rule() { + // A bare `label { color: ... }` would override container colours on child + // labels — the bug that made coloured-background text illegible. + let css = stylesheet(&Palette::default()); + assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced"); + } + #[test] fn shared_css_path_uses_runtime_dir() { std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234"); From 5e58558dd36031433d4a8d8e70c71206c3f1f8f4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:53:35 +0800 Subject: [PATCH 09/10] =?UTF-8?q?bread-theme=200.2.8:=20fix=20live=20reloa?= =?UTF-8?q?d=20=E2=80=94=20watch=20the=20dir,=20not=20the=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stylesheet is written with write-tmp-then-rename (atomic), which replaces the inode. A monitor on the file itself caught the first replace then went deaf (inotify reports DELETE_SELF and never re-arms), so `bread-theme reload` updated the file but no running GUI ever recoloured. Monitor the parent directory and filter for the stylesheet filename instead — that fires on every reload. Verified against a real atomic-rename write (event arrives as Renamed with the new name in other_file, so match both file and other_file). --- bread-theme/src/gtk.rs | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/bread-theme/src/gtk.rs b/bread-theme/src/gtk.rs index bc68dc2..aab7d01 100644 --- a/bread-theme/src/gtk.rs +++ b/bread-theme/src/gtk.rs @@ -26,6 +26,33 @@ fn reload_app() { } } +/// Watch the shared stylesheet for changes and run `reload` when it's rewritten. +/// +/// `bread-theme` writes the file with write-tmp-then-rename (atomic), which +/// *replaces the inode*. A monitor on the file itself dies after the first +/// replace (inotify reports DELETE_SELF and never re-arms), so we monitor the +/// parent *directory* and filter for the stylesheet's filename — that fires +/// reliably on every reload. Returns the monitor (keep it alive to stay armed). +fn watch_theme_file(reload: fn()) -> Option { + let target = crate::shared_css_path(); + let dir = target.parent()?; + // The dir must exist to be monitored; `bread-theme generate` makes it at + // login, but create it here too so a GUI started first still arms the watch. + let _ = std::fs::create_dir_all(dir); + let monitor = gio::File::for_path(dir) + .monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE) + .ok()?; + monitor.connect_changed(move |_, file, other, _event| { + // The rename lands as an event whose file (or move destination) is the + // stylesheet. Match either to catch both CREATED/CHANGED and MOVED_IN. + let is_target = |f: &gio::File| f.path().as_deref() == Some(target.as_path()); + if is_target(file) || other.is_some_and(is_target) { + reload(); + } + }); + Some(monitor) +} + /// Apply an app's *own* stylesheet and keep it live across palette changes. /// /// `build` is called now to produce the app-specific CSS, and again every time @@ -47,11 +74,7 @@ pub fn apply_app_css String + 'static>(build: F) { if cell.borrow().is_some() { return; } - let file = gio::File::for_path(crate::shared_css_path()); - if let Ok(monitor) = file.monitor_file(gio::FileMonitorFlags::NONE, gio::Cancellable::NONE) { - monitor.connect_changed(|_, _, _, _| reload_app()); - *cell.borrow_mut() = Some(monitor); - } + *cell.borrow_mut() = watch_theme_file(reload_app); }); } @@ -68,11 +91,7 @@ pub fn apply_shared() { if cell.borrow().is_some() { return; } - let file = gio::File::for_path(crate::shared_css_path()); - if let Ok(monitor) = file.monitor_file(gio::FileMonitorFlags::NONE, gio::Cancellable::NONE) { - monitor.connect_changed(|_, _, _, _| reload_shared()); - *cell.borrow_mut() = Some(monitor); - } + *cell.borrow_mut() = watch_theme_file(reload_shared); }); } From 10f62fb1a62fc5ca4eab90ae5e1bfc8cd9bf9fc9 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 22:55:12 +0800 Subject: [PATCH 10/10] feat: add breadpaper to ecosystem registry --- registry/bread-ecosystem.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index 530f938..96887cb 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -36,3 +36,8 @@ description = "Profile-aware Wi-Fi state machine with Tailscale integration" name = "breadpad" repo = "Breadway/breadpad" description = "Quick-capture scratchpad and note viewer with AI classification" + +[[products]] +name = "breadpaper" +repo = "Breadway/breadpaper" +description = "Wallpaper manager for the bread desktop"