diff --git a/Cargo.lock b/Cargo.lock index 0598260..5af4c11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,11 +257,14 @@ dependencies = [ name = "bread-theme" version = "0.7.4" dependencies = [ + "anyhow", "dirs 5.0.1", "gtk4", "libadwaita", "serde", "serde_json", + "toml 0.8.23", + "tracing", ] [[package]] diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index f5e1cda..7298837 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -12,6 +12,11 @@ keywords = ["theming", "pywal", "gtk4", "wayland"] serde = { workspace = true } serde_json = { workspace = true } dirs = { workspace = true } +# bread_theme::shell manifest parsing (theme.toml) — gtk-free, so `bread` +# (daemon) and `breadcrumbs` (CLI) can validate a theme without linking GTK. +toml = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } gtk4 = { version = "0.11", features = ["v4_12"], optional = true } # Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the # actual source of the modern GNOME look (grouped preference rows, real diff --git a/bread-theme/assets/shell/liquid-motion/liquid-motion.css b/bread-theme/assets/shell/liquid-motion/liquid-motion.css new file mode 100644 index 0000000..4d45d0e --- /dev/null +++ b/bread-theme/assets/shell/liquid-motion/liquid-motion.css @@ -0,0 +1,104 @@ +/* CSS template for the liquid-motion builtin (bread-theme/src/shell/builtin.rs). + * + * Curly-brace placeholders like {radius_bar} or {spring} are substituted + * from [tokens] by ShellTheme::css / Tokens::substitute; @-prefixed palette + * references (@accent, @on-bg, ...) pass through untouched, exactly like + * `bread_theme::stylesheet()` — GTK's own @define-color mechanism resolves + * them when the CSS provider is attached to a display/output. + * + * Scope: this is the window/workspace/clock chrome the manifest's own + * concepts (bar.window, modules.workspaces, modules.clock) actually model — + * not a byte-for-byte copy of breadbar/src/theme.rs::load_css's full ~250 + * lines (notification cards, wifi popover, control panel, media widget, ...). + * Those stay hand-written in breadbar for now; migrating them behind this + * same token-substitution mechanism is Phase 2/3 work (plan §6), once + * breadbar actually consumes ShellTheme and can verify pixel parity itself + * via --screenshot. Phase 1's job is the mechanism, exercised end-to-end + * here with a representative slice, not the full port. + */ + +@keyframes row-in { + from { opacity: 0; margin-top: 8px; } + to { opacity: 1; margin-top: 0; } +} +@keyframes digit-flip { + from { opacity: 0; margin-top: 7px; } + to { opacity: 1; margin-top: 0; } +} + +window.breadbar { + background-color: alpha(@bg, {bg_alpha}); + color: @on-bg; + border-radius: {radius_bar}px; + border: 1px solid alpha(@on-bg, 0.08); +} +window.breadbar > centerbox { padding: 0 8px 0 6px; } +window.breadbar button { min-height: 0; min-width: 0; } + +.workspace-trail { + background-image: linear-gradient(90deg, @{accent_from}, @{accent_to}); + background-color: @{accent_from}; + border-radius: {radius_card}px; +} +.workspace-btn { + background: transparent; + opacity: 0.36; + color: @on-bg; + border-radius: {radius_card}px; + border: none; + outline: none; + box-shadow: none; + min-width: 28px; + min-height: {chip_height}px; + margin: 0; + padding: 0 7px; + font-size: 22px; + font-weight: bold; + transition: opacity 0.22s {spring_settle}, background-color 0.22s {spring_settle}; +} +.workspace-btn:hover { opacity: 0.85; background: alpha(@on-bg, 0.08); } +.workspace-btn.occupied { opacity: 0.78; } +.workspace-btn.active { background: transparent; color: @on-accent; opacity: 1; } +.workspace-btn.active:hover { background: transparent; } +.workspace-btn.ws-in { animation: row-in 0.32s {spring_settle} both; } + +.clock-box { padding: 0 4px; } +.clock-label { + font-size: 24px; + font-weight: bold; + letter-spacing: 0.04em; + min-height: 0; + padding: 0; + margin-top: 3px; +} +.clock-digit { + font-size: 24px; + font-weight: bold; + letter-spacing: 0.04em; + min-width: 15px; + min-height: 0; + padding: 0; + margin: 0; +} +.clock-colon { min-width: 10px; opacity: 0.7; } +.clock-digit.flip { animation: digit-flip 0.45s {spring} both; } + +window.breadbar-osd { + background-color: alpha(@bg, 0.70); + color: @on-bg; + border-radius: {radius_pill}px; + border: 1px solid alpha(@on-bg, 0.10); +} + +window.breadbar-panel { + background-color: alpha(@bg, {bg_alpha}); + color: @on-bg; + border-radius: 14px; + border: 1px solid alpha(@on-bg, 0.12); +} + +window.breadbar-dismiss { + background-color: alpha(#000000, 0.02); +} + +.bread-widget-slot { margin-right: {pad}px; } diff --git a/bread-theme/assets/shell/liquid-motion/theme.toml b/bread-theme/assets/shell/liquid-motion/theme.toml new file mode 100644 index 0000000..72a8f4d --- /dev/null +++ b/bread-theme/assets/shell/liquid-motion/theme.toml @@ -0,0 +1,154 @@ +# The compiled-in builtin theme (bread-theme/src/shell/builtin.rs). Describes +# breadbar/breadbox AS THEY EXIST TODAY, not the 01-liquid-motion.html demo — +# where the two disagree (bar side margin, launcher width/top, the two easing +# curves), this file follows the current Rust source, since Phase 2's +# acceptance test is pixel-identical rendering against today's bar. +# +# Sources: breadbar/src/main.rs:16-22 (BAR_* / CHIP_HEIGHT / ICON_PX consts), +# breadbar/src/theme.rs (load_css's radius/pad/spring locals and the actual +# CSS selectors), breadbar/src/{panel,osd}.rs and +# breadbar/src/notifications/{popup,history}.rs (satellite window anchors, +# margins, namespaces), breadbox/breadbox/src/main.rs:341-344 (launcher +# geometry), ~/.config/hypr/scripts/ui/rules.lua (compositor rules). + +name = "Liquid Motion" +id = "liquid-motion" + +[tokens] +font_family = "Varela Round, sans-serif" +font_fallback = "sans-serif" +font_size_base = 14 +radius_bar = 16 +radius_card = 12 +radius_sm = 9 +# Not in the plan's §4 schema list, but a named local in theme.rs::load_css +# alongside radius_bar/radius_card/radius_sm (`radius_pill = "999px"`) — the +# OSD pill's corner radius. +radius_pill = 999 +pad = 12 +bg_alpha = 0.72 +# Two curves theme.rs actually uses, not one: `spring` is the overshoot/bounce +# curve (clock flips, pop-ins, the workspace caret draw); `spring_settle` is +# the flatter curve used for hovers and workspace/stat-pair transitions. The +# plan's §4 example names only `spring`. +spring = "cubic-bezier(0.22, 1.35, 0.36, 1)" +spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)" +# "accent" flows through as @accent (the workspace-trail gradient's start); +# these are palette token NAMES, not hex - pywal still drives colour. +accent_from = "accent" +accent_to = "teal" +# Not in the plan's §4 schema list, but breadbar::CHIP_HEIGHT / ::ICON_PX +# today. +chip_height = 32 +icon_px = 24 + +[bar.window] +anchors = ["top", "left", "right"] +width = "fill" +height = 44 +margin = { top = 12, left = 16, right = 16 } +exclusive = "auto" +# breadbar never calls gtk4-layer-shell's set_keyboard_mode today, which +# defaults to KeyboardMode::None — spelled out explicitly here rather than +# left to omit-means-default, since "the shell must never fail to start +# because a theme file is malformed" cuts both ways: an explicit builtin +# value can't silently drift if the crate's own Default ever changes. +keyboard = "none" +layer = "top" + +[bar.slots] +left = ["workspaces"] +centre = ["media", "clock"] +right = ["volume", "wifi", "battery", "control"] +drawer = [] + +[modules.workspaces] +style = "trail" +show_empty = true + +[modules.clock] +style = "flip" +format = "%H:%M" +show_date = false + +[launcher] +# breadbox/breadbox/src/main.rs:341-344 sets vbox.set_margin_top(120) and +# vbox.set_size_request(600, -1) — the demo's "540px overlay, 16% top" is a +# design-doc approximation, not what the code does today. +mode = "overlay" +width = 600 +top = "120px" +radius = 20 +icon_px = 36 +row_anim = "flip" +rule = "gradient" +footer = "count_apps" +sections = false +modes = ["apps"] + +# Keyed by layer-shell namespace, matching [compositor.*] below, so the two +# tables share one keyspace and can be validated against each other. +# breadbar-notif's popup toast uses set_default_width(320) (its history +# sibling on the same namespace uses 360 — 320 is the live-toast surface, the +# one this positions). breadbar-panel sets no window width at all: its +# popovers size from their own CSS (.control-panel-inner / .wifi-popover-inner +# min-width), which is why its width is "auto" rather than a number. +[surfaces."breadbar-notif"] +anchor = "top_right" +offset = [16, 64] +width = 320 +layer = "overlay" + +[surfaces."breadbar-osd"] +anchor = "bottom_centre" +offset = 80 +width = 180 +layer = "overlay" + +[surfaces."breadbar-panel"] +anchor = "top_right" +offset = [16, 64] +width = "auto" +layer = "overlay" + +[surfaces."breadbar-dismiss"] +# Anchored to all four edges (a fullscreen click-away scrim) with only a top +# margin, so it starts below the bar rather than covering it. +anchor = "fill" +offset = 56 +width = "fill" +layer = "overlay" + +# Faithful to scripts/ui/rules.lua's five breadbar namespaces + breadbox. +[compositor."breadbar"] +blur = true +ignore_alpha = 0.2 +blur_popups = true +animation = "slide top" + +[compositor."breadbar-osd"] +blur = true +ignore_alpha = 0.2 +animation = "slide bottom" + +[compositor."breadbar-notif"] +blur = true +ignore_alpha = 0.2 +animation = "slide right" + +[compositor."breadbar-panel"] +blur = true +ignore_alpha = 0.2 +animation = "slide right" + +[compositor."breadbar-dismiss"] +no_anim = true + +[compositor."breadbox"] +blur = true +ignore_alpha = 0.2 + +# No `css = "..."` overlay: the builtin is compiled in via `include_str!` +# and has no on-disk directory to resolve a relative overlay path against. +# `extra.css` is for user/system themes, which do have one — see +# `resolve_theme` in mod.rs. diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 2111c17..d20c955 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -4,6 +4,7 @@ pub mod adw; pub mod gtk; mod output; pub mod palette; +pub mod shell; pub use output::{ generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image, diff --git a/bread-theme/src/shell/builtin.rs b/bread-theme/src/shell/builtin.rs new file mode 100644 index 0000000..bd25560 --- /dev/null +++ b/bread-theme/src/shell/builtin.rs @@ -0,0 +1,23 @@ +//! The one compiled-in theme (plan §11 phase 1: "**One** built-in manifest +//! (`liquid-motion`) describing the bar as it exists today"). Both files are +//! plain data, not Rust — `theme.toml` is the manifest text a user override +//! would otherwise supply, and `liquid-motion.css` is the CSS template +//! `ShellTheme::css` substitutes tokens into (see that method's doc comment +//! for why this template is a representative subset of `breadbar::theme:: +//! load_css`'s full stylesheet rather than a byte-for-byte copy of it). +//! +//! Both are read with `include_str!` so a broken build can't ship without +//! them, and so [`super::builtin`] never touches the filesystem — it must +//! work identically whether or not `$XDG_CONFIG_HOME` exists at all. + +pub const LIQUID_MOTION_ID: &str = "liquid-motion"; + +pub const LIQUID_MOTION_TOML: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/liquid-motion/theme.toml" +)); + +pub const LIQUID_MOTION_CSS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/liquid-motion/liquid-motion.css" +)); diff --git a/bread-theme/src/shell/hotreload.rs b/bread-theme/src/shell/hotreload.rs new file mode 100644 index 0000000..15e68a2 --- /dev/null +++ b/bread-theme/src/shell/hotreload.rs @@ -0,0 +1,32 @@ +//! `watch()` — only compiled under the `gtk` feature, since +//! `gio::FileMonitor` is a gtk4 dependency and the rest of `shell` is +//! deliberately gtk-free (`bread`/`breadcrumbs` link this crate without the +//! `gtk` feature at all). + +use gtk4::gio; +use gtk4::prelude::*; + +/// Fires `f` with a freshly-[`super::load`]ed [`super::ShellTheme`] whenever +/// the active theme's own directory changes on disk. Keep the returned +/// monitor alive — dropping it disarms the watch. +/// +/// Watches the *directory*, not the `theme.toml` file itself, for the same +/// reason `bread_theme::gtk::watch_theme_file` does (see that function's doc +/// comment): an editor or `bread-theme` doing an atomic write-tmp-then- +/// rename replaces the inode, and a monitor on the file itself dies after +/// the first replace (inotify reports `DELETE_SELF` and never re-arms). +pub fn watch(f: F) -> gio::FileMonitor { + let id = super::active_theme_id(); + let dir = super::user_theme_path(&id) + .parent() + .expect("user_theme_path always has a parent") + .to_path_buf(); + let _ = std::fs::create_dir_all(&dir); + let monitor = gio::File::for_path(&dir) + .monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE) + .expect("failed to create a file monitor for the shell theme directory"); + monitor.connect_changed(move |_, _file, _other, _event| { + f(super::load()); + }); + monitor +} diff --git a/bread-theme/src/shell/manifest.rs b/bread-theme/src/shell/manifest.rs new file mode 100644 index 0000000..08be709 --- /dev/null +++ b/bread-theme/src/shell/manifest.rs @@ -0,0 +1,518 @@ +//! `theme.toml` deserialization, validation, and resolution into +//! [`crate::shell::ShellTheme`]. +//! +//! Two layers on purpose: `Raw*` types mirror the TOML shape exactly (every +//! field optional, `deny_unknown_fields` everywhere so a typo'd key is a +//! hard error naming that key rather than a silent no-op) and know nothing +//! about defaults; [`RawManifest::resolve`] is the one place defaults get +//! filled and string enums get validated, producing the fully-resolved +//! types in `types.rs`. + +use anyhow::{anyhow, bail, Context}; +use std::collections::{BTreeMap, HashMap}; + +use super::types::*; + +/// Module names a slot entry may reference without recompiling anything — +/// plan §2 tier 1/2 (declarative slots) plus the `widget:*` escape hatch +/// (tier 3, validated separately since its suffix is open-ended). This is +/// intentionally the set the *current* theme and the plan's own schema +/// example use; Phase 3 (breadbar's module registry) is the place a new +/// built-in module name gets added for real. +const KNOWN_MODULES: &[&str] = &[ + "workspaces", + "media", + "clock", + "volume", + "wifi", + "battery", + "control", + "launcher_entry", + "launcher_results", +]; + +pub(super) fn validate_module_name(theme_id: &str, slot: &str, module: &str) -> anyhow::Result<()> { + if module.starts_with("widget:") || KNOWN_MODULES.contains(&module) { + return Ok(()); + } + bail!( + "theme '{theme_id}': slot \"{slot}\" references unknown module \"{module}\" \ + (known modules: {}, or widget:)", + KNOWN_MODULES.join(", ") + ); +} + +pub(super) fn validate_slots(raw: &RawManifest, theme_id: &str) -> anyhow::Result<()> { + let Some(bar) = &raw.bar else { return Ok(()) }; + let Some(slots) = &bar.slots else { + return Ok(()); + }; + for (slot_name, list) in [ + ("left", &slots.left), + ("centre", &slots.centre), + ("right", &slots.right), + ("drawer", &slots.drawer), + ] { + for module in list { + validate_module_name(theme_id, slot_name, module)?; + } + } + Ok(()) +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawManifest { + pub(super) name: Option, + pub(super) id: Option, + /// Present only so `extends` deserializes as a *known* field (otherwise + /// `deny_unknown_fields` would reject every theme that sets it). The + /// value itself is read straight off the raw `toml::Value` in + /// `mod.rs::resolve_theme` — before this struct exists — since the + /// merge has to happen ahead of (and separately from) deserialization. + #[allow(dead_code)] + pub(super) extends: Option, + #[serde(default)] + pub(super) tokens: HashMap, + pub(super) bar: Option, + pub(super) modules: Option, + pub(super) launcher: Option, + pub(super) surfaces: Option>, + pub(super) compositor: Option>, + /// Overlay CSS path, resolved relative to the theme file's own + /// directory, appended last by `ShellTheme::css`. (Schema note: plan §4 + /// shows `css = "extra.css"` textually after the `[compositor]` table + /// with no table header of its own between them, which in real TOML + /// would nest it *inside* `[compositor]`. Treated here as a top-level + /// field per §5's `css()` doc — see this crate's implementation notes.) + pub(super) css: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawBar { + pub(super) window: Option, + pub(super) slots: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawWindow { + pub(super) anchors: Option>, + pub(super) width: Option, + pub(super) height: Option, + pub(super) margin: Option, + pub(super) exclusive: Option, + pub(super) keyboard: Option, + pub(super) layer: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +pub(super) enum RawSize { + Named(String), + Px(i64), +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +pub(super) enum RawExclusive { + Named(String), + Px(i64), +} + +#[derive(Debug, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, default)] +pub(super) struct RawMargin { + pub(super) top: i64, + pub(super) left: i64, + pub(super) right: i64, + pub(super) bottom: i64, +} + +#[derive(Debug, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, default)] +pub(super) struct RawSlots { + pub(super) left: Vec, + pub(super) centre: Vec, + pub(super) right: Vec, + pub(super) drawer: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawModules { + pub(super) workspaces: Option, + pub(super) clock: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawWorkspacesModule { + pub(super) style: Option, + pub(super) show_empty: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawClockModule { + pub(super) style: Option, + pub(super) format: Option, + pub(super) show_date: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawLauncher { + pub(super) mode: Option, + pub(super) width: Option, + pub(super) top: Option, + pub(super) radius: Option, + pub(super) icon_px: Option, + pub(super) row_anim: Option, + pub(super) rule: Option, + pub(super) footer: Option, + pub(super) sections: Option, + pub(super) modes: Option>, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawSurface { + pub(super) anchor: Option, + pub(super) offset: Option, + pub(super) width: Option, + pub(super) layer: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +pub(super) enum RawOffset { + Single(f64), + Pair([f64; 2]), +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +pub(super) enum RawSurfaceWidth { + Named(String), + Px(i64), +} + +#[derive(Debug, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, default)] +pub(super) struct RawLayerRule { + pub(super) blur: Option, + pub(super) ignore_alpha: Option, + pub(super) blur_popups: Option, + pub(super) animation: Option, + pub(super) no_anim: Option, +} + +fn token_value(v: &toml::Value) -> anyhow::Result { + match v { + toml::Value::String(s) => Ok(TokenValue::Str(s.clone())), + toml::Value::Integer(i) => Ok(TokenValue::Int(*i)), + toml::Value::Float(f) => Ok(TokenValue::Float(*f)), + toml::Value::Boolean(b) => Ok(TokenValue::Bool(*b)), + other => Err(anyhow!("must be a string, number, or bool, got {other:?}")), + } +} + +fn resolve_window(theme_id: &str, w: &RawWindow) -> anyhow::Result { + let default = WindowSpec::default(); + + let anchors = match &w.anchors { + Some(list) => { + for a in list { + if !matches!(a.as_str(), "top" | "bottom" | "left" | "right") { + bail!( + "theme '{theme_id}': bar.window.anchors contains unknown anchor \"{a}\" \ + (expected top|bottom|left|right)" + ); + } + } + list.clone() + } + None => default.anchors, + }; + + let width = match &w.width { + Some(RawSize::Named(s)) if s == "fill" => Width::Fill, + Some(RawSize::Named(other)) => bail!( + "theme '{theme_id}': bar.window.width = \"{other}\" is not \"fill\" \ + (use a bare number for a fixed width)" + ), + Some(RawSize::Px(n)) => Width::Px(*n as i32), + None => default.width, + }; + + let height = w.height.map(|h| h as i32).unwrap_or(default.height); + + let margin = w + .margin + .as_ref() + .map(|m| Margin { + top: m.top as i32, + left: m.left as i32, + right: m.right as i32, + bottom: m.bottom as i32, + }) + .unwrap_or(default.margin); + + let exclusive = match &w.exclusive { + Some(RawExclusive::Named(s)) if s == "auto" => Exclusive::Auto, + Some(RawExclusive::Named(s)) if s == "none" => Exclusive::None, + Some(RawExclusive::Named(other)) => bail!( + "theme '{theme_id}': bar.window.exclusive = \"{other}\" is not \"auto\" or \"none\" \ + (use a bare number for a fixed exclusive zone)" + ), + Some(RawExclusive::Px(n)) => Exclusive::Px(*n as i32), + None => default.exclusive, + }; + + let keyboard = match w.keyboard.as_deref() { + None => default.keyboard, + Some("none") => Keyboard::None, + Some("on_demand") => Keyboard::OnDemand, + Some("exclusive") => Keyboard::Exclusive, + Some(other) => bail!( + "theme '{theme_id}': bar.window.keyboard = \"{other}\" is not none|on_demand|exclusive" + ), + }; + + let layer = match w.layer.as_deref() { + None => default.layer, + Some("top") => "top".to_string(), + Some("overlay") => "overlay".to_string(), + Some(other) => { + bail!("theme '{theme_id}': bar.window.layer = \"{other}\" is not top|overlay") + } + }; + + Ok(WindowSpec { + anchors, + width, + height, + margin, + exclusive, + keyboard, + layer, + }) +} + +fn resolve_modules(theme_id: &str, m: Option<&RawModules>) -> anyhow::Result { + let ws = m.and_then(|m| m.workspaces.as_ref()); + let style = match ws.and_then(|w| w.style.as_deref()) { + None => WorkspaceStyle::Trail, + Some("trail") => WorkspaceStyle::Trail, + Some("pill") => WorkspaceStyle::Pill, + Some("dots") => WorkspaceStyle::Dots, + Some(other) => bail!( + "theme '{theme_id}': modules.workspaces.style = \"{other}\" is not trail|pill|dots" + ), + }; + let show_empty = ws.and_then(|w| w.show_empty).unwrap_or(true); + + let ck = m.and_then(|m| m.clock.as_ref()); + let cstyle = match ck.and_then(|c| c.style.as_deref()) { + None => ClockStyle::Flip, + Some("flip") => ClockStyle::Flip, + Some("plain") => ClockStyle::Plain, + Some("none") => ClockStyle::None, + Some(other) => { + bail!("theme '{theme_id}': modules.clock.style = \"{other}\" is not flip|plain|none") + } + }; + let format = ck + .and_then(|c| c.format.clone()) + .unwrap_or_else(|| "%H:%M".to_string()); + let show_date = ck.and_then(|c| c.show_date).unwrap_or(false); + + Ok(Modules { + workspaces: WorkspacesModule { style, show_empty }, + clock: ClockModule { + style: cstyle, + format, + show_date, + }, + }) +} + +fn resolve_launcher(theme_id: &str, l: Option<&RawLauncher>) -> anyhow::Result { + let mode = match l.and_then(|l| l.mode.as_deref()) { + None => LauncherMode::Overlay, + Some("overlay") => LauncherMode::Overlay, + Some("embedded") => LauncherMode::Embedded, + Some(other) => { + bail!("theme '{theme_id}': launcher.mode = \"{other}\" is not overlay|embedded") + } + }; + Ok(Launcher { + mode, + width: l.and_then(|l| l.width).unwrap_or(540) as i32, + top: l + .and_then(|l| l.top.clone()) + .unwrap_or_else(|| "16%".to_string()), + radius: l.and_then(|l| l.radius).unwrap_or(20) as i32, + icon_px: l.and_then(|l| l.icon_px).unwrap_or(36) as i32, + row_anim: l + .and_then(|l| l.row_anim.clone()) + .unwrap_or_else(|| "flip".to_string()), + rule: l + .and_then(|l| l.rule.clone()) + .unwrap_or_else(|| "gradient".to_string()), + footer: l + .and_then(|l| l.footer.clone()) + .unwrap_or_else(|| "count_apps".to_string()), + sections: l.and_then(|l| l.sections).unwrap_or(false), + modes: l + .and_then(|l| l.modes.clone()) + .unwrap_or_else(|| vec!["apps".to_string()]), + }) +} + +fn resolve_surfaces( + theme_id: &str, + raw: Option<&HashMap>, +) -> anyhow::Result> { + let mut out = BTreeMap::new(); + let Some(raw) = raw else { return Ok(out) }; + for (namespace, s) in raw { + let offset = match &s.offset { + None => vec![], + Some(RawOffset::Single(v)) => vec![*v], + Some(RawOffset::Pair(v)) => v.to_vec(), + }; + let width = match &s.width { + None => SurfaceWidth::Auto, + Some(RawSurfaceWidth::Named(n)) if n == "fill" => SurfaceWidth::Fill, + Some(RawSurfaceWidth::Named(n)) if n == "auto" => SurfaceWidth::Auto, + Some(RawSurfaceWidth::Named(other)) => bail!( + "theme '{theme_id}': surfaces.{namespace}.width = \"{other}\" is not \"fill\" or \"auto\" \ + (use a bare number for a fixed width)" + ), + Some(RawSurfaceWidth::Px(n)) => SurfaceWidth::Px(*n as i32), + }; + let layer = match s.layer.as_deref() { + None | Some("overlay") => "overlay".to_string(), + Some("top") => "top".to_string(), + Some(other) => bail!( + "theme '{theme_id}': surfaces.{namespace}.layer = \"{other}\" is not top|overlay" + ), + }; + out.insert( + namespace.clone(), + Surface { + anchor: s.anchor.clone().unwrap_or_default(), + offset, + width, + layer, + }, + ); + } + Ok(out) +} + +fn resolve_compositor(raw: Option<&HashMap>) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(raw) = raw else { return out }; + for (namespace, r) in raw { + out.insert( + namespace.clone(), + LayerRule { + blur: r.blur.unwrap_or(false), + ignore_alpha: r.ignore_alpha, + blur_popups: r.blur_popups.unwrap_or(false), + animation: r.animation.clone(), + no_anim: r.no_anim.unwrap_or(false), + }, + ); + } + out +} + +impl RawManifest { + /// Fill every default and validate every enum-ish string, producing a + /// fully-resolved [`super::ShellTheme`]. `requested_id` is the id this + /// manifest was looked up under (used as the id/name fallback when the + /// TOML omits `id`/`name`); `css_template` and `extra_css` are threaded + /// in by the discovery/extends logic in `mod.rs` since neither is a + /// plain TOML field (extra_css is *read from* a TOML field, `css`, but + /// resolving that path against the theme's own directory happens in the + /// caller, which is the only place that still has the directory handy). + pub(super) fn resolve( + &self, + requested_id: &str, + css_template: String, + extra_css: Option, + ) -> anyhow::Result { + let id = self.id.clone().unwrap_or_else(|| requested_id.to_string()); + let name = self.name.clone().unwrap_or_else(|| id.clone()); + + let mut tokens_map = BTreeMap::new(); + for (k, v) in &self.tokens { + let tv = token_value(v).with_context(|| format!("theme '{id}': tokens.{k}"))?; + tokens_map.insert(k.clone(), tv); + } + let tokens = Tokens::from_map(tokens_map); + + let window = match self.bar.as_ref().and_then(|b| b.window.as_ref()) { + Some(w) => resolve_window(&id, w)?, + None => WindowSpec::default(), + }; + + let slots = self + .bar + .as_ref() + .and_then(|b| b.slots.as_ref()) + .map(|s| Slots { + left: s.left.clone(), + centre: s.centre.clone(), + right: s.right.clone(), + drawer: s.drawer.clone(), + }) + .unwrap_or_default(); + + let modules = resolve_modules(&id, self.modules.as_ref())?; + let launcher = resolve_launcher(&id, self.launcher.as_ref())?; + let surfaces = resolve_surfaces(&id, self.surfaces.as_ref())?; + let compositor = resolve_compositor(self.compositor.as_ref()); + + Ok(super::ShellTheme { + name, + id, + tokens, + window, + slots, + modules, + launcher, + surfaces, + compositor, + css_template, + extra_css, + }) + } +} + +/// Deep-merge `over` onto `base`: tables merge key-by-key recursively; +/// anything else (scalars, arrays — including slot lists) is a full +/// replacement. This is `extends`'s one-level merge (plan §4/§11): +/// `mod.rs` calls this exactly once per `load_named`, with the base's own +/// `extends` key already stripped by the caller so a chain can't go deeper +/// than one level. +pub(super) fn merge_values(base: toml::Value, over: toml::Value) -> toml::Value { + match (base, over) { + (toml::Value::Table(mut base_t), toml::Value::Table(over_t)) => { + for (k, v) in over_t { + let merged = match base_t.remove(&k) { + Some(existing) => merge_values(existing, v), + None => v, + }; + base_t.insert(k, merged); + } + toml::Value::Table(base_t) + } + (_, over) => over, + } +} diff --git a/bread-theme/src/shell/mod.rs b/bread-theme/src/shell/mod.rs new file mode 100644 index 0000000..526b87c --- /dev/null +++ b/bread-theme/src/shell/mod.rs @@ -0,0 +1,899 @@ +//! `bread_theme::shell` — the shell theme manifest system (Phase 1 of the +//! `THEME_SYSTEM_PLAN.md` design: manifest types, discovery, `extends` +//! merge, validation, `css()`, `watch()`, and the one compiled-in +//! `liquid-motion` builtin describing breadbar/breadbox as they exist +//! today). +//! +//! This module is intentionally gtk-free except for [`watch`] (only +//! compiled under the `gtk` feature, since `gio::FileMonitor` is a gtk4 +//! dependency) — `bread` (the daemon) and `breadcrumbs` (the CLI) can read +//! and validate a theme without linking GTK at all. +//! +//! ## Discovery (plan §4) +//! +//! A theme id resolves through, first hit wins: +//! 1. `$XDG_CONFIG_HOME/bread/themes//theme.toml` (user) +//! 2. `/usr/share/bread/themes//theme.toml` (system, BOS package) +//! 3. the compiled-in builtin (currently only `liquid-motion`) +//! +//! The *active* id comes from `~/.config/bread/shell.toml`'s `active = "..."` +//! key, overridden by `$BREAD_SHELL_THEME` (the `--theme` CLI flag mentioned +//! in the plan is a consumer-side concern — breadbar/breadbox would set +//! `$BREAD_SHELL_THEME` themselves before calling [`load`], rather than this +//! crate parsing argv). +//! +//! ## `extends` (plan §4/§11) +//! +//! One level, deep-merged: a theme's raw TOML is merged over its `extends` +//! target's raw TOML (child wins key-by-key, recursing into tables; arrays — +//! including slot lists — are replaced wholesale, not concatenated). If the +//! base itself declares `extends`, that second-level `extends` is dropped +//! before merging — chains longer than one level are not supported, by +//! design (plan explicitly scopes this to "one level"). +//! +//! ## Never fails (plan §4/§5) +//! +//! [`load`] cannot fail: a missing or malformed *active* theme falls back to +//! the compiled-in builtin, logging once via `tracing::warn!` +//! ([`load_named`] is the fallible primitive underneath, for callers that +//! want to know *why* — e.g. a "broken theme" banner in bos-settings). + +mod builtin; +mod manifest; +mod types; + +#[cfg(feature = "gtk")] +mod hotreload; +#[cfg(feature = "gtk")] +pub use hotreload::watch; + +pub use types::*; + +use anyhow::{anyhow, Context}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use manifest::RawManifest; + +/// A theme, fully resolved: every default filled, every enum validated. +/// Built once by [`load`]/[`load_named`] and handed to consumers as an +/// immutable snapshot — a theme *change* (edit, `extends` retarget, hot +/// reload) produces a new `ShellTheme` rather than mutating this one. +#[derive(Debug, Clone, PartialEq)] +pub struct ShellTheme { + name: String, + id: String, + tokens: Tokens, + window: WindowSpec, + slots: Slots, + modules: Modules, + launcher: Launcher, + surfaces: BTreeMap, + compositor: BTreeMap, + css_template: String, + extra_css: Option, +} + +impl ShellTheme { + pub fn name(&self) -> &str { + &self.name + } + pub fn id(&self) -> &str { + &self.id + } + pub fn tokens(&self) -> &Tokens { + &self.tokens + } + pub fn window(&self) -> &WindowSpec { + &self.window + } + pub fn slots(&self) -> &Slots { + &self.slots + } + pub fn modules(&self) -> &Modules { + &self.modules + } + pub fn launcher(&self) -> &Launcher { + &self.launcher + } + pub fn surfaces(&self) -> &BTreeMap { + &self.surfaces + } + pub fn compositor_rules(&self) -> &BTreeMap { + &self.compositor + } + + /// Token substitution into the theme's CSS template, plus the optional + /// `extra.css` overlay appended last — plan §5. + /// + /// `palette` is accepted to match the plan §5 signature and for parity + /// with [`crate::stylesheet_resolved`]-style consumers in the future, + /// but is deliberately unused today: per this task's brief, `@accent` / + /// `@on-bg` *pass through untouched* here, the same way + /// [`crate::stylesheet`] leaves them for GTK's own `@define-color` + /// mechanism to resolve once the CSS provider is attached + /// (`bgtk::bind_window_with_app_css` / `apply_app_css`). A caller that + /// wants hex-resolved CSS combines this with + /// [`crate::resolve_color_names`] itself, exactly as + /// `breadbar::theme::load_css_for` does today. + #[allow(unused_variables)] + pub fn css(&self, palette: &crate::Palette) -> String { + let mut out = self.tokens.substitute(&self.css_template); + if let Some(extra) = &self.extra_css { + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(&self.tokens.substitute(extra)); + } + out + } +} + +/// Where a discovered theme's manifest text came from — also [`ThemeSummary`]'s +/// `source` field for a picker UI (bos-settings, plan §5 `list()`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeSource { + User, + System, + Builtin, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThemeSummary { + pub id: String, + pub name: String, + pub source: ThemeSource, +} + +fn config_home() -> PathBuf { + if let Ok(v) = std::env::var("XDG_CONFIG_HOME") { + if !v.is_empty() { + return PathBuf::from(v); + } + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") +} + +fn user_themes_dir() -> PathBuf { + config_home().join("bread/themes") +} + +fn system_themes_dir() -> PathBuf { + PathBuf::from("/usr/share/bread/themes") +} + +fn user_theme_path(id: &str) -> PathBuf { + user_themes_dir().join(id).join("theme.toml") +} + +fn system_theme_path(id: &str) -> PathBuf { + system_themes_dir().join(id).join("theme.toml") +} + +enum Source { + User(PathBuf), + System(PathBuf), + Builtin, +} + +fn find_source(id: &str) -> Option { + let user = user_theme_path(id); + if user.is_file() { + return Some(Source::User(user)); + } + let system = system_theme_path(id); + if system.is_file() { + return Some(Source::System(system)); + } + if id == builtin::LIQUID_MOTION_ID { + return Some(Source::Builtin); + } + None +} + +/// Manifest text plus, for on-disk sources, the directory it lives in (used +/// to resolve a relative `css = "extra.css"` overlay path). +fn read_source(src: &Source) -> anyhow::Result<(String, Option)> { + match src { + Source::User(p) | Source::System(p) => { + let text = + std::fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?; + Ok((text, p.parent().map(|d| d.to_path_buf()))) + } + Source::Builtin => Ok((builtin::LIQUID_MOTION_TOML.to_string(), None)), + } +} + +fn css_template_for(id: &str) -> String { + if id == builtin::LIQUID_MOTION_ID { + builtin::LIQUID_MOTION_CSS.to_string() + } else { + String::new() + } +} + +/// The fallible primitive: look up `id` through discovery, apply one level +/// of `extends`, validate, and resolve. Returns `Err` (naming the offending +/// key/path) rather than falling back — [`load`] is the caller that turns a +/// failure into the builtin. +pub fn load_named(id: &str) -> anyhow::Result { + resolve_theme(id, 0) +} + +fn resolve_theme(id: &str, extends_depth: u8) -> anyhow::Result { + let src = find_source(id).ok_or_else(|| { + anyhow!("no theme named '{id}' (checked user config, system dir, and builtins)") + })?; + let (text, dir) = read_source(&src)?; + let mut value: toml::Value = + toml::from_str(&text).with_context(|| format!("parsing theme '{id}'"))?; + + let extends = value + .get("extends") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let mut css_template = css_template_for(id); + + if let (Some(base_id), 0) = (&extends, extends_depth) { + let base_src = find_source(base_id) + .ok_or_else(|| anyhow!("theme '{id}' extends unknown theme '{base_id}'"))?; + let (base_text, _base_dir) = read_source(&base_src)?; + let mut base_value: toml::Value = toml::from_str(&base_text) + .with_context(|| format!("parsing base theme '{base_id}' (extended by '{id}')"))?; + // Cap at one level: drop the base's own `extends` so a chain can't + // go deeper (plan §4: "one level, deep-merged"). + if let toml::Value::Table(t) = &mut base_value { + t.remove("extends"); + } + css_template = css_template_for(base_id); + value = manifest::merge_values(base_value, value); + } + + let raw: RawManifest = value + .try_into() + .with_context(|| format!("theme '{id}' has an invalid or unrecognized field"))?; + + manifest::validate_slots(&raw, id)?; + + let extra_css = match &raw.css { + Some(rel) => { + let dir = dir.ok_or_else(|| { + anyhow!( + "theme '{id}' sets css = \"{rel}\" but has no on-disk directory to \ + resolve it against" + ) + })?; + let path = dir.join(rel); + Some( + std::fs::read_to_string(&path) + .with_context(|| format!("reading css overlay {}", path.display()))?, + ) + } + None => None, + }; + + raw.resolve(id, css_template, extra_css) +} + +/// Bypasses discovery entirely and resolves straight from the compiled-in +/// `LIQUID_MOTION_TOML`/`LIQUID_MOTION_CSS` constants — used as [`load`]'s +/// fallback specifically *because* it cannot be affected by a broken user +/// override file at the same id (unlike calling `load_named("liquid-motion")` +/// again, which would hit that same broken file first via discovery and +/// fail identically). +fn resolve_builtin() -> ShellTheme { + let value: toml::Value = toml::from_str(builtin::LIQUID_MOTION_TOML) + .expect("compiled-in builtin theme.toml must parse"); + let raw: RawManifest = value + .try_into() + .expect("compiled-in builtin theme.toml must satisfy the manifest schema"); + manifest::validate_slots(&raw, builtin::LIQUID_MOTION_ID) + .expect("compiled-in builtin theme.toml must use only known module names"); + raw.resolve( + builtin::LIQUID_MOTION_ID, + builtin::LIQUID_MOTION_CSS.to_string(), + None, + ) + .expect("compiled-in builtin theme.toml must resolve") +} + +static FALLBACK_LOGGED: std::sync::Once = std::sync::Once::new(); + +/// The active theme. Never fails: a missing or malformed active theme logs +/// once (`tracing::warn!`) and falls back to the compiled-in builtin — "the +/// shell must never fail to start because a theme file is malformed" (plan +/// §4). +pub fn load() -> ShellTheme { + let id = active_theme_id(); + match load_named(&id) { + Ok(theme) => theme, + Err(err) => { + FALLBACK_LOGGED.call_once(|| { + tracing::warn!( + "bread-theme: shell theme '{id}' failed to load ({err:#}); \ + falling back to builtin '{}'", + builtin::LIQUID_MOTION_ID + ); + }); + resolve_builtin() + } + } +} + +fn active_theme_id() -> String { + if let Ok(v) = std::env::var("BREAD_SHELL_THEME") { + if !v.trim().is_empty() { + return v; + } + } + let path = config_home().join("bread/shell.toml"); + if let Ok(text) = std::fs::read_to_string(&path) { + if let Ok(value) = toml::from_str::(&text) { + if let Some(active) = value.get("active").and_then(|v| v.as_str()) { + if !active.trim().is_empty() { + return active.to_string(); + } + } + } + } + builtin::LIQUID_MOTION_ID.to_string() +} + +fn scan_theme_dir( + dir: &std::path::Path, + source: ThemeSource, + out: &mut Vec, + seen: &mut std::collections::HashSet, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let toml_path = entry.path().join("theme.toml"); + let Ok(text) = std::fs::read_to_string(&toml_path) else { + continue; + }; + let Ok(value) = toml::from_str::(&text) else { + continue; + }; + let id = value + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| entry.file_name().to_str().map(|s| s.to_string())); + let Some(id) = id else { continue }; + if !seen.insert(id.clone()) { + continue; + } + let name = value + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&id) + .to_string(); + out.push(ThemeSummary { id, name, source }); + } +} + +/// Every theme discoverable across user config, system dir, and builtins — +/// for a picker UI (plan §5: "bos-settings picker"). User shadows system +/// shadows builtin for the same id, matching [`load_named`]'s discovery +/// order. +pub fn list() -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + scan_theme_dir(&user_themes_dir(), ThemeSource::User, &mut out, &mut seen); + scan_theme_dir( + &system_themes_dir(), + ThemeSource::System, + &mut out, + &mut seen, + ); + if seen.insert(builtin::LIQUID_MOTION_ID.to_string()) { + out.push(ThemeSummary { + id: builtin::LIQUID_MOTION_ID.to_string(), + name: "Liquid Motion".to_string(), + source: ThemeSource::Builtin, + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Guards mutation of XDG_CONFIG_HOME / BREAD_SHELL_THEME, which are + // process-global — mirrors bread_theme::output's XDG_ENV_LOCK pattern + // (a different env var, same reason: cargo test runs a module's tests + // in parallel by default). + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, + dir: PathBuf, + old_xdg: Option, + old_theme_var: Option, + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.old_xdg { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match &self.old_theme_var { + Some(v) => std::env::set_var("BREAD_SHELL_THEME", v), + None => std::env::remove_var("BREAD_SHELL_THEME"), + } + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + /// Isolated `$XDG_CONFIG_HOME` pointing at a fresh temp dir, with + /// `BREAD_SHELL_THEME` cleared so `active_theme_id()` can't pick up + /// whatever's set in the outer test-runner environment. Held for the + /// guard's lifetime. + fn isolated_xdg() -> EnvGuard { + let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!( + "bread-theme-shell-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_xdg = std::env::var("XDG_CONFIG_HOME").ok(); + let old_theme_var = std::env::var("BREAD_SHELL_THEME").ok(); + std::env::set_var("XDG_CONFIG_HOME", &dir); + std::env::remove_var("BREAD_SHELL_THEME"); + EnvGuard { + _lock: lock, + dir, + old_xdg, + old_theme_var, + } + } + + fn write_theme(xdg: &EnvGuard, id: &str, toml_body: &str) { + let dir = xdg.dir.join("bread/themes").join(id); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("theme.toml"), toml_body).unwrap(); + } + + // ---- builtin fidelity ------------------------------------------------- + + #[test] + fn builtin_window_spec_matches_current_breadbar_constants() { + // breadbar/src/main.rs:16-22: BAR_HEIGHT=44, BAR_MARGIN_TOP=12, + // BAR_MARGIN_SIDES=16, CHIP_HEIGHT=32, ICON_PX=24. The demo's + // 14px side margin is deliberately NOT what this asserts. + let theme = resolve_builtin(); + let w = theme.window(); + assert_eq!(w.height, 44); + assert_eq!(w.margin.top, 12); + assert_eq!(w.margin.left, 16); + assert_eq!(w.margin.right, 16); + assert_eq!(w.anchors, vec!["top", "left", "right"]); + assert!(matches!(w.width, Width::Fill)); + assert!(matches!(w.exclusive, Exclusive::Auto)); + assert!(matches!(w.keyboard, Keyboard::None)); + assert_eq!(w.layer, "top"); + + assert_eq!(theme.tokens().chip_height(), 32); + assert_eq!(theme.tokens().icon_px(), 24); + } + + #[test] + fn builtin_tokens_match_theme_rs_load_css_locals() { + // theme.rs::load_css: radius="12px", radius_bar="16px", + // radius_sm="9px", radius_pill="999px", pad="12px". + let theme = resolve_builtin(); + let t = theme.tokens(); + assert_eq!(t.radius_card(), 12); + assert_eq!(t.radius_bar(), 16); + assert_eq!(t.radius_sm(), 9); + assert_eq!(t.radius_pill(), 999); + assert_eq!(t.pad(), 12); + assert_eq!(t.spring(), "cubic-bezier(0.22, 1.35, 0.36, 1)"); + assert_eq!(t.spring_settle(), "cubic-bezier(0.22, 1.2, 0.36, 1)"); + } + + #[test] + fn builtin_covers_all_five_breadbar_namespaces_plus_breadbox() { + let theme = resolve_builtin(); + let rules = theme.compositor_rules(); + for ns in [ + "breadbar", + "breadbar-osd", + "breadbar-notif", + "breadbar-panel", + "breadbar-dismiss", + "breadbox", + ] { + assert!(rules.contains_key(ns), "missing compositor rule for {ns}"); + } + assert!(rules["breadbar"].blur); + assert!(rules["breadbar"].blur_popups); + assert_eq!(rules["breadbar"].animation.as_deref(), Some("slide top")); + assert!(rules["breadbar-dismiss"].no_anim); + assert!(!rules["breadbar-dismiss"].blur); + } + + #[test] + fn builtin_surfaces_are_keyed_by_namespace_and_cover_all_four() { + let theme = resolve_builtin(); + let surfaces = theme.surfaces(); + for ns in [ + "breadbar-notif", + "breadbar-osd", + "breadbar-panel", + "breadbar-dismiss", + ] { + assert!(surfaces.contains_key(ns), "missing surface spec for {ns}"); + } + assert_eq!(surfaces["breadbar-osd"].anchor, "bottom_centre"); + assert!(matches!( + surfaces["breadbar-panel"].width, + SurfaceWidth::Auto + )); + assert!(matches!( + surfaces["breadbar-dismiss"].width, + SurfaceWidth::Fill + )); + } + + #[test] + fn builtin_slots_and_modules_are_trail_and_flip() { + let theme = resolve_builtin(); + assert_eq!(theme.slots().left, vec!["workspaces"]); + assert_eq!(theme.slots().centre, vec!["media", "clock"]); + assert!(matches!( + theme.modules().workspaces.style, + WorkspaceStyle::Trail + )); + assert!(matches!(theme.modules().clock.style, ClockStyle::Flip)); + } + + #[test] + fn builtin_css_substitutes_tokens_and_leaves_palette_names_untouched() { + let theme = resolve_builtin(); + let css = theme.css(&crate::Palette::default()); + assert!( + css.contains("border-radius: 16px"), + "radius_bar not substituted:\n{css}" + ); + assert!( + css.contains("cubic-bezier(0.22, 1.35, 0.36, 1)"), + "spring not substituted:\n{css}" + ); + assert!( + css.contains("@accent, @teal"), + "accent_from/accent_to gradient not substituted:\n{css}" + ); + assert!( + css.contains("@on-bg"), + "palette name resolved when it should pass through:\n{css}" + ); + for placeholder in [ + "{radius_bar}", + "{radius_card}", + "{radius_sm}", + "{radius_pill}", + "{pad}", + "{spring}", + "{spring_settle}", + "{bg_alpha}", + "{accent_from}", + "{accent_to}", + "{chip_height}", + ] { + assert!( + !css.contains(placeholder), + "unsubstituted token placeholder {placeholder}:\n{css}" + ); + } + } + + // ---- extends merge ------------------------------------------------ + + #[test] + fn extends_deep_merges_one_level() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "base", + r#" + name = "Base" + id = "base" + [tokens] + radius_bar = 10 + pad = 8 + [bar.window] + height = 40 + [bar.slots] + left = ["workspaces"] + right = ["battery"] + "#, + ); + write_theme( + &xdg, + "child", + r#" + name = "Child" + id = "child" + extends = "base" + [tokens] + radius_bar = 99 + [bar.slots] + right = ["wifi", "battery"] + "#, + ); + + let theme = load_named("child").expect("child theme should resolve"); + // Overridden by the child. + assert_eq!(theme.tokens().radius_bar(), 99); + // Inherited from the base, untouched by the child. + assert_eq!(theme.tokens().pad(), 8); + assert_eq!(theme.window().height, 40); + // Arrays replace wholesale, not concatenate. + assert_eq!(theme.slots().right, vec!["wifi", "battery"]); + // A key the child never mentions at all stays inherited. + assert_eq!(theme.slots().left, vec!["workspaces"]); + } + + #[test] + fn extends_chain_is_capped_at_one_level() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "grandparent", + r#" + id = "grandparent" + [tokens] + pad = 1 + "#, + ); + write_theme( + &xdg, + "parent", + r#" + id = "parent" + extends = "grandparent" + [tokens] + pad = 2 + "#, + ); + write_theme( + &xdg, + "child", + r#" + id = "child" + extends = "parent" + "#, + ); + + let theme = load_named("child").expect("child theme should resolve"); + // Only one level is honored: child merges with parent (pad=2), and + // parent's own `extends = "grandparent"` is dropped rather than + // chased — the grandparent's pad=1 never applies. + assert_eq!(theme.tokens().pad(), 2); + } + + // ---- validation ------------------------------------------------------ + + #[test] + fn unknown_key_error_names_the_offending_key() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "typo", + r#" + id = "typo" + [bar.window] + heihgt = 44 + "#, + ); + let err = load_named("typo").expect_err("typo'd key must be a hard error"); + let msg = format!("{err:#}"); + assert!( + msg.contains("heihgt"), + "error should name the bad key, got: {msg}" + ); + } + + #[test] + fn unknown_top_level_key_is_an_error() { + let xdg = isolated_xdg(); + write_theme(&xdg, "typo2", "id = \"typo2\"\nfont = \"nope\"\n"); + let err = load_named("typo2").expect_err("unknown top-level key must be a hard error"); + assert!(format!("{err:#}").contains("font")); + } + + #[test] + fn unknown_slot_module_names_the_module() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "badmodule", + r#" + id = "badmodule" + [bar.slots] + left = ["teleporter"] + "#, + ); + let err = load_named("badmodule").expect_err("unknown module must be a hard error"); + let msg = format!("{err:#}"); + assert!( + msg.contains("teleporter"), + "error should name the module, got: {msg}" + ); + } + + #[test] + fn widget_prefixed_slot_entries_are_always_valid() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "widgetslot", + r#" + id = "widgetslot" + [bar.slots] + left = ["widget:my-lua-module"] + "#, + ); + let theme = load_named("widgetslot").expect("widget: entries should validate"); + assert_eq!(theme.slots().left, vec!["widget:my-lua-module"]); + } + + #[test] + fn invalid_enum_value_is_an_error() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "badstyle", + r#" + id = "badstyle" + [modules.workspaces] + style = "hexagon" + "#, + ); + let err = load_named("badstyle").expect_err("invalid style must be an error"); + assert!(format!("{err:#}").contains("hexagon")); + } + + // ---- fallback on broken theme ----------------------------------------- + + #[test] + fn malformed_active_theme_falls_back_to_builtin_without_panicking() { + let xdg = isolated_xdg(); + // A broken override sitting at the *active* id's own path — this is + // the case resolve_builtin() exists to survive without re-touching + // the same broken file. + write_theme(&xdg, "liquid-motion", "this is not [valid toml"); + + let theme = load(); // must not panic + assert_eq!(theme.window().height, 44); + assert_eq!(theme.id(), "liquid-motion"); + } + + #[test] + fn missing_active_theme_falls_back_to_builtin() { + let xdg = isolated_xdg(); + std::env::set_var("BREAD_SHELL_THEME", "does-not-exist"); + let theme = load(); + assert_eq!(theme.id(), "liquid-motion"); + drop(xdg); + } + + #[test] + fn load_named_on_broken_theme_returns_err_not_panic() { + let xdg = isolated_xdg(); + write_theme(&xdg, "broken", "not = [valid"); + assert!(load_named("broken").is_err()); + } + + // ---- css() / extra.css ------------------------------------------------- + + #[test] + fn extra_css_overlay_is_appended_and_token_substituted() { + let xdg = isolated_xdg(); + let dir = xdg.dir.join("bread/themes/overlaid"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("theme.toml"), + r#" + id = "overlaid" + extends = "liquid-motion" + css = "extra.css" + [tokens] + pad = 21 + "#, + ) + .unwrap(); + std::fs::write(dir.join("extra.css"), ".custom { margin: {pad}px; }\n").unwrap(); + + let theme = load_named("overlaid").expect("theme with extra.css should resolve"); + let css = theme.css(&crate::Palette::default()); + assert!( + css.contains(".custom { margin: 21px; }"), + "overlay not appended/substituted:\n{css}" + ); + // The inherited liquid-motion template should still be present too. + assert!(css.contains("window.breadbar")); + } + + // ---- active theme resolution ------------------------------------------- + + #[test] + fn env_var_overrides_shell_toml_active() { + let xdg = isolated_xdg(); + std::fs::create_dir_all(xdg.dir.join("bread")).unwrap(); + std::fs::write( + xdg.dir.join("bread/shell.toml"), + "active = \"liquid-motion\"\n", + ) + .unwrap(); + write_theme(&xdg, "envwins", "id = \"envwins\"\n"); + std::env::set_var("BREAD_SHELL_THEME", "envwins"); + assert_eq!(active_theme_id(), "envwins"); + } + + #[test] + fn shell_toml_active_used_when_no_env_var() { + let xdg = isolated_xdg(); + std::fs::create_dir_all(xdg.dir.join("bread")).unwrap(); + std::fs::write(xdg.dir.join("bread/shell.toml"), "active = \"from-file\"\n").unwrap(); + assert_eq!(active_theme_id(), "from-file"); + } + + #[test] + fn defaults_to_liquid_motion_with_nothing_configured() { + let _xdg = isolated_xdg(); + assert_eq!(active_theme_id(), "liquid-motion"); + } + + // ---- list() ------------------------------------------------------------ + + #[test] + fn list_includes_user_themes_and_the_builtin() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "custom-one", + "id = \"custom-one\"\nname = \"Custom One\"\n", + ); + let summaries = list(); + assert!(summaries + .iter() + .any(|s| s.id == "custom-one" && s.source == ThemeSource::User)); + assert!(summaries + .iter() + .any(|s| s.id == "liquid-motion" && s.source == ThemeSource::Builtin)); + } + + #[test] + fn list_user_theme_shadows_builtin_of_the_same_id() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "liquid-motion", + "id = \"liquid-motion\"\nname = \"User Override\"\n", + ); + let summaries = list(); + let matches: Vec<_> = summaries + .iter() + .filter(|s| s.id == "liquid-motion") + .collect(); + assert_eq!( + matches.len(), + 1, + "same id must not appear twice: {summaries:?}" + ); + assert_eq!(matches[0].source, ThemeSource::User); + } +} diff --git a/bread-theme/src/shell/types.rs b/bread-theme/src/shell/types.rs new file mode 100644 index 0000000..06982a2 --- /dev/null +++ b/bread-theme/src/shell/types.rs @@ -0,0 +1,358 @@ +//! Fully-resolved shell theme types — see `bread-theme/src/shell/mod.rs` for +//! the module overview and `manifest.rs` for how these are built from TOML. +//! +//! Every type here has all defaults filled in; there is no further "is this +//! set" branching once a `ShellTheme` exists. That resolution work happens +//! once, in `manifest.rs`, so consumers (breadbar, breadbox, bos-settings) +//! never have to know the manifest format at all. + +use std::collections::BTreeMap; + +/// Workspace strip rendering. Phase 1 ships only `Trail` (what breadbar draws +/// today); `Pill`/`Dots` exist now so 02/04 (plan §11 phases 5-6) are additive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceStyle { + Trail, + Pill, + Dots, +} + +/// Clock rendering. Phase 1 ships only `Flip` (today's per-digit flip clock). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClockStyle { + Flip, + Plain, + None, +} + +/// How the launcher attaches to the shell. Phase 1 ships only `Overlay` +/// (breadbox's own window); `Embedded` is theme 04's bar-drawer launcher. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LauncherMode { + Overlay, + Embedded, +} + +/// `gtk4_layer_shell::KeyboardMode` mirror, kept independent of the `gtk` +/// feature so the manifest types stay usable without GTK linked in (bread, +/// breadcrumbs). Values map 1:1 onto `KeyboardMode::{None,Exclusive,OnDemand}` +/// (verified against gtk4-layer-shell 0.8.1's `src/auto/enums.rs`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Keyboard { + None, + OnDemand, + Exclusive, +} + +/// `bar.window.width` / a surface's `width`: `"fill"` spans the anchored +/// edges, a bare number is a fixed/centred/hug width. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Width { + Fill, + Px(i32), +} + +/// `bar.window.exclusive`: `"auto"` reserves `height + margin.top`, `"none"` +/// reserves nothing (theme 04's capsule), or a literal pixel override. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Exclusive { + Auto, + None, + Px(i32), +} + +/// A satellite surface's width: unlike the bar window, a satellite can also +/// be `Auto` — sized by its own content/CSS with no `set_default_width` call +/// at all. `breadbar-panel` is exactly this today (popover content decides +/// its width via `.control-panel-inner`/`.wifi-popover-inner` min-width, not +/// the layer-shell window). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SurfaceWidth { + Fill, + Auto, + Px(i32), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Margin { + pub top: i32, + pub left: i32, + pub right: i32, + pub bottom: i32, +} + +/// `bar.window` — plan §2: window shape is data, not a closed layout enum. +/// Island/Edge/Capsule are three *values* of this struct, not three code +/// paths. +#[derive(Debug, Clone, PartialEq)] +pub struct WindowSpec { + pub anchors: Vec, + pub width: Width, + pub height: i32, + pub margin: Margin, + pub exclusive: Exclusive, + pub keyboard: Keyboard, + pub layer: String, +} + +impl Default for WindowSpec { + /// Generic baseline for a theme that omits `[bar.window]` entirely — + /// deliberately the plan §4 schema example's numbers, not necessarily + /// any particular shipped theme's. `liquid-motion` sets every field + /// explicitly, so it never falls through to this. + fn default() -> Self { + WindowSpec { + anchors: vec!["top".into(), "left".into(), "right".into()], + width: Width::Fill, + height: 44, + margin: Margin { + top: 12, + left: 14, + right: 14, + bottom: 0, + }, + exclusive: Exclusive::Auto, + keyboard: Keyboard::None, + layer: "top".into(), + } + } +} + +/// `bar.slots` — plan §2: structure is slots, not layout code. `drawer` is +/// the only thing Capsule/theme-04 adds over Island, and it's just an +/// (empty, for now) slot list, not a code path. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Slots { + pub left: Vec, + pub centre: Vec, + pub right: Vec, + pub drawer: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspacesModule { + pub style: WorkspaceStyle, + pub show_empty: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClockModule { + pub style: ClockStyle, + pub format: String, + pub show_date: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Modules { + pub workspaces: WorkspacesModule, + pub clock: ClockModule, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Launcher { + pub mode: LauncherMode, + pub width: i32, + pub top: String, + pub radius: i32, + pub icon_px: i32, + pub row_anim: String, + pub rule: String, + pub footer: String, + pub sections: bool, + pub modes: Vec, +} + +/// A satellite surface, keyed by layer-shell namespace in `[surfaces.*]` — +/// deliberately the same keyspace as `[compositor.*]` (see module docs) +/// rather than a role name, so the two tables can be validated against each +/// other and a namespace's positioning and compositor treatment live under +/// one lookup. +#[derive(Debug, Clone, PartialEq)] +pub struct Surface { + pub anchor: String, + pub offset: Vec, + pub width: SurfaceWidth, + pub layer: String, +} + +/// One `[compositor.*]` entry — plan §9: the per-namespace layer-shell rule +/// an app ships as its default and a theme may override. Mirrors the field +/// set `hl.layer_rule` actually accepts in `scripts/ui/rules.lua` (blur, +/// ignore_alpha, blur_popups, animation, no_anim) — that Lua API isn't in +/// hyprland-api.lua's type annotations, so this field set is evidenced by +/// working usage, not documentation (plan §12 risk 3). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct LayerRule { + pub blur: bool, + pub ignore_alpha: Option, + pub blur_popups: bool, + /// Passed through verbatim to `hl.layer_rule`'s `animation` field + /// (`"slide top"`, `"slide bottom"`, …) — kept as a plain string rather + /// than a closed enum since `hl.layer_rule`'s own field set is only + /// evidenced by working usage in `rules.lua`, not documented (plan §12 + /// risk 3); a closed Rust enum here would need updating in lockstep + /// with Hyprland additions this crate has no way to know about. + pub animation: Option, + pub no_anim: bool, +} + +/// A raw TOML scalar carried through to [`Tokens`] for `{name}` substitution +/// in [`crate::shell::ShellTheme::css`]. Kept untyped (rather than forcing +/// every token into a `String`) so `css()` can format a number without a +/// theme author having to quote it, while `bg_alpha = 0.72` etc. still round +/// -trips as a real float for any future non-string consumer. +#[derive(Debug, Clone, PartialEq)] +pub enum TokenValue { + Str(String), + Int(i64), + Float(f64), + Bool(bool), +} + +impl TokenValue { + /// Textual form used both for `{name}` substitution in CSS and for the + /// typed accessors' fallback formatting. + pub fn as_css(&self) -> String { + match self { + TokenValue::Str(s) => s.clone(), + TokenValue::Int(i) => i.to_string(), + TokenValue::Float(f) => { + if f.fract() == 0.0 { + format!("{f:.0}") + } else { + f.to_string() + } + } + TokenValue::Bool(b) => b.to_string(), + } + } +} + +/// `[tokens]`, resolved. Deliberately an open bag (`BTreeMap`), not a fixed +/// struct: the schema (plan §4) names eleven fields, but a theme may define +/// arbitrary extra keys purely for `{name}` substitution in `extra.css` +/// (`radius_pill`, `chip_height`, `icon_px`, `spring_settle` below are all +/// exactly this — real values `theme.rs::load_css` uses today that the plan +/// text's `[tokens]` example didn't list). The named accessors below give +/// the documented fields typed access with sensible defaults; [`Tokens::get`] +/// and [`Tokens::substitute`] cover everything else. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Tokens { + pub(crate) map: BTreeMap, +} + +impl Tokens { + pub fn from_map(map: BTreeMap) -> Self { + Tokens { map } + } + + pub fn get(&self, key: &str) -> Option<&TokenValue> { + self.map.get(key) + } + + pub fn keys(&self) -> impl Iterator { + self.map.keys().map(|s| s.as_str()) + } + + fn str_or(&self, key: &str, default: &str) -> String { + match self.map.get(key) { + Some(v) => v.as_css(), + None => default.to_string(), + } + } + + fn int_or(&self, key: &str, default: i64) -> i64 { + match self.map.get(key) { + Some(TokenValue::Int(i)) => *i, + Some(TokenValue::Float(f)) => *f as i64, + Some(TokenValue::Str(s)) => s.parse().unwrap_or(default), + _ => default, + } + } + + fn float_or(&self, key: &str, default: f64) -> f64 { + match self.map.get(key) { + Some(TokenValue::Float(f)) => *f, + Some(TokenValue::Int(i)) => *i as f64, + Some(TokenValue::Str(s)) => s.parse().unwrap_or(default), + _ => default, + } + } + + pub fn font_family(&self) -> String { + self.str_or("font_family", crate::tokens::FONT_FAMILY) + } + pub fn font_fallback(&self) -> String { + self.str_or("font_fallback", "sans-serif") + } + pub fn font_size_base(&self) -> i64 { + self.int_or("font_size_base", crate::tokens::FONT_SIZE_BASE as i64) + } + pub fn radius_bar(&self) -> i64 { + self.int_or("radius_bar", crate::tokens::RADIUS_PRIMARY as i64) + } + pub fn radius_card(&self) -> i64 { + self.int_or("radius_card", crate::tokens::RADIUS_PRIMARY as i64) + } + pub fn radius_sm(&self) -> i64 { + self.int_or("radius_sm", crate::tokens::RADIUS_SECONDARY as i64) + } + /// Not in the plan §4 schema list, but a named local in + /// `theme.rs::load_css` (`radius_pill = "999px"`) alongside the three + /// siblings that are. See the [`Tokens`] doc comment. + pub fn radius_pill(&self) -> i64 { + self.int_or("radius_pill", crate::tokens::RADIUS_PILL as i64) + } + pub fn pad(&self) -> i64 { + self.int_or("pad", crate::tokens::SPACE_MD as i64) + } + pub fn bg_alpha(&self) -> f64 { + self.float_or("bg_alpha", 0.72) + } + /// The overshoot/bounce curve (`0.22, 1.35, 0.36, 1`) — clock flips, + /// pop-ins, the workspace caret draw. + pub fn spring(&self) -> String { + self.str_or("spring", "cubic-bezier(0.22, 1.35, 0.36, 1)") + } + /// The settle curve (`0.22, 1.2, 0.36, 1`) — hovers, workspace-btn + /// opacity/background transitions, OSD/notification slide-ins. Not in + /// the plan §4 schema (which names only `spring`), but `theme.rs` uses + /// it just as pervasively as the overshoot curve. See [`Tokens`] doc. + pub fn spring_settle(&self) -> String { + self.str_or("spring_settle", "cubic-bezier(0.22, 1.2, 0.36, 1)") + } + pub fn accent_from(&self) -> String { + self.str_or("accent_from", "accent") + } + pub fn accent_to(&self) -> String { + let from = self.accent_from(); + self.str_or("accent_to", &from) + } + /// Workspace-pill / chip height. Not in the plan §4 schema, but + /// `breadbar::CHIP_HEIGHT` (32) today. See [`Tokens`] doc. + pub fn chip_height(&self) -> i64 { + self.int_or("chip_height", 32) + } + /// Not in the plan §4 schema, but `breadbar::ICON_PX` (24) today. See + /// [`Tokens`] doc. + pub fn icon_px(&self) -> i64 { + self.int_or("icon_px", 24) + } + + /// Replace every `{name}` occurrence in `template` with that token's + /// [`TokenValue::as_css`] form. Longest names are substituted first + /// (mirrors [`crate::resolve_color_names`]) so `{radius}` cannot + /// half-consume `{radius_bar}` if a theme happens to define both. + /// `@name` palette references are untouched — this only ever looks at + /// `{...}` tokens. + pub fn substitute(&self, template: &str) -> String { + let mut keys: Vec<&String> = self.map.keys().collect(); + keys.sort_by_key(|k| std::cmp::Reverse(k.len())); + let mut out = template.to_string(); + for k in keys { + let value = self.map[k].as_css(); + out = out.replace(&format!("{{{k}}}"), &value); + } + out + } +}