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; }}\