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

@ -0,0 +1,73 @@
/* CSS template for the spotlight builtin (bread-theme/src/shell/builtin.rs).
* Same scope and substitution rules as liquid-motion.css/glass-workbench.css:
* only the window/workspace/clock chrome the manifest's own concepts model.
*
* NOTE (unlike its two siblings): breadbar does not actually call
* `ShellTheme::css()` today its live stylesheet is the hand-written,
* token-driven `breadbar::theme::load_css()`, which is where the capsule's
* real chrome (dots, `.launcher-entry`, `.bread-drawer`/results rows) lives.
* This file exists so the manifest's own template mechanism stays exercised
* for all three builtins equally (and so a future `bread-theme` CLI/test
* that DOES call `.css()` sees a real spotlight template) see
* `ShellTheme::css`'s own doc comment for why that gap between "what the
* manifest models" and "what breadbar actually renders" is pre-existing,
* not new to this file.
*
* Source: bos-ui-demos/04-spotlight.html's <style> block. No trail overlay
* (dots never call `WorkspaceTrail::place`/`stretch`, same as pill) and no
* per-digit flip (`style = "none"` no clock label markup at all).
*/
window.breadbar {
background-color: alpha(@bg, {bg_alpha});
color: @on-bg;
border-radius: {radius_bar}px;
border: 1px solid alpha(@on-bg, 0.08);
}
window.breadbar > box > centerbox { padding: 0 {pad}px; }
window.breadbar button { min-height: 0; min-width: 0; }
/* Dots: colour/opacity/radius only width is per-instance data
* (`modules.workspaces.dot_widths`), set directly via `set_size_request` in
* Rust (`bar::workspaces::make_dot_button`), since GTK CSS has no
* per-instance variable width the way the demo's `[data-n]` attribute
* selectors do.
*/
.workspace-dot {
background-color: alpha(@on-bg, 0.35);
border-radius: {radius_pill}px;
border: none;
outline: none;
box-shadow: none;
min-height: 6px;
margin: 0;
padding: 0;
transition: background-color 0.25s {spring_settle}, opacity 0.25s {spring_settle};
}
.workspace-dot:hover { background-color: alpha(@on-bg, 0.55); }
.workspace-dot:not(.occupied):not(.active) { opacity: 0.35; }
.workspace-dot.active {
background-color: @{accent_from};
opacity: 1;
}
.workspace-dot.active:hover { background-color: @{accent_from}; }
window.breadbar-osd {
background-color: alpha(@bg, 0.72);
color: @on-bg;
border-radius: {radius_pill}px;
border: 1px solid alpha(@on-bg, 0.10);
}
window.breadbar-panel {
background-color: alpha(@bg, 0.86);
color: @on-bg;
border-radius: {radius_card}px;
border: 1px solid alpha(@on-bg, 0.12);
}
window.breadbar-dismiss {
background-color: alpha(#000000, 0.02);
}
.bread-widget-slot { margin-right: {pad}px; }

View file

@ -0,0 +1,183 @@
# The third compiled-in builtin (bread-theme/src/shell/builtin.rs), demo 04
# in THEME_SYSTEM_PLAN.md — "Spotlight": breadbar's bar IS the launcher, a
# centred capsule that expands into results (plan §7). Values are taken from
# bos-ui-demos/04-spotlight.html's <style> block; where the demo is silent
# (satellite offsets, bar-adjacent chip sizing) this file re-derives from the
# capsule's own top edge the same way glass-workbench re-derives from its
# flush bar's edge — see that file's header comment for the method.
#
# Two exceptions, same shape as glass-workbench's: font (Outfit is not
# installed on the dev machine, so this renders in fallback until the user
# installs ttf-outfit — same gap liquid-motion already has) and colour (the
# demo's #e87898 pink is a *flat* accent — `--accent` doubling as both the
# capsule's dot fill and its only gradient stop — so it maps to the
# palette's `pink` token for both accent_from and accent_to, never a literal
# hex value).
#
# What this builtin does NOT model (THEME_SYSTEM_PLAN.md phase 6 scope, task
# "OUT OF SCOPE" list — deferred to phase 6c): query modes (`=` calc, `>`
# command, `.` url) and result sections ("recent"/"apps") — `modes` stays
# `["apps"]` and `sections` stays `false`, same trivial values every other
# builtin already carries. The demo's `.searching .capsule { border-radius:
# 20px }` (22px collapsed, 20px while actively searching) also has no
# corresponding schema key — `[launcher].radius` is a single value, so this
# builtin uses the collapsed 22px; the 2px searching-state shrink is an
# unmodeled gap, not an oversight (see breadbar's launcher_entry/results
# task notes for where this is implemented in Rust instead).
name = "Spotlight"
id = "spotlight"
[tokens]
font_family = "Outfit"
font_fallback = "Varela Round, sans-serif"
font_size_base = 13
radius_bar = 22
radius_card = 12
radius_sm = 8
radius_pill = 999
pad = 14
# Demo: `background: color-mix(in oklab, var(--glass) 82%, transparent)`.
bg_alpha = 0.82
# The demo's one curve (`--spring: cubic-bezier(.22,1.35,.36,1)`) — the same
# overshoot liquid-motion calls `spring`. No separate flatter "settle" curve
# is defined in 04's stylesheet, so both accessors point at it, same
# reasoning as glass-workbench's single-curve tokens.
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
spring_settle = "cubic-bezier(0.22, 1.35, 0.36, 1)"
# Palette token NAMES, not hex — #e87898 is this palette's `pink`. Equal
# from/to because the demo's accent here is flat, not liquid-motion's
# accent→teal gradient.
accent_from = "pink"
accent_to = "pink"
# Demo: `.barrow { height: 36px }` with no separate workspace-chip height —
# the dots (`.dots button`) are sized entirely by `dot_widths` below, not by
# chip_height, but this token still feeds any other chip-shaped element
# (e.g. a future result-row affordance) at the bar's own row height.
chip_height = 36
# Demo: `.ico { width: 30px; height: 30px }` for a result row's icon
# swatch — the icon glyph itself sits inside that box with implicit
# padding, so the icon_px passed to bread-launcher's `ResultsList` is sized
# a little under the swatch box, same margin glass-workbench leaves between
# its 18px icon_px and larger chip_height.
icon_px = 22
# Floating centred capsule, full rounded border on every edge — same
# reasoning as liquid-motion's island, not glass-workbench's flush bar.
bar_border = "full"
[bar.window]
# Anchored top ONLY: gtk4-layer-shell centres a surface anchored to
# neither left nor right (THEME_SYSTEM_PLAN.md §7) — this is what makes the
# capsule float centred instead of spanning the screen width.
anchors = ["top"]
width = 480
height = 36
margin = { top = 16 }
# No exclusive zone: tiled clients run full-height behind the capsule,
# unlike Island/Edge's reserved strip — the capsule floats over content.
exclusive = "none"
# Hands keyboard focus over only while `launcher_entry` holds it (plan §7)
# — Island/Edge themes keep "none", unchanged.
keyboard = "on_demand"
layer = "top"
[bar.slots]
left = ["workspaces"]
centre = ["launcher_entry"]
right = ["battery"]
drawer = ["launcher_results"]
[modules.workspaces]
style = "dots"
show_empty = true
# 0/1/2/3-or-more open windows → dot width in px
# (`04-spotlight.html`'s `.dots button[data-n="N"]` rules).
dot_widths = [6, 10, 14, 18]
[modules.clock]
# No clock label at all — `launcher_entry`'s placeholder IS the clock
# (`04-spotlight.html`: `q.placeholder = t` in `clock()`, replaced by the
# search prompt only once focused/open).
style = "none"
format = "%H:%M"
show_date = false
placeholder_clock = true
[launcher]
mode = "embedded"
# Unread by an embedded launcher (breadbar's launcher_entry/launcher_results
# read [bar.window].width and this table's radius/icon_px instead of
# width/top — those two fields exist only for LauncherMode::Overlay, which
# breadbox's own window still uses). Kept at the bar's own width/22px radius
# for consistency with a reader who expects [launcher] to describe the
# launcher's shape regardless of mode.
width = 480
top = "16px"
radius = 22
icon_px = 22
row_anim = "none"
rule = "none"
footer = "count_apps"
sections = false
modes = ["apps"]
# Same five satellite namespaces as liquid-motion/glass-workbench, keyed
# identically. This bar's edge sits at margin.top(16) + height(36) = 52px —
# re-deriving glass-workbench's own edge+8/edge relationship (44 = 36+8,
# 36 = 36) against 52 gives 60 (52+8) and 52.
[surfaces."breadbar-notif"]
anchor = "top_right"
offset = [16, 60]
width = 320
layer = "overlay"
[surfaces."breadbar-osd"]
# Independent of bar height — unchanged from liquid-motion/glass-workbench.
anchor = "bottom_centre"
offset = 80
width = 180
layer = "overlay"
[surfaces."breadbar-panel"]
anchor = "top_right"
offset = [16, 60]
width = "auto"
layer = "overlay"
[surfaces."breadbar-dismiss"]
anchor = "fill"
offset = 52
width = "fill"
layer = "overlay"
# Appearance-only, identical in shape to liquid-motion/glass-workbench's
# rules — blur strength is still global (plan §9 known limit).
[compositor."breadbar"]
blur = true
ignore_alpha = 0.18
blur_popups = true
animation = "slide top"
[compositor."breadbar-osd"]
blur = true
ignore_alpha = 0.2
animation = "slide bottom"
[compositor."breadbar-notif"]
blur = true
ignore_alpha = 0.2
animation = "slide right"
[compositor."breadbar-panel"]
blur = true
ignore_alpha = 0.2
animation = "slide right"
[compositor."breadbar-dismiss"]
no_anim = true
[compositor."breadbox"]
blur = true
ignore_alpha = 0.2
# No `css = "..."` overlay — compiled-in builtin, same as its two siblings.

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