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
parent b260d57dc4
commit 511927569b
6 changed files with 659 additions and 128 deletions
Generated
+5
View File
@@ -1501,6 +1501,7 @@ checksum = "6caa4eca47cc2358e2c5ae60843a94118e338f87099c6af4170e6e968e8d77cb"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"egui", "egui",
"egui-winit",
"glow", "glow",
"log", "log",
"memoffset", "memoffset",
@@ -4166,6 +4167,9 @@ dependencies = [
"dialoguer", "dialoguer",
"directories", "directories",
"eframe", "eframe",
"egui_glow",
"glutin",
"glutin-winit",
"iroh", "iroh",
"iroh-tickets", "iroh-tickets",
"ksni", "ksni",
@@ -4182,6 +4186,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"ureq", "ureq",
"uuid", "uuid",
"winit",
"x11rb", "x11rb",
] ]
+16 -1
View File
@@ -40,6 +40,21 @@ notify-rust = { version = "4", optional = true }
# System-tray icon (StatusNotifierItem over D-Bus). Pure-Rust, riding the same # System-tray icon (StatusNotifierItem over D-Bus). Pure-Rust, riding the same
# zbus stack notify-rust already pulls — no GTK, no libappindicator/C libdbus. # zbus stack notify-rust already pulls — no GTK, no libappindicator/C libdbus.
ksni = { version = "0.3", optional = true } ksni = { version = "0.3", optional = true }
# Hand-rolled windowing stack for the GUI (replaces eframe::run_native) so we
# can drop the OS window on "hide to tray" — the only way to truly hide a
# toplevel on Wayland — and recreate it on Show. All of these are already pulled
# in transitively by eframe; making them direct adds no new crates to vet.
# eframe is kept for its egui re-export + icon_data PNG decoder. egui_glow needs
# its (non-default) `winit` feature for the `EguiGlow` integration type; eframe
# pulls egui_glow but without that feature, so we enable it here.
egui_glow = { version = "0.34.2", default-features = false, features = ["winit", "wayland", "x11"], optional = true }
# winit's default set minus `wayland-csd-adwaita`: KWin (and most desktop
# compositors) draw server-side decorations, and eframe never enabled CSD
# either, so dropping it keeps the dependency tree identical to before (no
# sctk-adwaita / tiny-skia / ttf-parser pulled in just for a fallback titlebar).
winit = { version = "0.30", default-features = false, features = ["rwh_06", "x11", "wayland", "wayland-dlopen"], optional = true }
glutin = { version = "0.32", optional = true }
glutin-winit = { version = "0.5", optional = true }
[profile.release] [profile.release]
lto = "thin" lto = "thin"
@@ -49,4 +64,4 @@ strip = "symbols"
[features] [features]
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless # Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
# build never pulls the GUI toolkit tree. # build never pulls the GUI toolkit tree.
gui = ["dep:eframe", "dep:notify-rust", "dep:ksni"] gui = ["dep:eframe", "dep:notify-rust", "dep:ksni", "dep:egui_glow", "dep:winit", "dep:glutin", "dep:glutin-winit"]
+6
View File
@@ -87,6 +87,12 @@ share code appears with a copy button. Connected viewers are listed with a
**Kick** button each, and a desktop notification fires as they join or leave. **Kick** button each, and a desktop notification fires as they join or leave.
View: paste a code, pick mpv or VLC, click **Connect** and the player launches. View: paste a code, pick mpv or VLC, click **Connect** and the player launches.
A system-tray icon shows current status. **Settings → "Keep running in the
tray when I close the window"** (off by default) makes the close button hide
the window — truly, by dropping it — while any active stream keeps running in
the child; reopen it from the tray. (Plain close still quits when the option is
off, or when no system tray is present.)
The window is a thin driver — it runs the same headless `pixelpass` as a The window is a thin driver — it runs the same headless `pixelpass` as a
child process and reads its event stream, so the GUI is purely additive and child process and reads its event stream, so the GUI is purely additive and
the capture machinery is untouched by it. On a build without the feature, the capture machinery is untouched by it. On a build without the feature,
+15 -5
View File
@@ -12,11 +12,12 @@ use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use eframe::egui;
use nix::sys::signal::{Signal, kill}; use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid; use nix::unistd::Pid;
use serde::Deserialize; use serde::Deserialize;
use super::Waker;
/// One parsed event from the child's stdout. Owned mirror of /// One parsed event from the child's stdout. Owned mirror of
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so /// [`crate::common::output::Event`] (which borrows for emit); kept separate so
/// the wire format and the parser can evolve independently. /// the wire format and the parser can evolve independently.
@@ -77,9 +78,13 @@ pub struct ChildProc {
} }
impl ChildProc { impl ChildProc {
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. `ctx` /// Spawn `pixelpass <args>` as a child, wiring up the event reader. The
/// is repainted whenever an event arrives so the UI updates live. /// `waker` is pinged whenever an event arrives so the UI thread wakes to
pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result<Self> { /// drain it — this wakes the winit event loop directly (via an
/// `EventLoopProxy`), so it works even when the window is hidden to the tray
/// and no frames are running (egui's own repaint callback would not fire
/// repeatedly in that idle state — see [`super::Waker`]).
pub fn spawn(args: &[String], waker: Waker) -> std::io::Result<Self> {
let exe = std::env::current_exe()?; let exe = std::env::current_exe()?;
let mut child = Command::new(exe) let mut child = Command::new(exe)
.args(args) .args(args)
@@ -104,9 +109,14 @@ impl ChildProc {
if tx.send(ev).is_err() { if tx.send(ev).is_err() {
break; // app gone break; // app gone
} }
ctx.request_repaint(); waker.wake();
} }
} }
// stdout closed → the child has exited (player closed, connection
// ended, or a failed launch). Wake once more so the UI reaps it and
// clears the "running" view, even if no final event was emitted and
// the window is hidden to the tray.
waker.wake();
}); });
let stderr_tail = Arc::new(Mutex::new(Vec::<String>::new())); let stderr_tail = Arc::new(Mutex::new(Vec::<String>::new()));
+600 -104
View File
@@ -8,46 +8,599 @@
//! child's JSON event stream (see [`crate::common::output`]) to drive what it //! child's JSON event stream (see [`crate::common::output`]) to drive what it
//! shows. That keeps the fragile capture stack sealed in a separate process: //! shows. That keeps the fragile capture stack sealed in a separate process:
//! the GUI can be closed or crash without taking a live stream down. //! the GUI can be closed or crash without taking a live stream down.
//!
//! ## Windowing: why we hand-roll the loop instead of using `eframe`
//!
//! The "keep running in the tray" feature needs to truly *hide* the window
//! while a stream keeps running in the child. On Wayland there is no
//! unmap-but-keep-alive request in xdg-shell, so winit's `set_visible(false)`
//! is a deliberate no-op there — the only way to make a toplevel vanish is to
//! **destroy its surface** and recreate it later. `eframe::run_native` owns the
//! one window it will never let you drop, so we replace it with a hand-rolled
//! [`winit::application::ApplicationHandler`] + `glutin` + [`egui_glow`] loop:
//!
//! * **Hide to tray** ([`Gfx::hide`]) parks the GL context as *not-current*
//! and drops the [`winit::window::Window`] + its GL surface — the Wayland
//! surface is genuinely gone, natively, on both Wayland and X11.
//! * **Show** ([`Gfx::show`], on a tray click) recreates the window + surface
//! and makes the parked context current again.
//!
//! The GL context, `glutin` display/config, and the `egui_glow` painter (with
//! its uploaded font/texture atlas) and `egui-winit` state (with its clipboard
//! connection) are **kept** across the cycle — only the OS window and its
//! surface churn. That preserves the hard-won Wayland clipboard integration and
//! avoids re-uploading textures on every show.
//!
//! Wakeups are routed through winit's [`winit::event_loop::EventLoopProxy`]
//! (see [`Waker`] and [`tray`]) rather than egui's repaint callback, because a
//! child event or tray click must wake the loop even while the window is
//! dropped and no egui frame is running.
mod child; mod child;
mod tray; mod tray;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, Instant};
use eframe::egui; use eframe::egui;
use egui_glow::EguiGlow;
use egui_glow::egui_winit;
use egui_glow::glow::{self, HasContext as _};
use glutin::config::{Config, ConfigTemplateBuilder};
use glutin::context::{
ContextApi, ContextAttributesBuilder, NotCurrentContext, NotCurrentGlContext as _,
PossiblyCurrentContext, PossiblyCurrentGlContext as _,
};
use glutin::display::{Display, GetGlDisplay as _, GlDisplay as _};
use glutin::surface::{
GlSurface as _, Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface,
};
use glutin_winit::{ApiPreference, DisplayBuilder};
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
use winit::raw_window_handle::HasWindowHandle as _;
use winit::window::{Window, WindowAttributes, WindowId};
use self::child::{ChildEvent, ChildProc}; use self::child::{ChildEvent, ChildProc};
use self::tray::{TrayAction, TrayHandle, TrayStatus}; use self::tray::{TrayAction, TrayHandle, TrayStatus};
/// Launch the GUI event loop. Blocks until the window is closed. Runs on the /// Initial / minimum window size, in logical points.
/// main thread (a winit requirement), which is where `main` calls it from. const INNER_SIZE: [f64; 2] = [520.0, 480.0];
pub fn run() -> anyhow::Result<()> { const MIN_INNER_SIZE: [f64; 2] = [460.0, 380.0];
// `app_id` must match the installed `pixelpass.desktop`: on Wayland the
// compositor sources the titlebar/taskbar icon from that desktop file's
// `Icon=` keyed by app_id — not from any pixels the app sets.
let mut viewport = egui::ViewportBuilder::default()
.with_app_id("pixelpass")
.with_inner_size([520.0, 480.0])
.with_min_inner_size([460.0, 380.0])
.with_title("PixelPass");
// Pixel icon for X11 titlebars (`_NET_WM_ICON`), embedded at compile time /// Events delivered to the winit loop from off the UI thread (or from egui).
// so the single binary stays self-contained. Non-fatal on failure: Wayland pub enum UserEvent {
// already has its icon via app_id, and X11 just keeps the generic fallback. /// Something changed off the UI thread — a headless child emitted a JSON
/// event or exited (see [`Waker`]). Drain it on the next tick, and repaint
/// if a window is currently shown.
Wake,
/// A system-tray icon/menu action (see [`tray`]).
Tray(TrayAction),
}
/// A cheap, cloneable handle that wakes the winit event loop from any thread.
///
/// Used by the headless-child reader threads: pinging this wakes the loop
/// **even when the window has been dropped to the tray** (no egui frame is
/// running then, so egui's own repaint callback would go quiet after the first
/// request — see the egui repaint bookkeeping). That's what keeps join/leave
/// desktop notifications and the tray tooltip live while hidden.
#[derive(Clone)]
pub struct Waker {
proxy: EventLoopProxy<UserEvent>,
}
impl Waker {
/// Wake the loop. A send error only means the loop has already exited, in
/// which case there is nothing left to wake.
pub fn wake(&self) {
let _ = self.proxy.send_event(UserEvent::Wake);
}
}
/// Window attributes used for both the first window and every recreated one, so
/// a window restored from the tray is identical to the original.
fn window_attributes() -> WindowAttributes {
use winit::dpi::LogicalSize;
// Wayland sources the titlebar/taskbar icon from the .desktop file matched
// by this app_id (NOT from `with_window_icon`, which Wayland ignores) — it
// must equal the installed pixelpass.desktop. `with_name(general, instance)`
// sets the Wayland app_id to `general`.
use winit::platform::wayland::WindowAttributesExtWayland as _;
let mut attrs = WindowAttributes::default()
.with_title("PixelPass")
.with_inner_size(LogicalSize::new(INNER_SIZE[0], INNER_SIZE[1]))
.with_min_inner_size(LogicalSize::new(MIN_INNER_SIZE[0], MIN_INNER_SIZE[1]))
// Stay unmapped until the first frame is painted, to avoid a flash of an
// empty window (on Wayland the surface only maps on the first swap
// anyway; this matters mostly for X11).
.with_visible(false)
.with_name("pixelpass", "pixelpass");
// X11 titlebar/taskbar icon (`_NET_WM_ICON`), embedded at compile time so
// the binary stays self-contained. Wayland ignores it (uses app_id above);
// X11 falls back to it. Non-fatal on failure.
match eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")) { match eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")) {
Ok(icon) => viewport = viewport.with_icon(icon), Ok(icon) => match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
Ok(winit_icon) => attrs = attrs.with_window_icon(Some(winit_icon)),
Err(e) => tracing::warn!("could not build X11 window icon: {e}"),
},
Err(e) => tracing::warn!("could not load embedded window icon: {e}"), Err(e) => tracing::warn!("could not load embedded window icon: {e}"),
} }
attrs
}
let options = eframe::NativeOptions { /// Build a GL surface for `window` using the established display + config.
viewport, fn create_surface(
..Default::default() display: &Display,
config: &Config,
window: &Window,
) -> anyhow::Result<Surface<WindowSurface>> {
let (w, h): (u32, u32) = window.inner_size().into();
let attrs = SurfaceAttributesBuilder::<WindowSurface>::new().build(
window.window_handle()?.as_raw(),
NonZeroU32::new(w).unwrap_or(NonZeroU32::MIN),
NonZeroU32::new(h).unwrap_or(NonZeroU32::MIN),
);
// SAFETY: `window` outlives the surface — both live in the same `WinState`
// and are dropped together — so the raw handle stays valid for the
// surface's lifetime.
let surface = unsafe { display.create_window_surface(config, &attrs)? };
Ok(surface)
}
/// GL + egui state that persists for the whole GUI session. Only the OS window
/// and its surface are dropped/recreated on hide/show (see the module docs);
/// everything here outlives the cycle.
struct Gfx {
gl: Arc<glow::Context>,
gl_display: Display,
gl_config: Config,
egui_glow: EguiGlow,
/// egui→window command state (title changes etc.), threaded through
/// [`egui_winit::process_viewport_commands`] each frame.
viewport_info: egui::ViewportInfo,
win: WinState,
}
/// Whether the window is currently mapped. The GL *context* is preserved in
/// both states; `Between` is only a momentary placeholder during a transition.
// Exactly one `WinState` exists (it's a field of the single `Gfx`), never a
// collection of them, so the inter-variant size gap costs ~250 idle bytes once
// — not worth boxing the window/surface that every frame touches.
#[allow(clippy::large_enum_variant)]
enum WinState {
Shown {
window: Window,
surface: Surface<WindowSurface>,
context: PossiblyCurrentContext,
},
Hidden {
context: NotCurrentContext,
},
/// Transient placeholder held only inside [`Gfx::hide`] / [`Gfx::show`].
Between,
}
impl Gfx {
/// Full first-time initialization: pick a GL config, create the window,
/// context, and surface, make the context current, and build the egui glow
/// integration. Runs once, from `resumed`.
fn create(event_loop: &ActiveEventLoop) -> anyhow::Result<Self> {
let template = ConfigTemplateBuilder::new()
.prefer_hardware_accelerated(None)
.with_depth_size(0)
.with_stencil_size(0)
.with_transparency(false);
let (maybe_window, gl_config) = DisplayBuilder::new()
.with_preference(ApiPreference::FallbackEgl)
.with_window_attributes(Some(window_attributes()))
.build(event_loop, template, |mut configs| {
configs.next().expect("no GL config matched")
})
.map_err(|e| anyhow::anyhow!("failed to choose a GL config: {e}"))?;
let gl_display = gl_config.display();
// On EGL/Wayland the window is built during `build`; on GLX it is
// deferred until the visual is known, so finalize it here.
let window = match maybe_window {
Some(w) => w,
None => glutin_winit::finalize_window(event_loop, window_attributes(), &gl_config)?,
};
let raw = window.window_handle()?.as_raw();
let ctx_attrs = ContextAttributesBuilder::new().build(Some(raw));
// Fall back to a GLES context if a core GL context can't be created.
let gles_attrs = ContextAttributesBuilder::new()
.with_context_api(ContextApi::Gles(None))
.build(Some(raw));
// SAFETY: `raw` comes from `window`, which lives at least as long as the
// context (both are owned by this `Gfx`).
let not_current = unsafe {
gl_display
.create_context(&gl_config, &ctx_attrs)
.or_else(|_| gl_display.create_context(&gl_config, &gles_attrs))
}
.map_err(|e| anyhow::anyhow!("failed to create a GL context: {e}"))?;
let surface = create_surface(&gl_display, &gl_config, &window)?;
let context = not_current
.make_current(&surface)
.map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?;
// Vsync, so the frame loop self-throttles and idles cheaply.
let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN));
// SAFETY: the loader is only called while `gl_display` is alive, which
// outlives the returned context.
let gl = Arc::new(unsafe {
glow::Context::from_loader_function(|s| match std::ffi::CString::new(s) {
Ok(name) => gl_display.get_proc_address(name.as_c_str()),
Err(_) => std::ptr::null(),
})
});
let egui_glow = EguiGlow::new(event_loop, gl.clone(), None, None, true);
window.set_visible(true);
window.request_redraw();
Ok(Self {
gl,
gl_display,
gl_config,
egui_glow,
viewport_info: egui::ViewportInfo::default(),
win: WinState::Shown {
window,
surface,
context,
},
})
}
fn shown_window(&self) -> Option<&Window> {
match &self.win {
WinState::Shown { window, .. } => Some(window),
_ => None,
}
}
/// Hide to tray: park the context as not-current and drop the window +
/// surface. The Wayland surface is genuinely destroyed (the only way to
/// hide a toplevel there).
fn hide(&mut self) {
match std::mem::replace(&mut self.win, WinState::Between) {
WinState::Shown {
window,
surface,
context,
} => match context.make_not_current() {
Ok(not_current) => {
// Order matters: the context must be made not-current before
// its surface is dropped.
drop(surface);
drop(window);
self.win = WinState::Hidden {
context: not_current,
};
}
Err(e) => {
tracing::error!("hide-to-tray: make_not_current failed: {e}");
// Leave `Between` (window already gone); a later Show will
// log that there's no parked context and the user can quit
// from the tray. This effectively never happens.
}
},
other => self.win = other, // already hidden / transient
}
}
/// Restore from the tray: recreate the window + surface and make the parked
/// context current again.
fn show(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
match std::mem::replace(&mut self.win, WinState::Between) {
WinState::Hidden { context } => {
let window =
glutin_winit::finalize_window(event_loop, window_attributes(), &self.gl_config)?;
let surface = create_surface(&self.gl_display, &self.gl_config, &window)?;
let context = context
.make_current(&surface)
.map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?;
let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN));
window.set_visible(true);
window.request_redraw();
self.win = WinState::Shown {
window,
surface,
context,
};
Ok(())
}
WinState::Shown {
window,
surface,
context,
} => {
// Already shown — just nudge a repaint and restore.
window.request_redraw();
self.win = WinState::Shown {
window,
surface,
context,
};
Ok(())
}
WinState::Between => {
anyhow::bail!("no parked GL context to restore the window from")
}
}
}
/// Run one egui frame and paint it. Returns how long egui wants to wait
/// before the next repaint (`Duration::MAX` == idle, sleep until an event).
/// No-op returning `MAX` if the window isn't currently shown.
fn paint_frame(&mut self, run_ui: impl FnMut(&mut egui::Ui)) -> Duration {
let WinState::Shown {
window,
surface,
context,
} = &self.win
else {
return Duration::MAX;
};
let eg = &mut self.egui_glow;
let raw_input = eg.egui_winit.take_egui_input(window);
let egui::FullOutput {
platform_output,
textures_delta,
shapes,
pixels_per_point,
viewport_output,
} = eg.egui_ctx.run_ui(raw_input, run_ui);
eg.egui_winit.handle_platform_output(window, platform_output);
// Apply any window commands egui emitted (we issue none directly, but
// egui may request e.g. IME changes) and read the next repaint delay.
let (repaint_delay, commands) = match viewport_output.get(&egui::ViewportId::ROOT) {
Some(out) => (out.repaint_delay, out.commands.clone()),
None => (Duration::MAX, Vec::new()),
};
if !commands.is_empty() {
let mut actions = Vec::new();
egui_winit::process_viewport_commands(
&eg.egui_ctx,
&mut self.viewport_info,
commands,
window,
&mut actions,
);
}
let clipped = eg.egui_ctx.tessellate(shapes, pixels_per_point);
for (id, image_delta) in &textures_delta.set {
eg.painter.set_texture(*id, image_delta);
}
let dimensions: [u32; 2] = window.inner_size().into();
// SAFETY: the context is current (we are in the `Shown` arm) and `gl`
// belongs to it.
unsafe {
self.gl.clear_color(0.08, 0.08, 0.08, 1.0);
self.gl.clear(glow::COLOR_BUFFER_BIT);
}
eg.painter
.paint_primitives(dimensions, pixels_per_point, &clipped);
for id in &textures_delta.free {
eg.painter.free_texture(*id);
}
if let Err(e) = surface.swap_buffers(context) {
tracing::warn!("GL swap_buffers failed: {e}");
}
repaint_delay
}
}
/// The winit application: owns the persistent GL/egui state ([`Gfx`]) and the
/// PixelPass UI logic ([`PixelPassApp`]), and routes events between them.
struct App {
state: PixelPassApp,
/// `None` until the first `resumed`; `Some` for the rest of the session
/// (the window inside may be Shown or Hidden).
gfx: Option<Gfx>,
/// When the next repaint is due, derived from egui's per-frame delay and
/// external wakes. `None` == idle (sleep until an event).
repaint_at: Option<Instant>,
}
impl App {
fn shown_window(&self) -> Option<&Window> {
self.gfx.as_ref().and_then(Gfx::shown_window)
}
fn request_redraw(&self) {
if let Some(w) = self.shown_window() {
w.request_redraw();
}
}
/// Run an egui frame now (if shown) and record the next repaint deadline.
fn redraw(&mut self) {
let App {
gfx,
state,
repaint_at,
..
} = self;
let Some(gfx) = gfx.as_mut() else { return };
let delay = gfx.paint_frame(|ui| state.draw(ui));
*repaint_at = (delay < Duration::MAX).then(|| Instant::now() + delay);
}
/// The window's close button: hide to the tray if enabled and a tray is
/// actually present, otherwise quit.
fn on_close(&mut self, event_loop: &ActiveEventLoop) {
let hide = self.state.close_to_tray
&& self
.state
.tray
.as_ref()
.is_some_and(TrayHandle::registered);
if hide {
if let Some(gfx) = self.gfx.as_mut() {
gfx.hide();
}
self.repaint_at = None;
} else {
event_loop.exit();
}
}
}
impl ApplicationHandler<UserEvent> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.gfx.is_some() {
return; // already initialized
}
match Gfx::create(event_loop) {
Ok(gfx) => self.gfx = Some(gfx),
Err(e) => {
tracing::error!("GUI: could not create the window/GL context: {e}");
event_loop.exit();
}
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
if matches!(event, WindowEvent::CloseRequested) {
self.on_close(event_loop);
return;
}
if matches!(event, WindowEvent::RedrawRequested) {
self.redraw();
return;
}
if let WindowEvent::Resized(size) = &event
&& let Some(gfx) = self.gfx.as_ref()
&& let WinState::Shown {
surface, context, ..
} = &gfx.win
&& let (Some(w), Some(h)) = (NonZeroU32::new(size.width), NonZeroU32::new(size.height))
{
surface.resize(context, w, h);
}
// Feed the event to egui and repaint if it wants one.
let Some(gfx) = self.gfx.as_mut() else { return };
let WinState::Shown { window, .. } = &gfx.win else {
return;
};
let response = gfx.egui_glow.egui_winit.on_window_event(window, &event);
if response.repaint {
window.request_redraw();
}
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
match event {
UserEvent::Wake => {
// A child emitted/closed: update state (and fire notifications /
// tray tooltip) even while hidden, then repaint if shown.
self.state.tick();
self.request_redraw();
}
UserEvent::Tray(TrayAction::Show) => {
if let Some(gfx) = self.gfx.as_mut()
&& let Err(e) = gfx.show(event_loop)
{
tracing::error!("could not restore the window from the tray: {e}");
}
self.state.tick();
}
UserEvent::Tray(TrayAction::Quit) => {
event_loop.exit();
}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let flow = match self.repaint_at {
None => ControlFlow::Wait,
Some(at) if at > Instant::now() => ControlFlow::WaitUntil(at),
Some(_) => {
// Due now: ask for a frame if there's a window to paint into;
// otherwise (hidden) drop the pending repaint and sleep.
if let Some(w) = self.shown_window() {
w.request_redraw();
} else {
self.repaint_at = None;
}
ControlFlow::Wait
}
};
event_loop.set_control_flow(flow);
}
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
// Release GL resources only if the context is current (window shown);
// when hidden the context isn't current, and the process is exiting
// anyway so the driver reclaims everything.
if let Some(gfx) = self.gfx.as_mut()
&& matches!(gfx.win, WinState::Shown { .. })
{
gfx.egui_glow.painter.destroy();
}
}
}
/// Launch the GUI. Blocks until the window is closed (or the tray Quit is
/// chosen). Runs on the main thread, a winit requirement, which is where `main`
/// calls it from.
pub fn run() -> anyhow::Result<()> {
let event_loop = EventLoop::<UserEvent>::with_user_event()
.build()
.map_err(|e| anyhow::anyhow!("failed to build the event loop: {e}"))?;
event_loop.set_control_flow(ControlFlow::Wait);
let proxy = event_loop.create_proxy();
let waker = Waker {
proxy: proxy.clone(),
};
// The tray runs on its own thread and wakes us via the proxy.
let tray = tray::start(proxy.clone());
let close_to_tray = crate::common::config::load()
.map(|c| c.gui.close_to_tray)
.unwrap_or(false);
let state = PixelPassApp {
screen: Screen::default(),
host: HostState::default(),
viewer: ViewerState::default(),
tray,
close_to_tray,
waker,
};
let mut app = App {
state,
gfx: None,
repaint_at: None,
}; };
eframe::run_native( event_loop
"PixelPass", .run_app(&mut app)
options, .map_err(|e| anyhow::anyhow!("GUI event loop error: {e}"))
Box::new(|cc| Ok(Box::new(PixelPassApp::new(cc.egui_ctx.clone())))),
)
.map_err(|e| anyhow::anyhow!("GUI failed to start: {e}"))
} }
/// Best-effort clipboard write. Returns whether it succeeded so callers can /// Best-effort clipboard write. Returns whether it succeeded so callers can
@@ -246,37 +799,36 @@ struct ViewerState {
error: Option<String>, error: Option<String>,
} }
#[derive(Default)] /// The PixelPass UI logic — screens, the running children, and the tray. Knows
/// nothing about windowing; [`App`] drives its [`PixelPassApp::draw`] each frame
/// and [`PixelPassApp::tick`] on each wake.
struct PixelPassApp { struct PixelPassApp {
screen: Screen, screen: Screen,
host: HostState, host: HostState,
viewer: ViewerState, viewer: ViewerState,
/// System-tray handle; `None` if the tray couldn't start, in which case the /// System-tray handle; `None` if the tray couldn't start, in which case the
/// window behaves normally (no minimize-to-tray). /// close button always quits (never hides).
tray: Option<TrayHandle>, tray: Option<TrayHandle>,
/// winit can't truly hide a Wayland toplevel, so close-to-tray iconifies /// Persisted preference: when true (and a tray is present), the close button
/// there and fully hides on X11. /// hides to the tray instead of quitting. Loaded at startup, written on
is_wayland: bool, /// toggle in Settings.
/// Set by the tray's "Quit" item so the next close really exits — the
/// window's own close button only hides to the tray.
really_quit: bool,
/// Persisted preference: when true, the close button hides to the tray
/// instead of quitting. Loaded at startup, written on toggle in Settings.
close_to_tray: bool, close_to_tray: bool,
/// Wakes the winit loop when a spawned child emits/exits.
waker: Waker,
} }
impl eframe::App for PixelPassApp { impl PixelPassApp {
// eframe 0.34 hands us the central-panel `ui` directly. /// Drain child output and reflect it into the tray. Runs on every wake,
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { /// whether or not a window is shown, so notifications and the tray tooltip
// Tray clicks + close-to-tray come first, before any drawing. /// stay live while hidden.
let ctx = ui.ctx().clone(); fn tick(&mut self) {
self.handle_tray(&ctx);
// Drain any pending child events before drawing this frame.
self.pump_host_events(); self.pump_host_events();
self.pump_viewer_events(); self.pump_viewer_events();
// Reflect the resulting host/viewer state into the tray icon.
self.sync_tray_status(); self.sync_tray_status();
}
/// Render the current screen. Called from inside the egui frame.
fn draw(&mut self, ui: &mut egui::Ui) {
match self.screen { match self.screen {
Screen::Menu => self.menu(ui), Screen::Menu => self.menu(ui),
Screen::Host => self.host(ui), Screen::Host => self.host(ui),
@@ -284,62 +836,6 @@ impl eframe::App for PixelPassApp {
Screen::Settings => self.settings(ui), Screen::Settings => self.settings(ui),
} }
} }
}
impl PixelPassApp {
/// Build the app and start the tray with the live egui context (needed so
/// tray clicks can wake a hidden/minimized window).
fn new(ctx: egui::Context) -> Self {
let close_to_tray = crate::common::config::load()
.map(|c| c.gui.close_to_tray)
.unwrap_or(false);
Self {
tray: tray::start(ctx),
is_wayland: std::env::var_os("WAYLAND_DISPLAY").is_some(),
close_to_tray,
..Default::default()
}
}
/// Drain tray actions and divert the window's close button to the tray
/// (keeping any live host stream running) instead of quitting.
fn handle_tray(&mut self, ctx: &egui::Context) {
let actions: Vec<TrayAction> = match &self.tray {
Some(t) => std::iter::from_fn(|| t.actions.try_recv().ok()).collect(),
None => Vec::new(),
};
for action in actions {
match action {
TrayAction::Show => {
// Restore from whichever hide path this backend used, then
// raise. The no-op one of these two is harmless.
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
TrayAction::Quit => {
self.really_quit = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
// Only divert the close when a tray is actually showing our icon —
// otherwise hiding would strand the window with no way to get it back.
let tray_live = self.tray.as_ref().is_some_and(TrayHandle::registered);
if tray_live
&& self.close_to_tray
&& !self.really_quit
&& ctx.input(|i| i.viewport().close_requested())
{
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
if self.is_wayland {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
} else {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
}
}
}
/// Mirror current activity into the tray icon's tooltip/menu. /// Mirror current activity into the tray icon's tooltip/menu.
fn sync_tray_status(&mut self) { fn sync_tray_status(&mut self) {
@@ -498,7 +994,7 @@ impl PixelPassApp {
.add_sized([160.0, 36.0], egui::Button::new("Start hosting")) .add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
.clicked() .clicked()
{ {
self.start_host(ui.ctx().clone()); self.start_host();
} }
ui.add_space(8.0); ui.add_space(8.0);
ui.label( ui.label(
@@ -621,7 +1117,7 @@ impl PixelPassApp {
} }
} }
fn start_host(&mut self, ctx: egui::Context) { fn start_host(&mut self) {
self.host.error = None; self.host.error = None;
self.host.last_refusal = None; self.host.last_refusal = None;
self.host.ticket = None; self.host.ticket = None;
@@ -650,7 +1146,7 @@ impl PixelPassApp {
args.push("--window".to_string()); args.push("--window".to_string());
} }
match ChildProc::spawn(&args, ctx) { match ChildProc::spawn(&args, self.waker.clone()) {
Ok(p) => self.host.proc = Some(p), Ok(p) => self.host.proc = Some(p),
Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")), Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")),
} }
@@ -867,7 +1363,7 @@ impl PixelPassApp {
.on_disabled_hover_text("Paste a valid share code first.") .on_disabled_hover_text("Paste a valid share code first.")
.clicked(); .clicked();
if decoded_id.is_some() && (connect_clicked || enter_pressed) { if decoded_id.is_some() && (connect_clicked || enter_pressed) {
self.start_viewer(ui.ctx().clone()); self.start_viewer();
} }
} }
@@ -911,7 +1407,7 @@ impl PixelPassApp {
self.viewer.focus_ticket = true; self.viewer.focus_ticket = true;
} }
fn start_viewer(&mut self, ctx: egui::Context) { fn start_viewer(&mut self) {
self.viewer.error = None; self.viewer.error = None;
self.viewer.url = None; self.viewer.url = None;
self.viewer.launched = false; self.viewer.launched = false;
@@ -919,7 +1415,7 @@ impl PixelPassApp {
let ticket = self.viewer.ticket_input.trim().to_string(); let ticket = self.viewer.ticket_input.trim().to_string();
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id)); self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
let args = vec![ticket, "--output".to_string(), "json".to_string()]; let args = vec![ticket, "--output".to_string(), "json".to_string()];
match ChildProc::spawn(&args, ctx) { match ChildProc::spawn(&args, self.waker.clone()) {
Ok(p) => self.viewer.proc = Some(p), Ok(p) => self.viewer.proc = Some(p),
Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")), Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")),
} }
+17 -18
View File
@@ -3,9 +3,13 @@
//! The tray runs on its **own dedicated thread** with its own current-thread //! 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 //! 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 //! 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. //! * app → tray: [`TrayStatus`] (idle / hosting / viewing), pushed on change.
//! //!
//! Why a separate thread instead of `Handle::current().spawn`: updating the //! Why a separate thread instead of `Handle::current().spawn`: updating the
@@ -15,12 +19,14 @@
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use eframe::egui;
use ksni::TrayMethods; 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 { pub enum TrayAction {
/// Left-click, or the "Show window" item: bring the window back. /// Left-click, or the "Show window" item: bring the window back.
Show, 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 /// 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. /// closes the app→tray channel, which ends the tray thread and removes the icon.
pub struct TrayHandle { pub struct TrayHandle {
/// Menu/icon actions to drain each frame.
pub actions: Receiver<TrayAction>,
status_tx: tokio::sync::mpsc::UnboundedSender<TrayStatus>, status_tx: tokio::sync::mpsc::UnboundedSender<TrayStatus>,
/// Set true once the tray actually registered with a StatusNotifier host. /// 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. /// 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 /// 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`). /// can't be resolved (e.g. running the dev binary before `make install`).
icon: Vec<ksni::Icon>, icon: Vec<ksni::Icon>,
actions: Sender<TrayAction>, /// Wakes the winit loop and delivers the action — works even when the
/// Repaint the (possibly hidden/minimized) window so it wakes to act on a /// window has been dropped to the tray (no egui frame is running then).
/// tray click — otherwise an idle, hidden window never processes the action. proxy: EventLoopProxy<UserEvent>,
ctx: egui::Context,
} }
impl PixelPassTray { impl PixelPassTray {
fn notify(&self, action: TrayAction) { fn notify(&self, action: TrayAction) {
let _ = self.actions.send(action); let _ = self.proxy.send_event(UserEvent::Tray(action));
self.ctx.request_repaint();
} }
} }
@@ -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, /// 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 /// 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). /// 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 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 (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::<TrayStatus>();
let registered = Arc::new(AtomicBool::new(false)); let registered = Arc::new(AtomicBool::new(false));
let registered_thread = registered.clone(); let registered_thread = registered.clone();
@@ -200,8 +201,7 @@ pub fn start(ctx: egui::Context) -> Option<TrayHandle> {
let tray = PixelPassTray { let tray = PixelPassTray {
status: TrayStatus::Idle, status: TrayStatus::Idle,
icon, icon,
actions: action_tx, proxy,
ctx,
}; };
let handle = match tray.spawn().await { let handle = match tray.spawn().await {
Ok(handle) => handle, Ok(handle) => handle,
@@ -226,7 +226,6 @@ pub fn start(ctx: egui::Context) -> Option<TrayHandle> {
.ok()?; .ok()?;
Some(TrayHandle { Some(TrayHandle {
actions: action_rx,
status_tx, status_tx,
registered, registered,
last_sent: None, last_sent: None,