From df6b568cc12ec3794ae496c4cc0e49cd9a401065 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 23:26:34 +0800 Subject: [PATCH] bar: implement glass-workbench's three module variants (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires modules.workspaces.style/modules.clock.style into the bar for real (Phase 3 shipped the schema but only ever consumed trail/flip), and adds cpu/ram as bar modules, so glass-workbench (bread-theme) renders correctly while liquid-motion's default path is untouched: - workspaces: "pill" never calls WorkspaceTrail::place/stretch (the trail overlay stays invisible) and honours modules.workspaces.show_empty for real, rendering unoccupied non-active workspaces dimmed via CSS instead of filtering them out of the row. "trail" keeps the exact pre-existing filter/place/stretch behaviour regardless of show_empty. - clock: "plain" registers a plain date+time label (date_lbl, built but never parented until now, plus a new clock_plain_lbl) instead of the per-digit flip box. "flip" is untouched. - cpu/ram: two new bar chips (bar_cpu_pair/bar_ram_pair, bar_cpu_lbl/ bar_ram_lbl) fed by the same AppInput::StatsUpdate data as the control panel's cpu_pair/mem_pair. Separate instances rather than reparenting the panel's own pair — a GTK widget can only have one parent, and reparenting would pull them out of the control panel's sys-grid, which no theme asked to change. theme.rs::load_css() now branches on tokens.bar_border() (full-border island vs. flush bar's bottom-only hairline) and modules.workspaces.style (dimmed/translucent trail-fill buttons vs. solid-accent-fill pills), and unconditionally gains .clock-plain/.clock-plain-time rules. The trail/flip branches reproduce today's CSS byte-for-byte. Verification (bread-capture, isolated headless-Sway harness): - Noise floor: same baseline binary against itself varies from AE=0 (same capture minute) up to ~1900px (an in-flight digit-flip/clock-tick or a live Wi-Fi scan straddling two captures) and ~39000px on control-panel (live hardware sensors) — all pre-existing, not introduced by this change. - Regression: this binary vs. the pre-Phase-5 baseline, captured in the same clock-minute to remove the dominant noise source, is AE=0 (bit-for- bit identical) on the bar view under the default liquid-motion theme. - glass-workbench (BREAD_SHELL_THEME=glass-workbench): bar view is 1920x36 (flush, no margin) vs. liquid-motion's 1920x56 (12px margin + 44px island), square corners vs. liquid-motion's rounded island, plain "Mon 24/08 23:24" clock, and cpu/ram/wifi/battery/control chips with no media widget — matches demo 02. The isolated Sway capture harness has no live Hyprland IPC, so it reports zero workspaces under both themes (pre-existing, unrelated to this change) — the pill fill/dim CSS itself is exercised by code review and the trail-untouched regression result, not by a captured pixel with visible buttons. cargo test: 18/18 passing (breadbar), no new clippy/cargo-check warnings beyond the two pre-existing ones this task named up front. --- src/bar/clock.rs | 13 +++++ src/main.rs | 143 +++++++++++++++++++++++++++++++++++++++++++---- src/theme.rs | 85 ++++++++++++++++++++++------ 3 files changed, 213 insertions(+), 28 deletions(-) diff --git a/src/bar/clock.rs b/src/bar/clock.rs index 45e25e6..f7d897b 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -18,6 +18,19 @@ pub fn current() -> String { format!("{} {}", date(), time()) } +/// `modules.clock.format` rendered against GLib's own `DateTime::format` +/// (a strftime subset — `%H`, `%M`, `%a`, `%d`, `%m`, ... all work). Falls +/// back to [`time`]'s hardcoded "HH:MM" on a malformed format string rather +/// than propagating an error — a broken theme's clock format must degrade, +/// not crash the bar, same as every other "malformed theme" fallback in +/// this system. +pub fn formatted(format: &str) -> String { + now() + .format(format) + .map(|s| s.to_string()) + .unwrap_or_else(|_| time()) +} + pub fn spawn_ticker(sender: ComponentSender) { relm4::spawn(async move { loop { diff --git a/src/main.rs b/src/main.rs index 37d93f2..db91068 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ mod surface; mod theme; mod widgets; -use bread_theme::shell::{Exclusive, Keyboard}; +use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, WorkspaceStyle}; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use hyprland::data::Workspace; @@ -45,6 +45,12 @@ pub struct App { time_str: String, clock_digits: Vec, date_lbl: gtk4::Label, + // `modules.clock.style = "plain"` (glass-workbench, Phase 5): a plain + // "HH:MM" label with no per-digit flip. Built alongside `clock_digits` + // regardless of the active theme's style so switching styles needs no + // recompile; only one of the two ever lands in a `[bar.slots]` module + // registration (see the "Assemble" section). + clock_plain_lbl: gtk4::Label, // ── Stats bar ───────────────────────────────────────────────────────── // Island chrome matches the Liquid Motion demo: volume / wifi / battery @@ -58,6 +64,14 @@ pub struct App { cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, Phase + // 5): separate instances from `cpu_pair`/`mem_pair` above, which stay + // parented in the control panel's sys-grid — a GTK widget can only have + // one parent, so reusing those here would mean reparenting them out of + // the panel, changing panel behaviour no theme asked to change. Fed by + // the same `AppInput::StatsUpdate` data. + bar_cpu_lbl: gtk4::Label, + bar_ram_lbl: gtk4::Label, gpu_lbl: gtk4::Label, vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, @@ -388,6 +402,22 @@ impl SimpleComponent for App { date_lbl.add_css_class("date-label"); date_lbl.set_visible(false); + // `modules.clock.style = "plain"` (glass-workbench): date_lbl above + // plus one plain "HH:MM" label, no per-digit flip markup at all. + // Built unconditionally alongside the flip clock so a theme's style + // choice is just which of the two gets registered into the "clock" + // slot below — see "Assemble". + let clock_plain_lbl = gtk4::Label::new(Some(&bar::clock::time())); + clock_plain_lbl.add_css_class("clock-plain-time"); + clock_plain_lbl.set_valign(gtk4::Align::Center); + clock_plain_lbl.set_vexpand(false); + let clock_plain_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + clock_plain_box.add_css_class("clock-plain"); + clock_plain_box.set_valign(gtk4::Align::Center); + clock_plain_box.set_vexpand(false); + clock_plain_box.append(&date_lbl); + clock_plain_box.append(&clock_plain_lbl); + // Center area: [media_widget · widgets · clock · widgets] let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); @@ -420,6 +450,18 @@ impl SimpleComponent for App { pair.set_hexpand(true); } gpu_pair.set_visible(false); + + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, + // Phase 5): separate chip instances from `cpu_pair`/`mem_pair` + // above, which stay parented in the control panel's sys-grid below + // — reusing them here would mean reparenting them out of the panel. + // Same icons, same `.stat-pair` chip styling every other bar chip + // (volume/battery) already uses; fed by the same `StatsUpdate` data. + let bar_cpu_lbl = stat_label(); + let bar_ram_lbl = stat_label(); + let bar_cpu_pair = stat_pair(asset!("CPU.svg"), &bar_cpu_lbl); + let bar_ram_pair = stat_pair(asset!("RAM Usage.svg"), &bar_ram_lbl); + let system_stats_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); system_stats_box.add_css_class("sys-grid"); let sys_row1 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); @@ -721,11 +763,32 @@ impl SimpleComponent for App { let mut bar_modules = bar::slots::ModuleRegistry::new(); bar_modules.register("workspaces", &workspace_trail.overlay); bar_modules.register("media", &media_widget); - bar_modules.register("clock", &clock_box); + // `modules.clock.style`: "flip" (default, liquid-motion) registers + // the existing per-digit clock_box unchanged; "plain" (glass- + // workbench) registers clock_plain_box instead and reveals date_lbl + // per `show_date` — clock_box/clock_digits are still fully built in + // that case, just never placed in any slot. "none" (Phase 6+, + // unused today) registers neither. + match bar_shell_theme.modules().clock.style { + ClockStyle::Plain => { + date_lbl.set_visible(bar_shell_theme.modules().clock.show_date); + bar_modules.register("clock", &clock_plain_box); + } + ClockStyle::Flip => bar_modules.register("clock", &clock_box), + ClockStyle::None => {} + } bar_modules.register("volume", &vol_box); bar_modules.register("wifi", &connectivity_pair); bar_modules.register("battery", &bat_box); bar_modules.register("control", &hamburger_btn); + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench): + // registered unconditionally, same as every other module — a theme + // that never names "cpu"/"ram" in a slot (liquid-motion) just never + // walks these entries in `for_each_in_slot` below, so they stay + // built but unparented, exactly like `media_widget` does for + // glass-workbench (which omits "media" entirely). + bar_modules.register("cpu", &bar_cpu_pair); + bar_modules.register("ram", &bar_ram_pair); // `tray` never appears in a bar slot — it stays inside the // control-panel popover (built above, next to the SNI tray) — but @@ -791,6 +854,7 @@ impl SimpleComponent for App { time_str: bar::clock::current(), clock_digits, date_lbl, + clock_plain_lbl, system_stats_box, system_sep, cpu_pair, @@ -800,6 +864,8 @@ impl SimpleComponent for App { cpu_lbl, mem_lbl, pwr_lbl, + bar_cpu_lbl, + bar_ram_lbl, gpu_lbl, vol_lbl, bat_lbl, @@ -938,7 +1004,14 @@ impl SimpleComponent for App { self.active_ws = new_active; if let Some(btn) = self.button_map.get(&self.active_ws).cloned() { btn.add_css_class("active"); - self.workspace_trail.stretch(from.as_ref(), &btn); + // Trail style only: pill/dots never call place()/ + // stretch() at all — the "active" CSS class above is + // the whole of their active-workspace treatment + // (solid accent fill, no trail overlay). + if theme::shell_theme().modules().workspaces.style == WorkspaceStyle::Trail + { + self.workspace_trail.stretch(from.as_ref(), &btn); + } } } } @@ -958,8 +1031,25 @@ impl SimpleComponent for App { } AppInput::ClockTick => { self.time_str = bar::clock::current(); - flip_clock_digits(&self.clock_digits, &bar::clock::time()); self.date_lbl.set_label(&bar::clock::date()); + let clock_module = theme::shell_theme().modules().clock.clone(); + match clock_module.style { + // Plain (glass-workbench): one label, no flip animation + // — `flip_clock_digits` would just be wasted work (and + // a pointless 450ms `play_once` timer) on digits that + // are never on screen. + ClockStyle::Plain => { + self.clock_plain_lbl + .set_label(&bar::clock::formatted(&clock_module.format)); + } + // Flip (default, liquid-motion) and None both keep + // exactly today's per-digit-flip update — None has no + // module in a slot to display it, but there's no reason + // to special-case skipping the (cheap, idempotent) work. + ClockStyle::Flip | ClockStyle::None => { + flip_clock_digits(&self.clock_digits, &bar::clock::time()); + } + } } AppInput::StatsUpdate(stats) => { let cpu = match stats.cpu_temp { @@ -969,6 +1059,11 @@ impl SimpleComponent for App { self.cpu_lbl.set_label(&cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass- + // workbench): same formatted text, separate chip instances + // (see the App struct field docs for why). + self.bar_cpu_lbl.set_label(&cpu); + self.bar_ram_lbl.set_label(&stats.mem); match stats.gpu_usage { Some(g) => { let gpu = match stats.gpu_temp { @@ -1306,14 +1401,33 @@ impl App { self.workspace_box.remove(&child); } self.button_map.clear(); + let modules = theme::shell_theme().modules().clone(); + let ws_style = modules.workspaces.style; + let show_empty = modules.workspaces.show_empty; for ws in &self.workspaces { if ws.monitor != self.monitor { continue; } - // Persistent empty Hyprland workspaces stay off the bar unless - // this output is actually looking at them. - if ws.windows == 0 && ws.id != self.active_ws { - continue; + let empty = ws.windows == 0 && ws.id != self.active_ws; + if empty { + match ws_style { + // Trail (default, liquid-motion): unconditionally off + // the bar, exactly as before this change — regardless + // of `show_empty`, which liquid-motion's own manifest + // declares `true` but this style has never consumed. + // Changing that now would be a real, undesired + // liquid-motion regression, not a Phase 5 fix. + WorkspaceStyle::Trail => continue, + // Pill/Dots: honour `show_empty` for real — demo 02's + // pills render an unoccupied, non-active workspace at + // reduced opacity via the `.workspace-btn:not(.occupied) + // :not(.active)` CSS rule rather than hiding it. + _ => { + if !show_empty { + continue; + } + } + } } let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0); if !prev.contains(&ws.id) { @@ -1322,10 +1436,15 @@ impl App { self.workspace_box.append(&btn); self.button_map.insert(ws.id, btn); } - match self.button_map.get(&self.active_ws).cloned() { - Some(btn) if animate => self.workspace_trail.stretch(None, &btn), - Some(btn) => self.workspace_trail.place(&btn), - None => self.workspace_trail.clear(), + // Trail style only: pill/dots never call place()/stretch()/clear() + // at all — the "active" CSS class `make_button` already applies is + // the whole of their active-workspace treatment. + if ws_style == WorkspaceStyle::Trail { + match self.button_map.get(&self.active_ws).cloned() { + Some(btn) if animate => self.workspace_trail.stretch(None, &btn), + Some(btn) => self.workspace_trail.place(&btn), + None => self.workspace_trail.clear(), + } } } diff --git a/src/theme.rs b/src/theme.rs index 768a69d..57d307f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -69,6 +69,66 @@ fn load_css() -> String { let spring = tokens.spring(); let spring_settle = tokens.spring_settle(); + // `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid- + // motion's floating island) draws a border on all four edges; "bottom" + // (glass-workbench's flush edge-to-edge bar) draws only the hairline + // the demo's `.bar { border-bottom: 1px solid #ffffff12 }` calls for — + // a full border on a bar flush against the screen's top/left/right + // edges would otherwise show as a stray line along those edges an + // island never has to worry about. Reused below for the centerbox's + // horizontal padding too: the flush bar's demo padding (`0 12px`, + // symmetric) differs from the island's own asymmetric `0 8px 0 6px`. + let flush = tokens.bar_border() == "bottom"; + let window_border = if flush { + "border: none; border-bottom: 1px solid alpha(@on-bg, 0.07);".to_string() + } else { + "border: 1px solid alpha(@on-bg, 0.08);".to_string() + }; + let centerbox_padding = if flush { "0 12px" } else { "0 8px 0 6px" }; + + // `modules.workspaces.style` (plan §11 Phase 5): "trail" (default, + // liquid-motion) is exactly today's CSS, unchanged byte-for-byte — + // dimmed/translucent buttons with the gradient trail overlay supplying + // the active fill. "pill"/"dots" (glass-workbench, Phase 6+) render the + // active state as a solid accent fill on the button itself instead, + // since neither style ever calls `WorkspaceTrail::place`/`stretch` + // (see `App::rebuild_buttons`) — the trail's own `.workspace-trail` + // pill CSS is therefore irrelevant for them (it's never made visible). + let workspace_css = match theme.modules().workspaces.style { + bread_theme::shell::WorkspaceStyle::Trail => format!( + ".workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ + background-color: @accent; border-radius: 12px; }}\ + .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ + 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 {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 {spring_settle} both; }}", + ), + _ => { + let accent = theme.tokens().accent_from(); + format!( + ".workspace-btn {{ background: transparent; opacity: 1; color: alpha(@on-bg, 0.4);\ + border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ + min-width: 22px; min-height: 20px; margin: 0; padding: 0 6px;\ + font-size: 12px; font-weight: 600;\ + transition: background-color 0.22s {spring_settle},\ + color 0.22s {spring_settle}, opacity 0.22s {spring_settle}; }}\ + .workspace-btn:hover {{ background: alpha(@on-bg, 0.08); }}\ + .workspace-btn.occupied {{ color: alpha(@on-bg, 0.8); }}\ + .workspace-btn:not(.occupied):not(.active) {{ opacity: 0.35; }}\ + .workspace-btn.active {{ background: @{accent}; color: @on-accent; opacity: 1; }}\ + .workspace-btn.active:hover {{ background: @{accent}; }}\ + .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", + ) + } + }; + format!( "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\ @@ -79,22 +139,10 @@ fn load_css() -> String { @keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\ @keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\ window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ - border-radius: {radius_bar}; border: 1px solid alpha(@on-bg, 0.08); }}\ - window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\ + border-radius: {radius_bar}; {window_border} }}\ + window.breadbar > centerbox {{ padding: {centerbox_padding}; }}\ window.breadbar button {{ min-height: 0; min-width: 0; }}\ - .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ - background-color: @accent; border-radius: 12px; }}\ - .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ - 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 {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 {spring_settle} both; }}\ + {workspace_css}\ .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; }}\ @@ -102,7 +150,9 @@ fn load_css() -> String { 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 {spring} both; }}\ - .date-label {{ font-size: 14px; opacity: 0.52; letter-spacing: 0.04em; }}\ + .clock-plain {{ padding: 0 4px; }}\ + .clock-plain-time {{ font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }}\ + .date-label {{ font-size: 12px; opacity: 0.48; letter-spacing: 0.04em; }}\ .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ .stat-label.tick {{ animation: digit-flip 0.35s {spring} both; }}\ .stats-box {{ margin-right: 0; }}\ @@ -321,6 +371,9 @@ fn load_css() -> String { pad = pad, spring = spring, spring_settle = spring_settle, + window_border = window_border, + centerbox_padding = centerbox_padding, + workspace_css = workspace_css, ) }