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

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