shell: add spotlight builtin (theme 04), dots/placeholder-clock schema, and anim::spring_to

- bread-theme:🐚 WorkspacesModule.dot_widths and ClockModule.placeholder_clock,
  resolved/validated in manifest.rs and modeled in types.rs, so a theme can
  actually configure dot-pill widths and an entry-as-clock placeholder
  instead of those staying schema-only.
- New compiled-in builtin "spotlight" (assets/shell/spotlight/), registered
  in builtin.rs so list() returns three themes: centred capsule window spec
  (anchors=[top] only, width=480, exclusive=none, keyboard=on_demand),
  workspaces.style=dots, clock.style=none+placeholder_clock, launcher.mode=
  embedded, drawer=[launcher_results]. Colours are palette token names
  (pink), never hex.
- bread-theme::anim::spring_to: a reusable TickCallback size-interpolation
  helper (GTK4 has no CSS width/height transition on a widget or layer-shell
  surface), generalized from WorkspaceTrail's existing ease/tick pattern, for
  breadbar's capsule-drawer expand/collapse.
- bread_launcher::LAUNCHER_APP: the launcher's one shared on-disk identity
  (cache/config/history), so breadbar's embedded capsule and breadbox's
  overlay window read and write the SAME cache and launch history rather
  than forking into two rankings of a user's apps.

Tests: +6 spotlight builtin tests (loads/in list/capsule window shape/
embedded mode/dots widths/flat-pink-not-hex), bread-theme --lib 63->69,
bread-launcher --lib unchanged at 18.
This commit is contained in:
Breadway 2026-08-25 00:21:14 +08:00
parent 6ffd689a4d
commit 8c5e42230a
9 changed files with 505 additions and 2 deletions

View file

@ -35,6 +35,18 @@ const GLASS_WORKBENCH_CSS: &str = include_str!(concat!(
"/assets/shell/glass-workbench/glass-workbench.css"
));
pub const SPOTLIGHT_ID: &str = "spotlight";
const SPOTLIGHT_TOML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/spotlight/theme.toml"
));
const SPOTLIGHT_CSS: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/spotlight/spotlight.css"
));
/// One compiled-in theme's identity plus its two `include_str!`ed assets.
/// `id`/`name` are also duplicated inside `toml`'s own `id =`/`name =`
/// fields — kept here too so [`all`]/[`find`] can list/look up a builtin
@ -68,6 +80,12 @@ pub const ALL: &[BuiltinTheme] = &[
toml: GLASS_WORKBENCH_TOML,
css: GLASS_WORKBENCH_CSS,
},
BuiltinTheme {
id: SPOTLIGHT_ID,
name: "Spotlight",
toml: SPOTLIGHT_TOML,
css: SPOTLIGHT_CSS,
},
];
/// Looks up a compiled-in theme by id — `None` means "not a builtin",

View file

@ -156,6 +156,11 @@ pub(super) struct RawModules {
pub(super) struct RawWorkspacesModule {
pub(super) style: Option<String>,
pub(super) show_empty: Option<bool>,
/// `[6, 10, 14, 18]`-shaped — see [`DotWidths`]. A length other than 4
/// is a hard error (validated in [`resolve_modules`]) rather than
/// silently truncated/padded, matching this file's "typo'd key is a
/// hard error" policy for enum-ish values.
pub(super) dot_widths: Option<Vec<i64>>,
}
#[derive(Debug, serde::Deserialize)]
@ -164,6 +169,7 @@ pub(super) struct RawClockModule {
pub(super) style: Option<String>,
pub(super) format: Option<String>,
pub(super) show_date: Option<bool>,
pub(super) placeholder_clock: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
@ -318,6 +324,15 @@ fn resolve_modules(theme_id: &str, m: Option<&RawModules>) -> anyhow::Result<Mod
),
};
let show_empty = ws.and_then(|w| w.show_empty).unwrap_or(true);
let dot_widths = match ws.and_then(|w| w.dot_widths.as_ref()) {
None => DEFAULT_DOT_WIDTHS,
Some(v) if v.len() == 4 => [v[0] as i32, v[1] as i32, v[2] as i32, v[3] as i32],
Some(v) => bail!(
"theme '{theme_id}': modules.workspaces.dot_widths has {} entries, expected 4 \
(0/1/2/3-or-more open windows)",
v.len()
),
};
let ck = m.and_then(|m| m.clock.as_ref());
let cstyle = match ck.and_then(|c| c.style.as_deref()) {
@ -333,13 +348,19 @@ fn resolve_modules(theme_id: &str, m: Option<&RawModules>) -> anyhow::Result<Mod
.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);
let placeholder_clock = ck.and_then(|c| c.placeholder_clock).unwrap_or(false);
Ok(Modules {
workspaces: WorkspacesModule { style, show_empty },
workspaces: WorkspacesModule {
style,
show_empty,
dot_widths,
},
clock: ClockModule {
style: cstyle,
format,
show_date,
placeholder_clock,
},
})
}

View file

@ -721,6 +721,103 @@ mod tests {
);
}
// ---- spotlight builtin (plan §11 phase 6) ------------------------------
#[test]
fn spotlight_loads_and_appears_in_list_alongside_the_other_two() {
let theme = load_named(builtin::SPOTLIGHT_ID).expect("spotlight builtin should resolve");
assert_eq!(theme.id(), "spotlight");
assert_eq!(theme.name(), "Spotlight");
let summaries = list();
assert!(
summaries
.iter()
.any(|s| s.id == "spotlight" && s.source == ThemeSource::Builtin),
"spotlight missing from list(): {summaries:?}"
);
// All three builtins must be listed side by side — adding the third
// must not have displaced either of the first two.
for id in ["liquid-motion", "glass-workbench"] {
assert!(
summaries
.iter()
.any(|s| s.id == id && s.source == ThemeSource::Builtin),
"{id} missing from list() after adding spotlight: {summaries:?}"
);
}
}
#[test]
fn spotlight_window_is_a_centred_capsule_not_a_full_width_bar() {
let theme = load_named(builtin::SPOTLIGHT_ID).expect("spotlight should resolve");
let w = theme.window();
// Anchored top ONLY — neither left nor right, which is what makes
// gtk4-layer-shell centre the surface instead of stretching it
// (THEME_SYSTEM_PLAN.md §7).
assert_eq!(w.anchors, vec!["top"]);
assert_eq!(w.width, Width::Px(480));
assert_eq!(w.height, 36);
assert_eq!(w.margin.top, 16);
assert_eq!(
w.exclusive,
Exclusive::None,
"the capsule floats over tiled content, unlike Island/Edge's reserved strip"
);
assert_eq!(
w.keyboard,
Keyboard::OnDemand,
"keyboard focus hands over only while launcher_entry holds it"
);
}
#[test]
fn spotlight_launcher_is_embedded_not_an_overlay_window() {
let theme = load_named(builtin::SPOTLIGHT_ID).expect("spotlight should resolve");
assert_eq!(theme.launcher().mode, LauncherMode::Embedded);
assert_eq!(theme.slots().centre, vec!["launcher_entry"]);
assert_eq!(theme.slots().drawer, vec!["launcher_results"]);
assert!(
!theme.slots().drawer.is_empty(),
"spotlight is the one builtin that actually uses the drawer slot"
);
}
#[test]
fn spotlight_workspaces_are_dots_with_the_demos_own_widths() {
let theme = load_named(builtin::SPOTLIGHT_ID).expect("spotlight should resolve");
assert!(matches!(theme.modules().workspaces.style, WorkspaceStyle::Dots));
assert_eq!(theme.modules().workspaces.dot_widths, [6, 10, 14, 18]);
}
#[test]
fn spotlight_clock_is_none_with_placeholder_clock_set() {
let theme = load_named(builtin::SPOTLIGHT_ID).expect("spotlight should resolve");
assert!(matches!(theme.modules().clock.style, ClockStyle::None));
assert!(
theme.modules().clock.placeholder_clock,
"spotlight's entry placeholder stands in for a clock module entirely"
);
}
#[test]
fn spotlight_accent_is_flat_pink_and_a_palette_token_not_hex() {
let theme = load_named(builtin::SPOTLIGHT_ID).expect("spotlight should resolve");
let t = theme.tokens();
assert_eq!(t.accent_from(), "pink");
assert_eq!(t.accent_to(), "pink");
let css = theme.css(&crate::Palette::default());
assert!(
css.contains("@pink"),
"accent_from/accent_to must resolve to the @pink palette token, not a hex literal:\n{css}"
);
assert!(
!css.contains("#e87898"),
"the demo's pink hex must never leak into the manifest — pywal theming depends on \
this staying a palette token name:\n{css}"
);
}
// ---- extends merge ------------------------------------------------
#[test]

View file

@ -25,6 +25,16 @@ pub enum ClockStyle {
None,
}
/// Dot pill widths (px) for 0/1/2/3-or-more open windows, `style = "dots"`
/// (theme 04/spotlight). Index 3 covers "3 or more" — the demo's own dots
/// never grow past that fourth width. Unused by `Trail`/`Pill`.
pub type DotWidths = [i32; 4];
/// The demo's own numbers (`04-spotlight.html`'s `.dots button[data-n="N"]`
/// rules) — the default a theme gets if it sets `style = "dots"` but omits
/// `dot_widths`.
pub const DEFAULT_DOT_WIDTHS: DotWidths = [6, 10, 14, 18];
/// 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)]
@ -133,6 +143,8 @@ pub struct Slots {
pub struct WorkspacesModule {
pub style: WorkspaceStyle,
pub show_empty: bool,
/// `style = "dots"` only — see [`DotWidths`]. Trail/Pill ignore this.
pub dot_widths: DotWidths,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -140,6 +152,12 @@ pub struct ClockModule {
pub style: ClockStyle,
pub format: String,
pub show_date: bool,
/// `style = "none"` + this `true`: no module renders a clock label of
/// its own — `launcher_entry`'s placeholder text becomes the time
/// instead (theme 04/spotlight: the capsule's entry IS the clock until
/// focused, per `04-spotlight.html`'s `q.placeholder = t`). Meaningless
/// for `Flip`/`Plain`, which already show a time some other way.
pub placeholder_clock: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]