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(),