feat(gui): hand-rolled winit loop for true window-hide on Wayland

Replace eframe::run_native with a winit ApplicationHandler + glutin +
egui_glow loop so "keep running in the tray" can genuinely hide the
window. winit's set_visible(false) is a deliberate no-op on Wayland
(xdg-shell has no unmap-but-keep-alive request), so the only way to hide
a toplevel is to destroy its surface: hide-to-tray now drops the Window +
GL surface (parking the GL context as not-current) and a tray click
recreates them and makes the context current again. The GL context,
glutin display/config, egui_glow painter (uploaded textures), and
egui-winit state (clipboard) all persist across the cycle — only the OS
window and its surface churn.

Wakeups route through winit's EventLoopProxy (the new Waker, and the
tray) instead of egui's repaint callback, so a child event or tray click
wakes the loop even while the window is dropped and no frame is running —
keeping viewer join/leave notifications and the tray tooltip live while
hidden. Removes the old Wayland minimize-to-tray fallback (window stayed
in the taskbar); hide is now uniform on Wayland and X11.

Deps: winit/glutin/glutin-winit/egui_glow promoted to direct (gui-gated,
optional) — all already transitive via eframe, so no new crates. winit's
default features minus wayland-csd-adwaita, so sctk-adwaita/tiny-skia/
ttf-parser aren't pulled for a CSD fallback titlebar (KWin draws
server-side decorations, and eframe never had CSD either).

Verified end-to-end on KWin Wayland: launch->render; close->window AND
taskbar entry gone (true hide, process stays alive); tray activate->
window + GL surface recreated and renders; tray quit->clean exit; stderr
clean throughout. cargo test --features gui: 15 pass; clippy clean;
headless dependency tree unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 15:41:38 -04:00
co-authored by Claude Opus 4.7
parent b260d57dc4
commit 511927569b
6 changed files with 659 additions and 128 deletions
+17 -18
View File
@@ -3,9 +3,13 @@
//! The tray runs on its **own dedicated thread** with its own current-thread
//! tokio runtime, fully decoupled from the winit event loop (which owns the
//! main thread) and from the process-wide `#[tokio::main]` runtime. It talks to
//! the egui app purely over channels:
//! the egui app purely over winit's event channel and a status channel:
//!
//! * tray → app: [`TrayAction`] (Show / Quit), polled each frame.
//! * tray → app: a [`super::UserEvent::Tray`] carrying a [`TrayAction`]
//! (Show / Quit), pushed through the [`winit::event_loop::EventLoopProxy`].
//! Using the proxy (not egui's repaint) is essential: a tray click must
//! wake the winit loop even when the window has been **dropped** (hidden to
//! tray), so the loop can recreate it.
//! * app → tray: [`TrayStatus`] (idle / hosting / viewing), pushed on change.
//!
//! Why a separate thread instead of `Handle::current().spawn`: updating the
@@ -15,12 +19,14 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use eframe::egui;
use ksni::TrayMethods;
use winit::event_loop::EventLoopProxy;
/// What the user picked from the tray icon or its menu (tray thread → app).
use super::UserEvent;
/// What the user picked from the tray icon or its menu (tray thread → app),
/// delivered as a [`UserEvent::Tray`].
pub enum TrayAction {
/// Left-click, or the "Show window" item: bring the window back.
Show,
@@ -49,8 +55,6 @@ fn status_text(status: TrayStatus) -> String {
/// Handle held by the egui app for the lifetime of the window. Dropping it
/// closes the app→tray channel, which ends the tray thread and removes the icon.
pub struct TrayHandle {
/// Menu/icon actions to drain each frame.
pub actions: Receiver<TrayAction>,
status_tx: tokio::sync::mpsc::UnboundedSender<TrayStatus>,
/// Set true once the tray actually registered with a StatusNotifier host.
/// The app must not divert the window's close to a tray that never appeared.
@@ -80,16 +84,14 @@ struct PixelPassTray {
/// ARGB pixmap, so the icon shows even where the themed "pixelpass" name
/// can't be resolved (e.g. running the dev binary before `make install`).
icon: Vec<ksni::Icon>,
actions: Sender<TrayAction>,
/// Repaint the (possibly hidden/minimized) window so it wakes to act on a
/// tray click — otherwise an idle, hidden window never processes the action.
ctx: egui::Context,
/// Wakes the winit loop and delivers the action — works even when the
/// window has been dropped to the tray (no egui frame is running then).
proxy: EventLoopProxy<UserEvent>,
}
impl PixelPassTray {
fn notify(&self, action: TrayAction) {
let _ = self.actions.send(action);
self.ctx.request_repaint();
let _ = self.proxy.send_event(UserEvent::Tray(action));
}
}
@@ -176,9 +178,8 @@ fn load_icon() -> Option<Vec<ksni::Icon>> {
/// Start the tray on its own thread. Returns a handle for the app to drive it,
/// or `None` if the icon couldn't be decoded or the thread couldn't spawn (in
/// which case the GUI simply runs without a tray — close behaves as before).
pub fn start(ctx: egui::Context) -> Option<TrayHandle> {
pub fn start(proxy: EventLoopProxy<UserEvent>) -> Option<TrayHandle> {
let icon = load_icon()?;
let (action_tx, action_rx) = std::sync::mpsc::channel();
let (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::<TrayStatus>();
let registered = Arc::new(AtomicBool::new(false));
let registered_thread = registered.clone();
@@ -200,8 +201,7 @@ pub fn start(ctx: egui::Context) -> Option<TrayHandle> {
let tray = PixelPassTray {
status: TrayStatus::Idle,
icon,
actions: action_tx,
ctx,
proxy,
};
let handle = match tray.spawn().await {
Ok(handle) => handle,
@@ -226,7 +226,6 @@ pub fn start(ctx: egui::Context) -> Option<TrayHandle> {
.ok()?;
Some(TrayHandle {
actions: action_rx,
status_tx,
registered,
last_sent: None,