diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 39930a1..8dc41e7 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -18,3 +18,9 @@ gtk4 = { version = "0.11", features = ["v4_12"], optional = true } # Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this). # bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature. gtk = ["dep:gtk4"] + +# The generator CLI. It only touches the gtk-free lib API (render + write), so +# it builds without the gtk feature and stays light. +[[bin]] +name = "bread-theme" +path = "src/bin/bread-theme.rs" diff --git a/bread-theme/src/bin/bread-theme.rs b/bread-theme/src/bin/bread-theme.rs new file mode 100644 index 0000000..e79d5c0 --- /dev/null +++ b/bread-theme/src/bin/bread-theme.rs @@ -0,0 +1,50 @@ +//! `bread-theme` — generates the ecosystem's shared GTK stylesheet from the +//! current pywal palette and writes it to the canonical path that every bread +//! GUI loads. Run it at session start, and again after the wallpaper/palette +//! changes (e.g. from a pywal hook); apps watch the file and recolour live. +//! +//! bread-theme # same as `generate` +//! bread-theme generate # render + write the shared stylesheet +//! bread-theme path # print the stylesheet path +//! bread-theme print # render to stdout (no write) + +use std::process::ExitCode; + +fn main() -> ExitCode { + let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into()); + match cmd.as_str() { + "path" => { + println!("{}", bread_theme::shared_css_path().display()); + ExitCode::SUCCESS + } + "print" => { + print!("{}", bread_theme::render()); + ExitCode::SUCCESS + } + "generate" => match bread_theme::write_shared_css() { + Ok(path) => { + eprintln!("bread-theme: wrote {}", path.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("bread-theme: failed to write stylesheet: {e}"); + ExitCode::FAILURE + } + }, + "-h" | "--help" | "help" => { + eprintln!( + "bread-theme — shared stylesheet generator\n\n\ + USAGE:\n bread-theme [generate|path|print]\n\n\ + generate render the pywal palette to the shared stylesheet (default)\n\ + path print the stylesheet path ({})\n\ + print render to stdout without writing", + bread_theme::shared_css_path().display() + ); + ExitCode::SUCCESS + } + other => { + eprintln!("bread-theme: unknown command '{other}' (try generate|path|print)"); + ExitCode::FAILURE + } + } +} diff --git a/bread-theme/src/gtk.rs b/bread-theme/src/gtk.rs index 6e62cb4..71f5306 100644 --- a/bread-theme/src/gtk.rs +++ b/bread-theme/src/gtk.rs @@ -1,7 +1,41 @@ +use gtk4::gio; +use gtk4::prelude::*; use gtk4::CssProvider; use std::cell::RefCell; use std::path::Path; +thread_local! { + static SHARED_PROVIDER: RefCell> = const { RefCell::new(None) }; + static SHARED_MONITOR: RefCell> = const { RefCell::new(None) }; +} + +fn reload_shared() { + let css = std::fs::read_to_string(crate::shared_css_path()) + .unwrap_or_else(|_| crate::render()); + SHARED_PROVIDER.with(|cell| apply_css(&css, cell)); +} + +/// Load the ecosystem's shared stylesheet (the file written by +/// `bread-theme generate`, or a freshly rendered fallback if absent) at +/// APPLICATION priority, and watch the file so the whole UI recolours live when +/// the palette changes — no app rebuild or restart needed. +/// +/// Call once at startup; then add the app's own CSS provider *after* this so +/// app-specific rules win on equal specificity. +pub fn apply_shared() { + reload_shared(); + SHARED_MONITOR.with(|cell| { + if cell.borrow().is_some() { + return; + } + let file = gio::File::for_path(crate::shared_css_path()); + if let Ok(monitor) = file.monitor_file(gio::FileMonitorFlags::NONE, gio::Cancellable::NONE) { + monitor.connect_changed(|_, _, _, _| reload_shared()); + *cell.borrow_mut() = Some(monitor); + } + }); +} + /// Apply a CSS string to the default display at APPLICATION priority. /// Re-uses an existing provider if one is passed in (for SIGHUP reloads). pub fn apply_css(css: &str, provider: &RefCell>) { diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 7d649e8..37fbf4e 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -54,6 +54,128 @@ pub fn css_vars(p: &Palette) -> String { ) } +/// Canonical `@define-color` block: the single naming all bread apps share. +/// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and +/// `accent` = color4. Apps must use these names, not raw palette slots, so the +/// whole ecosystem recolours together. +fn define_colors(p: &Palette) -> String { + format!( + "@define-color bg {bg};\n\ + @define-color fg {fg};\n\ + @define-color surface {c0};\n\ + @define-color overlay {c7};\n\ + @define-color accent {c4};\n\ + @define-color red {c1};\n\ + @define-color green {c2};\n\ + @define-color yellow {c3};\n\ + @define-color blue {c4};\n\ + @define-color pink {c5};\n\ + @define-color teal {c6};\n", + bg = p.background, fg = p.foreground, + c0 = p.color0, c1 = p.color1, c2 = p.color2, c3 = p.color3, + c4 = p.color4, c5 = p.color5, c6 = p.color6, c7 = p.color7, + ) +} + +/// The full shared component stylesheet — the single source of truth for how +/// every bread GUI (bos-settings, breadbar, breadbox, breadpad, breadman) styles +/// common widgets. Apps load this, then append only their own *layout* rules. +/// +/// Built entirely from the design tokens (font, spacing, radii) and the +/// `@define-color` palette, so changing the palette recolours every app. +pub fn stylesheet(p: &Palette) -> String { + use tokens::*; + format!( + "{vars}\ + * {{ font-family: '{font}'; font-size: {base}px; }}\n\ + window {{ background-color: @bg; color: @fg; }}\n\ + label {{ color: @fg; }}\n\ + .dim-label, .dim {{ color: @fg; opacity: 0.6; font-size: {sec}px; }}\n\ + .title {{ font-size: 1.4em; font-weight: bold; color: @fg; }}\n\ + .heading {{ font-weight: bold; color: @fg; opacity: 0.85; }}\n\ + .subtitle {{ color: @fg; opacity: 0.7; font-size: {sec}px; }}\n\ + button {{ background-color: @surface; color: @fg; border: none;\ + border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\ + button:hover {{ background-color: alpha(@fg, 0.14); }}\n\ + button:active {{ background-color: alpha(@fg, 0.20); }}\n\ + button:disabled {{ opacity: 0.5; }}\n\ + button.flat {{ background-color: transparent; }}\n\ + button.suggested-action {{ background-color: @accent; color: @bg; }}\n\ + button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\ + button.destructive-action {{ background-color: @red; color: @bg; }}\n\ + button.destructive-action:hover {{ background-color: alpha(@red, 0.85); }}\n\ + entry, spinbutton {{ background-color: @surface; color: @fg;\ + border: 1px solid @overlay; border-radius: {r2}px;\ + padding: {xs}px {sm}px; caret-color: @fg; }}\n\ + entry:focus-within, spinbutton:focus-within {{ border-color: @accent; outline: none; }}\n\ + entry image, spinbutton button {{ color: @fg; }}\n\ + dropdown > button {{ background-color: @surface; border-radius: {r2}px; }}\n\ + popover > contents {{ background-color: @surface; color: @fg; border-radius: {r1}px; }}\n\ + switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\ + switch:checked {{ background-color: @accent; }}\n\ + switch slider {{ background-color: @fg; border-radius: {pill}px; }}\n\ + list, listbox {{ background-color: transparent; }}\n\ + row {{ border-radius: {r2}px; }}\n\ + row:selected, list row:selected {{ background-color: @accent; color: @bg; }}\n\ + .sidebar {{ background-color: @surface; }}\n\ + .sidebar row {{ padding: {sm}px {md}px; color: @fg; }}\n\ + .sidebar row:selected {{ background-color: @accent; color: @bg; }}\n\ + .sidebar .section-header {{ padding: {md}px {md}px {xs}px {md}px;\ + font-size: {sec}px; font-weight: bold; color: @fg; opacity: 0.55; }}\n\ + .card {{ background-color: @surface; border-radius: {r1}px; padding: {md}px; }}\n\ + .chip, .pill {{ background-color: @overlay; color: @fg; border-radius: {pill}px;\ + padding: {xs}px {md}px; font-size: {sec}px; }}\n\ + .chip.active, .pill.active {{ background-color: @accent; color: @bg; }}\n\ + scrollbar {{ background-color: transparent; }}\n\ + scrollbar slider {{ background-color: alpha(@fg, 0.25); border-radius: {pill}px;\ + min-width: 6px; min-height: 6px; }}\n\ + scrollbar slider:hover {{ background-color: alpha(@fg, 0.45); }}\n\ + textview, .mono {{ font-family: monospace; }}\n\ + textview text {{ background-color: @surface; color: @fg; }}\n", + vars = define_colors(p), + font = FONT_FAMILY, + base = FONT_SIZE_BASE, + sec = FONT_SIZE_SECONDARY, + xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, + r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, pill = RADIUS_PILL, + ) +} + +/// Render the shared stylesheet for the current (pywal) palette. Used by the +/// `bread-theme` generator and as the in-app fallback when the generated file +/// isn't present yet. +pub fn render() -> String { + stylesheet(&load_palette()) +} + +/// Canonical path of the generated shared stylesheet. Apps load it; the +/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`, +/// falling back to the cache dir. +pub fn shared_css_path() -> std::path::PathBuf { + if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") { + if !rt.is_empty() { + return std::path::PathBuf::from(rt).join("bread").join("theme.css"); + } + } + dirs::cache_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) + .join("bread") + .join("theme.css") +} + +/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns +/// the path written. Used by the `bread-theme` CLI. +pub fn write_shared_css() -> std::io::Result { + let path = shared_css_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("css.tmp"); + std::fs::write(&tmp, render())?; + std::fs::rename(&tmp, &path)?; + Ok(path) +} + /// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`. pub fn hex_to_rgba(hex: &str, alpha: f32) -> String { let h = hex.trim_start_matches('#'); @@ -82,6 +204,30 @@ mod tests { assert!(css.contains("14px")); } + #[test] + fn stylesheet_defines_canonical_colors_and_components() { + let css = stylesheet(&Palette::default()); + for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] { + assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + } + // a representative spread of the shared component selectors + for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".title"] { + assert!(css.contains(sel), "stylesheet missing selector: {sel}"); + } + assert!(css.contains("Varela Round")); + } + + #[test] + fn shared_css_path_uses_runtime_dir() { + std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234"); + assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css")); + } + + #[test] + fn render_is_nonempty_css() { + assert!(render().contains("@define-color bg ")); + } + #[test] fn hex_to_rgba_known_value() { assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)");