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::{Monitors, Workspaces}, event_listener::{Event, EventStream}, prelude::*, shared::WorkspaceId, }; use relm4::ComponentSender; use crate::AppInput; /// 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) { 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) { relm4::spawn(async move { sync_state(&sender).await; // 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(_) | Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { sync_state(&sender).await; } 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); } _ => {} } } 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; } }); } 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_halign(gtk4::Align::Center); btn.set_vexpand(false); btn.set_hexpand(false); // `crate::theme::approved_chip_height`, not `tokens().chip_height()`: // the latter is the stale pre-demo `breadbar::CHIP_HEIGHT` token (32 // for this Trail/Pill style's theme) and, as a hard `set_size_request` // minimum, would out-rank the CSS `min-height` the demo actually wants // (26px Trail / 22px Pill) — see that function's doc comment. let style = crate::theme::shell_theme().modules().workspaces.style; btn.set_size_request(-1, crate::theme::approved_chip_height(style) as i32); 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; }); }); btn } /// `style = "dots"` (theme 04/spotlight): a label-less pill whose WIDTH /// encodes `windows` (0/1/2/3-or-more open). 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. /// /// `_dot_widths` (the manifest's `modules.workspaces.dot_widths`) is /// accepted but deliberately unused — see `APPROVED_DOT_WIDTHS` below, /// which overrides it with the approved Option B numbers the manifest's /// own value predates. Kept in the signature rather than dropped so the /// call site still documents where a real per-theme width would flow from /// once `theme.toml` catches up. 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); // Height is a deliberate departure from `04-spotlight.html`'s own 6px // (see the demo's `.dots button { height: 6px }`): reported as "too // small and hard to click", and also genuinely hard to *see* on a real // display, not just hard to hit. Option B (approved): 10px tall — up // from an earlier 9px pass that undershot the approved number by 1px. // Keeps the dots reading as slim pills rather than growing into little // chips (which would fight the capsule's minimal, text-first look), // while being clearly perceptible against the 36px-tall bar. const DOT_HEIGHT: i32 = 10; // Option B widths (approved): 8/13/17/22px for 0/1/2/3-or-more open // windows — `[8, 13, 17, 22]`, not the `dot_widths` parameter's own // manifest value. `theme.toml`'s `modules.workspaces.dot_widths = // [6, 10, 14, 18]` predates this pass and was never updated to match; // hardcoded here (ignoring the passed-in `dot_widths`) rather than // edited upstream, since bread-ecosystem is a sibling agent's repo // this pass. Flagged in the task report — `dot_widths` should become // `[8, 13, 17, 22]` in `assets/shell/spotlight/theme.toml`. const APPROVED_DOT_WIDTHS: bread_theme::shell::DotWidths = [8, 13, 17, 22]; btn.set_size_request( APPROVED_DOT_WIDTHS[dot_width_index(windows)], DOT_HEIGHT, ); btn.connect_clicked(move |_| { relm4::spawn(async move { switch_workspace(id).await; }); }); 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, 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, 1); 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.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(); 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) { 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 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, } }; 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 } }); self.inner.borrow_mut().tick = Some(id); } fn from_geom(&self, from: Option<>k4::Button>) -> Option { let st = self.inner.borrow(); // 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.host).map(inset_pill)) } } /// 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; 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, } } 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, } } None => *from, } } /// 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 < 8.0 || h < 8.0 || w > MAX_CHIP_W { 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, }; let w = g.w.max(1.0).round() as i32; let h = g.h.max(1.0).round() as i32; // Clearing first lets GTK shrink; size-request is a minimum. pill.set_size_request(-1, -1); pill.set_size_request(w, h); host.move_(pill, g.x, g.y); pill.set_visible(true); } fn still_placeholder(btn: >k4::Button, g: &Geom) -> 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 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) }