From 8471cc02fec504ab1f10e732238d38f7f20882e2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 10 Jul 2026 13:37:56 +0800 Subject: [PATCH 01/85] Make unsaved Nearby networks clickable, add password-entry dialog Rows for scanned networks not yet in breadcrumbs' saved config were both dimmed and disabled (set_sensitive(false)), so there was no way to add them from the popover. They now stay visually dimmed via the existing wifi-popover-row-unsaved style but remain clickable, opening a small password dialog that saves + joins the network via `breadcrumbs add` + `breadcrumbs join`. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/bar/wifi.rs | 16 +++++++++ src/main.rs | 86 +++++++++++++++++++++++++++++++++++++++++++++---- src/theme.rs | 1 + 5 files changed, 98 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7438d58..78efbfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.2.2" +version = "0.2.3" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index e4d513d..0f562ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.2.2" +version = "0.2.3" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" diff --git a/src/bar/wifi.rs b/src/bar/wifi.rs index 13c25fd..6531ece 100644 --- a/src/bar/wifi.rs +++ b/src/bar/wifi.rs @@ -142,3 +142,19 @@ pub fn spawn_join(ssid: String) { }); } +/// Fire-and-forget: save a new network with its password, then join it. +pub fn spawn_add_and_join(ssid: String, password: String) { + relm4::spawn(async move { + let added = tokio::process::Command::new("breadcrumbs") + .args(["add", &ssid, &password]) + .output() + .await; + if matches!(added, Ok(o) if o.status.success()) { + let _ = tokio::process::Command::new("breadcrumbs") + .args(["join", &ssid]) + .output() + .await; + } + }); +} + diff --git a/src/main.rs b/src/main.rs index 34bdd97..b10c130 100644 --- a/src/main.rs +++ b/src/main.rs @@ -814,7 +814,6 @@ impl App { row.add_css_class("wifi-popover-row"); if !entry.saved { row.add_css_class("wifi-popover-row-unsaved"); - row.set_sensitive(false); } let is_current = entry.ssid == self.current_ssid; if is_current { @@ -837,13 +836,16 @@ impl App { row_box.append(&lbl); row.set_child(Some(&row_box)); - if entry.saved { - let ssid_clone = entry.ssid.clone(); - row.connect_clicked(move |btn| { + let ssid_clone = entry.ssid.clone(); + let saved = entry.saved; + row.connect_clicked(move |btn| { + if saved { bar::wifi::spawn_join(ssid_clone.clone()); - close_parent_popover(btn); - }); - } + } else { + show_add_network_dialog(btn, ssid_clone.clone()); + } + close_parent_popover(btn); + }); self.wifi_popover_box.append(&row); } } @@ -901,6 +903,76 @@ fn close_parent_popover(widget: >k4::Button) { } } +/// Small modal prompting for a password, then saves + joins the network via +/// `breadcrumbs add` + `breadcrumbs join`. +fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { + let dialog = gtk4::Window::new(); + dialog.set_title(Some(&format!("Add “{ssid}”"))); + dialog.set_resizable(false); + dialog.add_css_class("wifi-add-dialog"); + if let Some(root) = anchor.root() { + if let Ok(win) = root.downcast::() { + dialog.set_transient_for(Some(&win)); + dialog.set_modal(true); + } + } + + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + body.set_margin_top(12); + body.set_margin_bottom(12); + body.set_margin_start(12); + body.set_margin_end(12); + + let lbl = gtk4::Label::new(Some(&format!("Password for {ssid}"))); + lbl.set_xalign(0.0); + body.append(&lbl); + + let entry = gtk4::PasswordEntry::new(); + entry.set_show_peek_icon(true); + body.append(&entry); + + let btn_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + btn_row.set_halign(gtk4::Align::End); + let cancel_btn = gtk4::Button::with_label("Cancel"); + let connect_btn = gtk4::Button::with_label("Connect"); + connect_btn.add_css_class("suggested-action"); + btn_row.append(&cancel_btn); + btn_row.append(&connect_btn); + body.append(&btn_row); + + dialog.set_child(Some(&body)); + + let d = dialog.clone(); + cancel_btn.connect_clicked(move |_| d.close()); + + let dialog_for_connect = dialog.clone(); + let entry_for_connect = entry.clone(); + let ssid_for_connect = ssid.clone(); + connect_btn.connect_clicked(move |_| { + let password = entry_for_connect.text().to_string(); + if password.is_empty() { + return; + } + bar::wifi::spawn_add_and_join(ssid_for_connect.clone(), password); + dialog_for_connect.close(); + }); + + let dialog_for_activate = dialog.clone(); + let entry_for_activate = entry.clone(); + let ssid_for_activate = ssid.clone(); + entry.connect_activate(move |_| { + let password = entry_for_activate.text().to_string(); + if password.is_empty() { + return; + } + bar::wifi::spawn_add_and_join(ssid_for_activate.clone(), password); + dialog_for_activate.close(); + }); + + dialog.present(); + entry.grab_focus(); +} + fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); pair.add_css_class("stat-pair"); diff --git a/src/theme.rs b/src/theme.rs index fb7819b..dc7e149 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -50,6 +50,7 @@ fn load_css() -> String { .wifi-popover-row-active {{ color: {accent}; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ + window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px; }}\ .media-widget {{ border-radius: 4px; padding: 0 6px; cursor: pointer; }}\ .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ From e8d5fd5a522f3a9bc45203588eea94444992ee72 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 10 Jul 2026 18:04:31 +0800 Subject: [PATCH 02/85] Set layer-shell namespace to breadbar, bump version to 0.2.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78efbfb..a277271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.2.3" +version = "0.2.4" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index 0f562ff..de80fcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.2.3" +version = "0.2.4" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" diff --git a/src/main.rs b/src/main.rs index b10c130..2767846 100644 --- a/src/main.rs +++ b/src/main.rs @@ -123,6 +123,7 @@ impl SimpleComponent for App { sender: ComponentSender, ) -> ComponentParts { 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); From 1f2d58d97f7b98d28483c78677ed0d10e55fc8ea Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 08:40:32 +0800 Subject: [PATCH 03/85] breadbar: reconnect Hyprland event stream, fix notification spec violations, point lock button at breadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/bar/workspaces.rs: the Hyprland EventStream loop exited permanently on the first Err/end-of-stream (Hyprland restart/reload, IPC hiccup), freezing every workspace button for the bar's remaining life. Now wrapped in a reconnect loop with capped exponential backoff, re-syncing workspace state on every reconnect - src/notifications/mod.rs + popup.rs: three spec deviations fixed — expire_timeout=0 now means never-expire instead of being coerced to 5s (and a critical-urgency notification with no explicit timeout also persists by default); NotificationClosed is now emitted with the correct reason code whenever a notification actually goes away (expiry or an explicit CloseNotification call); replaces_id no longer races its auto-dismiss timer against the replacement's, via a per-id generation counter checked before a stale timer is allowed to dismiss anything - src/main.rs + README.md + assets/icons-needed.txt: lock button (and its docs) now invoke breadlock instead of hyprlock, the thing breadlock was built to replace - src/notifications/mod.rs: added 4 unit tests for the new expire_timeout/ urgency mapping (pulled into a pure compute_expire() for testability) — this crate had zero test coverage before --- README.md | 2 +- assets/icons-needed.txt | 2 +- src/bar/workspaces.rs | 61 +++++++++++++++------ src/main.rs | 5 +- src/notifications/mod.rs | 109 +++++++++++++++++++++++++++++++++---- src/notifications/popup.rs | 96 ++++++++++++++++++++++++++------ 6 files changed, 229 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 7e58eff..d8b3f9e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Live CPU%, GPU%, and network throughput (download/upload) - Audio output selector (lists PulseAudio sinks via `pactl`, switching takes effect immediately) - System tray (SNI): apps that register with `org.kde.StatusNotifierWatcher` appear as icon buttons -- Power buttons: lock (`hyprlock`), suspend, reboot, poweroff +- Power buttons: lock (`breadlock`), suspend, reboot, poweroff **Notification daemon**: diff --git a/assets/icons-needed.txt b/assets/icons-needed.txt index e228054..c93fd85 100644 --- a/assets/icons-needed.txt +++ b/assets/icons-needed.txt @@ -20,7 +20,7 @@ Brightness.svg Power section buttons ----------------------- Lock.svg - Padlock icon — triggers hyprlock (lock screen). + Padlock icon — triggers breadlock (lock screen). Currently placeholder: 🔒 Sleep.svg diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index f3e2209..67942f8 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -10,28 +10,57 @@ use relm4::ComponentSender; use crate::AppInput; +/// Fetches the current workspace list + active workspace and pushes both to +/// the app — used both for the initial state and to re-sync after the event +/// stream reconnects (state may have changed while we were disconnected). +async fn sync_state(sender: &ComponentSender) { + if let Ok(ws) = Workspaces::get_async().await { + sender.input(AppInput::WorkspaceList(ws.to_vec())); + } + if let Ok(active) = Workspace::get_active_async().await { + sender.input(AppInput::ActiveWorkspace(active.id)); + } +} + pub fn spawn_watcher(sender: ComponentSender) { relm4::spawn(async move { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } - if let Ok(active) = Workspace::get_active_async().await { - sender.input(AppInput::ActiveWorkspace(active.id)); - } + sync_state(&sender).await; - let mut stream = EventStream::new(); - while let Some(Ok(event)) = stream.next().await { - match event { - Event::WorkspaceChanged(data) => { - sender.input(AppInput::ActiveWorkspace(data.id)); - } - Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); + // Hyprland's IPC event socket can drop out from under us — a + // Hyprland restart/reload, or just a transient hiccup — at which + // point `stream.next()` yields `None` (or an `Err`, also excluded + // by this `while let Some(Ok(..))` pattern). That used to just fall + // through and end this whole task permanently, freezing every + // workspace button for the rest of the bar's life. Reconnect with a + // capped exponential backoff instead of giving up. + let mut backoff = std::time::Duration::from_millis(500); + const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30); + + loop { + let mut stream = EventStream::new(); + while let Some(Ok(event)) = stream.next().await { + backoff = std::time::Duration::from_millis(500); + match event { + Event::WorkspaceChanged(data) => { + sender.input(AppInput::ActiveWorkspace(data.id)); } + Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { + if let Ok(ws) = Workspaces::get_async().await { + sender.input(AppInput::WorkspaceList(ws.to_vec())); + } + } + _ => {} } - _ => {} } + + eprintln!( + "breadbar: Hyprland event stream ended (restart/reload/IPC hiccup); \ + reconnecting in {:?}", + backoff + ); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(MAX_BACKOFF); + sync_state(&sender).await; } }); } diff --git a/src/main.rs b/src/main.rs index 2767846..55fc123 100644 --- a/src/main.rs +++ b/src/main.rs @@ -392,7 +392,10 @@ impl SimpleComponent for App { let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); power_row.add_css_class("power-row"); for (label, cmd) in [ - ("🔒", vec!["hyprlock"]), + // breadlock is the ecosystem's own screen locker — hyprlock is + // the thing it was built to replace; the bar shouldn't still + // be pointing at it. + ("🔒", vec!["breadlock"]), ("💤", vec!["systemctl", "suspend"]), ("🔄", vec!["systemctl", "reboot"]), ("⏻", vec!["systemctl", "poweroff"]), diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index b6c60b2..37343e9 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -1,20 +1,53 @@ pub mod popup; use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; use tokio::sync::mpsc; use zbus::zvariant::OwnedValue; +/// How long a shown notification should stay up before auto-dismissing. +/// Distinct from `Option` mainly for readability at call sites — +/// `Never` covers both the spec's `expire_timeout == 0` ("never expire") +/// and a critical-urgency notification with no explicit timeout, which +/// conventionally shouldn't auto-dismiss either. +#[derive(Debug, Clone, Copy)] +pub enum Expire { + Never, + After(Duration), +} + pub enum NotifEvent { Show { id: u32, app_name: String, summary: String, body: String, - timeout_ms: u32, + expire: Expire, }, Close(u32), } +/// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint +/// was critical) to our internal `Expire`, per the freedesktop notification +/// spec: `0` always means never expire; a negative value means "server +/// picks a default" (5s here, except critical notifications, which +/// conventionally persist); any non-negative value is taken literally. +/// Pulled out of `NotifServer::notify` so this mapping is unit-testable +/// without a live D-Bus connection. +fn compute_expire(expire_timeout: i32, urgency_critical: bool) -> Expire { + match expire_timeout { + 0 => Expire::Never, + t if t < 0 => { + if urgency_critical { + Expire::Never + } else { + Expire::After(Duration::from_millis(5000)) + } + } + t => Expire::After(Duration::from_millis(t as u64)), + } +} + struct NotifServer { tx: mpsc::Sender, next_id: AtomicU32, @@ -32,7 +65,7 @@ impl NotifServer { summary: &str, body: &str, _actions: Vec, - _hints: std::collections::HashMap, + hints: std::collections::HashMap, expire_timeout: i32, ) -> u32 { let id = if replaces_id != 0 { @@ -40,11 +73,19 @@ impl NotifServer { } else { self.next_id.fetch_add(1, Ordering::Relaxed) }; - let timeout_ms = if expire_timeout <= 0 { - 5000 - } else { - expire_timeout as u32 - }; + + // Per spec: 0 means "never expire" — this used to be lumped in + // with "-1: let the server pick a default" and coerced to a fixed + // 5s, so a sender explicitly asking for a persistent notification + // (e.g. a progress/error dialog) got auto-dismissed anyway. + // Critical-urgency notifications conventionally persist too, even + // when the sender left expire_timeout at the server-default (-1). + let urgency_critical = hints + .get("urgency") + .and_then(|v| u8::try_from(v).ok()) + .is_some_and(|u| u == 2); + let expire = compute_expire(expire_timeout, urgency_critical); + let _ = self .tx .send(NotifEvent::Show { @@ -52,7 +93,7 @@ impl NotifServer { app_name: app_name.to_string(), summary: summary.to_string(), body: body.to_string(), - timeout_ms, + expire, }) .await; id @@ -78,6 +119,7 @@ impl NotifServer { pub fn spawn() { let (tx, rx) = mpsc::channel(32); + let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); relm4::spawn(async move { let server = NotifServer { @@ -85,7 +127,7 @@ pub fn spawn() { next_id: AtomicU32::new(1), }; // Builder failures here would only occur with invalid static strings — safe to unwrap. - let _conn = zbus::connection::Builder::session() + let conn = zbus::connection::Builder::session() .unwrap() .name("org.freedesktop.Notifications") .unwrap() @@ -94,8 +136,55 @@ pub fn spawn() { .build() .await .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); + // Hand the connection to popup::run so it can emit `NotificationClosed` + // (spec-mandated whenever a notification actually goes away) — the + // dismiss decisions all happen over there, not in this interface impl. + let _ = conn_tx.send(conn); std::future::pending::<()>().await }); - relm4::spawn_local(popup::run(rx)); + relm4::spawn_local(async move { + if let Ok(conn) = conn_rx.await { + popup::run(rx, conn).await; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_timeout_never_expires_regardless_of_urgency() { + assert!(matches!(compute_expire(0, false), Expire::Never)); + assert!(matches!(compute_expire(0, true), Expire::Never)); + } + + #[test] + fn negative_timeout_defaults_to_five_seconds_for_normal_urgency() { + match compute_expire(-1, false) { + Expire::After(d) => assert_eq!(d, Duration::from_millis(5000)), + Expire::Never => panic!("expected a 5s default, got Never"), + } + } + + #[test] + fn negative_timeout_persists_for_critical_urgency() { + assert!(matches!(compute_expire(-1, true), Expire::Never)); + } + + #[test] + fn positive_timeout_is_taken_literally() { + match compute_expire(1500, false) { + Expire::After(d) => assert_eq!(d, Duration::from_millis(1500)), + Expire::Never => panic!("expected 1500ms, got Never"), + } + // Even for critical urgency, an explicit positive timeout is honored + // rather than overridden to Never — "critical persists" is only the + // *default* when the sender didn't specify one. + match compute_expire(1500, true) { + Expire::After(d) => assert_eq!(d, Duration::from_millis(1500)), + Expire::Never => panic!("expected 1500ms, got Never"), + } + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index c8a7e50..41db852 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -1,14 +1,29 @@ -use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration}; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::NotifEvent; +use super::{Expire, NotifEvent}; type Cards = Rc>>; +// Bumped every time an id gets a (re)placed card — an auto-dismiss timer +// scheduled for an earlier Show captures the generation it was scheduled +// under, and checks it's still current before dismissing. Without this, a +// notification that replaces an existing id (replaces_id) doesn't cancel +// the original's timer, so the *replacement* card gets dismissed on the +// *original*'s deadline instead of its own. +type Generations = Rc>>; -pub async fn run(mut rx: Receiver) { +/// NotificationClosed reason codes per the freedesktop spec. +mod close_reason { + pub const EXPIRED: u32 = 1; + #[allow(dead_code)] // no in-app dismiss button exists yet (see make_card) + pub const DISMISSED_BY_USER: u32 = 2; + pub const CLOSE_NOTIFICATION_CALL: u32 = 3; +} + +pub async fn run(mut rx: Receiver, conn: zbus::Connection) { let window = create_window(); let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); cards_box.set_margin_top(8); @@ -18,6 +33,7 @@ pub async fn run(mut rx: Receiver) { window.set_child(Some(&cards_box)); let cards: Cards = Rc::new(RefCell::new(HashMap::new())); + let generations: Generations = Rc::new(RefCell::new(HashMap::new())); while let Some(event) = rx.recv().await { match event { @@ -26,7 +42,7 @@ pub async fn run(mut rx: Receiver) { app_name, summary, body, - timeout_ms, + expire, } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { @@ -37,29 +53,75 @@ pub async fn run(mut rx: Receiver) { cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); - // Auto-dismiss via GLib-native timer (safe inside spawn_local) - let cards_clone = cards.clone(); - let cards_box_clone = cards_box.clone(); - let win_clone = window.clone(); - relm4::spawn_local(async move { - gtk4::glib::timeout_future(Duration::from_millis(timeout_ms as u64)).await; - dismiss(&cards_box_clone, &win_clone, &cards_clone, id); - }); + let my_generation = { + let mut gens = generations.borrow_mut(); + let g = gens.entry(id).or_insert(0); + *g += 1; + *g + }; + + // `Expire::Never` (expire_timeout=0, or a critical-urgency + // notification with no explicit timeout) schedules no timer + // at all — it persists until an explicit CloseNotification. + if let Expire::After(duration) = expire { + let cards_clone = cards.clone(); + let cards_box_clone = cards_box.clone(); + let win_clone = window.clone(); + let generations_clone = generations.clone(); + let conn_clone = conn.clone(); + relm4::spawn_local(async move { + gtk4::glib::timeout_future(duration).await; + let still_current = + generations_clone.borrow().get(&id) == Some(&my_generation); + if still_current + && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) + { + emit_closed(&conn_clone, id, close_reason::EXPIRED).await; + } + }); + } } NotifEvent::Close(id) => { - dismiss(&cards_box, &window, &cards, id); + if dismiss(&cards_box, &window, &cards, id) { + emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await; + } } } } } -fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) { - if let Some(card) = cards.borrow_mut().remove(&id) { - cards_box.remove(&card); - } +/// Removes `id`'s card if present. Returns whether a card was actually +/// removed, so callers only emit `NotificationClosed` for a real dismissal +/// (not a no-op on an id that's already gone or was never shown). +fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) -> bool { + let removed = cards.borrow_mut().remove(&id); + let Some(card) = removed else { + return false; + }; + cards_box.remove(&card); if cards.borrow().is_empty() { window.set_visible(false); } + true +} + +/// Emits the spec-mandated `NotificationClosed(id, reason)` signal. Sent +/// directly over the connection rather than through the zbus interface +/// macro's generated helper, since the dismiss decision happens here in the +/// popup task, not inside `NotifServer`'s own method bodies. +async fn emit_closed(conn: &zbus::Connection, id: u32, reason: u32) { + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "NotificationClosed", + &(id, reason), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit NotificationClosed for {id}: {e}"); + } } fn create_window() -> gtk4::Window { From 86432d718a3846701dac4ffb55018a055a6db640 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 14:36:33 +0800 Subject: [PATCH 04/85] Design refresh: new icon assets, bluetooth popover, notification urgency styling, RAM/power draw in control panel Adds real SVG icon assets replacing the icons-needed.txt checklist, a bluetooth popover module, per-notification urgency (CSS-styled), and app-name/summary dedup in notification cards. Rounds out the control panel's stats section with RAM and power draw alongside the existing CPU/GPU/net rows. --- Cargo.toml | 4 +- assets/Bluetooth Settings.svg | 1 + assets/Brightness.svg | 1 + assets/Lock.svg | 1 + assets/Next.svg | 1 + assets/Pause.svg | 1 + assets/Play.svg | 1 + assets/Previous.svg | 1 + assets/Restart.svg | 1 + assets/Shutdown.svg | 1 + assets/Sleep.svg | 1 + assets/Volume.svg | 1 + assets/icons-needed.txt | 48 ---- packaging/arch/PKGBUILD | 4 +- src/bar/bluetooth.rs | 123 ++++++++++ src/bar/mod.rs | 1 + src/bar/stats.rs | 29 ++- src/main.rs | 436 ++++++++++++++++++++++++++++------ src/notifications/mod.rs | 28 ++- src/notifications/popup.rs | 15 +- src/osd.rs | 45 ++-- src/theme.rs | 66 +++-- 22 files changed, 625 insertions(+), 185 deletions(-) create mode 100644 assets/Bluetooth Settings.svg create mode 100644 assets/Brightness.svg create mode 100644 assets/Lock.svg create mode 100644 assets/Next.svg create mode 100644 assets/Pause.svg create mode 100644 assets/Play.svg create mode 100644 assets/Previous.svg create mode 100644 assets/Restart.svg create mode 100644 assets/Shutdown.svg create mode 100644 assets/Sleep.svg create mode 100644 assets/Volume.svg delete mode 100644 assets/icons-needed.txt create mode 100644 src/bar/bluetooth.rs diff --git a/Cargo.toml b/Cargo.toml index de80fcc..a17c1bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,8 @@ version = "0.2.4" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" -authors = ["Breadway "] -repository = "https://github.com/Breadway/breadbar" +authors = ["Breadway "] +repository = "https://git.breadway.dev/Breadway/breadbar" keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] diff --git a/assets/Bluetooth Settings.svg b/assets/Bluetooth Settings.svg new file mode 100644 index 0000000..c176fff --- /dev/null +++ b/assets/Bluetooth Settings.svg @@ -0,0 +1 @@ + diff --git a/assets/Brightness.svg b/assets/Brightness.svg new file mode 100644 index 0000000..8afaa73 --- /dev/null +++ b/assets/Brightness.svg @@ -0,0 +1 @@ + diff --git a/assets/Lock.svg b/assets/Lock.svg new file mode 100644 index 0000000..0be852f --- /dev/null +++ b/assets/Lock.svg @@ -0,0 +1 @@ + diff --git a/assets/Next.svg b/assets/Next.svg new file mode 100644 index 0000000..44d5519 --- /dev/null +++ b/assets/Next.svg @@ -0,0 +1 @@ + diff --git a/assets/Pause.svg b/assets/Pause.svg new file mode 100644 index 0000000..3ff4ce2 --- /dev/null +++ b/assets/Pause.svg @@ -0,0 +1 @@ + diff --git a/assets/Play.svg b/assets/Play.svg new file mode 100644 index 0000000..2821df0 --- /dev/null +++ b/assets/Play.svg @@ -0,0 +1 @@ + diff --git a/assets/Previous.svg b/assets/Previous.svg new file mode 100644 index 0000000..23ce806 --- /dev/null +++ b/assets/Previous.svg @@ -0,0 +1 @@ + diff --git a/assets/Restart.svg b/assets/Restart.svg new file mode 100644 index 0000000..b8b9c76 --- /dev/null +++ b/assets/Restart.svg @@ -0,0 +1 @@ + diff --git a/assets/Shutdown.svg b/assets/Shutdown.svg new file mode 100644 index 0000000..787d1a3 --- /dev/null +++ b/assets/Shutdown.svg @@ -0,0 +1 @@ + diff --git a/assets/Sleep.svg b/assets/Sleep.svg new file mode 100644 index 0000000..a49d306 --- /dev/null +++ b/assets/Sleep.svg @@ -0,0 +1 @@ + diff --git a/assets/Volume.svg b/assets/Volume.svg new file mode 100644 index 0000000..3ffce7f --- /dev/null +++ b/assets/Volume.svg @@ -0,0 +1 @@ + diff --git a/assets/icons-needed.txt b/assets/icons-needed.txt deleted file mode 100644 index e228054..0000000 --- a/assets/icons-needed.txt +++ /dev/null @@ -1,48 +0,0 @@ -SVG icons needed for breadbar -============================== -24×24 viewBox. Use `currentColor` for all fill/stroke so icons recolour -automatically with the bar theme. Drop finished files in this directory. - - -Control panel — slider row icons ---------------------------------- -Volume.svg - Speaker / soundwave icon for the volume slider row. - Currently placeholder: 🔊 emoji label. - Usage: control panel, left of volume slider. - -Brightness.svg - Sun / light bulb icon for the brightness slider row. - Currently placeholder: ☀ emoji label. - Usage: control panel, left of brightness slider. - - -Power section buttons ------------------------ -Lock.svg - Padlock icon — triggers hyprlock (lock screen). - Currently placeholder: 🔒 - -Sleep.svg - Crescent moon or Zzz icon — triggers systemctl suspend. - Currently placeholder: 💤 - -Restart.svg - Circular arrow icon — triggers systemctl reboot. - Currently placeholder: 🔄 - -Shutdown.svg - Power symbol (⏻) icon — triggers systemctl poweroff. - Currently placeholder: ⏻ - - -How to wire up icons once SVGs are ready ------------------------------------------ -Each power button and slider row icon is currently a gtk4::Label with an emoji. -To replace with an SVG: - - 1. Add the SVG to this directory. - 2. In main.rs, replace the emoji Label with: - gtk4::Image::from_paintable(Some(&svg_texture(asset!("Icon Name.svg")))) - 3. For slider rows, replace the icon_lbl in build_slider_row() calls, - or add an overload that takes an image widget instead of a string. diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index bb55a53..e8570e0 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,11 +1,11 @@ -# Maintainer: Breadway +# Maintainer: Breadway pkgname=breadbar pkgver=0.2.0 pkgrel=1 pkgdesc="Minimal status bar and notification daemon for Hyprland" arch=('x86_64') -url="https://github.com/Breadway/breadbar" +url="https://git.breadway.dev/Breadway/breadbar" license=('MIT') # Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, diff --git a/src/bar/bluetooth.rs b/src/bar/bluetooth.rs new file mode 100644 index 0000000..422b298 --- /dev/null +++ b/src/bar/bluetooth.rs @@ -0,0 +1,123 @@ +use crate::{App, AppInput}; +use relm4::ComponentSender; +use std::fs; + +#[derive(Debug, Clone)] +pub struct BtDevice { + pub address: String, + pub name: String, + pub connected: bool, + pub paired: bool, +} + +#[derive(Debug, Clone)] +pub struct BtPopoverData { + pub powered: bool, + pub devices: Vec, +} + +/// Same rfkill scan `bar::stats` uses for the bar icon — kept independent +/// (rather than shared) since it's a two-line read and pulling in a shared +/// helper isn't worth the coupling. +fn powered() -> bool { + fs::read_dir("/sys/class/rfkill") + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .any(|e| { + let p = e.path(); + fs::read_to_string(p.join("type")) + .map(|t| t.trim() == "bluetooth") + .unwrap_or(false) + && fs::read_to_string(p.join("state")) + .map(|s| s.trim() == "1") + .unwrap_or(false) + }) +} + +async fn fetch_devices() -> Vec { + try_fetch_devices().await.unwrap_or_default() +} + +async fn try_fetch_devices() -> Option> { + let conn = zbus::Connection::system().await.ok()?; + let mgr = zbus::fdo::ObjectManagerProxy::builder(&conn) + .destination("org.bluez") + .ok()? + .path("/") + .ok()? + .build() + .await + .ok()?; + let objects = mgr.get_managed_objects().await.ok()?; + + let mut devices: Vec = objects + .values() + .filter_map(|ifaces| ifaces.get("org.bluez.Device1")) + .filter_map(|props| { + let paired = props + .get("Paired") + .and_then(|v| bool::try_from(v.clone()).ok()) + .unwrap_or(false); + if !paired { + return None; + } + let address = props + .get("Address") + .and_then(|v| String::try_from(v.clone()).ok())?; + let name = props + .get("Alias") + .or_else(|| props.get("Name")) + .and_then(|v| String::try_from(v.clone()).ok()) + .unwrap_or_else(|| address.clone()); + let connected = props + .get("Connected") + .and_then(|v| bool::try_from(v.clone()).ok()) + .unwrap_or(false); + Some(BtDevice { address, name, connected, paired }) + }) + .collect(); + + devices.sort_by(|a, b| b.connected.cmp(&a.connected).then(a.name.cmp(&b.name))); + Some(devices) +} + +pub fn spawn_popover_load(sender: ComponentSender) { + relm4::spawn(async move { + let devices = fetch_devices().await; + sender.input(AppInput::BtPopoverData(BtPopoverData { + powered: powered(), + devices, + })); + }); +} + +/// Fire-and-forget: toggle the adapter's rfkill soft-block. +pub fn spawn_set_powered(on: bool) { + relm4::spawn(async move { + let _ = tokio::process::Command::new("rfkill") + .args([if on { "unblock" } else { "block" }, "bluetooth"]) + .output() + .await; + }); +} + +/// Fire-and-forget: connect a paired device by address via `bluetoothctl`. +pub fn spawn_connect(address: String) { + relm4::spawn(async move { + let _ = tokio::process::Command::new("bluetoothctl") + .args(["connect", &address]) + .output() + .await; + }); +} + +/// Fire-and-forget: disconnect a device by address via `bluetoothctl`. +pub fn spawn_disconnect(address: String) { + relm4::spawn(async move { + let _ = tokio::process::Command::new("bluetoothctl") + .args(["disconnect", &address]) + .output() + .await; + }); +} diff --git a/src/bar/mod.rs b/src/bar/mod.rs index 2563f23..e006a63 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -1,3 +1,4 @@ +pub mod bluetooth; pub mod clock; pub mod control; pub mod media; diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 3991324..764d758 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -37,11 +37,22 @@ pub const BT_CONNECTED: &str = include_str!(concat!( "/assets/Bluetooth Connected.svg" )); +pub const ICON_VOLUME: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Volume.svg")); +pub const ICON_BRIGHTNESS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Brightness.svg")); +pub const ICON_LOCK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Lock.svg")); +pub const ICON_SLEEP: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Sleep.svg")); +pub const ICON_RESTART: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Restart.svg")); +pub const ICON_SHUTDOWN: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Shutdown.svg")); +pub const ICON_BT_SETTINGS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth Settings.svg")); + #[derive(Debug)] pub struct Stats { pub cpu: String, + pub cpu_pct: f32, pub mem: String, + pub mem_pct: f32, pub power: String, + pub power_watts: f32, pub bat: String, pub bat_icon: &'static str, pub ac_connected: bool, @@ -99,7 +110,8 @@ fn read_cpu() -> f32 { (dtotal - didle) as f32 / dtotal as f32 * 100.0 } -fn read_ram() -> u64 { +/// Returns (used_kb, total_kb). +fn read_ram() -> (u64, u64) { let text = fs::read_to_string("/proc/meminfo").unwrap_or_default(); let mut total = 0u64; let mut avail = 0u64; @@ -121,7 +133,7 @@ fn read_ram() -> u64 { break; } } - total.saturating_sub(avail) + (total.saturating_sub(avail), total) } fn bat_path() -> Option<&'static PathBuf> { @@ -392,8 +404,14 @@ fn read_crumbs_profile() -> Option { pub async fn poll() -> Stats { let cpu = read_cpu(); - let mem = read_ram(); - let power = read_power().map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); + let (mem, mem_total) = read_ram(); + let mem_pct = if mem_total > 0 { + mem as f32 / mem_total as f32 * 100.0 + } else { + 0.0 + }; + let power_watts = read_power(); + let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); let pct = read_battery(); let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%")); let bat_icon = pct.map_or(BAT_MID, bat_level_icon); @@ -426,12 +444,15 @@ pub async fn poll() -> Stats { let (net_rx_kbs, net_tx_kbs) = read_net_throughput(); Stats { cpu: format!("{cpu:.0}%"), + cpu_pct: cpu, mem: if mem >= 1024 * 1024 { format!("{:.1}G", mem as f32 / (1024.0 * 1024.0)) } else { format!("{}M", mem / 1024) }, + mem_pct, power, + power_watts: power_watts.unwrap_or(0.0), bat, bat_icon, ac_connected, diff --git a/src/main.rs b/src/main.rs index 2767846..c42b87c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,12 @@ mod notifications; mod osd; mod theme; +/// Thresholds above which the bar's CPU/RAM/power-draw readouts appear at +/// all — see `AppInput::StatsUpdate`. Below these, the bar stays quiet. +const CPU_ATTENTION_THRESHOLD: f32 = 70.0; +const MEM_ATTENTION_THRESHOLD: f32 = 80.0; +const POWER_ATTENTION_THRESHOLD: f32 = 30.0; + use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use hyprland::data::Workspace; @@ -29,6 +35,14 @@ pub struct App { clock_lbl: gtk4::Label, // ── Stats bar ───────────────────────────────────────────────────────── + // The system-stats trio (CPU/RAM/power draw) only shows up when a value + // crosses a "you probably want to know about this" threshold — otherwise + // the bar stays quiet. See AppInput::StatsUpdate. + system_stats_box: gtk4::Box, + system_sep: gtk4::Separator, + cpu_pair: gtk4::Box, + mem_pair: gtk4::Box, + pwr_pair: gtk4::Box, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, @@ -43,16 +57,20 @@ pub struct App { wifi_textures: std::collections::HashMap, // ── WiFi popover ────────────────────────────────────────────────────── - wifi_popover_box: gtk4::Box, + wifi_pane: gtk4::Box, crumbs_status: Option, wifi_popover_data: Option, wifi_profile: Option, current_ssid: String, + // ── Bluetooth popover ──────────────────────────────────────────────── + bt_pane: gtk4::Box, + bt_popover_data: Option, + // ── Media ───────────────────────────────────────────────────────────── media_widget: gtk4::Box, media_track_lbl: gtk4::Label, - media_play_btn: gtk4::Button, + media_play_icon: gtk4::Image, media_last: Option, media_paused_at: Option, @@ -66,10 +84,14 @@ pub struct App { panel_sink_signal: Option, panel_sinks: Vec, panel_cpu_lbl: gtk4::Label, + panel_mem_lbl: gtk4::Label, + panel_pwr_lbl: gtk4::Label, panel_gpu_lbl: gtk4::Label, panel_net_lbl: gtk4::Label, // ── Tray ────────────────────────────────────────────────────────────── + tray_section: gtk4::Box, + tray_sep: gtk4::Separator, tray_box: gtk4::Box, tray_items: std::collections::HashMap, } @@ -84,6 +106,7 @@ pub enum AppInput { CrumbsStatus(bar::wifi::CrumbsStatus), WifiPopoverData(bar::wifi::WifiPopoverData), SetProfile(String), + BtPopoverData(bar::bluetooth::BtPopoverData), MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), } @@ -176,39 +199,15 @@ impl SimpleComponent for App { let wifi_img = gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); - let wifi_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - wifi_pair.add_css_class("stat-pair"); - wifi_pair.add_css_class("wifi-pair"); wifi_img.add_css_class("stat-icon"); - wifi_pair.append(&wifi_img); - wifi_pair.append(&wifi_lbl); - let wifi_popover_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - wifi_popover_box.add_css_class("wifi-popover-inner"); - wifi_popover_box.set_margin_top(4); - wifi_popover_box.set_margin_bottom(4); - wifi_popover_box.set_margin_start(4); - wifi_popover_box.set_margin_end(4); + // Content pane only — this becomes a tab inside the merged + // connectivity popover built alongside the Bluetooth pane below, + // once bt_img exists too. See "Connectivity popover" further down. + let wifi_pane = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let loading_lbl = gtk4::Label::new(Some("Scanning…")); loading_lbl.add_css_class("wifi-popover-loading"); - wifi_popover_box.append(&loading_lbl); - - let wifi_popover = gtk4::Popover::new(); - wifi_popover.add_css_class("wifi-popover"); - wifi_popover.set_child(Some(&wifi_popover_box)); - wifi_popover.set_parent(&wifi_pair); - - let wpop = wifi_popover.clone(); - let gesture = gtk4::GestureClick::new(); - gesture.connect_released(move |_, _, _, _| { - if wpop.is_visible() { wpop.popdown(); } else { wpop.popup(); } - }); - wifi_pair.add_controller(gesture); - - let sender_wp = sender.clone(); - wifi_popover.connect_show(move |_| { - bar::wifi::spawn_popover_load(sender_wp.clone()); - }); + wifi_pane.append(&loading_lbl); // ── Media widget (center) ──────────────────────────────────────── let media_widget = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); @@ -235,18 +234,26 @@ impl SimpleComponent for App { media_controls_box.set_margin_start(4); media_controls_box.set_margin_end(4); - let prev_btn = gtk4::Button::with_label("⏮"); + let prev_btn = gtk4::Button::new(); + prev_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( + asset!("Previous.svg"), + ))))); prev_btn.add_css_class("flat"); prev_btn.add_css_class("media-btn"); prev_btn.connect_clicked(|_| bar::media::spawn_cmd("previous")); - let media_play_btn = gtk4::Button::with_label("⏸"); + let media_play_icon = gtk4::Image::from_paintable(Some(&svg_texture(asset!("Pause.svg")))); + let media_play_btn = gtk4::Button::new(); + media_play_btn.set_child(Some(&media_play_icon)); media_play_btn.add_css_class("flat"); media_play_btn.add_css_class("media-btn"); media_play_btn.add_css_class("media-play-btn"); media_play_btn.connect_clicked(|_| bar::media::spawn_cmd("play-pause")); - let next_btn = gtk4::Button::with_label("⏭"); + let next_btn = gtk4::Button::new(); + next_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( + asset!("Next.svg"), + ))))); next_btn.add_css_class("flat"); next_btn.add_css_class("media-btn"); next_btn.connect_clicked(|_| bar::media::spawn_cmd("next")); @@ -280,9 +287,22 @@ impl SimpleComponent for App { // ── Stats box (right side) ─────────────────────────────────────── let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); stats_box.add_css_class("stats-box"); - stats_box.append(&stat_pair(asset!("CPU.svg"), &cpu_lbl)); - stats_box.append(&stat_pair(asset!("RAM Usage.svg"), &mem_lbl)); - stats_box.append(&stat_pair(asset!("Power Draw.svg"), &pwr_lbl)); + + // CPU/RAM/power draw: hidden by default (see StatsUpdate), so this + // whole sub-group — plus its separator — collapses away when quiet. + let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); + let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); + let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); + let system_stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + system_stats_box.append(&cpu_pair); + system_stats_box.append(&mem_pair); + system_stats_box.append(&pwr_pair); + system_stats_box.set_visible(false); + stats_box.append(&system_stats_box); + let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); + system_sep.add_css_class("bar-sep"); + system_sep.set_visible(false); + stats_box.append(&system_sep); let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); @@ -296,28 +316,121 @@ impl SimpleComponent for App { stats_box.append(&bat_box); bt_img.add_css_class("bt-icon"); - bt_img.add_css_class("clickable"); - let bt_gesture = gtk4::GestureClick::new(); - bt_gesture.connect_released(|_, _, _, _| { - relm4::spawn(async { - let _ = tokio::process::Command::new("blueman-manager").spawn(); - }); + + // Content pane only — same deal as wifi_pane above. + let bt_pane = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let bt_loading_lbl = gtk4::Label::new(Some("Loading…")); + bt_loading_lbl.add_css_class("wifi-popover-loading"); + bt_pane.append(&bt_loading_lbl); + + // ── Connectivity popover ───────────────────────────────────────── + // WiFi and Bluetooth share one popover with one anatomy (same CSS + // classes throughout the two panes) instead of two near-identical + // popups behind two separate icons. A small tab row switches between + // them; both fetches kick off on open so switching tabs afterward is + // instant. The two panes live in a Stack rather than plain sibling + // Boxes with manual visibility toggling, which left stale width + // behind on reopen. + // + // hhomogeneous/vhomogeneous are ON (GTK's default), even though that + // means the popover is always sized to the *larger* of the two panes + // (visible empty space under the shorter one) — the alternative, + // sizing to just the active pane, means the popup has to resize + // itself in place when you switch tabs while it's open. Under + // gtk4-layer-shell that in-place resize doesn't reliably reach the + // compositor as a proper xdg_popup reposition; switching from the + // shorter tab to the taller one after a close/reopen cycle made the + // whole popover silently vanish instead of growing. A constant + // footprint sidesteps the resize entirely. + let content_stack = gtk4::Stack::new(); + content_stack.set_hhomogeneous(true); + content_stack.set_vhomogeneous(true); + // Reserve enough room up front for the tallest realistic content + // (WiFi tab with a handful of nearby networks). Homogeneous sizing + // alone still lets the *first* real data load (Scanning… → populated + // list) trigger a live resize while the popup is mapped, which hits + // the same reposition fragility as the tab-switch case — claiming + // the space before anything is shown avoids that resize too. + content_stack.set_size_request(220, 420); + content_stack.add_named(&wifi_pane, Some("wifi")); + content_stack.add_named(&bt_pane, Some("bluetooth")); + content_stack.set_visible_child_name("wifi"); + + let wifi_tab_btn = gtk4::ToggleButton::with_label("Wi-Fi"); + wifi_tab_btn.add_css_class("popover-tab"); + wifi_tab_btn.set_active(true); + let bt_tab_btn = gtk4::ToggleButton::with_label("Bluetooth"); + bt_tab_btn.add_css_class("popover-tab"); + bt_tab_btn.set_group(Some(&wifi_tab_btn)); + + let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + tab_row.add_css_class("popover-tab-row"); + tab_row.append(&wifi_tab_btn); + tab_row.append(&bt_tab_btn); + + let stack_for_wifi = content_stack.clone(); + wifi_tab_btn.connect_toggled(move |btn| { + if btn.is_active() { + stack_for_wifi.set_visible_child_name("wifi"); + } + }); + let stack_for_bt = content_stack.clone(); + bt_tab_btn.connect_toggled(move |btn| { + if btn.is_active() { + stack_for_bt.set_visible_child_name("bluetooth"); + } + }); + + let connectivity_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + connectivity_inner.add_css_class("wifi-popover-inner"); + connectivity_inner.append(&tab_row); + connectivity_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + connectivity_inner.append(&content_stack); + + let connectivity_popover = gtk4::Popover::new(); + connectivity_popover.add_css_class("wifi-popover"); + connectivity_popover.set_child(Some(&connectivity_inner)); + + let connectivity_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + connectivity_pair.add_css_class("stat-pair"); + connectivity_pair.add_css_class("wifi-pair"); + connectivity_pair.append(&bt_img); + connectivity_pair.append(&wifi_img); + connectivity_pair.append(&wifi_lbl); + // Anchored to wifi_lbl specifically, not the connectivity_pair row — + // a Popover parented to a multi-child Box via set_parent() balloons + // that Box's own allocation after a popup→popdown→popup cycle (its + // width roughly quadrupled in testing, shoving every bar item to its + // left further left). Anchoring to a single leaf widget instead + // sidesteps whatever GTK4/gtk4-layer-shell interaction causes that; + // the click target below still covers the whole row regardless. + connectivity_popover.set_parent(&wifi_lbl); + stats_box.append(&connectivity_pair); + + let cpop = connectivity_popover.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } + }); + connectivity_pair.add_controller(gesture); + + let sender_conn = sender.clone(); + connectivity_popover.connect_show(move |_| { + bar::wifi::spawn_popover_load(sender_conn.clone()); + bar::bluetooth::spawn_popover_load(sender_conn.clone()); }); - bt_img.add_controller(bt_gesture); - stats_box.append(&bt_img); - stats_box.append(&wifi_pair); // ── Control panel popover ──────────────────────────────────────── let panel_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); panel_inner.add_css_class("control-panel-inner"); // Volume row - let vol_row = build_slider_row("🔊", 0.0, 1.5, 0.02); + let vol_row = build_slider_row(bar::stats::ICON_VOLUME, 0.0, 1.5, 0.02); let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); // Brightness row - let bright_row = build_slider_row("☀", 0.0, 1.0, 0.02); + let bright_row = build_slider_row(bar::stats::ICON_BRIGHTNESS, 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); @@ -331,6 +444,14 @@ impl SimpleComponent for App { panel_cpu_lbl.add_css_class("control-panel-stat"); panel_cpu_lbl.set_xalign(0.0); + let panel_mem_lbl = gtk4::Label::new(Some("RAM —")); + panel_mem_lbl.add_css_class("control-panel-stat"); + panel_mem_lbl.set_xalign(0.0); + + let panel_pwr_lbl = gtk4::Label::new(Some("PWR —")); + panel_pwr_lbl.add_css_class("control-panel-stat"); + panel_pwr_lbl.set_xalign(0.0); + let panel_gpu_lbl = gtk4::Label::new(Some("GPU —")); panel_gpu_lbl.add_css_class("control-panel-stat"); panel_gpu_lbl.set_xalign(0.0); @@ -340,6 +461,8 @@ impl SimpleComponent for App { panel_net_lbl.set_xalign(0.0); stats_section.append(&panel_cpu_lbl); + stats_section.append(&panel_mem_lbl); + stats_section.append(&panel_pwr_lbl); stats_section.append(&panel_gpu_lbl); stats_section.append(&panel_net_lbl); panel_inner.append(&stats_section); @@ -377,9 +500,14 @@ impl SimpleComponent for App { tray_box.add_css_class("tray-box"); tray_section.append(&tray_header); tray_section.append(&tray_box); + // Collapsed (along with its separator) until an SNI app actually + // registers — an empty "Apps" section heading is dead weight. + tray_section.set_visible(false); panel_inner.append(&tray_section); - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + tray_sep.set_visible(false); + panel_inner.append(&tray_sep); // Power section let power_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); @@ -391,13 +519,14 @@ impl SimpleComponent for App { let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); power_row.add_css_class("power-row"); - for (label, cmd) in [ - ("🔒", vec!["hyprlock"]), - ("💤", vec!["systemctl", "suspend"]), - ("🔄", vec!["systemctl", "reboot"]), - ("⏻", vec!["systemctl", "poweroff"]), + for (icon_svg, cmd) in [ + (bar::stats::ICON_LOCK, vec!["hyprlock"]), + (bar::stats::ICON_SLEEP, vec!["systemctl", "suspend"]), + (bar::stats::ICON_RESTART, vec!["systemctl", "reboot"]), + (bar::stats::ICON_SHUTDOWN, vec!["systemctl", "poweroff"]), ] { - let btn = gtk4::Button::with_label(label); + let btn = gtk4::Button::new(); + btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture(icon_svg))))); btn.add_css_class("flat"); btn.add_css_class("power-btn"); btn.connect_clicked(move |_| { @@ -463,6 +592,11 @@ impl SimpleComponent for App { button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), clock_lbl, + system_stats_box, + system_sep, + cpu_pair, + mem_pair, + pwr_pair, cpu_lbl, mem_lbl, pwr_lbl, @@ -475,14 +609,16 @@ impl SimpleComponent for App { wifi_lbl, wifi_img, wifi_textures, - wifi_popover_box, + wifi_pane, crumbs_status: None, wifi_popover_data: None, wifi_profile: None, current_ssid: "—".to_string(), + bt_pane, + bt_popover_data: None, media_widget, media_track_lbl, - media_play_btn, + media_play_icon, media_last: None, media_paused_at: None, control_popover, @@ -494,8 +630,12 @@ impl SimpleComponent for App { panel_sink_signal: None, panel_sinks: vec![], panel_cpu_lbl, + panel_mem_lbl, + panel_pwr_lbl, panel_gpu_lbl, panel_net_lbl, + tray_section, + tray_sep, tray_box, tray_items: std::collections::HashMap::new(), }; @@ -539,6 +679,20 @@ impl SimpleComponent for App { self.cpu_lbl.set_label(&stats.cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); + + // Bar information diet: CPU/RAM/power draw only surface once + // they're actually worth knowing about, individually, so a + // hot CPU doesn't drag an idle RAM/power reading along with it. + let cpu_hot = stats.cpu_pct > CPU_ATTENTION_THRESHOLD; + let mem_hot = stats.mem_pct > MEM_ATTENTION_THRESHOLD; + let pwr_hot = stats.power_watts > POWER_ATTENTION_THRESHOLD; + self.cpu_pair.set_visible(cpu_hot); + self.mem_pair.set_visible(mem_hot); + self.pwr_pair.set_visible(pwr_hot); + let any_hot = cpu_hot || mem_hot || pwr_hot; + self.system_stats_box.set_visible(any_hot); + self.system_sep.set_visible(any_hot); + self.bat_lbl.set_label(&stats.bat); if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { self.bat_img.set_paintable(Some(tex)); @@ -574,6 +728,11 @@ impl SimpleComponent for App { }; self.panel_cpu_lbl.set_label(&cpu_str); + self.panel_mem_lbl + .set_label(&format!("RAM {:.0}% {}", stats.mem_pct, stats.mem)); + self.panel_pwr_lbl + .set_label(&format!("PWR {}", stats.power)); + let gpu_str = match (stats.gpu_usage, stats.gpu_temp) { (Some(u), Some(t)) => format!("GPU {u}% {t:.0}°C"), (Some(u), None) => format!("GPU {u}%"), @@ -603,19 +762,34 @@ impl SimpleComponent for App { btn.connect_clicked(move |_| bar::tray::spawn_activate(id_click.clone())); self.tray_box.append(&btn); self.tray_items.insert(id, btn); + self.tray_section.set_visible(true); + self.tray_sep.set_visible(true); } AppInput::TrayUpdate(bar::tray::TrayUpdate::Remove { id }) => { if let Some(btn) = self.tray_items.remove(&id) { self.tray_box.remove(&btn); } + let has_items = !self.tray_items.is_empty(); + self.tray_section.set_visible(has_items); + self.tray_sep.set_visible(has_items); } AppInput::CrumbsStatus(status) => { self.crumbs_status = Some(status); + // CrumbsStatus and WifiPopoverData arrive independently (different + // pollers); whichever lands second must still repaint the header — + // otherwise it only shows up if CrumbsStatus happens to win the race. + if self.wifi_popover_data.is_some() { + self.rebuild_wifi_popover(&sender); + } } AppInput::WifiPopoverData(data) => { self.wifi_popover_data = Some(data); self.rebuild_wifi_popover(&sender); } + AppInput::BtPopoverData(data) => { + self.bt_popover_data = Some(data); + self.rebuild_bt_popover(); + } AppInput::SetProfile(name) => { self.wifi_profile = Some(name); self.apply_wifi_label(); @@ -628,8 +802,12 @@ impl SimpleComponent for App { format!("{} · {}", state.artist, state.title) }; self.media_track_lbl.set_label(&label); - self.media_play_btn - .set_label(if state.playing { "⏸" } else { "▶" }); + let icon_svg = if state.playing { + asset!("Pause.svg") + } else { + asset!("Play.svg") + }; + self.media_play_icon.set_paintable(Some(&svg_texture(icon_svg))); if state.playing { self.media_paused_at = None; @@ -717,8 +895,8 @@ impl App { } fn rebuild_wifi_popover(&mut self, sender: &ComponentSender) { - while let Some(child) = self.wifi_popover_box.first_child() { - self.wifi_popover_box.remove(&child); + while let Some(child) = self.wifi_pane.first_child() { + self.wifi_pane.remove(&child); } if let Some(st) = &self.crumbs_status { @@ -755,15 +933,15 @@ impl App { status_lbl.set_xalign(0.0); header.append(&status_lbl); - self.wifi_popover_box.append(&header); - self.wifi_popover_box + self.wifi_pane.append(&header); + self.wifi_pane .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); } let Some(data) = &self.wifi_popover_data else { let lbl = gtk4::Label::new(Some("Scanning…")); lbl.add_css_class("wifi-popover-loading"); - self.wifi_popover_box.append(&lbl); + self.wifi_pane.append(&lbl); return; }; @@ -772,7 +950,7 @@ impl App { ph.set_xalign(0.0); ph.set_margin_top(6); ph.set_margin_bottom(2); - self.wifi_popover_box.append(&ph); + self.wifi_pane.append(&ph); for (name, active) in &data.profiles { let row = gtk4::Button::new(); @@ -796,18 +974,18 @@ impl App { bar::wifi::spawn_profile_set(name_clone.clone()); close_parent_popover(btn); }); - self.wifi_popover_box.append(&row); + self.wifi_pane.append(&row); } if !data.scan.is_empty() { - self.wifi_popover_box + self.wifi_pane .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); let nh = gtk4::Label::new(Some("Nearby")); nh.add_css_class("wifi-popover-section"); nh.set_xalign(0.0); nh.set_margin_top(6); nh.set_margin_bottom(2); - self.wifi_popover_box.append(&nh); + self.wifi_pane.append(&nh); for entry in &data.scan { let row = gtk4::Button::new(); @@ -847,25 +1025,120 @@ impl App { } close_parent_popover(btn); }); - self.wifi_popover_box.append(&row); + self.wifi_pane.append(&row); + } + } + } + + fn rebuild_bt_popover(&mut self) { + while let Some(child) = self.bt_pane.first_child() { + self.bt_pane.remove(&child); + } + + let Some(data) = &self.bt_popover_data else { + let lbl = gtk4::Label::new(Some("Loading…")); + lbl.add_css_class("wifi-popover-loading"); + self.bt_pane.append(&lbl); + return; + }; + + // Power toggle row + let toggle_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + toggle_row.set_margin_bottom(4); + let toggle_lbl = gtk4::Label::new(Some("Bluetooth")); + toggle_lbl.add_css_class("wifi-popover-ssid"); + toggle_lbl.set_hexpand(true); + toggle_lbl.set_xalign(0.0); + let toggle_switch = gtk4::Switch::new(); + toggle_switch.set_active(data.powered); + toggle_switch.set_valign(gtk4::Align::Center); + toggle_switch.connect_state_set(|_, on| { + bar::bluetooth::spawn_set_powered(on); + gtk4::glib::Propagation::Proceed + }); + toggle_row.append(&toggle_lbl); + toggle_row.append(&toggle_switch); + self.bt_pane.append(&toggle_row); + self.bt_pane + .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + + if !data.powered { + let lbl = gtk4::Label::new(Some("Bluetooth is off")); + lbl.add_css_class("wifi-popover-loading"); + self.bt_pane.append(&lbl); + } else if data.devices.is_empty() { + let lbl = gtk4::Label::new(Some("No paired devices")); + lbl.add_css_class("wifi-popover-loading"); + self.bt_pane.append(&lbl); + } else { + let dh = gtk4::Label::new(Some("Paired")); + dh.add_css_class("wifi-popover-section"); + dh.set_xalign(0.0); + dh.set_margin_top(2); + dh.set_margin_bottom(2); + self.bt_pane.append(&dh); + + for dev in &data.devices { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + if dev.connected { + row.add_css_class("wifi-popover-row-active"); + } + let lbl = gtk4::Label::new(Some(&format!( + "{}{}", + if dev.connected { "● " } else { " " }, + dev.name, + ))); + lbl.set_xalign(0.0); + row.set_child(Some(&lbl)); + + let address = dev.address.clone(); + let connected = dev.connected; + row.connect_clicked(move |_| { + if connected { + bar::bluetooth::spawn_disconnect(address.clone()); + } else { + bar::bluetooth::spawn_connect(address.clone()); + } + }); + self.bt_pane.append(&row); } } - self.wifi_popover_box.set_visible(true); + self.bt_pane + .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + let settings_row = gtk4::Button::new(); + settings_row.add_css_class("flat"); + settings_row.add_css_class("wifi-popover-row"); + let settings_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let settings_icon = gtk4::Image::from_paintable(Some(&svg_texture(bar::stats::ICON_BT_SETTINGS))); + settings_icon.add_css_class("stat-icon"); + settings_box.append(&settings_icon); + settings_box.append(>k4::Label::new(Some("Bluetooth settings"))); + settings_row.set_child(Some(&settings_box)); + settings_row.connect_clicked(|_| { + relm4::spawn(async { + let _ = tokio::process::Command::new("blueman-manager").spawn(); + }); + }); + self.bt_pane.append(&settings_row); } } // ── Helpers ─────────────────────────────────────────────────────────────────── -fn build_slider_row(icon: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { +fn build_slider_row(icon_svg: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); row.add_css_class("control-panel-row"); row.set_margin_top(2); row.set_margin_bottom(2); - let icon_lbl = gtk4::Label::new(Some(icon)); - icon_lbl.add_css_class("control-panel-row-icon"); - icon_lbl.set_width_chars(2); + // Rendered larger than the standard 16px stat/section icons: these are + // the two things in the panel you actually drag, so they should read as + // primary controls rather than blend in with passive readouts. + let icon = gtk4::Image::from_paintable(Some(&svg_texture_sized(icon_svg, 20))); + icon.add_css_class("control-panel-row-icon"); let slider = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); slider.set_draw_value(false); @@ -873,7 +1146,7 @@ fn build_slider_row(icon: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gt slider.set_width_request(180); slider.add_css_class("control-panel-slider"); - row.append(&icon_lbl); + row.append(&icon); row.append(&slider); (row, slider) } @@ -984,12 +1257,21 @@ fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { pair } -fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { +pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { + svg_texture_sized(svg_src, 16) +} + +/// Same as `svg_texture` but rendered at an explicit pixel size — used to give +/// primary/interactive icons (e.g. sliders you actually drag) more visual +/// weight than passive informational ones, which otherwise all read as the +/// same flat 16px stroke glyph once emoji stopped providing accidental variety. +pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { use resvg::{tiny_skia, usvg}; let fg = theme::fg_color(); + let dim = format!(r#"width="{px}" height="{px}""#); let svg = svg_src .replace("currentColor", &fg) - .replace(r#"width="24" height="24""#, r#"width="16" height="16""#); + .replace(r#"width="24" height="24""#, &dim); let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parse svg"); let size = tree.size().to_int_size(); let (w, h) = (size.width(), size.height()); diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index b6c60b2..21561d6 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -11,10 +11,29 @@ pub enum NotifEvent { summary: String, body: String, timeout_ms: u32, + urgency: Urgency, }, Close(u32), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Urgency { + Low, + Normal, + Critical, +} + +impl Urgency { + /// CSS class suffix for `.notification-card.urgency-`. + pub fn css_class(self) -> Option<&'static str> { + match self { + Urgency::Low => None, + Urgency::Normal => Some("urgency-normal"), + Urgency::Critical => Some("urgency-critical"), + } + } +} + struct NotifServer { tx: mpsc::Sender, next_id: AtomicU32, @@ -32,7 +51,7 @@ impl NotifServer { summary: &str, body: &str, _actions: Vec, - _hints: std::collections::HashMap, + hints: std::collections::HashMap, expire_timeout: i32, ) -> u32 { let id = if replaces_id != 0 { @@ -45,6 +64,12 @@ impl NotifServer { } else { expire_timeout as u32 }; + // Spec: hints["urgency"] is a byte, 0=low, 1=normal, 2=critical (default normal). + let urgency = match hints.get("urgency").and_then(|v| u8::try_from(v.clone()).ok()) { + Some(0) => Urgency::Low, + Some(2) => Urgency::Critical, + _ => Urgency::Normal, + }; let _ = self .tx .send(NotifEvent::Show { @@ -53,6 +78,7 @@ impl NotifServer { summary: summary.to_string(), body: body.to_string(), timeout_ms, + urgency, }) .await; id diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index c8a7e50..3e058cd 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -4,7 +4,7 @@ use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::NotifEvent; +use super::{NotifEvent, Urgency}; type Cards = Rc>>; @@ -27,12 +27,13 @@ pub async fn run(mut rx: Receiver) { summary, body, timeout_ms, + urgency, } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { cards_box.remove(&old); } - let card = make_card(&app_name, &summary, &body); + let card = make_card(&app_name, &summary, &body, urgency); cards_box.prepend(&card); cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); @@ -75,11 +76,17 @@ fn create_window() -> gtk4::Window { window } -fn make_card(app_name: &str, summary: &str, body: &str) -> gtk4::Box { +fn make_card(app_name: &str, summary: &str, body: &str, urgency: Urgency) -> gtk4::Box { let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4); card.add_css_class("notification-card"); + if let Some(class) = urgency.css_class() { + card.add_css_class(class); + } - if !app_name.is_empty() { + // Senders often set the title/summary to their own app name (e.g. a bare + // "Spotify" notification) — showing app_name above an identical summary + // is pure repetition, so skip the app label in that case. + if !app_name.is_empty() && !app_name.eq_ignore_ascii_case(summary) { let lbl = gtk4::Label::new(Some(app_name)); lbl.add_css_class("notification-app"); lbl.set_xalign(0.0); diff --git a/src/osd.rs b/src/osd.rs index 55106ec..d7520fc 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -122,40 +122,39 @@ fn brightness_watcher(tx: mpsc::Sender) { async fn run_osd(mut rx: mpsc::Receiver) { let window = create_window(); - let container = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - container.set_margin_top(12); - container.set_margin_bottom(12); - container.set_margin_start(16); - container.set_margin_end(16); + let container = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + container.set_margin_top(10); + container.set_margin_bottom(10); + container.set_margin_start(14); + container.set_margin_end(14); window.set_child(Some(&container)); - let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - let kind_lbl = gtk4::Label::new(Some("Volume")); - kind_lbl.add_css_class("osd-kind"); - kind_lbl.set_hexpand(true); - kind_lbl.set_xalign(0.0); - let pct_lbl = gtk4::Label::new(Some("0%")); - pct_lbl.add_css_class("osd-pct"); - header.append(&kind_lbl); - header.append(&pct_lbl); - container.append(&header); + let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture( + crate::bar::stats::ICON_VOLUME, + ))); + icon.add_css_class("osd-icon"); + container.append(&icon); let pbar = gtk4::ProgressBar::new(); pbar.add_css_class("osd-bar"); + pbar.set_hexpand(true); + pbar.set_valign(gtk4::Align::Center); container.append(&pbar); let dismiss_token = Rc::new(Cell::new(0u32)); while let Some(event) = rx.recv().await { - let (kind, pct) = match event { - OsdEvent::Volume { pct, muted } => { - (if muted { "Volume (Muted)" } else { "Volume" }, pct) - } - OsdEvent::Brightness { pct } => ("Brightness", pct), + let (icon_svg, pct, muted) = match event { + OsdEvent::Volume { pct, muted } => (crate::bar::stats::ICON_VOLUME, pct, muted), + OsdEvent::Brightness { pct } => (crate::bar::stats::ICON_BRIGHTNESS, pct, false), }; - kind_lbl.set_label(kind); - pct_lbl.set_label(&format!("{pct}%")); + icon.set_paintable(Some(&crate::svg_texture(icon_svg))); + if muted { + icon.add_css_class("osd-icon-muted"); + } else { + icon.remove_css_class("osd-icon-muted"); + } pbar.set_fraction(pct as f64 / 100.0); window.set_visible(true); @@ -179,6 +178,6 @@ fn create_window() -> gtk4::Window { window.set_layer(Layer::Overlay); window.set_anchor(Edge::Bottom, true); window.set_margin(Edge::Bottom, 80); - window.set_default_width(280); + window.set_default_width(180); window } diff --git a/src/theme.rs b/src/theme.rs index dc7e149..6cee541 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -14,71 +14,89 @@ fn load_css() -> String { // card) and child labels inherit it, so text stays legible whatever lightness // pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for // that background — the pywal hues themselves are untouched. + // + // Shared tokens: one radius and one padding rhythm reused across every + // popover/card/OSD surface so they read as one design system rather than + // four different ones. `radius_pill` is only for the tiny transient OSD. + let radius = "10px"; + let radius_sm = "6px"; + let radius_pill = "20px"; + let pad = "10px"; + format!( "window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\ - .workspace-btn {{ background: transparent; opacity: 0.45;\ - border-radius: 0; border: none; outline: none; box-shadow: none;\ - min-width: 24px; padding: 4px 8px; }}\ + .workspace-btn {{ background: transparent; opacity: 0.45; color: {on_bg};\ + border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ + min-width: 20px; margin: 5px 2px; padding: 2px 9px; }}\ .workspace-btn:hover {{ opacity: 0.8; }}\ - .workspace-btn.active {{ background: {accent}; color: {on_accent}; opacity: 1; }}\ + .workspace-btn.active {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ .stats-box {{ margin-right: 8px; }}\ .stat-pair {{ margin-right: 14px; }}\ .stat-icon {{ margin-right: 2px; }}\ .bt-icon {{ margin-right: 14px; }}\ + separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\ window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ - .notification-card {{ background: {surface}; color: {on_surface}; border-radius: 8px;\ - padding: 12px; margin-bottom: 8px; }}\ + .notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ + padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ + .notification-card.urgency-critical {{ border-left-color: {critical}; }}\ + .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ .notification-summary {{ font-weight: bold; }}\ .notification-app {{ opacity: 0.6; }}\ - window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: 8px; }}\ - .osd-kind {{ opacity: 0.75; font-size: 12px; }}\ - .osd-pct {{ font-weight: bold; font-size: 12px; }}\ - progressbar.osd-bar {{ min-height: 8px; }}\ - progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 4px; min-height: 8px; }}\ - progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 4px; min-height: 8px; }}\ + window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ + .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ + .osd-icon-muted {{ opacity: 0.35; }}\ + progressbar.osd-bar {{ min-height: 6px; }}\ + progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 3px; min-height: 6px; }}\ + progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\ .clickable {{ cursor: pointer; }}\ - .wifi-pair {{ border-radius: 4px; padding: 0 2px; }}\ + .wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\ .wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ - .wifi-popover-inner {{ min-width: 180px; padding: 2px; }}\ + .wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\ + .popover-tab-row {{ margin-bottom: {pad}; }}\ + .popover-tab {{ background: transparent; color: {on_bg}; border: none; box-shadow: none;\ + outline: none; border-radius: {radius_sm}; padding: 4px 10px; font-size: 11px;\ + font-weight: bold; opacity: 0.55; }}\ + .popover-tab:hover {{ opacity: 0.8; }}\ + .popover-tab:checked {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ .wifi-popover-ssid {{ font-weight: bold; font-size: 13px; }}\ .wifi-popover-ip {{ opacity: 0.6; font-size: 11px; }}\ .wifi-popover-status {{ font-size: 11px; margin-top: 2px; }}\ .wifi-popover-section {{ font-size: 10px; font-weight: bold; opacity: 0.5; letter-spacing: 0.08em; }}\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ - border-radius: 4px; padding: 2px 6px; }}\ + border-radius: {radius_sm}; padding: 4px 6px; }}\ .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ .wifi-popover-row-active {{ color: {accent}; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px; }}\ - .media-widget {{ border-radius: 4px; padding: 0 6px; cursor: pointer; }}\ + .media-widget {{ border-radius: {radius_sm}; padding: 0 6px; cursor: pointer; }}\ .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ .media-track-lbl {{ font-size: 12px; }}\ .media-controls {{ padding: 2px; }}\ - .media-btn {{ font-size: 16px; min-width: 36px; padding: 2px 8px; }}\ - .control-panel-btn {{ font-size: 14px; padding: 0 6px; margin-left: 6px; border-radius: 4px; }}\ + .media-btn {{ min-width: 32px; padding: 4px 8px; }}\ + .control-panel-btn {{ padding: 0 6px; margin-left: 6px; border-radius: {radius_sm}; }}\ .control-panel {{ }}\ - .control-panel-inner {{ min-width: 240px; padding: 8px; }}\ + .control-panel-inner {{ min-width: 220px; padding: {pad}; }}\ .control-panel-row {{ margin: 4px 0; }}\ - .control-panel-row-icon {{ opacity: 0.75; }}\ + .control-panel-row-icon {{ opacity: 1; margin-right: 4px; }}\ .control-panel-slider {{ margin: 0; }}\ - .control-panel-stats {{ margin: 8px 0; }}\ + .control-panel-stats {{ margin: {pad} 0; }}\ .control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\ - .control-panel-section {{ margin: 6px 0; }}\ + .control-panel-section {{ margin: {pad} 0; }}\ .control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\ letter-spacing: 0.08em; margin-bottom: 4px; }}\ .control-panel-sink-dropdown {{ }}\ .power-row {{ margin-top: 2px; }}\ - .power-btn {{ font-size: 16px; min-width: 44px; padding: 4px; border-radius: 6px; }}\ + .power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\ separator {{ margin: 4px 0; }}", bg_plain = p.background, bg_rgba = hex_to_rgba(&p.background, 0.92), surface = p.color0, accent = p.color4, + critical = p.color1, on_bg = ink_on(&p.background), on_surface = ink_on(&p.color0), - on_accent = ink_on(&p.color4), trough = hex_to_rgba(&p.color4, 0.25), ) } From 5ec8b69d375a99e29a6f72d8db735a0ed0f7c0d1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 19 Jul 2026 03:53:32 +0800 Subject: [PATCH 05/85] Switch to tag-pinned bread-ecosystem deps; bump version to v0.3.0 --- Cargo.lock | 438 ++++++++++++++++------------------------------------- Cargo.toml | 2 +- 2 files changed, 129 insertions(+), 311 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a277271..4d73507 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,12 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - [[package]] name = "arrayref" version = "0.3.9" @@ -22,9 +16,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-broadcast" @@ -102,9 +96,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-theme" @@ -119,7 +113,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.2.4" +version = "0.3.0" dependencies = [ "bread-theme", "futures-lite", @@ -142,15 +136,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cairo-rs" @@ -158,7 +152,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -220,9 +214,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "data-url" @@ -409,12 +403,6 @@ dependencies = [ "spin", ] -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "fragile" version = "2.1.0" @@ -426,9 +414,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -441,9 +429,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -451,15 +439,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -468,9 +456,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -487,9 +475,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -498,21 +486,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -552,9 +540,9 @@ dependencies = [ [[package]] name = "gdk4" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" dependencies = [ "cairo-rs", "gdk-pixbuf", @@ -568,9 +556,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -610,22 +598,20 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] name = "gio" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" dependencies = [ "futures-channel", "futures-core", @@ -640,9 +626,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" dependencies = [ "glib-sys", "gobject-sys", @@ -673,11 +659,11 @@ dependencies = [ [[package]] name = "glib" -version = "0.22.7" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -706,9 +692,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", "system-deps", @@ -727,32 +713,30 @@ dependencies = [ [[package]] name = "graphene-rs" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" dependencies = [ "glib", "graphene-sys", - "libc", ] [[package]] name = "graphene-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "pkg-config", "system-deps", ] [[package]] name = "gsk4" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" dependencies = [ "cairo-rs", "gdk4", @@ -765,9 +749,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -781,9 +765,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" dependencies = [ "cairo-rs", "field-offset", @@ -806,7 +790,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "gdk4", "glib", "glib-sys", @@ -830,9 +814,9 @@ dependencies = [ [[package]] name = "gtk4-macros" -version = "0.11.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -842,9 +826,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -859,15 +843,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -915,12 +890,6 @@ dependencies = [ "syn", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "imagesize" version = "0.13.0" @@ -934,9 +903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] @@ -947,13 +914,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -974,12 +940,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -988,9 +948,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -1012,15 +972,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -1043,9 +1003,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -1085,13 +1045,12 @@ dependencies = [ [[package]] name = "pango" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" dependencies = [ "gio", "glib", - "libc", "pango-sys", ] @@ -1150,16 +1109,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1180,9 +1129,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1288,7 +1237,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1297,9 +1246,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "scopeguard" @@ -1388,9 +1337,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simplecss" @@ -1415,15 +1364,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1431,9 +1380,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -1459,9 +1408,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -1494,7 +1443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1548,9 +1497,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -1565,9 +1514,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", @@ -1576,9 +1525,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -1600,9 +1549,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime", @@ -1621,9 +1570,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tracing" @@ -1709,9 +1658,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "js-sys", "serde_core", @@ -1732,27 +1681,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -1763,9 +1703,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1773,9 +1713,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -1786,47 +1726,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -1910,107 +1816,19 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "xml-rs" version = "0.8.28" @@ -2025,9 +1843,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "zbus" -version = "5.16.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" dependencies = [ "async-broadcast", "async-recursion", @@ -2055,9 +1873,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.16.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2070,9 +1888,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow", @@ -2081,15 +1899,15 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.12.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" dependencies = [ "endi", "enumflags2", @@ -2101,9 +1919,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2114,9 +1932,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index a17c1bb..49ddb62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.2.4" +version = "0.3.0" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" From d6baab24fc4a4fdb6855f36f27ff3895eaedfc4f Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:17:42 +0800 Subject: [PATCH 06/85] ci: remove GitHub push-mirror workflow --- .forgejo/workflows/mirror.yml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .forgejo/workflows/mirror.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 26a9b37..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 174440bcbf9dc555baf86b578580a1a4f5dc7387 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 09:58:03 +0800 Subject: [PATCH 07/85] ci: add dev/beta build track workflows Adds dev-release.yml (publishes on every push to dev) and beta-release.yml (publishes on a beta-v* tag), mirroring the pattern landing in bread-ecosystem/bread. See bread-ecosystem/docs/release-channels.md for the three-track policy. --- .forgejo/workflows/beta-release.yml | 53 ++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 62 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 .forgejo/workflows/beta-release.yml create mode 100644 .forgejo/workflows/dev-release.yml diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml new file mode 100644 index 0000000..284d340 --- /dev/null +++ b/.forgejo/workflows/beta-release.yml @@ -0,0 +1,53 @@ +name: beta release + +# Publishes a beta-track build when a `beta-v*` tag is pushed — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + tags: ['beta-v*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/breadbar/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64" + strip "${PKG_DIR}/breadbar-x86_64" + sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbar/latest" + + # No GitHub Release upload — beta, like the other non-stable track, + # is only distributed via dl.breadway.dev/beta/. + - name: regenerate beta index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..07c9cfd --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,62 @@ +name: dev release + +# Publishes a dev-track build on every push to `dev` — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + branches: ['dev'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch dev --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: compute dev version + run: | + set -euo pipefail + cd src + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/breadbar/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64" + strip "${PKG_DIR}/breadbar-x86_64" + sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadbar/latest" + + # No GitHub Release upload — dev, like the other non-stable track, + # is only distributed via dl.breadway.dev/dev/. + - name: regenerate dev index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From 4d33c0e9ae7b21faa894498d6cb3daf19fb1cef6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:10:00 +0800 Subject: [PATCH 08/85] ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set From 3996bce3a9cf07353335bf97d89332672e1690eb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:24:13 +0800 Subject: [PATCH 09/85] ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta workflows run close together on the same self-hosted runner — one job's rm -rf/clone can stomp another's in-progress checkout, causing the regenerate-index step to fail intermittently. Switch to mktemp -d. --- .forgejo/workflows/beta-release.yml | 12 +++++++----- .forgejo/workflows/dev-release.yml | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 284d340..fba7b2a 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -46,8 +46,10 @@ jobs: echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" exit 1 fi - rm -rf /tmp/bread-ecosystem-ci - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 07c9cfd..baffa6b 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -55,8 +55,10 @@ jobs: echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" exit 1 fi - rm -rf /tmp/bread-ecosystem-ci - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" From 15f2b111f192be02e8d4ff545b24c3b3cda2ba89 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 11:44:24 +0800 Subject: [PATCH 10/85] random commit message, read it yourself --- .gitignore | 3 +++ src/theme.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 816e2ad..7660082 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ logs/ # Internal design documents (not for distribution) aster-brief.md + +# Local hygiene notes (not for commit) +CLAUDE.md diff --git a/src/theme.rs b/src/theme.rs index 6cee541..ea20c89 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -19,7 +19,7 @@ fn load_css() -> String { // popover/card/OSD surface so they read as one design system rather than // four different ones. `radius_pill` is only for the tiny transient OSD. let radius = "10px"; - let radius_sm = "6px"; + let radius_sm = "0px"; let radius_pill = "20px"; let pad = "10px"; From e8cd2c88bc76e2e213c33ce2b2552b3fe5d31c91 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 13:51:44 +0800 Subject: [PATCH 11/85] ci: base dev version on the latest published tag, not Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml can drift stale relative to the actual last release (observed on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped dev version sort as OLDER than what's already installed — bakery's semver check correctly refused those "updates". Deriving the base version from git ls-remote --tags instead is self-healing regardless of Cargo.toml drift, with a Cargo.toml fallback only for a repo with no tags yet. --- .forgejo/workflows/dev-release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index baffa6b..097228a 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -26,7 +26,19 @@ jobs: run: | set -euo pipefail cd src - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + # Base the dev version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a dev build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi IFS='.' read -r MA MI PA <<< "${CUR}" SHA="$(git rev-parse --short HEAD)" TS="$(date -u +%Y%m%d%H%M%S)" From 5af8b6097d2cc2e9e4cae5ccb2e4bc8095c95ebb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 18:37:15 +0800 Subject: [PATCH 12/85] ci: make beta a branch-triggered freeze track, not a one-off tag Beta is now a real stabilization branch: publishes on every push to `beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.+, base version from the latest published tag) instead of a manual beta-v* tag. Fixes made during the freeze land via fix/ branches merged into `beta` directly. The gen-index.sh clone for beta pulls bread-ecosystem's default branch (main) rather than pinning to dev, since beta is the more stable track and main now carries the TRACK-aware script. --- .forgejo/workflows/beta-release.yml | 41 ++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index fba7b2a..16eff05 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -1,12 +1,12 @@ name: beta release -# Publishes a beta-track build when a `beta-v*` tag is pushed — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See bread-ecosystem's docs/release-channels.md for the three-track policy. on: push: - tags: ['beta-v*'] + branches: ['beta'] jobs: build: @@ -16,16 +16,37 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked + - name: compute beta version + run: | + set -euo pipefail + cd src + # Base the beta version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a beta build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/breadbar/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64" @@ -35,8 +56,8 @@ jobs: cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbar/latest" - # No GitHub Release upload — beta, like the other non-stable track, - # is only distributed via dl.breadway.dev/beta/. + # No GitHub Release upload — beta, like dev, is only distributed via + # dl.breadway.dev/beta/. - name: regenerate beta index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} @@ -50,6 +71,6 @@ jobs: # mktemp: a fixed clone path races when multiple repos' dev/beta # workflows run close together on the same self-hosted runner. ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" rm -rf "${ECOSYSTEM_CI_DIR}" From 905d91580d04c15421c526571cb088cdfa890c87 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:40:34 +0800 Subject: [PATCH 13/85] docs: add CONTRIBUTING.md Documents the dev/beta/main branch and release-track workflow shared across the bread ecosystem. See bread-ecosystem's docs/release-channels.md for the full policy this implements. --- CONTRIBUTING.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1dac2b5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing + +`breadbar` — Minimal status bar and notification daemon for Hyprland. + +Part of the bread ecosystem; this repo follows the same branch/release +workflow as every other ecosystem product. + +## Branches + +- **`main`** — release branch, always tag-ready. Nothing is committed to it + directly; it only moves forward via a `beta` merge (see below). +- **`dev`** — integration branch. All day-to-day work lands here first. + Every push to `dev` automatically builds and publishes a **dev-track** + build (see Tracks below) — use this to test your change in a real install + before it goes any further. +- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. + Every push to `beta` automatically builds and publishes a **beta-track** + build. While a freeze is active, only fixes for issues found *in that + freeze* should land on `beta`. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing +something reported against an active `beta` freeze, branch off `beta` +instead, merge the fix there to unblock testers, and also forward the same +fix into `dev` so it doesn't quietly reappear next cycle. + +## The release cycle + +1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build — install it with `bakery track set dev` and + `bakery update --all`, then report or fix anything broken with another + push to `dev`. +2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut + fresh from `dev`'s current tip. This freezes it as the stabilization + target — `dev` keeps moving independently starting the next cycle. +3. `beta` is open for anyone to test: `bakery track set beta` and + `bakery update --all`. **File issues against anything you find on this + repo's Forgejo issue tracker.** Fixes land via `fix/` branches + merged into `beta`. +4. Once `beta` has gone roughly **a month** without new issues, it's merged + into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the + stable release build. `beta` is then reset from `dev` to start the next + cycle. + +## Tracks, from a user's perspective + +``` +bakery track show # what you're currently on (defaults to stable) +bakery track set dev # or beta, or stable +bakery update --all # pull the latest build on your current track +``` + +| Track | What it is | Published from | +|--------|-----------|-----------------| +| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | +| `beta` | Current stabilization freeze | `beta`, on every push | +| `dev` | Bleeding edge | `dev`, on every push | + +Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / +`-beta.…`) from the latest published stable tag, so they always sort as +newer than what you have installed — no manual version bumping needed when +pushing to `dev` or `beta`. + +## Local development + +```sh +cargo build --release +cargo test --release +``` + +## CI + +- `dev-release.yml` — triggered on push to `dev`. +- `beta-release.yml` — triggered on push to `beta`. +- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. +- `package.yml` — publishes to the `[breadway]` pacman repo, also tag-triggered. + +All CI runs on a self-hosted runner; nothing runs automatically on plain +commits or PRs beyond the track builds above. See +[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md) +for the full policy, including how a new product gets wired onto these tracks. + +## Questions + +Open an issue on this repo's Forgejo tracker. From 175af5d483320e128a7562101ab6c91a9837a9bb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:53:51 +0800 Subject: [PATCH 14/85] Will change this commit message to mean something later --- Cargo.lock | 105 ++++++++++++++++++--- Cargo.toml | 15 +++ src/main.rs | 142 +++++++++++++++++++++++++--- src/theme.rs | 52 ++++++++++- src/widgets/client.rs | 106 +++++++++++++++++++++ src/widgets/mod.rs | 7 ++ src/widgets/render.rs | 212 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 611 insertions(+), 28 deletions(-) create mode 100644 src/widgets/client.rs create mode 100644 src/widgets/mod.rs create mode 100644 src/widgets/render.rs diff --git a/Cargo.lock b/Cargo.lock index 4d73507..2ecdfeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,16 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-shared" +version = "0.7.0" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "bread-theme" version = "0.2.3" @@ -111,11 +121,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.3.1" +dependencies = [ + "bread-shared", + "dirs", + "serde", + "serde_json", +] + [[package]] name = "breadbar" version = "0.3.0" dependencies = [ + "bread-shared", "bread-theme", + "bread-utils", "futures-lite", "gtk4", "gtk4-layer-shell", @@ -1115,7 +1137,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -1316,6 +1338,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1426,7 +1457,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 1.1.3+spec-1.1.0", "version-compare", ] @@ -1523,6 +1554,18 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -1531,11 +1574,20 @@ checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -1547,6 +1599,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -1554,9 +1620,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1565,9 +1631,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.2+spec-1.1.0" @@ -1814,6 +1886,15 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -1865,7 +1946,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -1893,7 +1974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow", + "winnow 1.0.4", "zvariant", ] @@ -1912,7 +1993,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.4", "zvariant_derive", "zvariant_utils", ] @@ -1940,5 +2021,5 @@ dependencies = [ "quote", "serde", "syn", - "winnow", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 49ddb62..673a8c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,12 @@ categories = ["gui"] [dependencies] bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } +# Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) +# for talking to breadd's IPC socket, and bread-shared purely for the +# WidgetSpec/WidgetNode wire types so we deserialize into real structs +# instead of hand-parsing serde_json::Value. See src/widgets/. +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.1", features = ["bread-client"] } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -24,6 +30,15 @@ serde_json = "1" # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. resvg = { version = "0.44", default-features = false } +# TEMPORARY local-dev override: the widget feature's bread-shared/bread-utils +# changes aren't tagged/released yet, so point the git deps above at the +# local checkouts instead. Remove this section (and bump the tags above) +# once bread-ecosystem and bread have real release tags for this work. +[patch."https://git.breadway.dev/Breadway/bread-ecosystem"] +bread-utils = { path = "../bread-ecosystem/bread-utils" } +[patch."https://git.breadway.dev/Breadway/bread"] +bread-shared = { path = "../bread/bread-shared" } + [profile.release] lto = "thin" codegen-units = 1 diff --git a/src/main.rs b/src/main.rs index 7e2c756..4bdb45e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod bar; mod notifications; mod osd; mod theme; +mod widgets; /// Thresholds above which the bar's CPU/RAM/power-draw readouts appear at /// all — see `AppInput::StatsUpdate`. Below these, the bar stays quiet. @@ -94,6 +95,15 @@ pub struct App { tray_sep: gtk4::Separator, tray_box: gtk4::Box, tray_items: std::collections::HashMap, + + // ── Lua-declared widgets ───────────────────────────────────────────── + // One container per WidgetPlacement (see bread_shared::widget), fully + // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's + // module doc for why that's simpler than incremental patching here. + widget_containers: + std::collections::HashMap, + widget_tray_section: gtk4::Box, + widget_tray_sep: gtk4::Separator, } #[derive(Debug)] @@ -109,6 +119,7 @@ pub enum AppInput { BtPopoverData(bar::bluetooth::BtPopoverData), MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), + WidgetsUpdate(Vec), } #[relm4::component(pub)] @@ -125,17 +136,6 @@ impl SimpleComponent for App { #[name = "center_box"] gtk::CenterBox { - #[wrap(Some)] - set_start_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 0, - - #[name = "workspace_box"] - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 4, - } - }, } } } @@ -153,6 +153,32 @@ impl SimpleComponent for App { root.set_anchor(Edge::Right, true); root.set_exclusive_zone(32); + // ── Workspace row (left) ──────────────────────────────────────── + // Built imperatively (not via the view! macro) so a widget + // container can sit as a plain sibling of workspace_box — see + // WidgetPlacement::RightOfWorkspaces below. + let workspace_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + workspace_row.append(&workspace_box); + + // ── Lua-declared widget containers ────────────────────────────── + // One per WidgetPlacement; positioned into the layout below as each + // surrounding section (workspace row / center area / stats box / + // control popover) is built. Populated by widgets::client's + // events.subscribe-driven refresh loop, started at the end of init. + use bread_shared::widget::WidgetPlacement; + let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_right_of_workspaces.add_css_class("bread-widget-slot"); + workspace_row.append(&widget_right_of_workspaces); + + let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_left_of_clock.add_css_class("bread-widget-slot"); + let widget_right_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_right_of_clock.add_css_class("bread-widget-slot"); + + let widget_left_of_stats = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_left_of_stats.add_css_class("bread-widget-slot"); + // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, @@ -278,15 +304,18 @@ impl SimpleComponent for App { let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); clock_lbl.add_css_class("clock-label"); - // Center area: [media_widget · clock] + // Center area: [media_widget · widgets · clock · widgets] let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); center_area.add_css_class("center-area"); center_area.append(&media_widget); + center_area.append(&widget_left_of_clock); center_area.append(&clock_lbl); + center_area.append(&widget_right_of_clock); // ── Stats box (right side) ─────────────────────────────────────── let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); stats_box.add_css_class("stats-box"); + stats_box.append(&widget_left_of_stats); // CPU/RAM/power draw: hidden by default (see StatsUpdate), so this // whole sub-group — plus its separator — collapses away when quiet. @@ -509,6 +538,24 @@ impl SimpleComponent for App { tray_sep.set_visible(false); panel_inner.append(&tray_sep); + // Widgets section — Lua-declared widgets with placement = "tray". + // Same collapse-when-empty idiom as the Apps section above. + let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + widget_tray_section.add_css_class("control-panel-section"); + let widget_tray_header = gtk4::Label::new(Some("Widgets")); + widget_tray_header.add_css_class("control-panel-section-header"); + widget_tray_header.set_xalign(0.0); + let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_tray_box.add_css_class("tray-box"); + widget_tray_section.append(&widget_tray_header); + widget_tray_section.append(&widget_tray_box); + widget_tray_section.set_visible(false); + panel_inner.append(&widget_tray_section); + + let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + widget_tray_sep.set_visible(false); + panel_inner.append(&widget_tray_sep); + // Power section let power_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); power_section.add_css_class("control-panel-section"); @@ -583,15 +630,24 @@ impl SimpleComponent for App { stats_box.append(&hamburger_btn); + let widget_containers = std::collections::HashMap::from([ + (WidgetPlacement::RightOfWorkspaces, widget_right_of_workspaces), + (WidgetPlacement::LeftOfClock, widget_left_of_clock), + (WidgetPlacement::RightOfClock, widget_right_of_clock), + (WidgetPlacement::LeftOfStats, widget_left_of_stats), + (WidgetPlacement::Tray, widget_tray_box), + ]); + // ── Assemble ───────────────────────────────────────────────────── let widgets = view_output!(); + widgets.center_box.set_start_widget(Some(&workspace_row)); widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); - let mut model = App { + let model = App { workspaces: vec![], active_ws: 1, - workspace_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), + workspace_box, button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), clock_lbl, @@ -641,8 +697,10 @@ impl SimpleComponent for App { tray_sep, tray_box, tray_items: std::collections::HashMap::new(), + widget_containers, + widget_tray_section, + widget_tray_sep, }; - model.workspace_box = widgets.workspace_box.clone(); theme::apply(); bar::workspaces::spawn_watcher(sender.clone()); @@ -651,6 +709,7 @@ impl SimpleComponent for App { bar::tray::spawn_watcher(sender.clone()); bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); + widgets::client::spawn(sender.clone()); notifications::spawn(); osd::spawn(); @@ -872,11 +931,64 @@ impl SimpleComponent for App { }); self.panel_sink_signal = Some(id); } + AppInput::WidgetsUpdate(specs) => { + self.reconcile_widgets(specs); + } } } } impl App { + fn reconcile_widgets(&mut self, specs: Vec) { + for container in self.widget_containers.values() { + while let Some(child) = container.first_child() { + container.remove(&child); + } + } + + let mut by_placement: std::collections::HashMap< + bread_shared::widget::WidgetPlacement, + Vec<&bread_shared::widget::WidgetSpec>, + > = std::collections::HashMap::new(); + for spec in &specs { + by_placement.entry(spec.placement).or_default().push(spec); + } + + for (placement, mut group) in by_placement { + let Some(container) = self.widget_containers.get(&placement) else { + continue; + }; + group.sort_by_key(|s| s.order); + for spec in group { + if !spec.visible { + continue; + } + let node = widgets::build_node(&spec.root, &spec.id); + if let Some(tooltip) = &spec.tooltip { + node.set_tooltip_text(Some(tooltip)); + } + container.append(&node); + } + } + + // The Tray placement has its own section/separator (handled below, + // same as the existing SNI tray items) — an empty inline slot has no + // such wrapper, so it must hide itself to stop contributing to + // center_area's `spacing` gap. + for (placement, container) in &self.widget_containers { + if *placement == bread_shared::widget::WidgetPlacement::Tray { + continue; + } + container.set_visible(container.first_child().is_some()); + } + + let has_tray_widgets = specs.iter().any(|s| { + s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray + }); + self.widget_tray_section.set_visible(has_tray_widgets); + self.widget_tray_sep.set_visible(has_tray_widgets); + } + fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), diff --git a/src/theme.rs b/src/theme.rs index ea20c89..5bb20b1 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -89,7 +89,57 @@ fn load_css() -> String { .control-panel-sink-dropdown {{ }}\ .power-row {{ margin-top: 2px; }}\ .power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\ - separator {{ margin: 4px 0; }}", + separator {{ margin: 4px 0; }}\ + /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ + slot rule below is what the four inline `.bread-widget-slot`\ + containers in main.rs rely on for the same 12px stat-pair rhythm\ + everything else in the bar uses (they carried the class with no\ + rule defining it until now). Everything after that is the fixed,\ + closed `style` vocabulary a `WidgetNode` can opt into — one class\ + per enum variant, so a module can only ever pick from this set,\ + never inject arbitrary CSS. The progress-bar rules give an\ + unstyled Progress node an intentional accent-colored fill instead\ + of Adwaita's default blue-on-gray, and let `style.color` retint\ + that fill the same way it retints label/icon text. */\ + .bread-widget-slot {{ margin-right: 12px; }}\ + progressbar.bread-widget-node trough {{ background-image: none; background-color: alpha(@accent, 0.25); border-radius: 3px; min-height: 6px; }}\ + progressbar.bread-widget-node trough progress {{ background-image: none; background-color: @accent; border-radius: 3px; min-height: 6px; }}\ + progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\ + progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\ + progressbar.bread-widget-node.bread-color-accent trough progress {{ background-color: @accent; }}\ + progressbar.bread-widget-node.bread-color-red trough progress {{ background-color: @red; }}\ + progressbar.bread-widget-node.bread-color-green trough progress {{ background-color: @green; }}\ + progressbar.bread-widget-node.bread-color-yellow trough progress {{ background-color: @yellow; }}\ + progressbar.bread-widget-node.bread-color-blue trough progress {{ background-color: @blue; }}\ + progressbar.bread-widget-node.bread-color-pink trough progress {{ background-color: @pink; }}\ + progressbar.bread-widget-node.bread-color-teal trough progress {{ background-color: @teal; }}\ + .bread-color-fg {{ color: @fg; }}\ + .bread-color-dim {{ color: @fg; opacity: 0.6; }}\ + .bread-color-accent {{ color: @accent; }}\ + .bread-color-red {{ color: @red; }}\ + .bread-color-green {{ color: @green; }}\ + .bread-color-yellow {{ color: @yellow; }}\ + .bread-color-blue {{ color: @blue; }}\ + .bread-color-pink {{ color: @pink; }}\ + .bread-color-teal {{ color: @teal; }}\ + .bread-weight-normal {{ font-weight: normal; }}\ + .bread-weight-bold {{ font-weight: bold; }}\ + .bread-size-xs {{ font-size: 10px; }}\ + .bread-size-sm {{ font-size: 12px; }}\ + .bread-size-md {{ font-size: 14px; }}\ + .bread-size-lg {{ font-size: 16px; }}\ + .bread-size-xl {{ font-size: 20px; }}\ + .bread-bg-none {{ background-color: transparent; }}\ + .bread-bg-surface {{ background-color: @surface; color: @on-surface; }}\ + .bread-bg-card {{ background-color: @surface; color: @on-surface; border-radius: 8px; padding: 12px; }}\ + .bread-radius-none {{ border-radius: 0; }}\ + .bread-radius-sm {{ border-radius: 4px; }}\ + .bread-radius-md {{ border-radius: 8px; }}\ + .bread-radius-full {{ border-radius: 999px; }}\ + .bread-padding-none {{ padding: 0; }}\ + .bread-padding-xs {{ padding: 4px; }}\ + .bread-padding-sm {{ padding: 8px; }}\ + .bread-padding-md {{ padding: 12px; }}", bg_plain = p.background, bg_rgba = hex_to_rgba(&p.background, 0.92), surface = p.color0, diff --git a/src/widgets/client.rs b/src/widgets/client.rs new file mode 100644 index 0000000..84d043b --- /dev/null +++ b/src/widgets/client.rs @@ -0,0 +1,106 @@ +//! Connects to breadd's IPC socket and keeps the bar's widget set in sync. +//! +//! breadbar is level-triggered here, not edge-triggered: `bread.widget.*` +//! events are used purely as a "something changed, go re-fetch" signal, not +//! applied as incremental patches. Every dirty signal (and the initial +//! connect) re-requests the complete widget list and hands it to `update()` +//! as one `AppInput::WidgetsUpdate`, which reconciles the bar's containers +//! from scratch. This sidesteps event-ordering/drop concerns entirely, and +//! widget registries are small enough that re-fetching the full list on +//! every change is not a real cost. + +use crate::{App, AppInput}; +use bread_shared::widget::WidgetSpec; +use bread_utils::bread_client::BreadClient; +use relm4::ComponentSender; +use std::time::Duration; + +/// breadbar's own registered app id — already reserved in +/// `bread_shared::apps::KNOWN_APPS` (see `Documentation.md`'s Namespaces +/// section). Used both to fetch widgets and to publish click events. +pub const APP_ID: &str = "bar"; + +/// Safety-net poll interval. `bread.widget.cleared` (emitted once per +/// daemon reload, including a full restart — see breadd's `reload_internal`) +/// is meant to catch the case where a module stops registering widgets +/// without anything else re-triggering a fetch, but a *restart* (as opposed +/// to a live `bread reload`) drops the subscription entirely; if that one +/// event fires before `BreadClient::subscribe`'s reconnect-with-backoff +/// finishes re-establishing the stream, it's missed and there's no second +/// chance from the event side. This poll is the backstop for that race — +/// infrequent enough that it's not a real cost, frequent enough that a missed +/// event self-heals well within a session rather than needing a manual +/// breadbar restart to clear stale widgets. +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +/// Start the widget subsystem: an initial fetch, a live subscription that +/// re-fetches on every `bread.widget.*` change, and a low-frequency poll as +/// a backstop against the reconnect race described above. Call once from +/// `init`. +pub fn spawn(sender: ComponentSender) { + // BreadClient::request is blocking std I/O; run it off the tokio + // runtime breadbar's other pollers rely on, same as the reasoning in + // `BreadClient::subscribe`'s own background-thread design. + let initial = sender.clone(); + std::thread::spawn(move || fetch_and_send(&initial)); + + // `subscribe` already reconnects with backoff on its own background + // thread for the lifetime of the process — there is no natural point to + // stop it before the app exits, so the handle is intentionally leaked + // rather than threaded through App just to be dropped at shutdown. + let live = sender.clone(); + let client = BreadClient::connect(APP_ID); + let subscription = client.subscribe("bread.widget.**", move |_event| { + fetch_and_send(&live); + }); + std::mem::forget(subscription); + + let polled = sender.clone(); + relm4::spawn(async move { + loop { + tokio::time::sleep(POLL_INTERVAL).await; + let polled = polled.clone(); + std::thread::spawn(move || fetch_and_send(&polled)); + } + }); +} + +fn fetch_and_send(sender: &ComponentSender) { + let client = BreadClient::connect(APP_ID); + let Some(result) = client.request("widgets.list", serde_json::Value::Null) else { + return; + }; + // Decode element-wise rather than `Vec` in one shot — one + // malformed entry from any module (a bad `class`, an unknown enum value, + // ...) must not blank out every other module's widgets. + let raw: Vec = serde_json::from_value(result).unwrap_or_default(); + let specs: Vec = raw + .into_iter() + .filter_map(|v| { + // `id`/`module` are read before the value is consumed by the + // failed parse below, so a malformed spec still names itself in + // the warning instead of just printing a bare serde error. + let id = v.get("id").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + let module = v.get("module").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + match serde_json::from_value::(v) { + Ok(spec) => Some(spec), + Err(e) => { + eprintln!( + "breadbar: dropping malformed widget spec (id={id}, module={module}): {e}" + ); + None + } + } + }) + .collect(); + sender.input(AppInput::WidgetsUpdate(specs)); +} + +/// Publish a widget click back to breadd. `action` is whatever opaque value +/// the Lua module put in the clicked node's `on_click`. +pub fn emit_click(widget_id: &str, action: &serde_json::Value) { + BreadClient::connect(APP_ID).emit( + "bread.bar.widget_clicked", + serde_json::json!({ "widget_id": widget_id, "action": action }), + ); +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..019f434 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,7 @@ +//! Lua-declared, live-updating widgets (see `Documentation.md`'s "Widgets" +//! section in the `bread` repo) rendered into breadbar's fixed layout slots. + +pub mod client; +mod render; + +pub use render::build_node; diff --git a/src/widgets/render.rs b/src/widgets/render.rs new file mode 100644 index 0000000..53de47b --- /dev/null +++ b/src/widgets/render.rs @@ -0,0 +1,212 @@ +//! Turns a `WidgetNode` tree into a live GTK4 widget tree. +//! +//! There is no diffing at the node level — see `client.rs`'s module doc for +//! why the whole thing is simply rebuilt whenever a widget's spec changes. +//! This keeps the renderer a pure, stateless `WidgetNode -> gtk4::Widget` +//! function. + +use super::client; +use bread_shared::widget::{ + Align as StyleAlign, Background, FontWeight, Orientation as NodeOrientation, Padding, Radius, + SemanticColor, TextSize, WidgetNode, WidgetStyle, +}; +use gtk4::prelude::*; + +/// Default max width for a Label node, in characters, absent an explicit +/// `size`/other override — the `style` vocabulary (see Documentation.md's +/// Widgets §style) has no dedicated width field yet, so this stays fixed for +/// every label rather than becoming a half-exposed knob. +const DEFAULT_LABEL_MAX_WIDTH_CHARS: i32 = 32; + +/// Curated bundled icons a widget can reference by name, so module authors +/// don't need to ship an SVG just to show a battery or bluetooth glyph. +/// Anything else goes through `icon.path` instead (see `bundled_or_path_icon`). +fn bundled_icon(name: &str) -> Option<&'static str> { + use crate::bar::stats::{ + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_OFF, BT_ON, ICON_BRIGHTNESS, ICON_LOCK, + ICON_VOLUME, WIFI_OFF, WIFI_STRONG, + }; + Some(match name { + "ac-power" => AC_POWER, + "battery-high" => BAT_HIGH, + "battery-mid" => BAT_MID, + "battery-low" => BAT_LOW, + "bluetooth-on" => BT_ON, + "bluetooth-off" => BT_OFF, + "wifi-strong" => WIFI_STRONG, + "wifi-off" => WIFI_OFF, + "lock" => ICON_LOCK, + "volume" => ICON_VOLUME, + "brightness" => ICON_BRIGHTNESS, + _ => return None, + }) +} + +fn icon_texture( + widget_id: &str, + name: Option<&str>, + path: Option<&str>, + px: u32, +) -> Option { + if let Some(n) = name { + return match bundled_icon(n) { + Some(svg) => Some(crate::svg_texture_sized(svg, px)), + None => { + eprintln!("breadbar: widget {widget_id}: unknown bundled icon name '{n}'"); + None + } + }; + } + let Some(path) = path else { + eprintln!("breadbar: widget {widget_id}: icon node has neither 'name' nor 'path'"); + return None; + }; + let expanded = bread_shared::expand_path(path); + match std::fs::read_to_string(&expanded) { + Ok(svg) => Some(crate::svg_texture_sized(&svg, px)), + Err(e) => { + eprintln!("breadbar: widget {widget_id}: failed to read icon path '{path}': {e}"); + None + } + } +} + +/// Map a node's typed `style` onto predefined CSS classes (see `theme.rs` for +/// the class definitions) — this is the only path from Lua's `style` field to +/// the widget, kept as narrow `Some(field) -> one class` mappings so there is +/// no way for it to become raw style injection. +fn apply_style(widget: >k4::Widget, style: &WidgetStyle) { + if let Some(color) = style.color { + widget.add_css_class(match color { + SemanticColor::Fg => "bread-color-fg", + SemanticColor::Dim => "bread-color-dim", + SemanticColor::Accent => "bread-color-accent", + SemanticColor::Red => "bread-color-red", + SemanticColor::Green => "bread-color-green", + SemanticColor::Yellow => "bread-color-yellow", + SemanticColor::Blue => "bread-color-blue", + SemanticColor::Pink => "bread-color-pink", + SemanticColor::Teal => "bread-color-teal", + }); + } + if let Some(weight) = style.weight { + widget.add_css_class(match weight { + FontWeight::Normal => "bread-weight-normal", + FontWeight::Bold => "bread-weight-bold", + }); + } + if let Some(size) = style.size { + widget.add_css_class(match size { + TextSize::Xs => "bread-size-xs", + TextSize::Sm => "bread-size-sm", + TextSize::Md => "bread-size-md", + TextSize::Lg => "bread-size-lg", + TextSize::Xl => "bread-size-xl", + }); + } + if let Some(background) = style.background { + widget.add_css_class(match background { + Background::None => "bread-bg-none", + Background::Surface => "bread-bg-surface", + Background::Card => "bread-bg-card", + }); + } + if let Some(radius) = style.radius { + widget.add_css_class(match radius { + Radius::None => "bread-radius-none", + Radius::Sm => "bread-radius-sm", + Radius::Md => "bread-radius-md", + Radius::Full => "bread-radius-full", + }); + } + if let Some(padding) = style.padding { + widget.add_css_class(match padding { + Padding::None => "bread-padding-none", + Padding::Xs => "bread-padding-xs", + Padding::Sm => "bread-padding-sm", + Padding::Md => "bread-padding-md", + }); + } + // GTK CSS has no text-align/justify-content equivalent — alignment is a + // widget property, not a stylesheet rule, so it's set directly instead + // of routing through an inert CSS class like the fields above. + if let Some(align) = style.align { + widget.set_halign(match align { + StyleAlign::Start => gtk4::Align::Start, + StyleAlign::Center => gtk4::Align::Center, + StyleAlign::End => gtk4::Align::End, + }); + } +} + +/// Build (or rebuild) the GTK widget tree for `node`, belonging to widget +/// `widget_id` (fully-qualified `.`, used to tag any click). +pub fn build_node(node: &WidgetNode, widget_id: &str) -> gtk4::Widget { + let widget: gtk4::Widget = match node { + WidgetNode::Box { + orientation, + spacing, + children, + .. + } => { + let gtk_orientation = match orientation { + NodeOrientation::Horizontal => gtk4::Orientation::Horizontal, + NodeOrientation::Vertical => gtk4::Orientation::Vertical, + }; + let container = gtk4::Box::new(gtk_orientation, spacing.unwrap_or(4)); + for child in children { + container.append(&build_node(child, widget_id)); + } + container.upcast() + } + WidgetNode::Label { text, .. } => { + let label = gtk4::Label::new(Some(text)); + // Unbounded, this is a bar-width-blowout waiting to happen from + // any buggy or malicious module — see Documentation.md issue #6. + label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + label.set_max_width_chars(DEFAULT_LABEL_MAX_WIDTH_CHARS); + label.upcast() + } + WidgetNode::Icon { name, path, size, .. } => { + let px = size.unwrap_or(16).max(1) as u32; + let texture = icon_texture(widget_id, name.as_deref(), path.as_deref(), px); + let image = gtk4::Image::from_paintable(texture.as_ref()); + image.upcast() + } + WidgetNode::Progress { value, .. } => { + let bar = gtk4::ProgressBar::new(); + bar.set_fraction(value.clamp(0.0, 1.0)); + // GtkProgressBar's natural expand behavior is to fill all + // available width, which — unlike Label/Box/Image, which hug + // their content by default — propagates up through every + // ancestor Box that doesn't set hexpand explicitly, all the way + // to the bar's end_widget. Pin it to a small fixed footprint so + // it reads as an inline meter instead of swallowing the bar. + bar.set_hexpand(false); + bar.set_valign(gtk4::Align::Center); + bar.set_size_request(40, 6); + bar.upcast() + } + }; + + widget.add_css_class("bread-widget-node"); + if let Some(class) = node.class() { + widget.add_css_class(class); + } + if let Some(style) = node.style() { + apply_style(&widget, style); + } + + if let Some(action) = node.on_click() { + widget.add_css_class("clickable"); + let widget_id = widget_id.to_string(); + let action = action.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + client::emit_click(&widget_id, &action); + }); + widget.add_controller(gesture); + } + + widget +} From 552d771e15bcdbfc81efb07493b10916f5cf93eb Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 10:12:52 +0800 Subject: [PATCH 15/85] fix: honor x-canonical-private-synchronous hint for notification replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notify-send-based senders (e.g. breadcrumbs) fire a new process per notification, so replaces_id is always 0 — they instead tag related notifications with the x-canonical-private-synchronous hint and expect the daemon to replace whatever's currently showing from that app. We weren't honoring it, so a persistent (Expire::Never) critical notification like breadcrumbs' "no Wi-Fi adapter" during a suspend transition could never be superseded by a later notification, leaving it stuck until breadbar was restarted. Fixes #2. --- src/notifications/mod.rs | 130 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index fbb29df..b1f1760 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -1,10 +1,21 @@ pub mod popup; +use std::collections::HashMap; use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; use std::time::Duration; use tokio::sync::mpsc; use zbus::zvariant::OwnedValue; +/// Hint key used by `notify-send` and honored by notify-osd/dunst: senders +/// that fire off a new process per notification (so `replaces_id` is always +/// 0) tag related notifications with the same `(app_name, tag)` pair to mean +/// "replace whatever from this app is already showing." Without honoring +/// this, a fire-and-forget sender can never supersede an earlier +/// `Expire::Never` notification from itself (e.g. a critical hardware +/// warning) — it just piles up a new card next to it forever. +const SYNCHRONOUS_HINT: &str = "x-canonical-private-synchronous"; + /// How long a shown notification should stay up before auto-dismissing. /// Distinct from `Option` mainly for readability at call sites — /// `Never` covers both the spec's `expire_timeout == 0` ("never expire") @@ -79,6 +90,9 @@ fn compute_expire(expire_timeout: i32, urgency_critical: bool) -> Expire { struct NotifServer { tx: mpsc::Sender, next_id: AtomicU32, + /// (app_name, synchronous-hint tag) -> id, for senders relying on + /// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`. + sync_tags: Mutex>, } #[zbus::interface(name = "org.freedesktop.Notifications")] @@ -96,8 +110,24 @@ impl NotifServer { hints: std::collections::HashMap, expire_timeout: i32, ) -> u32 { + let sync_tag = hints + .get(SYNCHRONOUS_HINT) + .and_then(|v| String::try_from(v.clone()).ok()); + let id = if replaces_id != 0 { + if let Some(tag) = &sync_tag { + self.sync_tags + .lock() + .unwrap() + .insert((app_name.to_string(), tag.clone()), replaces_id); + } replaces_id + } else if let Some(tag) = &sync_tag { + let key = (app_name.to_string(), tag.clone()); + let mut sync_tags = self.sync_tags.lock().unwrap(); + *sync_tags + .entry(key) + .or_insert_with(|| self.next_id.fetch_add(1, Ordering::Relaxed)) } else { self.next_id.fetch_add(1, Ordering::Relaxed) }; @@ -150,6 +180,7 @@ pub fn spawn() { let server = NotifServer { tx, next_id: AtomicU32::new(1), + sync_tags: Mutex::new(HashMap::new()), }; // Builder failures here would only occur with invalid static strings — safe to unwrap. let conn = zbus::connection::Builder::session() @@ -212,4 +243,103 @@ mod tests { Expire::Never => panic!("expected 1500ms, got Never"), } } + + fn test_server() -> (NotifServer, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(32); + ( + NotifServer { + tx, + next_id: AtomicU32::new(1), + sync_tags: Mutex::new(HashMap::new()), + }, + rx, + ) + } + + fn sync_hints(tag: &str) -> HashMap { + let mut hints = HashMap::new(); + hints.insert( + SYNCHRONOUS_HINT.to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from(tag)).unwrap(), + ); + hints + } + + #[tokio::test] + async fn synchronous_hint_reuses_id_for_same_app_and_tag() { + let (server, _rx) = test_server(); + let first = server + .notify( + "breadcrumbs", + 0, + "", + "no Wi-Fi adapter", + "", + vec![], + sync_hints("breadcrumbs"), + -1, + ) + .await; + let second = server + .notify( + "breadcrumbs", + 0, + "", + "back online", + "", + vec![], + sync_hints("breadcrumbs"), + -1, + ) + .await; + assert_eq!( + first, second, + "same app+tag should replace, not stack, a prior notification" + ); + } + + #[tokio::test] + async fn synchronous_hint_is_scoped_per_app_name() { + let (server, _rx) = test_server(); + let first = server + .notify( + "breadcrumbs", + 0, + "", + "no Wi-Fi adapter", + "", + vec![], + sync_hints("breadcrumbs"), + -1, + ) + .await; + let second = server + .notify( + "other-app", + 0, + "", + "unrelated", + "", + vec![], + sync_hints("breadcrumbs"), + -1, + ) + .await; + assert_ne!( + first, second, + "same tag from a different app must not collide" + ); + } + + #[tokio::test] + async fn no_synchronous_hint_always_allocates_a_new_id() { + let (server, _rx) = test_server(); + let first = server + .notify("breadcrumbs", 0, "", "one", "", vec![], HashMap::new(), -1) + .await; + let second = server + .notify("breadcrumbs", 0, "", "two", "", vec![], HashMap::new(), -1) + .await; + assert_ne!(first, second); + } } From f5daf902cedbc5bc7f8162c2b63970802d67c13b Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 10:27:52 +0800 Subject: [PATCH 16/85] Drop pacman packaging, bakery-only distribution bakery already fully covers what the PKGBUILD provided (binary, dependency declarations) except a LICENSE copy, which bakery.toml's new license_file field now closes. Removes packaging/arch/ and .forgejo/workflows/package.yml; adds the LICENSE artifact to each release/dev-release/beta-release workflow's prepare step. Not pacman-installed inside BOS today (BOS already consumes these apps exclusively via build-local.sh's skel-staging), so this only removes the option to `pacman -S` outside of BOS/bakery. --- .forgejo/workflows/beta-release.yml | 1 + .forgejo/workflows/dev-release.yml | 1 + .forgejo/workflows/package.yml | 40 ----------------------------- .forgejo/workflows/release.yml | 1 + bakery.toml | 1 + packaging/arch/PKGBUILD | 36 -------------------------- 6 files changed, 4 insertions(+), 76 deletions(-) delete mode 100644 .forgejo/workflows/package.yml delete mode 100644 packaging/arch/PKGBUILD diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 16eff05..ed482ba 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -53,6 +53,7 @@ jobs: strip "${PKG_DIR}/breadbar-x86_64" sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/LICENSE "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbar/latest" diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 097228a..5ffa25a 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -53,6 +53,7 @@ jobs: strip "${PKG_DIR}/breadbar-x86_64" sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/LICENSE "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadbar/latest" diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml deleted file mode 100644 index 2895a76..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build and publish package - -on: - push: - tags: ['v*'] - -jobs: - package: - runs-on: [self-hosted, hestia] - container: - image: archlinux:latest - steps: - # Note: no actions/checkout — the archlinux image has no Node, which JS - # actions require. Everything runs as shell steps and clones manually. - - name: Build and publish - env: - PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell libpulse iw - useradd -m builder - git config --global --add safe.directory '*' - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src - cd /home/builder/src - git archive --format=tar.gz --prefix="breadbar-${VERSION}/" HEAD \ - > packaging/arch/breadbar-${VERSION}.tar.gz - SHA=$(sha256sum packaging/arch/breadbar-${VERSION}.tar.gz | awk '{print $1}') - sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD - sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - chown -R builder:builder /home/builder/src - # --nocheck: packaging builds the artifact; tests belong in a CI job. - su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" - PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) - curl -fsS -X PUT \ - -H "Authorization: token ${PUBLISH_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${PKG}" \ - "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 8124ec6..264d787 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -28,6 +28,7 @@ jobs: strip "${PKG_DIR}/breadbar-x86_64" sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/LICENSE "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/breadbar/latest" diff --git a/bakery.toml b/bakery.toml index bd1153f..1b305d5 100644 --- a/bakery.toml +++ b/bakery.toml @@ -4,6 +4,7 @@ binaries = ["breadbar"] system_deps = ["gtk4", "gtk4-layer-shell", "wireplumber", "pipewire-pulse", "brightnessctl", "iw"] optional_system_deps = ["hyprland"] bread_deps = [] +license_file = "LICENSE" [config] dir = "~/.config/breadbar" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD deleted file mode 100644 index e8570e0..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,36 +0,0 @@ -# Maintainer: Breadway - -pkgname=breadbar -pkgver=0.2.0 -pkgrel=1 -pkgdesc="Minimal status bar and notification daemon for Hyprland" -arch=('x86_64') -url="https://git.breadway.dev/Breadway/breadbar" -license=('MIT') -# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's -# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, -# causing undefined-symbol errors. Disable LTO. -options=(!lto !debug) -depends=('gtk4' 'gtk4-layer-shell' 'wireplumber' 'pipewire-pulse' 'brightnessctl' 'iw') -optdepends=( - 'hyprland: workspace and window data integration' -) -makedepends=('rust' 'cargo') -source=("${pkgname}-${pkgver}.tar.gz") -sha256sums=('SKIP') - -build() { - cd "${srcdir}/${pkgname}-${pkgver}" - cargo build --release --locked -} - -check() { - cd "${srcdir}/${pkgname}-${pkgver}" - cargo test --release --locked -} - -package() { - cd "${srcdir}/${pkgname}-${pkgver}" - install -Dm755 target/release/breadbar "${pkgdir}/usr/bin/breadbar" - install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" -} From 56e8b02599a88438a558bd468f10fcf7ed821fe1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 11:25:06 +0800 Subject: [PATCH 17/85] Will change this commit message to mean something later --- Cargo.lock | 21 +++++++++++++++++---- Cargo.toml | 15 +++------------ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ecdfeb..08bf11d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,18 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-shared" version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml 0.8.23", +] + +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?branch=dev#8a794c03bfc7f2f294ff89d92494ad324afc30f5" dependencies = [ "dirs", "serde", @@ -112,8 +124,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" +version = "0.3.1" +source = "git+https://github.com/Breadway/bread-ecosystem?branch=dev#77bca8a1cf90d7fd38745270d2deb291b8b22bfe" dependencies = [ "dirs", "gtk4", @@ -124,8 +136,9 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#77bca8a1cf90d7fd38745270d2deb291b8b22bfe" dependencies = [ - "bread-shared", + "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", "dirs", "serde", "serde_json", @@ -135,7 +148,7 @@ dependencies = [ name = "breadbar" version = "0.3.0" dependencies = [ - "bread-shared", + "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=dev)", "bread-theme", "bread-utils", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index 673a8c9..02275a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,13 +10,13 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "dev", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.1", features = ["bread-client"] } -bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev", features = ["bread-client"] } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "dev" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -30,15 +30,6 @@ serde_json = "1" # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. resvg = { version = "0.44", default-features = false } -# TEMPORARY local-dev override: the widget feature's bread-shared/bread-utils -# changes aren't tagged/released yet, so point the git deps above at the -# local checkouts instead. Remove this section (and bump the tags above) -# once bread-ecosystem and bread have real release tags for this work. -[patch."https://git.breadway.dev/Breadway/bread-ecosystem"] -bread-utils = { path = "../bread-ecosystem/bread-utils" } -[patch."https://git.breadway.dev/Breadway/bread"] -bread-shared = { path = "../bread/bread-shared" } - [profile.release] lto = "thin" codegen-units = 1 From a00934a53d1cc5c7d340c621af84598698559f9b Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 11:48:36 +0800 Subject: [PATCH 18/85] update deps --- Cargo.lock | 65 ++++++++++++++++++++++++++++++------------------------ Cargo.toml | 2 +- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08bf11d..a90294c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,12 +88,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.1" @@ -187,7 +181,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags 2.13.1", + "bitflags", "cairo-sys-rs", "glib", "libc", @@ -698,7 +692,7 @@ version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ - "bitflags 2.13.1", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -825,7 +819,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" dependencies = [ - "bitflags 2.13.1", + "bitflags", "gdk4", "glib", "glib-sys", @@ -927,9 +921,9 @@ dependencies = [ [[package]] name = "imagesize" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indexmap" @@ -966,12 +960,13 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kurbo" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", + "polycool", "smallvec", ] @@ -1133,17 +1128,26 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "png" -version = "0.17.16" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1230,9 +1234,9 @@ dependencies = [ [[package]] name = "resvg" -version = "0.44.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" dependencies = [ "log", "pico-args", @@ -1253,9 +1257,12 @@ dependencies = [ [[package]] name = "roxmltree" -version = "0.20.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] [[package]] name = "rustc_version" @@ -1272,7 +1279,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1442,9 +1449,9 @@ dependencies = [ [[package]] name = "svgtypes" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ "kurbo", "siphasher", @@ -1515,9 +1522,9 @@ dependencies = [ [[package]] name = "tiny-skia" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" dependencies = [ "arrayref", "arrayvec", @@ -1530,9 +1537,9 @@ dependencies = [ [[package]] name = "tiny-skia-path" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" dependencies = [ "arrayref", "bytemuck", @@ -1721,9 +1728,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "usvg" -version = "0.44.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" dependencies = [ "base64", "data-url", diff --git a/Cargo.toml b/Cargo.toml index 02275a7..ad660fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" # Pure-Rust SVG rasteriser (default features off → no text/font deps; the icons # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. -resvg = { version = "0.44", default-features = false } +resvg = { version = "0.47", default-features = false } [profile.release] lto = "thin" From ac6ccfe88a4e1c4d018b48cb84b9a0fdf1359f08 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:15:57 +0800 Subject: [PATCH 19/85] Add --screenshot CLI mode for automated capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a single view ("bar" or "control-panel"), captures it via bread-screenshots, then exits — driven by bread-ecosystem's bread-capture orchestrator, or runnable standalone for one-off captures. Waits on GTK's map signal (plus a short settle delay) before capturing rather than guessing a fixed sleep upfront: the control-panel view in particular needs the popover's autohide disabled (a programmatic popup() has no input-event serial to grab the Wayland seat with) and a longer settle window so the CPU/RAM/PWR/GPU/network stats — populated by a 2-second background poller, not the popover's own open — have had at least one full cycle to fill in before the capture fires. --width/--height match whatever canvas bread-capture's isolation sizes the compositor to, so the "bar" view's geometry doesn't depend on querying anything at capture time. Needs RelmApp::with_args(vec![]) (GLib's own arg parser otherwise rejects --screenshot/--output before clap ever sees them) and allow_multiple_instances(true) for screenshot runs specifically, since GApplication is single-instance by default and this machine typically already has a real breadbar running. --- Cargo.lock | 377 ++++++++++++++++++++++++++++++++-------------- Cargo.toml | 4 + src/main.rs | 32 +++- src/screenshot.rs | 142 +++++++++++++++++ 4 files changed, 439 insertions(+), 116 deletions(-) create mode 100644 src/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index a90294c..1e67cb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,62 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arrayref" version = "0.3.9" @@ -40,7 +96,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -62,18 +118,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -94,12 +150,22 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-screenshots" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" +dependencies = [ + "anyhow", + "bread-utils", + "tracing", +] + [[package]] name = "bread-shared" version = "0.7.0" source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" dependencies = [ - "dirs", + "dirs 5.0.1", "serde", "serde_json", "toml 0.8.23", @@ -108,9 +174,9 @@ dependencies = [ [[package]] name = "bread-shared" version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?branch=dev#8a794c03bfc7f2f294ff89d92494ad324afc30f5" +source = "git+https://git.breadway.dev/Breadway/bread?branch=dev#34854f147135143ca4baf1d628e8c609e2c1cc8c" dependencies = [ - "dirs", + "dirs 6.0.0", "serde", "serde_json", "toml 0.8.23", @@ -119,9 +185,9 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=dev#77bca8a1cf90d7fd38745270d2deb291b8b22bfe" +source = "git+https://github.com/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" dependencies = [ - "dirs", + "dirs 5.0.1", "gtk4", "serde", "serde_json", @@ -130,10 +196,10 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#77bca8a1cf90d7fd38745270d2deb291b8b22bfe" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" dependencies = [ "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", - "dirs", + "dirs 5.0.1", "serde", "serde_json", ] @@ -142,9 +208,12 @@ dependencies = [ name = "breadbar" version = "0.3.0" dependencies = [ + "anyhow", + "bread-screenshots", "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=dev)", "bread-theme", "bread-utils", + "clap", "futures-lite", "gtk4", "gtk4-layer-shell", @@ -165,9 +234,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.1" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "bytes" @@ -215,14 +284,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "concurrent-queue" -version = "2.5.0" +name = "clap" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ - "crossbeam-utils", + "clap_builder", + "clap_derive", ] +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "convert_case" version = "0.10.0" @@ -241,12 +347,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - [[package]] name = "data-url" version = "0.3.2" @@ -272,7 +372,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", "unicode-xid", ] @@ -282,7 +382,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -293,15 +402,27 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] [[package]] -name = "either" -version = "1.16.0" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "endi" @@ -327,7 +448,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -357,11 +478,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -378,11 +498,11 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", ] [[package]] @@ -510,7 +630,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -611,20 +731,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -632,8 +738,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", + "wasm-bindgen", ] [[package]] @@ -716,7 +824,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -850,7 +958,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -916,7 +1024,7 @@ checksum = "31157e6ccefbad4b0cd7e549db6696691a70c11b108f26bf6bf76eef26af8c10" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -935,6 +1043,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -972,9 +1086,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" @@ -1057,6 +1171,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -1159,28 +1279,22 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1195,7 +1309,18 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", ] [[package]] @@ -1229,7 +1354,7 @@ checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1306,9 +1431,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1316,29 +1441,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1349,13 +1474,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1447,6 +1572,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "svgtypes" version = "0.16.1" @@ -1468,6 +1599,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -1477,7 +1619,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "version-compare", ] @@ -1506,7 +1648,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", ] [[package]] @@ -1517,7 +1668,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -1548,9 +1710,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1571,7 +1733,7 @@ checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1588,9 +1750,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -1647,9 +1809,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -1685,7 +1847,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1748,6 +1910,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" @@ -1771,15 +1939,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1812,7 +1971,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -1924,12 +2083,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "xml-rs" version = "0.8.28" @@ -1981,7 +2134,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zbus_names", "zvariant", "zvariant_utils", @@ -2027,7 +2180,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zvariant_utils", ] @@ -2040,6 +2193,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.119", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index ad660fe..23acc2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "d # instead of hand-parsing serde_json::Value. See src/widgets/. bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev", features = ["bread-client"] } bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "dev" } +# Capture primitives for `--screenshot` mode — see src/screenshot.rs. +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -26,6 +28,8 @@ zbus = { version = "5", default-features = false, features = ["tokio"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "process", "signal", "sync"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +clap = { version = "4", features = ["derive"] } +anyhow = "1" # Pure-Rust SVG rasteriser (default features off → no text/font deps; the icons # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. resvg = { version = "0.47", default-features = false } diff --git a/src/main.rs b/src/main.rs index 4bdb45e..82377e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ macro_rules! asset { mod bar; mod notifications; mod osd; +mod screenshot; mod theme; mod widgets; @@ -124,7 +125,7 @@ pub enum AppInput { #[relm4::component(pub)] impl SimpleComponent for App { - type Init = (); + type Init = Option; type Input = AppInput; type Output = (); @@ -141,7 +142,7 @@ impl SimpleComponent for App { } fn init( - _: Self::Init, + screenshot_req: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { @@ -644,6 +645,10 @@ impl SimpleComponent for App { widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); + // Captured before `control_popover` moves into `model` below — needed + // by the screenshot dispatch just before this function returns. + let control_popover_for_screenshot = control_popover.clone(); + let model = App { workspaces: vec![], active_ws: 1, @@ -713,6 +718,10 @@ impl SimpleComponent for App { notifications::spawn(); osd::spawn(); + if let Some(req) = screenshot_req { + screenshot::dispatch(&root, req, control_popover_for_screenshot); + } + ComponentParts { model, widgets } } @@ -1411,6 +1420,10 @@ fn stat_label() -> gtk4::Label { } fn main() { + use clap::Parser; + let cli = screenshot::Cli::parse(); + let screenshot_req = cli.screenshot_request(); + relm4::spawn(async { use tokio::signal::unix::{signal, SignalKind}; let mut stream = signal(SignalKind::hangup()).expect("SIGHUP handler"); @@ -1420,6 +1433,17 @@ fn main() { } }); - let app = RelmApp::new("sh.breadway.breadbar"); - app.run::(()); + // `with_args(vec![])` stops relm4 from handing our own --screenshot/ + // --output flags to GLib's option parser (`app.run()`'s default), which + // would otherwise reject them as unrecognized before Cli::parse() above + // ever sees argv. allow_multiple_instances is needed for screenshot runs + // specifically: GApplication is single-instance by default, and a normal + // breadbar is typically already running, so without this a screenshot + // invocation would just activate that existing instance instead of + // starting a fresh one whose `init()` receives the request at all. + let app = RelmApp::new("sh.breadway.breadbar").with_args(vec![]); + if screenshot_req.is_some() { + app.allow_multiple_instances(true); + } + app.run::(screenshot_req); } diff --git a/src/screenshot.rs b/src/screenshot.rs new file mode 100644 index 0000000..d276be8 --- /dev/null +++ b/src/screenshot.rs @@ -0,0 +1,142 @@ +//! `--screenshot` CLI mode: render a specific view, capture it via +//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s +//! `bread-capture` orchestrator, or run standalone for one-off captures. +//! +//! Capture waits on GTK's `map` signal rather than a blind sleep before +//! grabbing pixels — the surface (or, for popover views, the popover itself) +//! genuinely isn't on screen yet before that fires, so a fixed delay would +//! either race a slow first paint or pad every fast one for nothing. + +use clap::Parser; +use gtk4::prelude::*; +use std::path::PathBuf; +use std::time::Duration; + +/// Extra settle time after `map` for the first frame to actually paint +/// before grim runs — `map` fires once the surface exists, not once +/// anything has been drawn into it. +const SETTLE_DELAY: Duration = Duration::from_millis(300); + +/// Settle time for the control-panel view specifically: longer than +/// [`SETTLE_DELAY`] because the CPU/RAM/PWR/GPU/network labels there aren't +/// populated by the popover's own load (that only covers volume/brightness/ +/// sinks, see `bar::control::spawn_load`) — they're refreshed by +/// `bar::stats::spawn_poller`'s 2-second background loop, gated on +/// `control_popover.is_visible()` at each tick. Capturing any sooner than one +/// full poll interval after the popover opens leaves them at their initial +/// placeholder dashes. +const CONTROL_PANEL_SETTLE_DELAY: Duration = Duration::from_millis(2_200); + +/// Delay between the bar's own `map` and calling `popover.popup()`. Calling +/// `popup()` synchronously from inside the root window's `map` handler +/// produces a popover that reports itself `map`ped but never actually paints +/// (confirmed by an independent `grim` capture taken mid-sequence, showing no +/// popover at all) — presumably the parent widget's own allocation isn't +/// settled yet at that exact point. Giving the initial layout pass a beat to +/// finish first is what makes it actually render. +const PRE_POPUP_DELAY: Duration = Duration::from_millis(300); + +#[derive(Parser)] +#[command(name = "breadbar")] +pub struct Cli { + /// Render the named view, capture it, then exit instead of running + /// normally. Known views: "bar", "control-panel". + #[arg(long)] + pub screenshot: Option, + + /// PNG path to write the capture to. Required together with --screenshot. + #[arg(long)] + pub output: Option, + + /// Capture canvas width — matches the isolated compositor's output width + /// (`bread-capture --isolate-width`) so the geometry passed to `grim` + /// doesn't depend on querying anything at capture time. + #[arg(long, default_value_t = 1920)] + pub width: u32, + + /// Capture canvas height — see `width`. + #[arg(long, default_value_t = 1080)] + pub height: u32, +} + +pub struct ScreenshotRequest { + pub view: String, + pub output: PathBuf, + pub width: u32, + pub height: u32, +} + +impl Cli { + /// `None` for a normal run. Exits the process with an error if + /// `--screenshot` was given without `--output`, before any GTK/relm4 + /// setup happens. + pub fn screenshot_request(&self) -> Option { + let view = self.screenshot.clone()?; + let Some(output) = self.output.clone() else { + eprintln!("breadbar: --screenshot requires --output"); + std::process::exit(1); + }; + Some(ScreenshotRequest { view, output, width: self.width, height: self.height }) + } +} + +/// Wire up the given view's screenshot sequence. Called once from `init()`, +/// after the window and its popovers exist but before the component finishes +/// initializing — every path here ends by exiting the process, it never +/// returns control to the normal bar UI. +/// The bar's fixed height — matches `root.set_exclusive_zone(32)` / +/// `set_default_height: 32` in `main.rs`. Unlike the control-panel's full +/// canvas, this never varies with `--width`/`--height`. +const BAR_HEIGHT: i32 = 32; + +pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, control_popover: gtk4::Popover) { + match req.view.as_str() { + "bar" => { + let output = req.output; + let width = req.width as i32; + 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)); + }); + }); + } + "control-panel" => { + let output = req.output; + let (width, height) = (req.width as i32, req.height as i32); + let popover_to_open = control_popover.clone(); + root.connect_map(move |_| { + // Autohide (the default) tries to grab the Wayland seat on + // popup, keyed to a real input event's serial — a + // programmatic popup() has no such event to grab with. + // Screenshot mode never needs the popover to dismiss itself + // anyway. + popover_to_open.set_autohide(false); + let popover_to_open = popover_to_open.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + popover_to_open.popup(); + }); + }); + control_popover.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(CONTROL_PANEL_SETTLE_DELAY, move || { + finish(bread_screenshots::capture_region(0, 0, width, height, &output)); + }); + }); + } + other => { + eprintln!("breadbar: unknown screenshot view '{other}' (known: bar, control-panel)"); + std::process::exit(1); + } + } +} + +fn finish(result: anyhow::Result<()>) { + match result { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("breadbar: screenshot capture failed: {e}"); + std::process::exit(1); + } + } +} From 059e11cdebd547532303f36753e365811a10dcf4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:23:20 +0800 Subject: [PATCH 20/85] Bump bread-utils/bread-screenshots lockfile refs to bread-ecosystem's headless-Sway dev commit --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e67cb8..b48bacd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,7 +153,7 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff" dependencies = [ "anyhow", "bread-utils", @@ -196,7 +196,7 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff" dependencies = [ "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", "dirs 5.0.1", From 566aeeed8bb2610225a671aa205dcbdf611be952 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 17:17:17 +0800 Subject: [PATCH 21/85] breadbar: capture every view, not just bar/control-panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit breadbar is "a bar + the notification daemon + the OSD" — the screenshot mode only covered the bar and its control-panel popover, missing seven more distinct surfaces: the WiFi/Bluetooth connectivity popover (both tabs), the media-controls popover, the standalone notification window (both normal and critical urgency), the standalone OSD window (volume and brightness), and the wifi add-network dialog. All ten views are now --screenshot targets. Two real refactors needed to make the standalone notification/OSD windows screenshot-able at all, not just bigger match arms: - Both windows are built deep inside an async task (`run_osd`/ `popup::run`), only reachable after the real event loop starts — no window handle ever existed for a caller to hook `connect_map` on before that. Window construction is now synchronous in `osd::spawn`/ `notifications::spawn`, handed to the async loop as a parameter instead of created inside it. - Screenshot mode seeds each with one fixed sample event (SampleKind) via the same channel the real pactl/backlight/D-Bus sources feed, instead of waiting for real hardware/dbus activity. For notifications specifically this also means skipping the real org.freedesktop.Notifications D-Bus registration entirely in screenshot mode — claiming that well-known name would just race the real breadbar (if running) for it, for no benefit, since nothing external needs to reach a screenshot-only instance. show_add_network_dialog gained an `on_build` hook (called before `.present()`, the only point `connect_map` can still catch the map) so screenshot mode can capture it without changing its one real call site's behavior — and its `anchor` parameter widened from `&Button` to `&impl IsA` since the screenshot path anchors off a `ToggleButton`, not a `Button`. --- src/main.rs | 59 +++++++++++-- src/notifications/mod.rs | 110 ++++++++++++++++------- src/notifications/popup.rs | 26 +++++- src/osd.rs | 50 +++++++++-- src/screenshot.rs | 174 +++++++++++++++++++++++++++++-------- 5 files changed, 335 insertions(+), 84 deletions(-) diff --git a/src/main.rs b/src/main.rs index 82377e5..6ea7ded 100644 --- a/src/main.rs +++ b/src/main.rs @@ -645,9 +645,16 @@ impl SimpleComponent for App { widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); - // Captured before `control_popover` moves into `model` below — needed - // by the screenshot dispatch just before this function returns. + // Captured before these move into `model` (or are otherwise dropped + // as bare locals, never stored on `App` at all) — needed by the + // screenshot dispatch just before this function returns. let control_popover_for_screenshot = control_popover.clone(); + let connectivity_popover_for_screenshot = connectivity_popover.clone(); + let wifi_tab_btn_for_screenshot = wifi_tab_btn.clone(); + let bt_tab_btn_for_screenshot = bt_tab_btn.clone(); + let media_popover_for_screenshot = media_popover.clone(); + let media_widget_for_screenshot = media_widget.clone(); + let media_track_lbl_for_screenshot = media_track_lbl.clone(); let model = App { workspaces: vec![], @@ -715,11 +722,43 @@ impl SimpleComponent for App { bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); widgets::client::spawn(sender.clone()); - notifications::spawn(); - osd::spawn(); + + // Screenshot mode primes these with sample content instead of the + // real D-Bus/pactl/backlight sources — see notifications::SampleKind + // and osd::SampleKind's doc comments. + let notif_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { + "notification" => Some(notifications::SampleKind::Normal), + "notification-critical" => Some(notifications::SampleKind::Critical), + _ => None, + }); + let notification_window = notifications::spawn(notif_sample); + let osd_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { + "osd-volume" => Some(osd::SampleKind::Volume), + "osd-brightness" => Some(osd::SampleKind::Brightness), + _ => None, + }); + let osd_window = osd::spawn(osd_sample); if let Some(req) = screenshot_req { - screenshot::dispatch(&root, req, control_popover_for_screenshot); + let notification_window = matches!(req.view.as_str(), "notification" | "notification-critical") + .then_some(notification_window); + let osd_window = matches!(req.view.as_str(), "osd-volume" | "osd-brightness") + .then_some(osd_window); + screenshot::dispatch( + &root, + req, + screenshot::Handles { + control_popover: control_popover_for_screenshot, + connectivity_popover: connectivity_popover_for_screenshot, + wifi_tab_btn: wifi_tab_btn_for_screenshot, + bt_tab_btn: bt_tab_btn_for_screenshot, + media_popover: media_popover_for_screenshot, + media_widget: media_widget_for_screenshot, + media_track_lbl: media_track_lbl_for_screenshot, + notification_window, + osd_window, + }, + ); } ComponentParts { model, widgets } @@ -1145,7 +1184,7 @@ impl App { if saved { bar::wifi::spawn_join(ssid_clone.clone()); } else { - show_add_network_dialog(btn, ssid_clone.clone()); + show_add_network_dialog(btn, ssid_clone.clone(), |_| {}); } close_parent_popover(btn); }); @@ -1302,8 +1341,11 @@ fn close_parent_popover(widget: >k4::Button) { } /// Small modal prompting for a password, then saves + joins the network via -/// `breadcrumbs add` + `breadcrumbs join`. -fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { +/// `breadcrumbs add` + `breadcrumbs join`. `on_build` runs on the freshly +/// built dialog *before* it's presented — screenshot mode's only hook point, +/// since `connect_map` registered any later would miss a map that already +/// happened. The real call site passes a no-op. +fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_build: impl FnOnce(>k4::Window)) { let dialog = gtk4::Window::new(); dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); @@ -1367,6 +1409,7 @@ fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { dialog_for_activate.close(); }); + on_build(&dialog); dialog.present(); entry.grab_focus(); } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index b1f1760..0c25c0d 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -172,38 +172,90 @@ impl NotifServer { } } -pub fn spawn() { - let (tx, rx) = mpsc::channel(32); - let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); +/// A fixed sample notification for `--screenshot notification`/ +/// `notification-critical` — substitutes for a real `Notify` D-Bus call so a +/// capture doesn't depend on some external sender firing one at just the +/// right moment. +pub enum SampleKind { + Normal, + Critical, +} - relm4::spawn(async move { - let server = NotifServer { - tx, - next_id: AtomicU32::new(1), - sync_tags: Mutex::new(HashMap::new()), +impl SampleKind { + fn sample_event(&self) -> NotifEvent { + let urgency = match self { + SampleKind::Normal => Urgency::Normal, + SampleKind::Critical => Urgency::Critical, }; - // Builder failures here would only occur with invalid static strings — safe to unwrap. - let conn = zbus::connection::Builder::session() - .unwrap() - .name("org.freedesktop.Notifications") - .unwrap() - .serve_at("/org/freedesktop/Notifications", server) - .unwrap() - .build() - .await - .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); - // Hand the connection to popup::run so it can emit `NotificationClosed` - // (spec-mandated whenever a notification actually goes away) — the - // dismiss decisions all happen over there, not in this interface impl. - let _ = conn_tx.send(conn); - std::future::pending::<()>().await - }); - - relm4::spawn_local(async move { - if let Ok(conn) = conn_rx.await { - popup::run(rx, conn).await; + NotifEvent::Show { + id: 1, + app_name: "Sample App".into(), + summary: "Sample notification".into(), + body: "This is what a notification card looks like.".into(), + urgency, + expire: Expire::Never, } - }); + } +} + +/// Builds the notification window synchronously (see +/// `popup::build_window`'s doc comment) and spawns the event loop that +/// shows/updates/hides it. +/// +/// `sample`: `Some` skips real D-Bus registration entirely and seeds the +/// loop with one fixed sample event instead — screenshot mode only. Doing +/// the real `org.freedesktop.Notifications` registration in every +/// screenshot run would race the real breadbar (if running) for the same +/// well-known name for no benefit, since nothing needs to reach this +/// instance externally. +pub fn spawn(sample: Option) -> gtk4::Window { + let (window, cards_box) = popup::build_window(); + let (tx, rx) = mpsc::channel(32); + + match sample { + Some(kind) => { + let _ = tx.try_send(kind.sample_event()); + let window_for_loop = window.clone(); + relm4::spawn_local(async move { + popup::run(window_for_loop, cards_box, rx, None).await; + }); + } + None => { + let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); + + relm4::spawn(async move { + let server = NotifServer { + tx, + next_id: AtomicU32::new(1), + sync_tags: Mutex::new(HashMap::new()), + }; + // Builder failures here would only occur with invalid static strings — safe to unwrap. + let conn = zbus::connection::Builder::session() + .unwrap() + .name("org.freedesktop.Notifications") + .unwrap() + .serve_at("/org/freedesktop/Notifications", server) + .unwrap() + .build() + .await + .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); + // Hand the connection to popup::run so it can emit `NotificationClosed` + // (spec-mandated whenever a notification actually goes away) — the + // dismiss decisions all happen over there, not in this interface impl. + let _ = conn_tx.send(conn); + std::future::pending::<()>().await + }); + + let window_for_loop = window.clone(); + relm4::spawn_local(async move { + if let Ok(conn) = conn_rx.await { + popup::run(window_for_loop, cards_box, rx, Some(conn)).await; + } + }); + } + } + + window } #[cfg(test)] diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 69d1f6c..91adec0 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -23,7 +23,11 @@ mod close_reason { pub const CLOSE_NOTIFICATION_CALL: u32 = 3; } -pub async fn run(mut rx: Receiver, conn: zbus::Connection) { +/// Builds the notification window synchronously — so a caller (screenshot +/// mode in particular) has a real window to hook `connect_map` on before +/// `run`'s event loop, which needs an async `zbus::Connection` handshake in +/// the real path, ever starts. +pub fn build_window() -> (gtk4::Window, gtk4::Box) { let window = create_window(); let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); cards_box.set_margin_top(8); @@ -31,7 +35,21 @@ pub async fn run(mut rx: Receiver, conn: zbus::Connection) { cards_box.set_margin_start(8); cards_box.set_margin_end(8); window.set_child(Some(&cards_box)); + (window, cards_box) +} +/// `conn`: `None` in screenshot mode, which skips real D-Bus registration +/// entirely (see `super::spawn`) — there's no external client that needs to +/// reach a screenshot-only instance, and registering the well-known name +/// would just race the real breadbar for it. `NotificationClosed` is a +/// spec-mandated signal for real clients only, so it's simply not emitted +/// when there's no real connection to emit it on. +pub async fn run( + window: gtk4::Window, + cards_box: gtk4::Box, + mut rx: Receiver, + conn: Option, +) { let cards: Cards = Rc::new(RefCell::new(HashMap::new())); let generations: Generations = Rc::new(RefCell::new(HashMap::new())); @@ -109,8 +127,10 @@ fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) /// Emits the spec-mandated `NotificationClosed(id, reason)` signal. Sent /// directly over the connection rather than through the zbus interface /// macro's generated helper, since the dismiss decision happens here in the -/// popup task, not inside `NotifServer`'s own method bodies. -async fn emit_closed(conn: &zbus::Connection, id: u32, reason: u32) { +/// popup task, not inside `NotifServer`'s own method bodies. No-op when +/// `conn` is `None` (screenshot mode — see `run`'s doc comment). +async fn emit_closed(conn: &Option, id: u32, reason: u32) { + let Some(conn) = conn else { return }; let result = conn .emit_signal( None::<&str>, diff --git a/src/osd.rs b/src/osd.rs index d7520fc..5deb1d4 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -9,14 +9,50 @@ enum OsdEvent { Brightness { pct: u8 }, } -pub fn spawn() { +/// A fixed sample event for `--screenshot osd-volume`/`osd-brightness` — +/// substitutes for the real `pactl subscribe`/backlight-sysfs watchers so a +/// capture doesn't depend on this machine's actual volume/brightness at +/// capture time. +pub enum SampleKind { + Volume, + Brightness, +} + +impl SampleKind { + fn sample_event(&self) -> OsdEvent { + match self { + SampleKind::Volume => OsdEvent::Volume { pct: 65, muted: false }, + SampleKind::Brightness => OsdEvent::Brightness { pct: 80 }, + } + } +} + +/// Builds the OSD window synchronously (so a caller — screenshot mode, via +/// `sample`, in particular — has a real window to hook `connect_map` on +/// before the async event loop below ever runs) and spawns the event loop +/// that shows/updates/hides it. +/// +/// `sample`: `Some` skips the real volume/brightness watchers entirely and +/// seeds the loop with one fixed sample event instead — screenshot mode +/// only, so a capture never depends on (or is disrupted by) this machine's +/// actual audio/backlight state. +pub fn spawn(sample: Option) -> gtk4::Window { let (tx, rx) = mpsc::channel::(8); - let tx1 = tx.clone(); - std::thread::spawn(move || volume_watcher(tx1)); - std::thread::spawn(move || brightness_watcher(tx)); + match sample { + Some(kind) => { + let _ = tx.try_send(kind.sample_event()); + } + None => { + let tx1 = tx.clone(); + std::thread::spawn(move || volume_watcher(tx1)); + std::thread::spawn(move || brightness_watcher(tx)); + } + } - relm4::spawn_local(run_osd(rx)); + let window = create_window(); + relm4::spawn_local(run_osd(window.clone(), rx)); + window } fn volume_watcher(tx: mpsc::Sender) { @@ -119,9 +155,7 @@ fn brightness_watcher(tx: mpsc::Sender) { } } -async fn run_osd(mut rx: mpsc::Receiver) { - let window = create_window(); - +async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { let container = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); container.set_margin_top(10); container.set_margin_bottom(10); diff --git a/src/screenshot.rs b/src/screenshot.rs index d276be8..8ccdd87 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -6,6 +6,13 @@ //! grabbing pixels — the surface (or, for popover views, the popover itself) //! genuinely isn't on screen yet before that fires, so a fixed delay would //! either race a slow first paint or pad every fast one for nothing. +//! +//! breadbar is "a bar + the notification daemon + the OSD" (see its own +//! module docs), so its screenshot views span three separate top-level +//! surfaces, not just the bar: the bar itself and its popovers (this +//! module, anchored off `root`), plus the standalone notification and OSD +//! windows (`notifications::spawn`/`osd::spawn`, built and primed with +//! sample data by `main.rs` before `dispatch` runs — see [`Handles`]). use clap::Parser; use gtk4::prelude::*; @@ -17,15 +24,12 @@ use std::time::Duration; /// anything has been drawn into it. const SETTLE_DELAY: Duration = Duration::from_millis(300); -/// Settle time for the control-panel view specifically: longer than -/// [`SETTLE_DELAY`] because the CPU/RAM/PWR/GPU/network labels there aren't -/// populated by the popover's own load (that only covers volume/brightness/ -/// sinks, see `bar::control::spawn_load`) — they're refreshed by -/// `bar::stats::spawn_poller`'s 2-second background loop, gated on -/// `control_popover.is_visible()` at each tick. Capturing any sooner than one -/// full poll interval after the popover opens leaves them at their initial -/// placeholder dashes. -const CONTROL_PANEL_SETTLE_DELAY: Duration = Duration::from_millis(2_200); +/// Settle time for views whose content depends on `bar::stats::spawn_poller`'s +/// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels, +/// gated on popover visibility) or a similar live-data popover load +/// (connectivity's wifi/bluetooth scan) — capturing any sooner leaves +/// placeholder dashes/"Scanning…" instead of real content. +const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// Delay between the bar's own `map` and calling `popover.popup()`. Calling /// `popup()` synchronously from inside the root window's `map` handler @@ -36,11 +40,24 @@ const CONTROL_PANEL_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// finish first is what makes it actually render. const PRE_POPUP_DELAY: Duration = Duration::from_millis(300); +const KNOWN_VIEWS: &[&str] = &[ + "bar", + "control-panel", + "connectivity-wifi", + "connectivity-bluetooth", + "media-popover", + "notification", + "notification-critical", + "osd-volume", + "osd-brightness", + "wifi-add-dialog", +]; + #[derive(Parser)] #[command(name = "breadbar")] pub struct Cli { /// Render the named view, capture it, then exit instead of running - /// normally. Known views: "bar", "control-panel". + /// normally. See `screenshot::KNOWN_VIEWS` for the full list. #[arg(long)] pub screenshot: Option, @@ -80,20 +97,37 @@ impl Cli { } } -/// Wire up the given view's screenshot sequence. Called once from `init()`, -/// after the window and its popovers exist but before the component finishes -/// initializing — every path here ends by exiting the process, it never -/// returns control to the normal bar UI. +/// Every widget/window `dispatch` might need, gathered by `main.rs`'s +/// `init()` — most of these are plain locals there that never otherwise +/// outlive `init()` (never stored on `App`), so they have to be cloned out +/// before dispatch time same as `control_popover` always was. +pub struct Handles { + pub control_popover: gtk4::Popover, + pub connectivity_popover: gtk4::Popover, + pub wifi_tab_btn: gtk4::ToggleButton, + pub bt_tab_btn: gtk4::ToggleButton, + pub media_popover: gtk4::Popover, + pub media_widget: gtk4::Box, + pub media_track_lbl: gtk4::Label, + /// Already built and primed with sample content by `main.rs` (via + /// `notifications::spawn(Some(kind))`) when `req.view` calls for it — + /// `None` otherwise. + pub notification_window: Option, + /// Same deal as `notification_window`, via `osd::spawn(Some(kind))`. + pub osd_window: Option, +} + /// The bar's fixed height — matches `root.set_exclusive_zone(32)` / -/// `set_default_height: 32` in `main.rs`. Unlike the control-panel's full +/// `set_default_height: 32` in `main.rs`. Unlike the other views' full /// canvas, this never varies with `--width`/`--height`. const BAR_HEIGHT: i32 = 32; -pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, control_popover: gtk4::Popover) { +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); + match req.view.as_str() { "bar" => { - let output = req.output; - let width = req.width as i32; root.connect_map(move |_| { let output = output.clone(); gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { @@ -102,35 +136,103 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, control_ }); } "control-panel" => { - let output = req.output; - let (width, height) = (req.width as i32, req.height as i32); - let popover_to_open = control_popover.clone(); + open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + } + "connectivity-wifi" => { + handles.wifi_tab_btn.set_active(true); + open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + } + "connectivity-bluetooth" => { + handles.bt_tab_btn.set_active(true); + open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + } + "media-popover" => { + // Real media state only shows the widget/text when something's + // actually playing (see AppInput::MediaUpdate) — an automated + // run has nothing playing, so fake enough of it directly on the + // widgets to get a representative capture. + handles.media_widget.set_visible(true); + handles.media_track_lbl.set_text("Sample Track — Sample Artist"); + open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height); + } + "notification" | "notification-critical" => { + let Some(window) = handles.notification_window else { + eprintln!("breadbar: internal error — no notification window built for '{}'", req.view); + std::process::exit(1); + }; + capture_standalone_window(window, output, width, height); + } + "osd-volume" | "osd-brightness" => { + let Some(window) = handles.osd_window else { + eprintln!("breadbar: internal error — no OSD window built for '{}'", req.view); + std::process::exit(1); + }; + capture_standalone_window(window, output, width, height); + } + "wifi-add-dialog" => { + let anchor = handles.wifi_tab_btn; root.connect_map(move |_| { - // Autohide (the default) tries to grab the Wayland seat on - // popup, keyed to a real input event's serial — a - // programmatic popup() has no such event to grab with. - // Screenshot mode never needs the popover to dismiss itself - // anyway. - popover_to_open.set_autohide(false); - let popover_to_open = popover_to_open.clone(); - gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - popover_to_open.popup(); - }); - }); - control_popover.connect_map(move |_| { let output = output.clone(); - gtk4::glib::timeout_add_local_once(CONTROL_PANEL_SETTLE_DELAY, move || { - finish(bread_screenshots::capture_region(0, 0, width, height, &output)); + let anchor = anchor.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + crate::show_add_network_dialog(&anchor, "Sample Network".to_string(), move |dialog| { + capture_standalone_window(dialog.clone(), output.clone(), width, height); + }); }); }); } other => { - eprintln!("breadbar: unknown screenshot view '{other}' (known: bar, control-panel)"); + eprintln!( + "breadbar: unknown screenshot view '{other}' (known: {})", + KNOWN_VIEWS.join(", ") + ); std::process::exit(1); } } } +/// Shared shape for every popover view: force it open shortly after the bar +/// maps (autohide disabled — a programmatic `popup()` has no real input +/// event serial to grab the Wayland seat with), then capture the whole +/// canvas after `settle` once the popover itself maps. +fn open_popover_on_root_map( + root: >k4::ApplicationWindow, + popover: gtk4::Popover, + settle: Duration, + output: PathBuf, + width: i32, + height: i32, +) { + let popover_to_open = popover.clone(); + root.connect_map(move |_| { + popover_to_open.set_autohide(false); + let popover_to_open = popover_to_open.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + popover_to_open.popup(); + }); + }); + popover.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(settle, move || { + finish(bread_screenshots::capture_region(0, 0, width, height, &output)); + }); + }); +} + +/// Shared shape for the standalone notification/OSD windows and the wifi +/// add-network dialog: wait for `map`, settle, capture, exit. These are +/// already-visible-or-about-to-be windows by the time this is called (their +/// sample event is queued before `dispatch` even runs), so this is just the +/// capture half. +fn capture_standalone_window(window: gtk4::Window, output: PathBuf, width: i32, height: i32) { + window.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { + finish(bread_screenshots::capture_region(0, 0, width, height, &output)); + }); + }); +} + fn finish(result: anyhow::Result<()>) { match result { Ok(()) => std::process::exit(0), From c53d504208d55d65982249f2720b6ef031cae56d Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 30 Jul 2026 19:02:39 +0800 Subject: [PATCH 22/85] breadbar: give the wifi add-network dialog proper CSD + rounded corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was a bare gtk4::Window with no titlebar — GTK4's own minimal CSD fallback for that case is a flat bar with plain system-font title text and square corners, which is what actually made this look like a stray window from a different decade next to the rest of the (rounded, borderless) ecosystem. Now sets a real HeaderBar (which picks up the window's existing title automatically) and gives the dialog + headerbar matching rounded corners and dark theming via the existing .wifi-add-dialog CSS class. Also switched the Connect button off "suggested-action": GTK4's bundled theme special-cases that exact class name for a native OS-accent-colour feature that isn't a normal CSS rule and doesn't yield to any background-color override this stylesheet adds, confirmed empirically (a much more specific selector had zero effect). Moved to "confirm-button", the same accent-button convention breadman/breadpad already use. Known residual issue, not resolved here: the Connect button still doesn't render the accent colour even via "confirm-button" — narrowed down to *some* button-specific styling quirk in this one dialog (window- level rules like the rounded corners and dark background apply correctly; a maximally-obvious magenta test rule on the same selector also failed to render, ruling out a color-value mistake specifically), but not root-caused further. Left as a known follow-up rather than blocking the CSD fix, which was the actual ask. Also removed two `cursor: pointer` CSS properties (on .clickable and .media-widget) that GTK4 doesn't support as a CSS property at all — was logging "Theme parser error: No property named cursor" on every startup, doing nothing functionally either way. --- src/main.rs | 18 +++++++++++++++++- src/theme.rs | 11 ++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6ea7ded..58a70c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1350,6 +1350,15 @@ fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_bui dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); dialog.add_css_class("wifi-add-dialog"); + // A bare gtk4::Window with no titlebar set falls back to GTK's own + // minimal CSD: a flat bar with plain system-font title text and no + // rounding — which is what actually made this look like a stray window + // from a different decade next to the rest of the (rounded, borderless, + // shadowed) ecosystem. A real HeaderBar picks up the window's own title + // automatically and gets the same `.wifi-add-dialog` theming below. + let header = gtk4::HeaderBar::new(); + header.set_show_title_buttons(true); + dialog.set_titlebar(Some(&header)); if let Some(root) = anchor.root() { if let Ok(win) = root.downcast::() { dialog.set_transient_for(Some(&win)); @@ -1375,7 +1384,14 @@ fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_bui btn_row.set_halign(gtk4::Align::End); let cancel_btn = gtk4::Button::with_label("Cancel"); let connect_btn = gtk4::Button::with_label("Connect"); - connect_btn.add_css_class("suggested-action"); + // Not "suggested-action" — GTK4's own bundled theme special-cases that + // class for a newer native OS-accent-colour feature that isn't a normal + // CSS rule at all, and simply doesn't lose to any `background-color` + // override this stylesheet adds, however specific the selector (already + // confirmed empirically: adding a much more specific override rule had + // zero effect). "confirm-button" is the same ecosystem-wide accent + // button convention breadman/breadpad already use successfully. + connect_btn.add_css_class("confirm-button"); btn_row.append(&cancel_btn); btn_row.append(&connect_btn); body.append(&btn_row); diff --git a/src/theme.rs b/src/theme.rs index 5bb20b1..10c2415 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -48,7 +48,6 @@ fn load_css() -> String { progressbar.osd-bar {{ min-height: 6px; }}\ progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 3px; min-height: 6px; }}\ progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\ - .clickable {{ cursor: pointer; }}\ .wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\ .wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ .wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\ @@ -68,8 +67,14 @@ fn load_css() -> String { .wifi-popover-row-active {{ color: {accent}; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ - window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px; }}\ - .media-widget {{ border-radius: {radius_sm}; padding: 0 6px; cursor: pointer; }}\ + window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px;\ + border-radius: {radius}; }}\ + window.wifi-add-dialog headerbar {{ background-color: {bg_rgba}; color: {on_bg};\ + border-top-left-radius: {radius}; border-top-right-radius: {radius};\ + border-bottom: 1px solid alpha({on_bg}, 0.08); box-shadow: none; }}\ + .confirm-button {{ background-color: @accent; color: @on-accent; }}\ + .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ + .media-widget {{ border-radius: {radius_sm}; padding: 0 6px; }}\ .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ .media-track-lbl {{ font-size: 12px; }}\ From 9dc82817e97b47ab1fe8a37ba0926383b03c744b Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:05:29 +0800 Subject: [PATCH 23/85] =?UTF-8?q?CI:=20single-trunk=20model=20=E2=80=94=20?= =?UTF-8?q?dev=20triggers=20on=20main,=20beta=20becomes=20RC-tag-triggered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dev/beta branch split with one trunk (main): dev-track builds still publish on every push, but the beta track now publishes from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained beta branch. Removes the branch nobody reliably kept in sync. --- .forgejo/workflows/dev-release.yml | 15 ++++---- .../{beta-release.yml => rc-release.yml} | 38 +++++-------------- .forgejo/workflows/release.yml | 1 + 3 files changed, 17 insertions(+), 37 deletions(-) rename .forgejo/workflows/{beta-release.yml => rc-release.yml} (57%) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 5ffa25a..421489e 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -1,12 +1,11 @@ name: dev release -# Publishes a dev-track build on every push to `dev` — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a dev-track build on every push to `main` (the trunk +# branch — there is no separate `dev` branch). See bread-ecosystem's +# docs/release-channels.md for the release-track policy this is part of. on: push: - branches: ['dev'] + branches: ['main'] jobs: build: @@ -16,7 +15,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -33,7 +32,7 @@ jobs: # what's already installed and bakery would correctly refuse it. LATEST_TAG="$(git ls-remote --tags --refs \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else @@ -72,6 +71,6 @@ jobs: # mktemp: a fixed clone path races when multiple repos' dev/beta # workflows run close together on the same self-hosted runner. ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/rc-release.yml similarity index 57% rename from .forgejo/workflows/beta-release.yml rename to .forgejo/workflows/rc-release.yml index ed482ba..c05a6dc 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -1,52 +1,32 @@ -name: beta release +name: beta (rc) release -# Publishes a beta-track build on every push to `beta` — a frozen -# stabilization branch cut from `dev` when ready to stabilize; only -# fix/ branches merged into `beta` should land here afterward. -# See bread-ecosystem's docs/release-channels.md for the three-track policy. +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag +# pushed to `main` — there is no separate `beta` branch; "freezing" is +# just pausing pushes to main while an RC gets tested. See +# bread-ecosystem's docs/release-channels.md for the release-track policy. on: push: - branches: ['beta'] + tags: ['v*'] jobs: build: + if: ${{ contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch beta --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked - - name: compute beta version - run: | - set -euo pipefail - cd src - # Base the beta version off the latest published stable tag, - # not Cargo.toml — Cargo.toml can go stale relative to the last - # real release (seen in practice: breadbox/breadpad/breadcrumbs/ - # breadpaper), which would make a beta build sort as OLDER than - # what's already installed and bakery would correctly refuse it. - LATEST_TAG="$(git ls-remote --tags --refs \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" - if [ -n "${LATEST_TAG}" ]; then - CUR="${LATEST_TAG}" - else - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" - fi - IFS='.' read -r MA MI PA <<< "${CUR}" - SHA="$(git rev-parse --short HEAD)" - TS="$(date -u +%Y%m%d%H%M%S)" - echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" - - name: prepare artifacts run: | set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="/srv/breadway-dl/beta/breadbar/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 264d787..48d3dbc 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout From bba4aa2d2be86e7038bbbdafd09dab32a89e4012 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:08:41 +0800 Subject: [PATCH 24/85] CONTRIBUTING.md: document single-trunk + RC-tag release model --- CONTRIBUTING.md | 71 ++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1dac2b5..e171853 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,16 +7,10 @@ workflow as every other ecosystem product. ## Branches -- **`main`** — release branch, always tag-ready. Nothing is committed to it - directly; it only moves forward via a `beta` merge (see below). -- **`dev`** — integration branch. All day-to-day work lands here first. - Every push to `dev` automatically builds and publishes a **dev-track** - build (see Tracks below) — use this to test your change in a real install - before it goes any further. -- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. - Every push to `beta` automatically builds and publishes a **beta-track** - build. While a freeze is active, only fixes for issues found *in that - freeze* should land on `beta`. +There is one long-lived branch: **`main`**. All day-to-day work lands here. +Every push to `main` automatically builds and publishes a **dev-track** +build (see Tracks below) — a real install you can test before cutting +anything more formal. New work — features and bug fixes alike — goes on a short-lived branch: @@ -25,28 +19,26 @@ feature/ fix/ ``` -Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing -something reported against an active `beta` freeze, branch off `beta` -instead, merge the fix there to unblock testers, and also forward the same -fix into `dev` so it doesn't quietly reappear next cycle. +Branch off `main`, open a PR/push back into `main` when ready. Short-lived +branches get deleted on merge — they never accumulate the kind of drift a +second long-lived branch does. ## The release cycle -1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push +There's no separate `beta` or release branch — "stable" and "beta" are both +just **tags** on `main`, not branches that need to be kept in sync: + +1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push auto-publishes a dev build — install it with `bakery track set dev` and - `bakery update --all`, then report or fix anything broken with another - push to `dev`. -2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut - fresh from `dev`'s current tip. This freezes it as the stabilization - target — `dev` keeps moving independently starting the next cycle. -3. `beta` is open for anyone to test: `bakery track set beta` and - `bakery update --all`. **File issues against anything you find on this - repo's Forgejo issue tracker.** Fixes land via `fix/` branches - merged into `beta`. -4. Once `beta` has gone roughly **a month** without new issues, it's merged - into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the - stable release build. `beta` is then reset from `dev` to start the next - cycle. + `bakery update --all`, then fix anything broken with another push. +2. When you want to stabilize before a real release, tag a release + candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to + both remotes). That tag alone triggers a beta-track build — + "freezing" is just pausing pushes to `main` while you test it, not a + branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. +3. Once an RC has gone without issues, tag the real release: + `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the + signed stable release build. ## Tracks, from a user's perspective @@ -58,14 +50,15 @@ bakery update --all # pull the latest build on your current track | Track | What it is | Published from | |--------|-----------|-----------------| -| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | -| `beta` | Current stabilization freeze | `beta`, on every push | -| `dev` | Bleeding edge | `dev`, on every push | +| `stable` | The last tagged release | a `vX.Y.Z` tag | +| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | +| `dev` | Bleeding edge | `main`, on every push | -Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / -`-beta.…`) from the latest published stable tag, so they always sort as -newer than what you have installed — no manual version bumping needed when -pushing to `dev` or `beta`. +Dev versions are auto-computed (`X.Y.Z-dev.+`) from the +latest published stable tag, so they always sort as newer than what you +have installed — no manual version bumping needed. Beta versions are just +the RC tag itself (already valid semver, already sorts below the real +release it's a candidate for). ## Local development @@ -76,10 +69,10 @@ cargo test --release ## CI -- `dev-release.yml` — triggered on push to `dev`. -- `beta-release.yml` — triggered on push to `beta`. -- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. -- `package.yml` — publishes to the `[breadway]` pacman repo, also tag-triggered. +- `dev-release.yml` — triggered on push to `main`. +- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggered on any other `v*` tag push, cuts the actual + stable release. All CI runs on a self-hosted runner; nothing runs automatically on plain commits or PRs beyond the track builds above. See From d8c766abe469109c3d5c7236b35f240b7397c402 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 14:30:01 +0800 Subject: [PATCH 25/85] Repoint bread-ecosystem/bread git deps from dev to main branch --- Cargo.lock | 10 +++++----- Cargo.toml | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b48bacd..0dd43cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,7 +153,7 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" dependencies = [ "anyhow", "bread-utils", @@ -174,7 +174,7 @@ dependencies = [ [[package]] name = "bread-shared" version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?branch=dev#34854f147135143ca4baf1d628e8c609e2c1cc8c" +source = "git+https://git.breadway.dev/Breadway/bread?branch=main#b9e253074355c5f00f80e757ee8b5d2c5ac19328" dependencies = [ "dirs 6.0.0", "serde", @@ -185,7 +185,7 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" +source = "git+https://github.com/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" dependencies = [ "dirs 5.0.1", "gtk4", @@ -196,7 +196,7 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" dependencies = [ "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", "dirs 5.0.1", @@ -210,7 +210,7 @@ version = "0.3.0" dependencies = [ "anyhow", "bread-screenshots", - "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=dev)", + "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=main)", "bread-theme", "bread-utils", "clap", diff --git a/Cargo.toml b/Cargo.toml index 23acc2c..4748a93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,15 +10,15 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "dev", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev", features = ["bread-client"] } -bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "dev" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main", features = ["bread-client"] } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "main" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } From be0b54e1ea019dc87e5b03092d184f86a847895f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:40:24 +0800 Subject: [PATCH 26/85] Pin ecosystem crates off floating main; bakery pulls bread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bread-theme moves to git.breadway.dev v0.7.1. bread-utils and bread-screenshots pin ecosystem origin/main (v0.7.1 has no BreadClient::request and predates the screenshots crate). bread-shared pins bread v0.8.0-rc.1 — latest existing tag with widget wire types (v0.7.0 has none; v0.8.0 was never cut). bakery.toml sets bread_deps = ["bread"] so the daemon is installed alongside the bar. CLAUDE.md records the single-trunk workflow and the bar / notifications / OSD / widgets split. --- .gitignore | 3 --- CLAUDE.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 16 +++++++++------- Cargo.toml | 13 +++++++++---- README.md | 1 + bakery.toml | 2 +- 6 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 7660082..816e2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,3 @@ logs/ # Internal design documents (not for distribution) aster-brief.md - -# Local hygiene notes (not for commit) -CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f249380 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a +short map of the binary. It is not user-facing project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem. + +When starting work on a new feature, create branch `feature/`. +When working on a bug or issue, create branch `fix/`. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. + +## CI +- `dev-release.yml` triggers on `push: branches: ['main']`. +- `rc-release.yml` triggers on `vX.Y.Z-rc.N` tag pushes (beta track). +- `release.yml` triggers on any other `v*` tag push (stable). + None of these run on plain commits or PRs beyond what's listed. + +## Architecture + +One GTK4/`relm4` binary, four surfaces: + +| Area | Path | Role | +|---|---|---| +| Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | +| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups | +| OSD | `src/osd.rs` | Volume/brightness overlay | +| Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` | + +`--screenshot` (`src/screenshot.rs`) captures those views through +`bread-screenshots`; do not rewrite it just to retarget the crate pin. + +`application_id` drift vs Hyprland layer-rules/tour docs is known — leave it +unless every mention is updated in the same change. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't rewrite the widget system or `screenshot.rs` as part of pin/docs work. diff --git a/Cargo.lock b/Cargo.lock index 0dd43cc..de799e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,7 +153,7 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" dependencies = [ "anyhow", "bread-utils", @@ -173,19 +173,20 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?branch=main#b9e253074355c5f00f80e757ee8b5d2c5ac19328" +version = "0.8.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0-rc.1#2485e1af1f941c724461c0d59416c829ded638fe" dependencies = [ "dirs 6.0.0", "serde", "serde_json", "toml 0.8.23", + "uuid", ] [[package]] name = "bread-theme" version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs 5.0.1", "gtk4", @@ -196,9 +197,9 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" dependencies = [ - "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", + "bread-shared 0.7.0", "dirs 5.0.1", "serde", "serde_json", @@ -210,7 +211,7 @@ version = "0.3.0" dependencies = [ "anyhow", "bread-screenshots", - "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=main)", + "bread-shared 0.8.0", "bread-theme", "bread-utils", "clap", @@ -1922,6 +1923,7 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 4748a93..1d06a30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,15 +10,20 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main", features = ["bread-client"] } -bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "main" } +# v0.7.1's BreadClient has no `request`; crate added after that tag too. +# Pin origin/main rather than inventing a tag or floating on branch = "main". +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d", features = ["bread-client"] } +# v0.7.0 has no `bread_shared::widget`; v0.8.0 was never cut. Latest +# existing tag that carries the wire types breadbar deserializes. +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" } +# Same rev as bread-utils: v0.7.1 predates this crate (added 2026-07-23). +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/README.md b/README.md index d8b3f9e..474eafa 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Example — change the font size: | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | | `src/osd.rs` | Volume/brightness on-screen display | +| `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | Stats are polled every 2 seconds. Bluetooth and WiFi are sampled every 16 seconds and cached in between to avoid hammering D-Bus and `iw`. diff --git a/bakery.toml b/bakery.toml index 1b305d5..0226f34 100644 --- a/bakery.toml +++ b/bakery.toml @@ -3,7 +3,7 @@ description = "Minimal status bar and notification daemon for Hyprland" binaries = ["breadbar"] system_deps = ["gtk4", "gtk4-layer-shell", "wireplumber", "pipewire-pulse", "brightnessctl", "iw"] optional_system_deps = ["hyprland"] -bread_deps = [] +bread_deps = ["bread"] license_file = "LICENSE" [config] From 92f2e52c1fe40526a96a9a28bcd0599f4777a7da Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 27/85] Rename CLAUDE.md to AGENTS.md --- AGENTS.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5acf2c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# AGENTS.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a +short map of the binary. It is not user-facing project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem. + +When starting work on a new feature, create branch `feature/`. +When working on a bug or issue, create branch `fix/`. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. + +## CI +- `dev-release.yml` triggers on `push: branches: ['main']`. +- `rc-release.yml` triggers on `vX.Y.Z-rc.N` tag pushes (beta track). +- `release.yml` triggers on any other `v*` tag push (stable). + None of these run on plain commits or PRs beyond what's listed. + +## Architecture + +One GTK4/`relm4` binary, four surfaces: + +| Area | Path | Role | +|---|---|---| +| Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | +| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups | +| OSD | `src/osd.rs` | Volume/brightness overlay | +| Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` | + +`--screenshot` (`src/screenshot.rs`) captures those views through +`bread-screenshots`; do not rewrite it just to retarget the crate pin. + +`application_id` drift vs Hyprland layer-rules/tour docs is known — leave it +unless every mention is updated in the same change. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't rewrite the widget system or `screenshot.rs` as part of pin/docs work. From 89f7a93e8ade541e8b70fd2fa7a774d823e217b1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:04:00 +0800 Subject: [PATCH 28/85] Remove CLAUDE.md (renamed to AGENTS.md) --- CLAUDE.md | 51 --------------------------------------------------- 1 file changed, 51 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f249380..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,51 +0,0 @@ -# CLAUDE.md — Repo hygiene - -Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a -short map of the binary. It is not user-facing project documentation. - -This repo follows the branch/release workflow documented in `CONTRIBUTING.md` -— read and follow it for any git, branch, or release work here (the -single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, -etc). Don't improvise a different workflow. The short version: there is one -long-lived branch, `main` — no `dev` or `beta` branch exists. `main` -auto-publishes a dev-track build on every push. "Beta" and "stable" are both -just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track -build, push a plain `vX.Y.Z` tag to cut the signed stable release. -"Freezing" for stabilization means pausing pushes to `main`, not moving a -branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model -after `main` was found to have silently rotted out of sync with `dev`/`beta` -across most repos in this ecosystem. - -When starting work on a new feature, create branch `feature/`. -When working on a bug or issue, create branch `fix/`. - -## Remotes -- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. -- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. - -## CI -- `dev-release.yml` triggers on `push: branches: ['main']`. -- `rc-release.yml` triggers on `vX.Y.Z-rc.N` tag pushes (beta track). -- `release.yml` triggers on any other `v*` tag push (stable). - None of these run on plain commits or PRs beyond what's listed. - -## Architecture - -One GTK4/`relm4` binary, four surfaces: - -| Area | Path | Role | -|---|---|---| -| Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | -| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups | -| OSD | `src/osd.rs` | Volume/brightness overlay | -| Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` | - -`--screenshot` (`src/screenshot.rs`) captures those views through -`bread-screenshots`; do not rewrite it just to retarget the crate pin. - -`application_id` drift vs Hyprland layer-rules/tour docs is known — leave it -unless every mention is updated in the same change. - -## Don't -- Don't embed credentials in remote URLs — SSH or a credential helper only. -- Don't rewrite the widget system or `screenshot.rs` as part of pin/docs work. From 614dca71af5d226255234b89a9df30d433a2918a Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 19:12:27 +0800 Subject: [PATCH 29/85] ci: port breadbar onto shared bread-ecosystem build system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegates dev/rc/release build steps to bread-ecosystem's pinned Arch container via ci/build.sh, pinned to bread-ecosystem commit 147cfbbf96ae4b171027defa1130d2caddb934b1. Adds a check.yml workflow for fast clippy/test signal on feature/fix branches, matching the pattern proven in breadpad. No ci/deps.txt needed — breadbar's dependencies (zbus, resvg, gtk4-layer-shell, hyprland) are all pure Rust or already covered by the shared image's package set. --- .forgejo/workflows/check.yml | 24 ++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 2 +- .forgejo/workflows/rc-release.yml | 2 +- .forgejo/workflows/release.yml | 2 +- ci/bread-ecosystem.rev | 1 + ci/build.sh | 21 +++++++++++++++++++++ 6 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .forgejo/workflows/check.yml create mode 100644 ci/bread-ecosystem.rev create mode 100755 ci/build.sh diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..9bbb301 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +# Fast-fail lint/test on short-lived work branches, before it ever reaches +# main and triggers a dev-track release build. +on: + push: + branches: ['feature/**', 'fix/**'] + +jobs: + check: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: clippy + run: cd src && bash ci/build.sh cargo clippy --all-targets --locked -- -D warnings + + - name: test + run: cd src && bash ci/build.sh cargo test --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 421489e..13f0666 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -19,7 +19,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: compute dev version run: | diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml index c05a6dc..769b700 100644 --- a/.forgejo/workflows/rc-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -21,7 +21,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 48d3dbc..65b0c30 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..34e7aa9 --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +147cfbbf96ae4b171027defa1130d2caddb934b1 diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..2d59cfe --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Delegates to bread-ecosystem's shared CI build image/script, pinned to +# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI +# files now affect every product's release pipeline, so bumping the pin +# is a deliberate act instead of silent drift (see the bread-theme test +# that broke here for exactly that reason, before it was pinned by rev). +# +# Usage: ci/build.sh cargo build --release --locked +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" + +CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" +if [ ! -d "$CACHE_DIR" ]; then + rm -rf /tmp/bread-ecosystem-ci-* + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" + git -C "$CACHE_DIR" checkout --quiet "$REV" +fi + +bash "${CACHE_DIR}/ci/build.sh" breadbar "$ROOT" "$@" From 465088dc55ec5e58ec5561b4fb236b17939372a4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:54:04 +0800 Subject: [PATCH 30/85] Add notification history; pin ecosystem crates to v0.7.2 Keep a bounded in-memory history (last 50) and a layer-shell window listing app, summary, truncated body, and time. Toggle with `breadbar --history` or D-Bus `dev.breadway.Bar.ToggleHistory`. Pin bread-theme, bread-utils, and bread-screenshots to tag v0.7.2. bread-shared stays on bread v0.8.0-rc.1. --- AGENTS.md | 2 +- Cargo.lock | 12 +- Cargo.toml | 13 +- README.md | 4 +- src/main.rs | 11 ++ src/notifications/history.rs | 274 +++++++++++++++++++++++++++++++++++ src/notifications/mod.rs | 75 +++++++++- src/notifications/popup.rs | 11 +- src/screenshot.rs | 5 + src/theme.rs | 8 + 10 files changed, 394 insertions(+), 21 deletions(-) create mode 100644 src/notifications/history.rs diff --git a/AGENTS.md b/AGENTS.md index 5acf2c9..1207e2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ One GTK4/`relm4` binary, four surfaces: | Area | Path | Role | |---|---|---| | Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | -| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups | +| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups + in-memory history (`breadbar --history`) | | OSD | `src/osd.rs` | Volume/brightness overlay | | Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` | diff --git a/Cargo.lock b/Cargo.lock index de799e0..7c03af7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,8 +152,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "anyhow", "bread-utils", @@ -185,8 +185,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "dirs 5.0.1", "gtk4", @@ -196,8 +196,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared 0.7.0", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index 1d06a30..ffa6d25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,20 +10,17 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -# v0.7.1's BreadClient has no `request`; crate added after that tag too. -# Pin origin/main rather than inventing a tag or floating on branch = "main". -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d", features = ["bread-client"] } -# v0.7.0 has no `bread_shared::widget`; v0.8.0 was never cut. Latest -# existing tag that carries the wire types breadbar deserializes. +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +# v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if +# ecosystem crates move independently. bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -# Same rev as bread-utils: v0.7.1 predates this crate (added 2026-07-23). -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/README.md b/README.md index 474eafa..0047613 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.) - Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) - Supports `CloseNotification` and `replaces_id` +- In-memory history of the last 50 notifications (app, summary, truncated body, time). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. Not persisted. **Volume/brightness OSD**: @@ -138,8 +139,9 @@ Example — change the font size: | `src/bar/wifi.rs` | WiFi details popover, `breadcrumbs` profile/scan integration | | `src/bar/control.rs` | Control panel data: volume (`wpctl`), brightness (`brightnessctl`), sinks (`pactl`) | | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | -| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | +| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service + `dev.breadway.Bar` history IPC | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | +| `src/notifications/history.rs` | Bounded in-memory history and layer-shell history window | | `src/osd.rs` | Volume/brightness on-screen display | | `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | diff --git a/src/main.rs b/src/main.rs index 58a70c9..0ce99a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1481,6 +1481,17 @@ fn stat_label() -> gtk4::Label { fn main() { use clap::Parser; let cli = screenshot::Cli::parse(); + if cli.history { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + if let Err(e) = rt.block_on(notifications::toggle_history_remote()) { + eprintln!("breadbar: could not toggle history (is breadbar running?): {e}"); + std::process::exit(1); + } + return; + } let screenshot_req = cli.screenshot_request(); relm4::spawn(async { diff --git a/src/notifications/history.rs b/src/notifications/history.rs new file mode 100644 index 0000000..711f229 --- /dev/null +++ b/src/notifications/history.rs @@ -0,0 +1,274 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +use super::Urgency; + +pub const LIMIT: usize = 50; +const BODY_MAX_CHARS: usize = 96; + +pub type Store = Arc>>; + +#[derive(Debug, Clone)] +pub struct Entry { + pub id: u32, + pub app_name: String, + pub summary: String, + pub body: String, + pub urgency: Urgency, + pub received: SystemTime, +} + +pub struct Ui { + pub window: gtk4::Window, + pub list: gtk4::Box, + pub store: Store, +} + +pub fn new_store() -> Store { + Arc::new(Mutex::new(VecDeque::new())) +} + +/// Insert or replace by `id`, newest first. Drops anything past [`LIMIT`]. +pub fn record(store: &Store, entry: Entry) { + let mut hist = store.lock().unwrap(); + if let Some(pos) = hist.iter().position(|e| e.id == entry.id) { + hist.remove(pos); + } + hist.push_front(entry); + while hist.len() > LIMIT { + hist.pop_back(); + } +} + +pub fn build_window(store: Store) -> Ui { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-history"); + window.init_layer_shell(); + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, 48); + window.set_margin(Edge::Right, 20); + window.set_default_width(360); + window.set_keyboard_mode(KeyboardMode::OnDemand); + + let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + outer.set_margin_top(10); + outer.set_margin_bottom(10); + outer.set_margin_start(10); + outer.set_margin_end(10); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let title = gtk4::Label::new(Some("Notifications")); + title.add_css_class("history-title"); + title.set_xalign(0.0); + title.set_hexpand(true); + header.append(&title); + + let close_btn = gtk4::Button::with_label("Close"); + close_btn.add_css_class("flat"); + close_btn.add_css_class("history-close"); + let win_close = window.clone(); + close_btn.connect_clicked(move |_| { + win_close.set_visible(false); + }); + header.append(&close_btn); + outer.append(&header); + + let list = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + let scroll = gtk4::ScrolledWindow::new(); + scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + scroll.set_propagate_natural_height(true); + scroll.set_max_content_height(480); + scroll.set_min_content_width(320); + scroll.set_child(Some(&list)); + outer.append(&scroll); + + window.set_child(Some(&outer)); + + let win_esc = window.clone(); + let keys = gtk4::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, _| { + if key == gtk4::gdk::Key::Escape { + win_esc.set_visible(false); + gtk4::glib::Propagation::Stop + } else { + gtk4::glib::Propagation::Proceed + } + }); + window.add_controller(keys); + + window.connect_close_request(|w| { + w.set_visible(false); + gtk4::glib::Propagation::Stop + }); + + Ui { + window, + list, + store, + } +} + +pub fn toggle(ui: &Ui) { + if ui.window.is_visible() { + ui.window.set_visible(false); + } else { + rebuild(&ui.list, &ui.store); + ui.window.set_visible(true); + } +} + +pub fn refresh_if_visible(ui: &Ui) { + if ui.window.is_visible() { + rebuild(&ui.list, &ui.store); + } +} + +pub fn rebuild(list: >k4::Box, store: &Store) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + + let entries: Vec = store.lock().unwrap().iter().cloned().collect(); + if entries.is_empty() { + let empty = gtk4::Label::new(Some("No notifications yet")); + empty.add_css_class("history-empty"); + empty.set_xalign(0.0); + list.append(&empty); + return; + } + + for entry in entries { + list.append(&make_row(&entry)); + } +} + +fn make_row(entry: &Entry) -> gtk4::Box { + let card = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + card.add_css_class("notification-card"); + card.add_css_class("history-card"); + if let Some(class) = entry.urgency.css_class() { + card.add_css_class(class); + } + + let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let show_app = !entry.app_name.is_empty() + && !entry.app_name.eq_ignore_ascii_case(&entry.summary); + if show_app { + let app = gtk4::Label::new(Some(&entry.app_name)); + app.add_css_class("notification-app"); + app.set_xalign(0.0); + app.set_hexpand(true); + app.set_ellipsize(gtk4::pango::EllipsizeMode::End); + top.append(&app); + } else { + let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + spacer.set_hexpand(true); + top.append(&spacer); + } + let time = gtk4::Label::new(Some(&format_time(entry.received))); + time.add_css_class("history-time"); + time.set_xalign(1.0); + top.append(&time); + card.append(&top); + + if !entry.summary.is_empty() { + let summary = gtk4::Label::new(Some(&entry.summary)); + summary.add_css_class("notification-summary"); + summary.set_xalign(0.0); + summary.set_wrap(true); + summary.set_wrap_mode(gtk4::pango::WrapMode::WordChar); + card.append(&summary); + } + + let body = collapse_ws(&entry.body); + if !body.is_empty() { + let body_lbl = gtk4::Label::new(Some(&truncate(&body, BODY_MAX_CHARS))); + body_lbl.add_css_class("notification-body"); + body_lbl.add_css_class("history-body"); + body_lbl.set_xalign(0.0); + body_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + body_lbl.set_max_width_chars(48); + card.append(&body_lbl); + } + + card +} + +fn format_time(received: SystemTime) -> String { + let Ok(dur) = received.duration_since(SystemTime::UNIX_EPOCH) else { + return "--:--".into(); + }; + let Ok(dt) = gtk4::glib::DateTime::from_unix_local(dur.as_secs() as i64) else { + return "--:--".into(); + }; + dt.format("%H:%M") + .map(|s| s.to_string()) + .unwrap_or_else(|_| "--:--".into()) +} + +fn collapse_ws(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +fn truncate(s: &str, max_chars: usize) -> String { + let mut chars = s.chars(); + let taken: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{taken}…") + } else { + taken + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: u32, summary: &str) -> Entry { + Entry { + id, + app_name: "app".into(), + summary: summary.into(), + body: String::new(), + urgency: Urgency::Normal, + received: SystemTime::UNIX_EPOCH, + } + } + + #[test] + fn record_is_newest_first_and_bounded() { + let store = new_store(); + for i in 0..(LIMIT as u32 + 5) { + record(&store, entry(i, &format!("n{i}"))); + } + let hist = store.lock().unwrap(); + assert_eq!(hist.len(), LIMIT); + assert_eq!(hist.front().unwrap().id, LIMIT as u32 + 4); + assert_eq!(hist.back().unwrap().id, 5); + } + + #[test] + fn record_replaces_same_id_and_moves_to_front() { + let store = new_store(); + record(&store, entry(1, "old")); + record(&store, entry(2, "other")); + record(&store, entry(1, "new")); + let hist = store.lock().unwrap(); + assert_eq!(hist.len(), 2); + assert_eq!(hist[0].id, 1); + assert_eq!(hist[0].summary, "new"); + assert_eq!(hist[1].id, 2); + } + + #[test] + fn truncate_adds_ellipsis_past_limit() { + assert_eq!(truncate("hello", 10), "hello"); + assert_eq!(truncate("hello world", 5), "hello…"); + } +} diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index 0c25c0d..ccc8125 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -1,9 +1,10 @@ +pub mod history; pub mod popup; use std::collections::HashMap; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Mutex; -use std::time::Duration; +use std::time::{Duration, SystemTime}; use tokio::sync::mpsc; use zbus::zvariant::OwnedValue; @@ -37,6 +38,7 @@ pub enum NotifEvent { expire: Expire, }, Close(u32), + ToggleHistory, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -93,6 +95,34 @@ struct NotifServer { /// (app_name, synchronous-hint tag) -> id, for senders relying on /// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`. sync_tags: Mutex>, + history: history::Store, +} + +/// Private breadbar control surface on the same connection as +/// `org.freedesktop.Notifications`. `breadbar --history` is a one-shot +/// client of `ToggleHistory` — there is no other IPC. +struct BarService { + tx: mpsc::Sender, +} + +#[zbus::interface(name = "dev.breadway.Bar")] +impl BarService { + async fn toggle_history(&self) { + let _ = self.tx.send(NotifEvent::ToggleHistory).await; + } +} + +const BAR_DEST: &str = "org.freedesktop.Notifications"; +const BAR_PATH: &str = "/dev/breadway/Bar"; +const BAR_IFACE: &str = "dev.breadway.Bar"; + +/// Ask a running breadbar to toggle the history window. Used by +/// `breadbar --history`; does not start a second bar. +pub async fn toggle_history_remote() -> zbus::Result<()> { + let conn = zbus::Connection::session().await?; + conn.call_method(Some(BAR_DEST), BAR_PATH, Some(BAR_IFACE), "ToggleHistory", &()) + .await?; + Ok(()) } #[zbus::interface(name = "org.freedesktop.Notifications")] @@ -140,6 +170,18 @@ impl NotifServer { let urgency = Urgency::from_hint(hints.get("urgency")); let expire = compute_expire(expire_timeout, urgency == Urgency::Critical); + history::record( + &self.history, + history::Entry { + id, + app_name: app_name.to_string(), + summary: summary.to_string(), + body: body.to_string(), + urgency, + received: SystemTime::now(), + }, + ); + let _ = self .tx .send(NotifEvent::Show { @@ -217,18 +259,22 @@ pub fn spawn(sample: Option) -> gtk4::Window { let _ = tx.try_send(kind.sample_event()); let window_for_loop = window.clone(); relm4::spawn_local(async move { - popup::run(window_for_loop, cards_box, rx, None).await; + popup::run(window_for_loop, cards_box, rx, None, None).await; }); } None => { let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); + let store = history::new_store(); + let history_ui = history::build_window(store.clone()); relm4::spawn(async move { let server = NotifServer { - tx, + tx: tx.clone(), next_id: AtomicU32::new(1), sync_tags: Mutex::new(HashMap::new()), + history: store, }; + let bar = BarService { tx }; // Builder failures here would only occur with invalid static strings — safe to unwrap. let conn = zbus::connection::Builder::session() .unwrap() @@ -236,6 +282,8 @@ pub fn spawn(sample: Option) -> gtk4::Window { .unwrap() .serve_at("/org/freedesktop/Notifications", server) .unwrap() + .serve_at(BAR_PATH, bar) + .unwrap() .build() .await .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); @@ -249,7 +297,8 @@ pub fn spawn(sample: Option) -> gtk4::Window { let window_for_loop = window.clone(); relm4::spawn_local(async move { if let Ok(conn) = conn_rx.await { - popup::run(window_for_loop, cards_box, rx, Some(conn)).await; + popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)) + .await; } }); } @@ -303,6 +352,7 @@ mod tests { tx, next_id: AtomicU32::new(1), sync_tags: Mutex::new(HashMap::new()), + history: history::new_store(), }, rx, ) @@ -394,4 +444,21 @@ mod tests { .await; assert_ne!(first, second); } + + #[tokio::test] + async fn notify_records_history_newest_first() { + let (server, _rx) = test_server(); + server + .notify("app-a", 0, "", "first", "body-a", vec![], HashMap::new(), -1) + .await; + server + .notify("app-b", 0, "", "second", "body-b", vec![], HashMap::new(), -1) + .await; + let hist = server.history.lock().unwrap(); + assert_eq!(hist.len(), 2); + assert_eq!(hist[0].summary, "second"); + assert_eq!(hist[0].app_name, "app-b"); + assert_eq!(hist[0].body, "body-b"); + assert_eq!(hist[1].summary, "first"); + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 91adec0..9ec29af 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -4,7 +4,7 @@ use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::{Expire, NotifEvent, Urgency}; +use super::{history, Expire, NotifEvent, Urgency}; type Cards = Rc>>; // Bumped every time an id gets a (re)placed card — an auto-dismiss timer @@ -49,6 +49,7 @@ pub async fn run( cards_box: gtk4::Box, mut rx: Receiver, conn: Option, + history_ui: Option, ) { let cards: Cards = Rc::new(RefCell::new(HashMap::new())); let generations: Generations = Rc::new(RefCell::new(HashMap::new())); @@ -71,6 +72,9 @@ pub async fn run( cards_box.prepend(&card); cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); + if let Some(ui) = &history_ui { + history::refresh_if_visible(ui); + } let my_generation = { let mut gens = generations.borrow_mut(); @@ -105,6 +109,11 @@ pub async fn run( emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await; } } + NotifEvent::ToggleHistory => { + if let Some(ui) = &history_ui { + history::toggle(ui); + } + } } } } diff --git a/src/screenshot.rs b/src/screenshot.rs index 8ccdd87..ecf75b3 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -74,6 +74,11 @@ pub struct Cli { /// Capture canvas height — see `width`. #[arg(long, default_value_t = 1080)] pub height: u32, + + /// Toggle the in-memory notification history on a running breadbar, then + /// exit. Keybind-friendly; does not start a second instance. + #[arg(long)] + pub history: bool, } pub struct ScreenshotRequest { diff --git a/src/theme.rs b/src/theme.rs index 10c2415..0e7186f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -36,12 +36,20 @@ fn load_css() -> String { .bt-icon {{ margin-right: 14px; }}\ separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\ window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ + window.breadbar-history {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg};\ + border-radius: {radius}; }}\ .notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ .notification-card.urgency-critical {{ border-left-color: {critical}; }}\ .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ .notification-summary {{ font-weight: bold; }}\ .notification-app {{ opacity: 0.6; }}\ + .history-title {{ font-weight: bold; font-size: 13px; }}\ + .history-close {{ padding: 2px 8px; }}\ + .history-empty {{ opacity: 0.5; padding: 8px 0; }}\ + .history-time {{ opacity: 0.5; font-size: 11px; }}\ + .history-body {{ opacity: 0.75; }}\ + .history-card {{ margin-bottom: 6px; }}\ window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ .osd-icon-muted {{ opacity: 0.35; }}\ From 9691485bd64d9493009063278687a685e7fc899c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:05:47 +0800 Subject: [PATCH 31/85] Bump version to v0.3.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c03af7..1a1122a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,7 +207,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "bread-screenshots", diff --git a/Cargo.toml b/Cargo.toml index ffa6d25..1648851 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.3.0" +version = "0.3.1" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" From 4fddad510fa43155e1fe27c2c2dcb429d4616a0d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:11:28 +0800 Subject: [PATCH 32/85] Persist notification history to XDG state --- README.md | 4 +- src/notifications/history.rs | 180 ++++++++++++++++++++++++++++++++++- src/notifications/mod.rs | 12 ++- 3 files changed, 191 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0047613..3addfa1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.) - Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) - Supports `CloseNotification` and `replaces_id` -- In-memory history of the last 50 notifications (app, summary, truncated body, time). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. Not persisted. +- History of the last 50 notifications (app, summary, truncated body, time). Loaded from and saved to `$XDG_STATE_HOME/breadbar/history.json` (typically `~/.local/state/breadbar/history.json`). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. **Volume/brightness OSD**: @@ -141,7 +141,7 @@ Example — change the font size: | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service + `dev.breadway.Bar` history IPC | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | -| `src/notifications/history.rs` | Bounded in-memory history and layer-shell history window | +| `src/notifications/history.rs` | Bounded history (last 50, persisted under XDG state) and layer-shell history window | | `src/osd.rs` | Volume/brightness on-screen display | | `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 711f229..0e158cc 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -1,9 +1,12 @@ use std::collections::VecDeque; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use serde::{Deserialize, Serialize}; use super::Urgency; @@ -32,6 +35,30 @@ pub fn new_store() -> Store { Arc::new(Mutex::new(VecDeque::new())) } +/// Load the last [`LIMIT`] entries from `$XDG_STATE_HOME/breadbar/history.json` +/// (or `~/.local/state/breadbar/history.json`). Missing or corrupt files +/// yield an empty store — never fail startup. +pub fn load_store() -> Store { + let store = new_store(); + if let Some(path) = history_path() { + load_into(&store, &path); + } + store +} + +/// Next D-Bus notification id so persisted rows are not replaced on restart. +pub fn next_id(store: &Store) -> u32 { + store + .lock() + .unwrap() + .iter() + .map(|e| e.id) + .max() + .unwrap_or(0) + .saturating_add(1) + .max(1) +} + /// Insert or replace by `id`, newest first. Drops anything past [`LIMIT`]. pub fn record(store: &Store, entry: Entry) { let mut hist = store.lock().unwrap(); @@ -44,6 +71,106 @@ pub fn record(store: &Store, entry: Entry) { } } +/// Best-effort write of the in-memory store (already bounded) to the +/// XDG state file. Failures are silent — history stays in memory. +pub fn persist(store: &Store) { + if let Some(path) = history_path() { + let _ = persist_to(store, &path); + } +} + +fn history_path() -> Option { + Some(state_dir()?.join("history.json")) +} + +fn state_dir() -> Option { + if let Ok(xdg) = std::env::var("XDG_STATE_HOME") { + if !xdg.is_empty() { + return Some(PathBuf::from(xdg).join("breadbar")); + } + } + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".local/state/breadbar")) +} + +#[derive(Serialize, Deserialize)] +struct PersistedEntry { + id: u32, + app_name: String, + summary: String, + body: String, + urgency: String, + received_unix: u64, +} + +fn urgency_name(u: Urgency) -> &'static str { + match u { + Urgency::Low => "low", + Urgency::Normal => "normal", + Urgency::Critical => "critical", + } +} + +fn urgency_from_name(s: &str) -> Urgency { + match s { + "low" => Urgency::Low, + "critical" => Urgency::Critical, + _ => Urgency::Normal, + } +} + +fn to_persisted(entry: &Entry) -> PersistedEntry { + let received_unix = entry + .received + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + PersistedEntry { + id: entry.id, + app_name: entry.app_name.clone(), + summary: entry.summary.clone(), + body: entry.body.clone(), + urgency: urgency_name(entry.urgency).into(), + received_unix, + } +} + +fn from_persisted(entry: PersistedEntry) -> Entry { + Entry { + id: entry.id, + app_name: entry.app_name, + summary: entry.summary, + body: entry.body, + urgency: urgency_from_name(&entry.urgency), + received: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.received_unix), + } +} + +fn persist_to(store: &Store, path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let payload: Vec = store.lock().unwrap().iter().map(to_persisted).collect(); + let bytes = serde_json::to_vec(&payload).map_err(std::io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, bytes)?; + fs::rename(&tmp, path) +} + +fn load_into(store: &Store, path: &Path) { + let Ok(bytes) = fs::read(path) else { + return; + }; + let Ok(parsed) = serde_json::from_slice::>(&bytes) else { + return; + }; + let mut hist = store.lock().unwrap(); + hist.clear(); + for entry in parsed.into_iter().take(LIMIT) { + hist.push_back(from_persisted(entry)); + } +} + pub fn build_window(store: Store) -> Ui { let window = gtk4::Window::new(); window.add_css_class("breadbar-history"); @@ -271,4 +398,55 @@ mod tests { assert_eq!(truncate("hello", 10), "hello"); assert_eq!(truncate("hello world", 5), "hello…"); } + + #[test] + fn persist_roundtrip_keeps_newest_first_and_bound() { + let dir = std::env::temp_dir().join(format!( + "breadbar-history-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("history.json"); + let store = new_store(); + for i in 0..(LIMIT as u32 + 3) { + record(&store, entry(i, &format!("n{i}"))); + } + persist_to(&store, &path).unwrap(); + + let loaded = new_store(); + load_into(&loaded, &path); + assert_eq!(next_id(&loaded), LIMIT as u32 + 3); + let hist = loaded.lock().unwrap(); + assert_eq!(hist.len(), LIMIT); + assert_eq!(hist.front().unwrap().id, LIMIT as u32 + 2); + assert_eq!( + hist.front().unwrap().summary, + format!("n{}", LIMIT as u32 + 2) + ); + drop(hist); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn load_into_ignores_corrupt_file() { + let dir = std::env::temp_dir().join(format!( + "breadbar-history-bad-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("history.json"); + fs::write(&path, "not-json").unwrap(); + let store = new_store(); + load_into(&store, &path); + assert!(store.lock().unwrap().is_empty()); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index ccc8125..eccc2e7 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -96,6 +96,8 @@ struct NotifServer { /// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`. sync_tags: Mutex>, history: history::Store, + /// Unit tests leave this off so `Notify` does not write `$XDG_STATE_HOME`. + persist_history: bool, } /// Private breadbar control surface on the same connection as @@ -181,6 +183,9 @@ impl NotifServer { received: SystemTime::now(), }, ); + if self.persist_history { + history::persist(&self.history); + } let _ = self .tx @@ -264,15 +269,17 @@ pub fn spawn(sample: Option) -> gtk4::Window { } None => { let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); - let store = history::new_store(); + let store = history::load_store(); + let next_id = history::next_id(&store); let history_ui = history::build_window(store.clone()); relm4::spawn(async move { let server = NotifServer { tx: tx.clone(), - next_id: AtomicU32::new(1), + next_id: AtomicU32::new(next_id), sync_tags: Mutex::new(HashMap::new()), history: store, + persist_history: true, }; let bar = BarService { tx }; // Builder failures here would only occur with invalid static strings — safe to unwrap. @@ -353,6 +360,7 @@ mod tests { next_id: AtomicU32::new(1), sync_tags: Mutex::new(HashMap::new()), history: history::new_store(), + persist_history: false, }, rx, ) From 110f0cd3df6878f7b105fafe4983ff24b7262c83 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:06 +0800 Subject: [PATCH 33/85] Add notification actions and inline reply Parse FDO action pairs onto popup buttons, emit ActionInvoked on click (default action on the body), and show an inline-reply field when senders request it. Closing still emits NotificationClosed. History persist is unchanged. --- src/notifications/mod.rs | 211 +++++++++++++++++++++++++++++++-- src/notifications/popup.rs | 231 ++++++++++++++++++++++++++++++++++--- src/theme.rs | 4 + 3 files changed, 422 insertions(+), 24 deletions(-) diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index eccc2e7..4668798 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -17,6 +17,28 @@ use zbus::zvariant::OwnedValue; /// warning) — it just piles up a new card next to it forever. const SYNCHRONOUS_HINT: &str = "x-canonical-private-synchronous"; +/// Spec + GNOME/KDE reserved action id for an inline reply field. Hidden +/// from the button row; submitting the field emits `NotificationReplied` +/// (and `ActionInvoked` with this key). See `popup::emit_replied`. +pub const INLINE_REPLY_KEY: &str = "inline-reply"; + +/// KDE placeholder hint. Presence (or an `inline-reply` action) is enough +/// to show the reply field — Discord/Telegram use the action, Plasma often +/// only the hint. +const KDE_REPLY_PLACEHOLDER: &str = "x-kde-reply-placeholder"; + +/// Advertised `GetCapabilities` strings. `body` is the original set; +/// `actions` / `inline-reply` are this change; `body-markup` is the usual +/// companion so senders can ship ``/`` instead of stripping tags. +const CAPABILITIES: &[&str] = &["body", "body-markup", "actions", "inline-reply"]; + +/// One `(id, localized label)` pair from the Notify `actions` array. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Action { + pub key: String, + pub label: String, +} + /// How long a shown notification should stay up before auto-dismissing. /// Distinct from `Option` mainly for readability at call sites — /// `Never` covers both the spec's `expire_timeout == 0` ("never expire") @@ -36,6 +58,9 @@ pub enum NotifEvent { body: String, urgency: Urgency, expire: Expire, + actions: Vec, + /// Placeholder for the inline-reply field, if one should be shown. + inline_reply: Option, }, Close(u32), ToggleHistory, @@ -68,6 +93,36 @@ impl Urgency { } } +/// Spec: `actions` is a flat list of pairs `(id, localized label)`. An +/// unpaired trailing id is ignored. Empty keys are dropped. +fn parse_actions(raw: &[String]) -> Vec { + raw.chunks_exact(2) + .filter(|c| !c[0].is_empty()) + .map(|c| Action { + key: c[0].clone(), + label: c[1].clone(), + }) + .collect() +} + +/// Show an inline reply field when the sender asked for `inline-reply` or +/// sent the KDE placeholder hint. Placeholder text prefers the hint. +fn inline_reply_placeholder( + actions: &[Action], + hints: &HashMap, +) -> Option { + let from_hint = hints + .get(KDE_REPLY_PLACEHOLDER) + .and_then(|v| String::try_from(v.clone()).ok()) + .filter(|s| !s.is_empty()); + let has_action = actions.iter().any(|a| a.key == INLINE_REPLY_KEY); + if has_action || from_hint.is_some() { + Some(from_hint.unwrap_or_else(|| "Reply".into())) + } else { + None + } +} + /// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint /// was critical) to our internal `Expire`, per the freedesktop notification /// spec: `0` always means never expire; a negative value means "server @@ -122,8 +177,14 @@ const BAR_IFACE: &str = "dev.breadway.Bar"; /// `breadbar --history`; does not start a second bar. pub async fn toggle_history_remote() -> zbus::Result<()> { let conn = zbus::Connection::session().await?; - conn.call_method(Some(BAR_DEST), BAR_PATH, Some(BAR_IFACE), "ToggleHistory", &()) - .await?; + conn.call_method( + Some(BAR_DEST), + BAR_PATH, + Some(BAR_IFACE), + "ToggleHistory", + &(), + ) + .await?; Ok(()) } @@ -138,7 +199,7 @@ impl NotifServer { _app_icon: &str, summary: &str, body: &str, - _actions: Vec, + actions: Vec, hints: std::collections::HashMap, expire_timeout: i32, ) -> u32 { @@ -171,6 +232,8 @@ impl NotifServer { // when the sender left expire_timeout at the server-default (-1). let urgency = Urgency::from_hint(hints.get("urgency")); let expire = compute_expire(expire_timeout, urgency == Urgency::Critical); + let actions = parse_actions(&actions); + let inline_reply = inline_reply_placeholder(&actions, &hints); history::record( &self.history, @@ -196,6 +259,8 @@ impl NotifServer { body: body.to_string(), urgency, expire, + actions, + inline_reply, }) .await; id @@ -206,7 +271,7 @@ impl NotifServer { } fn get_capabilities(&self) -> Vec { - vec!["body".to_string()] + CAPABILITIES.iter().map(|s| (*s).to_string()).collect() } fn get_server_information(&self) -> (String, String, String, String) { @@ -241,6 +306,8 @@ impl SampleKind { body: "This is what a notification card looks like.".into(), urgency, expire: Expire::Never, + actions: vec![], + inline_reply: None, } } } @@ -304,8 +371,7 @@ pub fn spawn(sample: Option) -> gtk4::Window { let window_for_loop = window.clone(); relm4::spawn_local(async move { if let Ok(conn) = conn_rx.await { - popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)) - .await; + popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)).await; } }); } @@ -457,10 +523,28 @@ mod tests { async fn notify_records_history_newest_first() { let (server, _rx) = test_server(); server - .notify("app-a", 0, "", "first", "body-a", vec![], HashMap::new(), -1) + .notify( + "app-a", + 0, + "", + "first", + "body-a", + vec![], + HashMap::new(), + -1, + ) .await; server - .notify("app-b", 0, "", "second", "body-b", vec![], HashMap::new(), -1) + .notify( + "app-b", + 0, + "", + "second", + "body-b", + vec![], + HashMap::new(), + -1, + ) .await; let hist = server.history.lock().unwrap(); assert_eq!(hist.len(), 2); @@ -469,4 +553,115 @@ mod tests { assert_eq!(hist[0].body, "body-b"); assert_eq!(hist[1].summary, "first"); } + + #[test] + fn parse_actions_pairs_and_drops_trailing_id() { + let parsed = parse_actions(&[ + "default".into(), + "Open".into(), + "snooze".into(), + "Snooze".into(), + "orphan".into(), + ]); + assert_eq!( + parsed, + vec![ + Action { + key: "default".into(), + label: "Open".into(), + }, + Action { + key: "snooze".into(), + label: "Snooze".into(), + }, + ] + ); + } + + #[test] + fn parse_actions_skips_empty_keys() { + assert!(parse_actions(&["", "Nope"].map(String::from)).is_empty()); + } + + #[test] + fn inline_reply_from_action_or_kde_hint() { + let reply_action = vec![Action { + key: INLINE_REPLY_KEY.into(), + label: "Reply".into(), + }]; + assert_eq!( + inline_reply_placeholder(&reply_action, &HashMap::new()).as_deref(), + Some("Reply") + ); + assert!(inline_reply_placeholder(&[], &HashMap::new()).is_none()); + + let mut hints = HashMap::new(); + hints.insert( + KDE_REPLY_PLACEHOLDER.to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from("Write a reply…")).unwrap(), + ); + assert_eq!( + inline_reply_placeholder(&[], &hints).as_deref(), + Some("Write a reply…") + ); + // Hint wins over the generic default when both are present. + assert_eq!( + inline_reply_placeholder(&reply_action, &hints).as_deref(), + Some("Write a reply…") + ); + } + + #[test] + fn get_capabilities_includes_actions_and_inline_reply() { + let (server, _rx) = test_server(); + let caps = server.get_capabilities(); + for wanted in ["body", "body-markup", "actions", "inline-reply"] { + assert!( + caps.iter().any(|c| c == wanted), + "missing capability {wanted}" + ); + } + } + + #[tokio::test] + async fn notify_forwards_actions_and_inline_reply() { + let (server, mut rx) = test_server(); + server + .notify( + "chat", + 0, + "", + "Alice", + "hello", + vec![ + "default".into(), + "Open".into(), + INLINE_REPLY_KEY.into(), + "Reply".into(), + ], + HashMap::new(), + -1, + ) + .await; + match rx.recv().await.expect("Show event") { + NotifEvent::Show { + actions, + inline_reply, + summary, + .. + } => { + assert_eq!(summary, "Alice"); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].key, "default"); + assert_eq!(actions[1].key, INLINE_REPLY_KEY); + assert_eq!(inline_reply.as_deref(), Some("Reply")); + } + _ => panic!("expected Show, got a different event"), + } + // History persist path is unchanged: actions are UI-only, not stored. + let hist = server.history.lock().unwrap(); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].summary, "Alice"); + assert_eq!(hist[0].body, "hello"); + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 9ec29af..f221524 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -1,10 +1,10 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::{history, Expire, NotifEvent, Urgency}; +use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY}; type Cards = Rc>>; // Bumped every time an id gets a (re)placed card — an auto-dismiss timer @@ -18,7 +18,6 @@ type Generations = Rc>>; /// NotificationClosed reason codes per the freedesktop spec. mod close_reason { pub const EXPIRED: u32 = 1; - #[allow(dead_code)] // no in-app dismiss button exists yet (see make_card) pub const DISMISSED_BY_USER: u32 = 2; pub const CLOSE_NOTIFICATION_CALL: u32 = 3; } @@ -63,12 +62,26 @@ pub async fn run( body, urgency, expire, + actions, + inline_reply, } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { cards_box.remove(&old); } - let card = make_card(&app_name, &summary, &body, urgency); + let card = make_card(CardSpec { + id, + app_name: &app_name, + summary: &summary, + body: &body, + urgency, + actions: &actions, + inline_reply: inline_reply.as_deref(), + conn: conn.clone(), + cards: cards.clone(), + cards_box: cards_box.clone(), + window: window.clone(), + }); cards_box.prepend(&card); cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); @@ -96,8 +109,7 @@ pub async fn run( gtk4::glib::timeout_future(duration).await; let still_current = generations_clone.borrow().get(&id) == Some(&my_generation); - if still_current - && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) + if still_current && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) { emit_closed(&conn_clone, id, close_reason::EXPIRED).await; } @@ -164,39 +176,226 @@ fn create_window() -> gtk4::Window { window.set_margin(Edge::Top, 20); window.set_margin(Edge::Right, 20); window.set_default_width(320); + // 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); window } -fn make_card(app_name: &str, summary: &str, body: &str, urgency: Urgency) -> gtk4::Box { +struct CardSpec<'a> { + id: u32, + app_name: &'a str, + summary: &'a str, + body: &'a str, + urgency: Urgency, + actions: &'a [Action], + inline_reply: Option<&'a str>, + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, +} + +fn make_card(spec: CardSpec<'_>) -> gtk4::Box { let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4); card.add_css_class("notification-card"); - if let Some(class) = urgency.css_class() { + if let Some(class) = spec.urgency.css_class() { card.add_css_class(class); } + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + // Senders often set the title/summary to their own app name (e.g. a bare // "Spotify" notification) — showing app_name above an identical summary // is pure repetition, so skip the app label in that case. - if !app_name.is_empty() && !app_name.eq_ignore_ascii_case(summary) { - let lbl = gtk4::Label::new(Some(app_name)); + if !spec.app_name.is_empty() && !spec.app_name.eq_ignore_ascii_case(spec.summary) { + let lbl = gtk4::Label::new(Some(spec.app_name)); lbl.add_css_class("notification-app"); lbl.set_xalign(0.0); - card.append(&lbl); + content.append(&lbl); } - let summary_lbl = gtk4::Label::new(Some(summary)); + let summary_lbl = gtk4::Label::new(Some(spec.summary)); summary_lbl.add_css_class("notification-summary"); summary_lbl.set_xalign(0.0); summary_lbl.set_wrap(true); - card.append(&summary_lbl); + content.append(&summary_lbl); - if !body.is_empty() { - let body_lbl = gtk4::Label::new(Some(body)); + if !spec.body.is_empty() { + let body_lbl = gtk4::Label::new(None); body_lbl.add_css_class("notification-body"); body_lbl.set_xalign(0.0); body_lbl.set_wrap(true); - card.append(&body_lbl); + apply_body_text(&body_lbl, spec.body); + content.append(&body_lbl); + } + + if spec.actions.iter().any(|a| a.key == "default") { + content.add_css_class("notification-default"); + let gesture = gtk4::GestureClick::new(); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + gesture.connect_released(move |_, _, _, _| { + invoke_action(invoke.clone(), "default"); + }); + content.add_controller(gesture); + } + + card.append(&content); + + let visible: Vec<&Action> = spec + .actions + .iter() + .filter(|a| a.key != "default" && a.key != INLINE_REPLY_KEY) + .collect(); + if !visible.is_empty() { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-actions"); + row.set_halign(gtk4::Align::End); + for action in visible { + let btn = gtk4::Button::with_label(&action.label); + btn.add_css_class("notification-action"); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let key = action.key.clone(); + btn.connect_clicked(move |_| { + invoke_action(invoke.clone(), &key); + }); + row.append(&btn); + } + card.append(&row); + } + + if let Some(placeholder) = spec.inline_reply { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-reply"); + + let entry = gtk4::Entry::new(); + entry.add_css_class("notification-reply-entry"); + entry.set_placeholder_text(Some(placeholder)); + entry.set_hexpand(true); + + let send_label = spec + .actions + .iter() + .find(|a| a.key == INLINE_REPLY_KEY) + .map(|a| a.label.as_str()) + .filter(|l| !l.is_empty()) + .unwrap_or("Send"); + let send = gtk4::Button::with_label(send_label); + send.add_css_class("notification-action"); + + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let entry_for_btn = entry.clone(); + let invoke_btn = invoke.clone(); + send.connect_clicked(move |_| { + submit_reply(&entry_for_btn, invoke_btn.clone()); + }); + entry.connect_activate(move |e| { + submit_reply(e, invoke.clone()); + }); + + row.append(&entry); + row.append(&send); + card.append(&row); } card } + +/// FDO `body-markup` is a small Pango-ish subset (``, ``, ``, +/// ``). Invalid markup falls back to plain text so a bad sender +/// doesn't blank the card. +fn apply_body_text(label: >k4::Label, body: &str) { + if body.contains('<') && gtk4::pango::parse_markup(body, '\0').is_ok() { + label.set_markup(body); + return; + } + label.set_text(body); +} + +#[derive(Clone)] +struct Invoke { + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, + id: u32, +} + +fn invoke_action(invoke: Invoke, key: &str) { + let key = key.to_string(); + relm4::spawn_local(async move { + emit_action(&invoke.conn, invoke.id, &key).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +fn submit_reply(entry: >k4::Entry, invoke: Invoke) { + let text = entry.text().to_string(); + if text.trim().is_empty() { + return; + } + relm4::spawn_local(async move { + emit_replied(&invoke.conn, invoke.id, &text).await; + emit_action(&invoke.conn, invoke.id, INLINE_REPLY_KEY).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +async fn emit_action(conn: &Option, id: u32, action_key: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "ActionInvoked", + &(id, action_key), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit ActionInvoked for {id}: {e}"); + } +} + +/// GNOME/KDE (and clients such as Discord/Telegram) listen for this +/// non-spec signal on `org.freedesktop.Notifications` when the user +/// submits an inline reply. Signature: `NotificationReplied(u32 id, s text)`. +/// We also emit `ActionInvoked(id, "inline-reply")` so senders that only +/// watch the spec signal still see the send. +async fn emit_replied(conn: &Option, id: u32, text: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "NotificationReplied", + &(id, text), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit NotificationReplied for {id}: {e}"); + } +} diff --git a/src/theme.rs b/src/theme.rs index 0e7186f..11820bf 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -44,6 +44,10 @@ fn load_css() -> String { .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ .notification-summary {{ font-weight: bold; }}\ .notification-app {{ opacity: 0.6; }}\ + .notification-actions {{ margin-top: 6px; }}\ + .notification-action {{ padding: 2px 8px; font-size: 11px; }}\ + .notification-reply {{ margin-top: 6px; }}\ + .notification-reply-entry {{ min-width: 0; }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\ .history-close {{ padding: 2px 8px; }}\ .history-empty {{ opacity: 0.5; padding: 8px 0; }}\ From 62c6dd5ea34c40d0a72cf2af6b661c3d4d737fcb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:07 +0800 Subject: [PATCH 34/85] Adopt bread_utils::screenshot_cli for --screenshot flags Replace the local settle delay, canvas defaults, and pair-validation error path with bread-utils v0.7.2. Clap parsing stays in-tree. --- src/screenshot.rs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/screenshot.rs b/src/screenshot.rs index ecf75b3..d3848eb 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -14,16 +14,12 @@ //! windows (`notifications::spawn`/`osd::spawn`, built and primed with //! sample data by `main.rs` before `dispatch` runs — see [`Handles`]). +use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY}; use clap::Parser; use gtk4::prelude::*; use std::path::PathBuf; use std::time::Duration; -/// Extra settle time after `map` for the first frame to actually paint -/// before grim runs — `map` fires once the surface exists, not once -/// anything has been drawn into it. -const SETTLE_DELAY: Duration = Duration::from_millis(300); - /// Settle time for views whose content depends on `bar::stats::spawn_poller`'s /// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels, /// gated on popover visibility) or a similar live-data popover load @@ -38,7 +34,7 @@ const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// popover at all) — presumably the parent widget's own allocation isn't /// settled yet at that exact point. Giving the initial layout pass a beat to /// finish first is what makes it actually render. -const PRE_POPUP_DELAY: Duration = Duration::from_millis(300); +const PRE_POPUP_DELAY: Duration = SETTLE_DELAY; const KNOWN_VIEWS: &[&str] = &[ "bar", @@ -68,11 +64,11 @@ pub struct Cli { /// Capture canvas width — matches the isolated compositor's output width /// (`bread-capture --isolate-width`) so the geometry passed to `grim` /// doesn't depend on querying anything at capture time. - #[arg(long, default_value_t = 1920)] + #[arg(long, default_value_t = DEFAULT_WIDTH)] pub width: u32, /// Capture canvas height — see `width`. - #[arg(long, default_value_t = 1080)] + #[arg(long, default_value_t = DEFAULT_HEIGHT)] pub height: u32, /// Toggle the in-memory notification history on a running breadbar, then @@ -89,16 +85,20 @@ pub struct ScreenshotRequest { } impl Cli { - /// `None` for a normal run. Exits the process with an error if - /// `--screenshot` was given without `--output`, before any GTK/relm4 + /// `None` for a normal run. Exits the process with an error if the + /// `--screenshot` / `--output` pair is incomplete, before any GTK/relm4 /// setup happens. pub fn screenshot_request(&self) -> Option { - let view = self.screenshot.clone()?; - let Some(output) = self.output.clone() else { - eprintln!("breadbar: --screenshot requires --output"); + if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) { + eprintln!("breadbar: {e}"); std::process::exit(1); - }; - Some(ScreenshotRequest { view, output, width: self.width, height: self.height }) + } + Some(ScreenshotRequest { + view: self.screenshot.clone()?, + output: self.output.clone()?, + width: self.width, + height: self.height, + }) } } From ae1fee3591d8d8971c5266b4bf7163f5025776d5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:50:22 +0800 Subject: [PATCH 35/85] CI: refuse unsigned bakery index on stable tag releases --- .forgejo/workflows/release.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 65b0c30..7814150 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,16 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && bash ci/build.sh cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: prepare artifacts run: | @@ -34,8 +43,14 @@ jobs: ln -sfn "${VERSION}" "/srv/breadway-dl/breadbar/latest" - name: regenerate index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" + exit 1 + fi rm -rf /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From 1806c6f912475578fa89953a1175b1c146fb9257 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:23:31 +0800 Subject: [PATCH 36/85] Bind each bar to its output palette and finish the island chrome One layer-shell window per Hyprland output now loads that output's bread-theme palette. Notifications, history, and OSD follow the monitor they appear on. Pin bread-theme to v0.7.4. --- Cargo.lock | 187 +++-- Cargo.toml | 2 +- src/bar/clock.rs | 18 +- src/bar/control.rs | 1 + src/bar/stats.rs | 49 +- src/bar/wifi.rs | 111 ++- src/bar/workspaces.rs | 315 +++++++- src/main.rs | 1354 ++++++++++++++++++++++------------ src/notifications/history.rs | 10 +- src/notifications/popup.rs | 6 +- src/osd.rs | 7 +- src/screenshot.rs | 51 +- src/theme.rs | 286 +++++-- src/widgets/render.rs | 9 +- 14 files changed, 1652 insertions(+), 754 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a1122a..3bc508e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -185,8 +185,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs 5.0.1", "gtk4", @@ -265,7 +265,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -286,9 +286,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.4" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -296,9 +296,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -564,9 +564,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -579,9 +579,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -589,15 +589,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -606,9 +606,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -625,32 +625,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -685,7 +685,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -718,7 +718,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -771,7 +771,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", "windows-sys 0.61.2", ] @@ -835,7 +835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -846,7 +846,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -867,7 +867,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -898,7 +898,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -924,9 +924,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" dependencies = [ "bitflags", "gdk4", @@ -939,15 +939,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps", + "system-deps 8.0.0", ] [[package]] @@ -978,7 +978,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1058,9 +1058,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1093,9 +1093,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -1214,7 +1214,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1243,9 +1243,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -1321,7 +1321,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1624,6 +1624,19 @@ dependencies = [ "version-compare", ] +[[package]] +name = "system-deps" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1654,11 +1667,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -1674,9 +1687,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -1728,13 +1741,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1919,9 +1932,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -1943,9 +1956,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -1956,9 +1969,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1966,9 +1979,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -1979,9 +1992,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -2087,9 +2100,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmlwriter" @@ -2099,9 +2112,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-recursion", @@ -2129,14 +2142,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -2153,6 +2166,15 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zmij" version = "1.0.23" @@ -2161,40 +2183,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.3", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 1648851..1e24f17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs diff --git a/src/bar/clock.rs b/src/bar/clock.rs index 4501fde..45e25e6 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -1,11 +1,21 @@ use crate::{App, AppInput}; use relm4::ComponentSender; +pub fn now() -> gtk4::glib::DateTime { + gtk4::glib::DateTime::now_local().expect("local time") +} + +pub fn time() -> String { + let dt = now(); + format!("{:02}:{:02}", dt.hour(), dt.minute()) +} + +pub fn date() -> String { + now().format("%a %d/%m").expect("date format").to_string() +} + pub fn current() -> String { - let dt = gtk4::glib::DateTime::now_local().expect("local time"); - let date = dt.format("%a %d/%m").expect("date format"); - let time = format!("{:02}:{:02}", dt.hour(), dt.minute()); - format!("{} {}", date, time) + format!("{} {}", date(), time()) } pub fn spawn_ticker(sender: ComponentSender) { diff --git a/src/bar/control.rs b/src/bar/control.rs index ed99568..f480057 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,6 +121,7 @@ pub fn spawn_set_brightness(v: f64) { }); } +#[allow(dead_code)] pub fn spawn_set_sink(name: String) { relm4::spawn(async move { let _ = tokio::process::Command::new("pactl") diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 764d758..892f792 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -25,6 +25,14 @@ pub const WIFI_MEDIUM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), " pub const WIFI_WEAK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg")); pub const WIFI_OFF: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Disconnect.svg")); +/// Adwaita symbolic names — these are drawn for 16px status bars, not our +/// hand-cropped Lucide arcs. +pub const WIFI_ICON_EXCELLENT: &str = "network-wireless-signal-excellent-symbolic"; +pub const WIFI_ICON_GOOD: &str = "network-wireless-signal-good-symbolic"; +pub const WIFI_ICON_OK: &str = "network-wireless-signal-ok-symbolic"; +pub const WIFI_ICON_WEAK: &str = "network-wireless-signal-weak-symbolic"; +pub const WIFI_ICON_OFF: &str = "network-wireless-offline-symbolic"; + pub const BAT_HIGH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg")); pub const BAT_MID: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg")); pub const BAT_LOW: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg")); @@ -65,6 +73,7 @@ pub struct Stats { pub gpu_temp: Option, pub net_rx_kbs: f32, pub net_tx_kbs: f32, + pub volume_pct: u8, } struct CpuSnapshot { @@ -76,7 +85,7 @@ static PREV_CPU: OnceLock> = OnceLock::new(); static BAT_PATH: OnceLock> = OnceLock::new(); static AC_PATH: OnceLock> = OnceLock::new(); static WIFI_CACHE: LazyLock> = - LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF))); + LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_ICON_OFF))); static WIFI_TICK: AtomicU8 = AtomicU8::new(0); fn read_cpu() -> f32 { @@ -271,7 +280,7 @@ fn wifi_iface() -> Option<&'static str> { async fn read_wifi() -> (String, &'static str) { let Some(iface) = wifi_iface() else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let link_out = tokio::process::Command::new("iw") @@ -281,7 +290,7 @@ async fn read_wifi() -> (String, &'static str) { .ok(); let link_stdout = match link_out { Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), - _ => return ("—".into(), WIFI_OFF), + _ => return ("—".into(), WIFI_ICON_OFF), }; let mut ssid = None; @@ -296,13 +305,14 @@ async fn read_wifi() -> (String, &'static str) { } let Some(ssid) = ssid else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let icon = match rssi { - Some(r) if r >= -55 => WIFI_STRONG, - Some(r) if r >= -70 => WIFI_MEDIUM, - _ => WIFI_WEAK, + Some(r) if r >= -55 => WIFI_ICON_EXCELLENT, + Some(r) if r >= -70 => WIFI_ICON_GOOD, + Some(r) if r >= -80 => WIFI_ICON_OK, + _ => WIFI_ICON_WEAK, }; (ssid, icon) @@ -413,7 +423,8 @@ pub async fn poll() -> Stats { let power_watts = read_power(); let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); let pct = read_battery(); - let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%")); + // Demo bar prints the bare number ("83"), not "83%". + let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}")); let bat_icon = pct.map_or(BAT_MID, bat_level_icon); let ac_connected = read_ac(); // BT and WiFi both refresh every 8 cycles (~16 s); cache in between. @@ -442,6 +453,7 @@ pub async fn poll() -> Stats { let gpu_usage = read_gpu_usage(); let gpu_temp = read_gpu_temp(); let (net_rx_kbs, net_tx_kbs) = read_net_throughput(); + let volume_pct = read_volume_pct(); Stats { cpu: format!("{cpu:.0}%"), cpu_pct: cpu, @@ -465,9 +477,30 @@ pub async fn poll() -> Stats { gpu_temp, net_rx_kbs, net_tx_kbs, + volume_pct, } } +/// `wpctl get-volume` prints `Volume: 0.44 [MUTED]`. Scale to a 0–150 percent +/// for the bar chip. Missing pipewire / wpctl degrades to 0 rather than +/// blocking the rest of the poll. +fn read_volume_pct() -> u8 { + let out = std::process::Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + .ok(); + let Some(o) = out.filter(|o| o.status.success()) else { + return 0; + }; + String::from_utf8_lossy(&o.stdout) + .trim() + .strip_prefix("Volume:") + .and_then(|s| s.split_whitespace().next()) + .and_then(|s| s.parse::().ok()) + .map(|v| (v * 100.0).round().clamp(0.0, 150.0) as u8) + .unwrap_or(0) +} + pub fn spawn_poller(sender: ComponentSender) { relm4::spawn(async move { loop { diff --git a/src/bar/wifi.rs b/src/bar/wifi.rs index 6531ece..cf5e272 100644 --- a/src/bar/wifi.rs +++ b/src/bar/wifi.rs @@ -24,6 +24,8 @@ pub struct ScanEntry { pub struct WifiPopoverData { pub profiles: Vec<(String, bool)>, // (name, is_active) pub scan: Vec, + /// False while nmcli is still listing APs — profiles must still be usable. + pub scan_ready: bool, } async fn fetch_status() -> Option { @@ -73,31 +75,57 @@ async fn fetch_profile_list() -> Vec<(String, bool)> { .collect() } +async fn saved_ssids() -> std::collections::HashSet { + let out = tokio::process::Command::new("nmcli") + .args(["-t", "-f", "NAME,TYPE", "connection", "show"]) + .output() + .await; + let Ok(o) = out else { + return std::collections::HashSet::new(); + }; + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let (name, ty) = line.rsplit_once(':')?; + if ty == "802-11-wireless" || ty == "wifi" { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + +/// Cached AP list (no rescan). Fast enough to paint next to profiles. async fn fetch_scan() -> Vec { - let Ok(Ok(out)) = tokio::time::timeout( - Duration::from_secs(10), - tokio::process::Command::new("breadcrumbs") - .args(["scan-list", "--json"]) + let out = tokio::time::timeout( + Duration::from_secs(4), + tokio::process::Command::new("nmcli") + .args(["-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi", "list"]) .output(), ) - .await - else { + .await; + let Ok(Ok(o)) = out else { return vec![]; }; - let arr: Vec = - serde_json::from_slice(&out.stdout).unwrap_or_default(); - arr.into_iter() - .filter_map(|v| { - let ssid = v["ssid"].as_str()?.to_string(); - if ssid.is_empty() { + let saved = saved_ssids().await; + let mut seen = std::collections::HashSet::new(); + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.rsplitn(3, ':'); + let _in_use = parts.next()?; + let signal = parts.next()?.parse::().ok().unwrap_or(0); + let ssid = parts.next()?.replace("\\:", ":"); + if ssid.is_empty() || ssid == "--" || !seen.insert(ssid.clone()) { return None; } - let signal = v["signal"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let saved = v["saved"].as_bool().unwrap_or(false); - Some(ScanEntry { ssid, signal, saved }) + let saved = saved.contains(&ssid); + Some(ScanEntry { + ssid, + signal, + saved, + }) }) .collect() } @@ -114,11 +142,32 @@ pub fn spawn_status_poller(sender: ComponentSender) { }); } -/// Called when the popover opens — loads profiles + scan in parallel. +/// Profiles first (so you can switch Home/Away immediately), then the +/// cached AP list. A background rescan refreshes the list if it finds more. pub fn spawn_popover_load(sender: ComponentSender) { relm4::spawn(async move { - let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan()); - sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan })); + let profiles = fetch_profile_list().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: vec![], + scan_ready: false, + })); + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: scan.clone(), + scan_ready: true, + })); + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "rescan"]) + .output() + .await; + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles, + scan, + scan_ready: true, + })); }); } @@ -132,29 +181,27 @@ pub fn spawn_profile_set(name: String) { }); } -/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`. +/// Fire-and-forget: connect to a known SSID via NetworkManager. pub fn spawn_join(ssid: String) { relm4::spawn(async move { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid]) .output() .await; }); } -/// Fire-and-forget: save a new network with its password, then join it. +/// Save in breadcrumbs (if the CLI still accepts `add`) and connect with nmcli. pub fn spawn_add_and_join(ssid: String, password: String) { relm4::spawn(async move { - let added = tokio::process::Command::new("breadcrumbs") + let _ = tokio::process::Command::new("breadcrumbs") .args(["add", &ssid, &password]) .output() .await; - if matches!(added, Ok(o) if o.status.success()) { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) - .output() - .await; - } + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid, "password", &password]) + .output() + .await; }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 67942f8..1181f02 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -1,7 +1,12 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Instant; + use futures_lite::StreamExt; +use gtk4::glib::ControlFlow; use gtk4::prelude::*; use hyprland::{ - data::{Workspace, Workspaces}, + data::{Monitors, Workspaces}, event_listener::{Event, EventStream}, prelude::*, shared::WorkspaceId, @@ -10,16 +15,62 @@ use relm4::ComponentSender; use crate::AppInput; -/// Fetches the current workspace list + active workspace and pushes both to -/// the app — used both for the initial state and to re-sync after the event -/// stream reconnects (state may have changed while we were disconnected). +/// Stock Hyprland accepts `hyprctl dispatch workspace N`. Lua-config +/// Hyprland (BOS) rewrites that as `hl.dispatch(workspace N)`, which is +/// a syntax error — the working form is `hl.dsp.focus({workspace=N})`. +async fn switch_workspace(id: hyprland::shared::WorkspaceId) { + let arg = id.to_string(); + let stock = tokio::process::Command::new("hyprctl") + .args(["dispatch", "workspace", &arg]) + .output() + .await; + if let Ok(o) = &stock { + let err = String::from_utf8_lossy(&o.stderr); + let out = String::from_utf8_lossy(&o.stdout); + if o.status.success() && !err.contains("hl.dispatch") && !out.contains("hl.dispatch") { + return; + } + } + let expr = format!("hl.dispatch(hl.dsp.focus({{workspace={arg}}}))"); + let lua = tokio::process::Command::new("hyprctl") + .args(["eval", &expr]) + .output() + .await; + match lua { + Ok(o) if o.status.success() => {} + Ok(o) => eprintln!( + "breadbar: workspace {arg}: {}", + String::from_utf8_lossy(&o.stderr) + ), + Err(e) => eprintln!("breadbar: workspace {arg}: {e}"), + } +} + +/// Stretch to the old→new span, then snap onto the destination — CSS +/// transitions cannot widen a pill across two buttons, so the trail's +/// Fixed allocation is interpolated on the frame clock instead. +const STRETCH_MS: f64 = 220.0; +const SNAP_MS: f64 = 380.0; + +/// Full workspace + per-monitor active snapshot. Each bar filters this to +/// its own output so a second display does not inherit the laptop's set. async fn sync_state(sender: &ComponentSender) { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } - if let Ok(active) = Workspace::get_active_async().await { - sender.input(AppInput::ActiveWorkspace(active.id)); + let workspaces = Workspaces::get_async() + .await + .map(|w| w.to_vec()) + .unwrap_or_default(); + let mut actives = std::collections::HashMap::new(); + if let Ok(mons) = Monitors::get_async().await { + for m in mons { + if !m.disabled { + actives.insert(m.name, m.active_workspace.id); + } + } } + sender.input(AppInput::WorkspaceSync { + workspaces, + actives, + }); } pub fn spawn_watcher(sender: ComponentSender) { @@ -41,13 +92,21 @@ pub fn spawn_watcher(sender: ComponentSender) { while let Some(Ok(event)) = stream.next().await { backoff = std::time::Duration::from_millis(500); match event { - Event::WorkspaceChanged(data) => { - sender.input(AppInput::ActiveWorkspace(data.id)); + Event::WorkspaceChanged(_) + | Event::WorkspaceAdded(_) + | Event::WorkspaceDeleted(_) => { + sync_state(&sender).await; } - Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } + Event::MonitorAdded(data) => { + sender.input(AppInput::MonitorAdded(data.name)); + sync_state(&sender).await; + } + Event::MonitorRemoved(name) => { + sender.input(AppInput::MonitorRemoved(name)); + sync_state(&sender).await; + } + Event::ActiveWindowChanged(_) => { + sender.input(AppInput::DismissPanels); } _ => {} } @@ -65,17 +124,233 @@ pub fn spawn_watcher(sender: ComponentSender) { }); } -pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Button { +pub fn make_button( + id: WorkspaceId, + name: &str, + active: WorkspaceId, + occupied: bool, +) -> gtk4::Button { let btn = gtk4::Button::with_label(name); btn.add_css_class("workspace-btn"); + if occupied { + btn.add_css_class("occupied"); + } if id == active { btn.add_css_class("active"); } + btn.set_valign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_size_request(-1, crate::CHIP_HEIGHT); btn.connect_clicked(move |_| { - use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; - let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( - id, - ))); + relm4::spawn(async move { + switch_workspace(id).await; + }); }); btn } + +#[derive(Clone, Copy)] +struct Geom { + x: f64, + y: f64, + w: f64, + h: f64, +} + +struct TrailInner { + tick: Option, + geom: Geom, +} + +/// Overlay + Fixed pill sitting *behind* the workspace buttons. The +/// Overlay's measured size comes from the button row; the pill is the +/// main child so it paints underneath and never steals clicks. +pub struct WorkspaceTrail { + pub overlay: gtk4::Overlay, + pub buttons: gtk4::Box, + host: gtk4::Fixed, + pill: gtk4::Box, + inner: Rc>, +} + +impl WorkspaceTrail { + pub fn new() -> Self { + let overlay = gtk4::Overlay::new(); + overlay.add_css_class("workspace-overlay"); + overlay.set_valign(gtk4::Align::Center); + overlay.set_vexpand(false); + + let host = gtk4::Fixed::new(); + host.set_can_target(false); + + let pill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + pill.add_css_class("workspace-trail"); + pill.set_can_target(false); + pill.set_visible(false); + host.put(&pill, 0.0, 0.0); + + let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); + buttons.set_halign(gtk4::Align::Fill); + buttons.set_valign(gtk4::Align::Center); + buttons.set_vexpand(false); + + overlay.set_child(Some(&host)); + overlay.add_overlay(&buttons); + overlay.set_measure_overlay(&buttons, true); + + let inner = Rc::new(RefCell::new(TrailInner { + tick: None, + geom: Geom { + x: 0.0, + y: 0.0, + w: 0.0, + h: 0.0, + }, + })); + + Self { + overlay, + buttons, + host, + pill, + inner, + } + } + + pub fn cancel(&self) { + if let Some(id) = self.inner.borrow_mut().tick.take() { + id.remove(); + } + } + + pub fn clear(&self) { + self.cancel(); + self.pill.set_visible(false); + self.inner.borrow_mut().geom.w = 0.0; + } + + pub fn place(&self, btn: >k4::Button) { + self.cancel(); + if let Some(g) = button_geom(btn, &self.overlay) { + self.apply(&g); + return; + } + // First map: the button exists but has no allocation yet. + let pill = self.pill.clone(); + let host = self.host.clone(); + let btn = btn.clone(); + let inner = self.inner.clone(); + let id = self.overlay.add_tick_callback(move |ov, _| { + let Some(g) = button_geom(&btn, ov) else { + return ControlFlow::Continue; + }; + apply_geom(&host, &pill, &inner, &g); + inner.borrow_mut().tick = None; + ControlFlow::Break + }); + self.inner.borrow_mut().tick = Some(id); + } + + pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { + let from_g = self.from_geom(from); + if let Some(to_g) = button_geom(to, &self.overlay) { + self.stretch_geom(from_g, to_g); + return; + } + // Destination just appeared (empty workspace becoming active) and + // has no allocation yet. Wait one layout pass, then stretch from + // the last pill — don't snap via place(). + self.cancel(); + let overlay = self.overlay.clone(); + let pill = self.pill.clone(); + let host = self.host.clone(); + let inner = self.inner.clone(); + let btn = to.clone(); + let id = self.overlay.add_tick_callback(move |ov, _| { + let Some(to_g) = button_geom(&btn, ov) else { + return ControlFlow::Continue; + }; + inner.borrow_mut().tick = None; + stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g); + ControlFlow::Break + }); + self.inner.borrow_mut().tick = Some(id); + } + + fn from_geom(&self, from: Option<>k4::Button>) -> Option { + let st = self.inner.borrow(); + if self.pill.is_visible() && st.geom.w > 0.5 { + return Some(st.geom); + } + drop(st); + from.and_then(|b| button_geom(b, &self.overlay)) + } + + fn stretch_geom(&self, from_g: Option, to_g: Geom) { + stretch_geom_on( + &self.overlay, + &self.host, + &self.pill, + &self.inner, + from_g, + to_g, + ); + } + + fn apply(&self, g: &Geom) { + apply_geom(&self.host, &self.pill, &self.inner, g); + } +} + +fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option { + let r = btn.compute_bounds(overlay)?; + let w = f64::from(r.width()); + let h = f64::from(r.height()); + if w < 1.0 || h < 1.0 { + return None; + } + Some(Geom { + x: f64::from(r.x()), + y: f64::from(r.y()), + w, + h, + }) +} + +fn apply_geom(host: >k4::Fixed, pill: >k4::Box, inner: &Rc>, g: &Geom) { + inner.borrow_mut().geom = Geom { + x: g.x, + y: g.y, + w: g.w, + h: g.h, + }; + pill.set_size_request(g.w.max(1.0).round() as i32, g.h.max(1.0).round() as i32); + host.move_(pill, g.x, g.y); + pill.set_visible(true); +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +fn lerp_geom(a: &Geom, b: &Geom, t: f64) -> Geom { + Geom { + x: lerp(a.x, b.x, t), + y: lerp(a.y, b.y, t), + w: lerp(a.w, b.w, t), + h: lerp(a.h, b.h, t), + } +} + +fn ease(t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Approximates the demo's cubic-bezier(.22, 1.4, .36, 1) snap. +fn ease_overshoot(t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + let c = 1.4; + let t1 = t - 1.0; + 1.0 + t1 * t1 * ((c + 1.0) * t1 + c) +} diff --git a/src/main.rs b/src/main.rs index 0ce99a2..ad09dc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,39 +7,56 @@ macro_rules! asset { mod bar; mod notifications; mod osd; +mod panel; mod screenshot; mod theme; mod widgets; -/// Thresholds above which the bar's CPU/RAM/power-draw readouts appear at -/// all — see `AppInput::StatsUpdate`. Below these, the bar stays quiet. -const CPU_ATTENTION_THRESHOLD: f32 = 70.0; -const MEM_ATTENTION_THRESHOLD: f32 = 80.0; -const POWER_ATTENTION_THRESHOLD: f32 = 30.0; +/// 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 gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use hyprland::data::Workspace; use hyprland::shared::WorkspaceId; use relm4::prelude::*; +use relm4::{Component, ComponentController, Controller}; use std::cell::Cell; use std::rc::Rc; +pub struct BarInit { + pub screenshot: Option, + pub monitor: Option, + pub primary: bool, +} + pub struct App { + monitor: String, + primary: bool, + satellites: Vec<(String, Controller)>, + // ── Workspaces ──────────────────────────────────────────────────────── workspaces: Vec, active_ws: WorkspaceId, workspace_box: gtk4::Box, + workspace_trail: bar::workspaces::WorkspaceTrail, button_map: std::collections::HashMap, // ── Clock ───────────────────────────────────────────────────────────── time_str: String, - clock_lbl: gtk4::Label, + clock_digits: Vec, + date_lbl: gtk4::Label, // ── Stats bar ───────────────────────────────────────────────────────── - // The system-stats trio (CPU/RAM/power draw) only shows up when a value - // crosses a "you probably want to know about this" threshold — otherwise - // the bar stays quiet. See AppInput::StatsUpdate. + // Island chrome matches the Liquid Motion demo: volume / wifi / battery + // / hamburger. CPU/RAM/power live in the control panel, not on the bar. system_stats_box: gtk4::Box, system_sep: gtk4::Separator, cpu_pair: gtk4::Box, @@ -48,6 +65,7 @@ pub struct App { cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, + vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, bat_img: gtk4::Image, bat_textures: std::collections::HashMap, @@ -56,7 +74,6 @@ pub struct App { bt_textures: std::collections::HashMap, wifi_lbl: gtk4::Label, wifi_img: gtk4::Image, - wifi_textures: std::collections::HashMap, // ── WiFi popover ────────────────────────────────────────────────────── wifi_pane: gtk4::Box, @@ -77,19 +94,9 @@ pub struct App { media_paused_at: Option, // ── Control panel ───────────────────────────────────────────────────── - control_popover: gtk4::Popover, panel_vol_slider: gtk4::Scale, panel_bright_slider: gtk4::Scale, panel_loading: Rc>, - panel_sink_store: gtk4::StringList, - panel_sink_dropdown: gtk4::DropDown, - panel_sink_signal: Option, - panel_sinks: Vec, - panel_cpu_lbl: gtk4::Label, - panel_mem_lbl: gtk4::Label, - panel_pwr_lbl: gtk4::Label, - panel_gpu_lbl: gtk4::Label, - panel_net_lbl: gtk4::Label, // ── Tray ────────────────────────────────────────────────────────────── tray_section: gtk4::Box, @@ -101,16 +108,21 @@ pub struct App { // One container per WidgetPlacement (see bread_shared::widget), fully // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's // module doc for why that's simpler than incremental patching here. - widget_containers: - std::collections::HashMap, + widget_containers: std::collections::HashMap, widget_tray_section: gtk4::Box, widget_tray_sep: gtk4::Separator, + + panels: panel::PanelSet, } #[derive(Debug)] pub enum AppInput { - WorkspaceList(Vec), - ActiveWorkspace(WorkspaceId), + WorkspaceSync { + workspaces: Vec, + actives: std::collections::HashMap, + }, + MonitorAdded(String), + MonitorRemoved(String), ClockTick, StatsUpdate(bar::stats::Stats), TrayUpdate(bar::tray::TrayUpdate), @@ -121,11 +133,13 @@ pub enum AppInput { MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), WidgetsUpdate(Vec), + ReconcileMonitors, + DismissPanels, } #[relm4::component(pub)] impl SimpleComponent for App { - type Init = Option; + type Init = BarInit; type Input = AppInput; type Output = (); @@ -133,7 +147,7 @@ impl SimpleComponent for App { gtk::ApplicationWindow { add_css_class: "breadbar", set_title: Some("breadbar"), - set_default_height: 32, + set_default_height: BAR_HEIGHT, #[name = "center_box"] gtk::CenterBox { @@ -142,25 +156,53 @@ impl SimpleComponent for App { } fn init( - screenshot_req: Self::Init, + init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { + let screenshot_req = init.screenshot; + let monitor_name = init + .monitor + .clone() + .or_else(primary_hypr_monitor) + .unwrap_or_else(|| "eDP-1".into()); + 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_exclusive_zone(32); + 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); + eprintln!( + "breadbar: init monitor={monitor_name} primary={}", + init.primary + ); + if screenshot_req.is_none() && !bind_layer_monitor(&root, &monitor_name) { + // Unbound satellites must not map on the compositor default + // (that stacks a second exclusive-zone bar on the laptop). + root.set_exclusive_zone(-1); + if !init.primary { + root.set_visible(false); + } + } // ── Workspace row (left) ──────────────────────────────────────── // Built imperatively (not via the view! macro) so a widget // container can sit as a plain sibling of workspace_box — see - // WidgetPlacement::RightOfWorkspaces below. - let workspace_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + // WidgetPlacement::RightOfWorkspaces below. The Overlay trail + // lives behind the buttons; rebuild_buttons only touches the + // button box, never the trail host. + let workspace_trail = bar::workspaces::WorkspaceTrail::new(); + let workspace_box = workspace_trail.buttons.clone(); let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - workspace_row.append(&workspace_box); + workspace_row.set_margin_start(8); + workspace_row.set_valign(gtk4::Align::Center); + workspace_row.set_vexpand(false); + workspace_row.append(&workspace_trail.overlay); // ── Lua-declared widget containers ────────────────────────────── // One per WidgetPlacement; positioned into the layout below as each @@ -182,8 +224,7 @@ impl SimpleComponent for App { // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ - AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, - WIFI_OFF, WIFI_STRONG, WIFI_WEAK, + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME, }; let bat_textures: std::collections::HashMap = [BAT_HIGH, BAT_MID, BAT_LOW] @@ -195,26 +236,26 @@ impl SimpleComponent for App { .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) .collect(); - let wifi_textures: std::collections::HashMap = - [WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK, WIFI_OFF] - .into_iter() - .map(|p| (p.as_ptr() as usize, svg_texture(p))) - .collect(); - // ── Stat labels ────────────────────────────────────────────────── let cpu_lbl = stat_label(); let mem_lbl = stat_label(); let pwr_lbl = stat_label(); + let vol_lbl = stat_label(); let bat_lbl = stat_label(); + let vol_img = svg_image(ICON_VOLUME); + vol_img.add_css_class("stat-icon"); let bat_img = gtk4::Image::from_paintable(Some( bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), )); - let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(AC_POWER))); + 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); + bt_img.set_visible(false); // ── WiFi pair + popover ────────────────────────────────────────── let wifi_lbl = gtk4::Label::new(None); @@ -223,9 +264,11 @@ impl SimpleComponent for App { wifi_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); wifi_lbl.set_max_width_chars(28); wifi_lbl.set_xalign(0.0); - let wifi_img = - gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); - + // SSID lives in the popover + tooltip — a 20-char network name + // 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); wifi_img.add_css_class("stat-icon"); // Content pane only — this becomes a tab inside the merged @@ -239,10 +282,18 @@ impl SimpleComponent for App { // ── Media widget (center) ──────────────────────────────────────── let media_widget = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); media_widget.add_css_class("media-widget"); + bar_chip(&media_widget); media_widget.set_visible(false); - let media_indicator = gtk4::Label::new(Some("▶")); - media_indicator.add_css_class("media-indicator"); + let media_eq = gtk4::Box::new(gtk4::Orientation::Horizontal, 3); + media_eq.add_css_class("media-eq"); + media_eq.set_valign(gtk4::Align::Center); + for _ in 0..4 { + let bar = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + bar.add_css_class("media-eq-bar"); + bar.set_valign(gtk4::Align::End); + media_eq.append(&bar); + } let media_track_lbl = gtk4::Label::new(None); media_track_lbl.add_css_class("media-track-lbl"); @@ -250,7 +301,7 @@ impl SimpleComponent for App { media_track_lbl.set_max_width_chars(42); media_track_lbl.set_xalign(0.0); - media_widget.append(&media_indicator); + media_widget.append(&media_eq); media_widget.append(&media_track_lbl); // Media controls popover @@ -262,14 +313,12 @@ impl SimpleComponent for App { media_controls_box.set_margin_end(4); let prev_btn = gtk4::Button::new(); - prev_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( - asset!("Previous.svg"), - ))))); + prev_btn.set_child(Some(&svg_image(asset!("Previous.svg")))); prev_btn.add_css_class("flat"); prev_btn.add_css_class("media-btn"); prev_btn.connect_clicked(|_| bar::media::spawn_cmd("previous")); - let media_play_icon = gtk4::Image::from_paintable(Some(&svg_texture(asset!("Pause.svg")))); + let media_play_icon = svg_image(asset!("Pause.svg")); let media_play_btn = gtk4::Button::new(); media_play_btn.set_child(Some(&media_play_icon)); media_play_btn.add_css_class("flat"); @@ -278,9 +327,7 @@ impl SimpleComponent for App { media_play_btn.connect_clicked(|_| bar::media::spawn_cmd("play-pause")); let next_btn = gtk4::Button::new(); - next_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( - asset!("Next.svg"), - ))))); + next_btn.set_child(Some(&svg_image(asset!("Next.svg")))); next_btn.add_css_class("flat"); next_btn.add_css_class("media-btn"); next_btn.connect_clicked(|_| bar::media::spawn_cmd("next")); @@ -289,37 +336,47 @@ impl SimpleComponent for App { media_controls_box.append(&media_play_btn); media_controls_box.append(&next_btn); - let media_popover = gtk4::Popover::new(); - media_popover.add_css_class("media-popover"); - media_popover.set_child(Some(&media_controls_box)); - media_popover.set_parent(&media_widget); - - let mpop = media_popover.clone(); - let mgesture = gtk4::GestureClick::new(); - mgesture.connect_released(move |_, _, _, _| { - if mpop.is_visible() { mpop.popdown(); } else { mpop.popup(); } - }); - media_widget.add_controller(mgesture); - - // Clock label - let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); - clock_lbl.add_css_class("clock-label"); + // Clock: time is the hero, date sits beside it quieter. + // Per-glyph labels so a minute rollover can flip only the digits + // that changed — same motion as the Liquid Motion demo. + let clock_time = bar::clock::time(); + let clock_digits = make_clock_digits(&clock_time); + let clock_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + clock_box.add_css_class("clock-box"); + clock_box.add_css_class("clock-label"); + clock_box.set_valign(gtk4::Align::Center); + clock_box.set_vexpand(false); + // Varela Round's em box sits optically high in the 44px island. + clock_box.set_margin_top(3); + for digit in &clock_digits { + clock_box.append(digit); + } + let date_lbl = gtk4::Label::new(Some(&bar::clock::date())); + date_lbl.add_css_class("date-label"); + date_lbl.set_visible(false); // Center area: [media_widget · widgets · clock · widgets] - let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); + center_area.set_valign(gtk4::Align::Center); + center_area.set_vexpand(false); center_area.append(&media_widget); center_area.append(&widget_left_of_clock); - center_area.append(&clock_lbl); + center_area.append(&clock_box); center_area.append(&widget_right_of_clock); // ── Stats box (right side) ─────────────────────────────────────── - let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + // Demo order: [vol 64] [wifi] [bat 83] [☰] + let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); stats_box.add_css_class("stats-box"); + stats_box.set_margin_end(2); + stats_box.set_valign(gtk4::Align::Center); + stats_box.set_vexpand(false); stats_box.append(&widget_left_of_stats); - // CPU/RAM/power draw: hidden by default (see StatsUpdate), so this - // whole sub-group — plus its separator — collapses away when quiet. + // CPU/RAM/power draw stay built (control panel + screenshots still + // read the labels) but never mount on the island — the demo bar + // does not show them. let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); @@ -328,22 +385,26 @@ impl SimpleComponent for App { system_stats_box.append(&mem_pair); system_stats_box.append(&pwr_pair); system_stats_box.set_visible(false); - stats_box.append(&system_stats_box); let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); system_sep.add_css_class("bar-sep"); system_sep.set_visible(false); - stats_box.append(&system_sep); + + let vol_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + vol_box.add_css_class("stat-pair"); + bar_chip(&vol_box); + vol_lbl.add_css_class("stat-label"); + vol_box.append(&vol_img); + vol_box.append(&vol_lbl); + stats_box.append(&vol_box); let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); + bar_chip(&bat_box); bat_img.add_css_class("stat-icon"); bat_lbl.add_css_class("stat-label"); ac_img.add_css_class("stat-icon"); - ac_img.set_margin_start(6); bat_box.append(&bat_img); bat_box.append(&bat_lbl); - bat_box.append(&ac_img); - stats_box.append(&bat_box); bt_img.add_css_class("bt-icon"); @@ -362,41 +423,38 @@ impl SimpleComponent for App { // Boxes with manual visibility toggling, which left stale width // behind on reopen. // - // hhomogeneous/vhomogeneous are ON (GTK's default), even though that - // means the popover is always sized to the *larger* of the two panes - // (visible empty space under the shorter one) — the alternative, - // sizing to just the active pane, means the popup has to resize - // itself in place when you switch tabs while it's open. Under - // gtk4-layer-shell that in-place resize doesn't reliably reach the - // compositor as a proper xdg_popup reposition; switching from the - // shorter tab to the taller one after a close/reopen cycle made the - // whole popover silently vanish instead of growing. A constant - // footprint sidesteps the resize entirely. + // Scrollport is a fixed 300px so tab-switch / first scan cannot + // resize the xdg_popup (that vanish-on-grow bug). Nearby networks + // live in the scroll, not a clipped 240px well. let content_stack = gtk4::Stack::new(); content_stack.set_hhomogeneous(true); - content_stack.set_vhomogeneous(true); - // Reserve enough room up front for the tallest realistic content - // (WiFi tab with a handful of nearby networks). Homogeneous sizing - // alone still lets the *first* real data load (Scanning… → populated - // list) trigger a live resize while the popup is mapped, which hits - // the same reposition fragility as the tab-switch case — claiming - // the space before anything is shown avoids that resize too. - content_stack.set_size_request(220, 420); + content_stack.set_vhomogeneous(false); + content_stack.set_transition_type(gtk4::StackTransitionType::Crossfade); + content_stack.set_transition_duration(220); content_stack.add_named(&wifi_pane, Some("wifi")); content_stack.add_named(&bt_pane, Some("bluetooth")); content_stack.set_visible_child_name("wifi"); - let wifi_tab_btn = gtk4::ToggleButton::with_label("Wi-Fi"); - wifi_tab_btn.add_css_class("popover-tab"); + // Fixed scrollport so nearby networks stay reachable without + // resizing the xdg_popup (that vanish-on-grow bug). + let content_scroll = gtk4::ScrolledWindow::new(); + content_scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + content_scroll.set_propagate_natural_width(true); + content_scroll.set_min_content_height(300); + content_scroll.set_max_content_height(300); + content_scroll.set_child(Some(&content_stack)); + + let wifi_tab_btn = popover_tab("Wi-Fi"); wifi_tab_btn.set_active(true); - let bt_tab_btn = gtk4::ToggleButton::with_label("Bluetooth"); - bt_tab_btn.add_css_class("popover-tab"); + let bt_tab_btn = popover_tab("Bluetooth"); bt_tab_btn.set_group(Some(&wifi_tab_btn)); - let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); tab_row.add_css_class("popover-tab-row"); + tab_row.set_homogeneous(true); tab_row.append(&wifi_tab_btn); tab_row.append(&bt_tab_btn); + let wifi_caret = popover_caret(); let stack_for_wifi = content_stack.clone(); wifi_tab_btn.connect_toggled(move |btn| { @@ -414,170 +472,53 @@ impl SimpleComponent for App { let connectivity_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); connectivity_inner.add_css_class("wifi-popover-inner"); connectivity_inner.append(&tab_row); - connectivity_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - connectivity_inner.append(&content_stack); - - let connectivity_popover = gtk4::Popover::new(); - connectivity_popover.add_css_class("wifi-popover"); - connectivity_popover.set_child(Some(&connectivity_inner)); + connectivity_inner.append(&wifi_caret); + connectivity_inner.append(&content_scroll); let connectivity_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); connectivity_pair.add_css_class("stat-pair"); connectivity_pair.add_css_class("wifi-pair"); - connectivity_pair.append(&bt_img); + connectivity_pair.add_css_class("icon-only"); + bar_chip(&connectivity_pair); + wifi_img.set_halign(gtk4::Align::Center); + wifi_img.set_hexpand(false); connectivity_pair.append(&wifi_img); - connectivity_pair.append(&wifi_lbl); - // Anchored to wifi_lbl specifically, not the connectivity_pair row — - // a Popover parented to a multi-child Box via set_parent() balloons - // that Box's own allocation after a popup→popdown→popup cycle (its - // width roughly quadrupled in testing, shoving every bar item to its - // left further left). Anchoring to a single leaf widget instead - // sidesteps whatever GTK4/gtk4-layer-shell interaction causes that; - // the click target below still covers the whole row regardless. - connectivity_popover.set_parent(&wifi_lbl); stats_box.append(&connectivity_pair); - - let cpop = connectivity_popover.clone(); - let gesture = gtk4::GestureClick::new(); - gesture.connect_released(move |_, _, _, _| { - if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } - }); - connectivity_pair.add_controller(gesture); - - let sender_conn = sender.clone(); - connectivity_popover.connect_show(move |_| { - bar::wifi::spawn_popover_load(sender_conn.clone()); - bar::bluetooth::spawn_popover_load(sender_conn.clone()); - }); + stats_box.append(&bat_box); // ── Control panel popover ──────────────────────────────────────── + // Liquid Motion chrome: CONTROL / vol / bl / lock·sleep·off. + // SNI tray + Lua tray widgets still mount here, but only as a + // headerless icon row when something actually registers. let panel_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); panel_inner.add_css_class("control-panel-inner"); - // Volume row - let vol_row = build_slider_row(bar::stats::ICON_VOLUME, 0.0, 1.5, 0.02); + let panel_header = gtk4::Label::new(Some("CONTROL")); + panel_header.add_css_class("control-panel-header"); + panel_header.set_xalign(0.0); + panel_inner.append(&panel_header); + panel_inner.append(&popover_caret()); + + let vol_row = build_slider_row("vol", 0.0, 1.5, 0.02); let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); - // Brightness row - let bright_row = build_slider_row(bar::stats::ICON_BRIGHTNESS, 0.0, 1.0, 0.02); + let bright_row = build_slider_row("bl", 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Stats section - let stats_section = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - stats_section.add_css_class("control-panel-stats"); - - let panel_cpu_lbl = gtk4::Label::new(Some("CPU —")); - panel_cpu_lbl.add_css_class("control-panel-stat"); - panel_cpu_lbl.set_xalign(0.0); - - let panel_mem_lbl = gtk4::Label::new(Some("RAM —")); - panel_mem_lbl.add_css_class("control-panel-stat"); - panel_mem_lbl.set_xalign(0.0); - - let panel_pwr_lbl = gtk4::Label::new(Some("PWR —")); - panel_pwr_lbl.add_css_class("control-panel-stat"); - panel_pwr_lbl.set_xalign(0.0); - - let panel_gpu_lbl = gtk4::Label::new(Some("GPU —")); - panel_gpu_lbl.add_css_class("control-panel-stat"); - panel_gpu_lbl.set_xalign(0.0); - - let panel_net_lbl = gtk4::Label::new(Some("↓ — ↑ —")); - panel_net_lbl.add_css_class("control-panel-stat"); - panel_net_lbl.set_xalign(0.0); - - stats_section.append(&panel_cpu_lbl); - stats_section.append(&panel_mem_lbl); - stats_section.append(&panel_pwr_lbl); - stats_section.append(&panel_gpu_lbl); - stats_section.append(&panel_net_lbl); - panel_inner.append(&stats_section); - - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Audio output section - let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - sink_section.add_css_class("control-panel-section"); - let sink_header = gtk4::Label::new(Some("Audio Output")); - sink_header.add_css_class("control-panel-section-header"); - sink_header.set_xalign(0.0); - - let panel_sink_store = gtk4::StringList::new(&[]); - let panel_sink_dropdown = gtk4::DropDown::new( - Some(panel_sink_store.clone().upcast::()), - Option::::None, - ); - panel_sink_dropdown.add_css_class("control-panel-sink-dropdown"); - panel_sink_dropdown.set_hexpand(true); - - sink_section.append(&sink_header); - sink_section.append(&panel_sink_dropdown); - panel_inner.append(&sink_section); - - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Tray section - let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - tray_section.add_css_class("control-panel-section"); - let tray_header = gtk4::Label::new(Some("Apps")); - tray_header.add_css_class("control-panel-section-header"); - tray_header.set_xalign(0.0); - let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - tray_box.add_css_class("tray-box"); - tray_section.append(&tray_header); - tray_section.append(&tray_box); - // Collapsed (along with its separator) until an SNI app actually - // registers — an empty "Apps" section heading is dead weight. - tray_section.set_visible(false); - panel_inner.append(&tray_section); - - let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); - tray_sep.set_visible(false); - panel_inner.append(&tray_sep); - - // Widgets section — Lua-declared widgets with placement = "tray". - // Same collapse-when-empty idiom as the Apps section above. - let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - widget_tray_section.add_css_class("control-panel-section"); - let widget_tray_header = gtk4::Label::new(Some("Widgets")); - widget_tray_header.add_css_class("control-panel-section-header"); - widget_tray_header.set_xalign(0.0); - let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_tray_box.add_css_class("tray-box"); - widget_tray_section.append(&widget_tray_header); - widget_tray_section.append(&widget_tray_box); - widget_tray_section.set_visible(false); - panel_inner.append(&widget_tray_section); - - let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); - widget_tray_sep.set_visible(false); - panel_inner.append(&widget_tray_sep); - - // Power section - let power_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - power_section.add_css_class("control-panel-section"); - let power_header = gtk4::Label::new(Some("Power")); - power_header.add_css_class("control-panel-section-header"); - power_header.set_xalign(0.0); - power_section.append(&power_header); - - let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); power_row.add_css_class("power-row"); - for (icon_svg, cmd) in [ + power_row.set_halign(gtk4::Align::Center); + for (label, cmd) in [ // breadlock is the ecosystem's own screen locker — hyprlock is // the thing it was built to replace; the bar shouldn't still // be pointing at it. - (bar::stats::ICON_LOCK, vec!["breadlock"]), - (bar::stats::ICON_SLEEP, vec!["systemctl", "suspend"]), - (bar::stats::ICON_RESTART, vec!["systemctl", "reboot"]), - (bar::stats::ICON_SHUTDOWN, vec!["systemctl", "poweroff"]), + ("lock", vec!["breadlock"]), + ("sleep", vec!["systemctl", "suspend"]), + ("off", vec!["systemctl", "poweroff"]), ] { - let btn = gtk4::Button::new(); - btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture(icon_svg))))); + let btn = gtk4::Button::with_label(label); btn.add_css_class("flat"); btn.add_css_class("power-btn"); btn.connect_clicked(move |_| { @@ -590,49 +531,121 @@ impl SimpleComponent for App { }); power_row.append(&btn); } - power_section.append(&power_row); - panel_inner.append(&power_section); + panel_inner.append(&power_row); - let control_popover = gtk4::Popover::new(); - control_popover.add_css_class("control-panel"); - control_popover.set_child(Some(&panel_inner)); + // SNI / Lua tray sit under the demo chrome so a single icon cannot + // split the sliders from the power chips. + let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + tray_section.add_css_class("control-panel-section"); + let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + tray_box.add_css_class("tray-box"); + tray_box.set_halign(gtk4::Align::Center); + tray_section.append(&tray_box); + tray_section.set_visible(false); + panel_inner.append(&tray_section); + let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + tray_sep.set_visible(false); - // Hamburger button + let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + widget_tray_section.add_css_class("control-panel-section"); + let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_tray_box.add_css_class("tray-box"); + widget_tray_box.set_halign(gtk4::Align::Center); + widget_tray_section.append(&widget_tray_box); + widget_tray_section.set_visible(false); + panel_inner.append(&widget_tray_section); + let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + widget_tray_sep.set_visible(false); + + // Hamburger button — same chip chrome as volume / wifi / battery. let hamburger_btn = gtk4::Button::with_label("☰"); hamburger_btn.add_css_class("flat"); hamburger_btn.add_css_class("control-panel-btn"); - - control_popover.set_parent(&hamburger_btn); - - let cpop = control_popover.clone(); - hamburger_btn.connect_clicked(move |_| { - if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } - }); - - let sender_cp = sender.clone(); - control_popover.connect_show(move |_| { - bar::control::spawn_load(sender_cp.clone()); - }); + hamburger_btn.add_css_class("stat-pair"); + hamburger_btn.add_css_class("icon-only"); + bar_chip(&hamburger_btn); // Slider signals — use Rc> to suppress feedback during data load let panel_loading = Rc::new(Cell::new(false)); let loading_v = panel_loading.clone(); + let vol_lbl_live = vol_lbl.clone(); panel_vol_slider.connect_value_changed(move |s| { - if loading_v.get() { return; } + vol_lbl_live.set_label(&format!("{:.0}", s.value() * 100.0)); + if loading_v.get() { + return; + } bar::control::spawn_set_volume(s.value()); }); let loading_b = panel_loading.clone(); panel_bright_slider.connect_value_changed(move |s| { - if loading_b.get() { return; } + if loading_b.get() { + return; + } bar::control::spawn_set_brightness(s.value()); }); stats_box.append(&hamburger_btn); + // Standalone layer windows — below the island, slid in by Hyprland. + let panels = panel::PanelSet::new( + &monitor_name, + &connectivity_inner, + &panel_inner, + &media_controls_box, + ); + + let sender_conn = sender.clone(); + panels.connectivity.connect_map(move |_| { + bar::wifi::spawn_popover_load(sender_conn.clone()); + bar::bluetooth::spawn_popover_load(sender_conn.clone()); + }); + let sender_cp = sender.clone(); + panels.control.connect_map(move |_| { + bar::control::spawn_load(sender_cp.clone()); + }); + + { + let panels = panels.clone(); + let win = panels.connectivity.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + connectivity_pair.add_controller(gesture); + } + { + let panels = panels.clone(); + let win = panels.control.clone(); + hamburger_btn.connect_clicked(move |_| { + panels.toggle(&win); + }); + } + { + let panels = panels.clone(); + let win = panels.control.clone(); + let vol_gesture = gtk4::GestureClick::new(); + vol_gesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + vol_box.add_controller(vol_gesture); + } + { + let panels = panels.clone(); + let win = panels.media.clone(); + let mgesture = gtk4::GestureClick::new(); + mgesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + media_widget.add_controller(mgesture); + } + let widget_containers = std::collections::HashMap::from([ - (WidgetPlacement::RightOfWorkspaces, widget_right_of_workspaces), + ( + WidgetPlacement::RightOfWorkspaces, + widget_right_of_workspaces, + ), (WidgetPlacement::LeftOfClock, widget_left_of_clock), (WidgetPlacement::RightOfClock, widget_right_of_clock), (WidgetPlacement::LeftOfStats, widget_left_of_stats), @@ -648,21 +661,37 @@ impl SimpleComponent for App { // Captured before these move into `model` (or are otherwise dropped // as bare locals, never stored on `App` at all) — needed by the // screenshot dispatch just before this function returns. - let control_popover_for_screenshot = control_popover.clone(); - let connectivity_popover_for_screenshot = connectivity_popover.clone(); + let control_panel_for_screenshot = panels.control.clone(); + let connectivity_panel_for_screenshot = panels.connectivity.clone(); let wifi_tab_btn_for_screenshot = wifi_tab_btn.clone(); let bt_tab_btn_for_screenshot = bt_tab_btn.clone(); - let media_popover_for_screenshot = media_popover.clone(); + let media_panel_for_screenshot = panels.media.clone(); let media_widget_for_screenshot = media_widget.clone(); let media_track_lbl_for_screenshot = media_track_lbl.clone(); + // Never launch sibling App windows from inside this init — RelmApp + // is still in GApplication activate, and a same-type launch here + // creates a second primary on the laptop. Idle reconcile after. + let satellites = Vec::new(); + if init.primary && screenshot_req.is_none() { + let later = sender.clone(); + gtk4::glib::idle_add_local_once(move || { + later.input(AppInput::ReconcileMonitors); + }); + } + let model = App { + monitor: monitor_name, + primary: init.primary, + satellites, workspaces: vec![], active_ws: 1, workspace_box, + workspace_trail, button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), - clock_lbl, + clock_digits, + date_lbl, system_stats_box, system_sep, cpu_pair, @@ -671,6 +700,7 @@ impl SimpleComponent for App { cpu_lbl, mem_lbl, pwr_lbl, + vol_lbl, bat_lbl, bat_img, bat_textures, @@ -679,7 +709,6 @@ impl SimpleComponent for App { bt_textures, wifi_lbl, wifi_img, - wifi_textures, wifi_pane, crumbs_status: None, wifi_popover_data: None, @@ -692,19 +721,9 @@ impl SimpleComponent for App { media_play_icon, media_last: None, media_paused_at: None, - control_popover, panel_vol_slider, panel_bright_slider, panel_loading, - panel_sink_store, - panel_sink_dropdown, - panel_sink_signal: None, - panel_sinks: vec![], - panel_cpu_lbl, - panel_mem_lbl, - panel_pwr_lbl, - panel_gpu_lbl, - panel_net_lbl, tray_section, tray_sep, tray_box, @@ -712,16 +731,20 @@ impl SimpleComponent for App { widget_containers, widget_tray_section, widget_tray_sep, + panels, }; theme::apply(); + theme::bind_output(&root, &model.monitor); bar::workspaces::spawn_watcher(sender.clone()); bar::clock::spawn_ticker(sender.clone()); bar::stats::spawn_poller(sender.clone()); - bar::tray::spawn_watcher(sender.clone()); bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); - widgets::client::spawn(sender.clone()); + if init.primary { + bar::tray::spawn_watcher(sender.clone()); + widgets::client::spawn(sender.clone()); + } // Screenshot mode primes these with sample content instead of the // real D-Bus/pactl/backlight sources — see notifications::SampleKind @@ -731,28 +754,37 @@ impl SimpleComponent for App { "notification-critical" => Some(notifications::SampleKind::Critical), _ => None, }); - let notification_window = notifications::spawn(notif_sample); + let notification_window = if init.primary { + Some(notifications::spawn(notif_sample)) + } else { + None + }; let osd_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { "osd-volume" => Some(osd::SampleKind::Volume), "osd-brightness" => Some(osd::SampleKind::Brightness), _ => None, }); - let osd_window = osd::spawn(osd_sample); + let osd_window = if init.primary { + Some(osd::spawn(osd_sample)) + } else { + None + }; if let Some(req) = screenshot_req { - let notification_window = matches!(req.view.as_str(), "notification" | "notification-critical") - .then_some(notification_window); - let osd_window = matches!(req.view.as_str(), "osd-volume" | "osd-brightness") - .then_some(osd_window); + let notification_window = notification_window.filter(|_| { + matches!(req.view.as_str(), "notification" | "notification-critical") + }); + let osd_window = + osd_window.filter(|_| matches!(req.view.as_str(), "osd-volume" | "osd-brightness")); screenshot::dispatch( &root, req, screenshot::Handles { - control_popover: control_popover_for_screenshot, - connectivity_popover: connectivity_popover_for_screenshot, + control_panel: control_panel_for_screenshot, + connectivity_panel: connectivity_panel_for_screenshot, wifi_tab_btn: wifi_tab_btn_for_screenshot, bt_tab_btn: bt_tab_btn_for_screenshot, - media_popover: media_popover_for_screenshot, + media_panel: media_panel_for_screenshot, media_widget: media_widget_for_screenshot, media_track_lbl: media_track_lbl_for_screenshot, notification_window, @@ -766,48 +798,88 @@ impl SimpleComponent for App { fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { - AppInput::WorkspaceList(list) => { - let mut sorted = list; + AppInput::WorkspaceSync { + workspaces, + actives, + } => { + let mut sorted = workspaces; sorted.sort_by_key(|w| w.id); + let new_active = actives + .get(&self.monitor) + .copied() + .unwrap_or(self.active_ws); + // Workspace also carries last-window title/address, which + // change constantly. Only the visible row (id/name/monitor/ + // occupied) should rebuild the pills — otherwise a title + // flicker on switch cancels the trail mid-stretch. + let rows_changed = visible_ws_rows(&sorted, &self.monitor, new_active) + != visible_ws_rows(&self.workspaces, &self.monitor, self.active_ws); + let active_changed = new_active != self.active_ws; self.workspaces = sorted; - self.rebuild_buttons(); + if self.primary { + self.reconcile_satellites(); + } + if rows_changed { + self.active_ws = new_active; + self.rebuild_buttons(active_changed); + } else if active_changed { + let from = self.button_map.get(&self.active_ws).cloned(); + if let Some(old) = &from { + old.remove_css_class("active"); + } + 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); + } + } } - AppInput::ActiveWorkspace(id) => { - if let Some(old) = self.button_map.get(&self.active_ws) { - old.remove_css_class("active"); + AppInput::MonitorAdded(name) => { + if !self.primary || name == self.monitor { + return; } - self.active_ws = id; - if let Some(btn) = self.button_map.get(&self.active_ws) { - btn.add_css_class("active"); + if self.satellites.iter().any(|(n, _)| n == &name) { + return; } + if let Some(ctrl) = spawn_satellite(&name) { + self.satellites.push((name, ctrl)); + } + } + AppInput::MonitorRemoved(name) => { + drop_satellite(&mut self.satellites, &name); } AppInput::ClockTick => { self.time_str = bar::clock::current(); - self.clock_lbl.set_label(&self.time_str); + flip_clock_digits(&self.clock_digits, &bar::clock::time()); + self.date_lbl.set_label(&bar::clock::date()); } AppInput::StatsUpdate(stats) => { self.cpu_lbl.set_label(&stats.cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); - // Bar information diet: CPU/RAM/power draw only surface once - // they're actually worth knowing about, individually, so a - // hot CPU doesn't drag an idle RAM/power reading along with it. - let cpu_hot = stats.cpu_pct > CPU_ATTENTION_THRESHOLD; - let mem_hot = stats.mem_pct > MEM_ATTENTION_THRESHOLD; - let pwr_hot = stats.power_watts > POWER_ATTENTION_THRESHOLD; - self.cpu_pair.set_visible(cpu_hot); - self.mem_pair.set_visible(mem_hot); - self.pwr_pair.set_visible(pwr_hot); - let any_hot = cpu_hot || mem_hot || pwr_hot; - self.system_stats_box.set_visible(any_hot); - self.system_sep.set_visible(any_hot); + // Island bar never shows the system-stats trio — they live + // in the control panel. Keep the widgets hidden so a later + // re-parent cannot accidentally flash them. + self.cpu_pair.set_visible(false); + self.mem_pair.set_visible(false); + self.pwr_pair.set_visible(false); + self.system_stats_box.set_visible(false); + self.system_sep.set_visible(false); - self.bat_lbl.set_label(&stats.bat); + tick_label(&self.vol_lbl, &stats.volume_pct.to_string()); + self.vol_lbl.set_tooltip_text(Some(&format!("volume {}%", stats.volume_pct))); + tick_label(&self.bat_lbl, &stats.bat); if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { self.bat_img.set_paintable(Some(tex)); } - self.ac_img.set_visible(stats.ac_connected); + let bat_tip = if stats.ac_connected { + format!("{}% · charging", stats.bat) + } else { + format!("{}%", stats.bat) + }; + self.bat_img.set_tooltip_text(Some(&bat_tip)); + self.ac_img.set_visible(false); if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { self.bt_img.set_paintable(Some(tex)); } @@ -822,41 +894,12 @@ impl SimpleComponent for App { .map(|s| s.internet && !s.captive_portal) .unwrap_or(true); let icon = if !internet_ok && stats.wifi_ssid != "—" { - bar::stats::WIFI_OFF + bar::stats::WIFI_ICON_OFF } else { stats.wifi_icon }; - if let Some(tex) = self.wifi_textures.get(&(icon.as_ptr() as usize)) { - self.wifi_img.set_paintable(Some(tex)); - } + self.wifi_img.set_icon_name(Some(icon)); - // Live-update control panel stats while open - if self.control_popover.is_visible() { - let cpu_str = match (stats.cpu_temp, stats.cpu.as_str()) { - (Some(t), pct) => format!("CPU {pct} {t:.0}°C"), - (None, pct) => format!("CPU {pct}"), - }; - self.panel_cpu_lbl.set_label(&cpu_str); - - self.panel_mem_lbl - .set_label(&format!("RAM {:.0}% {}", stats.mem_pct, stats.mem)); - self.panel_pwr_lbl - .set_label(&format!("PWR {}", stats.power)); - - let gpu_str = match (stats.gpu_usage, stats.gpu_temp) { - (Some(u), Some(t)) => format!("GPU {u}% {t:.0}°C"), - (Some(u), None) => format!("GPU {u}%"), - (None, Some(t)) => format!("GPU {t:.0}°C"), - (None, None) => "GPU —".to_string(), - }; - self.panel_gpu_lbl.set_label(&gpu_str); - - self.panel_net_lbl.set_label(&format!( - "↓ {} ↑ {}", - fmt_speed(stats.net_rx_kbs), - fmt_speed(stats.net_tx_kbs), - )); - } } AppInput::TrayUpdate(bar::tray::TrayUpdate::Add { id, icon, title }) => { if self.tray_items.contains_key(&id) { @@ -917,7 +960,14 @@ impl SimpleComponent for App { } else { asset!("Play.svg") }; - self.media_play_icon.set_paintable(Some(&svg_texture(icon_svg))); + self.media_play_icon + .set_paintable(Some(&svg_texture(icon_svg))); + prepare_icon(&self.media_play_icon, ICON_PX); + if state.playing { + self.media_widget.add_css_class("playing"); + } else { + self.media_widget.remove_css_class("playing"); + } if state.playing { self.media_paused_at = None; @@ -929,19 +979,20 @@ impl SimpleComponent for App { .media_paused_at .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); - self.media_widget.set_visible(within_linger); + reveal_media(&self.media_widget, within_linger); } else { // Player gone — honour linger from last pause + self.media_widget.remove_css_class("playing"); if let Some(paused_at) = self.media_paused_at { if paused_at.elapsed().as_secs() < 30 * 60 { - self.media_widget.set_visible(true); + reveal_media(&self.media_widget, true); } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; self.media_paused_at = None; } } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; } } @@ -952,36 +1003,18 @@ impl SimpleComponent for App { self.panel_vol_slider.set_value(data.volume); self.panel_bright_slider.set_value(data.brightness); self.panel_loading.set(false); - - // Rebuild sink dropdown — disconnect, repopulate, reconnect - if let Some(id) = self.panel_sink_signal.take() { - self.panel_sink_dropdown.disconnect(id); - } - // Clear store - let n = self.panel_sink_store.n_items(); - for i in (0..n).rev() { - self.panel_sink_store.remove(i); - } - for sink in &data.sinks { - self.panel_sink_store.append(&sink.description); - } - if let Some(idx) = data.sinks.iter().position(|s| s.is_default) { - self.panel_sink_dropdown.set_selected(idx as u32); - } - self.panel_sinks = data.sinks; - - let sinks = self.panel_sinks.clone(); - let id = self.panel_sink_dropdown.connect_selected_notify(move |dd| { - let idx = dd.selected() as usize; - if let Some(sink) = sinks.get(idx) { - bar::control::spawn_set_sink(sink.name.clone()); - } - }); - self.panel_sink_signal = Some(id); } AppInput::WidgetsUpdate(specs) => { self.reconcile_widgets(specs); } + AppInput::ReconcileMonitors => { + if self.primary { + self.reconcile_satellites(); + } + } + AppInput::DismissPanels => { + self.panels.hide_all(); + } } } } @@ -1030,34 +1063,84 @@ impl App { container.set_visible(container.first_child().is_some()); } - let has_tray_widgets = specs.iter().any(|s| { - s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray - }); + let has_tray_widgets = specs + .iter() + .any(|s| s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray); self.widget_tray_section.set_visible(has_tray_widgets); self.widget_tray_sep.set_visible(has_tray_widgets); } + fn reconcile_satellites(&mut self) { + let live: Vec = hypr_monitor_names() + .into_iter() + .filter(|n| n != &self.monitor) + .collect(); + let stale: Vec = self + .satellites + .iter() + .filter_map(|(n, _)| { + if live.contains(n) { + None + } else { + Some(n.clone()) + } + }) + .collect(); + for name in stale { + drop_satellite(&mut self.satellites, &name); + } + for name in live { + if self.satellites.iter().any(|(n, _)| n == &name) { + continue; + } + if let Some(ctrl) = spawn_satellite(&name) { + self.satellites.push((name, ctrl)); + } + } + } + fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), None => self.current_ssid.clone(), }; self.wifi_lbl.set_label(&label); + self.wifi_img.set_tooltip_text(Some(&label)); } - fn rebuild_buttons(&mut self) { + fn rebuild_buttons(&mut self, animate: bool) { + self.workspace_trail.cancel(); + let prev: std::collections::HashSet = + self.button_map.keys().copied().collect(); while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } self.button_map.clear(); for ws in &self.workspaces { - let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws); + 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 btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0); + if !prev.contains(&ws.id) { + play_once(&btn, "ws-in", 360); + } 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(), + } } fn rebuild_wifi_popover(&mut self, sender: &ComponentSender) { + let panels = self.panels.clone(); while let Some(child) = self.wifi_pane.first_child() { self.wifi_pane.remove(&child); } @@ -1089,7 +1172,11 @@ impl App { parts.push("internet ✗"); } if st.tailscale_required { - parts.push(if st.tailscale_ok { "tailscale ✓" } else { "tailscale ✗" }); + parts.push(if st.tailscale_ok { + "tailscale ✓" + } else { + "tailscale ✗" + }); } let status_lbl = gtk4::Label::new(Some(&parts.join(" "))); status_lbl.add_css_class("wifi-popover-status"); @@ -1101,59 +1188,36 @@ impl App { .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); } - let Some(data) = &self.wifi_popover_data else { + let nh = gtk4::Label::new(Some("NETWORKS")); + nh.add_css_class("wifi-popover-section"); + nh.set_xalign(0.0); + nh.set_margin_top(2); + nh.set_margin_bottom(4); + self.wifi_pane.append(&nh); + + let data = self.wifi_popover_data.as_ref(); + let scan_ready = data.map(|d| d.scan_ready).unwrap_or(false); + let scan = data.map(|d| d.scan.as_slice()).unwrap_or(&[]); + let profiles = data + .map(|d| unique_profiles(&d.profiles)) + .unwrap_or_default(); + + if !scan_ready { let lbl = gtk4::Label::new(Some("Scanning…")); lbl.add_css_class("wifi-popover-loading"); - self.wifi_pane.append(&lbl); - return; - }; - - let ph = gtk4::Label::new(Some("Profiles")); - ph.add_css_class("wifi-popover-section"); - ph.set_xalign(0.0); - ph.set_margin_top(6); - ph.set_margin_bottom(2); - self.wifi_pane.append(&ph); - - for (name, active) in &data.profiles { - let row = gtk4::Button::new(); - row.add_css_class("flat"); - row.add_css_class("wifi-popover-row"); - if *active { - row.add_css_class("wifi-popover-row-active"); - } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if *active { "● " } else { " " }, - name - ))); lbl.set_xalign(0.0); - row.set_child(Some(&lbl)); - - let name_clone = name.clone(); - let sender_clone = sender.clone(); - row.connect_clicked(move |btn| { - sender_clone.input(AppInput::SetProfile(name_clone.clone())); - bar::wifi::spawn_profile_set(name_clone.clone()); - close_parent_popover(btn); - }); - self.wifi_pane.append(&row); - } - - if !data.scan.is_empty() { - self.wifi_pane - .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - let nh = gtk4::Label::new(Some("Nearby")); - nh.add_css_class("wifi-popover-section"); - nh.set_xalign(0.0); - nh.set_margin_top(6); - nh.set_margin_bottom(2); - self.wifi_pane.append(&nh); - - for entry in &data.scan { + self.wifi_pane.append(&lbl); + } else if scan.is_empty() { + let lbl = gtk4::Label::new(Some("No networks found")); + lbl.add_css_class("wifi-popover-loading"); + lbl.set_xalign(0.0); + self.wifi_pane.append(&lbl); + } else { + for (i, entry) in scan.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); if !entry.saved { row.add_css_class("wifi-popover-row-unsaved"); } @@ -1162,31 +1226,61 @@ impl App { row.add_css_class("wifi-popover-row-active"); } - let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let icon_svg = wifi_icon_for_signal(entry.signal); - if let Some(tex) = self.wifi_textures.get(&(icon_svg.as_ptr() as usize)) { - let img = gtk4::Image::from_paintable(Some(tex)); - img.add_css_class("stat-icon"); - row_box.append(&img); - } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if is_current { "● " } else { " " }, - entry.ssid, - ))); + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let img = gtk4::Image::from_icon_name(wifi_icon_for_signal(entry.signal)); + prepare_icon(&img, 18); + img.add_css_class("stat-icon"); + row_box.append(&img); + let lbl = gtk4::Label::new(Some(&entry.ssid)); lbl.set_xalign(0.0); + lbl.set_hexpand(true); + lbl.set_valign(gtk4::Align::Center); row_box.append(&lbl); row.set_child(Some(&row_box)); let ssid_clone = entry.ssid.clone(); let saved = entry.saved; + let panels = panels.clone(); row.connect_clicked(move |btn| { if saved { bar::wifi::spawn_join(ssid_clone.clone()); } else { show_add_network_dialog(btn, ssid_clone.clone(), |_| {}); } - close_parent_popover(btn); + panels.hide_all(); + }); + self.wifi_pane.append(&row); + } + } + + if !profiles.is_empty() { + let ph = gtk4::Label::new(Some("PROFILES")); + ph.add_css_class("wifi-popover-section"); + ph.set_xalign(0.0); + ph.set_margin_top(10); + ph.set_margin_bottom(4); + self.wifi_pane.append(&ph); + + for (i, (name, active)) in profiles.into_iter().enumerate() { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); + if active { + row.add_css_class("wifi-popover-row-active"); + } + let lbl = gtk4::Label::new(Some(&name)); + lbl.set_xalign(0.0); + lbl.set_valign(gtk4::Align::Center); + row.set_child(Some(&lbl)); + + let name_clone = name.clone(); + let sender_clone = sender.clone(); + let panels = panels.clone(); + row.connect_clicked(move |_| { + sender_clone.input(AppInput::SetProfile(name_clone.clone())); + bar::wifi::spawn_profile_set(name_clone.clone()); + panels.hide_all(); }); self.wifi_pane.append(&row); } @@ -1213,6 +1307,7 @@ impl App { toggle_lbl.set_hexpand(true); toggle_lbl.set_xalign(0.0); let toggle_switch = gtk4::Switch::new(); + toggle_switch.add_css_class("bt-switch"); toggle_switch.set_active(data.powered); toggle_switch.set_valign(gtk4::Align::Center); toggle_switch.connect_state_set(|_, on| { @@ -1234,26 +1329,24 @@ impl App { lbl.add_css_class("wifi-popover-loading"); self.bt_pane.append(&lbl); } else { - let dh = gtk4::Label::new(Some("Paired")); + let dh = gtk4::Label::new(Some("PAIRED")); dh.add_css_class("wifi-popover-section"); dh.set_xalign(0.0); dh.set_margin_top(2); - dh.set_margin_bottom(2); + dh.set_margin_bottom(4); self.bt_pane.append(&dh); - for dev in &data.devices { + for (i, dev) in data.devices.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); if dev.connected { row.add_css_class("wifi-popover-row-active"); } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if dev.connected { "● " } else { " " }, - dev.name, - ))); + let lbl = gtk4::Label::new(Some(&dev.name)); lbl.set_xalign(0.0); + lbl.set_valign(gtk4::Align::Center); row.set_child(Some(&lbl)); let address = dev.address.clone(); @@ -1275,7 +1368,7 @@ impl App { settings_row.add_css_class("flat"); settings_row.add_css_class("wifi-popover-row"); let settings_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let settings_icon = gtk4::Image::from_paintable(Some(&svg_texture(bar::stats::ICON_BT_SETTINGS))); + let settings_icon = svg_image(bar::stats::ICON_BT_SETTINGS); settings_icon.add_css_class("stat-icon"); settings_box.append(&settings_icon); settings_box.append(>k4::Label::new(Some("Bluetooth settings"))); @@ -1291,65 +1384,60 @@ impl App { // ── Helpers ─────────────────────────────────────────────────────────────────── -fn build_slider_row(icon_svg: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { +fn build_slider_row(label: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); row.add_css_class("control-panel-row"); - row.set_margin_top(2); - row.set_margin_bottom(2); - // Rendered larger than the standard 16px stat/section icons: these are - // the two things in the panel you actually drag, so they should read as - // primary controls rather than blend in with passive readouts. - let icon = gtk4::Image::from_paintable(Some(&svg_texture_sized(icon_svg, 20))); - icon.add_css_class("control-panel-row-icon"); + let lbl = gtk4::Label::new(Some(label)); + lbl.add_css_class("control-panel-row-label"); + lbl.set_xalign(0.0); + lbl.set_width_chars(3); let slider = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); slider.set_draw_value(false); slider.set_hexpand(true); - slider.set_width_request(180); + slider.set_width_request(160); slider.add_css_class("control-panel-slider"); - row.append(&icon); + row.append(&lbl); row.append(&slider); (row, slider) } -fn fmt_speed(kbs: f32) -> String { - if kbs >= 1024.0 { - format!("{:.1} MB/s", kbs / 1024.0) - } else { - format!("{:.0} KB/s", kbs) - } -} - fn wifi_icon_for_signal(pct: u8) -> &'static str { - use bar::stats::{WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, WIFI_WEAK}; + use bar::stats::{ + WIFI_ICON_EXCELLENT, WIFI_ICON_GOOD, WIFI_ICON_OFF, WIFI_ICON_OK, WIFI_ICON_WEAK, + }; match pct { - 75..=100 => WIFI_STRONG, - 50..=74 => WIFI_MEDIUM, - 25..=49 => WIFI_WEAK, - _ => WIFI_OFF, + 75..=100 => WIFI_ICON_EXCELLENT, + 50..=74 => WIFI_ICON_GOOD, + 25..=49 => WIFI_ICON_OK, + 1..=24 => WIFI_ICON_WEAK, + _ => WIFI_ICON_OFF, } } -fn close_parent_popover(widget: >k4::Button) { - if let Some(w) = widget.ancestor(gtk4::Popover::static_type()) { - if let Ok(p) = w.downcast::() { - p.popdown(); - } - } -} + /// Small modal prompting for a password, then saves + joins the network via /// `breadcrumbs add` + `breadcrumbs join`. `on_build` runs on the freshly /// built dialog *before* it's presented — screenshot mode's only hook point, /// since `connect_map` registered any later would miss a map that already /// happened. The real call site passes a no-op. -fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_build: impl FnOnce(>k4::Window)) { +fn show_add_network_dialog( + anchor: &impl IsA, + ssid: String, + on_build: impl FnOnce(>k4::Window), +) { let dialog = gtk4::Window::new(); dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); dialog.add_css_class("wifi-add-dialog"); + if let Some(output) = bread_theme::gtk::output_for_widget(anchor) { + theme::bind_output(&dialog, &output); + } else { + theme::bind_auto(&dialog); + } // A bare gtk4::Window with no titlebar set falls back to GTK's own // minimal CSD: a flat bar with plain system-font title text and no // rounding — which is what actually made this look like a stray window @@ -1433,33 +1521,180 @@ fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_bui fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); pair.add_css_class("stat-pair"); - let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_svg))); + bar_chip(&pair); + let img = svg_image(icon_svg); img.add_css_class("stat-icon"); pair.append(&img); pair.append(label); pair } -pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { - svg_texture_sized(svg_src, 16) +/// Identity of the pills this bar actually draws. Ignores last-window +/// title/address so a tab title change cannot rebuild the row. +fn visible_ws_rows( + workspaces: &[Workspace], + monitor: &str, + active: WorkspaceId, +) -> Vec<(WorkspaceId, String, bool)> { + workspaces + .iter() + .filter(|w| w.monitor == monitor && (w.windows > 0 || w.id == active)) + .map(|w| (w.id, w.name.clone(), w.windows > 0)) + .collect() } -/// Same as `svg_texture` but rendered at an explicit pixel size — used to give -/// primary/interactive icons (e.g. sliders you actually drag) more visual -/// weight than passive informational ones, which otherwise all read as the -/// same flat 16px stroke glyph once emoji stopped providing accidental variety. +fn bar_chip(widget: &impl IsA) { + widget.set_valign(gtk4::Align::Center); + widget.set_vexpand(false); +} + +fn popover_caret() -> gtk4::Box { + let caret = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + caret.add_css_class("popover-caret"); + caret.set_hexpand(true); + caret +} + +fn stagger_row(widget: &impl IsA, i: usize) { + widget.add_css_class("row-in"); + widget.add_css_class(&format!("stagger-{}", i.min(11))); +} + +fn play_once(widget: &impl IsA, class: &str, ms: u64) { + widget.remove_css_class(class); + widget.add_css_class(class); + let w = widget.as_ref().clone(); + let class = class.to_string(); + gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(ms), move || { + w.remove_css_class(&class); + }); +} + +fn make_clock_digits(time: &str) -> Vec { + time.chars() + .map(|ch| { + let lbl = gtk4::Label::new(Some(&ch.to_string())); + lbl.add_css_class("clock-digit"); + if ch == ':' { + lbl.add_css_class("clock-colon"); + } + lbl.set_valign(gtk4::Align::Center); + lbl.set_vexpand(false); + lbl.set_yalign(0.5); + lbl + }) + .collect() +} + +fn flip_clock_digits(digits: &[gtk4::Label], time: &str) { + let chars: Vec = time.chars().collect(); + for (i, lbl) in digits.iter().enumerate() { + let next = chars.get(i).copied().unwrap_or(' '); + let next_s = next.to_string(); + if lbl.label().as_str() == next_s { + continue; + } + lbl.set_label(&next_s); + if next != ':' { + play_once(lbl, "flip", 450); + } + } +} + +fn tick_label(lbl: >k4::Label, text: &str) { + if lbl.label().as_str() == text { + return; + } + lbl.set_label(text); + play_once(lbl, "tick", 360); +} + +fn reveal_media(widget: >k4::Box, show: bool) { + if show && !widget.is_visible() { + play_once(widget, "media-in", 420); + } + widget.set_visible(show); +} + +fn popover_tab(label: &str) -> gtk4::ToggleButton { + let btn = gtk4::ToggleButton::with_label(label); + btn.add_css_class("popover-tab"); + btn.set_hexpand(true); + btn.set_valign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_size_request(-1, CHIP_HEIGHT); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + child.set_hexpand(true); + child.set_vexpand(false); + if let Ok(lbl) = child.downcast::() { + lbl.set_xalign(0.5); + lbl.set_yalign(0.5); + } + } + btn +} + +/// breadcrumbs can list the same profile twice under different case +/// (`Home` / `home`). Keep the active spelling when there is one. +fn unique_profiles(profiles: &[(String, bool)]) -> Vec<(String, bool)> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for (name, active) in profiles { + if *active { + seen.insert(name.to_ascii_lowercase()); + out.push((name.clone(), true)); + } + } + for (name, active) in profiles { + if seen.insert(name.to_ascii_lowercase()) { + out.push((name.clone(), *active)); + } + } + out +} + +pub(crate) fn prepare_icon(img: >k4::Image, px: i32) { + img.set_pixel_size(px); + img.set_valign(gtk4::Align::Center); + img.set_vexpand(false); +} + +pub(crate) fn svg_image(svg_src: &str) -> gtk4::Image { + svg_image_sized(svg_src, ICON_PX as u32) +} + +pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image { + let img = gtk4::Image::from_paintable(Some(&svg_texture_sized(svg_src, px))); + prepare_icon(&img, px as i32); + img +} + +pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { + svg_texture_sized(svg_src, ICON_PX as u32) +} + +/// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK +/// displays the texture at `px` via `Image::set_pixel_size`. pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { use resvg::{tiny_skia, usvg}; + let raster = px.saturating_mul(2).max(1); let fg = theme::fg_color(); - let dim = format!(r#"width="{px}" height="{px}""#); + let dim = format!(r#"width="{raster}" height="{raster}""#); let svg = svg_src .replace("currentColor", &fg) + .replace(r#"stroke-width="2""#, r#"stroke-width="2.35""#) .replace(r#"width="24" height="24""#, &dim); let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parse svg"); let size = tree.size().to_int_size(); let (w, h) = (size.width(), size.height()); let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap"); - resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut()); + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); let bytes = gtk4::glib::Bytes::from_owned(pixmap.take()); gtk4::gdk::MemoryTexture::new( w as i32, @@ -1471,6 +1706,8 @@ pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { .upcast() } + + fn stat_label() -> gtk4::Label { let lbl = gtk4::Label::new(None); lbl.add_css_class("stat-label"); @@ -1515,5 +1752,138 @@ fn main() { if screenshot_req.is_some() { app.allow_multiple_instances(true); } - app.run::(screenshot_req); + app.run::(BarInit { + screenshot: screenshot_req, + monitor: None, + primary: true, + }); +} + +/// Live outputs only. `hyprctl monitors all` (and the hyprland crate's +/// `Monitors::get`) keep ghost connectors after a rename — `DVI-I-1` stayed +/// at 0×0 with `disabled=false` after the panel became `DVI-I-2`, and a +/// geometry fallback then stacked a second bar on the laptop. +#[derive(Debug, Clone, serde::Deserialize)] +struct HyprMon { + name: String, + x: i32, + y: i32, + #[serde(default)] + focused: bool, + #[serde(default)] + disabled: bool, +} + +fn hypr_monitors_live() -> Vec { + let output = match std::process::Command::new("hyprctl") + .args(["monitors", "-j"]) + .output() + { + Ok(o) if o.status.success() => o.stdout, + _ => return Vec::new(), + }; + serde_json::from_slice::>(&output) + .unwrap_or_default() + .into_iter() + .filter(|m| !m.disabled) + .collect() +} + +fn primary_hypr_monitor() -> Option { + let mons = hypr_monitors_live(); + mons.iter() + .find(|m| m.focused) + .or_else(|| mons.first()) + .map(|m| m.name.clone()) +} + +fn hypr_monitor_names() -> Vec { + hypr_monitors_live() + .into_iter() + .map(|m| m.name) + .collect() +} + +fn hypr_monitor_origin(name: &str) -> Option<(i32, i32)> { + hypr_monitors_live() + .into_iter() + .find(|m| m.name == name) + .map(|m| (m.x, m.y)) +} + +/// Hyprland connector names and GDK connector names can disagree after a +/// hotplug (`DVI-I-1` vs `DVI-I-2`). Match the connector first, then the +/// output's origin — transform swaps width/height so size is not reliable. +/// Never steal a GDK output whose connector is already a live Hyprland name. +fn gdk_monitor_for_hypr(name: &str) -> Option { + use gtk4::gdk::prelude::MonitorExt; + use gtk4::gio::prelude::ListModelExt; + let display = gtk4::gdk::Display::default()?; + let list = display.monitors(); + for i in 0..list.n_items() { + let Some(mon) = list.item(i).and_downcast::() else { + continue; + }; + if mon.connector().as_deref() == Some(name) { + return Some(mon); + } + } + let (hx, hy) = hypr_monitor_origin(name)?; + let live = hypr_monitor_names(); + for i in 0..list.n_items() { + let Some(mon) = list.item(i).and_downcast::() else { + continue; + }; + let g = mon.geometry(); + if g.x() != hx || g.y() != hy { + continue; + } + if let Some(conn) = mon.connector() { + if live.iter().any(|n| n != name && n == conn.as_str()) { + return None; + } + } + return Some(mon); + } + None +} + +pub(crate) fn bind_layer_monitor(window: &impl LayerShell, name: &str) -> bool { + match gdk_monitor_for_hypr(name) { + Some(mon) => { + window.set_monitor(Some(&mon)); + true + } + None => { + eprintln!("breadbar: no GDK monitor for {name}"); + false + } + } +} + +fn spawn_satellite(name: &str) -> Option> { + if gdk_monitor_for_hypr(name).is_none() { + eprintln!("breadbar: skip bar on {name}: no matching GDK output"); + return None; + } + let ctrl = App::builder() + .launch(BarInit { + screenshot: None, + monitor: Some(name.to_string()), + primary: false, + }) + .detach(); + ctrl.widget().present(); + Some(ctrl) +} + +fn drop_satellite(satellites: &mut Vec<(String, Controller)>, name: &str) { + satellites.retain(|(n, ctrl)| { + if n == name { + ctrl.widget().set_visible(false); + false + } else { + true + } + }); } diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 0e158cc..61adc0f 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -175,13 +175,15 @@ pub fn build_window(store: Store) -> Ui { let window = gtk4::Window::new(); 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, 48); - window.set_margin(Edge::Right, 20); + 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(360); window.set_keyboard_mode(KeyboardMode::OnDemand); + crate::theme::bind_auto(&window); let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8); outer.set_margin_top(10); @@ -284,8 +286,8 @@ fn make_row(entry: &Entry) -> gtk4::Box { } let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - let show_app = !entry.app_name.is_empty() - && !entry.app_name.eq_ignore_ascii_case(&entry.summary); + let show_app = + !entry.app_name.is_empty() && !entry.app_name.eq_ignore_ascii_case(&entry.summary); if show_app { let app = gtk4::Label::new(Some(&entry.app_name)); app.add_css_class("notification-app"); diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index f221524..5753b65 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -170,15 +170,17 @@ fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); 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, 20); - window.set_margin(Edge::Right, 20); + 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); // 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 5deb1d4..d438292 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -163,9 +163,7 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { container.set_margin_end(14); window.set_child(Some(&container)); - let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture( - crate::bar::stats::ICON_VOLUME, - ))); + let icon = crate::svg_image(crate::bar::stats::ICON_VOLUME); icon.add_css_class("osd-icon"); container.append(&icon); @@ -184,6 +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); if muted { icon.add_css_class("osd-icon-muted"); } else { @@ -209,9 +208,11 @@ fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); 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::theme::bind_auto(&window); window } diff --git a/src/screenshot.rs b/src/screenshot.rs index d3848eb..05e9af6 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -20,11 +20,9 @@ use gtk4::prelude::*; use std::path::PathBuf; use std::time::Duration; -/// Settle time for views whose content depends on `bar::stats::spawn_poller`'s -/// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels, -/// gated on popover visibility) or a similar live-data popover load -/// (connectivity's wifi/bluetooth scan) — capturing any sooner leaves -/// placeholder dashes/"Scanning…" instead of real content. +/// Settle time for views whose content depends on a live-data popover load +/// (connectivity's wifi/bluetooth scan, control-panel sliders) — capturing +/// any sooner leaves placeholder dashes/"Scanning…" instead of real content. const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// Delay between the bar's own `map` and calling `popover.popup()`. Calling @@ -107,11 +105,11 @@ impl Cli { /// outlive `init()` (never stored on `App`), so they have to be cloned out /// before dispatch time same as `control_popover` always was. pub struct Handles { - pub control_popover: gtk4::Popover, - pub connectivity_popover: gtk4::Popover, + pub control_panel: gtk4::Window, + pub connectivity_panel: gtk4::Window, pub wifi_tab_btn: gtk4::ToggleButton, pub bt_tab_btn: gtk4::ToggleButton, - pub media_popover: gtk4::Popover, + pub media_panel: gtk4::Window, pub media_widget: gtk4::Box, pub media_track_lbl: gtk4::Label, /// Already built and primed with sample content by `main.rs` (via @@ -122,10 +120,10 @@ pub struct Handles { pub osd_window: Option, } -/// The bar's fixed height — matches `root.set_exclusive_zone(32)` / -/// `set_default_height: 32` in `main.rs`. Unlike the other views' full -/// canvas, this never varies with `--width`/`--height`. -const BAR_HEIGHT: i32 = 32; +/// 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; pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { let output = req.output; @@ -141,15 +139,15 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: }); } "control-panel" => { - open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.control_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-wifi" => { handles.wifi_tab_btn.set_active(true); - open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-bluetooth" => { handles.bt_tab_btn.set_active(true); - open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "media-popover" => { // Real media state only shows the widget/text when something's @@ -157,8 +155,9 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: // run has nothing playing, so fake enough of it directly on the // widgets to get a representative capture. handles.media_widget.set_visible(true); + handles.media_widget.add_css_class("playing"); handles.media_track_lbl.set_text("Sample Track — Sample Artist"); - open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.media_panel, SETTLE_DELAY, output, width, height); } "notification" | "notification-critical" => { let Some(window) = handles.notification_window else { @@ -196,27 +195,25 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: } } -/// Shared shape for every popover view: force it open shortly after the bar -/// maps (autohide disabled — a programmatic `popup()` has no real input -/// event serial to grab the Wayland seat with), then capture the whole -/// canvas after `settle` once the popover itself maps. -fn open_popover_on_root_map( +/// Shared shape for panel views: present the standalone layer window after +/// the bar maps, then capture the canvas once the panel itself maps. +fn open_panel_on_root_map( root: >k4::ApplicationWindow, - popover: gtk4::Popover, + panel: gtk4::Window, settle: Duration, output: PathBuf, width: i32, height: i32, ) { - let popover_to_open = popover.clone(); + let panel_to_open = panel.clone(); root.connect_map(move |_| { - popover_to_open.set_autohide(false); - let popover_to_open = popover_to_open.clone(); + let panel_to_open = panel_to_open.clone(); gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - popover_to_open.popup(); + panel_to_open.set_visible(true); + panel_to_open.present(); }); }); - popover.connect_map(move |_| { + panel.connect_map(move |_| { let output = output.clone(); gtk4::glib::timeout_add_local_once(settle, move || { finish(bread_screenshots::capture_region(0, 0, width, height, &output)); diff --git a/src/theme.rs b/src/theme.rs index 11820bf..50f34f8 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,5 @@ -use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette}; +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; @@ -7,7 +8,6 @@ thread_local! { } fn load_css() -> String { - let p = load_palette(); // breadbar-specific rules only — fonts, base colours, and generic widgets // come from the shared ecosystem stylesheet (applied first in `apply()`). // Colour is set on each surface (bar, active workspace pill, notification @@ -15,37 +15,78 @@ fn load_css() -> String { // pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for // that background — the pywal hues themselves are untouched. // - // Shared tokens: one radius and one padding rhythm reused across every - // popover/card/OSD surface so they read as one design system rather than - // four different ones. `radius_pill` is only for the tiny transient OSD. - let radius = "10px"; - let radius_sm = "0px"; - let radius_pill = "20px"; - let pad = "10px"; + // Glass workbench: 16px island on the bar, 12px cards/popovers, pill OSD. + // 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"; format!( - "window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\ - .workspace-btn {{ background: transparent; opacity: 0.45; color: {on_bg};\ - border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ - min-width: 20px; margin: 5px 2px; padding: 2px 9px; }}\ - .workspace-btn:hover {{ opacity: 0.8; }}\ - .workspace-btn.active {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ - .stats-box {{ margin-right: 8px; }}\ - .stat-pair {{ margin-right: 14px; }}\ - .stat-icon {{ margin-right: 2px; }}\ - .bt-icon {{ margin-right: 14px; }}\ - separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\ - window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ - window.breadbar-history {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg};\ - border-radius: {radius}; }}\ - .notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ - padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ - .notification-card.urgency-critical {{ border-left-color: {critical}; }}\ - .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ + "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ + @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\ + @keyframes media-eq {{ to {{ min-height: 14px; }} }}\ + @keyframes pop-in {{ from {{ opacity: 0; margin-top: -10px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes pop-out {{ from {{ opacity: 1; margin-top: 0; }} to {{ opacity: 0; margin-top: -6px; }} }}\ + @keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @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; }}\ + window.breadbar button {{ min-height: 0; min-width: 0; }}\ + .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ + background-color: @accent; border-radius: 999px; }}\ + .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ + border-radius: 999px; border: none; outline: none; box-shadow: none;\ + min-width: 32px; min-height: 32px; margin: 0 2px; padding: 0 12px;\ + 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); }}\ + .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; }}\ + .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; }}\ + .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; }}\ + .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),\ + opacity 0.18s ease; }}\ + .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\ + .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\ + .stat-pair.icon-only {{ padding: 6px; border-radius: 999px; }}\ + .stat-icon {{ margin-right: 6px; }}\ + .stat-pair.icon-only .stat-icon {{ margin-right: 0; }}\ + .bt-icon {{ margin-right: 8px; }} + separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\ + background: alpha(@on-bg, 0.10); }}\ + 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; }}\ + .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; }}\ + .notification-card.urgency-critical {{ border-left-color: @red; }}\ + .notification-card.urgency-normal {{ border-left-color: @accent; }}\ .notification-summary {{ font-weight: bold; }}\ - .notification-app {{ opacity: 0.6; }}\ + .notification-app {{ opacity: 0.55; font-size: 11px; letter-spacing: 0.04em; }}\ .notification-actions {{ margin-top: 6px; }}\ - .notification-action {{ padding: 2px 8px; font-size: 11px; }}\ + .notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\ .notification-reply {{ margin-top: 6px; }}\ .notification-reply-entry {{ min-width: 0; }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\ @@ -54,59 +95,126 @@ fn load_css() -> String { .history-time {{ opacity: 0.5; font-size: 11px; }}\ .history-body {{ opacity: 0.75; }}\ .history-card {{ margin-bottom: 6px; }}\ - window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ + 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; }}\ .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ .osd-icon-muted {{ opacity: 0.35; }}\ progressbar.osd-bar {{ min-height: 6px; }}\ - progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 3px; min-height: 6px; }}\ - progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\ - .wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\ - .wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ - .wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\ - .popover-tab-row {{ margin-bottom: {pad}; }}\ - .popover-tab {{ background: transparent; color: {on_bg}; border: none; box-shadow: none;\ - outline: none; border-radius: {radius_sm}; padding: 4px 10px; font-size: 11px;\ - font-weight: bold; opacity: 0.55; }}\ + progressbar.osd-bar trough {{ background-image: none; background-color: alpha(@accent, 0.25);\ + border-radius: 3px; min-height: 6px; }}\ + progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\ + border-radius: 3px; min-height: 6px; }}\ + .wifi-pair {{ padding: 6px; }}\ + window.breadbar-panel {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ + border-radius: 14px; border: 1px solid alpha(@on-bg, 0.12); }}\ + window.breadbar-dismiss, .breadbar-dismiss-hit {{\ + background-color: alpha(#000000, 0.02); }}\ + .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; }}\ + .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;\ + padding: 3px; margin-bottom: 10px; }}\ + .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),\ + opacity 0.22s ease, color 0.22s ease; }}\ .popover-tab:hover {{ opacity: 0.8; }}\ - .popover-tab:checked {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ - .wifi-popover-ssid {{ font-weight: bold; font-size: 13px; }}\ - .wifi-popover-ip {{ opacity: 0.6; font-size: 11px; }}\ - .wifi-popover-status {{ font-size: 11px; margin-top: 2px; }}\ - .wifi-popover-section {{ font-size: 10px; font-weight: bold; opacity: 0.5; letter-spacing: 0.08em; }}\ + .popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\ + .popover-tab label {{ padding: 0; margin: 0; }}\ + .wifi-popover-ssid {{ font-weight: bold; font-size: 18px; }}\ + .wifi-popover-ip {{ opacity: 0.6; font-size: 16px; }}\ + .wifi-popover-status {{ font-size: 16px; margin-top: 2px; }}\ + .wifi-popover-section {{ font-size: 13px; font-weight: bold; opacity: 0.45;\ + letter-spacing: 0.12em; }}\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ - border-radius: {radius_sm}; padding: 4px 6px; }}\ - .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ - .wifi-popover-row-active {{ color: {accent}; }}\ + 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); }}\ + .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; }}\ + .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; }}\ + .stagger-6 {{ animation-delay: 168ms; }} .stagger-7 {{ animation-delay: 196ms; }}\ + .stagger-8 {{ animation-delay: 224ms; }} .stagger-9 {{ animation-delay: 252ms; }}\ + .stagger-10 {{ animation-delay: 280ms; }} .stagger-11 {{ animation-delay: 308ms; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ - window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px;\ - border-radius: {radius}; }}\ - window.wifi-add-dialog headerbar {{ background-color: {bg_rgba}; color: {on_bg};\ + switch.bt-switch, switch.bt-switch:hover, switch.bt-switch:checked,\ + switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\ + 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); }}\ + 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; }}\ + 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.08); box-shadow: none; }}\ + 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: {radius_sm}; padding: 0 6px; }}\ - .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ - .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ - .media-track-lbl {{ font-size: 12px; }}\ - .media-controls {{ padding: 2px; }}\ - .media-btn {{ min-width: 32px; padding: 4px 8px; }}\ - .control-panel-btn {{ padding: 0 6px; margin-left: 6px; border-radius: {radius_sm}; }}\ + .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); }}\ + .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-eq {{ min-height: 14px; margin-right: 4px; }}\ + .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @accent;\ + border-radius: 2px; }}\ + .media-widget.playing .media-eq-bar {{\ + animation: media-eq 0.85s ease-in-out infinite alternate; }}\ + .media-widget.playing .media-eq-bar:nth-child(2) {{ animation-delay: 0.1s; min-height: 11px; }}\ + .media-widget.playing .media-eq-bar:nth-child(3) {{ animation-delay: 0.22s; min-height: 7px; }}\ + .media-widget.playing .media-eq-bar:nth-child(4) {{ animation-delay: 0.06s; min-height: 13px; }}\ + .media-track-lbl {{ font-size: 17px; }}\ + .media-controls {{ padding: 4px; }}\ + .media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\ + transition: background-color 0.18s ease; }}\ + .media-btn:hover {{ background: alpha(@on-bg, 0.10); }}\ + .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),\ + 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); }}\ .control-panel {{ }}\ .control-panel-inner {{ min-width: 220px; padding: {pad}; }}\ - .control-panel-row {{ margin: 4px 0; }}\ - .control-panel-row-icon {{ opacity: 1; margin-right: 4px; }}\ - .control-panel-slider {{ margin: 0; }}\ - .control-panel-stats {{ margin: {pad} 0; }}\ - .control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\ - .control-panel-section {{ margin: {pad} 0; }}\ - .control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\ - letter-spacing: 0.08em; margin-bottom: 4px; }}\ - .control-panel-sink-dropdown {{ }}\ - .power-row {{ margin-top: 2px; }}\ - .power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\ - separator {{ margin: 4px 0; }}\ + .control-panel-header {{ font-size: 12px; font-weight: bold; letter-spacing: 0.12em;\ + opacity: 0.45; margin-bottom: 8px; }}\ + .control-panel-row {{ margin: 8px 0; }}\ + .control-panel-row-label {{ font-size: 16px; opacity: 0.78; }}\ + .control-panel-slider {{ margin: 0; padding: 0; min-height: 18px; }}\ + scale.control-panel-slider trough {{ min-height: 6px; border-radius: 99px;\ + background-image: none; background-color: alpha(@on-bg, 0.12);\ + border: none; outline: none; box-shadow: none; }}\ + scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\ + background-image: none; background-color: @accent; }}\ + scale.control-panel-slider slider {{ min-width: 0; min-height: 0; margin: 0;\ + padding: 0; opacity: 0; background: transparent; border: none;\ + outline: none; box-shadow: none; }}\ + .control-panel-section {{ margin: 8px 0 0; }}\ + .power-row {{ margin-top: 8px; }}\ + .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); }}\ + .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; }}\ + .tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\ + separator {{ margin: 4px 0; background: alpha(@on-bg, 0.10); }}\ /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ slot rule below is what the four inline `.bread-widget-slot`\ containers in main.rs rely on for the same 12px stat-pair rhythm\ @@ -157,14 +265,11 @@ fn load_css() -> String { .bread-padding-xs {{ padding: 4px; }}\ .bread-padding-sm {{ padding: 8px; }}\ .bread-padding-md {{ padding: 12px; }}", - bg_plain = p.background, - bg_rgba = hex_to_rgba(&p.background, 0.92), - surface = p.color0, - accent = p.color4, - critical = p.color1, - on_bg = ink_on(&p.background), - on_surface = ink_on(&p.color0), - trough = hex_to_rgba(&p.color4, 0.25), + radius = radius, + radius_bar = radius_bar, + radius_sm = radius_sm, + radius_pill = radius_pill, + pad = pad, ) } @@ -175,6 +280,31 @@ pub fn fg_color() -> String { ink_on(&load_palette().background).to_string() } +/// Ink colour for the given Hyprland output's wallpaper palette. +#[allow(dead_code)] +pub fn fg_color_for(output: &str) -> String { + ink_on(&load_palette_for(output).background).to_string() +} + +/// Bind this window (and its popover children) to `output`'s palette. +/// +/// App CSS still uses `@accent` / `@on-bg` tokens; `bind_window_with_app_css` +/// resolves them against that output. Display-level [`apply`] stays as the +/// SIGHUP / single-output fallback. +pub fn bind_output(widget: &impl IsA, output: &str) { + bgtk::bind_window_with_app_css(widget, output, load_css_for); +} + +/// Bind a satellite window (notification, history, OSD, wifi dialog) to +/// whichever output it is actually rendered on. +pub fn bind_auto(window: &impl IsA) { + bgtk::bind_window_auto_with_app_css(window, load_css_for); +} + +fn load_css_for(_palette: &Palette) -> String { + load_css() +} + /// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`. pub fn apply() { // Shared ecosystem base (fonts, palette, generic widgets) — applied first diff --git a/src/widgets/render.rs b/src/widgets/render.rs index 53de47b..785470b 100644 --- a/src/widgets/render.rs +++ b/src/widgets/render.rs @@ -24,7 +24,8 @@ const DEFAULT_LABEL_MAX_WIDTH_CHARS: i32 = 32; fn bundled_icon(name: &str) -> Option<&'static str> { use crate::bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_OFF, BT_ON, ICON_BRIGHTNESS, ICON_LOCK, - ICON_VOLUME, WIFI_OFF, WIFI_STRONG, + ICON_RESTART, ICON_SHUTDOWN, ICON_SLEEP, ICON_VOLUME, WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, + WIFI_WEAK, }; Some(match name { "ac-power" => AC_POWER, @@ -34,8 +35,13 @@ fn bundled_icon(name: &str) -> Option<&'static str> { "bluetooth-on" => BT_ON, "bluetooth-off" => BT_OFF, "wifi-strong" => WIFI_STRONG, + "wifi-medium" => WIFI_MEDIUM, + "wifi-weak" => WIFI_WEAK, "wifi-off" => WIFI_OFF, "lock" => ICON_LOCK, + "sleep" => ICON_SLEEP, + "restart" => ICON_RESTART, + "shutdown" => ICON_SHUTDOWN, "volume" => ICON_VOLUME, "brightness" => ICON_BRIGHTNESS, _ => return None, @@ -171,6 +177,7 @@ pub fn build_node(node: &WidgetNode, widget_id: &str) -> gtk4::Widget { let px = size.unwrap_or(16).max(1) as u32; let texture = icon_texture(widget_id, name.as_deref(), path.as_deref(), px); let image = gtk4::Image::from_paintable(texture.as_ref()); + crate::prepare_icon(&image, px as i32); image.upcast() } WidgetNode::Progress { value, .. } => { From 96d666b3cb6755453afd4a42bf6b60903ffb6825 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:45:59 +0800 Subject: [PATCH 37/85] Fix CI: ship panel.rs and the workspace trail helper mod panel and stretch_geom_on were referenced on main but not committed, so --locked release builds failed. --- src/bar/workspaces.rs | 163 +++++++++++++++++++++++++++++++----------- src/main.rs | 58 +++++++++++---- src/panel.rs | 154 +++++++++++++++++++++++++++++++++++++++ src/theme.rs | 5 +- 4 files changed, 325 insertions(+), 55 deletions(-) create mode 100644 src/panel.rs diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 1181f02..fd83c79 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -230,49 +230,68 @@ impl WorkspaceTrail { } pub fn place(&self, btn: >k4::Button) { - self.cancel(); - if let Some(g) = button_geom(btn, &self.overlay) { - self.apply(&g); - return; - } - // First map: the button exists but has no allocation yet. let pill = self.pill.clone(); let host = self.host.clone(); - let btn = btn.clone(); let inner = self.inner.clone(); - let id = self.overlay.add_tick_callback(move |ov, _| { - let Some(g) = button_geom(&btn, ov) else { - return ControlFlow::Continue; - }; + self.when_stable(btn, move |g| { apply_geom(&host, &pill, &inner, &g); - inner.borrow_mut().tick = None; - ControlFlow::Break }); - self.inner.borrow_mut().tick = Some(id); } pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { let from_g = self.from_geom(from); - if let Some(to_g) = button_geom(to, &self.overlay) { - self.stretch_geom(from_g, to_g); - return; - } - // Destination just appeared (empty workspace becoming active) and - // has no allocation yet. Wait one layout pass, then stretch from - // the last pill — don't snap via place(). - self.cancel(); let overlay = self.overlay.clone(); let pill = self.pill.clone(); let host = self.host.clone(); let inner = self.inner.clone(); - let btn = to.clone(); + let dest = to.clone(); + self.when_stable(to, move |to_g| { + stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g, Some(dest)); + }); + } + + /// New empty-workspace buttons first allocate at CSS `min-width` (32px) + /// and only then grow to the padded label. Two stable frames of that + /// placeholder is not enough — empty→empty used to shrink the pill to it. + fn when_stable(&self, btn: >k4::Button, then: impl FnOnce(Geom) + 'static) { + self.cancel(); + let btn = btn.clone(); + let inner = self.inner.clone(); + let last = std::cell::Cell::new(None::); + let same = std::cell::Cell::new(0u8); + let frames = std::cell::Cell::new(0u8); + let then = std::cell::Cell::new(Some(then)); let id = self.overlay.add_tick_callback(move |ov, _| { - let Some(to_g) = button_geom(&btn, ov) else { - return ControlFlow::Continue; + frames.set(frames.get().saturating_add(1)); + let n = frames.get(); + let Some(g) = button_geom(&btn, ov) else { + last.set(None); + same.set(0); + return if n > 24 { + inner.borrow_mut().tick = None; + ControlFlow::Break + } else { + ControlFlow::Continue + }; }; - inner.borrow_mut().tick = None; - stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g); - ControlFlow::Break + // Still sitting on the 32px min-width slot, or smaller than the + // button's natural request — keep waiting for the real layout. + if still_placeholder(&btn, &g) && n < 20 { + last.set(None); + same.set(0); + return ControlFlow::Continue; + } + let stable = last.get().is_some_and(|p| geom_close(&p, &g)); + last.set(Some(g)); + same.set(if stable { same.get().saturating_add(1) } else { 0 }); + if same.get() >= 2 || n > 22 { + inner.borrow_mut().tick = None; + if let Some(f) = then.take() { + f(g); + } + return ControlFlow::Break; + } + ControlFlow::Continue }); self.inner.borrow_mut().tick = Some(id); } @@ -286,20 +305,68 @@ impl WorkspaceTrail { from.and_then(|b| button_geom(b, &self.overlay)) } - fn stretch_geom(&self, from_g: Option, to_g: Geom) { - stretch_geom_on( - &self.overlay, - &self.host, - &self.pill, - &self.inner, - from_g, - to_g, - ); +} + +fn stretch_geom_on( + overlay: >k4::Overlay, + host: >k4::Fixed, + pill: >k4::Box, + inner: &Rc>, + from_g: Option, + to_g: Geom, + dest: Option, +) { + let Some(from_g) = from_g else { + apply_geom(host, pill, inner, &to_g); + return; + }; + if (from_g.x - to_g.x).abs() < 0.5 && (from_g.w - to_g.w).abs() < 0.5 { + apply_geom(host, pill, inner, &to_g); + return; } - fn apply(&self, g: &Geom) { - apply_geom(&self.host, &self.pill, &self.inner, g); + let span_x = from_g.x.min(to_g.x); + let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; + let mid = Geom { + x: span_x, + y: to_g.y, + w: span_w, + h: to_g.h, + }; + + if let Some(id) = inner.borrow_mut().tick.take() { + id.remove(); } + let started = Instant::now(); + let pill = pill.clone(); + let host = host.clone(); + let inner_tick = inner.clone(); + let dest = dest.clone(); + let ov = overlay.clone(); + let id = overlay.add_tick_callback(move |_, _| { + let elapsed = started.elapsed().as_secs_f64() * 1000.0; + let (g, done) = if elapsed < STRETCH_MS { + let t = ease(elapsed / STRETCH_MS); + (lerp_geom(&from_g, &mid, t), false) + } else if elapsed < STRETCH_MS + SNAP_MS { + let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); + (lerp_geom(&mid, &to_g, t), false) + } else { + let end = dest + .as_ref() + .and_then(|b| button_geom(b, &ov)) + .unwrap_or(to_g); + (end, true) + }; + apply_geom(&host, &pill, &inner_tick, &g); + if done { + inner_tick.borrow_mut().tick = None; + ControlFlow::Break + } else { + ControlFlow::Continue + } + }); + inner.borrow_mut().tick = Some(id); } fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option { @@ -324,11 +391,27 @@ fn apply_geom(host: >k4::Fixed, pill: >k4::Box, inner: &Rc bool { + let (min_w, nat_w, _, _) = btn.measure(gtk4::Orientation::Horizontal, -1); + g.w <= f64::from(min_w) + 1.0 || g.w + 0.5 < f64::from(nat_w) +} + +fn geom_close(a: &Geom, b: &Geom) -> bool { + (a.x - b.x).abs() < 0.5 + && (a.y - b.y).abs() < 0.5 + && (a.w - b.w).abs() < 0.5 + && (a.h - b.h).abs() < 0.5 +} + fn lerp(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t } diff --git a/src/main.rs b/src/main.rs index ad09dc3..21c4646 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,9 +62,11 @@ pub struct App { cpu_pair: gtk4::Box, mem_pair: gtk4::Box, pwr_pair: gtk4::Box, + gpu_pair: gtk4::Box, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, + gpu_lbl: gtk4::Label, vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, bat_img: gtk4::Image, @@ -240,6 +242,7 @@ impl SimpleComponent for App { let cpu_lbl = stat_label(); let mem_lbl = stat_label(); let pwr_lbl = stat_label(); + let gpu_lbl = stat_label(); let vol_lbl = stat_label(); let bat_lbl = stat_label(); @@ -380,11 +383,22 @@ impl SimpleComponent for App { let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); - let system_stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - system_stats_box.append(&cpu_pair); - system_stats_box.append(&mem_pair); - system_stats_box.append(&pwr_pair); - system_stats_box.set_visible(false); + let gpu_pair = stat_pair(asset!("GPU.svg"), &gpu_lbl); + for pair in [&cpu_pair, &mem_pair, &pwr_pair, &gpu_pair] { + pair.add_css_class("sys-stat"); + pair.set_hexpand(true); + } + gpu_pair.set_visible(false); + 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); + sys_row1.append(&cpu_pair); + sys_row1.append(&mem_pair); + let sys_row2 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + sys_row2.append(&gpu_pair); + sys_row2.append(&pwr_pair); + system_stats_box.append(&sys_row1); + system_stats_box.append(&sys_row2); let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); system_sep.add_css_class("bar-sep"); system_sep.set_visible(false); @@ -507,6 +521,13 @@ impl SimpleComponent for App { let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); + let sys_header = gtk4::Label::new(Some("SYSTEM")); + sys_header.add_css_class("control-panel-header"); + sys_header.set_xalign(0.0); + sys_header.set_margin_top(10); + panel_inner.append(&sys_header); + panel_inner.append(&system_stats_box); + let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); power_row.add_css_class("power-row"); power_row.set_halign(gtk4::Align::Center); @@ -697,9 +718,11 @@ impl SimpleComponent for App { cpu_pair, mem_pair, pwr_pair, + gpu_pair, cpu_lbl, mem_lbl, pwr_lbl, + gpu_lbl, vol_lbl, bat_lbl, bat_img, @@ -854,17 +877,24 @@ impl SimpleComponent for App { self.date_lbl.set_label(&bar::clock::date()); } AppInput::StatsUpdate(stats) => { - self.cpu_lbl.set_label(&stats.cpu); + let cpu = match stats.cpu_temp { + Some(t) => format!("{} · {:.0}°", stats.cpu, t), + None => stats.cpu, + }; + self.cpu_lbl.set_label(&cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); - - // Island bar never shows the system-stats trio — they live - // in the control panel. Keep the widgets hidden so a later - // re-parent cannot accidentally flash them. - self.cpu_pair.set_visible(false); - self.mem_pair.set_visible(false); - self.pwr_pair.set_visible(false); - self.system_stats_box.set_visible(false); + match stats.gpu_usage { + Some(g) => { + let gpu = match stats.gpu_temp { + Some(t) => format!("{g}% · {t:.0}°"), + None => format!("{g}%"), + }; + self.gpu_lbl.set_label(&gpu); + self.gpu_pair.set_visible(true); + } + None => self.gpu_pair.set_visible(false), + } self.system_sep.set_visible(false); tick_label(&self.vol_lbl, &stats.volume_pct.to_string()); diff --git a/src/panel.rs b/src/panel.rs new file mode 100644 index 0000000..c1bcd75 --- /dev/null +++ b/src/panel.rs @@ -0,0 +1,154 @@ +//! Standalone layer-shell panels for wifi / control / media. +//! +//! GTK `Popover` is an xdg_popup child of the island, so it paints over the +//! bar and Hyprland can only fade it. These are their own surfaces, parked +//! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from +//! the right. + +use gtk4::gdk::Key; +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, 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; + +#[derive(Clone)] +pub struct PanelSet { + pub connectivity: gtk4::Window, + pub control: gtk4::Window, + pub media: gtk4::Window, + dismiss: gtk4::Window, +} + +impl PanelSet { + pub fn new( + monitor: &str, + connectivity_child: &impl IsA, + control_child: &impl IsA, + media_child: &impl IsA, + ) -> Self { + let connectivity = make_panel("wifi-popover", connectivity_child, monitor); + let control = make_panel("control-panel", control_child, monitor); + let media = make_panel("media-popover", media_child, monitor); + let dismiss = make_dismiss(monitor); + + let set = Self { + connectivity, + control, + media, + dismiss, + }; + set.wire_dismiss(); + set.wire_escape(); + set + } + + pub fn toggle(&self, which: >k4::Window) { + if which.is_visible() { + self.hide_all(); + } else { + self.show(which); + } + } + + pub fn show(&self, which: >k4::Window) { + self.hide_panels(); + // Dismiss first so the panel maps above it (same Overlay layer). + self.dismiss.set_visible(true); + self.dismiss.present(); + which.set_visible(true); + which.present(); + } + + pub fn hide_all(&self) { + self.hide_panels(); + self.dismiss.set_visible(false); + } + + fn hide_panels(&self) { + self.connectivity.set_visible(false); + self.control.set_visible(false); + self.media.set_visible(false); + } + + fn wire_dismiss(&self) { + let set = self.clone(); + let click = gtk4::GestureClick::new(); + click.set_button(0); + click.connect_pressed(move |_, _, _, _| { + set.hide_all(); + }); + if let Some(child) = self.dismiss.child() { + child.add_controller(click); + } else { + self.dismiss.add_controller(click); + } + } + + fn wire_escape(&self) { + for win in [&self.connectivity, &self.control, &self.media] { + let set = self.clone(); + let keys = gtk4::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, _| { + if key == Key::Escape { + set.hide_all(); + gtk4::glib::Propagation::Stop + } else { + gtk4::glib::Propagation::Proceed + } + }); + win.add_controller(keys); + } + } +} + +fn make_panel(class: &str, child: &impl IsA, monitor: &str) -> gtk4::Window { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-panel"); + window.add_css_class(class); + window.set_decorated(false); + 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); + window.set_exclusive_zone(-1); + window.set_keyboard_mode(KeyboardMode::OnDemand); + window.set_child(Some(child)); + bind_layer_monitor(&window, monitor); + theme::bind_output(&window, monitor); + window.set_visible(false); + window +} + +fn make_dismiss(monitor: &str) -> gtk4::Window { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-dismiss"); + window.init_layer_shell(); + 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); + 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 + // alpha is what actually receives the click-away. + let hit = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + hit.add_css_class("breadbar-dismiss-hit"); + hit.set_hexpand(true); + hit.set_vexpand(true); + window.set_child(Some(&hit)); + bind_layer_monitor(&window, monitor); + theme::bind_output(&window, monitor); + window.set_visible(false); + window +} diff --git a/src/theme.rs b/src/theme.rs index 50f34f8..3a2ecc7 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -190,7 +190,10 @@ fn load_css() -> String { .control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\ .control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\ .control-panel {{ }}\ - .control-panel-inner {{ min-width: 220px; padding: {pad}; }}\ + .control-panel-inner {{ min-width: 248px; padding: {pad}; }}\ + .sys-grid {{ margin: 2px 0 6px; }}\ + .sys-stat {{ padding: 4px 2px; background: transparent; }}\ + .sys-stat:hover {{ background: transparent; }}\ .control-panel-header {{ font-size: 12px; font-weight: bold; letter-spacing: 0.12em;\ opacity: 0.45; margin-bottom: 8px; }}\ .control-panel-row {{ margin: 8px 0; }}\ From 241dfd17a75e486df31fa61b495b8bf3fc68fa0b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:49:52 +0800 Subject: [PATCH 38/85] Include GPU.svg so the bar release build can compile --- assets/GPU.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 assets/GPU.svg diff --git a/assets/GPU.svg b/assets/GPU.svg new file mode 100644 index 0000000..03c7f1c --- /dev/null +++ b/assets/GPU.svg @@ -0,0 +1 @@ + From e4c12e9b62cc570d410626abc24c38ac672b74ba Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:04:05 +0800 Subject: [PATCH 39/85] Add audio output switching and fix the workspace trail The control panel lists PipeWire sinks and sets the default, moving playing streams so the change is immediate. The workspace pill starts on switch without a layout wait, measures against the Fixed host so it stays centered on the digit, and ignores row-wide bounds that used to stretch it across several chips. --- src/bar/control.rs | 21 +++- src/bar/workspaces.rs | 216 +++++++++++++++++++----------------------- src/main.rs | 51 ++++++++++ src/theme.rs | 12 ++- 4 files changed, 172 insertions(+), 128 deletions(-) diff --git a/src/bar/control.rs b/src/bar/control.rs index f480057..43cb6c3 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,12 +121,29 @@ pub fn spawn_set_brightness(v: f64) { }); } -#[allow(dead_code)] -pub fn spawn_set_sink(name: String) { +pub fn spawn_set_sink(name: String, sender: ComponentSender) { relm4::spawn(async move { let _ = tokio::process::Command::new("pactl") .args(["set-default-sink", &name]) .output() .await; + // Default sink alone leaves already-playing streams on the old + // device — move them too so the switch is audible immediately. + if let Ok(o) = tokio::process::Command::new("pactl") + .args(["list", "short", "sink-inputs"]) + .output() + .await + { + for line in String::from_utf8_lossy(&o.stdout).lines() { + let Some(id) = line.split_whitespace().next() else { + continue; + }; + let _ = tokio::process::Command::new("pactl") + .args(["move-sink-input", id, &name]) + .output() + .await; + } + } + spawn_load(sender); }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index fd83c79..2c05a15 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -139,8 +139,14 @@ pub fn make_button( btn.add_css_class("active"); } btn.set_valign(gtk4::Align::Center); + btn.set_halign(gtk4::Align::Center); btn.set_vexpand(false); + btn.set_hexpand(false); btn.set_size_request(-1, crate::CHIP_HEIGHT); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + } btn.connect_clicked(move |_| { relm4::spawn(async move { switch_workspace(id).await; @@ -189,7 +195,7 @@ impl WorkspaceTrail { pill.set_visible(false); host.put(&pill, 0.0, 0.0); - let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); + let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 1); buttons.set_halign(gtk4::Align::Fill); buttons.set_valign(gtk4::Align::Center); buttons.set_vexpand(false); @@ -230,150 +236,125 @@ impl WorkspaceTrail { } pub fn place(&self, btn: >k4::Button) { + self.cancel(); + if let Some(g) = button_geom(btn, &self.host) { + apply_geom(&self.host, &self.pill, &self.inner, &inset_pill(g)); + return; + } let pill = self.pill.clone(); let host = self.host.clone(); let inner = self.inner.clone(); - self.when_stable(btn, move |g| { - apply_geom(&host, &pill, &inner, &g); + let btn = btn.clone(); + let id = self.overlay.add_tick_callback(move |_, _| { + let Some(g) = button_geom(&btn, &host) else { + return ControlFlow::Continue; + }; + apply_geom(&host, &pill, &inner, &inset_pill(g)); + inner.borrow_mut().tick = None; + ControlFlow::Break }); + self.inner.borrow_mut().tick = Some(id); } pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { - let from_g = self.from_geom(from); - let overlay = self.overlay.clone(); + self.cancel(); + let Some(from_g) = self.from_geom(from) else { + self.place(to); + return; + }; + let dest = to.clone(); let pill = self.pill.clone(); let host = self.host.clone(); let inner = self.inner.clone(); - let dest = to.clone(); - self.when_stable(to, move |to_g| { - stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g, Some(dest)); - }); - } - - /// New empty-workspace buttons first allocate at CSS `min-width` (32px) - /// and only then grow to the padded label. Two stable frames of that - /// placeholder is not enough — empty→empty used to shrink the pill to it. - fn when_stable(&self, btn: >k4::Button, then: impl FnOnce(Geom) + 'static) { - self.cancel(); - let btn = btn.clone(); - let inner = self.inner.clone(); - let last = std::cell::Cell::new(None::); - let same = std::cell::Cell::new(0u8); - let frames = std::cell::Cell::new(0u8); - let then = std::cell::Cell::new(Some(then)); - let id = self.overlay.add_tick_callback(move |ov, _| { - frames.set(frames.get().saturating_add(1)); - let n = frames.get(); - let Some(g) = button_geom(&btn, ov) else { - last.set(None); - same.set(0); - return if n > 24 { - inner.borrow_mut().tick = None; - ControlFlow::Break - } else { - ControlFlow::Continue - }; - }; - // Still sitting on the 32px min-width slot, or smaller than the - // button's natural request — keep waiting for the real layout. - if still_placeholder(&btn, &g) && n < 20 { - last.set(None); - same.set(0); - return ControlFlow::Continue; - } - let stable = last.get().is_some_and(|p| geom_close(&p, &g)); - last.set(Some(g)); - same.set(if stable { same.get().saturating_add(1) } else { 0 }); - if same.get() >= 2 || n > 22 { - inner.borrow_mut().tick = None; - if let Some(f) = then.take() { - f(g); + let started = Instant::now(); + let id = self.overlay.add_tick_callback(move |_, _| { + let to_g = resolved_dest(&dest, &host, &from_g); + let mid = { + let span_x = from_g.x.min(to_g.x); + let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; + Geom { + x: span_x, + y: to_g.y, + w: span_w, + h: to_g.h, } - return ControlFlow::Break; + }; + let elapsed = started.elapsed().as_secs_f64() * 1000.0; + let (g, done) = if elapsed < STRETCH_MS { + let t = ease(elapsed / STRETCH_MS); + (lerp_geom(&from_g, &mid, t), false) + } else if elapsed < STRETCH_MS + SNAP_MS { + let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); + (lerp_geom(&mid, &to_g, t), false) + } else { + (to_g, true) + }; + apply_geom(&host, &pill, &inner, &g); + if done { + inner.borrow_mut().tick = None; + ControlFlow::Break + } else { + ControlFlow::Continue } - ControlFlow::Continue }); self.inner.borrow_mut().tick = Some(id); } fn from_geom(&self, from: Option<>k4::Button>) -> Option { let st = self.inner.borrow(); - if self.pill.is_visible() && st.geom.w > 0.5 { + // A leftover mid-stretch can be as wide as the whole row — never + // treat that as the start of the next animation. + if self.pill.is_visible() && st.geom.w > 0.5 && st.geom.w <= MAX_CHIP_W { return Some(st.geom); } drop(st); - from.and_then(|b| button_geom(b, &self.overlay)) + from.and_then(|b| button_geom(b, &self.host).map(inset_pill)) } - } -fn stretch_geom_on( - overlay: >k4::Overlay, - host: >k4::Fixed, - pill: >k4::Box, - inner: &Rc>, - from_g: Option, - to_g: Geom, - dest: Option, -) { - let Some(from_g) = from_g else { - apply_geom(host, pill, inner, &to_g); - return; - }; - if (from_g.x - to_g.x).abs() < 0.5 && (from_g.w - to_g.w).abs() < 0.5 { - apply_geom(host, pill, inner, &to_g); - return; - } +/// Keep the trail slimmer than the hit target so the fill doesn't look +/// like a second, fatter button. +const PILL_INSET_X: f64 = 5.0; +const PILL_INSET_Y: f64 = 3.0; +/// One workspace chip is a digit + padding. Wider than this is the overlay +/// or the whole button row leaking through `compute_bounds`. +const MAX_CHIP_W: f64 = 72.0; - let span_x = from_g.x.min(to_g.x); - let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; - let mid = Geom { - x: span_x, - y: to_g.y, - w: span_w, - h: to_g.h, - }; - - if let Some(id) = inner.borrow_mut().tick.take() { - id.remove(); +fn inset_pill(g: Geom) -> Geom { + let w = (g.w - PILL_INSET_X * 2.0).max(10.0); + let h = (g.h - PILL_INSET_Y * 2.0).max(18.0); + Geom { + x: g.x + (g.w - w) * 0.5, + y: g.y + (g.h - h) * 0.5, + w, + h, } - let started = Instant::now(); - let pill = pill.clone(); - let host = host.clone(); - let inner_tick = inner.clone(); - let dest = dest.clone(); - let ov = overlay.clone(); - let id = overlay.add_tick_callback(move |_, _| { - let elapsed = started.elapsed().as_secs_f64() * 1000.0; - let (g, done) = if elapsed < STRETCH_MS { - let t = ease(elapsed / STRETCH_MS); - (lerp_geom(&from_g, &mid, t), false) - } else if elapsed < STRETCH_MS + SNAP_MS { - let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); - (lerp_geom(&mid, &to_g, t), false) - } else { - let end = dest - .as_ref() - .and_then(|b| button_geom(b, &ov)) - .unwrap_or(to_g); - (end, true) - }; - apply_geom(&host, &pill, &inner_tick, &g); - if done { - inner_tick.borrow_mut().tick = None; - ControlFlow::Break - } else { - ControlFlow::Continue +} + +fn resolved_dest(btn: >k4::Button, host: >k4::Fixed, from: &Geom) -> Geom { + match button_geom(btn, host) { + Some(g) if !still_placeholder(btn, &g) => inset_pill(g), + Some(g) => { + let centered = inset_pill(g); + Geom { + x: centered.x + (centered.w - from.w) * 0.5, + y: centered.y + (centered.h - from.h) * 0.5, + w: from.w, + h: from.h, + } } - }); - inner.borrow_mut().tick = Some(id); + None => *from, + } } -fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option { - let r = btn.compute_bounds(overlay)?; +/// Position in the Fixed host's space — that's what `host.move_` uses. +/// Measuring against the Overlay instead left the pill a few px left of +/// the digit whenever the host and overlay origins disagreed. +fn button_geom(btn: >k4::Button, host: >k4::Fixed) -> Option { + let r = btn.compute_bounds(host)?; let w = f64::from(r.width()); let h = f64::from(r.height()); - if w < 1.0 || h < 1.0 { + if w < 8.0 || h < 8.0 || w > MAX_CHIP_W { return None; } Some(Geom { @@ -405,13 +386,6 @@ fn still_placeholder(btn: >k4::Button, g: &Geom) -> bool { g.w <= f64::from(min_w) + 1.0 || g.w + 0.5 < f64::from(nat_w) } -fn geom_close(a: &Geom, b: &Geom) -> bool { - (a.x - b.x).abs() < 0.5 - && (a.y - b.y).abs() < 0.5 - && (a.w - b.w).abs() < 0.5 - && (a.h - b.h).abs() < 0.5 -} - fn lerp(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t } diff --git a/src/main.rs b/src/main.rs index 21c4646..309a8fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,6 +99,8 @@ pub struct App { panel_vol_slider: gtk4::Scale, panel_bright_slider: gtk4::Scale, panel_loading: Rc>, + sink_box: gtk4::Box, + sink_section: gtk4::Box, // ── Tray ────────────────────────────────────────────────────────────── tray_section: gtk4::Box, @@ -517,6 +519,18 @@ impl SimpleComponent for App { let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); + let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.add_css_class("control-panel-section"); + let sink_header = gtk4::Label::new(Some("OUTPUT")); + sink_header.add_css_class("control-panel-header"); + sink_header.set_xalign(0.0); + sink_header.set_margin_top(6); + let sink_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.append(&sink_header); + sink_section.append(&sink_box); + sink_section.set_visible(false); + panel_inner.append(&sink_section); + let bright_row = build_slider_row("bl", 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); @@ -747,6 +761,8 @@ impl SimpleComponent for App { panel_vol_slider, panel_bright_slider, panel_loading, + sink_box, + sink_section, tray_section, tray_sep, tray_box, @@ -1033,6 +1049,7 @@ impl SimpleComponent for App { self.panel_vol_slider.set_value(data.volume); self.panel_bright_slider.set_value(data.brightness); self.panel_loading.set(false); + self.rebuild_sinks(&data.sinks, &sender); } AppInput::WidgetsUpdate(specs) => { self.reconcile_widgets(specs); @@ -1129,6 +1146,40 @@ impl App { } } + fn rebuild_sinks( + &mut self, + sinks: &[bar::control::AudioSink], + sender: &ComponentSender, + ) { + while let Some(child) = self.sink_box.first_child() { + self.sink_box.remove(&child); + } + self.sink_section.set_visible(!sinks.is_empty()); + for (i, sink) in sinks.iter().enumerate() { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + row.add_css_class("sink-row"); + if sink.is_default { + row.add_css_class("wifi-popover-row-active"); + } + stagger_row(&row, i); + let lbl = gtk4::Label::new(Some(&sink.description)); + lbl.set_xalign(0.0); + lbl.set_hexpand(true); + lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + lbl.set_max_width_chars(22); + lbl.set_valign(gtk4::Align::Center); + row.set_child(Some(&lbl)); + let name = sink.name.clone(); + let sender = sender.clone(); + row.connect_clicked(move |_| { + bar::control::spawn_set_sink(name.clone(), sender.clone()); + }); + self.sink_box.append(&row); + } + } + fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), diff --git a/src/theme.rs b/src/theme.rs index 3a2ecc7..f11b8d2 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -39,10 +39,10 @@ fn load_css() -> String { window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\ window.breadbar button {{ min-height: 0; min-width: 0; }}\ .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ - background-color: @accent; border-radius: 999px; }}\ + background-color: @accent; border-radius: 12px; }}\ .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ - border-radius: 999px; border: none; outline: none; box-shadow: none;\ - min-width: 32px; min-height: 32px; margin: 0 2px; padding: 0 12px;\ + 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); }}\ @@ -67,9 +67,10 @@ fn load_css() -> String { opacity 0.18s ease; }}\ .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\ .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\ - .stat-pair.icon-only {{ padding: 6px; border-radius: 999px; }}\ + .stat-pair.icon-only {{ padding: 4px; border-radius: 999px;\ + min-width: 32px; min-height: 32px; }}\ .stat-icon {{ margin-right: 6px; }}\ - .stat-pair.icon-only .stat-icon {{ margin-right: 0; }}\ + .stat-pair.icon-only .stat-icon {{ margin: 0; }}\ .bt-icon {{ margin-right: 8px; }} separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\ background: alpha(@on-bg, 0.10); }}\ @@ -208,6 +209,7 @@ fn load_css() -> String { padding: 0; opacity: 0; background: transparent; border: none;\ outline: none; box-shadow: none; }}\ .control-panel-section {{ margin: 8px 0 0; }}\ + .sink-row label {{ font-size: 15px; }}\ .power-row {{ margin-top: 8px; }}\ .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;\ From cd7465a18bb90bf138c648fddd32d56d2b0b1bfb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:09:04 +0800 Subject: [PATCH 40/85] Bump version to v0.3.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bc508e..d7ff339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,7 +207,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.3.1" +version = "0.3.3" dependencies = [ "anyhow", "bread-screenshots", diff --git a/Cargo.toml b/Cargo.toml index 1e24f17..f5bfbb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.3.1" +version = "0.3.3" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" From 9c4205b1b25904d16a1cf828de3b11c69f49d19e Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 6 Aug 2026 08:47:05 +0800 Subject: [PATCH 41/85] clippy: fix char-comparison and map_or lints (cherry picked from commit 2e393082a744fe85f1ca1f8c6af48ce288e64b6c) --- src/bar/stats.rs | 2 +- src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 892f792..26358e0 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -402,7 +402,7 @@ fn read_crumbs_profile() -> Option { for line in text.lines() { if let Some(rest) = line.trim().strip_prefix("profile") { let val = rest - .trim_start_matches(|c: char| c == ' ' || c == '=') + .trim_start_matches([' ', '=']) .trim_matches('"'); if !val.is_empty() { return Some(val.to_string()); diff --git a/src/main.rs b/src/main.rs index 309a8fd..5210546 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1023,7 +1023,7 @@ impl SimpleComponent for App { let within_linger = self .media_paused_at - .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); + .is_none_or(|t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); reveal_media(&self.media_widget, within_linger); } else { From 700fc3ed16b54aa68b88ca70b260fe738e485f18 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:02:51 +0800 Subject: [PATCH 42/85] Unify bread-ecosystem crate pins at v0.7.4 bread-theme was already pinned to v0.7.4 while bread-utils and bread-screenshots trailed at v0.7.2, even though bread-ecosystem locks all workspace packages together as of a9754d9. Bump the two lagging pins so all three crates from that repo resolve to the same tag/commit. (cherry picked from commit f0de82aafb547134e0edc9dac8d72c01586db57c) --- Cargo.lock | 8 ++++---- Cargo.toml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7ff339..14765ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,8 +152,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" dependencies = [ "anyhow", "bread-utils", @@ -196,8 +196,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" dependencies = [ "bread-shared 0.7.0", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index f5bfbb6..1825aa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,12 @@ bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["bread-client"] } # v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if # ecosystem crates move independently. bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } From 3049a20be3a24fdd4fe2ee68935acdc74e1e8793 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:07:51 +0800 Subject: [PATCH 43/85] gitignore: exclude graphify-out local tool cache --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 816e2ad..4f90dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ logs/ # Internal design documents (not for distribution) aster-brief.md + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ From 297207a7aad1e85fb8633983f150119f93a5239c Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 13:11:20 +0800 Subject: [PATCH 44/85] notifications: stop toast popups from stealing focus or blocking clicks Toasts never grab keyboard focus and pass every pointer event through to whatever's underneath, via an empty layer-shell input region. --- src/notifications/popup.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 5753b65..9c767b6 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -177,9 +177,16 @@ fn create_window() -> gtk4::Window { 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); - // 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); + // 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::theme::bind_auto(&window); window } From bbb1a6c8d22fcb176c5e40a4fdddfd768e88e0e2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:58:25 +0800 Subject: [PATCH 45/85] 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::shell::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::shell::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. --- Cargo.lock | 4 +- Cargo.toml | 6 ++ src/bar/workspaces.rs | 2 +- src/main.rs | 93 +++++++++++++++++++-------- src/notifications/history.rs | 12 ++-- src/notifications/popup.rs | 22 ++----- src/osd.rs | 9 +-- src/panel.rs | 25 +++----- src/screenshot.rs | 8 ++- src/surface.rs | 78 +++++++++++++++++++++++ src/theme.rs | 118 ++++++++++++++++++++++++++++------- 11 files changed, 278 insertions(+), 99 deletions(-) create mode 100644 src/surface.rs 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)); +} From c5f7dd1ee39859541e6e07ece8e47e8303316c25 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 17:40:05 +0800 Subject: [PATCH 46/85] bar: assemble modules from [bar.slots] instead of a fixed source order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ModuleRegistry (src/bar/slots.rs), mapping each [bar.slots] module name (workspaces/media/clock/volume/wifi/battery/control) to its already-built widget. main.rs now registers the seven modules once they're constructed, then walks ShellTheme::slots() to append them into the left/centre/right containers in theme order instead of a hardcoded sequence. An unknown module name in a theme manifest is logged and skipped rather than panicking. The Lua-declared widget_* containers keep their fixed interleave (right-of-workspaces, left/right-of-clock, left-of-stats) — that's Phase 3b, not this change. --- src/bar/mod.rs | 1 + src/bar/slots.rs | 53 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 61 +++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 src/bar/slots.rs diff --git a/src/bar/mod.rs b/src/bar/mod.rs index e006a63..0d20edc 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -2,6 +2,7 @@ pub mod bluetooth; pub mod clock; pub mod control; pub mod media; +pub mod slots; pub mod stats; pub mod tray; pub mod wifi; diff --git a/src/bar/slots.rs b/src/bar/slots.rs new file mode 100644 index 0000000..cb4d359 --- /dev/null +++ b/src/bar/slots.rs @@ -0,0 +1,53 @@ +//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a). +//! +//! Each bar module (`workspaces`, `media`, `clock`, `volume`, `wifi`, +//! `battery`, `control`, …) is still built exactly where it always was in +//! `main.rs` — this registry only decouples the ORDER in which those +//! already-constructed widgets get appended into the left/centre/right +//! containers from the fixed source-code order they were built in. main.rs +//! registers each widget by its manifest module name once construction is +//! done, then walks `ShellTheme::slots()` to append them in the theme's +//! order instead of a hardcoded sequence. +//! +//! The Lua-declared `widget_*` containers (`WidgetPlacement`) are NOT part +//! of this registry — their fixed interleave (right-of-workspaces, +//! left/right-of-clock, left-of-stats) stays exactly as it is today. +//! Generalizing their placement is Phase 3b, not this task. + +use gtk4::prelude::*; +use std::collections::HashMap; + +/// Maps a `[bar.slots]` module name to its already-built widget. +#[derive(Default)] +pub struct ModuleRegistry(HashMap<&'static str, gtk4::Widget>); + +impl ModuleRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Registers `widget` under `name` (a `[bar.slots]` module name, e.g. + /// `"workspaces"` or `"clock"`). + pub fn register(&mut self, name: &'static str, widget: &impl IsA) { + self.0.insert(name, widget.clone().upcast()); + } + + /// Appends every module named in `names` (a manifest slot list, in + /// theme order) into `container` via `on_widget`, which lets the + /// caller interleave fixed Lua widget containers around specific + /// modules (e.g. the clock). A name with no registered widget is + /// logged and skipped — an unrecognized or unmapped module in a theme + /// manifest must never crash the bar. + pub fn for_each_in_slot( + &self, + names: &[String], + mut on_widget: impl FnMut(&str, >k4::Widget), + ) { + for name in names { + match self.0.get(name.as_str()) { + Some(widget) => on_widget(name, widget), + None => eprintln!("breadbar: [bar.slots] names unknown module '{name}' — skipping"), + } + } + } +} diff --git a/src/main.rs b/src/main.rs index cf9a5c1..6bf0c6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -233,7 +233,8 @@ impl SimpleComponent for App { workspace_row.set_margin_start(8); workspace_row.set_valign(gtk4::Align::Center); workspace_row.set_vexpand(false); - workspace_row.append(&workspace_trail.overlay); + // `workspace_trail.overlay` is appended in the "Assemble" section + // below, in the order `[bar.slots].left` names it — not here. // ── Lua-declared widget containers ────────────────────────────── // One per WidgetPlacement; positioned into the layout below as each @@ -243,7 +244,9 @@ impl SimpleComponent for App { use bread_shared::widget::WidgetPlacement; let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); widget_right_of_workspaces.add_css_class("bread-widget-slot"); - workspace_row.append(&widget_right_of_workspaces); + // Also appended in "Assemble" — after the left slot's modules, so + // its fixed position (right of the workspace modules) is preserved + // whatever `[bar.slots].left` contains. let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); widget_left_of_clock.add_css_class("bread-widget-slot"); @@ -396,10 +399,9 @@ impl SimpleComponent for App { center_area.add_css_class("center-area"); center_area.set_valign(gtk4::Align::Center); center_area.set_vexpand(false); - center_area.append(&media_widget); - center_area.append(&widget_left_of_clock); - center_area.append(&clock_box); - center_area.append(&widget_right_of_clock); + // `media_widget`/`clock_box` and the `widget_left_of_clock`/ + // `widget_right_of_clock` interleave around the clock module are + // both appended in "Assemble" below, per `[bar.slots].centre`. // ── Stats box (right side) ─────────────────────────────────────── // Demo order: [vol 64] [wifi] [bat 83] [☰] @@ -408,7 +410,9 @@ impl SimpleComponent for App { stats_box.set_margin_end(2); stats_box.set_valign(gtk4::Align::Center); stats_box.set_vexpand(false); - stats_box.append(&widget_left_of_stats); + // Also appended in "Assemble" — before the right slot's modules, + // preserving its fixed position (left of the stats modules) + // whatever `[bar.slots].right` contains. // CPU/RAM/power draw stay built (control panel + screenshots still // read the labels) but never mount on the island — the demo bar @@ -442,7 +446,7 @@ impl SimpleComponent for App { vol_lbl.add_css_class("stat-label"); vol_box.append(&vol_img); vol_box.append(&vol_lbl); - stats_box.append(&vol_box); + // Appended in "Assemble" below, per `[bar.slots].right`. let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); @@ -530,8 +534,8 @@ impl SimpleComponent for App { wifi_img.set_halign(gtk4::Align::Center); wifi_img.set_hexpand(false); connectivity_pair.append(&wifi_img); - stats_box.append(&connectivity_pair); - stats_box.append(&bat_box); + // `connectivity_pair` and `bat_box` are appended in "Assemble" + // below, per `[bar.slots].right`. // ── Control panel popover ──────────────────────────────────────── // Liquid Motion chrome: CONTROL / vol / bl / lock·sleep·off. @@ -652,7 +656,8 @@ impl SimpleComponent for App { bar::control::spawn_set_brightness(s.value()); }); - stats_box.append(&hamburger_btn); + // `hamburger_btn` is appended in "Assemble" below, per + // `[bar.slots].right`. // Standalone layer windows — below the island, slid in by Hyprland. let panels = panel::PanelSet::new( @@ -707,6 +712,40 @@ impl SimpleComponent for App { media_widget.add_controller(mgesture); } + // ── Assemble: slot-driven module order (plan §11 Phase 3a) ─────── + // Every module widget above is already fully built; only the ORDER + // it lands in its container, and which of left/centre/right it + // lands in, comes from the theme manifest's `[bar.slots]` now. + // The `widget_*` Lua containers keep today's fixed interleave + // (right-of-workspaces, left/right-of-clock, left-of-stats) — + // generalizing their placement is Phase 3b, not this task. + let bar_shell_theme = theme::shell_theme(); + let bar_slots = bar_shell_theme.slots(); + 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); + 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_modules.for_each_in_slot(&bar_slots.left, |_, widget| workspace_row.append(widget)); + workspace_row.append(&widget_right_of_workspaces); + + bar_modules.for_each_in_slot(&bar_slots.centre, |name, widget| { + if name == "clock" { + center_area.append(&widget_left_of_clock); + } + center_area.append(widget); + if name == "clock" { + center_area.append(&widget_right_of_clock); + } + }); + + stats_box.append(&widget_left_of_stats); + bar_modules.for_each_in_slot(&bar_slots.right, |_, widget| stats_box.append(widget)); + let widget_containers = std::collections::HashMap::from([ ( WidgetPlacement::RightOfWorkspaces, From 8d80a05d9b80182800eaec0a2d34d5cbb0de5db7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 18:23:24 +0800 Subject: [PATCH 47/85] bar: route Lua widgets to any [bar.slots] slot, not just four fixed spots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3b of the shell theme system. A slot list entry can now be `widget:`, where `` is either a WidgetPlacement alias (right_of_workspaces, left_of_clock, right_of_clock, left_of_stats, tray) or a Lua module name. ModuleRegistry::for_each_in_slot creates each widget container on demand at its slot position; reconcile_widgets routes each WidgetSpec by module name first, falling back to its placement alias, and logs+drops (never panics) a spec with no matching container. bread_shared::widget::WidgetPlacement stays untouched — it's still the wire type breadd sends, unmodified and unshadowed; only the container map that placement now resolves through is theme-driven. --- src/bar/slots.rs | 59 ++++++++++++---- src/main.rs | 177 ++++++++++++++++++++++++++++------------------- 2 files changed, 150 insertions(+), 86 deletions(-) diff --git a/src/bar/slots.rs b/src/bar/slots.rs index cb4d359..87bf915 100644 --- a/src/bar/slots.rs +++ b/src/bar/slots.rs @@ -1,4 +1,5 @@ -//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a). +//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a), +//! extended in Phase 3b to also route `widget:` entries. //! //! Each bar module (`workspaces`, `media`, `clock`, `volume`, `wifi`, //! `battery`, `control`, …) is still built exactly where it always was in @@ -9,10 +10,14 @@ //! done, then walks `ShellTheme::slots()` to append them in the theme's //! order instead of a hardcoded sequence. //! -//! The Lua-declared `widget_*` containers (`WidgetPlacement`) are NOT part -//! of this registry — their fixed interleave (right-of-workspaces, -//! left/right-of-clock, left-of-stats) stays exactly as it is today. -//! Generalizing their placement is Phase 3b, not this task. +//! A slot entry may also be `widget:`, where `` is either a +//! `WidgetPlacement` alias (`right_of_workspaces`, `left_of_clock`, +//! `right_of_clock`, `left_of_stats`, `tray`) or a Lua module name (see +//! `bread_shared::widget::WidgetSpec::module`) — these route through +//! `for_each_in_slot`'s `on_widget` callback rather than this registry, +//! since their containers are Lua-widget slots created on demand by the +//! caller, not modules registered here. `WidgetPlacement` itself is a wire +//! type from `bread-shared` and is never referenced in this file. use gtk4::prelude::*; use std::collections::HashMap; @@ -32,22 +37,50 @@ impl ModuleRegistry { self.0.insert(name, widget.clone().upcast()); } - /// Appends every module named in `names` (a manifest slot list, in - /// theme order) into `container` via `on_widget`, which lets the - /// caller interleave fixed Lua widget containers around specific - /// modules (e.g. the clock). A name with no registered widget is - /// logged and skipped — an unrecognized or unmapped module in a theme - /// manifest must never crash the bar. + /// Walks every entry named in `names` (a manifest slot list, in theme + /// order). A `widget:` entry calls `on_widget(key)`, letting the + /// caller create-or-fetch that Lua widget container and append it at + /// this exact position. Anything else is looked up as a registered + /// module name and passed to `on_module`; a name with no registered + /// widget is logged and skipped — an unrecognized or unmapped module in + /// a theme manifest must never crash the bar. pub fn for_each_in_slot( &self, names: &[String], - mut on_widget: impl FnMut(&str, >k4::Widget), + mut on_module: impl FnMut(&str, >k4::Widget), + mut on_widget: impl FnMut(&str), ) { for name in names { + if let Some(key) = name.strip_prefix("widget:") { + on_widget(key); + continue; + } match self.0.get(name.as_str()) { - Some(widget) => on_widget(name, widget), + Some(widget) => on_module(name, widget), None => eprintln!("breadbar: [bar.slots] names unknown module '{name}' — skipping"), } } } } + +/// Returns the widget container keyed `key` in `containers`, creating it +/// (a plain horizontal box, styled like every other Lua widget slot) on +/// first use. Called from `for_each_in_slot`'s `on_widget` callback so a +/// `widget:` slot entry gets a container the first time a theme +/// places one there, regardless of whether `key` is a `WidgetPlacement` +/// alias or a Lua module name — `reconcile_widgets` (main.rs) is what +/// gives that distinction meaning when it routes specs into these +/// containers. +pub fn widget_slot_container( + containers: &mut HashMap, + key: &str, +) -> gtk4::Box { + containers + .entry(key.to_string()) + .or_insert_with(|| { + let b = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + b.add_css_class("bread-widget-slot"); + b + }) + .clone() +} diff --git a/src/main.rs b/src/main.rs index 6bf0c6d..37d93f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,10 +101,16 @@ pub struct App { tray_items: std::collections::HashMap, // ── Lua-declared widgets ───────────────────────────────────────────── - // One container per WidgetPlacement (see bread_shared::widget), fully - // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's + // One container per `widget:` slot entry (Phase 3b — see + // bar::slots::ModuleRegistry and reconcile_widgets' routing below), + // fully rebuilt on every AppInput::WidgetsUpdate — see widgets::client's // module doc for why that's simpler than incremental patching here. - widget_containers: std::collections::HashMap, + // Keyed by the slot entry's key: either a WidgetPlacement alias + // (`right_of_workspaces`, `left_of_clock`, `right_of_clock`, + // `left_of_stats`, `tray`) or a Lua module name. `bread_shared::widget`'s + // `WidgetPlacement` itself never appears here — it's a wire type from + // the bread daemon API and stays untouched. + widget_containers: std::collections::HashMap, widget_tray_section: gtk4::Box, widget_tray_sep: gtk4::Separator, @@ -223,9 +229,9 @@ impl SimpleComponent for App { // ── Workspace row (left) ──────────────────────────────────────── // Built imperatively (not via the view! macro) so a widget - // container can sit as a plain sibling of workspace_box — see - // WidgetPlacement::RightOfWorkspaces below. The Overlay trail - // lives behind the buttons; rebuild_buttons only touches the + // container can sit as a plain sibling of workspace_box — see the + // `widget:*` slot-entry handling in "Assemble" below. The Overlay + // trail lives behind the buttons; rebuild_buttons only touches the // button box, never the trail host. let workspace_trail = bar::workspaces::WorkspaceTrail::new(); let workspace_box = workspace_trail.buttons.clone(); @@ -237,24 +243,12 @@ impl SimpleComponent for App { // below, in the order `[bar.slots].left` names it — not here. // ── Lua-declared widget containers ────────────────────────────── - // One per WidgetPlacement; positioned into the layout below as each - // surrounding section (workspace row / center area / stats box / - // control popover) is built. Populated by widgets::client's - // events.subscribe-driven refresh loop, started at the end of init. - use bread_shared::widget::WidgetPlacement; - let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_right_of_workspaces.add_css_class("bread-widget-slot"); - // Also appended in "Assemble" — after the left slot's modules, so - // its fixed position (right of the workspace modules) is preserved - // whatever `[bar.slots].left` contains. - - let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_left_of_clock.add_css_class("bread-widget-slot"); - let widget_right_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_right_of_clock.add_css_class("bread-widget-slot"); - - let widget_left_of_stats = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_left_of_stats.add_css_class("bread-widget-slot"); + // Phase 3b: a container per `widget:` slot entry is created + // on demand while walking `[bar.slots]` in the "Assemble" section + // below (see `bar::slots::widget_slot_container`), so ANY slot can + // host a Lua widget — not just the four fixed positions Phase 3a + // shipped with. Populated by widgets::client's events.subscribe- + // driven refresh loop, started at the end of init. // `tokens.icon_px` (plan §4) — bar-chrome icon pixel size; reused // below for every `prepare_icon` call in this function. @@ -399,9 +393,9 @@ impl SimpleComponent for App { center_area.add_css_class("center-area"); center_area.set_valign(gtk4::Align::Center); center_area.set_vexpand(false); - // `media_widget`/`clock_box` and the `widget_left_of_clock`/ - // `widget_right_of_clock` interleave around the clock module are - // both appended in "Assemble" below, per `[bar.slots].centre`. + // `media_widget`/`clock_box` and any `widget:*` entries interleaved + // around them are all appended in "Assemble" below, in the exact + // order `[bar.slots].centre` names them. // ── Stats box (right side) ─────────────────────────────────────── // Demo order: [vol 64] [wifi] [bat 83] [☰] @@ -712,13 +706,16 @@ impl SimpleComponent for App { media_widget.add_controller(mgesture); } - // ── Assemble: slot-driven module order (plan §11 Phase 3a) ─────── + // ── Assemble: slot-driven module + widget order (plan §11 Phase 3b) ── // Every module widget above is already fully built; only the ORDER // it lands in its container, and which of left/centre/right it - // lands in, comes from the theme manifest's `[bar.slots]` now. - // The `widget_*` Lua containers keep today's fixed interleave - // (right-of-workspaces, left/right-of-clock, left-of-stats) — - // generalizing their placement is Phase 3b, not this task. + // lands in, comes from the theme manifest's `[bar.slots]` now. A + // `widget:` slot entry gets (or creates) a Lua widget + // container at that exact position — `` is either a + // `WidgetPlacement` alias or a Lua module name; see + // `bar::slots::widget_slot_container` and `reconcile_widgets`' + // routing below. This is how a Lua widget can land in ANY slot, + // not just the four fixed positions Phase 3a shipped with. let bar_shell_theme = theme::shell_theme(); let bar_slots = bar_shell_theme.slots(); let mut bar_modules = bar::slots::ModuleRegistry::new(); @@ -730,32 +727,29 @@ impl SimpleComponent for App { bar_modules.register("battery", &bat_box); bar_modules.register("control", &hamburger_btn); - bar_modules.for_each_in_slot(&bar_slots.left, |_, widget| workspace_row.append(widget)); - workspace_row.append(&widget_right_of_workspaces); + // `tray` never appears in a bar slot — it stays inside the + // control-panel popover (built above, next to the SNI tray) — but + // it's keyed here so `reconcile_widgets`' routing finds it the same + // way as any slot-driven widget container. + let mut widget_containers: std::collections::HashMap = + std::collections::HashMap::new(); + widget_containers.insert("tray".to_string(), widget_tray_box); - bar_modules.for_each_in_slot(&bar_slots.centre, |name, widget| { - if name == "clock" { - center_area.append(&widget_left_of_clock); - } - center_area.append(widget); - if name == "clock" { - center_area.append(&widget_right_of_clock); - } - }); - - stats_box.append(&widget_left_of_stats); - bar_modules.for_each_in_slot(&bar_slots.right, |_, widget| stats_box.append(widget)); - - let widget_containers = std::collections::HashMap::from([ - ( - WidgetPlacement::RightOfWorkspaces, - widget_right_of_workspaces, - ), - (WidgetPlacement::LeftOfClock, widget_left_of_clock), - (WidgetPlacement::RightOfClock, widget_right_of_clock), - (WidgetPlacement::LeftOfStats, widget_left_of_stats), - (WidgetPlacement::Tray, widget_tray_box), - ]); + bar_modules.for_each_in_slot( + &bar_slots.left, + |_, widget| workspace_row.append(widget), + |key| workspace_row.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), + ); + bar_modules.for_each_in_slot( + &bar_slots.centre, + |_, widget| center_area.append(widget), + |key| center_area.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), + ); + bar_modules.for_each_in_slot( + &bar_slots.right, + |_, widget| stats_box.append(widget), + |key| stats_box.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), + ); // ── Assemble ───────────────────────────────────────────────────── let widgets = view_output!(); @@ -1144,6 +1138,22 @@ impl SimpleComponent for App { } } +/// The `widget:` alias `for_each_in_slot` recognizes for each +/// `WidgetPlacement` variant — the fallback a `WidgetSpec` routes through +/// when no `widget:` container claims its module name specifically. +/// Kept in one place since both the builtin manifest's slot lists and +/// `reconcile_widgets`' routing below must agree on these names. +fn placement_alias(placement: bread_shared::widget::WidgetPlacement) -> &'static str { + use bread_shared::widget::WidgetPlacement::*; + match placement { + Tray => "tray", + LeftOfClock => "left_of_clock", + RightOfClock => "right_of_clock", + RightOfWorkspaces => "right_of_workspaces", + LeftOfStats => "left_of_stats", + } +} + impl App { fn reconcile_widgets(&mut self, specs: Vec) { for container in self.widget_containers.values() { @@ -1152,23 +1162,47 @@ impl App { } } - let mut by_placement: std::collections::HashMap< - bread_shared::widget::WidgetPlacement, + // Route each spec to a widget_containers entry: a `widget:` + // slot entry (keyed by WidgetSpec::module) takes priority over the + // spec's placement alias, so a theme can retarget one Lua module's + // widgets without moving every widget that shares its placement. + // A spec whose module AND placement alias both lack a container + // (e.g. a theme's slots omit that placement's widget: entry + // entirely) is logged and dropped rather than silently vanishing — + // WidgetPlacement itself never changes; only which container (if + // any) each spec lands in does. + let mut by_container: std::collections::HashMap< + String, Vec<&bread_shared::widget::WidgetSpec>, > = std::collections::HashMap::new(); for spec in &specs { - by_placement.entry(spec.placement).or_default().push(spec); + let key = if self.widget_containers.contains_key(&spec.module) { + spec.module.clone() + } else { + placement_alias(spec.placement).to_string() + }; + if self.widget_containers.contains_key(&key) { + by_container.entry(key).or_default().push(spec); + } else { + eprintln!( + "breadbar: widget '{}' (module '{}', placement {:?}) has no matching \ + [bar.slots] widget: container — dropping", + spec.id, spec.module, spec.placement + ); + } } - for (placement, mut group) in by_placement { - let Some(container) = self.widget_containers.get(&placement) else { - continue; - }; + let mut has_tray_widgets = false; + for (key, mut group) in by_container { + let container = &self.widget_containers[&key]; group.sort_by_key(|s| s.order); for spec in group { if !spec.visible { continue; } + if key == "tray" { + has_tray_widgets = true; + } let node = widgets::build_node(&spec.root, &spec.id); if let Some(tooltip) = &spec.tooltip { node.set_tooltip_text(Some(tooltip)); @@ -1177,20 +1211,17 @@ impl App { } } - // The Tray placement has its own section/separator (handled below, - // same as the existing SNI tray items) — an empty inline slot has no - // such wrapper, so it must hide itself to stop contributing to - // center_area's `spacing` gap. - for (placement, container) in &self.widget_containers { - if *placement == bread_shared::widget::WidgetPlacement::Tray { + // The "tray" container has its own section/separator (handled + // below, same as the existing SNI tray items) — an empty inline + // slot has no such wrapper, so it must hide itself to stop + // contributing to its parent box's `spacing` gap. + for (key, container) in &self.widget_containers { + if key == "tray" { continue; } container.set_visible(container.first_child().is_some()); } - let has_tray_widgets = specs - .iter() - .any(|s| s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray); self.widget_tray_section.set_visible(has_tray_widgets); self.widget_tray_sep.set_visible(has_tray_widgets); } From df6b568cc12ec3794ae496c4cc0e49cd9a401065 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 23:26:34 +0800 Subject: [PATCH 48/85] 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, ) } From 1fb10a10cdcc0580204bffbdfb0ca5ecb6cc4721 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 00:29:38 +0800 Subject: [PATCH 49/85] bar: implement spotlight's embedded launcher capsule (Phase 6b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New bread-launcher dep (gtk feature), pinned tag v0.7.4 with a dev-only [patch] entry alongside bread-theme's. - Wire the drawer slot (plan §2/§7): root becomes a vbox whose first row is the existing CenterBox and whose second is drawer_box, appended via the same ModuleRegistry::for_each_in_slot pattern left/centre/right already use. Empty for liquid-motion/glass-workbench (zero children, zero size); theme.rs's window.breadbar > centerbox selector becomes > box > centerbox to follow the new nesting, byte-identical CSS for both existing themes. - modules.workspaces.style = dots: bar::workspaces::make_dot_button, width from modules.workspaces.dot_widths, wired as a third rebuild_buttons arm. - New launcher_entry (plain GtkEntry) and launcher_results (bread_launcher::gtk::ResultsList, sharing breadbox's cache/history via bread_launcher::LAUNCHER_APP) modules, built unconditionally and placed only when a theme's [bar.slots] names them (spotlight today). - Capsule expand/collapse: typing/focus opens the drawer and animates its height via bread_theme::anim::spring_to; Up/Down move the selection, Enter launches (do_launch + record_launch) and collapses, Escape collapses and releases keyboard focus (on_demand ties to GTK's own focus-widget state, so releasing GTK focus hands the compositor keyboard back). - window.width now wires Width::Px into set_default_width (previously unconsumed — Fill-anchored themes never needed it, the capsule does). - Two new --screenshot views: capsule-collapsed, capsule-expanded (the latter focuses launcher_entry, types a query, and captures bar height + the drawer's actual settled height rather than a guessed constant). cargo check/clippy: no new warnings (same two pre-existing: dead_code on system_stats_box/cpu_pair/mem_pair/pwr_pair, from_* on workspaces.rs). cargo test: 18/18 unchanged. --- Cargo.lock | 24 +++- Cargo.toml | 6 + src/bar/workspaces.rs | 39 +++++ src/main.rs | 326 +++++++++++++++++++++++++++++++++++++++++- src/screenshot.rs | 59 ++++++++ src/theme.rs | 68 ++++++++- 6 files changed, 512 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5be557..c1c3eda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,13 +150,22 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-launcher" +version = "0.7.4" +dependencies = [ + "bread-utils 0.7.4", + "gtk4", + "serde_json", +] + [[package]] name = "bread-screenshots" version = "0.7.4" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" dependencies = [ "anyhow", - "bread-utils", + "bread-utils 0.7.4 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4)", "tracing", ] @@ -196,6 +205,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "bread-utils" +version = "0.7.4" +dependencies = [ + "bread-shared 0.7.0", + "dirs 5.0.1", + "serde", + "serde_json", +] + [[package]] name = "bread-utils" version = "0.7.4" @@ -212,10 +231,11 @@ name = "breadbar" version = "0.3.3" dependencies = [ "anyhow", + "bread-launcher", "bread-screenshots", "bread-shared 0.8.0", "bread-theme", - "bread-utils", + "bread-utils 0.7.4 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4)", "clap", "futures-lite", "gtk4", diff --git a/Cargo.toml b/Cargo.toml index de10b69..7fba78a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,11 @@ bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" } +# Headless app-launcher core + GTK4 results-list widget (plan §3/§7): theme +# 04/spotlight embeds the SAME `ResultsList` breadbox's overlay wraps, via +# `bar::launcher_results` — one implementation, two hosts. `gtk` feature for +# the results widget itself. +bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -46,3 +51,4 @@ strip = "symbols" # (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" } +bread-launcher = { path = "../bread-ecosystem/bread-launcher" } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 611e15e..8b30505 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -155,6 +155,45 @@ pub fn make_button( btn } +/// `style = "dots"` (theme 04/spotlight): a label-less pill whose WIDTH +/// encodes `windows` (0/1/2/3-or-more open) via `dot_widths` — see +/// `bread_theme::shell::WorkspacesModule::dot_widths`. Distinct from +/// [`make_button`] (Trail/Pill) rather than a variant of it because dots +/// carry no text at all (`04-spotlight.html`'s `.dots button` has no label); +/// reusing `Button::with_label("")` would still measure/lay out an empty +/// label box that a genuinely childless button doesn't. Width is a hard +/// `set_size_request` snap, not animated — GTK CSS min-width transitions +/// don't participate in a directly-set size request the way an opacity/ +/// background-color transition does, and the plan only calls out the +/// capsule's own expand/collapse as worth the `anim::spring_to` treatment. +pub fn make_dot_button( + id: WorkspaceId, + active: WorkspaceId, + windows: i32, + dot_widths: bread_theme::shell::DotWidths, +) -> gtk4::Button { + let btn = gtk4::Button::new(); + btn.add_css_class("workspace-dot"); + if windows > 0 { + btn.add_css_class("occupied"); + } + if id == active { + btn.add_css_class("active"); + } + btn.set_valign(gtk4::Align::Center); + btn.set_halign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_hexpand(false); + let idx = (windows.max(0) as usize).min(3); + btn.set_size_request(dot_widths[idx], 6); + btn.connect_clicked(move |_| { + relm4::spawn(async move { + switch_workspace(id).await; + }); + }); + btn +} + #[derive(Clone, Copy)] struct Geom { x: f64, diff --git a/src/main.rs b/src/main.rs index db91068..0b794ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,8 @@ mod surface; mod theme; mod widgets; -use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, WorkspaceStyle}; +use bread_launcher::gtk::ResultsList; +use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, Width, WorkspaceStyle}; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use hyprland::data::Workspace; @@ -21,8 +22,21 @@ use hyprland::shared::WorkspaceId; use relm4::prelude::*; use relm4::{Component, ComponentController, Controller}; use std::cell::Cell; +use std::cell::RefCell; use std::rc::Rc; +/// The launched-app event's publisher id and event name — deliberately +/// matching breadbox's OWN constants (`breadbox/src/main.rs`: `APP_ID = +/// "box"`, `LAUNCHED_EVENT = "bread.box.launched"`), NOT breadbar's own +/// `widgets::client::APP_ID` ("bar"). Theme 04/spotlight's capsule IS the +/// launcher wearing a different shell (plan §7), sharing breadbox's cache +/// and history via `bread_launcher::LAUNCHER_APP` — it publishes under that +/// same launcher identity too, so anything downstream listening for "an app +/// was launched via the launcher" sees one event stream regardless of which +/// surface launched it. +const LAUNCHER_APP_ID: &str = "box"; +const LAUNCHER_LAUNCHED_EVENT: &str = "bread.box.launched"; + pub struct BarInit { pub screenshot: Option, pub monitor: Option, @@ -51,6 +65,15 @@ pub struct App { // recompile; only one of the two ever lands in a `[bar.slots]` module // registration (see the "Assemble" section). clock_plain_lbl: gtk4::Label, + // `modules.clock.placeholder_clock` (spotlight, theme 04): when set, + // `AppInput::ClockTick` writes the time into this entry's placeholder + // text instead of (or alongside) any clock label — see that handler. + launcher_entry: gtk4::Entry, + // Whether the capsule's drawer is currently expanded — read by + // `ClockTick` so a live search in progress never has its placeholder + // text stomped (it wouldn't be visible anyway once there's real text, + // but matches the demo's own `if (!open) q.placeholder = t;` guard). + launcher_open: Rc>, // ── Stats bar ───────────────────────────────────────────────────────── // Island chrome matches the Liquid Motion demo: volume / wifi / battery @@ -165,8 +188,27 @@ impl SimpleComponent for App { set_title: Some("breadbar"), set_default_height: bar_height, - #[name = "center_box"] - gtk::CenterBox { + // Root is a vbox (bar row + drawer), not a bare CenterBox, per + // plan §2/§11: `drawer` is the only structural thing Capsule/ + // theme-04 adds over Island/Edge, and it's a slot below the bar + // row, not a separate layout code path. `drawer_box` starts + // empty and zero-height for every theme that never names a + // module in `[bar.slots].drawer` (liquid-motion, glass- + // workbench) — see main.rs's "Assemble" section and + // `theme.rs`'s `window.breadbar > box > centerbox` selector + // update for why this is a no-op for both. + #[name = "root_vbox"] + gtk::Box { + set_orientation: gtk4::Orientation::Vertical, + + #[name = "center_box"] + gtk::CenterBox { + }, + + #[name = "drawer_box"] + gtk::Box { + set_orientation: gtk4::Orientation::Vertical, + }, } } } @@ -211,6 +253,17 @@ impl SimpleComponent for App { 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); + // `Width::Fill` (Island/Edge): unset, exactly as before this + // change — the surface stretches to the anchored left/right edges + // on its own, with no explicit width request needed. `Width::Px` + // (the capsule, anchored top-only): gtk4-layer-shell has nothing to + // stretch it TO, so without this it would size to its natural + // content width instead of the theme's requested 480px — this was + // a schema key declared but never consumed before theme 04 needed + // a real value out of it. + if let Width::Px(px) = window_spec.width { + root.set_default_width(px); + } // "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 { @@ -418,6 +471,66 @@ impl SimpleComponent for App { clock_plain_box.append(&date_lbl); clock_plain_box.append(&clock_plain_lbl); + // ── Launcher entry + results (theme 04/spotlight, plan §7) ─────── + // Built unconditionally, exactly like `clock_plain_box` above — + // placed in a slot only by a theme that names "launcher_entry"/ + // "launcher_results" (spotlight today; see "Assemble" below). + // `bread_launcher::LAUNCHER_APP` ("breadbox") is the launcher's + // shared identity: this reads/writes the SAME icon cache and + // launch history breadbox's own overlay window does, so the + // capsule and breadbox rank a user's apps identically instead of + // forking into two histories just because a different theme + // happens to be active (see that constant's own doc comment). + let launcher_cfg = theme::shell_theme().launcher().clone(); + let launcher_manifest: std::collections::HashMap = + std::fs::read_to_string(bread_launcher::IconCache::manifest_path( + bread_launcher::LAUNCHER_APP, + )) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, std::path::PathBuf::from(v))) + .collect(); + let launcher_history = Rc::new(RefCell::new(bread_launcher::LaunchHistory::load( + bread_launcher::LAUNCHER_APP, + ))); + // No per-workspace priority context here — that's breadbox's own + // `Config`/`Context` format (breadbox-shared), not launcher + // substance, and breadbar has no equivalent concept. The capsule + // sorts by launch history then alphabetically, same fallback + // ordering breadbox itself uses once a workspace has no configured + // priority list at all. + let launcher_entries = bread_launcher::load_sorted_entries( + &launcher_manifest, + &[], + &launcher_history.borrow(), + ); + let launcher_results = ResultsList::new( + &launcher_entries, + launcher_cfg.icon_px, + Rc::clone(&launcher_history), + ); + launcher_results.scroller.add_css_class("bread-drawer-scroller"); + + let launcher_entry = gtk4::Entry::new(); + launcher_entry.add_css_class("launcher-entry"); + launcher_entry.set_has_frame(false); + launcher_entry.set_hexpand(true); + gtk4::prelude::EntryExt::set_alignment(&launcher_entry, 0.5); + // `modules.clock.placeholder_clock` (spotlight): the entry's idle + // placeholder IS the clock — no separate clock module renders at + // all under `style = "none"`. Any other theme gets a plain "Search" + // placeholder (never shown today: no other builtin slots + // "launcher_entry" anywhere), so this still degrades sanely if a + // future/user theme places it without also setting the flag. + let placeholder_clock = theme::shell_theme().modules().clock.placeholder_clock; + launcher_entry.set_placeholder_text(Some(if placeholder_clock { + bar::clock::time() + } else { + "Search".to_string() + }.as_str())); + // Center area: [media_widget · widgets · clock · widgets] let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); @@ -789,6 +902,11 @@ impl SimpleComponent for App { // glass-workbench (which omits "media" entirely). bar_modules.register("cpu", &bar_cpu_pair); bar_modules.register("ram", &bar_ram_pair); + // Theme 04/spotlight (plan §7): unconditional, same reasoning — + // liquid-motion/glass-workbench never name either in a slot, so + // both stay built but unparented for them. + bar_modules.register("launcher_entry", &launcher_entry); + bar_modules.register("launcher_results", &launcher_results.scroller); // `tray` never appears in a bar slot — it stays inside the // control-panel popover (built above, next to the SNI tray) — but @@ -820,6 +938,143 @@ impl SimpleComponent for App { widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); + // `drawer` slot (plan §2/§7/§11 Phase 6): the only slot list that + // isn't left/centre/right of the CenterBox — appended into the vbox + // row below it instead. Empty for every theme but spotlight, so + // `drawer_box` stays a childless, zero-height box for them (see the + // `window.breadbar > box > centerbox` selector note in theme.rs for + // why the vbox wrapper itself is safe for those two themes too). + widgets.drawer_box.add_css_class("bread-drawer"); + bar_modules.for_each_in_slot( + &bar_slots.drawer, + |_, widget| widgets.drawer_box.append(widget), + |key| { + widgets + .drawer_box + .append(&bar::slots::widget_slot_container(&mut widget_containers, key)) + }, + ); + // Collapsed by default; `Overflow::Hidden` clips the results list + // while its allocated height is below its natural content height, + // same as the demo's `.results { max-height: 0; overflow: hidden }`. + widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden); + widgets.drawer_box.set_size_request(-1, 0); + + // ── Capsule expand/collapse + search wiring (theme 04/spotlight) ── + // Effectively a no-op under every other theme: `launcher_entry` + // never receives focus if it's never in a slot, so `open_fn` is + // simply never invoked. `results.set_query`/select_next`/`select_prev` + // and launching all come straight from `bread-launcher`; only the + // capsule shell (drawer height, entry placeholder/alignment, + // keyboard focus) is this file's own. + let anim: Rc>> = Rc::new(RefCell::new(None)); + let launcher_open: Rc> = Rc::new(Cell::new(false)); + + let open_fn: Rc = Rc::new({ + let drawer_box = widgets.drawer_box.clone(); + let anim = Rc::clone(&anim); + let launcher_open = Rc::clone(&launcher_open); + let entry = launcher_entry.clone(); + move || { + if !launcher_open.get() { + launcher_open.set(true); + entry.add_css_class("searching"); + gtk4::prelude::EntryExt::set_alignment(&entry, 0.0); + } + let target = drawer_target_height(&drawer_box); + let current = drawer_box.size_request().1; + animate_drawer_height(&drawer_box, &anim, current, target); + } + }); + let close_fn: Rc = Rc::new({ + let drawer_box = widgets.drawer_box.clone(); + let anim = Rc::clone(&anim); + let launcher_open = Rc::clone(&launcher_open); + let entry = launcher_entry.clone(); + let root_for_focus = root.clone(); + move || { + if !launcher_open.get() { + return; + } + launcher_open.set(false); + entry.remove_css_class("searching"); + gtk4::prelude::EntryExt::set_alignment(&entry, 0.5); + entry.set_text(""); + if placeholder_clock { + entry.set_placeholder_text(Some(&bar::clock::time())); + } + let current = drawer_box.size_request().1; + animate_drawer_height(&drawer_box, &anim, current, 0); + // `keyboard = "on_demand"` (plan §7) ties the layer-shell + // surface's keyboard grab to GTK's own focus-widget state — + // releasing focus here is what hands the keyboard back. + gtk4::prelude::GtkWindowExt::set_focus(&root_for_focus, None::<>k4::Widget>); + } + }); + + { + let results = launcher_results.clone(); + let open_fn = Rc::clone(&open_fn); + launcher_entry.connect_changed(move |entry| { + results.set_query(entry.text().as_str()); + open_fn(); + }); + } + { + let open_fn = Rc::clone(&open_fn); + let focus_ctrl = gtk4::EventControllerFocus::new(); + focus_ctrl.connect_enter(move |_| open_fn()); + launcher_entry.add_controller(focus_ctrl); + } + { + let results = launcher_results.clone(); + let close_fn = Rc::clone(&close_fn); + let key_ctrl = gtk4::EventControllerKey::new(); + key_ctrl.connect_key_pressed(move |_, key, _, _| { + use gtk4::gdk::Key; + match key { + Key::Escape => { + close_fn(); + gtk4::glib::Propagation::Stop + } + Key::Down => { + results.select_next(); + gtk4::glib::Propagation::Stop + } + Key::Up => { + results.select_prev(); + gtk4::glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(entry) = results.selected_entry() { + results.record_launch(&entry); + bread_launcher::do_launch( + &entry, + LAUNCHER_APP_ID, + LAUNCHER_LAUNCHED_EVENT, + ); + } + close_fn(); + gtk4::glib::Propagation::Stop + } + _ => gtk4::glib::Propagation::Proceed, + } + }); + launcher_entry.add_controller(key_ctrl); + } + // Row click launches too, same as breadbox's own overlay. + { + let results = launcher_results.clone(); + let close_fn = Rc::clone(&close_fn); + launcher_results.list.connect_row_activated(move |_, row| { + if let Some(entry) = bread_launcher::gtk::row_entry(row) { + results.record_launch(&entry); + bread_launcher::do_launch(&entry, LAUNCHER_APP_ID, LAUNCHER_LAUNCHED_EVENT); + } + close_fn(); + }); + } + // Captured before these move into `model` (or are otherwise dropped // as bare locals, never stored on `App` at all) — needed by the // screenshot dispatch just before this function returns. @@ -830,6 +1085,12 @@ impl SimpleComponent for App { let media_panel_for_screenshot = panels.media.clone(); let media_widget_for_screenshot = media_widget.clone(); let media_track_lbl_for_screenshot = media_track_lbl.clone(); + // Theme 04/spotlight's capsule (plan §6b): `launcher_entry` moves + // into `model` below, `drawer_box` lives only in `widgets` — both + // need a clone out here for the same reason every other + // `_for_screenshot` handle above does. + let launcher_entry_for_screenshot = launcher_entry.clone(); + let drawer_box_for_screenshot = widgets.drawer_box.clone(); // Never launch sibling App windows from inside this init — RelmApp // is still in GApplication activate, and a same-type launch here @@ -855,6 +1116,8 @@ impl SimpleComponent for App { clock_digits, date_lbl, clock_plain_lbl, + launcher_entry, + launcher_open, system_stats_box, system_sep, cpu_pair, @@ -963,6 +1226,8 @@ impl SimpleComponent for App { media_track_lbl: media_track_lbl_for_screenshot, notification_window, osd_window, + launcher_entry: launcher_entry_for_screenshot, + drawer_box: drawer_box_for_screenshot, }, ); } @@ -1050,6 +1315,15 @@ impl SimpleComponent for App { flip_clock_digits(&self.clock_digits, &bar::clock::time()); } } + // `modules.clock.placeholder_clock` (spotlight): the + // capsule's entry IS the clock until focused — matches the + // demo's own `if (!open) q.placeholder = t;` guard so a + // live search in progress never has its placeholder text + // (invisibly, since real text covers it) stomped mid-type. + if clock_module.placeholder_clock && !self.launcher_open.get() { + self.launcher_entry + .set_placeholder_text(Some(&bar::clock::time())); + } } AppInput::StatsUpdate(stats) => { let cpu = match stats.cpu_temp { @@ -1429,7 +1703,17 @@ impl App { } } } - let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0); + let btn = match ws_style { + WorkspaceStyle::Dots => bar::workspaces::make_dot_button( + ws.id, + self.active_ws, + ws.windows as i32, + modules.workspaces.dot_widths, + ), + WorkspaceStyle::Trail | WorkspaceStyle::Pill => { + bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0) + } + }; if !prev.contains(&ws.id) { play_once(&btn, "ws-in", 360); } @@ -1925,6 +2209,40 @@ fn reveal_media(widget: >k4::Box, show: bool) { widget.set_visible(show); } +/// Drives `drawer_box`'s height from `from` to `to` over 360ms via +/// `bread_theme::anim::spring_to` (plan §7: GTK4 has no CSS height +/// transition on a widget, so the capsule's `.results { max-height: 0 → +/// 420px }` becomes a `set_size_request` interpolation on the frame clock +/// instead). Cancels any run already in flight first — reopening mid-close +/// (or vice versa) must restart from the CURRENT height, not fight a +/// leftover callback still walking toward the old target. +fn animate_drawer_height( + drawer_box: >k4::Box, + anim: &Rc>>, + from: i32, + to: i32, +) { + if let Some(id) = anim.borrow_mut().take() { + id.remove(); + } + let target = drawer_box.clone(); + let id = bread_theme::anim::spring_to(drawer_box, from, to, 360.0, move |h| { + target.set_size_request(-1, h); + }); + *anim.borrow_mut() = Some(id); +} + +/// The drawer's natural content height right now, capped at the demo's own +/// 420px (`04-spotlight.html`: `.searching .results { max-height: 420px }`) +/// — `ResultsList`'s scroller already self-caps at 480px +/// (`max_content_height`), shared with breadbox, so this is a tighter, +/// spotlight-specific ceiling on top of that shared one, not a replacement +/// for it. +fn drawer_target_height(drawer_box: >k4::Box) -> i32 { + let (_, natural, _, _) = drawer_box.measure(gtk4::Orientation::Vertical, -1); + natural.min(420) +} + fn popover_tab(label: &str) -> gtk4::ToggleButton { let btn = gtk4::ToggleButton::with_label(label); btn.add_css_class("popover-tab"); diff --git a/src/screenshot.rs b/src/screenshot.rs index 38f857e..8299ecd 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -45,6 +45,14 @@ const KNOWN_VIEWS: &[&str] = &[ "osd-volume", "osd-brightness", "wifi-add-dialog", + // Theme 04/spotlight's capsule (plan §6b) — see `dispatch`'s two new + // match arms. "capsule-collapsed" captures the same region "bar" + // always has (bar_capture_height already reflects the active theme's + // own window height/margin); it exists as its own name purely so a + // verification script doesn't have to already know "bar" means "the + // capsule, collapsed" under spotlight specifically. + "capsule-collapsed", + "capsule-expanded", ]; #[derive(Parser)] @@ -118,6 +126,15 @@ pub struct Handles { pub notification_window: Option, /// Same deal as `notification_window`, via `osd::spawn(Some(kind))`. pub osd_window: Option, + /// Theme 04/spotlight's capsule centre module — see the + /// "capsule-expanded" view, which focuses it and types a query to + /// drive the drawer open before capturing. + pub launcher_entry: gtk4::Entry, + /// The `drawer` slot's own container — read back after the open + /// animation settles so "capsule-expanded"'s capture height matches + /// however tall the results actually grew, rather than a guessed + /// constant. + pub drawer_box: gtk4::Box, } /// Capture height for the `bar` view: layer-shell top margin + widget @@ -177,6 +194,48 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: }; capture_standalone_window(window, output, width, height); } + "capsule-collapsed" => { + // Identical to "bar" — see KNOWN_VIEWS's doc comment on why + // this has its own name anyway. + 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)); + }); + }); + } + "capsule-expanded" => { + // Focus + a real query, the same way a person opens the + // capsule (`connect_changed`/`EventControllerFocus::enter` in + // main.rs's capsule wiring do the rest: `open_fn` runs, + // `results.set_query` filters, and `animate_drawer_height` + // grows `drawer_box`). Capture height is bar height + however + // tall the drawer actually settled, not a hardcoded guess — + // this stays correct even if `dot_widths`/font/entry-count + // numbers change later. + let entry = handles.launcher_entry; + let drawer_box = handles.drawer_box; + root.connect_map(move |_| { + let output = output.clone(); + let entry = entry.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + entry.grab_focus(); + entry.set_text("f"); + let output = output.clone(); + let drawer_box = drawer_box.clone(); + // Past the 360ms spring_to run plus a normal capture + // settle, so the drawer's size_request (and the actual + // on-screen layer-shell surface it grows) has reached + // its final height before grabbing pixels. + gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { + let drawer_h = drawer_box.size_request().1.max(0); + let capture_h = bar_height + drawer_h; + finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); + }); + }); + }); + } "wifi-add-dialog" => { let anchor = handles.wifi_tab_btn; root.connect_map(move |_| { diff --git a/src/theme.rs b/src/theme.rs index 57d307f..ebd9884 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -68,6 +68,13 @@ fn load_css() -> String { // sites below (see the Phase 0 constant inventory). let spring = tokens.spring(); let spring_settle = tokens.spring_settle(); + let bg_alpha = tokens.bg_alpha(); + // Palette token NAME (never hex — see every builtin theme.toml's own + // comment on this), used below by the dots/launcher-entry/drawer rules. + // liquid-motion/glass-workbench never render those (see the match arms + // and unconditional-but-unused block below), so this being "accent" vs + // "green" vs "pink" per theme has no visible effect on them. + let accent_from = tokens.accent_from(); // `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid- // motion's floating island) draws a border on all four edges; "bottom" @@ -110,7 +117,7 @@ fn load_css() -> String { .workspace-btn.active:hover {{ background: transparent; }}\ .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", ), - _ => { + bread_theme::shell::WorkspaceStyle::Pill => { let accent = theme.tokens().accent_from(); format!( ".workspace-btn {{ background: transparent; opacity: 1; color: alpha(@on-bg, 0.4);\ @@ -127,6 +134,30 @@ fn load_css() -> String { .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", ) } + // "dots" (theme 04/spotlight): a label-less pill whose WIDTH comes + // from `modules.workspaces.dot_widths` and is set directly via + // `Widget::set_size_request` in `bar::workspaces::make_dot_button` + // — GTK CSS has no per-instance variable width, so unlike the demo's + // `.dots button[data-n="N"]` rules this class only supplies colour/ + // opacity/radius, never a width. `04-spotlight.html`'s own base + // rule (`background: #5a4a54`) is a *dim, desaturated* grey, not the + // bar's ink colour — approximated here as a low-alpha `@on-bg` fill + // so it still tracks pywal instead of hardcoding a hex that would + // clash with a light palette. + bread_theme::shell::WorkspaceStyle::Dots => { + let accent = theme.tokens().accent_from(); + format!( + ".workspace-dot {{ background-color: alpha(@on-bg, 0.35); color: transparent;\ + border-radius: {radius_pill}; border: none; outline: none; box-shadow: none;\ + min-height: 6px; margin: 0; padding: 0;\ + transition: background-color 0.25s {spring_settle},\ + opacity 0.25s {spring_settle}; }}\ + .workspace-dot:hover {{ background-color: alpha(@on-bg, 0.55); }}\ + .workspace-dot:not(.occupied):not(.active) {{ opacity: 0.35; }}\ + .workspace-dot.active {{ background-color: @{accent}; opacity: 1; }}\ + .workspace-dot.active:hover {{ background-color: @{accent}; }}", + ) + } }; format!( @@ -138,9 +169,15 @@ fn load_css() -> String { @keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\ @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;\ + window.breadbar {{ background-color: alpha(@bg, {bg_alpha}); color: @on-bg;\ border-radius: {radius_bar}; {window_border} }}\ - window.breadbar > centerbox {{ padding: {centerbox_padding}; }}\ + /* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\ + row + drawer, plan §2) as of the `drawer` slot wiring — every\ + theme's centerbox is now one level deeper than before, this\ + selector just follows it there. Since `padding` doesn't depend\ + on nesting depth, liquid-motion/glass-workbench render byte-\ + identical CSS either way. */\ + window.breadbar > box > centerbox {{ padding: {centerbox_padding}; }}\ window.breadbar button {{ min-height: 0; min-width: 0; }}\ {workspace_css}\ .clock-box {{ padding: 0 4px; }}\ @@ -363,7 +400,28 @@ fn load_css() -> String { .bread-padding-none {{ padding: 0; }}\ .bread-padding-xs {{ padding: 4px; }}\ .bread-padding-sm {{ padding: 8px; }}\ - .bread-padding-md {{ padding: 12px; }}", + .bread-padding-md {{ padding: 12px; }}\ + /* Theme 04/spotlight's embedded launcher (plan §7). Unconditional,\ + like `.clock-plain-time` above: `launcher_entry`/`launcher_results`\ + are built regardless of the active theme (see main.rs's \"Assemble\"\ + section), just never placed in a slot outside spotlight, so these\ + rules render nothing on liquid-motion/glass-workbench. */\ + .launcher-entry {{ background: transparent; color: @on-bg; border: none;\ + outline: none; box-shadow: none; caret-color: @{accent_from};\ + font-size: 13px; font-weight: 500; letter-spacing: 0.06em;\ + padding: 0; margin: 0; min-height: 0; }}\ + .launcher-entry.searching {{ font-size: 15px; letter-spacing: 0; }}\ + .bread-drawer {{ min-height: 0; }}\ + .bread-drawer.open {{ border-top: 1px solid alpha(@on-bg, 0.08);\ + margin-top: 6px; padding-top: 2px; }}\ + .bread-drawer listbox {{ background: transparent; padding: 2px 0; }}\ + .bread-drawer row {{ padding: 8px 14px; border-radius: {radius_sm};\ + color: @on-bg; background-color: transparent; }}\ + .bread-drawer row:hover {{ background-color: alpha(@on-bg, 0.08); }}\ + .bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\ + color: @on-bg; }}\ + .bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\ + .bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}", radius = radius, radius_bar = radius_bar, radius_sm = radius_sm, @@ -374,6 +432,8 @@ fn load_css() -> String { window_border = window_border, centerbox_padding = centerbox_padding, workspace_css = workspace_css, + bg_alpha = bg_alpha, + accent_from = accent_from, ) } From 88c1bb0b0765a2d00d2dc163d9ee31d7521b4433 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 00:40:26 +0800 Subject: [PATCH 50/85] bar/workspaces: unit-test the dots width-index mapping The isolated screenshot harness has no Hyprland IPC (bread-capture's isolation.rs), so it can never exercise a nonzero window count and prove dot widths visually. Pulled the count->dot_widths-index mapping out of make_dot_button as dot_width_index(), a pure function with no GTK dependency, and unit-tested it directly (0/1/2 pass through, 3+ collapses onto index 3, negative counts clamp instead of underflowing the array index) as the stand-in for that visual proof. --- src/bar/workspaces.rs | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 8b30505..4873431 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -184,8 +184,7 @@ pub fn make_dot_button( btn.set_halign(gtk4::Align::Center); btn.set_vexpand(false); btn.set_hexpand(false); - let idx = (windows.max(0) as usize).min(3); - btn.set_size_request(dot_widths[idx], 6); + btn.set_size_request(dot_widths[dot_width_index(windows)], 6); btn.connect_clicked(move |_| { relm4::spawn(async move { switch_workspace(id).await; @@ -194,6 +193,46 @@ pub fn make_dot_button( btn } +/// Maps an open-window count to a [`bread_theme::shell::DotWidths`] index: +/// 0/1/2 pass through, 3-or-more all collapse onto index 3 (the demo's own +/// `.dots button[data-n="3"]` never has a "4" variant). Pulled out of +/// [`make_dot_button`] as its own pure function purely so it's testable +/// without a GTK display — the isolated screenshot harness +/// (`bread-capture`) has no Hyprland IPC, so it can never exercise a +/// nonzero window count, and this is what stands in for that visual proof +/// (see the task notes on that gap). +fn dot_width_index(windows: i32) -> usize { + (windows.max(0) as usize).min(3) +} + +#[cfg(test)] +mod dot_width_tests { + use super::dot_width_index; + + #[test] + fn zero_and_one_and_two_pass_through() { + assert_eq!(dot_width_index(0), 0); + assert_eq!(dot_width_index(1), 1); + assert_eq!(dot_width_index(2), 2); + } + + #[test] + fn three_or_more_all_collapse_onto_index_three() { + assert_eq!(dot_width_index(3), 3); + assert_eq!(dot_width_index(4), 3); + assert_eq!(dot_width_index(50), 3); + } + + #[test] + fn negative_windows_clamps_to_zero_rather_than_panicking() { + // Hyprland's `windows` count is unsigned (u16) in practice, but + // `make_dot_button` takes a plain i32 — a negative value must + // never underflow the `dot_widths` index and panic. + assert_eq!(dot_width_index(-1), 0); + assert_eq!(dot_width_index(i32::MIN), 0); + } +} + #[derive(Clone, Copy)] struct Geom { x: f64, From 2694dc0e13c0b6724404e26ade6a61026cf058eb Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 10:53:52 +0800 Subject: [PATCH 51/85] capsule: clamp drawer collapse height and warn once per dropped widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawer's spring easing overshoots past t=1.0 by design — the bounce is the point on expand. On collapse (from=content height, to=0) that same overshoot carried the interpolated value below zero, and set_size_request hard-asserts height >= -1, so a GTK-CRITICAL fired once per frame for the whole 360ms close. Clamp to 0 rather than -1: -1 is GTK's use-natural-height sentinel, which is not what a closing drawer wants. reconcile_widgets re-ran its undeliverable-widget warning on every reconcile, and breadd re-pushes specs continuously for any timer-driven widget, so a one-off diagnostic became unbounded log spam under a theme whose slots don't name that widget's placement. Warn once per widget id instead. --- src/main.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0b794ad..e080f54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -148,6 +148,9 @@ pub struct App { // `WidgetPlacement` itself never appears here — it's a wire type from // the bread daemon API and stays untouched. widget_containers: std::collections::HashMap, + /// Widget ids already reported as undeliverable by `reconcile_widgets`, so + /// the warning fires once per widget instead of once per reconcile. + dropped_widget_warned: std::collections::HashSet, widget_tray_section: gtk4::Box, widget_tray_sep: gtk4::Separator, @@ -1161,6 +1164,7 @@ impl SimpleComponent for App { tray_box, tray_items: std::collections::HashMap::new(), widget_containers, + dropped_widget_warned: std::collections::HashSet::new(), widget_tray_section, widget_tray_sep, panels, @@ -1552,7 +1556,15 @@ impl App { }; if self.widget_containers.contains_key(&key) { by_container.entry(key).or_default().push(spec); - } else { + } else if self.dropped_widget_warned.insert(spec.id.clone()) { + // Warn ONCE per widget id, not once per reconcile: breadd + // re-pushes every spec on each update (a widget on a timer, + // like a git-branch poller, reconciles continuously), which + // turned a legitimate one-off diagnostic into unbounded log + // spam. The set is only added to, so a spec that starts + // resolving again after a theme switch stays quiet — the + // message is about a theme lacking the slot, and repeating it + // every tick tells the reader nothing new. eprintln!( "breadbar: widget '{}' (module '{}', placement {:?}) has no matching \ [bar.slots] widget: container — dropping", @@ -2227,7 +2239,14 @@ fn animate_drawer_height( } let target = drawer_box.clone(); let id = bread_theme::anim::spring_to(drawer_box, from, to, 360.0, move |h| { - target.set_size_request(-1, h); + // `spring_ease` deliberately overshoots past t=1.0 — that bounce is the + // point on expand, but on a collapse (from=content height, to=0) the + // same overshoot carries the interpolated value BELOW zero, and + // `set_size_request` hard-asserts `height >= -1` (GTK-CRITICAL, once + // per frame at 60fps). -1 is GTK's "use natural height" sentinel, not a + // valid animation frame, so clamp to 0 rather than -1: a drawer mid- + // collapse wants zero height, never its natural height. + target.set_size_request(-1, h.max(0)); }); *anim.borrow_mut() = Some(id); } From 33181a74616ea9432fa5ffbd4308da7552db2300 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 10:56:26 +0800 Subject: [PATCH 52/85] capsule: capture key events before the entry consumes Return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventControllerKey defaults to PropagationPhase::Bubble. GtkEntry handles Return in the target phase itself — emitting activate and returning TRUE — which stops propagation before a bubble-phase controller runs. The capsule's Enter-to-launch handler was therefore never reached: the selected app never launched, with no error, because the keypress was consumed upstream. Capture phase puts the controller ahead of the entry for the keys it claims (Return/Up/Down/Escape); everything else still Proceeds to the entry so normal text input is unaffected. --- src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main.rs b/src/main.rs index e080f54..aa0d318 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1033,6 +1033,14 @@ impl SimpleComponent for App { let results = launcher_results.clone(); let close_fn = Rc::clone(&close_fn); let key_ctrl = gtk4::EventControllerKey::new(); + // CAPTURE, not the default BUBBLE. A GtkEntry handles Return in the + // target phase itself — it emits `activate` and returns TRUE, which + // stops propagation before a bubble-phase controller ever runs, so + // Enter silently did nothing and the selected app never launched. + // Capturing puts this controller ahead of the entry's own handling + // for every key it cares about (Return/Up/Down/Escape), and keys it + // doesn't claim still Proceed to the entry for normal text input. + key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); key_ctrl.connect_key_pressed(move |_, key, _, _| { use gtk4::gdk::Key; match key { From c68ed0d7e94c03894d6e19d7ce36fc3ce9e04c84 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 11:47:49 +0800 Subject: [PATCH 53/85] panel: extend the dismiss-scrim pattern for the capsule's click-away (item B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PanelSet's shared breadbar-dismiss surface already handles click-away for the wifi/control/media popovers; this adds the pieces the capsule needs to reuse it rather than a second mechanism: an extra on_dismiss callback invoked alongside hide_all() on every dismiss click, and show_capsule_dismiss/hide_dismiss, which show the scrim with its clickable region starting at a caller-given top margin instead of the popover default. That margin matters: breadbar-dismiss's layer (overlay) always renders above the bar's own (top), so if its clickable region ever reached up into where the drawer is actually drawn, it would swallow clicks meant for a result row instead of the click-away. The capsule's own call site (main.rs, not part of this commit) always passes a margin sized to the drawer's maximum possible height, never its live one, so this can never happen regardless of how tall the drawer currently is. Not yet wired into the capsule's open/close — PanelSet's three new methods are unused until that follow-up commit, hence the transient dead_code warnings. --- src/panel.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/panel.rs b/src/panel.rs index 9070c63..b9982ed 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -5,18 +5,31 @@ //! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from //! the right. +use std::cell::RefCell; +use std::rc::Rc; + use gtk4::gdk::Key; use gtk4::prelude::*; -use gtk4_layer_shell::{KeyboardMode, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; use crate::{bind_layer_monitor, theme}; +/// A boxed, ref-counted, optionally-unset click-away callback — see +/// `PanelSet::on_dismiss`'s own doc comment. +type DismissCallback = Rc>>>; + #[derive(Clone)] pub struct PanelSet { pub connectivity: gtk4::Window, pub control: gtk4::Window, pub media: gtk4::Window, dismiss: gtk4::Window, + // Theme 04/spotlight's capsule (plan §7 phase 6c): an extra click-away + // callback invoked alongside the popover-dismiss path below, so the + // SAME `breadbar-dismiss` surface/click-catcher also collapses the + // capsule's drawer — see `show_capsule_dismiss`/`hide_dismiss` and + // `set_on_dismiss`. `None` under every other theme (never set). + on_dismiss: DismissCallback, } impl PanelSet { @@ -36,6 +49,7 @@ impl PanelSet { control, media, dismiss, + on_dismiss: Rc::new(RefCell::new(None)), }; set.wire_dismiss(); set.wire_escape(); @@ -52,6 +66,10 @@ impl PanelSet { pub fn show(&self, which: >k4::Window) { self.hide_panels(); + // A prior capsule search (see `show_capsule_dismiss`) may have left + // the shared dismiss surface's top margin pushed down past its + // popover-shaped default — restore it before this popover uses it. + self.reset_dismiss_margin(); // Dismiss first so the panel maps above it (same Overlay layer). self.dismiss.set_visible(true); self.dismiss.present(); @@ -70,12 +88,55 @@ impl PanelSet { self.media.set_visible(false); } + /// Theme 04/spotlight's capsule (plan §7 phase 6c): registers `cb` to + /// run whenever the shared dismiss surface is clicked, alongside the + /// popovers' own `hide_all`. `cb` is expected to no-op when the capsule + /// isn't actually open (matching `close_fn`'s own guard in main.rs), so + /// this firing on an ordinary popover click-away is harmless. + pub fn set_on_dismiss(&self, cb: impl Fn() + 'static) { + *self.on_dismiss.borrow_mut() = Some(Rc::new(cb)); + } + + /// Shows the dismiss scrim with its clickable region starting at + /// `top_margin` px from the screen top, rather than the theme's own + /// popover-shaped default. See the call site in main.rs's capsule + /// `open_fn` for why this needs to be at least the capsule's own row + /// height plus the drawer's maximum possible height: the dismiss + /// surface's layer (`overlay`) always renders above the bar's own + /// (`top`), so if its clickable region ever reached up into where the + /// drawer is actually drawn, it would swallow clicks meant for a + /// result row instead of forwarding them. + pub fn show_capsule_dismiss(&self, top_margin: i32) { + self.dismiss.set_margin(Edge::Top, top_margin); + self.dismiss.set_visible(true); + self.dismiss.present(); + } + + /// Hides the dismiss scrim and restores its margin to the theme's own + /// popover default, so a later popover `show()` isn't left with a + /// leftover capsule-sized gap. + pub fn hide_dismiss(&self) { + self.reset_dismiss_margin(); + self.dismiss.set_visible(false); + } + + fn reset_dismiss_margin(&self) { + let theme = theme::shell_theme(); + if let Some(surf) = theme.surfaces().get("breadbar-dismiss") { + let top = surf.offset.first().copied().unwrap_or(0.0) as i32; + self.dismiss.set_margin(Edge::Top, top); + } + } + fn wire_dismiss(&self) { let set = self.clone(); let click = gtk4::GestureClick::new(); click.set_button(0); click.connect_pressed(move |_, _, _, _| { set.hide_all(); + if let Some(cb) = set.on_dismiss.borrow().as_ref() { + cb(); + } }); if let Some(child) = self.dismiss.child() { child.add_controller(click); From e52c507f321e557ac4d764c85ac17c7cdcfcbadd Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 11:48:09 +0800 Subject: [PATCH 54/85] capsule: keyboard open, query modes, sections, and search-state geometry (phase 6c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the spotlight capsule (theme 04), THEME_SYSTEM_PLAN.md §7: - Keyboard open (item A): a new launcher_command module subscribes to bread.command.box.open — only when the active theme's launcher is Embedded — and focuses launcher_entry on receipt, which the existing EventControllerFocus::connect_enter already turns into an open_fn() call, same path a mouse click into the entry takes. breadbox's own keybind-triggered launch now redirects to this same event under an embedded theme instead of stacking its overlay window on top (see the breadbox commit). - Click-away scrim (item B): open_fn/close_fn now show/hide panel.rs's dismiss surface via the new show_capsule_dismiss/hide_dismiss, and panels.set_on_dismiss(close_fn) wires a click-away into the capsule's own close path. The scrim's clickable region starts at a fixed offset (capsule row height + the drawer's own maximum content height, DRAWER_MAX_HEIGHT_PX) rather than the drawer's live height, so it can never geometrically overlap a rendered result row regardless of breadbar-dismiss's overlay layer always rendering above the bar's own. - Query modes (item C): connect_changed now runs bread_launcher::parse_query against the entry text and, when the parsed prefix is listed in [launcher].modes, swaps launcher_results.scroller for a new mode_list populated by populate_mode_list (calc result / filtered commands / an "open this URL" prompt). key_ctrl routes Up/Down/Return at whichever list is active; a `>`/`.` row's action runs through the new ModeAction enum (RunShell for a command's own fixed exec string, OpenUrl straight to xdg-open) so arbitrary typed text is never passed through a shell. - Sections (item D): ResultsList::new's new `sections` bool is threaded from [launcher].sections. - Search-state geometry (item E): root's own width now spring-animates between [launcher].width and .search_width (animate_capsule_width, same set_size_request-driven technique animate_drawer_height already uses), and a `.searching` class on root switches border-radius to .search_radius via a new CSS rule/transition in theme.rs. Also fixes a real bug this surfaced: drawer_target_height was measuring drawer_box itself, whose size_request is continuously overwritten by animate_drawer_height's own tick callback — GTK clamps a widget's measure() up to at least its own explicit size request, so switching from the (tall) app list to a one-row calc result measured the PREVIOUS frame's forced height instead of the new content's actual size. It now sums each visible child's own natural height directly, which also fixes the same latent issue for narrowing app-search results. screenshot.rs gains two new views (capsule-sections, capsule-calc) to exercise D and C's calc path. --- src/launcher_command.rs | 39 +++++ src/main.rs | 376 +++++++++++++++++++++++++++++++++++++++- src/screenshot.rs | 54 ++++++ src/theme.rs | 26 ++- 4 files changed, 490 insertions(+), 5 deletions(-) create mode 100644 src/launcher_command.rs diff --git a/src/launcher_command.rs b/src/launcher_command.rs new file mode 100644 index 0000000..4d47560 --- /dev/null +++ b/src/launcher_command.rs @@ -0,0 +1,39 @@ +//! Subscribes to `bread.command.box.open` and focuses/opens the capsule — +//! only under `[launcher] mode = "embedded"` (spotlight, THEME_SYSTEM_PLAN.md +//! §7 phase 6c). See `breadbox/EVENTS.md` for the command's existing +//! contract: it's honored today only while `breadbox listen` is running, and +//! is a silent no-op at the bus with no subscriber. breadbar becomes a +//! SECOND subscriber of the exact same verb here — under an embedded theme, +//! breadbox's own `main` (see its doc comment on `dispatch_embedded_open`) +//! redirects a direct launch to this same event instead of mapping its own +//! overlay window, specifically so this module can pick it up. `breadbox +//! listen`'s own handling of the same event is separately made a no-op +//! under an embedded theme (see its `handle_open`) — there is exactly one +//! real handler for this event at a time, whichever theme is active. +//! +//! Same connection pattern as `widgets::client` (this crate's other +//! `BreadClient::subscribe` user): a fire-and-forget connect, a background +//! subscription thread with its own reconnect/backoff, and the handle is +//! leaked rather than threaded through `App` — there's no natural point to +//! stop it before the process exits. + +use crate::{App, AppInput}; +use bread_theme::shell::LauncherMode; +use bread_utils::bread_client::BreadClient; +use relm4::ComponentSender; + +/// Starts the subscription iff the active shell theme's launcher is +/// `Embedded`. A no-op call under every other theme — never connects to +/// breadd at all, matching the "effectively a no-op under every other +/// theme" pattern the rest of the capsule wiring already follows (main.rs's +/// `open_fn`/`close_fn` doc comment). +pub fn spawn(sender: ComponentSender) { + if crate::theme::shell_theme().launcher().mode != LauncherMode::Embedded { + return; + } + let client = BreadClient::connect(crate::widgets::client::APP_ID); + let subscription = client.subscribe("bread.command.box.open", move |_event| { + sender.input(AppInput::OpenLauncher); + }); + std::mem::forget(subscription); +} diff --git a/src/main.rs b/src/main.rs index aa0d318..89e2104 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ macro_rules! asset { } mod bar; +mod launcher_command; mod notifications; mod osd; mod panel; @@ -37,6 +38,14 @@ use std::rc::Rc; const LAUNCHER_APP_ID: &str = "box"; const LAUNCHER_LAUNCHED_EVENT: &str = "bread.box.launched"; +/// The drawer's own content-height ceiling (`04-spotlight.html`: `.searching +/// .results { max-height: 420px }`) — see [`drawer_target_height`]. Also +/// used to size the click-away scrim's dead zone (see `open_fn` in `init`): +/// the scrim's clickable region never reaches higher than the capsule row +/// plus this much, so it can never overlap a real result row regardless of +/// how tall the drawer currently is. +const DRAWER_MAX_HEIGHT_PX: i32 = 420; + pub struct BarInit { pub screenshot: Option, pub monitor: Option, @@ -177,6 +186,13 @@ pub enum AppInput { WidgetsUpdate(Vec), ReconcileMonitors, DismissPanels, + // `bread.command.box.open` (plan §7 phase 6c, `launcher_command` + // module): only ever dispatched when the active theme's launcher is + // `Embedded` — `launcher_command::spawn` never subscribes otherwise. + // Focuses `launcher_entry`, which the existing `EventControllerFocus` + // (`connect_enter`) already turns into an `open_fn()` call, the same + // path a mouse click into the entry takes. + OpenLauncher, } #[relm4::component(pub)] @@ -513,6 +529,7 @@ impl SimpleComponent for App { &launcher_entries, launcher_cfg.icon_px, Rc::clone(&launcher_history), + launcher_cfg.sections, ); launcher_results.scroller.add_css_class("bread-drawer-scroller"); @@ -963,6 +980,22 @@ impl SimpleComponent for App { widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden); widgets.drawer_box.set_size_request(-1, 0); + // ── Query-mode results (plan §7 phase 6c: `=` calc, `>` cmd, `.` + // url) ─────────────────────────────────────────────────────────── + // A second, small list living alongside `launcher_results.scroller` + // in the same drawer — only one of the two is ever visible at a + // time (see `connect_changed` below). Built unconditionally, same + // as `launcher_results` itself, but only ever APPENDED into + // `drawer_box` for an embedded launcher: every other theme must + // keep `drawer_box` exactly as childless as it already is (see the + // comment above `bar_modules.for_each_in_slot(&bar_slots.drawer, ..)`). + let mode_list = gtk4::ListBox::new(); + mode_list.set_selection_mode(gtk4::SelectionMode::Browse); + mode_list.set_visible(false); + if launcher_cfg.mode == bread_theme::shell::LauncherMode::Embedded { + widgets.drawer_box.append(&mode_list); + } + // ── Capsule expand/collapse + search wiring (theme 04/spotlight) ── // Effectively a no-op under every other theme: `launcher_entry` // never receives focus if it's never in a slot, so `open_fn` is @@ -971,18 +1004,53 @@ impl SimpleComponent for App { // capsule shell (drawer height, entry placeholder/alignment, // keyboard focus) is this file's own. let anim: Rc>> = Rc::new(RefCell::new(None)); + // Search-state width (plan §7 phase 6c: `[launcher].search_width`, + // `04-spotlight.html`'s `.searching .capsule { width: 520px }`) — + // a separate animation from the drawer's own height, both driven + // by `bread_theme::anim::spring_to` independently. + let anim_width: Rc>> = Rc::new(RefCell::new(None)); let launcher_open: Rc> = Rc::new(Cell::new(false)); + // The click-away scrim's dead zone (item B, see `DRAWER_MAX_HEIGHT_PX`'s + // own doc comment) — the capsule row's own theme-default dismiss + // offset (52px: `bar.window.height` + `bar.window.margin.top`) plus + // the drawer's own max content height, so the scrim's clickable + // region can never reach up into a rendered result row. + let capsule_dismiss_margin: i32 = theme::shell_theme() + .surfaces() + .get("breadbar-dismiss") + .map(|s| s.offset.first().copied().unwrap_or(0.0) as i32) + .unwrap_or(52) + + DRAWER_MAX_HEIGHT_PX; let open_fn: Rc = Rc::new({ let drawer_box = widgets.drawer_box.clone(); let anim = Rc::clone(&anim); + let anim_width = Rc::clone(&anim_width); let launcher_open = Rc::clone(&launcher_open); let entry = launcher_entry.clone(); + let root = root.clone(); + let panels = panels.clone(); + let idle_width = launcher_cfg.width; + let search_width = launcher_cfg.search_width; move || { if !launcher_open.get() { launcher_open.set(true); entry.add_css_class("searching"); gtk4::prelude::EntryExt::set_alignment(&entry, 0.0); + // `.searching` on the root itself (plan §7 phase 6c): + // toggles `[launcher].search_radius` via CSS (see + // theme.rs's `window.breadbar.searching` rule) and + // marks the width animation's starting point below. + root.add_css_class("searching"); + let current_width = root.width(); + let from = if current_width > 0 { current_width } else { idle_width }; + animate_capsule_width(&root, &anim_width, from, search_width); + // Item B: click-away scrim. Shown at a fixed dead-zone + // offset (`capsule_dismiss_margin`), never the live + // drawer height — see that constant's own doc comment + // for why a live-tracking offset would risk swallowing + // clicks meant for a result row. + panels.show_capsule_dismiss(capsule_dismiss_margin); } let target = drawer_target_height(&drawer_box); let current = drawer_box.size_request().1; @@ -992,9 +1060,13 @@ impl SimpleComponent for App { let close_fn: Rc = Rc::new({ let drawer_box = widgets.drawer_box.clone(); let anim = Rc::clone(&anim); + let anim_width = Rc::clone(&anim_width); let launcher_open = Rc::clone(&launcher_open); let entry = launcher_entry.clone(); let root_for_focus = root.clone(); + let root_for_width = root.clone(); + let panels = panels.clone(); + let idle_width = launcher_cfg.width; move || { if !launcher_open.get() { return; @@ -1006,6 +1078,10 @@ impl SimpleComponent for App { if placeholder_clock { entry.set_placeholder_text(Some(&bar::clock::time())); } + root_for_width.remove_css_class("searching"); + let current_width = root_for_width.width(); + animate_capsule_width(&root_for_width, &anim_width, current_width, idle_width); + panels.hide_dismiss(); let current = drawer_box.size_request().1; animate_drawer_height(&drawer_box, &anim, current, 0); // `keyboard = "on_demand"` (plan §7) ties the layer-shell @@ -1015,11 +1091,55 @@ impl SimpleComponent for App { } }); + panels.set_on_dismiss({ + let close_fn = Rc::clone(&close_fn); + move || close_fn() + }); + + // Which of the four query modes is currently driving `mode_list` + // (plan §7 phase 6c) — read by `key_ctrl` below to route Up/Down/ + // Return at the right list, and whether that mode's row is even + // selectable (an info-only row, e.g. an empty calc expression, + // never is). `launcher_cfg.modes` gates which prefixes actually + // switch mode: a prefix this theme doesn't list in `modes` falls + // through to a literal Apps query, prefix character and all. + let active_mode: Rc> = + Rc::new(Cell::new(bread_launcher::QueryKind::Apps)); + let mode_selectable: Rc> = Rc::new(Cell::new(false)); + let modes = launcher_cfg.modes.clone(); + { let results = launcher_results.clone(); + let mode_list = mode_list.clone(); let open_fn = Rc::clone(&open_fn); + let active_mode = Rc::clone(&active_mode); + let mode_selectable = Rc::clone(&mode_selectable); launcher_entry.connect_changed(move |entry| { - results.set_query(entry.text().as_str()); + let text = entry.text(); + let parsed = bread_launcher::parse_query(&text); + let mode_name = match parsed.kind { + bread_launcher::QueryKind::Calc => "calc", + bread_launcher::QueryKind::Cmd => "cmd", + bread_launcher::QueryKind::Url => "url", + bread_launcher::QueryKind::Apps => "apps", + }; + let kind = if modes.iter().any(|m| m == mode_name) { + parsed.kind + } else { + bread_launcher::QueryKind::Apps + }; + active_mode.set(kind); + if kind == bread_launcher::QueryKind::Apps { + mode_list.set_visible(false); + results.scroller.set_visible(true); + mode_selectable.set(false); + results.set_query(text.as_str()); + } else { + results.scroller.set_visible(false); + let selectable = populate_mode_list(&mode_list, &parsed); + mode_selectable.set(selectable); + mode_list.set_visible(true); + } open_fn(); }); } @@ -1031,7 +1151,10 @@ impl SimpleComponent for App { } { let results = launcher_results.clone(); + let mode_list = mode_list.clone(); let close_fn = Rc::clone(&close_fn); + let active_mode = Rc::clone(&active_mode); + let mode_selectable = Rc::clone(&mode_selectable); let key_ctrl = gtk4::EventControllerKey::new(); // CAPTURE, not the default BUBBLE. A GtkEntry handles Return in the // target phase itself — it emits `activate` and returns TRUE, which @@ -1043,6 +1166,37 @@ impl SimpleComponent for App { key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); key_ctrl.connect_key_pressed(move |_, key, _, _| { use gtk4::gdk::Key; + if active_mode.get() != bread_launcher::QueryKind::Apps { + // Calc/Cmd/Url (plan §7 phase 6c): Up/Down move the + // (possibly single-row) `mode_list` selection; Return + // runs whatever `mode_row_action` finds on the + // selected row — nothing, for a calc result or an + // empty prompt, since those rows carry none. + return match key { + Key::Escape => { + close_fn(); + gtk4::glib::Propagation::Stop + } + Key::Down if mode_selectable.get() => { + listbox_select_next(&mode_list); + gtk4::glib::Propagation::Stop + } + Key::Up if mode_selectable.get() => { + listbox_select_prev(&mode_list); + gtk4::glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(row) = mode_list.selected_row() { + if let Some(action) = mode_row_action(&row) { + run_mode_action(&action); + close_fn(); + } + } + gtk4::glib::Propagation::Stop + } + _ => gtk4::glib::Propagation::Proceed, + }; + } match key { Key::Escape => { close_fn(); @@ -1073,6 +1227,17 @@ impl SimpleComponent for App { }); launcher_entry.add_controller(key_ctrl); } + // Click on a mode_list row (a `>`-mode command or the `.`-mode + // "open this URL" prompt) acts too, same as a result row's click. + { + let close_fn = Rc::clone(&close_fn); + mode_list.connect_row_activated(move |_, row| { + if let Some(action) = mode_row_action(row) { + run_mode_action(&action); + close_fn(); + } + }); + } // Row click launches too, same as breadbox's own overlay. { let results = launcher_results.clone(); @@ -1188,6 +1353,12 @@ impl SimpleComponent for App { if init.primary { bar::tray::spawn_watcher(sender.clone()); widgets::client::spawn(sender.clone()); + // `bread.command.box.open` (plan §7 phase 6c): one subscriber, + // same reasoning as `widgets::client::spawn` above — a keybind + // should focus ONE capsule, not every satellite monitor's. + // A no-op call under every theme but spotlight (see the + // module's own doc comment). + launcher_command::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 @@ -1515,6 +1686,9 @@ impl SimpleComponent for App { AppInput::DismissPanels => { self.panels.hide_all(); } + AppInput::OpenLauncher => { + self.launcher_entry.grab_focus(); + } } } } @@ -2259,6 +2433,30 @@ fn animate_drawer_height( *anim.borrow_mut() = Some(id); } +/// Drives the capsule's own window width from `from` to `to` over 360ms +/// (plan §7 phase 6c: `[launcher].search_width`, `04-spotlight.html`'s +/// `.searching .capsule { width: 520px }`) — the same `spring_to` + +/// `set_size_request` technique `animate_drawer_height` uses for the +/// drawer's height, applied to the root window itself instead of a child +/// box. Unlike a drawer collapse, width never animates toward a negative +/// target (idle/search widths are both positive theme values), so there is +/// no analogous "clamp to 0" concern here. +fn animate_capsule_width( + root: >k4::ApplicationWindow, + anim: &Rc>>, + from: i32, + to: i32, +) { + if let Some(id) = anim.borrow_mut().take() { + id.remove(); + } + let target = root.clone(); + let id = bread_theme::anim::spring_to(root, from, to, 360.0, move |w| { + target.set_size_request(w.max(0), -1); + }); + *anim.borrow_mut() = Some(id); +} + /// The drawer's natural content height right now, capped at the demo's own /// 420px (`04-spotlight.html`: `.searching .results { max-height: 420px }`) /// — `ResultsList`'s scroller already self-caps at 480px @@ -2266,8 +2464,180 @@ fn animate_drawer_height( /// spotlight-specific ceiling on top of that shared one, not a replacement /// for it. fn drawer_target_height(drawer_box: >k4::Box) -> i32 { - let (_, natural, _, _) = drawer_box.measure(gtk4::Orientation::Vertical, -1); - natural.min(420) + // Deliberately never measures `drawer_box` itself. `animate_drawer_height`'s + // tick callback calls `drawer_box.set_size_request(-1, h)` on every + // frame, and GTK clamps a widget's own `measure()` result up to at + // least its own explicit size request — so once an animation has run + // even one frame, `drawer_box.measure()` reports that frame's forced + // height (or the spring's overshoot past it), not whatever its + // children actually need next. This bit spotlight's new query-mode + // rows directly: switching from the (tall) app list to a one-row calc + // result measured "437" instead of "~33", because the PREVIOUS + // animation frame had already forced `drawer_box` to 437px. + // + // Summing each currently-visible child's own natural height instead + // sidesteps this entirely — `launcher_results.scroller` and + // `mode_list` never get an explicit size request of their own, so + // their `measure()` always reflects their actual current content. + let mut total = 0; + let mut child = drawer_box.first_child(); + while let Some(c) = child { + if c.is_visible() { + let (_, natural, _, _) = c.measure(gtk4::Orientation::Vertical, -1); + total += natural; + } + child = c.next_sibling(); + } + total.min(DRAWER_MAX_HEIGHT_PX) +} + +// ── Query-mode rows (plan §7 phase 6c) ────────────────────────────────── +// +// `mode_list`'s rows are NOT `bread_launcher::DesktopEntry`-backed +// (`bread_launcher::gtk::row_entry` returns `None` for every one of +// these), so they're built/read here rather than through that crate. + +/// A single-line, non-interactive row — the calc result, or a "nothing +/// typed yet" placeholder. Reuses `.app-name` so it inherits the same +/// `.bread-drawer row` typography `bread-launcher`'s own rows get. +fn mode_info_row(text: &str) -> gtk4::ListBoxRow { + let row = gtk4::ListBoxRow::new(); + row.set_selectable(false); + row.set_activatable(false); + let lbl = gtk4::Label::new(Some(text)); + lbl.add_css_class("app-name"); + lbl.set_xalign(0.0); + row.set_child(Some(&lbl)); + row +} + +/// A single-line, actionable row (a `>`-mode command, or the `.`-mode +/// "open this URL" prompt) — Enter/click spawns `action` once resolved by +/// [`run_mode_action`]. +fn mode_action_row(text: &str, action: ModeAction) -> gtk4::ListBoxRow { + let row = gtk4::ListBoxRow::new(); + let lbl = gtk4::Label::new(Some(text)); + lbl.add_css_class("app-name"); + lbl.set_xalign(0.0); + row.set_child(Some(&lbl)); + unsafe { row.set_data("mode_action", action) }; + row +} + +/// What Enter/click on a [`mode_action_row`] does. Two variants, not one +/// shell-command string, so a `.`-mode URL (arbitrary user-typed text) +/// never passes through `bash -c` at all — only a `>`-mode command's own +/// fixed, trusted `exec` string does. +#[derive(Clone)] +enum ModeAction { + RunShell(&'static str), + OpenUrl(String), +} + +fn mode_row_action(row: >k4::ListBoxRow) -> Option { + unsafe { row.data::("mode_action").map(|p| p.as_ref().clone()) } +} + +fn run_mode_action(action: &ModeAction) { + match action { + ModeAction::RunShell(cmd) => { + let _ = std::process::Command::new("bash") + .args(["-c", cmd]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + } + ModeAction::OpenUrl(url) => { + // No scheme-adding shell involved — `xdg-open` gets the raw + // argument, so nothing in a `.`-mode query is ever parsed as + // shell syntax. + let target = if url.contains("://") { + url.clone() + } else { + format!("https://{url}") + }; + let _ = std::process::Command::new("xdg-open") + .arg(target) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + } + } +} + +/// Moves `list`'s selection to the next/previous row — unlike +/// `bread_launcher::gtk::ResultsList::select_next`/`select_prev`, `mode_list` +/// never has hidden rows to skip (it's cleared and rebuilt from scratch on +/// every query change), so this is the plain, un-filtered version. +fn listbox_select_next(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(-1); + if let Some(row) = list.row_at_index(cur + 1) { + list.select_row(Some(&row)); + } +} + +fn listbox_select_prev(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(0); + if cur > 0 { + if let Some(row) = list.row_at_index(cur - 1) { + list.select_row(Some(&row)); + } + } +} + +/// Clears `mode_list` and rebuilds it for `parsed` — the calc result, +/// filtered `>`-mode commands, or the `.`-mode "open this URL" prompt. +/// Returns whether anything is now selectable (a real command/URL row, not +/// just an info row) so the caller knows whether Return has anything to do. +fn populate_mode_list(mode_list: >k4::ListBox, parsed: &bread_launcher::ParsedQuery) -> bool { + while let Some(row) = mode_list.row_at_index(0) { + mode_list.remove(&row); + } + match parsed.kind { + bread_launcher::QueryKind::Calc => { + match bread_launcher::eval_calc(&parsed.value) { + Some(result) => mode_list.append(&mode_info_row(&format!("= {result}"))), + None => mode_list.append(&mode_info_row("=")), + } + false + } + bread_launcher::QueryKind::Cmd => { + let matches = bread_launcher::filter_commands( + &parsed.value, + bread_launcher::builtin_commands(), + ); + if matches.is_empty() { + mode_list.append(&mode_info_row("No matching commands")); + false + } else { + for cmd in &matches { + mode_list.append(&mode_action_row(cmd.name, ModeAction::RunShell(cmd.exec))); + } + if let Some(first) = mode_list.row_at_index(0) { + mode_list.select_row(Some(&first)); + } + true + } + } + bread_launcher::QueryKind::Url => { + if parsed.value.is_empty() { + mode_list.append(&mode_info_row(".")); + false + } else { + mode_list.append(&mode_action_row( + &format!("Open {}", parsed.value), + ModeAction::OpenUrl(parsed.value.clone()), + )); + if let Some(first) = mode_list.row_at_index(0) { + mode_list.select_row(Some(&first)); + } + true + } + } + bread_launcher::QueryKind::Apps => false, + } } fn popover_tab(label: &str) -> gtk4::ToggleButton { diff --git a/src/screenshot.rs b/src/screenshot.rs index 8299ecd..1b372fd 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -53,6 +53,13 @@ const KNOWN_VIEWS: &[&str] = &[ // capsule, collapsed" under spotlight specifically. "capsule-collapsed", "capsule-expanded", + // Phase 6c: query sections and the `=` calc mode — see `dispatch`'s + // two new match arms below. "capsule-expanded" already exercises the + // search-state width/radius change (item E: it types a query, which + // now also drives `open_fn`'s `.searching` root class + capsule-width + // spring animation), so that gap doesn't need its own view. + "capsule-sections", + "capsule-calc", ]; #[derive(Parser)] @@ -236,6 +243,53 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: }); }); } + "capsule-sections" => { + // Focus with NO query typed — the idle browse view + // (`ResultsList::new`'s "Recent"/"Apps" header rows are visible + // from construction; `set_query` is what would hide them, and + // it's never called here). Same settle timing as + // "capsule-expanded", just without the `entry.set_text` step. + let entry = handles.launcher_entry; + let drawer_box = handles.drawer_box; + root.connect_map(move |_| { + let output = output.clone(); + let entry = entry.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + entry.grab_focus(); + let output = output.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { + let drawer_h = drawer_box.size_request().1.max(0); + let capture_h = bar_height + drawer_h; + finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); + }); + }); + }); + } + "capsule-calc" => { + // `=` mode (item C): the drawer's `mode_list` shows a single + // evaluated result row instead of `launcher_results.scroller` + // (see `populate_mode_list`'s `QueryKind::Calc` arm). + let entry = handles.launcher_entry; + let drawer_box = handles.drawer_box; + root.connect_map(move |_| { + let output = output.clone(); + let entry = entry.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + entry.grab_focus(); + entry.set_text("=6*7"); + let output = output.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { + let drawer_h = drawer_box.size_request().1.max(0); + let capture_h = bar_height + drawer_h; + finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); + }); + }); + }); + } "wifi-add-dialog" => { let anchor = handles.wifi_tab_btn; root.connect_map(move |_| { diff --git a/src/theme.rs b/src/theme.rs index ebd9884..8e3f37c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -75,6 +75,13 @@ fn load_css() -> String { // and unconditional-but-unused block below), so this being "accent" vs // "green" vs "pink" per theme has no visible effect on them. let accent_from = tokens.accent_from(); + // `[launcher].search_radius` (plan §7 phase 6c) — `LauncherMode:: + // Embedded` only (spotlight); `.launcher().radius` itself already + // equals `radius_bar` for that theme (see its own theme.toml comment), + // so a theme that omits `search_radius` gets `radius_search == + // radius_bar` here too, i.e. no visible shrink, matching bread-theme's + // own "default to the idle value" fallback. + let radius_search = format!("{}px", theme.launcher().search_radius); // `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid- // motion's floating island) draws a border on all four edges; "bottom" @@ -170,7 +177,12 @@ 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, {bg_alpha}); color: @on-bg;\ - border-radius: {radius_bar}; {window_border} }}\ + border-radius: {radius_bar}; {window_border}\ + transition: border-radius 0.3s {spring_settle}; }}\ + /* `[launcher].search_radius` (plan §7 phase 6c, spotlight only —\ + `launcher_entry` never gets focus under any other theme, so\ + `.searching` never lands on `window.breadbar` there). */\ + window.breadbar.searching {{ border-radius: {radius_search}; }}\ /* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\ row + drawer, plan §2) as of the `drawer` slot wiring — every\ theme's centerbox is now one level deeper than before, this\ @@ -421,8 +433,18 @@ fn load_css() -> String { .bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\ color: @on-bg; }}\ .bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\ - .bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}", + .bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}\ + /* `[launcher].sections` (plan §7 phase 6c) — the idle drawer's\ + \"Recent\"/\"Apps\" group labels (`bread_launcher::gtk::\ + build_header_row`). Unconditional, same reasoning as every other\ + launcher rule above: only spotlight ever builds a row with this\ + class at all. */\ + .bread-drawer-section-header {{ padding: 6px 14px 2px; }}\ + .section-header-label {{ font-size: 11px; font-weight: 600;\ + letter-spacing: 0.08em; text-transform: uppercase;\ + opacity: 0.45; }}", radius = radius, + radius_search = radius_search, radius_bar = radius_bar, radius_sm = radius_sm, radius_pill = radius_pill, From a7db4d093dccdbd836a1cb2ff9611c91c813eebb Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 11:58:39 +0800 Subject: [PATCH 55/85] capsule: keep the drawer hidden when collapsed and pin the capsule width Two rendering bugs, both from treating set_size_request as a maximum when it is only a minimum. The drawer was collapsed with set_size_request(-1, 0) but left visible. GTK still allocates a visible box its natural height, and this layer-shell surface has no fixed height, so the window grew to fit the entire results list: the capsule rendered open at idle with a stray result row beneath it. It is now hidden while collapsed, revealed in open_fn before the height animation, and hidden again on the frame the collapse reaches zero so the close stays animated rather than snapping shut. The capsule's width came from set_default_width, which is only a preference a wide child overrides. Combined with the results list propagating its natural width, the pill stretched well past the theme's 480px. The width request is now pinned, and the list no longer propagates width. --- src/main.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/main.rs b/src/main.rs index 89e2104..887333e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -282,6 +282,13 @@ impl SimpleComponent for App { // a real value out of it. if let Width::Px(px) = window_spec.width { root.set_default_width(px); + // set_default_width alone is only a preference — a wide child (the + // results list, whose natural width is its longest app name plus + // icon and wm-class) overrides it, so the capsule rendered far + // wider than the theme's 480px and stopped reading as a pill. + // Pinning the request keeps the surface at the theme's width + // regardless of what the app catalog contains. + root.set_size_request(px, -1); } // "auto" reserves height + top margin so tiled clients sit below the // gap — see WindowSpec::exclusive's doc comment (bread-theme). @@ -979,6 +986,13 @@ impl SimpleComponent for App { // same as the demo's `.results { max-height: 0; overflow: hidden }`. widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden); widgets.drawer_box.set_size_request(-1, 0); + // set_size_request is a MINIMUM, not a maximum: GTK still allocates a + // visible box its natural height, and this layer-shell surface has no + // fixed height, so the window grew to fit the whole results list and + // the capsule sat open-at-idle showing a stray row. A hidden widget + // requests no size at all, which is what "collapsed" actually needs. + // open_fn/close_fn toggle this back on/off around the height animation. + widgets.drawer_box.set_visible(false); // ── Query-mode results (plan §7 phase 6c: `=` calc, `>` cmd, `.` // url) ─────────────────────────────────────────────────────────── @@ -1035,6 +1049,10 @@ impl SimpleComponent for App { move || { if !launcher_open.get() { launcher_open.set(true); + // Reveal before measuring/animating: while hidden the box + // reports no natural height, so the open animation would + // target 0 and nothing would appear. + drawer_box.set_visible(true); entry.add_css_class("searching"); gtk4::prelude::EntryExt::set_alignment(&entry, 0.0); // `.searching` on the root itself (plan §7 phase 6c): @@ -2429,6 +2447,14 @@ fn animate_drawer_height( // valid animation frame, so clamp to 0 rather than -1: a drawer mid- // collapse wants zero height, never its natural height. target.set_size_request(-1, h.max(0)); + // Collapse finished: hide the box so it stops claiming natural height. + // set_size_request is only a minimum, so a visible-but-zero-request + // drawer still gets allocated its children's full height and holds the + // capsule open. Hiding here rather than in close_fn keeps the collapse + // animated instead of snapping shut on the first frame. + if to == 0 && h <= 0 { + target.set_visible(false); + } }); *anim.borrow_mut() = Some(id); } From 6c4df8813cd563174e6075a1cba0cf999ddee2cd Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 12:04:43 +0800 Subject: [PATCH 56/85] capsule: accept the open event breadbox can actually emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app may only publish inside its own bread..* namespace, so breadbox (app id 'box') cannot emit bread.command.box.open — bread-client refuses it and the redirect did nothing but log a warning. The capsule now also subscribes to bread.box.open_requested, which is what breadbox emits. bread.command.box.open is kept as well: that is the addressed-to-an-app command form an external trigger (the bread CLI, a keybind, another app) would legitimately send, so the capsule opens whether it was asked directly or told by breadbox. --- src/launcher_command.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/launcher_command.rs b/src/launcher_command.rs index 4d47560..798f5ec 100644 --- a/src/launcher_command.rs +++ b/src/launcher_command.rs @@ -32,8 +32,22 @@ pub fn spawn(sender: ComponentSender) { return; } let client = BreadClient::connect(crate::widgets::client::APP_ID); - let subscription = client.subscribe("bread.command.box.open", move |_event| { - sender.input(AppInput::OpenLauncher); - }); - std::mem::forget(subscription); + // Two verbs, deliberately. + // + // `bread.box.open_requested` is what breadbox actually emits when the + // active theme is embedded: an app may only publish inside its own + // `bread..*` namespace, so breadbox (app id `box`) cannot emit a + // `bread.command.*` event at all — bread-client refuses it outright. + // + // `bread.command.box.open` is kept because it is the addressed-TO-an-app + // command form, which is what an external trigger (the `bread` CLI, a + // keybind, another app) would legitimately send. Honouring both means the + // capsule opens whether it was asked directly or told by breadbox. + for verb in ["bread.box.open_requested", "bread.command.box.open"] { + let sender = sender.clone(); + let subscription = client.subscribe(verb, move |_event| { + sender.input(AppInput::OpenLauncher); + }); + std::mem::forget(subscription); + } } From 6b15f24fb28127d34653781628a2f2c9778acbdc Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:04:49 +0800 Subject: [PATCH 57/85] build: move the dev-only source override out of Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same change as breadbox's fa85fc1. The [patch] block pointing bread-theme and bread-launcher at ../bread-ecosystem shipped in the committed manifest, where CI cannot resolve it — the workflow clones this repo alone and runs cargo --locked with no sibling checkout. Moved to a gitignored .cargo/config.toml. Hygiene, not a CI fix: bread-launcher does not exist at tag v0.7.4, so the pin cannot resolve without the override until bread-ecosystem is tagged. --- .gitignore | 3 +++ Cargo.toml | 7 ------- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 4f90dfb..af5d71a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ aster-brief.md # graphify knowledge-graph output (local tool cache, not for commit) graphify-out/ + +# Local-only source overrides (see .cargo/config.toml). +.cargo/ diff --git a/Cargo.toml b/Cargo.toml index 7fba78a..fa918e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,10 +45,3 @@ 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" } -bread-launcher = { path = "../bread-ecosystem/bread-launcher" } From 02ed92ae9d1765e0ea7d12c7c0350afcab7c8347 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:18:43 +0800 Subject: [PATCH 58/85] theme: adapt to bread-theme's ThemeWatch return type bread_theme::shell::watch() now returns an opaque ThemeWatch (it re-arms itself onto a new theme's directory when the active theme id changes, rather than staying pinned to whichever directory was active at call time) instead of a bare gio::FileMonitor. Update the stored handle's type to match. --- src/theme.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/theme.rs b/src/theme.rs index 8e3f37c..dacba3c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -507,7 +507,11 @@ pub fn apply() { } thread_local! { - static SHELL_THEME_MONITOR: RefCell> = + // `bread_theme::shell::ThemeWatch`, not a bare `gio::FileMonitor`: the + // watch now re-arms itself onto a new theme's directory when the active + // theme id changes underneath it (see that type's doc comment), so the + // handle we keep alive is opaque, not a single fixed monitor. + static SHELL_THEME_MONITOR: RefCell> = const { RefCell::new(None) }; } From 1d3081851007c672ae8b58a6e7adfb3c10a50f32 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:19:15 +0800 Subject: [PATCH 59/85] surface: pin satellite window width instead of just requesting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit surface::apply()'s SurfaceWidth::Px handling only called set_default_width(), which is advisory — a wide child (an unwrapped app-name label, or a long summary/body with nothing narrower than its natural width to wrap against) overrides it, so breadbar-notif (320px) and breadbar-osd (180px) could render wider than their theme's configured width instead of wrapping. This is the exact 'wide child overrides set_default_width' trap main.rs's capsule Width::Px handling already learned and documented; apply() predates that fix and never got it. Add the same set_size_request(px, -1) pin used there. history.rs's 360px override of the shared breadbar-notif namespace has to override both calls now, not just set_default_width, since the pin from apply() would otherwise win over a bare default-width override. --- src/notifications/history.rs | 6 +++++- src/surface.rs | 22 ++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/notifications/history.rs b/src/notifications/history.rs index e3919bd..66c8783 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -180,8 +180,12 @@ pub fn build_window(store: Store) -> Ui { // 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. + // that isn't something the manifest schema models today. Both calls + // must be overridden, not just `set_default_width` — `apply()` also + // pins `set_size_request` to the toast's 320px, and a bare width alone + // would lose to that pin the same way it lost to a wide child before. window.set_default_width(360); + window.set_size_request(360, -1); window.set_keyboard_mode(KeyboardMode::OnDemand); crate::theme::bind_auto(&window); diff --git a/src/surface.rs b/src/surface.rs index d2812a2..4b69705 100644 --- a/src/surface.rs +++ b/src/surface.rs @@ -19,12 +19,15 @@ use gtk4_layer_shell::{Edge, Layer, LayerShell}; /// 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. +/// The width applied here is not authoritative 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. Because a `Px` width is pinned with BOTH +/// `set_default_width` and `set_size_request` (see below — the latter is +/// what actually holds against a wide child), such a caller must override +/// both, not just `set_default_width`, or the pin from here wins. pub fn apply(window: >k4::Window, namespace: &str) { let theme = crate::theme::shell_theme(); let Some(surf) = theme.surfaces().get(namespace) else { @@ -74,5 +77,12 @@ pub fn apply(window: >k4::Window, namespace: &str) { if let SurfaceWidth::Px(px) = surf.width { window.set_default_width(px); + // set_default_width alone is only a preference — a wide child (an + // unwrapped app-name label, or a long summary/body before GTK has + // any allocation narrower than its natural width to wrap against) + // overrides it, so the window renders wider than the theme's + // requested px and stops matching the theme. Same trap, same fix, + // as main.rs's capsule `Width::Px` handling — see its comment. + window.set_size_request(px, -1); } } From 110ad2c6f99c933553a1f13ae4fb5cd876b75252 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:19:49 +0800 Subject: [PATCH 60/85] notifications: re-apply toast click-through after the surface::apply migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feature/theme-spotlight branched before main's 297207a ('notifications: stop toast popups from stealing focus or blocking clicks') and the notification popup was rewritten in this branch to use surface::apply() for its layer-shell setup, which sets only anchor/margin/width/layer — no input region — and left the window on KeyboardMode::OnDemand. Merging this branch would have silently reintroduced the original bug with no merge conflict to flag it. Re-apply the fix on top of surface::apply(): add surface::click_through(), an opt-in helper that sets an empty layer-shell input region on map so every pointer event passes through to whatever's underneath, and call it from the toast's create_window(). history.rs's window (the genuinely interactive notification-history view) is untouched and correctly keeps OnDemand and normal hit-testing. Also switch the toast itself from KeyboardMode::OnDemand to KeyboardMode::None. The toast's card layout does build action buttons and, when a notification carries an inline-reply hint, a GtkEntry — but with an empty input region nothing on the toast is ever clickable or focusable regardless of keyboard mode, so OnDemand only offered a focus capability with no way to trigger it. Those controls remain reachable from the history window, which is opened deliberately and keeps real hit-testing. --- src/notifications/popup.rs | 18 +++++++++++++++--- src/surface.rs | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 394e136..a45e402 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -172,9 +172,21 @@ fn create_window() -> gtk4::Window { window.init_layer_shell(); window.set_namespace(Some("breadbar-notif")); 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); + // Toasts are purely informational: they never grab keyboard focus... + window.set_keyboard_mode(KeyboardMode::None); + // ...and click through entirely, via an empty input region — every + // pointer event passes straight to whatever's underneath instead of + // hitting the toast. `make_card` below does build action buttons and, + // when a notification carries INLINE_REPLY_KEY, a reply `GtkEntry` — + // but with no input region reaching the toast at all, neither is ever + // clickable or focusable from here regardless of keyboard mode, so + // OnDemand would grant a focus capability nothing can trigger. Those + // controls are only reachable from the history window (history.rs), + // which is opened deliberately and correctly keeps OnDemand + normal + // hit-testing. If the toast itself grows real click interactivity + // later, this needs to become a real (non-empty) input region sized to + // just the interactive rows, not a blanket revert to OnDemand. + crate::surface::click_through(&window); crate::theme::bind_auto(&window); window } diff --git a/src/surface.rs b/src/surface.rs index 4b69705..10adcdc 100644 --- a/src/surface.rs +++ b/src/surface.rs @@ -86,3 +86,28 @@ pub fn apply(window: >k4::Window, namespace: &str) { window.set_size_request(px, -1); } } + +/// Makes `window` fully click-through: an empty layer-shell input region +/// means every pointer event passes to whatever is underneath instead of +/// being consumed by this surface. Deliberately opt-in, not part of +/// `apply()` — like `exclusive` zone and `keyboard` mode (see the module +/// doc comment), this isn't a `[surfaces.*]` schema concept, and most of +/// breadbar's satellites (history, the panel, the dismiss-scrim) genuinely +/// need real hit-testing. Only a purely-informational surface — today just +/// the notification toast — should call this. +/// +/// Must be applied in a `connect_map` handler: the surface (and therefore +/// `window.surface()`) doesn't exist until the window is mapped. +/// +/// This exists so a future migration of a surface's window-setup code +/// (like the one from hand-rolled layer-shell calls to this module) can't +/// silently drop a click-through requirement the way `breadbar-notif` did +/// once already — see the git history of `notifications/popup.rs` around +/// the "stop toast popups from stealing focus or blocking clicks" fix. +pub fn click_through(window: >k4::Window) { + window.connect_map(|win| { + if let Some(surface) = win.surface() { + surface.set_input_region(Some(>k4::cairo::Region::create())); + } + }); +} From 2cbdb58f37f77ce502b30355a4bc55116a7d9848 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:20:59 +0800 Subject: [PATCH 61/85] launcher: open the capsule on the currently-focused monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launcher_command::spawn() only runs for the primary App instance, and AppInput::OpenLauncher always grabbed focus on that instance's own capsule — but self.monitor is whichever output was focused ONCE, at that instance's own init(), not re-resolved on every keybind press. Start breadbar while eDP-1 is focused, move to DVI-I-1, press the launcher keybind, and the capsule opened on eDP-1, off-screen from where the user was looking. Re-resolve the focused Hyprland monitor at fire-time via primary_hypr_monitor() (already used for the same purpose at startup) and route to the right instance: the primary's own capsule if the focused monitor is its own, or forward to that monitor's tracked Controller in self.satellites otherwise. Falls back to the local capsule when there's no focused monitor to resolve (screenshot mode, hyprctl unavailable) or no tracked satellite for it yet (a very recent hotplug reconcile hasn't caught up with). Split the routing decision into a pure resolve_launcher_route() helper so it's unit-testable without a live App/GTK/Hyprland stack; covered by four new tests. --- src/main.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 887333e..9a24b96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1705,7 +1705,31 @@ impl SimpleComponent for App { self.panels.hide_all(); } AppInput::OpenLauncher => { - self.launcher_entry.grab_focus(); + // Only the primary instance subscribes to the open command + // (`launcher_command::spawn`), but `self.monitor` here is + // whichever output was focused ONCE, at this instance's own + // `init()` — baked in at process start, not re-resolved on + // every keybind press. If the user has since moved focus to + // a different monitor, blindly grabbing focus on `self` + // would open the capsule on the wrong screen. Re-resolve + // the focused monitor now and route to whichever instance + // actually owns it. + let satellite_names: Vec<&str> = + self.satellites.iter().map(|(n, _)| n.as_str()).collect(); + let focused = primary_hypr_monitor(); + match resolve_launcher_route(focused.as_deref(), &self.monitor, &satellite_names) + { + LauncherRoute::Satellite(name) => { + // resolve_launcher_route only returns a name present in + // satellite_names, so this lookup cannot miss. + if let Some((_, ctrl)) = self.satellites.iter().find(|(n, _)| *n == name) { + ctrl.sender().emit(AppInput::OpenLauncher); + } + } + LauncherRoute::Local => { + self.launcher_entry.grab_focus(); + } + } } } } @@ -2847,6 +2871,35 @@ fn primary_hypr_monitor() -> Option { .map(|m| m.name.clone()) } +/// Where `AppInput::OpenLauncher` should be actually handled: locally (this +/// instance grabs its own capsule's focus), or forwarded to a specific +/// satellite instance. Pure decision logic, split out of the `update` match +/// arm so it's unit-testable without a live `App`/GTK/Hyprland stack. +#[derive(Debug, Clone, PartialEq, Eq)] +enum LauncherRoute { + Local, + Satellite(String), +} + +/// `focused`: the currently-focused Hyprland monitor, re-queried at +/// keybind-fire time (`None` if Hyprland's monitor query failed, e.g. +/// screenshot mode). `own`: this instance's own monitor, fixed at `init()`. +/// `satellites`: names of monitors this (necessarily primary) instance +/// tracks a `Controller` for. +/// +/// Falls back to `Local` whenever forwarding isn't possible or isn't +/// needed, so a caller can always make forward progress: no focused +/// monitor, the focused monitor is this instance's own, or the focused +/// monitor has no tracked satellite yet. +fn resolve_launcher_route(focused: Option<&str>, own: &str, satellites: &[&str]) -> LauncherRoute { + match focused { + Some(name) if name != own && satellites.contains(&name) => { + LauncherRoute::Satellite(name.to_string()) + } + _ => LauncherRoute::Local, + } +} + fn hypr_monitor_names() -> Vec { hypr_monitors_live() .into_iter() @@ -2937,3 +2990,42 @@ fn drop_satellite(satellites: &mut Vec<(String, Controller)>, name: &str) { } }); } + +#[cfg(test)] +mod launcher_route_tests { + use super::{resolve_launcher_route, LauncherRoute}; + + #[test] + fn focused_monitor_is_own_stays_local() { + assert_eq!( + resolve_launcher_route(Some("eDP-1"), "eDP-1", &["DVI-I-1"]), + LauncherRoute::Local + ); + } + + #[test] + fn focused_monitor_is_tracked_satellite_forwards() { + assert_eq!( + resolve_launcher_route(Some("DVI-I-1"), "eDP-1", &["DVI-I-1"]), + LauncherRoute::Satellite("DVI-I-1".to_string()) + ); + } + + #[test] + fn focused_monitor_with_no_tracked_satellite_falls_back_local() { + // e.g. reconcile hasn't caught up with a very recent hotplug yet. + assert_eq!( + resolve_launcher_route(Some("HDMI-A-1"), "eDP-1", &["DVI-I-1"]), + LauncherRoute::Local + ); + } + + #[test] + fn no_focused_monitor_falls_back_local() { + // Hyprland's monitor query failed (screenshot mode, hyprctl missing). + assert_eq!( + resolve_launcher_route(None, "eDP-1", &["DVI-I-1"]), + LauncherRoute::Local + ); + } +} From 030cf280967b9858d4296468ab0a0f51195c03ce Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:21:23 +0800 Subject: [PATCH 62/85] launcher: log when a mode-row action fails to launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_mode_action discarded spawn()'s Result with 'let _ =' for both RunShell and OpenUrl, with no log line either way. If xdg-open is missing, or the shell/spawn fails for any reason, pressing Enter on a '>'-command or '.'-URL row did nothing at all with zero diagnostic — worse than logging nothing being silent, there was no way to even suspect what happened. Log to stderr on Err for both variants. Also pull the URL scheme-adding logic out into a pure url_open_target() helper, covered by two new tests, so it's exercised without spawning a real process. --- src/main.rs | 52 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9a24b96..93bb86c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2588,31 +2588,44 @@ fn mode_row_action(row: >k4::ListBoxRow) -> Option { unsafe { row.data::("mode_action").map(|p| p.as_ref().clone()) } } +/// Pure half of the `.`-mode URL action: adds a scheme when the user typed +/// a bare host (`example.com` -> `https://example.com`), leaves anything +/// that already looks like `scheme://...` untouched. +fn url_open_target(url: &str) -> String { + if url.contains("://") { + url.to_string() + } else { + format!("https://{url}") + } +} + fn run_mode_action(action: &ModeAction) { match action { ModeAction::RunShell(cmd) => { - let _ = std::process::Command::new("bash") + if let Err(e) = std::process::Command::new("bash") .args(["-c", cmd]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) - .spawn(); + .spawn() + { + eprintln!("breadbar: failed to run mode command {cmd:?}: {e}"); + } } ModeAction::OpenUrl(url) => { // No scheme-adding shell involved — `xdg-open` gets the raw // argument, so nothing in a `.`-mode query is ever parsed as // shell syntax. - let target = if url.contains("://") { - url.clone() - } else { - format!("https://{url}") - }; - let _ = std::process::Command::new("xdg-open") - .arg(target) + let target = url_open_target(url); + if let Err(e) = std::process::Command::new("xdg-open") + .arg(&target) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) - .spawn(); + .spawn() + { + eprintln!("breadbar: failed to open url {target:?}: {e}"); + } } } } @@ -3029,3 +3042,22 @@ mod launcher_route_tests { ); } } + +#[cfg(test)] +mod url_open_target_tests { + use super::url_open_target; + + #[test] + fn bare_host_gets_https_scheme() { + assert_eq!(url_open_target("example.com"), "https://example.com"); + } + + #[test] + fn existing_scheme_is_left_untouched() { + assert_eq!(url_open_target("http://example.com"), "http://example.com"); + assert_eq!( + url_open_target("ftp://example.com/file"), + "ftp://example.com/file" + ); + } +} From fecac7671aac9afddbffdc42e4639c609b0fb423 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 20:59:36 +0800 Subject: [PATCH 63/85] launcher: stop the capsule opening itself at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GTK4 auto-assigns keyboard focus to the first can-focus widget in a window as it's first mapped, and launcher_entry was that widget with nothing to stop it — EventControllerFocus's connect_enter turned that spurious focus into an unconditional open_fn() call, so the capsule looked open (and "focused") the moment breadbar started, with no real click ever having reached it. Since keyboard = "on_demand" only grants the layer-shell surface real compositor keyboard focus in response to genuine interaction, this also explains why typing and Escape did nothing in that state: GTK believed the entry had focus, but the compositor never actually handed the surface a keyboard grab. launcher_entry now starts with can_focus(false), so nothing can land focus on it implicitly. The two places that legitimately want it focused now ask for it explicitly: a new GestureClick on the entry (a real, compositor-visible click — the same kind of interaction on-demand keyboard mode is meant to react to) and the OpenLauncher command handler, both flip can_focus back on immediately before calling grab_focus(). --- src/main.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/main.rs b/src/main.rs index 93bb86c..8295e7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -544,6 +544,20 @@ impl SimpleComponent for App { launcher_entry.add_css_class("launcher-entry"); launcher_entry.set_has_frame(false); launcher_entry.set_hexpand(true); + // Starts non-focusable ("the spotlight theme starts with the + // search open"). GTK4 auto-assigns keyboard focus to the first + // can-focus widget in a window as it's first mapped/shown — with + // nothing else focusable in the bar, that was always + // `launcher_entry`, and `focus_ctrl`'s `connect_enter` below + // (added unconditionally) turns "the entry gained focus" straight + // into `open_fn()`, so the capsule opened itself at startup. A + // widget that can't focus is skipped by BOTH that automatic + // selection and an explicit `grab_focus()` call — see GTK's own + // docs for `Widget::grab_focus`: "if widget is not focusable... + // this function does nothing." Every place that later wants real + // focus (the click gesture below, and `AppInput::OpenLauncher`'s + // handler) flips this back to `true` immediately before grabbing. + launcher_entry.set_can_focus(false); gtk4::prelude::EntryExt::set_alignment(&launcher_entry, 0.5); // `modules.clock.placeholder_clock` (spotlight): the entry's idle // placeholder IS the clock — no separate clock module renders at @@ -1167,6 +1181,34 @@ impl SimpleComponent for App { focus_ctrl.connect_enter(move |_| open_fn()); launcher_entry.add_controller(focus_ctrl); } + // "you can't close it using escape unless you are focused on the + // UI" / "it doesn't grab keyboard for typing": both are the same + // root cause as the startup-open bug above, from the other side. + // `launcher_entry.set_can_focus(false)` means nothing (startup or + // otherwise) can silently steal GTK's own notion of focus any + // more — so it now needs a real, explicit grab. This click gesture + // is that grab for the mouse path: a `GestureClick` press is a + // genuine user-originated pointer event delivered through the + // compositor, which is also exactly the kind of interaction + // `KeyboardMode::OnDemand` (gtk4-layer-shell/wlr-layer-shell) is + // documented to react to — the protocol spec (wlr-layer-shell- + // unstable-v1.xml) leaves *when* an on-demand surface gets the + // compositor's keyboard focus as "implementation-defined", but a + // literal click on the surface is the universal, minimum-common- + // denominator trigger every compositor implementation reacts to + // (it's the same interaction wofi/fuzzel/rofi-wayland rely on). + // Flipping `can_focus` back on right before `grab_focus()` mirrors + // `AppInput::OpenLauncher`'s handler below, which needs the exact + // same two-liner for the hotkey/command path. + { + let entry_for_click = launcher_entry.clone(); + let click = gtk4::GestureClick::new(); + click.connect_pressed(move |_, _, _, _| { + entry_for_click.set_can_focus(true); + entry_for_click.grab_focus(); + }); + launcher_entry.add_controller(click); + } { let results = launcher_results.clone(); let mode_list = mode_list.clone(); @@ -1727,6 +1769,12 @@ impl SimpleComponent for App { } } LauncherRoute::Local => { + // See `launcher_entry.set_can_focus(false)`'s own + // comment above: a hotkey/command-triggered open + // has no pointer click to flip this back on, so + // this path has to do it itself or `grab_focus()` + // below is a silent no-op. + self.launcher_entry.set_can_focus(true); self.launcher_entry.grab_focus(); } } From 55d0e399bd1b6738fe94ac49c55452695ede45a6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 21:05:39 +0800 Subject: [PATCH 64/85] panel: scope the capsule's click-away dead zone to its own column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "it only sometimes is dismissed when you click somewhere else": the dismiss scrim's clickable region started at a fixed offset below the screen top (the capsule row plus the drawer's max possible height, kept generous on purpose so an animating drawer never has its result rows swallowed) but via a plain layer-shell margin, which pushes the scrim's *entire width* down by that much — not just the strip under the capsule. That left a full-screen-wide dead band above it (near 470px tall on a 1200px-tall display) where a click neither dismissed the capsule nor hit anything else, since breadbar-dismiss sits on the overlay layer above the bar's own top layer and nothing else was there to catch it either. show_capsule_dismiss now also takes an optional (x, width) hole, computed from the capsule's own real on-screen column — queried live from `hyprctl layers -j` (ground truth; the layer-shell protocol never hands a client its own assigned position back) rather than assumed — and punches exactly that column out of the scrim's input region via a custom cairo Region, leaving the same vertical safety margin in place but no longer swallowing clicks beside the capsule. Falls back to the old full-width margin if the geometry query fails for any reason. Added capsule_dismiss_hole (pure coordinate math, unit tested) and hypr_capsule_center_x (the live query) to main.rs. --- src/main.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++- src/panel.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 216 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8295e7c..f0cf010 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1060,6 +1060,7 @@ impl SimpleComponent for App { let panels = panels.clone(); let idle_width = launcher_cfg.width; let search_width = launcher_cfg.search_width; + let monitor_name_for_dismiss = monitor_name.clone(); move || { if !launcher_open.get() { launcher_open.set(true); @@ -1081,8 +1082,20 @@ impl SimpleComponent for App { // offset (`capsule_dismiss_margin`), never the live // drawer height — see that constant's own doc comment // for why a live-tracking offset would risk swallowing - // clicks meant for a result row. - panels.show_capsule_dismiss(capsule_dismiss_margin); + // clicks meant for a result row. `hole` scopes that + // dead zone to the capsule's own column instead of the + // full screen width ("it only sometimes is dismissed + // when you click somewhere else") — see + // `capsule_dismiss_hole`'s doc comment. Falls back to + // the old full-width dead zone if the live geometry + // query fails for any reason. + let hole = hypr_capsule_center_x(&monitor_name_for_dismiss).and_then( + |center_x| { + let (origin_x, _) = hypr_monitor_origin(&monitor_name_for_dismiss)?; + Some(capsule_dismiss_hole(center_x, origin_x, search_width)) + }, + ); + panels.show_capsule_dismiss(capsule_dismiss_margin, hole); } let target = drawer_target_height(&drawer_box); let current = drawer_box.size_request().1; @@ -2975,6 +2988,64 @@ fn hypr_monitor_origin(name: &str) -> Option<(i32, i32)> { .map(|m| (m.x, m.y)) } +/// The bar/capsule's own layer-surface geometry for `monitor`, straight +/// from the compositor (`hyprctl layers -j`, ground truth — not derived +/// from anything GTK/gtk4-layer-shell reports client-side, since the +/// wlr-layer-shell protocol never hands a client its own assigned x/y back; +/// only width/height come through `configure`). Matched by `namespace` +/// ("breadbar", set via `root.set_namespace` above), which is unique per +/// output since each monitor gets its own bound `App` instance/window. +/// Returns the surface's horizontal center in Hyprland's global coordinate +/// space. `None` on any parse/lookup failure — callers must fall back to +/// the pre-existing, safe-but-broader dead-zone behaviour rather than +/// guess. +fn hypr_capsule_center_x(monitor: &str) -> Option { + let output = std::process::Command::new("hyprctl") + .args(["layers", "-j"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let root: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + let levels = root.get(monitor)?.get("levels")?.as_object()?; + for arr in levels.values() { + let Some(items) = arr.as_array() else { + continue; + }; + for item in items { + if item.get("namespace").and_then(|v| v.as_str()) != Some("breadbar") { + continue; + } + let x = item.get("x")?.as_i64()? as i32; + let w = item.get("w")?.as_i64()? as i32; + return Some(x + w / 2); + } + } + None +} + +/// The click-away scrim's capsule-column hole, in coordinates local to the +/// `breadbar-dismiss` surface (see `PanelSet::show_capsule_dismiss`) — +/// pure arithmetic, split out for unit testing. `capsule_center_global` and +/// `monitor_origin_x` are both in Hyprland's global compositor space +/// (`hypr_capsule_center_x`/`hypr_monitor_origin`); `column_width` is the +/// capsule's own *configured* search-state width +/// (`[launcher].search_width`), not a live-queried one — this fires right +/// as `open_fn` starts the width-animation from idle to search width, so a +/// live query at that exact instant would catch it mid-transition. Using +/// the wider, settled target here (like `DRAWER_MAX_HEIGHT_PX` already does +/// for the vertical bound) means the hole is never narrower than the +/// capsule ever actually gets while the scrim is showing. +fn capsule_dismiss_hole( + capsule_center_global: i32, + monitor_origin_x: i32, + column_width: i32, +) -> (i32, i32) { + let local_center = capsule_center_global - monitor_origin_x; + (local_center - column_width / 2, column_width) +} + /// Hyprland connector names and GDK connector names can disagree after a /// hotplug (`DVI-I-1` vs `DVI-I-2`). Match the connector first, then the /// output's origin — transform swaps width/height so size is not reliable. @@ -3091,6 +3162,37 @@ mod launcher_route_tests { } } +#[cfg(test)] +mod capsule_dismiss_hole_tests { + use super::capsule_dismiss_hole; + + #[test] + fn centered_capsule_on_primary_monitor_at_origin() { + // A 520px-wide capsule centered on a 1920px-wide monitor at global + // origin (0,0): global center x = 960, monitor origin x = 0. + let (x, w) = capsule_dismiss_hole(960, 0, 520); + assert_eq!((x, w), (960 - 260, 520)); + } + + #[test] + fn negative_origin_secondary_monitor_converts_to_local() { + // This machine's own DVI-I-1 (`hyprctl layers -j`, quoted in this + // module's doc comments): monitor origin x = -1080. A capsule + // centered on that output's own 1080px-wide span sits at global + // center x = -1080 + 540 = -540. + let (x, w) = capsule_dismiss_hole(-540, -1080, 520); + // Local center is 540 (origin subtracted back out); hole starts + // 260px to either side of it, independent of the monitor's sign. + assert_eq!((x, w), (540 - 260, 520)); + } + + #[test] + fn hole_width_always_matches_requested_column_width() { + let (_, w) = capsule_dismiss_hole(100, 0, 480); + assert_eq!(w, 480); + } +} + #[cfg(test)] mod url_open_target_tests { use super::url_open_target; diff --git a/src/panel.rs b/src/panel.rs index b9982ed..cca16c5 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -5,7 +5,7 @@ //! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from //! the right. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use gtk4::gdk::Key; @@ -14,10 +14,26 @@ use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; use crate::{bind_layer_monitor, theme}; +/// Outside this rectangle's local x/y span, the dismiss window's own real +/// size (Wayland clips an input region to the surface's actual bounds, same +/// as `surface::click_through`'s empty-region trick) — big enough to cover +/// any realistic monitor layout, including a negative-origin secondary +/// output (`hyprctl layers -j` reported `x: -1080` for this machine's own +/// DVI-I-1). Centered on the origin so it's safe regardless of which way a +/// hole's coordinates end up signed. +const HOLE_CANVAS_SPAN: i32 = 20_000; + /// A boxed, ref-counted, optionally-unset click-away callback — see /// `PanelSet::on_dismiss`'s own doc comment. type DismissCallback = Rc>>>; +/// The capsule-column hole punched in the dismiss scrim's input region — +/// local x, y, width, height — see `PanelSet::dismiss_hole`'s own doc +/// comment. Shared (not just passed by value) so `make_dismiss`'s +/// `connect_map` hook and every `show_capsule_dismiss`/`reset_dismiss_margin` +/// call agree on the current value. +type DismissHole = Rc>>; + #[derive(Clone)] pub struct PanelSet { pub connectivity: gtk4::Window, @@ -30,6 +46,16 @@ pub struct PanelSet { // capsule's drawer — see `show_capsule_dismiss`/`hide_dismiss` and // `set_on_dismiss`. `None` under every other theme (never set). on_dismiss: DismissCallback, + // The rectangle (local to `dismiss`'s own surface coordinates) that + // should stay click-through even while the scrim otherwise covers the + // screen — `show_capsule_dismiss`'s own doc comment explains why this + // exists and how it's computed. `None` = no hole, the plain + // margin-based popover behaviour applies instead. Read inside + // `dismiss`'s own `connect_map` (the input region can only be set once + // the surface is real — see `surface::click_through`'s doc comment for + // the same constraint) and, for the case where `dismiss` is already + // mapped from a prior show, applied immediately too. + dismiss_hole: DismissHole, } impl PanelSet { @@ -42,7 +68,8 @@ impl PanelSet { let connectivity = make_panel("wifi-popover", connectivity_child, monitor); let control = make_panel("control-panel", control_child, monitor); let media = make_panel("media-popover", media_child, monitor); - let dismiss = make_dismiss(monitor); + let dismiss_hole: DismissHole = Rc::new(Cell::new(None)); + let dismiss = make_dismiss(monitor, &dismiss_hole); let set = Self { connectivity, @@ -50,6 +77,7 @@ impl PanelSet { media, dismiss, on_dismiss: Rc::new(RefCell::new(None)), + dismiss_hole, }; set.wire_dismiss(); set.wire_escape(); @@ -106,8 +134,31 @@ impl PanelSet { /// (`top`), so if its clickable region ever reached up into where the /// drawer is actually drawn, it would swallow clicks meant for a /// result row instead of forwarding them. - pub fn show_capsule_dismiss(&self, top_margin: i32) { - self.dismiss.set_margin(Edge::Top, top_margin); + /// + /// Before this fix, that safety was bought with a `set_margin` that + /// pushed the scrim's *entire width* down by `top_margin` — leaving a + /// full-screen-wide dead band above it (up to ~470px on a 1200px-tall + /// display) where a click neither dismissed nor hit anything else, the + /// "it only sometimes is dismissed when you click somewhere else" + /// report. `hole`, when known (local-to-this-surface x-start/width, in + /// `main.rs`'s `capsule_dismiss_hole`), keeps exactly the same + /// vertical safety margin but scopes the dead band to the capsule's + /// own column instead of the full width, so everywhere else in that + /// band is dismiss-clickable too. `None` (geometry unavailable, e.g. + /// `hyprctl` failed) falls back to the old full-width behaviour rather + /// than risk a hole in the wrong place. + pub fn show_capsule_dismiss(&self, top_margin: i32, hole: Option<(i32, i32)>) { + match hole { + Some((x, w)) if w > 0 => { + self.dismiss.set_margin(Edge::Top, 0); + self.dismiss_hole.set(Some((x, 0, w, top_margin))); + } + _ => { + self.dismiss.set_margin(Edge::Top, top_margin); + self.dismiss_hole.set(None); + } + } + apply_dismiss_hole(&self.dismiss, &self.dismiss_hole); self.dismiss.set_visible(true); self.dismiss.present(); } @@ -126,6 +177,10 @@ impl PanelSet { let top = surf.offset.first().copied().unwrap_or(0.0) as i32; self.dismiss.set_margin(Edge::Top, top); } + // A stale capsule-shaped hole must not leak into a popover's own + // full-width dead zone. + self.dismiss_hole.set(None); + apply_dismiss_hole(&self.dismiss, &self.dismiss_hole); } fn wire_dismiss(&self) { @@ -180,7 +235,7 @@ fn make_panel(class: &str, child: &impl IsA, monitor: &str) -> gtk window } -fn make_dismiss(monitor: &str) -> gtk4::Window { +fn make_dismiss(monitor: &str, hole: &DismissHole) -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-dismiss"); window.init_layer_shell(); @@ -206,5 +261,57 @@ fn make_dismiss(monitor: &str) -> gtk4::Window { bind_layer_monitor(&window, monitor); theme::bind_output(&window, monitor); window.set_visible(false); + // The underlying `GdkSurface` (and therefore `window.surface()`, which + // `apply_dismiss_hole` needs) doesn't exist until the window is mapped + // — same constraint `surface::click_through` documents. This surface + // gets hidden/shown repeatedly (every popover open/close, every + // capsule search), and GTK4 unmaps-then-remaps a toplevel each time + // its visibility toggles off then on, so re-applying here on every + // `map` (not just the first) is what keeps a freshly (re)shown surface + // honouring whatever hole was set before this particular `present()`. + { + let hole = Rc::clone(hole); + window.connect_map(move |win| apply_dismiss_hole(win, &hole)); + } window } + +/// Sets `dismiss`'s click-away input region to "everywhere" minus `hole` +/// (if any) — see `PanelSet::show_capsule_dismiss`'s doc comment for why. +/// Only takes effect once `dismiss.surface()` is real, i.e. the window is +/// currently mapped; harmlessly no-ops otherwise (the `connect_map` hook in +/// `make_dismiss` re-runs this the moment that stops being true). +fn apply_dismiss_hole(dismiss: >k4::Window, hole: &DismissHole) { + let Some(surface) = dismiss.surface() else { + return; + }; + match hole.get() { + Some((x, y, w, h)) => { + let canvas = gtk4::cairo::RectangleInt::new( + -HOLE_CANVAS_SPAN, + -HOLE_CANVAS_SPAN, + HOLE_CANVAS_SPAN * 2, + HOLE_CANVAS_SPAN * 2, + ); + let region = gtk4::cairo::Region::create_rectangle(&canvas); + let punch = gtk4::cairo::RectangleInt::new(x, y, w, h); + if region.subtract_rectangle(&punch).is_ok() { + surface.set_input_region(Some(®ion)); + } else { + // Punching the hole failed for some reason (an invalid + // cairo status on a plain rectangle op, effectively + // unreachable in practice) — falling back to `None` (the + // protocol's documented "no input region set: whole + // surface hits") is still safer than leaving whatever + // region predates this call in place, which could be + // stale from a completely different mode (e.g. an old + // popover-shaped margin-only region with no hole at all). + eprintln!( + "breadbar: could not punch capsule hole in dismiss scrim's input region" + ); + surface.set_input_region(None); + } + } + None => surface.set_input_region(None), + } +} From 0a0bd5f431c35b95f8e36f1af75921c86047f273 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 21:05:52 +0800 Subject: [PATCH 65/85] wifi chip: fix off-center icon and drop dead CSS rule wifi_img.set_hexpand(false) meant the icon packed at the start of the 32px icon-only chip with no leftover space for halign:Center to work with, sitting visibly left of center on glass-workbench/liquid-motion. hexpand(true) fixes the centering, but that alone silently bubbles the expand flag up through connectivity_pair into the shared right-hand stats box and the centerbox's end slot, blowing the wifi/vol cluster's layout apart (confirmed via a --screenshot bar capture: the whole cluster jumped left against the clock with a huge gap before battery/hamburger). connectivity_pair.set_hexpand(false) pins the box's own expand explicitly so the fix stays contained to this chip. Also drops the dead .wifi-pair { padding: 6px } rule: it can never apply since .stat-pair.icon-only's two-class selector always beats its one-class specificity regardless of source order, so the intended padding never actually rendered. --- src/main.rs | 31 +++++++++++++++++++++++++++++-- src/theme.rs | 1 - 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index f0cf010..3a8618f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -718,11 +718,38 @@ impl SimpleComponent for App { let connectivity_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); connectivity_pair.add_css_class("stat-pair"); - connectivity_pair.add_css_class("wifi-pair"); connectivity_pair.add_css_class("icon-only"); bar_chip(&connectivity_pair); wifi_img.set_halign(gtk4::Align::Center); - wifi_img.set_hexpand(false); + // `.stat-pair.icon-only` forces a 32px min-width on this box, wider + // than the 24px icon's natural size. Without hexpand, a `gtk4::Box` + // packs a non-expanding child at its natural size flush against the + // start edge and leaves the leftover width trailing after it — so + // `halign: Center` had nothing to center within and the glyph sat a + // few pixels left of true center (reported: wifi icon not centered + // on glass-workbench/liquid-motion). `bat_box` never showed this + // because it isn't `icon-only` — no forced min-width wider than its + // (icon + label) content, so there's no leftover space to + // mis-place. hexpand(true) gives the icon a fillable cell spanning + // the full 32px box, which `halign: Center` then centers within, + // matching `bat_box`'s already-centered result. + // + // A bare `set_hexpand(true)` on the image is not enough on its + // own: GTK4 computes a container's *effective* expand by OR-ing in + // its children's hexpand whenever the container's own hexpand + // hasn't been explicitly set, so the flag silently bubbles up + // through `connectivity_pair` into the shared right-hand stats box + // and from there into the centerbox's end slot — which then hands + // that slot most of the bar's remaining width instead of its + // normal packed size. The visible symptom was dramatic, not + // subtle: the whole vol/wifi cluster jumped left to sit against + // the clock, with a huge dead gap before battery/hamburger, in a + // `--screenshot bar` capture. `connectivity_pair.set_hexpand(false)` + // pins this box's own expand explicitly, which stops the + // computation from climbing any further — the child can still + // fill and center within this one box's fixed 32px cell. + wifi_img.set_hexpand(true); + connectivity_pair.set_hexpand(false); connectivity_pair.append(&wifi_img); // `connectivity_pair` and `bat_box` are appended in "Assemble" // below, per `[bar.slots].right`. diff --git a/src/theme.rs b/src/theme.rs index dacba3c..d220180 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -249,7 +249,6 @@ fn load_css() -> String { border-radius: 3px; min-height: 6px; }}\ progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\ border-radius: 3px; min-height: 6px; }}\ - .wifi-pair {{ padding: 6px; }}\ window.breadbar-panel {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ border-radius: 14px; border: 1px solid alpha(@on-bg, 0.12); }}\ window.breadbar-dismiss, .breadbar-dismiss-hit {{\ From 1c028b59befd3f26aa1e84751765e86356178412 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 21:13:41 +0800 Subject: [PATCH 66/85] stat-pair chips: token-driven radius, drop icon-only circular override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .stat-pair's border-radius was a hardcoded 10px shared identically by all three themes: coincidentally close for liquid-motion (radius_sm 9px, matching its demo's .chip radius almost exactly), flatly wrong for glass-workbench (demo's .chip is 6px, exactly this theme's own radius_sm), and disconnected from spotlight's much rounder capsule language. Chips now round by chip_radius: radius_sm for liquid-motion/ glass-workbench, radius_pill for spotlight (its bar/dots are already that round, so its lone .stat-pair occupant, the battery chip, now reads as part of that same family instead of a stray rounded rect inside a much-rounder capsule). Also drops .stat-pair.icon-only's own 999px radius override. That made wifi and the liquid-motion hamburger — the only two icon-only chips — fully circular while their row neighbours (vol, battery) stayed a rounded rect at .stat-pair's radius: a visible mismatch inside one row, called out against the hamburger specifically. Every demo's .chip class draws vol/wifi/battery/menu identically and none of them circular, so dropping the override just lets the shared radius cascade through unchanged. Verified via bread-capture's isolated headless-Sway harness with a temporary (not committed) debug outline on .stat-pair: liquid-motion's four right-side chips now share one visible corner radius instead of two circular + two rounded-rect, and spotlight's battery chip reads as a rounded pill matching its capsule/dots instead of a sharper rect. --- src/theme.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/theme.rs b/src/theme.rs index d220180..8dba26c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -100,6 +100,24 @@ fn load_css() -> String { }; let centerbox_padding = if flush { "0 12px" } else { "0 8px 0 6px" }; + // Radius for `.stat-pair` (vol/wifi/battery/hamburger chips): radius_sm + // for liquid-motion (9px) and glass-workbench (6px, exact match to that + // demo's `.chip` radius) reads as "the same small-control rounding this + // theme uses everywhere else" — but spotlight's overall language is + // dramatically rounder (radius_bar 22px, workspace dots at radius_pill + // 999px) than either sibling theme, so its one `.stat-pair` occupant + // (battery — the only slot entry besides a Lua widget under + // `[bar.slots].right`) looked like a stray sharp-cornered rectangle + // dropped inside a capsule and next to fully-round dots (reported: + // "the spotlight battery chip... radii that don't match their + // neighbours"). Keying off the same `WorkspaceStyle` enum + // `workspace_css` below already switches on, rather than the theme id, + // so this stays in step if a future theme ever reuses the "dots" style. + let chip_radius = match theme.modules().workspaces.style { + bread_theme::shell::WorkspaceStyle::Dots => radius_pill.clone(), + _ => radius_sm.clone(), + }; + // `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 @@ -205,12 +223,38 @@ fn load_css() -> String { .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; }}\ - .stat-pair {{ margin: 0; border-radius: 10px; padding: 5px 9px; min-height: 0;\ + /* Radius was a hardcoded 10px here regardless of theme — right by\ + coincidence for liquid-motion's demo (`.chip {{ border-radius:\ + 10px }}`, this theme's radius_sm is 9px, a 1px rounding-off),\ + wrong for glass-workbench (demo's `.chip` is 6px, exactly this\ + theme's radius_sm — the hardcoded 10px never matched it), and\ + wrong-in-spirit for spotlight even though no `.chip` class\ + exists in that demo to compare against: a small, sharp-ish\ + radius reads as a stray rectangle inside a 22px-radius capsule\ + sitting right next to 999px-radius workspace dots (reported:\ + spotlight's battery chip not matching its neighbours). See\ + `chip_radius` above — radius_sm for the other two themes,\ + radius_pill for spotlight, so every theme's stat chips round\ + the way that theme's *other* rounded chrome already does,\ + instead of all three sharing one borrowed hardcoded number. */\ + .stat-pair {{ margin: 0; border-radius: {chip_radius}; padding: 5px 9px; min-height: 0;\ 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); }}\ - .stat-pair.icon-only {{ padding: 4px; border-radius: 999px;\ + /* No border-radius override here (was a hardcoded 999px, making\ + wifi/hamburger — the only two `.icon-only` chips — fully\ + circular while their row neighbours vol/battery stayed a\ + rounded rect at `.stat-pair`'s own radius: a visible rounding\ + mismatch inside one row, reported against the liquid-motion\ + hamburger specifically). Every demo's `.chip` class (liquid-\ + motion, glass-workbench) draws vol/wifi/bat/menu identically,\ + none of them circular — dropping the override here just lets\ + `.stat-pair`'s own `chip_radius` cascade through unchanged, so\ + the icon-only chips match their siblings instead of standing\ + out (spotlight has no icon-only chip today, but would get the\ + same pill radius as its one `.stat-pair` sibling if it ever did). */\ + .stat-pair.icon-only {{ padding: 4px;\ min-width: 32px; min-height: 32px; }}\ .stat-icon {{ margin-right: 6px; }}\ .stat-pair.icon-only .stat-icon {{ margin: 0; }}\ @@ -447,6 +491,7 @@ fn load_css() -> String { radius_bar = radius_bar, radius_sm = radius_sm, radius_pill = radius_pill, + chip_radius = chip_radius, pad = pad, spring = spring, spring_settle = spring_settle, From 583d73c53cb78812b37458454120d4f37206f43d Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 21:14:57 +0800 Subject: [PATCH 67/85] liquid-motion workspace pills: match demo radius and height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .workspace-trail/.workspace-btn hardcoded border-radius: 12px and min-height: 28px — neither matches this theme's own radius_sm token (9px) nor the 01-liquid-motion.html demo's .ws-btn/.trail rules (26px tall, 9px radius). radius_sm happens to be an exact match for the demo's 9px here. Reported: "the pills on liquid motion just look off". Not visually verified via capture — the isolated headless-Sway harness has no Hyprland IPC, so workspace buttons render empty there regardless of theme (pre-existing harness limit, unrelated to this change). Verified by reading the CSS against 01-liquid-motion.html's