From fcba3760387e2523edb71350f8efea3bc851b21e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:20:00 +0800 Subject: [PATCH 1/6] Add per-output palettes and window-scoped theme binding Each Hyprland/GDK connector can have its own palette and stylesheet under $XDG_RUNTIME_DIR/bread/{palettes,themes}/. GTK apps bind a widget-level provider so two windows in one process can follow different wallpapers. Bump workspace version to 0.7.4 for the tag. --- Cargo.toml | 2 +- bread-polkit/src/ui.rs | 1 + bread-theme/CHANGELOG.md | 45 +++- bread-theme/src/bin/bread-theme.rs | 160 +++++++++++++- bread-theme/src/gtk.rs | 331 +++++++++++++++++++++++++++- bread-theme/src/lib.rs | 184 +++++++++++++--- bread-theme/src/output.rs | 338 +++++++++++++++++++++++++++++ bread-theme/src/palette.rs | 13 +- 8 files changed, 1016 insertions(+), 58 deletions(-) create mode 100644 bread-theme/src/output.rs diff --git a/Cargo.toml b/Cargo.toml index edccbba..10d698a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screensh resolver = "2" [workspace.package] -version = "0.7.2" +version = "0.7.4" edition = "2021" license = "MIT" authors = ["Breadway "] diff --git a/bread-polkit/src/ui.rs b/bread-polkit/src/ui.rs index 50275bd..033f171 100644 --- a/bread-polkit/src/ui.rs +++ b/bread-polkit/src/ui.rs @@ -135,6 +135,7 @@ pub fn show_prompt(app: &Application, prompt: Prompt) { panel.append(&buttons); window.set_child(Some(&panel)); + bread_theme::gtk::bind_window_auto_with_app_css(&window, |_| app_css()); let reply = prompt.reply.clone(); let cookie = prompt.cookie.clone(); diff --git a/bread-theme/CHANGELOG.md b/bread-theme/CHANGELOG.md index 2675e55..9b27493 100644 --- a/bread-theme/CHANGELOG.md +++ b/bread-theme/CHANGELOG.md @@ -1,11 +1,48 @@ # bread-theme changelog +## 0.7.4 + +Per-output (per-monitor) theming. Session-global `theme.css` remains the +fallback / focused-monitor sheet; each Hyprland/GDK connector can now have +its own palette and stylesheet. BOS still keeps bg/surface/overlay/fg +fixed — only color1–6 come from the wallpaper. + +On disk under `$XDG_RUNTIME_DIR/bread/` (same fallback as `shared_css_path`): + +- `palettes/.json` — accents only (round-trips through + `from_wal_json` / a color1–6 object; never persists pywal's light bg) +- `themes/.css` — `stylesheet()` for that palette + +New lib API: + +- `themes_dir`, `palettes_dir`, `output_css_path`, `output_palette_path`, + `sanitize_output` +- `load_palette_for`, `write_output_palette`, `write_output_css`, + `write_shared_css_from` +- `palette_from_image` (isolated `wal -i`, does not touch `~/.cache/wal`), + `generate_output`, `palette_from_json` +- `stylesheet_resolved` — inlines `@accent` / `@on-bg` / … to hex so GTK's + display-global `@define-color` cannot leak the wrong monitor's accent + +GTK (`gtk` feature): `bind_window`, `bind_window_with_app_css`, +`output_for_widget`, `bind_window_auto`, `bind_window_auto_with_app_css`. +Widget-scoped providers at `USER - 10` so they beat `apply_shared` but +lose to user CSS. Existing `apply_shared` / `apply_app_css` / +`apply_css` / `apply_user_css` are unchanged. + +CLI: `bread-theme generate-output --image | --from-json + [--shared]`. Does not write `theme.css` unless `--shared`. + ## Coordinated bump policy -`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned -git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature -API requires all three dependents to bump their `Cargo.toml` git tag and cut a -release together. Note the impact in this file before tagging. +`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other +GTK bread apps as a pinned git dependency. A breaking change to `Palette`, +`css_vars`, or the `gtk` feature API requires dependents to bump their +`Cargo.toml` git tag and cut a release together. Note the impact in this file +before tagging. + +**0.7.4** adds per-output bind APIs (`bind_window*`, `load_palette_for`, +`generate_output`). Apps that call those must pin `tag = "v0.7.4"`. --- diff --git a/bread-theme/src/bin/bread-theme.rs b/bread-theme/src/bin/bread-theme.rs index 266ea9c..3d7a862 100644 --- a/bread-theme/src/bin/bread-theme.rs +++ b/bread-theme/src/bin/bread-theme.rs @@ -9,6 +9,8 @@ //! # signal every running bread GUI to recolour //! bread-theme path # print the stylesheet path //! bread-theme print # render to stdout (no write) +//! bread-theme generate-output --image [--shared] +//! bread-theme generate-output --from-json [--shared] use std::process::ExitCode; @@ -25,6 +27,149 @@ fn write_and_report(verb: &str) -> ExitCode { } } +fn print_help() { + eprintln!( + "bread-theme — shared stylesheet generator\n\n\ + USAGE:\n\ + \x20 bread-theme [generate|reload|path|print]\n\ + \x20 bread-theme generate-output --image [--shared]\n\ + \x20 bread-theme generate-output --from-json [--shared]\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\n\ + generate-output write palettes/.json and themes/.css\n\ + \x20 --image isolated `wal -i` (does not touch ~/.cache/wal)\n\ + \x20 --from-json wal colors.json or a color1-6 object\n\ + \x20 --shared also write the session-global theme.css", + bread_theme::shared_css_path().display() + ); +} + +fn generate_output_cmd() -> ExitCode { + let args: Vec = std::env::args().skip(2).collect(); + if args.is_empty() + || args + .iter() + .any(|a| matches!(a.as_str(), "-h" | "--help" | "help")) + { + print_help(); + return if args.is_empty() { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }; + } + + let output = args[0].as_str(); + if output.starts_with('-') { + eprintln!("bread-theme: generate-output requires an OUTPUT name (got '{output}')"); + return ExitCode::FAILURE; + } + + let mut image: Option<&str> = None; + let mut from_json: Option<&str> = None; + let mut shared = false; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--shared" => shared = true, + "--image" => { + i += 1; + match args.get(i) { + Some(p) => image = Some(p.as_str()), + None => { + eprintln!("bread-theme: --image requires a path"); + return ExitCode::FAILURE; + } + } + } + "--from-json" => { + i += 1; + match args.get(i) { + Some(p) => from_json = Some(p.as_str()), + None => { + eprintln!("bread-theme: --from-json requires a path"); + return ExitCode::FAILURE; + } + } + } + other => { + eprintln!("bread-theme: unknown generate-output flag '{other}'"); + return ExitCode::FAILURE; + } + } + i += 1; + } + + match (image, from_json) { + (Some(_), Some(_)) => { + eprintln!("bread-theme: pass only one of --image or --from-json"); + ExitCode::FAILURE + } + (None, None) => { + eprintln!("bread-theme: generate-output needs --image or --from-json "); + ExitCode::FAILURE + } + (Some(path), None) => { + match bread_theme::generate_output(output, std::path::Path::new(path)) { + Ok(css) => finish_generate_output(output, css, shared), + Err(e) => { + eprintln!("bread-theme: generate-output failed: {e}"); + ExitCode::FAILURE + } + } + } + (None, Some(path)) => match write_output_from_json(output, path, shared) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("bread-theme: generate-output failed: {e}"); + ExitCode::FAILURE + } + }, + } +} + +fn write_output_from_json(output: &str, json_path: &str, shared: bool) -> std::io::Result<()> { + let json = std::fs::read_to_string(json_path)?; + let palette = bread_theme::palette_from_json(&json).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("could not parse palette JSON: {json_path}"), + ) + })?; + let pal_path = bread_theme::write_output_palette(output, &palette)?; + let css_path = bread_theme::write_output_css(output, &palette)?; + eprintln!( + "bread-theme: wrote {} and {}", + pal_path.display(), + css_path.display() + ); + if shared { + let shared_path = bread_theme::write_shared_css_from(&palette)?; + eprintln!("bread-theme: wrote shared {}", shared_path.display()); + } + Ok(()) +} + +fn finish_generate_output(output: &str, css: std::path::PathBuf, shared: bool) -> ExitCode { + eprintln!("bread-theme: wrote {}", css.display()); + if shared { + match bread_theme::write_shared_css_from(&bread_theme::load_palette_for(output)) { + Ok(path) => { + eprintln!("bread-theme: wrote shared {}", path.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("bread-theme: failed to write shared stylesheet: {e}"); + ExitCode::FAILURE + } + } + } else { + ExitCode::SUCCESS + } +} + fn main() -> ExitCode { let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into()); match cmd.as_str() { @@ -42,20 +187,15 @@ fn main() -> ExitCode { // 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"), + "generate-output" => generate_output_cmd(), "-h" | "--help" | "help" => { - eprintln!( - "bread-theme — shared stylesheet generator\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() - ); + print_help(); ExitCode::SUCCESS } other => { - eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)"); + eprintln!( + "bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output)" + ); ExitCode::FAILURE } } diff --git a/bread-theme/src/gtk.rs b/bread-theme/src/gtk.rs index fb759c7..43e450d 100644 --- a/bread-theme/src/gtk.rs +++ b/bread-theme/src/gtk.rs @@ -1,8 +1,18 @@ +use gtk4::gdk::prelude::*; use gtk4::gio; +use gtk4::glib::object::ObjectType; use gtk4::prelude::*; use gtk4::CssProvider; use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::rc::Rc; + +use crate::Palette; + +/// Above APPLICATION (600) so we beat [`apply_shared`], below USER (800) +/// so `apply_user_css` still wins. +const BIND_PRIORITY: u32 = gtk4::STYLE_PROVIDER_PRIORITY_USER - 10; thread_local! { static SHARED_PROVIDER: RefCell> = const { RefCell::new(None) }; @@ -14,8 +24,7 @@ thread_local! { } fn reload_shared() { - let css = std::fs::read_to_string(crate::shared_css_path()) - .unwrap_or_else(|_| crate::render()); + let css = std::fs::read_to_string(crate::shared_css_path()).unwrap_or_else(|_| crate::render()); SHARED_PROVIDER.with(|cell| apply_css(&css, cell)); } @@ -121,7 +130,10 @@ pub fn apply_css(css: &str, provider: &RefCell>) { /// breadpad, and breadman (both cream), none of which agreed with each /// other or with the shared token. pub fn chip(label: &str) -> gtk4::Button { - gtk4::Button::builder().label(label).css_classes(["chip"]).build() + gtk4::Button::builder() + .label(label) + .css_classes(["chip"]) + .build() } /// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active` @@ -137,6 +149,319 @@ pub fn set_chip_active(chip: &impl IsA, active: bool) { } } +/// Gdk connector for the monitor currently showing this widget, if any. +pub fn output_for_widget(widget: &impl IsA) -> Option { + let widget = widget.as_ref(); + let native = widget.native()?; + let surface = NativeExt::surface(&native)?; + let monitor = widget.display().monitor_at_surface(&surface)?; + monitor.connector().map(|c| c.to_string()) +} + +struct WidgetBind { + output: String, + theme: CssProvider, + app: Option, + app_build: Option String>>, + /// Keep the directory monitor + child model alive for this widget. + _watch: Option, +} + +thread_local! { + static BINDS: RefCell> = RefCell::new(HashMap::new()); + static THEMES_MONITOR: RefCell> = const { RefCell::new(None) }; + static DESTROY_HOOKED: RefCell> = RefCell::new(HashSet::new()); + static AUTO_HOOKED: RefCell> = RefCell::new(HashSet::new()); + static ENTER_HOOKED: RefCell> = RefCell::new(HashSet::new()); +} + +fn widget_key(widget: >k4::Widget) -> usize { + widget.as_ptr() as usize +} + +#[allow(deprecated)] +fn add_widget_provider(widget: >k4::Widget, provider: &CssProvider, prio: u32) { + widget.style_context().add_provider(provider, prio); +} + +/// Same `CssProvider` on the widget and its current descendants so component +/// rules actually reach buttons/labels (a style-context provider is not +/// inherited by children). +fn attach_tree(widget: >k4::Widget, theme: &CssProvider, app: Option<&CssProvider>) { + add_widget_provider(widget, theme, BIND_PRIORITY); + if let Some(app) = app { + add_widget_provider(widget, app, BIND_PRIORITY + 1); + } + let mut child = widget.first_child(); + while let Some(c) = child { + attach_tree(&c, theme, app); + child = c.next_sibling(); + } +} + +fn ensure_destroy_cleanup(widget: >k4::Widget) { + let key = widget_key(widget); + let inserted = DESTROY_HOOKED.with(|s| s.borrow_mut().insert(key)); + if !inserted { + return; + } + widget.connect_destroy(move |w| { + let key = widget_key(w); + BINDS.with(|b| { + b.borrow_mut().remove(&key); + }); + DESTROY_HOOKED.with(|s| { + s.borrow_mut().remove(&key); + }); + AUTO_HOOKED.with(|s| { + s.borrow_mut().remove(&key); + }); + }); +} + +fn ensure_themes_watch() { + THEMES_MONITOR.with(|cell| { + if cell.borrow().is_some() { + return; + } + let dir = crate::themes_dir(); + let _ = std::fs::create_dir_all(&dir); + let monitor = gio::File::for_path(&dir) + .monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE) + .ok(); + if let Some(ref m) = monitor { + m.connect_changed(move |_, file, other, _event| { + let path = file.path().or_else(|| other.and_then(|f| f.path())); + let Some(path) = path else { + return; + }; + if path.extension().and_then(|e| e.to_str()) != Some("css") { + return; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + return; + }; + reload_binds_for_sanitized(stem); + }); + } + *cell.borrow_mut() = monitor; + }); +} + +fn reload_binds_for_sanitized(sanitized: &str) { + BINDS.with(|binds| { + for bind in binds.borrow_mut().values_mut() { + if crate::sanitize_output(&bind.output) != sanitized { + continue; + } + let palette = crate::load_palette_for(&bind.output); + bind.theme + .load_from_string(&crate::stylesheet_resolved(&palette)); + if let (Some(build), Some(provider)) = (&bind.app_build, &bind.app) { + provider.load_from_string(&crate::resolve_color_names(&build(&palette), &palette)); + } + } + }); +} + +fn watch_root_children(widget: >k4::Widget) -> gio::ListModel { + let model = widget.observe_children(); + let root = widget.downgrade(); + model.connect_items_changed(move |_, _, _, _| { + let Some(root) = root.upgrade() else { + return; + }; + let key = widget_key(&root); + BINDS.with(|binds| { + if let Some(bind) = binds.borrow().get(&key) { + attach_tree(&root, &bind.theme, bind.app.as_ref()); + } + }); + }); + model +} + +fn bind_window_inner( + widget: >k4::Widget, + output: &str, + app_build: Option String>>, +) { + let key = widget_key(widget); + let palette = crate::load_palette_for(output); + let theme_css = crate::stylesheet_resolved(&palette); + let app_css = app_build + .as_ref() + .map(|build| crate::resolve_color_names(&build(&palette), &palette)); + + BINDS.with(|binds| { + let mut map = binds.borrow_mut(); + if let Some(existing) = map.get_mut(&key) { + existing.output = output.to_string(); + existing.theme.load_from_string(&theme_css); + existing.app_build = app_build.clone(); + match (&app_css, existing.app.as_ref()) { + (Some(css), Some(p)) => p.load_from_string(css), + (Some(css), None) => { + let p = CssProvider::new(); + p.load_from_string(css); + add_widget_provider(widget, &p, BIND_PRIORITY + 1); + existing.app = Some(p); + } + (None, Some(p)) => p.load_from_string(""), + (None, None) => {} + } + attach_tree(widget, &existing.theme, existing.app.as_ref()); + return; + } + + let theme = CssProvider::new(); + theme.load_from_string(&theme_css); + add_widget_provider(widget, &theme, BIND_PRIORITY); + + let app = app_css.map(|css| { + let p = CssProvider::new(); + p.load_from_string(&css); + add_widget_provider(widget, &p, BIND_PRIORITY + 1); + p + }); + + attach_tree(widget, &theme, app.as_ref()); + + let child_model = watch_root_children(widget); + map.insert( + key, + WidgetBind { + output: output.to_string(), + theme, + app, + app_build, + _watch: Some(child_model), + }, + ); + }); + + ensure_destroy_cleanup(widget); + ensure_themes_watch(); + ensure_map_reattach(widget); +} + +fn ensure_map_reattach(widget: >k4::Widget) { + // `connect_map` once per widget — re-bind already lives in BINDS. + thread_local! { + static MAP_HOOKED: RefCell> = RefCell::new(HashSet::new()); + } + let key = widget_key(widget); + let inserted = MAP_HOOKED.with(|s| s.borrow_mut().insert(key)); + if !inserted { + return; + } + widget.connect_map(|w| { + BINDS.with(|binds| { + if let Some(bind) = binds.borrow().get(&widget_key(w)) { + attach_tree(w, &bind.theme, bind.app.as_ref()); + } + }); + }); + widget.connect_destroy(move |_| { + MAP_HOOKED.with(|s| { + s.borrow_mut().remove(&key); + }); + }); +} + +/// Attach a widget-level `CssProvider` with +/// `stylesheet_resolved(load_palette_for(output))` above APPLICATION so it +/// beats [`apply_shared`] for this widget tree. User CSS still wins. +/// Calling again on the same widget replaces the provider; it does not stack. +pub fn bind_window(widget: &impl IsA, output: &str) { + bind_window_inner(widget.as_ref(), output, None); +} + +/// [`bind_window`], then also apply `build(&palette)` on the same widget. +/// App CSS may still use `@accent` etc.; those names are inlined against +/// the same palette before loading. +pub fn bind_window_with_app_css(widget: &impl IsA, output: &str, build: F) +where + F: Fn(&Palette) -> String + 'static, +{ + bind_window_inner(widget.as_ref(), output, Some(Rc::new(build))); +} + +fn attach_enter_monitor(widget: >k4::Widget, build: Option String>>) { + let Some(native) = widget.native() else { + return; + }; + let Some(surface) = NativeExt::surface(&native) else { + return; + }; + let surf_key = surface.as_ptr() as usize; + let already = ENTER_HOOKED.with(|s| !s.borrow_mut().insert(surf_key)); + if already { + return; + } + let widget = widget.clone(); + surface.connect_enter_monitor(move |_, monitor| { + let Some(conn) = monitor.connector() else { + return; + }; + bind_window_inner(&widget, conn.as_str(), build.clone()); + }); +} + +fn bind_auto(native: >k4::Native, build: Option String>>) { + let widget = native.upcast_ref::().clone(); + + let apply = { + let widget = widget.clone(); + let build = build.clone(); + Rc::new(move || { + if let Some(output) = output_for_widget(&widget) { + bind_window_inner(&widget, &output, build.clone()); + } + }) + }; + + apply(); + + let key = widget_key(&widget); + let inserted = AUTO_HOOKED.with(|s| s.borrow_mut().insert(key)); + if inserted { + widget.connect_realize({ + let apply = apply.clone(); + let widget = widget.clone(); + let build = build.clone(); + move |_| { + apply(); + attach_enter_monitor(&widget, build.clone()); + } + }); + widget.connect_map({ + let apply = apply.clone(); + move |_| apply() + }); + ensure_destroy_cleanup(&widget); + } + + if widget.is_realized() { + attach_enter_monitor(&widget, build); + } +} + +/// Realize + `GdkSurface::enter-monitor`: rebind when the window moves +/// outputs. If the connector is unknown, leave unbound (display fallback) +/// rather than guessing the wrong monitor. +pub fn bind_window_auto(window: &impl IsA) { + bind_auto(window.as_ref(), None); +} + +/// [`bind_window_auto`] plus per-output app CSS, resolved to hex. +pub fn bind_window_auto_with_app_css(window: &impl IsA, build: F) +where + F: Fn(&Palette) -> String + 'static, +{ + bind_auto(window.as_ref(), Some(Rc::new(build))); +} + /// Apply a user CSS override file at USER priority. Clears the provider if the /// file is absent so stale overrides don't persist across SIGHUP reloads. pub fn apply_user_css(path: &Path, provider: &RefCell>) { diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 15058fb..f280027 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -1,9 +1,15 @@ -pub mod palette; -#[cfg(feature = "gtk")] -pub mod gtk; #[cfg(feature = "adw")] pub mod adw; +#[cfg(feature = "gtk")] +pub mod gtk; +mod output; +pub mod palette; +pub use output::{ + generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image, + palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css, + write_output_palette, write_shared_css_from, +}; pub use palette::{load_palette, Palette}; /// Design tokens from BREAD_DESIGN_SYSTEM.md. @@ -53,7 +59,11 @@ 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) } + 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) } @@ -64,7 +74,11 @@ pub fn luminance(hex: &str) -> f32 { /// 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" } + if luminance(hex) > 0.179 { + "#11111b" + } else { + "#f5f5f5" + } } /// Canonical (name, value) list: the single naming all bread apps share. @@ -143,9 +157,18 @@ pub fn css_tokens() -> String { \x20\x20--radius-tertiary: {r3}px;\n\ \x20\x20--radius-pill: {pill}px;\n\ }}\n", - font = FONT_FAMILY, base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY, - xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xl = SPACE_XL, - r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, r3 = RADIUS_TERTIARY, pill = RADIUS_PILL, + font = FONT_FAMILY, + base = FONT_SIZE_BASE, + sec = FONT_SIZE_SECONDARY, + xs = SPACE_XS, + sm = SPACE_SM, + md = SPACE_MD, + lg = SPACE_LG, + xl = SPACE_XL, + r1 = RADIUS_PRIMARY, + r2 = RADIUS_SECONDARY, + r3 = RADIUS_TERTIARY, + pill = RADIUS_PILL, ) } @@ -261,28 +284,33 @@ pub fn render() -> String { /// `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") + output::runtime_bread_dir().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)?; + write_shared_css_from(&load_palette()) +} + +/// `stylesheet()` with `@name` references in rule bodies replaced by hex. +/// Longer names first (`on-surface` before `surface`, `on-bg` before `bg`) +/// so a prefix match cannot half-replace `@on-bg`. +pub fn stylesheet_resolved(p: &Palette) -> String { + resolve_color_names(&stylesheet(p), p) +} + +/// Replace `@define-color` names (`@accent`, `@on-bg`, …) with hex values. +/// Used by [`stylesheet_resolved`] and by GTK `bind_window` so display-global +/// named colors cannot leak the wrong monitor's accent. +pub(crate) fn resolve_color_names(css: &str, p: &Palette) -> String { + let mut pairs: Vec<(&str, String)> = color_pairs(p).into_iter().collect(); + pairs.sort_by(|a, b| b.0.len().cmp(&a.0.len())); + let mut out = css.to_string(); + for (name, value) in pairs { + out = out.replace(&format!("@{name}"), &value); } - let tmp = path.with_extension("css.tmp"); - std::fs::write(&tmp, render())?; - std::fs::rename(&tmp, &path)?; - Ok(path) + out } /// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`. @@ -301,8 +329,13 @@ mod tests { #[test] fn css_vars_contains_all_define_color_names() { let css = css_vars(&Palette::default()); - for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] { - assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + for name in &[ + "bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay", + ] { + assert!( + css.contains(&format!("@define-color {name} ")), + "missing @define-color {name}" + ); } } @@ -322,8 +355,18 @@ mod tests { // color name — the illegible-text bug. css_vars() must now emit // exactly the same color set as the full stylesheet. let css = css_vars(&Palette::default()); - for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] { - assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + for name in &[ + "accent", + "on-bg", + "on-surface", + "on-accent", + "on-red", + "on-overlay", + ] { + assert!( + css.contains(&format!("@define-color {name} ")), + "missing @define-color {name}" + ); } } @@ -334,7 +377,16 @@ mod tests { let p = Palette::default(); let vars = css_vars(&p); let sheet = stylesheet(&p); - for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] { + for name in &[ + "bg", + "fg", + "surface", + "overlay", + "accent", + "on-bg", + "on-surface", + "on-accent", + ] { let needle = format!("@define-color {name} "); assert!(vars.contains(&needle) && sheet.contains(&needle)); } @@ -344,10 +396,21 @@ mod tests { 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}"); + 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", ".page-title"] { + for sel in &[ + "button", + "entry", + "switch:checked", + ".card", + ".sidebar", + "scrollbar slider", + ".page-title", + ] { assert!(css.contains(sel), "stylesheet missing selector: {sel}"); } assert!(css.contains("Varela Round")); @@ -362,7 +425,10 @@ mod tests { let gtk = define_colors(&p); let web = css_custom_properties(&p); for (name, _) in color_pairs(&p) { - assert!(gtk.contains(&format!("@define-color {name} ")), "gtk missing {name}"); + assert!( + gtk.contains(&format!("@define-color {name} ")), + "gtk missing {name}" + ); assert!(web.contains(&format!("--{name}: ")), "web missing {name}"); } } @@ -409,7 +475,10 @@ mod tests { 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}"); + assert!( + css.contains(&format!("@define-color {name} ")), + "missing @define-color {name}" + ); } } @@ -418,13 +487,58 @@ mod tests { // 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"); + assert!( + !css.contains("label { color:"), + "blanket label colour rule reintroduced" + ); } #[test] fn shared_css_path_uses_runtime_dir() { + let _lock = crate::output::XDG_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); 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")); + assert_eq!( + shared_css_path(), + std::path::PathBuf::from("/run/user/1234/bread/theme.css") + ); + } + + #[test] + fn stylesheet_resolved_inlines_color4_and_drops_named_refs_in_rules() { + let mut p = Palette::default(); + p.color4 = "#7aa2f7".into(); + let css = stylesheet_resolved(&p); + assert!(css.contains("#7aa2f7"), "color4 must appear as hex: {css}"); + // Rule bodies must not keep named colors — GTK display-global + // @define-color would otherwise leak the wrong monitor's accent. + let rules = css + .lines() + .filter(|l| !l.trim_start().starts_with("@define-color")) + .collect::>() + .join("\n"); + assert!( + !rules.contains("@accent"), + "leftover @accent in rules:\n{rules}" + ); + assert!( + !rules.contains("@on-bg"), + "leftover @on-bg in rules:\n{rules}" + ); + assert!( + !rules.contains("@on-surface"), + "leftover @on-surface in rules:\n{rules}" + ); + assert!( + !rules.contains("@on-accent"), + "leftover @on-accent in rules:\n{rules}" + ); + // Longer names first: @on-bg must not become @on-#... + assert!( + !rules.contains("@on-#"), + "half-replaced on-* name:\n{rules}" + ); } #[test] diff --git a/bread-theme/src/output.rs b/bread-theme/src/output.rs new file mode 100644 index 0000000..4f2f069 --- /dev/null +++ b/bread-theme/src/output.rs @@ -0,0 +1,338 @@ +//! Per-output (per-monitor) palette and stylesheet paths under +//! `$XDG_RUNTIME_DIR/bread/{palettes,themes}/`. + +use serde::Serialize; +use std::path::{Path, PathBuf}; + +use crate::palette::{from_wal_json, Palette}; +use crate::{load_palette, stylesheet}; + +/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`]. +pub(crate) fn runtime_bread_dir() -> PathBuf { + if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") { + if !rt.is_empty() { + return PathBuf::from(rt).join("bread"); + } + } + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("bread") +} + +/// Keep `[A-Za-z0-9._-]`; replace everything else with `_`. +pub fn sanitize_output(output: &str) -> String { + let s: String = output + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect(); + if s.is_empty() { + "_".into() + } else { + s + } +} + +pub fn themes_dir() -> PathBuf { + runtime_bread_dir().join("themes") +} + +pub fn palettes_dir() -> PathBuf { + runtime_bread_dir().join("palettes") +} + +pub fn output_css_path(output: &str) -> PathBuf { + themes_dir().join(format!("{}.css", sanitize_output(output))) +} + +pub fn output_palette_path(output: &str) -> PathBuf { + palettes_dir().join(format!("{}.json", sanitize_output(output))) +} + +fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => path.with_file_name(format!("{name}.tmp")), + None => path.with_extension("tmp"), + }; + std::fs::write(&tmp, contents)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +/// Accents only — never persist pywal's light background/surface/overlay/fg. +#[derive(Serialize)] +struct StoredColors { + color1: String, + color2: String, + color3: String, + color4: String, + color5: String, + color6: String, +} + +#[derive(Serialize)] +struct StoredPalette { + colors: StoredColors, +} + +/// Parse on-disk JSON: wal `colors.json` shape, or a flat `{color1..color6}` object. +/// Always forces FIXED background/foreground/color0/color7 via [`from_wal_json`]. +pub fn palette_from_json(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + if value + .get("colors") + .and_then(|c| c.as_object()) + .is_some_and(|o| !o.is_empty()) + { + return from_wal_json(json); + } + if value.get("color1").is_some() + || value.get("color2").is_some() + || value.get("color3").is_some() + || value.get("color4").is_some() + || value.get("color5").is_some() + || value.get("color6").is_some() + { + let wrapped = serde_json::json!({ "colors": value }); + return from_wal_json(&wrapped.to_string()); + } + from_wal_json(json) +} + +/// Load `palettes/.json`; fall back to [`load_palette`]. +pub fn load_palette_for(output: &str) -> Palette { + std::fs::read_to_string(output_palette_path(output)) + .ok() + .and_then(|s| palette_from_json(&s)) + .unwrap_or_else(load_palette) +} + +pub fn write_output_palette(output: &str, palette: &Palette) -> std::io::Result { + let path = output_palette_path(output); + let stored = StoredPalette { + colors: StoredColors { + color1: palette.color1.clone(), + color2: palette.color2.clone(), + color3: palette.color3.clone(), + color4: palette.color4.clone(), + color5: palette.color5.clone(), + color6: palette.color6.clone(), + }, + }; + let json = serde_json::to_string_pretty(&stored) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + atomic_write(&path, &json)?; + Ok(path) +} + +pub fn write_output_css(output: &str, palette: &Palette) -> std::io::Result { + let path = output_css_path(output); + atomic_write(&path, &stylesheet(palette))?; + Ok(path) +} + +/// Like [`crate::write_shared_css`] but from an explicit palette. +pub fn write_shared_css_from(palette: &Palette) -> std::io::Result { + let path = crate::shared_css_path(); + atomic_write(&path, &stylesheet(palette))?; + Ok(path) +} + +/// Isolated `wal -i -n -q` with `XDG_CACHE_HOME` set to a unique temp +/// dir so the user's `~/.cache/wal` is not clobbered. +pub fn palette_from_image(path: &Path) -> std::io::Result { + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let tmp = std::env::temp_dir().join(format!("bread-theme-wal-{pid}-{nanos}")); + std::fs::create_dir_all(&tmp)?; + struct Rm(PathBuf); + impl Drop for Rm { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _guard = Rm(tmp.clone()); + + // Classic pywal ignores XDG_CACHE_HOME and writes $HOME/.cache/wal. + // Point HOME at the temp dir so a per-output extract cannot clobber + // the session cache (or the other monitor's last `wal -i`). + let status = match std::process::Command::new("wal") + .arg("-i") + .arg(path) + .args(["-n", "-q"]) + .env("HOME", &tmp) + .env("XDG_CACHE_HOME", tmp.join(".cache")) + .status() + { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "wal is not installed", + )); + } + Err(e) => return Err(e), + Ok(s) => s, + }; + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("wal failed with {status}"), + )); + } + + let json_path = [ + tmp.join(".cache").join("wal").join("colors.json"), + tmp.join("wal").join("colors.json"), + ] + .into_iter() + .find(|p| p.is_file()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "wal did not write colors.json under the isolated cache", + ) + })?; + let json = std::fs::read_to_string(&json_path)?; + from_wal_json(&json).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "wal produced unparseable colors.json", + ) + }) +} + +/// [`palette_from_image`] + [`write_output_palette`] + [`write_output_css`]. +pub fn generate_output(output: &str, image: &Path) -> std::io::Result { + let palette = palette_from_image(image)?; + write_output_palette(output, &palette)?; + write_output_css(output, &palette) +} + +#[cfg(test)] +pub(crate) static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + use crate::palette::{FIXED_BACKGROUND, FIXED_FOREGROUND, FIXED_OVERLAY, FIXED_SURFACE}; + + fn lock_xdg() -> std::sync::MutexGuard<'static, ()> { + XDG_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn with_runtime_dir(f: impl FnOnce(&Path) -> T) -> T { + let _lock = lock_xdg(); + let dir = std::env::temp_dir().join(format!( + "bread-theme-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let old = std::env::var("XDG_RUNTIME_DIR").ok(); + std::env::set_var("XDG_RUNTIME_DIR", &dir); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir))); + match old { + Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v), + None => std::env::remove_var("XDG_RUNTIME_DIR"), + } + let _ = std::fs::remove_dir_all(&dir); + match result { + Ok(v) => v, + Err(e) => std::panic::resume_unwind(e), + } + } + + #[test] + fn sanitize_output_keeps_hyprland_connectors() { + assert_eq!(sanitize_output("HDMI-A-1"), "HDMI-A-1"); + assert_eq!(sanitize_output("eDP-1"), "eDP-1"); + assert_eq!(sanitize_output("DP-2"), "DP-2"); + } + + #[test] + fn sanitize_output_replaces_unsafe_chars() { + assert_eq!(sanitize_output("HDMI A:1"), "HDMI_A_1"); + assert_eq!(sanitize_output("foo/bar"), "foo_bar"); + assert_eq!(sanitize_output(""), "_"); + assert_eq!(sanitize_output("..ok_name-1"), "..ok_name-1"); + } + + #[test] + fn output_paths_use_sanitize_and_sit_under_dirs() { + let _lock = lock_xdg(); + std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234"); + let css = output_css_path("HDMI A:1"); + let pal = output_palette_path("HDMI A:1"); + assert_eq!(css, themes_dir().join("HDMI_A_1.css")); + assert_eq!(pal, palettes_dir().join("HDMI_A_1.json")); + assert!(css.starts_with(themes_dir())); + assert!(pal.starts_with(palettes_dir())); + assert_eq!( + output_css_path("eDP-1"), + PathBuf::from("/run/user/1234/bread/themes/eDP-1.css") + ); + } + + #[test] + fn load_palette_for_missing_file_has_fixed_bg() { + with_runtime_dir(|_| { + let p = load_palette_for("no-such-output"); + assert_eq!(p.background, FIXED_BACKGROUND); + assert!(p.color4.starts_with('#')); + }); + } + + #[test] + fn write_output_palette_roundtrips_color4() { + with_runtime_dir(|_| { + let mut p = Palette::default(); + p.color4 = "#7aa2f7".into(); + p.background = "#ffffff".into(); + write_output_palette("HDMI-A-1", &p).unwrap(); + let loaded = load_palette_for("HDMI-A-1"); + assert_eq!(loaded.color4, "#7aa2f7"); + assert_eq!(loaded.background, FIXED_BACKGROUND); + assert_eq!(loaded.foreground, FIXED_FOREGROUND); + assert_eq!(loaded.color0, FIXED_SURFACE); + assert_eq!(loaded.color7, FIXED_OVERLAY); + }); + } + + #[test] + fn write_shared_css_from_writes_shared_css_path() { + with_runtime_dir(|rt| { + let path = write_shared_css_from(&Palette::default()).unwrap(); + assert_eq!(path, crate::shared_css_path()); + assert_eq!(path, rt.join("bread").join("theme.css")); + let css = std::fs::read_to_string(&path).unwrap(); + assert!(css.contains("@define-color accent ")); + }); + } + + #[test] + fn load_palette_for_accepts_flat_color_object() { + with_runtime_dir(|_| { + let path = output_palette_path("DP-1"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, r##"{"color4":"#112233","color1":"#abcdef"}"##).unwrap(); + let p = load_palette_for("DP-1"); + assert_eq!(p.color4, "#112233"); + assert_eq!(p.color1, "#abcdef"); + assert_eq!(p.background, FIXED_BACKGROUND); + }); + } +} diff --git a/bread-theme/src/palette.rs b/bread-theme/src/palette.rs index 85a8aa7..e51f6b0 100644 --- a/bread-theme/src/palette.rs +++ b/bread-theme/src/palette.rs @@ -9,10 +9,10 @@ use std::path::PathBuf; /// off-hue background, and every bread GUI's panels inherit it — the app /// stops looking like a dark BOS tool and starts looking like whatever colour /// the wallpaper happened to be. -const FIXED_BACKGROUND: &str = "#0c0c0c"; -const FIXED_FOREGROUND: &str = "#e8e8e8"; -const FIXED_SURFACE: &str = "#1a1a1a"; -const FIXED_OVERLAY: &str = "#d8d8d8"; +pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c"; +pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8"; +pub(crate) const FIXED_SURFACE: &str = "#1a1a1a"; +pub(crate) const FIXED_OVERLAY: &str = "#d8d8d8"; /// Accent fallback when no pywal palette exists yet (fresh install, before /// any wallpaper has been set for real) — BOS's own bread-toned accents, @@ -84,7 +84,10 @@ pub fn load_palette() -> Palette { pub(crate) fn from_wal_json(json: &str) -> Option { let wal: WalColors = serde_json::from_str(json).ok()?; let c = |k: &str, fallback: &str| -> String { - wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into()) + wal.colors + .get(k) + .cloned() + .unwrap_or_else(|| fallback.into()) }; Some(Palette { background: FIXED_BACKGROUND.into(), From a9754d90ed32efcc26765abd01c9f441bfb01b1e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:28:18 +0800 Subject: [PATCH 2/6] Lock workspace packages at 0.7.4 so --locked release builds match the tag --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 070c24d..feaba0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,7 +138,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -187,14 +187,14 @@ dependencies = [ [[package]] name = "bread-app" -version = "0.7.2" +version = "0.7.4" dependencies = [ "bread-utils", ] [[package]] name = "bread-capture" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -204,7 +204,7 @@ dependencies = [ [[package]] name = "bread-onnx" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "bread-polkit" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-app", @@ -234,7 +234,7 @@ dependencies = [ [[package]] name = "bread-screenshots" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -254,7 +254,7 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" +version = "0.7.4" dependencies = [ "dirs", "gtk4", @@ -265,7 +265,7 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.7.2" +version = "0.7.4" dependencies = [ "bread-shared", "dirs", From e2c6452e4f9166be6b057fbedce9796d0029d486 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:37:08 +0800 Subject: [PATCH 3/6] ci: export VERSION for rc sign steps and drop unsafe tag path filters RC sign steps read ${VERSION} from the environment, but prepare only set it locally. Export it via GITHUB_ENV like dev-bakery.yml. Drop paths: filters on tag-triggered rc workflows (they pointed at the old beta-*.yml names and can skip an RC publish when the tag diff misses those paths); the job if: contains -rc. is the real gate. Skip -rc. tags in package.yml because PKGBUILD pkgver cannot contain a hyphen. Rebuild bakery on bread-utils changes (path dependency). --- .forgejo/workflows/dev-bakery.yml | 1 + .forgejo/workflows/package.yml | 3 +++ .forgejo/workflows/rc-bakery.yml | 9 ++++----- .forgejo/workflows/rc-bread-theme.yml | 9 ++++----- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/dev-bakery.yml b/.forgejo/workflows/dev-bakery.yml index f63769e..a7c4bde 100644 --- a/.forgejo/workflows/dev-bakery.yml +++ b/.forgejo/workflows/dev-bakery.yml @@ -8,6 +8,7 @@ on: branches: ['main'] paths: - 'bakery/**' + - 'bread-utils/**' - 'Cargo.toml' - 'Cargo.lock' - '.forgejo/workflows/dev-bakery.yml' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 6725e22..ca941f2 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -6,6 +6,9 @@ on: jobs: package: + # PKGBUILD pkgver cannot contain `-`; skip RC tags the same way + # release-bakery.yml does. + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] container: image: archlinux:latest diff --git a/.forgejo/workflows/rc-bakery.yml b/.forgejo/workflows/rc-bakery.yml index dc7f695..d4e7853 100644 --- a/.forgejo/workflows/rc-bakery.yml +++ b/.forgejo/workflows/rc-bakery.yml @@ -7,11 +7,9 @@ name: beta (rc) bakery on: push: tags: ['v*'] - paths: - - 'bakery/**' - - 'Cargo.toml' - - 'Cargo.lock' - - '.forgejo/workflows/beta-bakery.yml' + # No paths: filter. Tag pushes compare against an unrelated commit and + # would skip the RC publish if bakery/** wasn't in that diff; the job + # `if: contains -rc.` is the real gate. jobs: build: @@ -35,6 +33,7 @@ jobs: run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" diff --git a/.forgejo/workflows/rc-bread-theme.yml b/.forgejo/workflows/rc-bread-theme.yml index c364ce6..bd53ba1 100644 --- a/.forgejo/workflows/rc-bread-theme.yml +++ b/.forgejo/workflows/rc-bread-theme.yml @@ -7,11 +7,9 @@ name: beta (rc) bread-theme on: push: tags: ['v*'] - paths: - - 'bread-theme/**' - - 'Cargo.toml' - - 'Cargo.lock' - - '.forgejo/workflows/beta-bread-theme.yml' + # No paths: filter. Tag pushes compare against an unrelated commit and + # would skip the RC publish if bread-theme/** wasn't in that diff; the + # job `if: contains -rc.` is the real gate. jobs: build: @@ -32,6 +30,7 @@ jobs: run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" From 4f8859527d0aab43912799528fef2af60574f334 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:38:04 +0800 Subject: [PATCH 4/6] bread-utils: pin bread-shared to v0.8.0 The bread-client feature still resolved bread-shared from tag v0.7.0. Point the git dependency at v0.8.0 and refresh Cargo.lock. --- Cargo.lock | 50 +++++++++++++++++++++++++++++++++++------- bread-utils/Cargo.toml | 2 +- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index feaba0f..0598260 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ dependencies = [ "chrono", "clap", "clap_complete", - "dirs", + "dirs 5.0.1", "fs4", "hex", "minisign-verify", @@ -243,20 +243,21 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +version = "0.8.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704" dependencies = [ - "dirs", + "dirs 6.0.0", "serde", "serde_json", "toml 0.8.23", + "uuid", ] [[package]] name = "bread-theme" version = "0.7.4" dependencies = [ - "dirs", + "dirs 5.0.1", "gtk4", "libadwaita", "serde", @@ -268,7 +269,7 @@ name = "bread-utils" version = "0.7.4" dependencies = [ "bread-shared", - "dirs", + "dirs 5.0.1", "gtk4", "gtk4-layer-shell", "serde", @@ -610,7 +611,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -621,10 +631,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -1966,6 +1988,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.13.1" @@ -2703,6 +2736,7 @@ version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", diff --git a/bread-utils/Cargo.toml b/bread-utils/Cargo.toml index 69e2172..a0ab6c2 100644 --- a/bread-utils/Cargo.toml +++ b/bread-utils/Cargo.toml @@ -15,7 +15,7 @@ dirs = { workspace = true } gtk4 = { version = "0.11", features = ["v4_12"], optional = true } gtk4-layer-shell = { version = "0.8", optional = true } toml_edit = { version = "0.22", optional = true } -bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0", optional = true } [features] # Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional From f5a47490f70a66d201b38afa14814a2c45f127a7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:38:04 +0800 Subject: [PATCH 5/6] bread-theme: quote only the named font so sans-serif stays a fallback FONT_FAMILY is "Varela Round, sans-serif" but emission wrapped the whole string in quotes, so CSS looked up one family named that string. Emit 'Varela Round', sans-serif instead and lock that in the tests. --- bread-theme/src/lib.rs | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index f280027..2111c17 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -32,6 +32,13 @@ pub mod tokens { pub const RADIUS_PILL: u16 = 999; } +/// CSS `font-family` list: quote the named face, leave the generic fallback +/// unquoted. Wrapping [`tokens::FONT_FAMILY`] in one pair of quotes would +/// make a single family named "Varela Round, sans-serif" and drop sans-serif. +fn css_font_family() -> &'static str { + "'Varela Round', sans-serif" +} + /// Emit the `@define-color` block that all bread apps use, plus the shared /// font rule. /// @@ -47,9 +54,9 @@ pub mod tokens { /// one color-block implementation and it cannot drift again. pub fn css_vars(p: &Palette) -> String { format!( - "{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n", + "{vars}* {{ font-family: {font}; font-size: {size}px; }}\n", vars = define_colors(p), - font = tokens::FONT_FAMILY, + font = css_font_family(), size = tokens::FONT_SIZE_BASE, ) } @@ -144,7 +151,7 @@ pub fn css_tokens() -> String { use tokens::*; format!( ":root {{\n\ - \x20\x20--font-family: '{font}';\n\ + \x20\x20--font-family: {font};\n\ \x20\x20--font-size-base: {base}px;\n\ \x20\x20--font-size-secondary: {sec}px;\n\ \x20\x20--space-xs: {xs}px;\n\ @@ -157,7 +164,7 @@ pub fn css_tokens() -> String { \x20\x20--radius-tertiary: {r3}px;\n\ \x20\x20--radius-pill: {pill}px;\n\ }}\n", - font = FONT_FAMILY, + font = css_font_family(), base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY, xs = SPACE_XS, @@ -182,7 +189,7 @@ pub fn stylesheet(p: &Palette) -> String { use tokens::*; format!( "{vars}\ - * {{ font-family: '{font}'; font-size: {base}px; }}\n\ + * {{ font-family: {font}; font-size: {base}px; }}\n\ /* 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\ @@ -265,7 +272,7 @@ pub fn stylesheet(p: &Palette) -> String { textview, .mono {{ font-family: monospace; }}\n\ textview text {{ background-color: @surface; color: @on-surface; }}\n", vars = define_colors(p), - font = FONT_FAMILY, + font = css_font_family(), base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY, xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, @@ -342,7 +349,11 @@ mod tests { #[test] fn css_vars_contains_font_rule() { let css = css_vars(&Palette::default()); - assert!(css.contains("Varela Round")); + assert!(css.contains("font-family: 'Varela Round', sans-serif;")); + assert!( + !css.contains("font-family: 'Varela Round, sans-serif'"), + "named face and generic fallback must not be one quoted family" + ); assert!(css.contains("14px")); } @@ -413,7 +424,11 @@ mod tests { ] { assert!(css.contains(sel), "stylesheet missing selector: {sel}"); } - assert!(css.contains("Varela Round")); + assert!(css.contains("font-family: 'Varela Round', sans-serif;")); + assert!( + !css.contains("font-family: 'Varela Round, sans-serif'"), + "named face and generic fallback must not be one quoted family" + ); } #[test] @@ -445,7 +460,11 @@ mod tests { #[test] fn css_tokens_contains_font_and_spacing_vars() { let css = css_tokens(); - assert!(css.contains("--font-family: 'Varela Round, sans-serif';")); + assert!(css.contains("--font-family: 'Varela Round', sans-serif;")); + assert!( + !css.contains("--font-family: 'Varela Round, sans-serif'"), + "named face and generic fallback must not be one quoted family" + ); assert!(css.contains("--font-size-base: 14px;")); assert!(css.contains("--space-md: 12px;")); assert!(css.contains("--radius-pill: 999px;")); From 347f356b1ddac442fe4b5dd7f23349a591351e74 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:38:04 +0800 Subject: [PATCH 6/6] bread-polkit: add bakery.toml so the agent can be published later Not added to registry/bread-ecosystem.toml: that would put it on the bakery index (and risk the BOS ISO) without a lockfile update. bakery.toml declares the binary and contrib desktop file; README/CONTRIBUTING note that it stays unpublished. --- CONTRIBUTING.md | 4 +++- README.md | 8 +++++--- bread-polkit/bakery.toml | 11 +++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 bread-polkit/bakery.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb4d0e1..349d06b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,9 @@ are `bakery` (the ecosystem package manager) and `bread-theme` (the shared theming crate). Shared crates that sibling apps pin — not bakery packages of their own — are `bread-utils`, `bread-app`, `bread-onnx`, `bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree -session agent, also not a bakery product. Other ecosystem products +session agent: it has `bread-polkit/bakery.toml` so it *can* be published, +but it is not in `registry/bread-ecosystem.toml` (unpublished — not on +the bakery index, not on the BOS ISO). Other ecosystem products (`bread`, `breadbar`, `breadbox`, …) live in their own repos under `Breadway/` but follow the same workflow described here. The product list is `registry/bread-ecosystem.toml`. New GTK tools should depend on diff --git a/README.md b/README.md index 34ccf27..927c629 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ bread-ecosystem/ ├── bread-theme/ # shared pywal + fixed-dark-base theming crate ├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …) ├── bread-app/ # GTK bootstrap new tools should use (app id, singleton, overlay, command listen) -├── bread-polkit/ # themed PolicyKit authentication agent (not a bakery product) +├── bread-polkit/ # themed PolicyKit agent (bakery.toml present; unpublished) ├── bread-onnx/ # shared ONNX runtime helpers ├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes ├── bread-capture/ # orchestrator that drives those `--screenshot` modes @@ -179,8 +179,10 @@ tree; `bread-polkit` is the first in-tree consumer. ### bread-polkit A session PolicyKit authentication agent (password prompt, cancel, -identity). Not a wrapper around `polkit-gnome`. Not published via bakery -and not on the BOS ISO lockfile. +identity). Not a wrapper around `polkit-gnome`. `bread-polkit/bakery.toml` +exists so it can be published via bakery; it is not in +`registry/bread-ecosystem.toml` and is therefore unpublished — not on the +bakery index and not on the BOS ISO lockfile. ```sh cargo run -p bread-polkit diff --git a/bread-polkit/bakery.toml b/bread-polkit/bakery.toml new file mode 100644 index 0000000..4dd8e44 --- /dev/null +++ b/bread-polkit/bakery.toml @@ -0,0 +1,11 @@ +name = "bread-polkit" +description = "Themed PolicyKit authentication agent for the bread desktop" +binaries = ["bread-polkit"] +system_deps = ["gtk4", "gtk4-layer-shell", "polkit"] +optional_system_deps = ["hyprland"] +bread_deps = [] +license_file = "LICENSE" +desktop_file = "bread-polkit.desktop" + +[install] +post_install = []