Add bread_theme::shell manifest system (Phase 1)

Implements the shell theme manifest layer from THEME_SYSTEM_PLAN.md
§4-5: ShellTheme/WindowSpec/Slots/Tokens/LayerRule types, TOML
discovery (user -> system -> compiled-in builtin), one level of
`extends` deep-merge, deny_unknown_fields validation naming the
offending key, slot module-name validation, and css() token
substitution with an extra.css overlay. load() never fails, falling
back to the compiled-in builtin and logging once.

Ships exactly one builtin manifest, liquid-motion, describing
breadbar/breadbox as they exist today (not the design-doc demo, which
disagrees with the code on bar side margin, launcher geometry, and the
easing curves). Compositor rules and surface specs are keyed by
layer-shell namespace and cover all five breadbar namespaces plus
breadbox/breadbar-panel/breadbar-dismiss.

watch() is gated behind the existing `gtk` feature (gio::FileMonitor
is a gtk4 dependency); the rest of the module is gtk-free so bread and
breadcrumbs can validate a theme without linking GTK. No consumer
changes — breadbar/breadbox still use their own hardcoded values.
This commit is contained in:
Breadway 2026-08-23 15:21:08 +08:00
parent 347f356b1d
commit 96aa6a513b
10 changed files with 2097 additions and 0 deletions

3
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: Fn(super::ShellTheme) + 'static>(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
}

View file

@ -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:<lua-module-name>)",
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<String>,
pub(super) id: Option<String>,
/// 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<String>,
#[serde(default)]
pub(super) tokens: HashMap<String, toml::Value>,
pub(super) bar: Option<RawBar>,
pub(super) modules: Option<RawModules>,
pub(super) launcher: Option<RawLauncher>,
pub(super) surfaces: Option<HashMap<String, RawSurface>>,
pub(super) compositor: Option<HashMap<String, RawLayerRule>>,
/// 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<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawBar {
pub(super) window: Option<RawWindow>,
pub(super) slots: Option<RawSlots>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawWindow {
pub(super) anchors: Option<Vec<String>>,
pub(super) width: Option<RawSize>,
pub(super) height: Option<i64>,
pub(super) margin: Option<RawMargin>,
pub(super) exclusive: Option<RawExclusive>,
pub(super) keyboard: Option<String>,
pub(super) layer: Option<String>,
}
#[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<String>,
pub(super) centre: Vec<String>,
pub(super) right: Vec<String>,
pub(super) drawer: Vec<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawModules {
pub(super) workspaces: Option<RawWorkspacesModule>,
pub(super) clock: Option<RawClockModule>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawWorkspacesModule {
pub(super) style: Option<String>,
pub(super) show_empty: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawClockModule {
pub(super) style: Option<String>,
pub(super) format: Option<String>,
pub(super) show_date: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawLauncher {
pub(super) mode: Option<String>,
pub(super) width: Option<i64>,
pub(super) top: Option<String>,
pub(super) radius: Option<i64>,
pub(super) icon_px: Option<i64>,
pub(super) row_anim: Option<String>,
pub(super) rule: Option<String>,
pub(super) footer: Option<String>,
pub(super) sections: Option<bool>,
pub(super) modes: Option<Vec<String>>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawSurface {
pub(super) anchor: Option<String>,
pub(super) offset: Option<RawOffset>,
pub(super) width: Option<RawSurfaceWidth>,
pub(super) layer: Option<String>,
}
#[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<bool>,
pub(super) ignore_alpha: Option<f64>,
pub(super) blur_popups: Option<bool>,
pub(super) animation: Option<String>,
pub(super) no_anim: Option<bool>,
}
fn token_value(v: &toml::Value) -> anyhow::Result<TokenValue> {
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<WindowSpec> {
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<Modules> {
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<Launcher> {
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<String, RawSurface>>,
) -> anyhow::Result<BTreeMap<String, Surface>> {
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<String, RawLayerRule>>) -> BTreeMap<String, LayerRule> {
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<String>,
) -> anyhow::Result<super::ShellTheme> {
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,
}
}

View file

@ -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/<id>/theme.toml` (user)
//! 2. `/usr/share/bread/themes/<id>/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<String, Surface>,
compositor: BTreeMap<String, LayerRule>,
css_template: String,
extra_css: Option<String>,
}
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<String, Surface> {
&self.surfaces
}
pub fn compositor_rules(&self) -> &BTreeMap<String, LayerRule> {
&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<Source> {
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<PathBuf>)> {
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<ShellTheme> {
resolve_theme(id, 0)
}
fn resolve_theme(id: &str, extends_depth: u8) -> anyhow::Result<ShellTheme> {
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::<toml::Value>(&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<ThemeSummary>,
seen: &mut std::collections::HashSet<String>,
) {
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::<toml::Value>(&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<ThemeSummary> {
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<String>,
old_theme_var: Option<String>,
}
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);
}
}

View file

@ -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<String>,
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<String>,
pub centre: Vec<String>,
pub right: Vec<String>,
pub drawer: Vec<String>,
}
#[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<String>,
}
/// 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<f64>,
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<f64>,
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<String>,
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<String, TokenValue>,
}
impl Tokens {
pub fn from_map(map: BTreeMap<String, TokenValue>) -> Self {
Tokens { map }
}
pub fn get(&self, key: &str) -> Option<&TokenValue> {
self.map.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
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
}
}