mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
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:
+11
-4
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user