From e4c12e9b62cc570d410626abc24c38ac672b74ba Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:04:05 +0800 Subject: [PATCH] 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;\