Drive bar/window geometry and CSS tokens from the shell theme manifest

breadbar no longer hardcodes BAR_HEIGHT/BAR_MARGIN_TOP/BAR_MARGIN_SIDES/
CHIP_HEIGHT/ICON_PX or the root window's layer-shell setup: it loads
bread_theme:🐚:ShellTheme once (theme::shell_theme(), cached in a
thread-local) and reads bar.window (anchors, margin, exclusive zone,
keyboard mode - now set explicitly instead of relying on the library
default) plus tokens.{icon_px,chip_height} from it everywhere those used
to be literals.

The four satellite surfaces (breadbar-osd, breadbar-notif, breadbar-panel,
breadbar-dismiss) now get their anchor/margin/width/layer from the
manifest's [surfaces.*] table via a small new surface::apply() helper,
narrowly scoped to the three anchor shapes those surfaces actually use.
The pre-existing 8px gap between the panel's top margin and the dismiss
scrim's is preserved exactly (and now commented) rather than "fixed".

theme.rs's load_css() keeps its ~250 lines of hand-written breadbar CSS
(notifications, wifi popover, control panel, media widget) but now reads
its five radius/pad locals and the two easing curves (spring vs
spring_settle - hover/settle transitions were previously miscategorized
as the overshoot curve in the constant audit; the actual code already
used the settle curve there, confirmed against src) from theme tokens
instead of hardcoding them.

Also wires bread_theme:🐚:watch() so editing the active theme's
theme.toml/extra.css hot-reloads CSS tokens without a restart, same as
a pywal palette change already does. Window-spec values still need a
restart per the plan (read once at window-construction time).

Verified pixel-identical: captured all 10 breadbar --screenshot views
(bar, control-panel, connectivity-wifi/bluetooth, media-popover,
notification/-critical, osd-volume/-brightness, wifi-add-dialog) via
bread-capture's isolated headless-Sway harness against both the
pre-change and post-change binaries; every view diffs byte-identical
at the decoded-pixel level.
This commit is contained in:
Breadway 2026-08-23 15:58:25 +08:00
parent 297207a7aa
commit bbb1a6c8d2
11 changed files with 278 additions and 99 deletions

View file

@ -9,21 +9,13 @@ mod notifications;
mod osd;
mod panel;
mod screenshot;
mod surface;
mod theme;
mod widgets;
/// Floating island bar: widget height, layer-shell inset, reserved zone.
/// Exclusive zone is height + top margin so tiled clients sit below the gap.
pub const BAR_HEIGHT: i32 = 44;
pub const BAR_MARGIN_TOP: i32 = 12;
pub const BAR_MARGIN_SIDES: i32 = 16;
/// Chip / workspace-pill height. Must stay smaller than `BAR_HEIGHT` so
/// hover/active highlights hug the glyphs instead of filling the island.
pub const CHIP_HEIGHT: i32 = 32;
pub const ICON_PX: i32 = 24;
use bread_theme::shell::{Exclusive, Keyboard};
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use hyprland::data::Workspace;
use hyprland::shared::WorkspaceId;
use relm4::prelude::*;
@ -151,7 +143,7 @@ impl SimpleComponent for App {
gtk::ApplicationWindow {
add_css_class: "breadbar",
set_title: Some("breadbar"),
set_default_height: BAR_HEIGHT,
set_default_height: bar_height,
#[name = "center_box"]
gtk::CenterBox {
@ -171,16 +163,51 @@ impl SimpleComponent for App {
.or_else(primary_hypr_monitor)
.unwrap_or_else(|| "eDP-1".into());
// `bar.window` (plan §2/§6) — window shape is data, not a closed
// layout enum. Read once and reused below for the layer-shell setup
// and (via `bar_height`, captured for the view! macro above) the
// root window's initial GTK height.
let window_spec = theme::shell_theme().window().clone();
let bar_height = window_spec.height;
root.init_layer_shell();
root.set_namespace(Some("breadbar"));
root.set_layer(Layer::Top);
root.set_anchor(Edge::Top, true);
root.set_anchor(Edge::Left, true);
root.set_anchor(Edge::Right, true);
root.set_margin(Edge::Top, BAR_MARGIN_TOP);
root.set_margin(Edge::Left, BAR_MARGIN_SIDES);
root.set_margin(Edge::Right, BAR_MARGIN_SIDES);
root.set_exclusive_zone(BAR_HEIGHT + BAR_MARGIN_TOP);
root.set_layer(if window_spec.layer == "overlay" {
Layer::Overlay
} else {
Layer::Top
});
for anchor in &window_spec.anchors {
match anchor.as_str() {
"top" => root.set_anchor(Edge::Top, true),
"bottom" => root.set_anchor(Edge::Bottom, true),
"left" => root.set_anchor(Edge::Left, true),
"right" => root.set_anchor(Edge::Right, true),
other => eprintln!(
"breadbar: bar.window.anchors entry \"{other}\" is not top|bottom|left|right, ignoring"
),
}
}
root.set_margin(Edge::Top, window_spec.margin.top);
root.set_margin(Edge::Left, window_spec.margin.left);
root.set_margin(Edge::Right, window_spec.margin.right);
// "auto" reserves height + top margin so tiled clients sit below the
// gap — see WindowSpec::exclusive's doc comment (bread-theme).
let exclusive_zone = match window_spec.exclusive {
Exclusive::Auto => window_spec.height + window_spec.margin.top,
Exclusive::None => -1,
Exclusive::Px(px) => px,
};
root.set_exclusive_zone(exclusive_zone);
// breadbar never called `set_keyboard_mode` before Phase 2 — it
// relied on gtk4-layer-shell's own default (`KeyboardMode::None`),
// which is exactly what the builtin manifest's `keyboard = "none"`
// resolves to. Same behaviour, no longer implicit.
root.set_keyboard_mode(match window_spec.keyboard {
Keyboard::None => KeyboardMode::None,
Keyboard::OnDemand => KeyboardMode::OnDemand,
Keyboard::Exclusive => KeyboardMode::Exclusive,
});
eprintln!(
"breadbar: init monitor={monitor_name} primary={}",
init.primary
@ -226,6 +253,10 @@ impl SimpleComponent for App {
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
// below for every `prepare_icon` call in this function.
let icon_px = theme::shell_theme().tokens().icon_px() as i32;
// ── SVG icon sets ────────────────────────────────────────────────
use bar::stats::{
AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME,
@ -253,13 +284,13 @@ impl SimpleComponent for App {
let bat_img = gtk4::Image::from_paintable(Some(
bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(),
));
prepare_icon(&bat_img, ICON_PX);
prepare_icon(&bat_img, icon_px);
let ac_img = svg_image(AC_POWER);
ac_img.set_visible(false);
let bt_img = gtk4::Image::from_paintable(Some(
bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(),
));
prepare_icon(&bt_img, ICON_PX);
prepare_icon(&bt_img, icon_px);
bt_img.set_visible(false);
// ── WiFi pair + popover ──────────────────────────────────────────
@ -273,7 +304,7 @@ impl SimpleComponent for App {
// crowding the tray is the opposite of a glass workbench bar.
wifi_lbl.set_visible(false);
let wifi_img = gtk4::Image::from_icon_name(bar::stats::WIFI_ICON_EXCELLENT);
prepare_icon(&wifi_img, ICON_PX);
prepare_icon(&wifi_img, icon_px);
wifi_img.add_css_class("stat-icon");
// Content pane only — this becomes a tab inside the merged
@ -783,6 +814,11 @@ impl SimpleComponent for App {
if init.primary {
bar::tray::spawn_watcher(sender.clone());
widgets::client::spawn(sender.clone());
// Optional (plan §10, Phase 2 item 6): live theme.toml/extra.css
// token reload, the same way a pywal palette change already
// hot-reloads via `apply_app_css`. One watch per process, so
// only the primary instance arms it.
theme::watch_hot_reload();
}
// Screenshot mode primes these with sample content instead of the
@ -1008,7 +1044,10 @@ impl SimpleComponent for App {
};
self.media_play_icon
.set_paintable(Some(&svg_texture(icon_svg)));
prepare_icon(&self.media_play_icon, ICON_PX);
prepare_icon(
&self.media_play_icon,
theme::shell_theme().tokens().icon_px() as i32,
);
if state.playing {
self.media_widget.add_css_class("playing");
} else {
@ -1703,7 +1742,7 @@ fn popover_tab(label: &str) -> gtk4::ToggleButton {
btn.set_hexpand(true);
btn.set_valign(gtk4::Align::Center);
btn.set_vexpand(false);
btn.set_size_request(-1, CHIP_HEIGHT);
btn.set_size_request(-1, theme::shell_theme().tokens().chip_height() as i32);
if let Some(child) = btn.child() {
child.set_halign(gtk4::Align::Center);
child.set_valign(gtk4::Align::Center);
@ -1743,7 +1782,7 @@ pub(crate) fn prepare_icon(img: &gtk4::Image, px: i32) {
}
pub(crate) fn svg_image(svg_src: &str) -> gtk4::Image {
svg_image_sized(svg_src, ICON_PX as u32)
svg_image_sized(svg_src, theme::shell_theme().tokens().icon_px() as u32)
}
pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image {
@ -1753,7 +1792,7 @@ pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image {
}
pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture {
svg_texture_sized(svg_src, ICON_PX as u32)
svg_texture_sized(svg_src, theme::shell_theme().tokens().icon_px() as u32)
}
/// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK