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