diff --git a/Cargo.lock b/Cargo.lock index 14765ed..d5be557 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,12 +186,14 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.7.4" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ + "anyhow", "dirs 5.0.1", "gtk4", "serde", "serde_json", + "toml 0.8.23", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1825aa8..de10b69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,3 +40,9 @@ resvg = { version = "0.47", default-features = false } lto = "thin" codegen-units = 1 strip = "symbols" + +# DEV ONLY — remove before tagging. Points bread-theme at the local +# bread-ecosystem checkout so Phase 2 can build against `bread_theme::shell` +# (feature/shell-theme-manifest) before it is tagged as a release. +[patch."https://git.breadway.dev/Breadway/bread-ecosystem"] +bread-theme = { path = "../bread-ecosystem/bread-theme" } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 2c05a15..611e15e 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -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); diff --git a/src/main.rs b/src/main.rs index 5210546..cf9a5c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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: >k4::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 diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 61adc0f..e3919bd 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -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); diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 9c767b6..394e136 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -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(>k4::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 } diff --git a/src/osd.rs b/src/osd.rs index d438292..73ce020 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -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) { }; 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 } diff --git a/src/panel.rs b/src/panel.rs index c1bcd75..9070c63 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -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, 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 diff --git a/src/screenshot.rs b/src/screenshot.rs index 05e9af6..38f857e 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -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: >k4::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)); }); }); } diff --git a/src/surface.rs b/src/surface.rs new file mode 100644 index 0000000..d2812a2 --- /dev/null +++ b/src/surface.rs @@ -0,0 +1,78 @@ +//! Applies a `[surfaces.]` 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: >k4::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); + } +} diff --git a/src/theme.rs b/src/theme.rs index f11b8d2..768a69d 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -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> = 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> = + 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 { + 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> = + 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)); +}