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:
+15
-5
@@ -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 <args>` 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<Self> {
|
||||
/// Spawn `pixelpass <args>` 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<Self> {
|
||||
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::<String>::new()));
|
||||
|
||||
+600
-104
@@ -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<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")) {
|
||||
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<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(
|
||||
"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<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 {
|
||||
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<TrayHandle>,
|
||||
/// 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<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.
|
||||
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}")),
|
||||
}
|
||||
|
||||
+17
-18
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user