fix(tray): review fixes — Linux build, non-blocking SNI updates

- sni: import gpui::AppContext (Linux build broke on background_spawn)
- sni: push updates through a background updater task instead of blocking
  the foreground poll loop on a DBus round-trip; bursts coalesce to the
  newest snapshot, and channel close (Backend drop) shuts the service down
- reveal: keep the target pane maximized if it already is; only a
  maximized sibling gets cleared
- icon: center non-square SVGs when rasterizing (was top-left aligned)
- stale comments: 'gave_up' poll-loop reference, prompt button name
This commit is contained in:
l0ng-ai
2026-07-16 14:29:21 +08:00
parent a4683399dd
commit 780be2cedc
4 changed files with 81 additions and 51 deletions
+11 -4
View File
@@ -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;
}
+15 -12
View File
@@ -45,12 +45,7 @@ const AMBER: (u8, u8, u8) = (0xF5, 0x9E, 0x0B);
pub(super) fn render(attention: bool) -> Option<RgbaImage> {
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 {
+2 -2
View File
@@ -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<TrayAction>,
_cx: &AsyncApp,
+53 -33
View File
@@ -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<SniTray>,
/// 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<TraySnapshot>,
}
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<TrayAction>,
cx: &AsyncApp,
) -> Option<Self> {
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::<TraySnapshot>();
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());
}
}