diff --git a/Cargo.lock b/Cargo.lock index b82f0c0..1ae0db6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1501,6 +1501,7 @@ checksum = "6caa4eca47cc2358e2c5ae60843a94118e338f87099c6af4170e6e968e8d77cb" dependencies = [ "bytemuck", "egui", + "egui-winit", "glow", "log", "memoffset", @@ -4166,6 +4167,9 @@ dependencies = [ "dialoguer", "directories", "eframe", + "egui_glow", + "glutin", + "glutin-winit", "iroh", "iroh-tickets", "ksni", @@ -4182,6 +4186,7 @@ dependencies = [ "tracing-subscriber", "ureq", "uuid", + "winit", "x11rb", ] diff --git a/Cargo.toml b/Cargo.toml index 60948b6..f607d43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,21 @@ notify-rust = { version = "4", optional = true } # System-tray icon (StatusNotifierItem over D-Bus). Pure-Rust, riding the same # zbus stack notify-rust already pulls — no GTK, no libappindicator/C libdbus. 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] lto = "thin" @@ -49,4 +64,4 @@ strip = "symbols" [features] # Opt-in graphical front-end (pixelpass --gui). Default-off so the headless # 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"] diff --git a/README.md b/README.md index 7d3fd68..cbcb93d 100644 --- a/README.md +++ b/README.md @@ -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. 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 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, diff --git a/src/gui/child.rs b/src/gui/child.rs index 97b7ee4..1f9f76f 100644 --- a/src/gui/child.rs +++ b/src/gui/child.rs @@ -12,11 +12,12 @@ use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; use std::time::Duration; -use eframe::egui; use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use serde::Deserialize; +use super::Waker; + /// One parsed event from the child's stdout. Owned mirror of /// [`crate::common::output::Event`] (which borrows for emit); kept separate so /// the wire format and the parser can evolve independently. @@ -77,9 +78,13 @@ pub struct ChildProc { } impl ChildProc { - /// Spawn `pixelpass ` as a child, wiring up the event reader. `ctx` - /// is repainted whenever an event arrives so the UI updates live. - pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result { + /// Spawn `pixelpass ` as a child, wiring up the event reader. The + /// `waker` is pinged whenever an event arrives so the UI thread wakes to + /// 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 { let exe = std::env::current_exe()?; let mut child = Command::new(exe) .args(args) @@ -104,9 +109,14 @@ impl ChildProc { if tx.send(ev).is_err() { 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::::new())); diff --git a/src/gui/mod.rs b/src/gui/mod.rs index a2674b3..0ff165d 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -8,46 +8,599 @@ //! 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: //! 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 tray; +use std::num::NonZeroU32; +use std::sync::Arc; +use std::time::{Duration, Instant}; + 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::tray::{TrayAction, TrayHandle, TrayStatus}; -/// Launch the GUI event loop. Blocks until the window is closed. Runs on the -/// main thread (a winit requirement), which is where `main` calls it from. -pub fn run() -> anyhow::Result<()> { - // `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"); +/// Initial / minimum window size, in logical points. +const INNER_SIZE: [f64; 2] = [520.0, 480.0]; +const MIN_INNER_SIZE: [f64; 2] = [460.0, 380.0]; - // Pixel icon for X11 titlebars (`_NET_WM_ICON`), embedded at compile time - // so the single binary stays self-contained. Non-fatal on failure: Wayland - // already has its icon via app_id, and X11 just keeps the generic fallback. +/// Events delivered to the winit loop from off the UI thread (or from egui). +pub enum UserEvent { + /// 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, +} + +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")) { - 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}"), } + attrs +} - let options = eframe::NativeOptions { - viewport, - ..Default::default() +/// Build a GL surface for `window` using the established display + config. +fn create_surface( + display: &Display, + config: &Config, + window: &Window, +) -> anyhow::Result> { + let (w, h): (u32, u32) = window.inner_size().into(); + let attrs = SurfaceAttributesBuilder::::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, + 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, + 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 { + 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, + /// 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, +} + +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 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::::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( - "PixelPass", - options, - Box::new(|cc| Ok(Box::new(PixelPassApp::new(cc.egui_ctx.clone())))), - ) - .map_err(|e| anyhow::anyhow!("GUI failed to start: {e}")) + event_loop + .run_app(&mut app) + .map_err(|e| anyhow::anyhow!("GUI event loop error: {e}")) } /// Best-effort clipboard write. Returns whether it succeeded so callers can @@ -246,37 +799,36 @@ struct ViewerState { error: Option, } -#[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 { screen: Screen, host: HostState, viewer: ViewerState, /// 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, - /// winit can't truly hide a Wayland toplevel, so close-to-tray iconifies - /// there and fully hides on X11. - is_wayland: bool, - /// 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. + /// Persisted preference: when true (and a tray is present), the close button + /// hides to the tray instead of quitting. Loaded at startup, written on + /// toggle in Settings. close_to_tray: bool, + /// Wakes the winit loop when a spawned child emits/exits. + waker: Waker, } -impl eframe::App for PixelPassApp { - // eframe 0.34 hands us the central-panel `ui` directly. - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - // Tray clicks + close-to-tray come first, before any drawing. - let ctx = ui.ctx().clone(); - self.handle_tray(&ctx); - // Drain any pending child events before drawing this frame. +impl PixelPassApp { + /// Drain child output and reflect it into the tray. Runs on every wake, + /// whether or not a window is shown, so notifications and the tray tooltip + /// stay live while hidden. + fn tick(&mut self) { self.pump_host_events(); self.pump_viewer_events(); - // Reflect the resulting host/viewer state into the tray icon. 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 { Screen::Menu => self.menu(ui), Screen::Host => self.host(ui), @@ -284,62 +836,6 @@ impl eframe::App for PixelPassApp { 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 = 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. fn sync_tray_status(&mut self) { @@ -498,7 +994,7 @@ impl PixelPassApp { .add_sized([160.0, 36.0], egui::Button::new("Start hosting")) .clicked() { - self.start_host(ui.ctx().clone()); + self.start_host(); } ui.add_space(8.0); 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.last_refusal = None; self.host.ticket = None; @@ -650,7 +1146,7 @@ impl PixelPassApp { args.push("--window".to_string()); } - match ChildProc::spawn(&args, ctx) { + match ChildProc::spawn(&args, self.waker.clone()) { Ok(p) => self.host.proc = Some(p), 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.") .clicked(); 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; } - fn start_viewer(&mut self, ctx: egui::Context) { + fn start_viewer(&mut self) { self.viewer.error = None; self.viewer.url = None; self.viewer.launched = false; @@ -919,7 +1415,7 @@ impl PixelPassApp { let ticket = self.viewer.ticket_input.trim().to_string(); self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id)); 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), Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")), } diff --git a/src/gui/tray.rs b/src/gui/tray.rs index 11f33c6..a963208 100644 --- a/src/gui/tray.rs +++ b/src/gui/tray.rs @@ -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, status_tx: tokio::sync::mpsc::UnboundedSender, /// 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, - actions: Sender, - /// 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, } 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> { /// 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 { +pub fn start(proxy: EventLoopProxy) -> Option { let icon = load_icon()?; - let (action_tx, action_rx) = std::sync::mpsc::channel(); let (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::(); let registered = Arc::new(AtomicBool::new(false)); let registered_thread = registered.clone(); @@ -200,8 +201,7 @@ pub fn start(ctx: egui::Context) -> Option { 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 { .ok()?; Some(TrayHandle { - actions: action_rx, status_tx, registered, last_sent: None,