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

68
bread-theme/src/anim.rs Normal file
View file

@ -0,0 +1,68 @@
//! `anim::spring_to` — THEME_SYSTEM_PLAN.md §7/§8: GTK4 has no CSS
//! width/height transition on a widget or a layer-shell surface (unlike the
//! demos' `transition: width .45s var(--spring)` /
//! `transition: max-height .4s var(--spring)`), so a size change that wants
//! to animate has to interpolate a plain integer over the frame clock
//! instead and re-apply it (`set_size_request`, `set_default_width`, ...)
//! every frame.
//!
//! This is the same house technique `breadbar::bar::workspaces::WorkspaceTrail`
//! already uses for the workspace trail's stretch/snap (a local, un-exported
//! `ease`/`ease_overshoot` pair driven by `add_tick_callback` and
//! `Instant::elapsed`) — lifted here, generalized to a plain `i32 -> i32`
//! interpolation with a caller-supplied frame callback, so theme 04's
//! capsule-drawer expand/collapse (and any future popover that wants the
//! same effect) doesn't have to reimplement it.
use gtk4::glib::ControlFlow;
use gtk4::prelude::*;
/// Approximates `cubic-bezier(0.22, 1.35, 0.36, 1)` — the overshoot/"spring"
/// curve every builtin theme's `tokens.spring` names (`Tokens::spring`'s own
/// default). Not a literal bezier solve (same approximation
/// `bar::workspaces::ease_overshoot` uses) — exact enough that the eye can't
/// tell it apart from the real curve at animation speeds.
fn spring_ease(t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
let c = 1.35;
let t1 = t - 1.0;
1.0 + t1 * t1 * ((c + 1.0) * t1 + c)
}
/// Interpolates from `from` to `to` over `duration_ms`, calling `on_frame`
/// with each intermediate value (and, on the final tick, the exact `to` —
/// never an off-by-rounding near-miss) via `widget`'s frame clock.
///
/// Returns the [`gtk4::TickCallbackId`] so a caller that might need to
/// interrupt an in-flight run (e.g. the drawer re-opening before its close
/// animation finished) can `.remove()` it early; a run left to finish on its
/// own needs no cleanup — the callback self-terminates by returning
/// [`ControlFlow::Break`] once `duration_ms` has elapsed, same as
/// `WorkspaceTrail`'s own tick callbacks.
pub fn spring_to(
widget: &impl IsA<gtk4::Widget>,
from: i32,
to: i32,
duration_ms: f64,
on_frame: impl FnMut(i32) + 'static,
) -> gtk4::TickCallbackId {
let started = std::time::Instant::now();
// `add_tick_callback` requires `Fn`, not `FnMut` — the caller's frame
// closure almost always needs to mutate captured state (a widget's size
// request, an `Rc<Cell<..>>` flag), so it's boxed behind a `RefCell`
// here rather than pushing `Cell`/`RefCell` plumbing onto every call
// site (every current and future caller wants `FnMut`, none want `Fn`).
let on_frame = std::cell::RefCell::new(on_frame);
widget.add_tick_callback(move |_, _| {
let elapsed = started.elapsed().as_secs_f64() * 1000.0;
let mut on_frame = on_frame.borrow_mut();
if elapsed >= duration_ms {
on_frame(to);
return ControlFlow::Break;
}
let t = spring_ease(elapsed / duration_ms);
let value = from as f64 + (to - from) as f64 * t;
on_frame(value.round() as i32);
ControlFlow::Continue
})
}

View file

@ -1,6 +1,8 @@
#[cfg(feature = "adw")]
pub mod adw;
#[cfg(feature = "gtk")]
pub mod anim;
#[cfg(feature = "gtk")]
pub mod gtk;
mod layerrules;
mod output;

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