bar: route Lua widgets to any [bar.slots] slot, not just four fixed spots

Phase 3b of the shell theme system. A slot list entry can now be
`widget:<key>`, where `<key>` is either a WidgetPlacement alias
(right_of_workspaces, left_of_clock, right_of_clock, left_of_stats, tray)
or a Lua module name. ModuleRegistry::for_each_in_slot creates each
widget container on demand at its slot position; reconcile_widgets routes
each WidgetSpec by module name first, falling back to its placement
alias, and logs+drops (never panics) a spec with no matching container.

bread_shared::widget::WidgetPlacement stays untouched — it's still the
wire type breadd sends, unmodified and unshadowed; only the container
map that placement now resolves through is theme-driven.
This commit is contained in:
Breadway 2026-08-24 18:23:24 +08:00
parent c5f7dd1ee3
commit 8d80a05d9b
2 changed files with 150 additions and 86 deletions

View file

@ -1,4 +1,5 @@
//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a). //! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a),
//! extended in Phase 3b to also route `widget:<key>` entries.
//! //!
//! Each bar module (`workspaces`, `media`, `clock`, `volume`, `wifi`, //! Each bar module (`workspaces`, `media`, `clock`, `volume`, `wifi`,
//! `battery`, `control`, …) is still built exactly where it always was in //! `battery`, `control`, …) is still built exactly where it always was in
@ -9,10 +10,14 @@
//! done, then walks `ShellTheme::slots()` to append them in the theme's //! done, then walks `ShellTheme::slots()` to append them in the theme's
//! order instead of a hardcoded sequence. //! order instead of a hardcoded sequence.
//! //!
//! The Lua-declared `widget_*` containers (`WidgetPlacement`) are NOT part //! A slot entry may also be `widget:<key>`, where `<key>` is either a
//! of this registry — their fixed interleave (right-of-workspaces, //! `WidgetPlacement` alias (`right_of_workspaces`, `left_of_clock`,
//! left/right-of-clock, left-of-stats) stays exactly as it is today. //! `right_of_clock`, `left_of_stats`, `tray`) or a Lua module name (see
//! Generalizing their placement is Phase 3b, not this task. //! `bread_shared::widget::WidgetSpec::module`) — these route through
//! `for_each_in_slot`'s `on_widget` callback rather than this registry,
//! since their containers are Lua-widget slots created on demand by the
//! caller, not modules registered here. `WidgetPlacement` itself is a wire
//! type from `bread-shared` and is never referenced in this file.
use gtk4::prelude::*; use gtk4::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
@ -32,22 +37,50 @@ impl ModuleRegistry {
self.0.insert(name, widget.clone().upcast()); self.0.insert(name, widget.clone().upcast());
} }
/// Appends every module named in `names` (a manifest slot list, in /// Walks every entry named in `names` (a manifest slot list, in theme
/// theme order) into `container` via `on_widget`, which lets the /// order). A `widget:<key>` entry calls `on_widget(key)`, letting the
/// caller interleave fixed Lua widget containers around specific /// caller create-or-fetch that Lua widget container and append it at
/// modules (e.g. the clock). A name with no registered widget is /// this exact position. Anything else is looked up as a registered
/// logged and skipped — an unrecognized or unmapped module in a theme /// module name and passed to `on_module`; a name with no registered
/// manifest must never crash the bar. /// widget is logged and skipped — an unrecognized or unmapped module in
/// a theme manifest must never crash the bar.
pub fn for_each_in_slot( pub fn for_each_in_slot(
&self, &self,
names: &[String], names: &[String],
mut on_widget: impl FnMut(&str, &gtk4::Widget), mut on_module: impl FnMut(&str, &gtk4::Widget),
mut on_widget: impl FnMut(&str),
) { ) {
for name in names { for name in names {
if let Some(key) = name.strip_prefix("widget:") {
on_widget(key);
continue;
}
match self.0.get(name.as_str()) { match self.0.get(name.as_str()) {
Some(widget) => on_widget(name, widget), Some(widget) => on_module(name, widget),
None => eprintln!("breadbar: [bar.slots] names unknown module '{name}' — skipping"), None => eprintln!("breadbar: [bar.slots] names unknown module '{name}' — skipping"),
} }
} }
} }
} }
/// Returns the widget container keyed `key` in `containers`, creating it
/// (a plain horizontal box, styled like every other Lua widget slot) on
/// first use. Called from `for_each_in_slot`'s `on_widget` callback so a
/// `widget:<key>` slot entry gets a container the first time a theme
/// places one there, regardless of whether `key` is a `WidgetPlacement`
/// alias or a Lua module name — `reconcile_widgets` (main.rs) is what
/// gives that distinction meaning when it routes specs into these
/// containers.
pub fn widget_slot_container(
containers: &mut HashMap<String, gtk4::Box>,
key: &str,
) -> gtk4::Box {
containers
.entry(key.to_string())
.or_insert_with(|| {
let b = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
b.add_css_class("bread-widget-slot");
b
})
.clone()
}

View file

@ -101,10 +101,16 @@ pub struct App {
tray_items: std::collections::HashMap<String, gtk4::Button>, tray_items: std::collections::HashMap<String, gtk4::Button>,
// ── Lua-declared widgets ───────────────────────────────────────────── // ── Lua-declared widgets ─────────────────────────────────────────────
// One container per WidgetPlacement (see bread_shared::widget), fully // One container per `widget:<key>` slot entry (Phase 3b — see
// rebuilt on every AppInput::WidgetsUpdate — see widgets::client's // bar::slots::ModuleRegistry and reconcile_widgets' routing below),
// fully rebuilt on every AppInput::WidgetsUpdate — see widgets::client's
// module doc for why that's simpler than incremental patching here. // module doc for why that's simpler than incremental patching here.
widget_containers: std::collections::HashMap<bread_shared::widget::WidgetPlacement, gtk4::Box>, // Keyed by the slot entry's key: either a WidgetPlacement alias
// (`right_of_workspaces`, `left_of_clock`, `right_of_clock`,
// `left_of_stats`, `tray`) or a Lua module name. `bread_shared::widget`'s
// `WidgetPlacement` itself never appears here — it's a wire type from
// the bread daemon API and stays untouched.
widget_containers: std::collections::HashMap<String, gtk4::Box>,
widget_tray_section: gtk4::Box, widget_tray_section: gtk4::Box,
widget_tray_sep: gtk4::Separator, widget_tray_sep: gtk4::Separator,
@ -223,9 +229,9 @@ impl SimpleComponent for App {
// ── Workspace row (left) ──────────────────────────────────────── // ── Workspace row (left) ────────────────────────────────────────
// Built imperatively (not via the view! macro) so a widget // Built imperatively (not via the view! macro) so a widget
// container can sit as a plain sibling of workspace_box — see // container can sit as a plain sibling of workspace_box — see the
// WidgetPlacement::RightOfWorkspaces below. The Overlay trail // `widget:*` slot-entry handling in "Assemble" below. The Overlay
// lives behind the buttons; rebuild_buttons only touches the // trail lives behind the buttons; rebuild_buttons only touches the
// button box, never the trail host. // button box, never the trail host.
let workspace_trail = bar::workspaces::WorkspaceTrail::new(); let workspace_trail = bar::workspaces::WorkspaceTrail::new();
let workspace_box = workspace_trail.buttons.clone(); let workspace_box = workspace_trail.buttons.clone();
@ -237,24 +243,12 @@ impl SimpleComponent for App {
// below, in the order `[bar.slots].left` names it — not here. // below, in the order `[bar.slots].left` names it — not here.
// ── Lua-declared widget containers ────────────────────────────── // ── Lua-declared widget containers ──────────────────────────────
// One per WidgetPlacement; positioned into the layout below as each // Phase 3b: a container per `widget:<key>` slot entry is created
// surrounding section (workspace row / center area / stats box / // on demand while walking `[bar.slots]` in the "Assemble" section
// control popover) is built. Populated by widgets::client's // below (see `bar::slots::widget_slot_container`), so ANY slot can
// events.subscribe-driven refresh loop, started at the end of init. // host a Lua widget — not just the four fixed positions Phase 3a
use bread_shared::widget::WidgetPlacement; // shipped with. Populated by widgets::client's events.subscribe-
let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); // driven refresh loop, started at the end of init.
widget_right_of_workspaces.add_css_class("bread-widget-slot");
// Also appended in "Assemble" — after the left slot's modules, so
// its fixed position (right of the workspace modules) is preserved
// whatever `[bar.slots].left` contains.
let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
widget_left_of_clock.add_css_class("bread-widget-slot");
let widget_right_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
widget_right_of_clock.add_css_class("bread-widget-slot");
let widget_left_of_stats = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
widget_left_of_stats.add_css_class("bread-widget-slot");
// `tokens.icon_px` (plan §4) — bar-chrome icon pixel size; reused // `tokens.icon_px` (plan §4) — bar-chrome icon pixel size; reused
// below for every `prepare_icon` call in this function. // below for every `prepare_icon` call in this function.
@ -399,9 +393,9 @@ impl SimpleComponent for App {
center_area.add_css_class("center-area"); center_area.add_css_class("center-area");
center_area.set_valign(gtk4::Align::Center); center_area.set_valign(gtk4::Align::Center);
center_area.set_vexpand(false); center_area.set_vexpand(false);
// `media_widget`/`clock_box` and the `widget_left_of_clock`/ // `media_widget`/`clock_box` and any `widget:*` entries interleaved
// `widget_right_of_clock` interleave around the clock module are // around them are all appended in "Assemble" below, in the exact
// both appended in "Assemble" below, per `[bar.slots].centre`. // order `[bar.slots].centre` names them.
// ── Stats box (right side) ─────────────────────────────────────── // ── Stats box (right side) ───────────────────────────────────────
// Demo order: [vol 64] [wifi] [bat 83] [☰] // Demo order: [vol 64] [wifi] [bat 83] [☰]
@ -712,13 +706,16 @@ impl SimpleComponent for App {
media_widget.add_controller(mgesture); media_widget.add_controller(mgesture);
} }
// ── Assemble: slot-driven module order (plan §11 Phase 3a) ─────── // ── Assemble: slot-driven module + widget order (plan §11 Phase 3b) ──
// Every module widget above is already fully built; only the ORDER // Every module widget above is already fully built; only the ORDER
// it lands in its container, and which of left/centre/right it // it lands in its container, and which of left/centre/right it
// lands in, comes from the theme manifest's `[bar.slots]` now. // lands in, comes from the theme manifest's `[bar.slots]` now. A
// The `widget_*` Lua containers keep today's fixed interleave // `widget:<key>` slot entry gets (or creates) a Lua widget
// (right-of-workspaces, left/right-of-clock, left-of-stats) — // container at that exact position — `<key>` is either a
// generalizing their placement is Phase 3b, not this task. // `WidgetPlacement` alias or a Lua module name; see
// `bar::slots::widget_slot_container` and `reconcile_widgets`'
// routing below. This is how a Lua widget can land in ANY slot,
// not just the four fixed positions Phase 3a shipped with.
let bar_shell_theme = theme::shell_theme(); let bar_shell_theme = theme::shell_theme();
let bar_slots = bar_shell_theme.slots(); let bar_slots = bar_shell_theme.slots();
let mut bar_modules = bar::slots::ModuleRegistry::new(); let mut bar_modules = bar::slots::ModuleRegistry::new();
@ -730,32 +727,29 @@ impl SimpleComponent for App {
bar_modules.register("battery", &bat_box); bar_modules.register("battery", &bat_box);
bar_modules.register("control", &hamburger_btn); bar_modules.register("control", &hamburger_btn);
bar_modules.for_each_in_slot(&bar_slots.left, |_, widget| workspace_row.append(widget)); // `tray` never appears in a bar slot — it stays inside the
workspace_row.append(&widget_right_of_workspaces); // control-panel popover (built above, next to the SNI tray) — but
// it's keyed here so `reconcile_widgets`' routing finds it the same
// way as any slot-driven widget container.
let mut widget_containers: std::collections::HashMap<String, gtk4::Box> =
std::collections::HashMap::new();
widget_containers.insert("tray".to_string(), widget_tray_box);
bar_modules.for_each_in_slot(&bar_slots.centre, |name, widget| { bar_modules.for_each_in_slot(
if name == "clock" { &bar_slots.left,
center_area.append(&widget_left_of_clock); |_, widget| workspace_row.append(widget),
} |key| workspace_row.append(&bar::slots::widget_slot_container(&mut widget_containers, key)),
center_area.append(widget); );
if name == "clock" { bar_modules.for_each_in_slot(
center_area.append(&widget_right_of_clock); &bar_slots.centre,
} |_, widget| center_area.append(widget),
}); |key| center_area.append(&bar::slots::widget_slot_container(&mut widget_containers, key)),
);
stats_box.append(&widget_left_of_stats); bar_modules.for_each_in_slot(
bar_modules.for_each_in_slot(&bar_slots.right, |_, widget| stats_box.append(widget)); &bar_slots.right,
|_, widget| stats_box.append(widget),
let widget_containers = std::collections::HashMap::from([ |key| stats_box.append(&bar::slots::widget_slot_container(&mut widget_containers, key)),
( );
WidgetPlacement::RightOfWorkspaces,
widget_right_of_workspaces,
),
(WidgetPlacement::LeftOfClock, widget_left_of_clock),
(WidgetPlacement::RightOfClock, widget_right_of_clock),
(WidgetPlacement::LeftOfStats, widget_left_of_stats),
(WidgetPlacement::Tray, widget_tray_box),
]);
// ── Assemble ───────────────────────────────────────────────────── // ── Assemble ─────────────────────────────────────────────────────
let widgets = view_output!(); let widgets = view_output!();
@ -1144,6 +1138,22 @@ impl SimpleComponent for App {
} }
} }
/// The `widget:<key>` alias `for_each_in_slot` recognizes for each
/// `WidgetPlacement` variant — the fallback a `WidgetSpec` routes through
/// when no `widget:<module>` container claims its module name specifically.
/// Kept in one place since both the builtin manifest's slot lists and
/// `reconcile_widgets`' routing below must agree on these names.
fn placement_alias(placement: bread_shared::widget::WidgetPlacement) -> &'static str {
use bread_shared::widget::WidgetPlacement::*;
match placement {
Tray => "tray",
LeftOfClock => "left_of_clock",
RightOfClock => "right_of_clock",
RightOfWorkspaces => "right_of_workspaces",
LeftOfStats => "left_of_stats",
}
}
impl App { impl App {
fn reconcile_widgets(&mut self, specs: Vec<bread_shared::widget::WidgetSpec>) { fn reconcile_widgets(&mut self, specs: Vec<bread_shared::widget::WidgetSpec>) {
for container in self.widget_containers.values() { for container in self.widget_containers.values() {
@ -1152,23 +1162,47 @@ impl App {
} }
} }
let mut by_placement: std::collections::HashMap< // Route each spec to a widget_containers entry: a `widget:<module>`
bread_shared::widget::WidgetPlacement, // slot entry (keyed by WidgetSpec::module) takes priority over the
// spec's placement alias, so a theme can retarget one Lua module's
// widgets without moving every widget that shares its placement.
// A spec whose module AND placement alias both lack a container
// (e.g. a theme's slots omit that placement's widget: entry
// entirely) is logged and dropped rather than silently vanishing —
// WidgetPlacement itself never changes; only which container (if
// any) each spec lands in does.
let mut by_container: std::collections::HashMap<
String,
Vec<&bread_shared::widget::WidgetSpec>, Vec<&bread_shared::widget::WidgetSpec>,
> = std::collections::HashMap::new(); > = std::collections::HashMap::new();
for spec in &specs { for spec in &specs {
by_placement.entry(spec.placement).or_default().push(spec); let key = if self.widget_containers.contains_key(&spec.module) {
spec.module.clone()
} else {
placement_alias(spec.placement).to_string()
};
if self.widget_containers.contains_key(&key) {
by_container.entry(key).or_default().push(spec);
} else {
eprintln!(
"breadbar: widget '{}' (module '{}', placement {:?}) has no matching \
[bar.slots] widget: container dropping",
spec.id, spec.module, spec.placement
);
}
} }
for (placement, mut group) in by_placement { let mut has_tray_widgets = false;
let Some(container) = self.widget_containers.get(&placement) else { for (key, mut group) in by_container {
continue; let container = &self.widget_containers[&key];
};
group.sort_by_key(|s| s.order); group.sort_by_key(|s| s.order);
for spec in group { for spec in group {
if !spec.visible { if !spec.visible {
continue; continue;
} }
if key == "tray" {
has_tray_widgets = true;
}
let node = widgets::build_node(&spec.root, &spec.id); let node = widgets::build_node(&spec.root, &spec.id);
if let Some(tooltip) = &spec.tooltip { if let Some(tooltip) = &spec.tooltip {
node.set_tooltip_text(Some(tooltip)); node.set_tooltip_text(Some(tooltip));
@ -1177,20 +1211,17 @@ impl App {
} }
} }
// The Tray placement has its own section/separator (handled below, // The "tray" container has its own section/separator (handled
// same as the existing SNI tray items) — an empty inline slot has no // below, same as the existing SNI tray items) — an empty inline
// such wrapper, so it must hide itself to stop contributing to // slot has no such wrapper, so it must hide itself to stop
// center_area's `spacing` gap. // contributing to its parent box's `spacing` gap.
for (placement, container) in &self.widget_containers { for (key, container) in &self.widget_containers {
if *placement == bread_shared::widget::WidgetPlacement::Tray { if key == "tray" {
continue; continue;
} }
container.set_visible(container.first_child().is_some()); container.set_visible(container.first_child().is_some());
} }
let has_tray_widgets = specs
.iter()
.any(|s| s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray);
self.widget_tray_section.set_visible(has_tray_widgets); self.widget_tray_section.set_visible(has_tray_widgets);
self.widget_tray_sep.set_visible(has_tray_widgets); self.widget_tray_sep.set_visible(has_tray_widgets);
} }