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

@ -142,7 +142,7 @@ pub fn make_button(
btn.set_halign(gtk4::Align::Center);
btn.set_vexpand(false);
btn.set_hexpand(false);
btn.set_size_request(-1, crate::CHIP_HEIGHT);
btn.set_size_request(-1, crate::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);

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

View file

@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use gtk4_layer_shell::{KeyboardMode, LayerShell};
use serde::{Deserialize, Serialize};
use super::Urgency;
@ -176,11 +176,11 @@ pub fn build_window(store: Store) -> Ui {
window.add_css_class("breadbar-history");
window.init_layer_shell();
window.set_namespace(Some("breadbar-notif"));
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Right, true);
window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8);
window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES);
crate::surface::apply(&window, "breadbar-notif");
// Overrides `[surfaces."breadbar-notif"].width` (320px, the live-toast
// popup's width — see `surface::apply`'s doc comment): the history
// window genuinely wants a different width on the same namespace, and
// that isn't something the manifest schema models today.
window.set_default_width(360);
window.set_keyboard_mode(KeyboardMode::OnDemand);
crate::theme::bind_auto(&window);

View file

@ -1,7 +1,7 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use gtk4_layer_shell::{KeyboardMode, LayerShell};
use tokio::sync::mpsc::Receiver;
use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY};
@ -171,22 +171,10 @@ fn create_window() -> gtk4::Window {
window.add_css_class("breadbar-notification");
window.init_layer_shell();
window.set_namespace(Some("breadbar-notif"));
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Right, true);
window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8);
window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES);
window.set_default_width(320);
// Toasts are purely informational for now: never grab keyboard focus...
window.set_keyboard_mode(KeyboardMode::None);
// ...and click through entirely — an empty input region means every
// pointer event passes straight to whatever's underneath instead of
// hitting the toast.
window.connect_map(|win| {
if let Some(surface) = win.surface() {
surface.set_input_region(Some(&gtk4::cairo::Region::create()));
}
});
crate::surface::apply(&window, "breadbar-notif");
// OnDemand so an inline-reply GtkEntry can take keys without the popup
// stealing every keystroke the rest of the time.
window.set_keyboard_mode(KeyboardMode::OnDemand);
crate::theme::bind_auto(&window);
window
}

View file

@ -1,7 +1,7 @@
use std::{cell::Cell, rc::Rc, time::Duration};
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell};
use gtk4_layer_shell::LayerShell;
use tokio::sync::mpsc;
enum OsdEvent {
@ -182,7 +182,7 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver<OsdEvent>) {
};
icon.set_paintable(Some(&crate::svg_texture(icon_svg)));
crate::prepare_icon(&icon, crate::ICON_PX);
crate::prepare_icon(&icon, crate::theme::shell_theme().tokens().icon_px() as i32);
if muted {
icon.add_css_class("osd-icon-muted");
} else {
@ -209,10 +209,7 @@ fn create_window() -> gtk4::Window {
window.add_css_class("breadbar-osd");
window.init_layer_shell();
window.set_namespace(Some("breadbar-osd"));
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Bottom, true);
window.set_margin(Edge::Bottom, 80);
window.set_default_width(180);
crate::surface::apply(&window, "breadbar-osd");
crate::theme::bind_auto(&window);
window
}

View file

@ -7,11 +7,9 @@
use gtk4::gdk::Key;
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use gtk4_layer_shell::{KeyboardMode, LayerShell};
use crate::{bind_layer_monitor, theme, BAR_HEIGHT, BAR_MARGIN_SIDES, BAR_MARGIN_TOP};
const BELOW_BAR: i32 = BAR_MARGIN_TOP + BAR_HEIGHT + 8;
use crate::{bind_layer_monitor, theme};
#[derive(Clone)]
pub struct PanelSet {
@ -111,11 +109,7 @@ fn make_panel(class: &str, child: &impl IsA<gtk4::Widget>, monitor: &str) -> gtk
window.set_resizable(false);
window.init_layer_shell();
window.set_namespace(Some("breadbar-panel"));
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Right, true);
window.set_margin(Edge::Top, BELOW_BAR);
window.set_margin(Edge::Right, BAR_MARGIN_SIDES);
crate::surface::apply(&window, "breadbar-panel");
window.set_exclusive_zone(-1);
window.set_keyboard_mode(KeyboardMode::OnDemand);
window.set_child(Some(child));
@ -132,12 +126,13 @@ fn make_dismiss(monitor: &str) -> gtk4::Window {
window.set_namespace(Some("breadbar-dismiss"));
// Overlay with the panels, but mapped first so they sit above it.
// Top margin keeps the island's chips clickable.
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Bottom, true);
window.set_anchor(Edge::Left, true);
window.set_anchor(Edge::Right, true);
window.set_margin(Edge::Top, BAR_MARGIN_TOP + BAR_HEIGHT);
//
// NOTE — deliberate, not a bug: this surface's top margin is 8px less
// than `breadbar-panel`'s (see `make_panel` above / `[surfaces.*]` in
// the active theme). The panels start 8px lower than the dismiss
// scrim's clickable region. This predates Phase 2 and is preserved
// exactly for pixel-identical rendering — do not "fix" this gap.
crate::surface::apply(&window, "breadbar-dismiss");
window.set_exclusive_zone(-1);
window.set_keyboard_mode(KeyboardMode::None);
// An empty window never maps a hit region. A filling child + a hair of

View file

@ -123,18 +123,22 @@ pub struct Handles {
/// Capture height for the `bar` view: layer-shell top margin + widget
/// height (the exclusive zone). Unlike the other views' full canvas,
/// this never varies with `--width`/`--height`.
const BAR_HEIGHT: i32 = crate::BAR_HEIGHT + crate::BAR_MARGIN_TOP;
fn bar_capture_height() -> i32 {
let window = crate::theme::shell_theme().window().clone();
window.height + window.margin.top
}
pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) {
let output = req.output;
let (width, height) = (req.width as i32, req.height as i32);
let bar_height = bar_capture_height();
match req.view.as_str() {
"bar" => {
root.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, BAR_HEIGHT, &output));
finish(bread_screenshots::capture_region(0, 0, width, bar_height, &output));
});
});
}

78
src/surface.rs Normal file
View file

@ -0,0 +1,78 @@
//! Applies a `[surfaces.<namespace>]` entry (plan §4/§6, Phase 2) to a
//! satellite layer-shell window: anchor, margin (from `offset`), width and
//! layer. Deliberately narrow — it only understands the three anchor shapes
//! breadbar's four built-in surfaces actually use today ("breadbar-notif",
//! "breadbar-osd", "breadbar-panel", "breadbar-dismiss": `top_right`,
//! `bottom_centre`, `fill`), not a general anchor DSL (the plan's own
//! anti-goal, §2). `exclusive` zone and `keyboard` mode aren't part of the
//! `[surfaces.*]` schema (`bread_theme::shell::Surface` has no such fields)
//! and stay hardcoded at each call site, same as before this refactor.
use bread_theme::shell::SurfaceWidth;
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell};
/// `namespace` should be a key in the active theme's `[surfaces.*]` table —
/// every call site in this crate passes one of breadbar's own namespace
/// literals, so a miss here means the active theme fell out of sync with
/// the Rust source, not a bad runtime value. Logs and leaves the window at
/// gtk4-layer-shell's own defaults rather than panicking, matching every
/// other "malformed/incomplete theme" fallback in this system.
///
/// Does not set `set_default_width` for a namespace shared by more than one
/// window with genuinely different widths (`breadbar-notif`'s live toast is
/// 320px, its history sibling is 360px, and only the toast's width is
/// modeled in `[surfaces.*]` — see the Phase 0 constant inventory); callers
/// that need a different width than the theme's own set it explicitly
/// afterward.
pub fn apply(window: &gtk4::Window, namespace: &str) {
let theme = crate::theme::shell_theme();
let Some(surf) = theme.surfaces().get(namespace) else {
eprintln!(
"breadbar: no [surfaces.{namespace}] entry in the active theme; \
window left at layer-shell defaults"
);
return;
};
window.set_layer(if surf.layer == "top" {
Layer::Top
} else {
Layer::Overlay
});
match surf.anchor.as_str() {
"top_right" => {
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Right, true);
// offset = [right, top] for this anchor shape.
let right = surf.offset.first().copied().unwrap_or(0.0) as i32;
let top = surf.offset.get(1).copied().unwrap_or(0.0) as i32;
window.set_margin(Edge::Right, right);
window.set_margin(Edge::Top, top);
}
"bottom_centre" => {
window.set_anchor(Edge::Bottom, true);
let bottom = surf.offset.first().copied().unwrap_or(0.0) as i32;
window.set_margin(Edge::Bottom, bottom);
}
"fill" => {
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
window.set_anchor(edge, true);
}
// Only a top margin is meaningful here — a fullscreen click-away
// scrim that starts below the bar rather than covering it.
let top = surf.offset.first().copied().unwrap_or(0.0) as i32;
window.set_margin(Edge::Top, top);
}
other => eprintln!(
"breadbar: surfaces.{namespace}.anchor = \"{other}\" is not one of \
top_right|bottom_centre|fill breadbar's satellite windows don't \
understand any other shape yet, leaving this window unanchored"
),
}
if let SurfaceWidth::Px(px) = surf.width {
window.set_default_width(px);
}
}

View file

@ -1,10 +1,38 @@
use bread_theme::shell::ShellTheme;
use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette};
use gtk4::prelude::IsA;
use gtk4::CssProvider;
use std::cell::RefCell;
use std::rc::Rc;
thread_local! {
static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
// Loaded lazily on first access and cached — the underlying
// `bread_theme::shell::load()` call happens at most once per process,
// not once per read site (plan §5/§6, Phase 2). Stored as an `Rc` so
// window/surface-geometry call sites elsewhere in the crate can hold a
// cheap clone rather than re-reading the cell each time.
static SHELL_THEME: RefCell<Rc<ShellTheme>> =
RefCell::new(Rc::new(bread_theme::shell::load()));
}
/// The active shell theme — window geometry, `[surfaces.*]`, and CSS tokens
/// (plan §5). Every consumer (this module's own `load_css`, plus main.rs,
/// osd.rs, panel.rs, notifications/, and `surface::apply`) reads through
/// this single shared instance instead of calling `bread_theme::shell::load()`
/// itself.
pub fn shell_theme() -> Rc<ShellTheme> {
SHELL_THEME.with(|cell| cell.borrow().clone())
}
/// Replaces the shared shell theme in place. Only used by the optional
/// `theme.toml` hot-reload watch (see `watch_hot_reload` below) — per plan
/// §10, a window-spec change (anchors, margins, exclusive zone, keyboard)
/// still needs a restart to take effect, since those are read once at
/// window-construction time; only CSS token values re-resolve live, the
/// next time `load_css` runs.
pub fn set_shell_theme(theme: ShellTheme) {
SHELL_THEME.with(|cell| *cell.borrow_mut() = Rc::new(theme));
}
fn load_css() -> String {
@ -19,11 +47,27 @@ fn load_css() -> String {
// Hyprland `layerrule = blur, breadbar` frosts the translucent fills —
// the CSS just leaves alpha. Colours are bread-theme tokens so pywal
// accents (`@accent`) flow through on SIGHUP / `bread-theme reload`.
let radius = "12px";
let radius_bar = "16px";
let radius_sm = "9px";
let radius_pill = "999px";
let pad = "12px";
//
// These ~250 lines are breadbar-specific chrome (notifications, wifi
// popover, control panel, media widget) that `ShellTheme::css()` does
// not template — only the window/workspace/clock chrome the manifest's
// own concepts model does (plan §6 scope note). This function stays
// hand-written CSS; it now just reads its radius/pad/easing numbers from
// the theme's tokens instead of hardcoding them.
let theme = shell_theme();
let tokens = theme.tokens();
let radius = format!("{}px", tokens.radius_card());
let radius_bar = format!("{}px", tokens.radius_bar());
let radius_sm = format!("{}px", tokens.radius_sm());
let radius_pill = format!("{}px", tokens.radius_pill());
let pad = format!("{}px", tokens.pad());
// Two curves, not one: `spring` is the overshoot/bounce curve (clock
// flips, pop-ins, the workspace caret draw); `spring_settle` is the
// flatter curve used for hovers and background/opacity transitions.
// Do not collapse these — they read differently and cover different
// sites below (see the Phase 0 constant inventory).
let spring = tokens.spring();
let spring_settle = tokens.spring_settle();
format!(
"@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\
@ -44,26 +88,26 @@ fn load_css() -> String {
border-radius: 12px; border: none; outline: none; box-shadow: none;\
min-width: 28px; min-height: 28px; margin: 0; padding: 0 7px;\
font-size: 22px; font-weight: bold;\
transition: opacity 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
transition: opacity 0.22s {spring_settle},\
background-color 0.22s {spring_settle}; }}\
.workspace-btn:hover {{ opacity: 0.85; background: alpha(@on-bg, 0.08); }}\
.workspace-btn.occupied {{ opacity: 0.78; }}\
.workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\
.workspace-btn.active:hover {{ background: transparent; }}\
.workspace-btn.ws-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\
.workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}\
.clock-box {{ padding: 0 4px; }}\
.clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\
min-height: 0; padding: 0; margin-top: 3px; }}\
.clock-digit {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\
min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\
.clock-colon {{ min-width: 10px; opacity: 0.7; }}\
.clock-digit.flip {{ animation: digit-flip 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.clock-digit.flip {{ animation: digit-flip 0.45s {spring} both; }}\
.date-label {{ font-size: 14px; opacity: 0.52; letter-spacing: 0.04em; }}\
.stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\
.stat-label.tick {{ animation: digit-flip 0.35s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.stat-label.tick {{ animation: digit-flip 0.35s {spring} both; }}\
.stats-box {{ margin-right: 0; }}\
.stat-pair {{ margin: 0; border-radius: 10px; padding: 5px 9px; min-height: 0;\
transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
transition: background-color 0.22s {spring_settle},\
opacity 0.18s ease; }}\
.stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\
.stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\
@ -77,11 +121,11 @@ fn load_css() -> String {
window.breadbar-notification {{ background-color: transparent; color: @on-bg; }}\
window.breadbar-history {{ background-color: alpha(@bg, 0.70); color: @on-bg;\
border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\
animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
animation: pop-in 0.45s {spring} both; }}\
.notification-card {{ background: alpha(@bg, 0.70); color: @on-bg; border-radius: {radius};\
padding: {pad}; margin-bottom: 8px; border: 1px solid alpha(@on-bg, 0.10);\
border-left: 3px solid transparent;\
animation: notif-in 0.45s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\
animation: notif-in 0.45s {spring_settle} both; }}\
.notification-card.urgency-critical {{ border-left-color: @red; }}\
.notification-card.urgency-normal {{ border-left-color: @accent; }}\
.notification-summary {{ font-weight: bold; }}\
@ -98,7 +142,7 @@ fn load_css() -> String {
.history-card {{ margin-bottom: 6px; }}\
window.breadbar-osd {{ background-color: alpha(@bg, 0.70); color: @on-bg;\
border-radius: {radius_pill}; border: 1px solid alpha(@on-bg, 0.10);\
animation: osd-in 0.4s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\
animation: osd-in 0.4s {spring_settle} both; }}\
.osd-icon {{ opacity: 0.85; margin-right: 8px; }}\
.osd-icon-muted {{ opacity: 0.35; }}\
progressbar.osd-bar {{ min-height: 6px; }}\
@ -114,7 +158,7 @@ fn load_css() -> String {
.popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\
background-color: @accent;\
background-image: linear-gradient(90deg, @accent, @teal);\
animation: caret-draw 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
animation: caret-draw 0.45s {spring} both; }}\
.wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\
window.wifi-popover button {{ min-height: 0; min-width: 0; }}\
.popover-tab-row {{ background: alpha(@on-bg, 0.06); border-radius: 10px;\
@ -122,7 +166,7 @@ fn load_css() -> String {
.popover-tab {{ background: transparent; color: @on-bg; border: none; box-shadow: none;\
outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\
font-size: 17px; font-weight: bold; opacity: 0.55;\
transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
transition: background-color 0.22s {spring_settle},\
opacity 0.22s ease, color 0.22s ease; }}\
.popover-tab:hover {{ opacity: 0.8; }}\
.popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\
@ -134,12 +178,12 @@ fn load_css() -> String {
letter-spacing: 0.12em; }}\
.wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\
outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\
transition: background-color 0.18s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
transition: background-color 0.18s {spring_settle}; }}\
.wifi-popover-row label {{ font-size: 18px; }}\
.wifi-popover-row:hover {{ background: alpha(@on-bg, 0.08); }}\
.wifi-popover-row-active {{ background: alpha(@accent, 0.14); color: @accent; }}\
.wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\
.row-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.row-in {{ animation: row-in 0.32s {spring} both; }}\
.stagger-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\
.stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\
.stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\
@ -153,23 +197,23 @@ fn load_css() -> String {
border: none; outline: none; box-shadow: none; background-image: none;\
border-radius: 99px; }}\
switch.bt-switch {{ background-color: alpha(@on-bg, 0.14);\
transition: background-color 0.25s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
transition: background-color 0.25s {spring_settle}; }}\
switch.bt-switch:checked {{ background-color: @accent; }}\
switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\
border-radius: 99px; border: none; outline: none; box-shadow: none;\
background-image: none; background-color: @on-bg; }}\
window.wifi-add-dialog {{ background-color: alpha(@bg, 0.70); color: @on-bg; min-width: 240px;\
border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\
animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
animation: pop-in 0.45s {spring} both; }}\
window.wifi-add-dialog headerbar {{ background-color: alpha(@bg, 0.70); color: @on-bg;\
border-top-left-radius: {radius}; border-top-right-radius: {radius};\
border-bottom: 1px solid alpha(@on-bg, 0.10); box-shadow: none; }}\
.confirm-button {{ background-color: @accent; color: @on-accent; }}\
.confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\
.media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: 0;\
transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
transition: background-color 0.22s {spring_settle}; }}\
.media-widget:hover {{ background: alpha(@on-bg, 0.08); }}\
.media-widget.media-in {{ animation: row-in 0.4s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.media-widget.media-in {{ animation: row-in 0.4s {spring} both; }}\
.media-eq {{ min-height: 14px; margin-right: 4px; }}\
.media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @accent;\
border-radius: 2px; }}\
@ -186,7 +230,7 @@ fn load_css() -> String {
.control-panel-btn {{ padding: 5px 8px; margin: 0; border-radius: 10px;\
opacity: 0.92; font-size: 18px; line-height: 1; min-width: 0; min-height: 0;\
background: transparent; border: none; outline: none; box-shadow: none;\
transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
transition: background-color 0.22s {spring_settle},\
opacity 0.18s ease; }}\
.control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\
.control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\
@ -214,7 +258,7 @@ fn load_css() -> String {
.power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\
background: alpha(@on-bg, 0.08); font-size: 13px; border: none;\
outline: none; box-shadow: none;\
transition: background-color 0.2s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
transition: background-color 0.2s {spring_settle}; }}\
.power-btn:hover {{ background: alpha(@on-bg, 0.14); }}\
.power-btn:active {{ background: alpha(@accent, 0.22); }}\
.notification-action {{ transition: background-color 0.18s ease; }}\
@ -275,6 +319,8 @@ fn load_css() -> String {
radius_sm = radius_sm,
radius_pill = radius_pill,
pad = pad,
spring = spring,
spring_settle = spring_settle,
)
}
@ -324,3 +370,27 @@ pub fn apply() {
let user_path = std::path::PathBuf::from(format!("{home}/.config/breadbar/style.css"));
USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell));
}
thread_local! {
static SHELL_THEME_MONITOR: RefCell<Option<gtk4::gio::FileMonitor>> =
const { RefCell::new(None) };
}
/// Wires `bread_theme::shell::watch()` (plan §10) so editing the active
/// theme's `theme.toml`/`extra.css` on disk re-resolves CSS tokens without a
/// restart, the same way a pywal palette change already does via
/// `apply_app_css`. Window-spec values (anchors, margins, exclusive zone,
/// keyboard mode) are read once at window-construction time and are *not*
/// re-applied here — per plan §10 those need a restart, since live-swapping
/// a mapped layer-shell surface's anchors/exclusive-zone is a lot of
/// teardown risk for a rare operation.
///
/// Call once at startup (primary instance only — every satellite window
/// calling this would just re-arm the same watch redundantly).
pub fn watch_hot_reload() {
let monitor = bread_theme::shell::watch(|new_theme| {
set_shell_theme(new_theme);
apply();
});
SHELL_THEME_MONITOR.with(|cell| *cell.borrow_mut() = Some(monitor));
}