bread-theme: shared component stylesheet + generator CLI

Adds the single source of truth for bread GUI styling so the apps stop
each re-implementing (and drifting on) component CSS:

- stylesheet(&Palette): full component sheet (buttons, entries, switches,
  dropdowns, lists/rows/sidebars, cards, chips, scrollbars, headings) built
  from the design tokens + a canonical @define-color block (surface=color0,
  overlay=color7, accent=color4).
- render() / shared_css_path() / write_shared_css(): render for the current
  pywal palette and write to $XDG_RUNTIME_DIR/bread/theme.css.
- gtk::apply_shared(): load that file (or a rendered fallback) at APPLICATION
  priority and watch it, so every app recolours live with no rebuild.
- new `bread-theme` CLI (generate|path|print) — gtk-free, light. Run at
  session start and on palette change; apps pick it up via the file watch.

The contract is a CSS *file*, so apps stay decoupled from this crate's gtk4
version. Tests cover the stylesheet, path, and render helpers.
This commit is contained in:
Breadway 2026-06-16 16:43:09 +08:00
parent 578067183b
commit 8305b4a58b
4 changed files with 236 additions and 0 deletions

View file

@ -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"

View file

@ -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
}
}
}

View file

@ -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<Option<CssProvider>> = const { RefCell::new(None) };
static SHARED_MONITOR: RefCell<Option<gio::FileMonitor>> = 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<Option<CssProvider>>) {

View file

@ -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<std::path::PathBuf> {
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)");