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:
parent
347f356b1d
commit
96aa6a513b
10 changed files with 2097 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
23
bread-theme/src/shell/builtin.rs
Normal file
23
bread-theme/src/shell/builtin.rs
Normal 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"
|
||||
));
|
||||
32
bread-theme/src/shell/hotreload.rs
Normal file
32
bread-theme/src/shell/hotreload.rs
Normal 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
|
||||
}
|
||||
518
bread-theme/src/shell/manifest.rs
Normal file
518
bread-theme/src/shell/manifest.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
899
bread-theme/src/shell/mod.rs
Normal file
899
bread-theme/src/shell/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
358
bread-theme/src/shell/types.rs
Normal file
358
bread-theme/src/shell/types.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue