diff --git a/src/ui/app.rs b/src/ui/app.rs index b69c30e4..3b8372cb 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -825,9 +825,16 @@ impl Tty7App { self.activate(ix, window, cx); // The reveal must actually show the pane: a sibling leaf // maximized in this tab would otherwise keep the target - // off-screen while we hand it keyboard focus (every other - // focus-moving path clears this too). - self.maximized = None; + // off-screen while we hand it keyboard focus. The target + // itself staying maximized is fine — it's already the + // visible one. + if self + .maximized + .as_ref() + .is_some_and(|m| m.entity_id().as_u64() != leaf_id) + { + self.maximized = None; + } if let Some(leaf) = self.tabs[ix] .pane .leaves() @@ -886,7 +893,7 @@ impl Tty7App { cx, ); cx.spawn(async move |_this, cx| { - // Index 1 == "Shut Down"; Cancel or a dismissed prompt do nothing. + // Index 1 == "Quit and Stop"; Cancel or a dismissed prompt do nothing. if !matches!(answer.await, Ok(1)) { return; } diff --git a/src/ui/tray/icon.rs b/src/ui/tray/icon.rs index a450624b..1e9b924b 100644 --- a/src/ui/tray/icon.rs +++ b/src/ui/tray/icon.rs @@ -45,12 +45,7 @@ const AMBER: (u8, u8, u8) = (0xF5, 0x9E, 0x0B); pub(super) fn render(attention: bool) -> Option { let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?; let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?; - let scale = SIZE as f32 / tree.size().width().max(tree.size().height()); - resvg::render( - &tree, - tiny_skia::Transform::from_scale(scale, scale), - &mut pixmap.as_mut(), - ); + resvg::render(&tree, fit_center(&tree, SIZE), &mut pixmap.as_mut()); if attention { // macOS attention leaves template mode (color needs real RGB), so the @@ -114,12 +109,7 @@ pub(super) fn agent_avatar( let tree = usvg::Tree::from_data(&svg, &usvg::Options::default()).ok()?; let glyph_size = (s * 0.60).round() as u32; let mut glyph = tiny_skia::Pixmap::new(glyph_size, glyph_size)?; - let scale = glyph_size as f32 / tree.size().width().max(tree.size().height()); - resvg::render( - &tree, - tiny_skia::Transform::from_scale(scale, scale), - &mut glyph.as_mut(), - ); + resvg::render(&tree, fit_center(&tree, glyph_size), &mut glyph.as_mut()); recolor(&mut glyph, (0xFF, 0xFF, 0xFF)); let offset = ((SIZE - glyph_size) / 2) as i32; pixmap.draw_pixmap( @@ -166,6 +156,19 @@ pub(super) fn agent_avatar( Some(pixmap) } +/// Scale-to-fit + center transform for rendering an SVG into a square +/// `size`×`size` bitmap — a non-square SVG would otherwise hug the top-left +/// corner. (Both bundled icons are square today; this keeps that an +/// aesthetic fact, not a correctness assumption.) +fn fit_center(tree: &usvg::Tree, size: u32) -> tiny_skia::Transform { + let (w, h) = (tree.size().width(), tree.size().height()); + let scale = size as f32 / w.max(h); + tiny_skia::Transform::from_scale(scale, scale).post_translate( + (size as f32 - w * scale) / 2.0, + (size as f32 - h * scale) / 2.0, + ) +} + /// Un-premultiply a tiny-skia pixmap into straight RGBA (what /// `tray_icon::Icon`/`muda::Icon` want). pub(super) fn to_rgba(pixmap: &tiny_skia::Pixmap) -> RgbaImage { diff --git a/src/ui/tray/native.rs b/src/ui/tray/native.rs index 71abff8f..5e818250 100644 --- a/src/ui/tray/native.rs +++ b/src/ui/tray/native.rs @@ -22,8 +22,8 @@ pub(super) struct Backend { impl Backend { /// Build the status item with the calm icon and an initial (empty-state) /// menu; the first `update` follows immediately. `None` (creation - /// failure) is terminal for this enable-cycle — see `gave_up` in the - /// poll loop. + /// failure) is retried by the poll loop on a slow backoff (see + /// `mod.rs`). pub(super) async fn create( tx: smol::channel::Sender, _cx: &AsyncApp, diff --git a/src/ui/tray/sni.rs b/src/ui/tray/sni.rs index 61302aa0..17611cb9 100644 --- a/src/ui/tray/sni.rs +++ b/src/ui/tray/sni.rs @@ -2,60 +2,80 @@ //! //! ksni owns a service thread and re-queries the [`ksni::Tray`] impl for //! icon/menu/status whenever we call `Handle::update`, so the backend just -//! swaps the stored snapshot in. Menu item activation runs on ksni's thread; -//! actions cross back to gpui over the same channel the other platforms use. +//! swaps the stored snapshot in. That call is a blocking round-trip to the +//! service thread, so updates flow through a background task rather than the +//! foreground poll loop. Menu item activation runs on ksni's thread; actions +//! cross back to gpui over the same channel the other platforms use. //! //! On desktops without an SNI host (bare GNOME without the AppIndicator //! extension) the spawn fails; the poll loop logs once and the app runs //! without a tray. use super::{SpecItem, TrayAction, TraySnapshot, action_from_id, icon}; -use gpui::AsyncApp; +use gpui::{AppContext as _, AsyncApp}; pub(super) struct Backend { - handle: ksni::blocking::Handle, + /// Feeds the updater task spawned in [`Backend::create`]. Dropping the + /// Backend closes the channel, which makes that task shut the SNI + /// service down — removing the icon. + updates: smol::channel::Sender, } impl Backend { - /// Spawn the SNI service. Registration is a DBus round-trip, so it runs - /// on the background executor rather than stalling the foreground poll - /// loop; the returned handle is `Send` and lives with the poll task. + /// Spawn the SNI service plus its updater task. Registration and every + /// later `Handle::update` are blocking round-trips to ksni's service + /// thread, so both live on the background executor rather than stalling + /// the foreground poll loop. pub(super) async fn create( tx: smol::channel::Sender, cx: &AsyncApp, ) -> Option { - cx.background_spawn(async move { - use ksni::blocking::TrayMethods as _; - let tray = SniTray { - tx, - snap: TraySnapshot::default(), - }; - match tray.spawn() { - Ok(handle) => Some(Backend { handle }), - Err(e) => { - log::warn!("failed to register StatusNotifierItem: {e}"); - None + let handle = cx + .background_spawn(async move { + use ksni::blocking::TrayMethods as _; + let tray = SniTray { + tx, + snap: TraySnapshot::default(), + }; + match tray.spawn() { + Ok(handle) => Some(handle), + Err(e) => { + log::warn!("failed to register StatusNotifierItem: {e}"); + None + } } + }) + .await?; + let (updates, update_rx) = smol::channel::unbounded::(); + cx.background_spawn(async move { + while let Ok(mut snap) = update_rx.recv().await { + // Coalesce a queued burst down to the newest snapshot — + // intermediate states would each cost a DBus push. + while let Ok(later) = update_rx.try_recv() { + snap = later; + } + // `update` re-reads menu/icon/status from the Tray impl and + // pushes the changed properties over DBus. `None` (service + // gone) can only follow the `shutdown` below; a vanished SNI + // *host* is ksni's problem — it re-registers by itself when + // a watcher returns to the bus. + handle.update(move |tray| tray.snap = snap); } + // Channel closed: the Backend was dropped (tray toggled off or + // app exit). Dropping the handle alone would leave the service + // thread (and the icon) alive; ask it to unregister. The awaiter + // is intentionally not waited on — teardown can finish on ksni's + // thread. + handle.shutdown(); }) - .await + .detach(); + Some(Backend { updates }) } pub(super) fn update(&mut self, snap: &TraySnapshot) { - let snap = snap.clone(); - // `update` re-reads menu/icon/status from the Tray impl and pushes - // the changed properties over DBus. Returns None once the service is - // gone (host died) — nothing to do about it here. - self.handle.update(move |tray| tray.snap = snap); - } -} - -impl Drop for Backend { - fn drop(&mut self) { - // Dropping the handle alone leaves the service thread (and the icon) - // alive; ask it to unregister. The awaiter is intentionally not - // waited on — teardown can finish on ksni's thread. - self.handle.shutdown(); + // Unbounded channel: the only send failure is "closed", impossible + // while the Backend (whose drop is what closes it) is alive. + let _ = self.updates.try_send(snap.clone()); } }