From b093bf8471cdabb0871a300ec532ea65da7dfcb5 Mon Sep 17 00:00:00 2001 From: Raman Date: Sat, 25 Jul 2026 20:42:40 +0200 Subject: [PATCH 1/3] Add a Wayland layer-shell overlay and fix window geometry on Wayland Render the always-on-top metrics overlay through a wlr-layer-shell surface on Linux (the winit/eframe always-on-top hint is ignored on Wayland, so the overlay could never stay above a full-screen game). The overlay is draggable with a click-through body, remembers its position, and adopts the main window's egui style. It shares a single wgpu instance/adapter/device/queue with eframe (created at startup and handed to eframe via WgpuSetup::Existing), so the app runs one Vulkan stack rather than two. The Linux backend lives under app::overlay::layer_shell; the approach is documented in docs/OVERLAY.md. Also remember the main window's size and maximized state across restarts and fix a couple of Wayland-specific window issues (a 0x0 initial-size crash and jittery resizing). --- Cargo.lock | 39 +- Cargo.toml | 9 + docs/OVERLAY.md | 254 ++++++ src/app/mod.rs | 101 ++- src/app/overlay/layer_shell.rs | 1042 ++++++++++++++++++++++++ src/app/{overlay.rs => overlay/mod.rs} | 225 ++++- src/app/settings/app_settings.rs | 10 + src/app/settings/mod.rs | 3 +- src/main.rs | 33 +- 9 files changed, 1665 insertions(+), 51 deletions(-) create mode 100644 docs/OVERLAY.md create mode 100644 src/app/overlay/layer_shell.rs rename src/app/{overlay.rs => overlay/mod.rs} (57%) diff --git a/Cargo.lock b/Cargo.lock index ac3b771..704a322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,8 @@ dependencies = [ "crossbeam-channel", "educe", "eframe", + "egui", + "egui-wgpu", "egui_plot", "flate2", "itertools", @@ -18,6 +20,8 @@ dependencies = [ "log", "notify", "png", + "pollster", + "raw-window-handle", "regex", "reqwest", "rfd", @@ -26,6 +30,7 @@ dependencies = [ "serde_json", "simplelog", "smallvec", + "smithay-client-toolkit 0.19.2", "timer", "winres", ] @@ -2172,6 +2177,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" +dependencies = [ + "libc", +] + [[package]] name = "memmap2" version = "0.9.10" @@ -3476,7 +3490,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "ab_glyph", "log", - "memmap2", + "memmap2 0.9.10", "smithay-client-toolkit 0.19.2", "tiny-skia", ] @@ -3675,12 +3689,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.11.1", + "bytemuck", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", - "memmap2", + "memmap2 0.9.10", + "pkg-config", "rustix 0.38.44", "thiserror 1.0.69", "wayland-backend", @@ -3690,6 +3706,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-wlr", "wayland-scanner", + "xkbcommon", "xkeysym", ] @@ -3705,7 +3722,7 @@ dependencies = [ "cursor-icon", "libc", "log", - "memmap2", + "memmap2 0.9.10", "rustix 1.1.4", "thiserror 2.0.18", "wayland-backend", @@ -5097,7 +5114,7 @@ dependencies = [ "dpi", "js-sys", "libc", - "memmap2", + "memmap2 0.9.10", "ndk", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -5284,6 +5301,17 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +[[package]] +name = "xkbcommon" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e" +dependencies = [ + "libc", + "memmap2 0.8.0", + "xkeysym", +] + [[package]] name = "xkbcommon-dl" version = "0.4.2" @@ -5302,6 +5330,9 @@ name = "xkeysym" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +dependencies = [ + "bytemuck", +] [[package]] name = "xml-rs" diff --git a/Cargo.toml b/Cargo.toml index 2262e3f..7438850 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,5 +42,14 @@ flate2 = "1" [profile.release] lto = "thin" +[target.'cfg(target_os = "linux")'.dependencies] +# Wayland layer-shell overlay: always-on-top over full-screen games, which the +# winit/eframe path cannot do on Wayland. Versions matched to eframe 0.34. +smithay-client-toolkit = "0.19" +egui = "0.34" +egui-wgpu = "0.34" +raw-window-handle = "0.6" +pollster = "0.4" + [target.'cfg(windows)'.build-dependencies] winres = "0.1" diff --git a/docs/OVERLAY.md b/docs/OVERLAY.md new file mode 100644 index 0000000..bfe2713 --- /dev/null +++ b/docs/OVERLAY.md @@ -0,0 +1,254 @@ +# Combat overlay + +## Purpose + +The overlay is the always-on-top DPS/metrics panel a player keeps in front of +the game while fighting. It mirrors the newest combat and must float above a +full-screen game window. This document covers how it is rendered, why there are +two rendering back ends, and the invariants that keep the Wayland back end from +crashing. + +Owned by `src/app/overlay.rs` (`Overlay`, the app-side controller) and +`src/app/layer_overlay.rs` (`LayerOverlay`, the Linux/Wayland surface). Driven +from the main tab UI via `Overlay::show()`. + +## Context + +The app is a single `eframe`/`egui` process (`src/app/mod.rs`). The main window +runs on the main thread. The overlay needs to be a *separate* top-level surface +that stays above the game. + +``` + main eframe/egui process + ┌───────────────────────────────────────────────────────────┐ + │ App (main window) │ + │ └─ Overlay ── Arc> │ + │ │ │ + │ platform split in Overlay::show() │ + │ │ │ + │ ┌──────┴───────────────┐ │ + │ │ │ │ + │ non-Linux Linux │ + │ eframe deferred LayerOverlay ── calloop channel ───► │ + │ viewport (handle) │ + └───────────────────────────────────────────────────────────┘ + │ + cla-layer-overlay thread (Linux only) + wlr-layer-shell surface + wgpu + egui +``` + +## Why two back ends + +`Overlay::show()` (`src/app/overlay.rs:210`) branches on +`#[cfg(target_os = "linux")]`. + +| Target | Back end | Mechanism | +|---|---|---| +| Windows / other | eframe **deferred viewport** | `show_viewport_deferred` (`overlay.rs:292`) with `.with_always_on_top()` | +| Linux | **wlr-layer-shell** surface on its own thread | `src/app/layer_overlay.rs` | + +The split exists because on **Wayland** a normal top-level window (what an +eframe viewport is, via `winit`) cannot force itself above a full-screen game: +the always-on-top hint is advisory and compositors (KWin included) ignore it. +The `wlr-layer-shell` protocol's `overlay` layer *is* honored, so on Linux the +overlay is a layer surface instead of a viewport. The two back ends render the +same content (see [Unified styling](#unified-styling)) but share no windowing +code. + +The non-Linux path is unchanged legacy behavior and is not described further +here; read `OverlayInner::show_overlay()` (`overlay.rs:315`) for it. + +## Invariants + +- **I1 — Drop order (Linux).** In `run()` the wgpu surface (`State.gpu`) is + built from raw handles of the `wl_surface`/`Connection`. It MUST be dropped + before them, or wgpu tears down a surface backed by a destroyed object and + the process segfaults. Enforced two ways: `gpu` is the first field of `State` + (fields drop in declaration order, `layer_overlay.rs:205`) and `run()` + explicitly sets `app.gpu = None` before returning (`layer_overlay.rs:192`). +- **I2 — Non-zero surface size.** Wayland rejects a 0×0 geometry. The surface + never requests below `MIN_W`×`MIN_H` (240×80); the auto-size clamps to it. +- **I3 — Passthrough by default.** Out of move mode the surface carries an + empty input region, so clicks reach the game underneath. See + [Move mode](#move-mode-and-input-passthrough). +- **I4 — All overlay-thread data is `Send` + plain.** Only `OverlayData` + (`Vec` columns + rows) crosses the channel; no `egui`/`Combat` types. + +## Data flow + +Combat data originates in the analyzer and reaches the overlay through an +`AnalysisHandler` (a per-consumer subscription to refreshed combats). On Linux +the formatted snapshot is then handed to the layer thread over a calloop +channel. + +``` + analyzer ─► AnalysisHandler ─► OverlayInner.poll_update() (overlay.rs:409) + │ AnalysisInfo::Refreshed + ▼ + OverlayInner.perform_update() (overlay.rs:431) + │ build DisplayData (sorted rows, + │ formatted strings, enabled columns) + ▼ + Linux only: OverlayInner.to_overlay_data() (overlay.rs:384) + │ OverlayData { columns, rows } (plain) + ▼ + LayerOverlay.update(data) ── Msg::Data ──► calloop channel + LayerOverlay.set_move(f) ── Msg::Move ──► │ + ▼ + cla-layer-overlay thread: State +``` + +`Overlay::show()` pumps this every frame while the overlay is visible and asks +the main context to repaint every 500 ms (`overlay.rs:269`) so fresh data keeps +flowing to the thread. `set_move` is sent every frame too; the thread ignores +it unless the flag actually changed (`layer_overlay.rs`, `Msg::Move` handler). + +### Message contract + +`enum Msg` (`layer_overlay.rs`) is the only thing crossing the thread boundary: + +| Variant | Payload | Effect on the layer thread | +|---|---|---| +| `Data` | `OverlayData` | replace displayed rows, request redraw | +| `Move` | `bool` | toggle move mode → swap input region | +| `Stop` | — | leave the event loop, tear down (I1) | + +A closed channel (`ChannelEvent::Closed`) is treated as `Stop`. + +## Components + +| File / symbol | Responsibility | Called by | +|---|---|---| +| `overlay.rs` `Overlay` | app-side controller, platform split, UI buttons | main tab UI | +| `overlay.rs` `OverlayInner` | polls analyzer, builds `DisplayData`, owns the `LayerOverlay` handle | `Overlay` | +| `layer_overlay.rs` `LayerOverlay` | thread handle: `spawn`/`update`/`set_move`/`stop`; stops thread on `Drop` | `OverlayInner` | +| `layer_overlay.rs` `run()` | thread body: Wayland globals, event loop, redraw loop | `spawn` | +| `layer_overlay.rs` `State` | per-surface state: wgpu, egui, geometry, pointer/drag | delegated handlers | +| `custom_widgets/table.rs` `Table` | shared table widget used by both back ends | both render paths | + +The `LayerOverlay` handle lives in `OverlayInner.layer` +(`Option`). It is created lazily on first visible frame +(`overlay.rs:258`) and dropped when the overlay is hidden +(`toggle_show()`, `overlay.rs:374`), which stops the thread. + +## Layer thread internals (Linux) + +`run()` (`layer_overlay.rs:121`) sets up a self-contained Wayland client using +`smithay-client-toolkit` (SCTK): + +1. Connect, bind globals: `CompositorState`, `LayerShell`, `Shm`, `SeatState`. +2. Create a surface, wrap it as an `overlay`-layer `LayerSurface` anchored + `TOP | LEFT`, keyboard interactivity `None`. +3. Set the initial (empty) input region via `apply_input_region()` (I3). +4. Build a calloop `EventLoop`, feed it the Wayland source and the `Msg` + channel receiver. +5. Loop: `dispatch(16 ms)` then `render()` when `needs_redraw`; exit on `stop`. +6. On exit, drop `gpu` explicitly (I1). + +### wgpu surface from raw handles + +egui needs a wgpu surface, but SCTK owns the `wl_surface`. `State::init_gpu()` +(`layer_overlay.rs:260`) bridges them: it reads the `wl_display` pointer from +the connection backend and the `wl_surface` pointer from the layer, wraps them +as `raw-window-handle` `Wayland*Handle`s, and calls +`Instance::create_surface_unsafe`. This is the coupling that makes I1 mandatory. + +```rust +// src/app/layer_overlay.rs (init_gpu, elided) +let display_ptr = NonNull::new(self.conn.backend().display_ptr() as *mut _)...; +let surface_ptr = NonNull::new(self.layer.wl_surface().id().as_ptr() as *mut _)...; +let raw_display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(display_ptr)); +let raw_window = RawWindowHandle::Wayland(WaylandWindowHandle::new(surface_ptr)); +// instance.create_surface_unsafe(RawHandle { raw_display, raw_window }) +``` + +### Render loop and auto-size + +`State::render()` (`layer_overlay.rs:323`) runs egui headless +(`egui::Context::run` with a manual `RawInput` screen rect), tessellates, and +submits via `egui_wgpu::Renderer`. `pixels_per_point` is fixed at `1.0`. + +The surface sizes itself to its content: the `Table` returns its rect, and the +required size (plus margins) is clamped to `MIN_W`×`MIN_H` (I2). When it differs +from the current size, `render()` calls `layer.set_size()` + `commit()` and +requests another redraw. Because the `Table` measures column widths over a few +frames, `render()` also forces a redraw while +`egui_ctx.has_requested_repaint()` is true, so the size settles. + +### Failure modes + +| Symptom | Cause | Where to look | +|---|---|---| +| Segfault when hiding/closing the overlay | I1 violated (surface dropped before wgpu) | field order in `State`, `app.gpu = None` in `run()` | +| `layer overlay: ...` logged, no overlay | Wayland connect/bind failed (not a Wayland session, no layer-shell) | `run()` return path, `spawn()` error log | +| Overlay eats clicks meant for the game | input region left non-empty (I3) | `apply_input_region()`, move-mode plumbing | +| Overlay stuck at 240×80 or oversized | auto-size not converging | `render()` size block, `Table::size()` | + +## Move mode and input passthrough + +`LayerSurface` cannot be dragged like an xdg-toplevel, so movement is +implemented with the input region + surface margins. + +- **Input region** (`apply_input_region()`, `layer_overlay.rs:239`): in move + mode the whole surface takes pointer input (`set_input_region(None)`); out of + move mode an empty `Region` is attached so clicks fall through (I3). +- **Pointer** is obtained through `SeatHandler` when a seat advertises the + pointer capability; `PointerHandler::pointer_frame` + (`layer_overlay.rs:539`) tracks position and left-button drag. +- **Drag** (`drag_to()`, `layer_overlay.rs:251`): on left-button press the + surface-local pointer position becomes the `grab` point. Each motion adjusts + the `(top, left)` margin by `pointer - grab`, then `set_margin` + `commit`. + +Why margins from a fixed anchor rather than absolute placement: layer surfaces +have no client-set absolute position; the offset from an anchor is the only +positioning lever. The grab point stays valid across the move because after the +commit the surface origin has shifted by the same delta, so the next +surface-local pointer coordinate re-references to the original `grab` — the +margins self-correct rather than drift. + +`move_mode` is driven entirely by the app: the ✋ button toggles +`OverlayInner.move_around`, which is pushed via `set_move()` each frame. It +starts `true` so a freshly shown overlay can be positioned before being locked +down. + +## Unified styling + +Both back ends render with the same `Table` widget +(`src/custom_widgets/table.rs`) rather than duplicating layout. The layer +thread uses it inside its headless egui context exactly as the viewport path +does (`Table::new(ui).header(...).body(...)`), which is possible because +`eframe` re-exports the same `egui` crate the layer thread depends on directly +(a single `egui 0.34` in the lockfile). The header/body row heights (15/25) and +right-aligned value cells match the viewport overlay, so the two look +identical. + +## Testing + +`layer_overlay.rs` has one `#[ignore]` integration test, `spawn_render_stop`, +that spawns the overlay, pushes a row, toggles move mode both ways, and stops — +exercising the I1 teardown path and the input-region swap. It needs a real +Wayland session and briefly shows the overlay: + +``` +cargo test spawn_render_stop -- --ignored +``` + +## Decisions and trade-offs + +- **Separate thread for the layer surface.** The layer surface runs its own + Wayland connection and calloop loop instead of sharing the main `winit` + event loop. Reason: eframe/winit does not expose layer-shell, and driving a + second Wayland client on the main loop would entangle it with eframe's. Cost: + a thread and the `Msg` channel; benefit: the two back ends stay fully + decoupled and the main loop is untouched. +- **Plain `OverlayData` over the channel (I4).** Formatting and sorting happen + on the main side (`perform_update`), so the layer thread only lays out + strings. Keeps `egui`/`Combat`/`Settings` off the thread boundary. +- **Margins for movement, not absolute coordinates.** Forced by the protocol; + see [Move mode](#move-mode-and-input-passthrough). + +## Open questions + +1. Multi-output placement — the surface is not pinned to a chosen monitor; the + compositor picks the output. Needs an output-selection story if users run + multiple monitors. Not blocking current single-overlay use. diff --git a/src/app/mod.rs b/src/app/mod.rs index ff801d5..515e16e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -22,6 +22,11 @@ mod state; mod status; mod summary_copy; +// The layer-shell overlay backend lives under `overlay::layer_shell`; re-export +// the startup helper so main.rs can build the shared wgpu stack (see main.rs). +#[cfg(target_os = "linux")] +pub use overlay::layer_shell::create_shared_gpu; + pub struct App { settings_window: SettingsWindow, combats: Vec, @@ -33,16 +38,31 @@ pub struct App { upload: Upload, records: Records, state: AppState, + // Deferred persistence of the window size: written once resizing settles + // (see track_window_geometry). + window_geometry_dirty: bool, + last_geometry_change: f64, +} + +/// Window geometry to restore at startup: last size (points) and whether the +/// window was maximized. Read before the viewport is built (see main.rs). +pub fn saved_window_geometry() -> (Option, bool) { + let settings = Settings::load_or_default(); + let size = settings.general.window_size.map(|[w, h]| vec2(w, h)); + (size, settings.general.window_maximized) } impl App { - pub fn new(cc: &eframe::CreationContext) -> Self { + pub fn new( + cc: &eframe::CreationContext, + overlay_instance: Option, + ) -> Self { cc.egui_ctx .memory_mut(|m| m.options.repaint_on_widget_change = false); let state = AppState::new(&cc.egui_ctx); let settings_window = SettingsWindow::new(&cc.egui_ctx, cc.egui_ctx.native_pixels_per_point()); - Self { + let app = Self { settings_window, combats: Default::default(), selected_combat_index: None, @@ -53,13 +73,40 @@ impl App { upload: Default::default(), records: Default::default(), state, + window_geometry_dirty: false, + last_geometry_change: 0.0, + }; + + // On Linux, hand the layer-shell overlay the shared wgpu handles: the + // instance we created up front (passed in) plus eframe's + // adapter/device/queue — which, thanks to WgpuSetup::Existing, are the + // very ones we handed eframe. So both render through one device. + #[cfg(target_os = "linux")] + if let (Some(instance), Some(render_state)) = (overlay_instance, cc.wgpu_render_state.as_ref()) + { + app.state.overlay.set_gpu(overlay::layer_shell::OverlayGpu { + instance, + adapter: render_state.adapter.clone(), + device: render_state.device.clone(), + queue: render_state.queue.clone(), + }); } + #[cfg(not(target_os = "linux"))] + let _ = overlay_instance; + + app } } impl eframe::App for App { fn ui(&mut self, ui: &mut eframe::egui::Ui, frame: &mut eframe::Frame) { self.handle_analysis_infos(); + self.track_window_geometry(ui.ctx()); + // Remember where the overlay was dragged (persisted on exit). + #[cfg(target_os = "linux")] + if let Some(position) = self.state.overlay.position() { + self.state.settings.general.overlay_position = Some(position); + } CentralPanel::default().show_inside(ui, |ui| { ui.vertical(|ui| { ui.horizontal(|ui| { @@ -160,9 +207,59 @@ impl eframe::App for App { fn clear_color(&self, _visuals: &eframe::egui::Visuals) -> [f32; 4] { _visuals.window_fill().to_normalized_gamma_f32() } + + fn on_exit(&mut self) { + // Backup flush of the latest geometry on close (see track_window_geometry). + self.state.settings.save(); + } } impl App { + /// Remembers the main window's size and maximized state so the next launch + /// restores them (see main.rs). The size comes from the egui viewport rect + /// because on Wayland the OS-reported `inner_rect` is `None`. The settings + /// file is written only once the size has settled (no change for a moment), + /// never while the edge is being dragged, so resizing stays smooth. + fn track_window_geometry(&mut self, ctx: &eframe::egui::Context) { + let now = ctx.input(|i| i.time); + let maximized = ctx.input(|i| i.viewport().maximized); + let size = ctx.viewport_rect().size(); + + // Only remember the windowed size, so un-maximizing restores something + // sane rather than the full-screen size. + if maximized != Some(true) { + let size = [size.x, size.y]; + if self.state.settings.general.window_size != Some(size) { + self.state.settings.general.window_size = Some(size); + self.window_geometry_dirty = true; + self.last_geometry_change = now; + } + } + if let Some(maximized) = maximized { + if self.state.settings.general.window_maximized != maximized { + self.state.settings.general.window_maximized = maximized; + self.window_geometry_dirty = true; + self.last_geometry_change = now; + } + } + + if self.window_geometry_dirty { + let idle = now - self.last_geometry_change; + if idle >= 2.0 { + // Settled for 2 s: write once, off the resize hot path. + self.state.settings.save(); + self.window_geometry_dirty = false; + } else if idle < 0.5 { + // Actively resizing: keep redrawing every frame so the content + // tracks the window instead of lagging behind the drag. + ctx.request_repaint(); + } else { + // Idle but not yet settled: check again to flush the size. + ctx.request_repaint_after(std::time::Duration::from_millis(300)); + } + } + } + fn handle_analysis_infos(&mut self) { let combatlog_file = &self.state.settings.analysis.combatlog_file; for info in self.state.analysis_handler.check_for_info() { diff --git a/src/app/overlay/layer_shell.rs b/src/app/overlay/layer_shell.rs new file mode 100644 index 0000000..cea3bfc --- /dev/null +++ b/src/app/overlay/layer_shell.rs @@ -0,0 +1,1042 @@ +//! Wayland layer-shell overlay (Linux). +//! +//! On Wayland a normal (winit) window cannot force itself above a full-screen +//! game — the always-on-top hint is ignored. The `wlr-layer-shell` "overlay" +//! layer *is* honored by the compositor (KWin included), so the overlay runs +//! here as a layer surface rendered with egui via wgpu, on its own thread, fed +//! data from the main app over a calloop channel. +//! +//! Public API: [`LayerOverlay::spawn`], [`LayerOverlay::update`], +//! [`LayerOverlay::stop`]. The struct stops the thread on drop. + +use std::ptr::NonNull; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use egui_wgpu::wgpu; +use raw_window_handle::{ + RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, +}; +use smithay_client_toolkit::{ + compositor::{CompositorHandler, CompositorState, Region}, + delegate_compositor, delegate_layer, delegate_output, delegate_pointer, delegate_registry, + delegate_seat, delegate_shm, + output::{OutputHandler, OutputState}, + registry::{ProvidesRegistryState, RegistryState}, + registry_handlers, + seat::{ + pointer::{PointerEvent, PointerEventKind, PointerHandler}, + Capability, SeatHandler, SeatState, + }, + shell::{ + wlr_layer::{ + Anchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler, LayerSurface, + LayerSurfaceConfigure, + }, + WaylandSurface, + }, + shm::{Shm, ShmHandler}, +}; +use smithay_client_toolkit::reexports::protocols::wp::relative_pointer::zv1::client::{ + zwp_relative_pointer_manager_v1::ZwpRelativePointerManagerV1, + zwp_relative_pointer_v1::{self, ZwpRelativePointerV1}, +}; +use smithay_client_toolkit::reexports::calloop::{ + channel::{channel, Channel, Event as ChannelEvent, Sender}, + EventLoop, +}; +use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; +use smithay_client_toolkit::reexports::client::{ + globals::registry_queue_init, + protocol::{wl_output, wl_pointer, wl_seat, wl_surface}, + Connection, Dispatch, Proxy, QueueHandle, +}; + +use crossbeam_channel::{unbounded, Receiver as EventRx, Sender as EventTx}; + +use crate::custom_widgets::table::Table; + +/// Snapshot of what the overlay should display. Plain, `Send` data. +#[derive(Clone, Default)] +pub struct OverlayData { + pub columns: Vec, + pub rows: Vec, + /// Every configurable column with its enabled flag, for the ⛭ popup on the + /// overlay. Order matches the app's column list so `ToggleColumn(i)` maps + /// straight back. See [`OverlayEvent`]. + pub all_columns: Vec<(String, bool)>, +} + +#[derive(Clone)] +pub struct OverlayRow { + pub name: String, + pub values: Vec, +} + +/// Sent from the overlay thread back to the app (the overlay owns its own +/// toolbar now). The app applies these and recomputes the displayed data. +pub enum OverlayEvent { + ToggleColumn(usize), +} + +enum Msg { + Data(OverlayData), + Style(Arc), + Stop, +} + +/// Handle to the overlay thread held by the main app. +pub struct LayerOverlay { + tx: Sender, + join: Option>, + /// Current (top, left) anchor margin, updated by the thread as the user + /// drags, so the app can persist the position. See [`LayerOverlay::spawn`]. + position: Arc>, + /// Events raised by the overlay's own toolbar (e.g. column toggles). + events: EventRx, +} + +/// The wgpu handles shared by eframe's main window and the layer-shell overlay, +/// so the app runs a single Vulkan stack instead of two. Created once at startup +/// (see [`create_shared_gpu`]) and handed to eframe via `WgpuSetup::Existing`. +/// All fields are cheap `Arc`-backed clones and are `Send + Sync`, so they cross +/// into the overlay thread freely. +#[derive(Clone)] +pub struct OverlayGpu { + pub instance: wgpu::Instance, + pub adapter: wgpu::Adapter, + pub device: wgpu::Device, + pub queue: wgpu::Queue, +} + +/// Creates the one wgpu instance/adapter/device/queue the whole app uses. The +/// handles go to eframe (`WgpuSetup::Existing`) for the main window and to the +/// layer-shell overlay via [`OverlayGpu`], so both render through the same +/// device — no second instance, and no Vulkan-teardown races on overlay close. +pub fn create_shared_gpu() -> OverlayGpu { + let instance = wgpu::Instance::default(); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + ..Default::default() + })) + .expect("no wgpu adapter for the shared overlay/main-window device"); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("sto-cla-shared"), + // Mirror egui-wgpu's default device limits so eframe accepts the device + // we hand it (it wants a 4k+ capable max_texture_dimension_2d). + required_limits: wgpu::Limits { + max_texture_dimension_2d: 8192, + ..wgpu::Limits::default() + }, + ..Default::default() + })) + .expect("no wgpu device for the shared overlay/main-window device"); + OverlayGpu { + instance, + adapter, + device, + queue, + } +} + +impl LayerOverlay { + /// The [`OverlayGpu`] handles are created once by the app and shared with + /// eframe's main-window renderer, so the overlay never spins up a second + /// Vulkan stack. They outlive every show/hide cycle; this thread only holds + /// clones, so closing the overlay tears down just its surface, never the + /// shared device. + pub fn spawn(gpu: OverlayGpu, initial_position: (i32, i32)) -> Self { + let (tx, rx) = channel::(); + let (events_tx, events) = unbounded::(); + let position = Arc::new(Mutex::new(initial_position)); + let thread_position = Arc::clone(&position); + let join = std::thread::Builder::new() + .name("cla-layer-overlay".into()) + .spawn(move || { + if let Err(e) = run(rx, gpu, thread_position, events_tx) { + log::error!("layer overlay: {e}"); + } + }) + .ok(); + Self { + tx, + join, + position, + events, + } + } + + /// Current (top, left) anchor margin, for persisting the overlay position. + pub fn position(&self) -> (i32, i32) { + *self.position.lock().unwrap() + } + + /// Drains events raised by the overlay's toolbar since the last call. + pub fn poll_events(&self) -> Vec { + self.events.try_iter().collect() + } + + pub fn update(&self, data: OverlayData) { + let _ = self.tx.send(Msg::Data(data)); + } + + /// Match the overlay to the main window by pushing its full egui style + /// (colors, spacing, text styles), so both look identical. + pub fn set_style(&self, style: Arc) { + let _ = self.tx.send(Msg::Style(style)); + } + + pub fn stop(&mut self) { + let _ = self.tx.send(Msg::Stop); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +impl Drop for LayerOverlay { + fn drop(&mut self) { + self.stop(); + } +} + +const MIN_W: u32 = 240; +const MIN_H: u32 = 80; + +/// Linux input event code for the left mouse button (`BTN_LEFT`). +const BTN_LEFT: u32 = 0x110; + +fn run( + rx: Channel, + gpu: OverlayGpu, + position: Arc>, + events_tx: EventTx, +) -> Result<(), Box> { + let OverlayGpu { + instance, + adapter, + device, + queue, + } = gpu; + let conn = Connection::connect_to_env()?; + let (globals, event_queue) = registry_queue_init(&conn)?; + let qh = event_queue.handle(); + + let compositor = CompositorState::bind(&globals, &qh)?; + let layer_shell = LayerShell::bind(&globals, &qh)?; + let shm = Shm::bind(&globals, &qh)?; + + // Restore the saved (top, left) offset from the TOP|LEFT anchor. + let margin = *position.lock().unwrap(); + let surface = compositor.create_surface(&qh); + let layer = + layer_shell.create_layer_surface(&qh, surface, Layer::Overlay, Some("sto-cla-overlay"), None); + layer.set_anchor(Anchor::TOP | Anchor::LEFT); + layer.set_margin(margin.0, 0, 0, margin.1); + layer.set_size(MIN_W * 2, MIN_H * 2); + layer.set_keyboard_interactivity(KeyboardInteractivity::None); + // Ignore other surfaces' exclusive zones and reserve none of our own, so + // dragging only moves us (the exclusive zone includes the margin, and an + // auto/default zone makes the compositor reflow on every move -> jitter). + layer.set_exclusive_zone(-1); + layer.commit(); + + let mut app = State { + registry_state: RegistryState::new(&globals), + output_state: OutputState::new(&globals, &qh), + seat_state: SeatState::new(&globals, &qh), + shm, + compositor, + layer, + conn: conn.clone(), + instance, + adapter, + device, + queue, + width: MIN_W * 2, + height: MIN_H * 2, + gpu: None, + data: OverlayData::default(), + needs_redraw: true, + stop: false, + pointer: None, + rel_manager: globals + .bind::(&qh, 1..=1, ()) + .ok(), + rel_pointer: None, + move_mode: false, + dragging: false, + pointer_pos: (0.0, 0.0), + drag_delta: (0.0, 0.0), + margin, + output_size: None, + position, + style: None, + style_dirty: false, + events_tx, + settings_open: false, + egui_events: Vec::new(), + pointer_on_toolbar: false, + toolbar_rect: (0, 0, 0, 0), + }; + // Start passive: only the toolbar strip catches clicks; the rest passes + // through to the game until "move" mode or the settings popup is on. + app.apply_input_region(); + + let mut event_loop: EventLoop = EventLoop::try_new()?; + let handle = event_loop.handle(); + WaylandSource::new(conn, event_queue).insert(handle.clone())?; + handle + .insert_source(rx, |event, _, app: &mut State| match event { + ChannelEvent::Msg(Msg::Data(data)) => { + app.data = data; + app.needs_redraw = true; + } + ChannelEvent::Msg(Msg::Style(style)) => { + if app.style.is_none() { + app.needs_redraw = true; // first theme: draw with it right away + } + app.style = Some(style); + app.style_dirty = true; + } + ChannelEvent::Msg(Msg::Stop) => app.stop = true, + ChannelEvent::Closed => app.stop = true, + }) + .map_err(|e| format!("insert channel source: {e}"))?; + + while !app.stop { + event_loop.dispatch(Some(std::time::Duration::from_millis(16)), &mut app)?; + if app.needs_redraw { + // Reset before rendering: render() may set it again (toolbar toggle, + // table settling) to request another frame right away. Resetting + // after would clobber that and defer the redraw to the next event. + app.needs_redraw = false; + app.render(); + } + } + // Drop the overlay's wgpu surface before the wl_surface / connection it was + // built from, to avoid a use-after-free. The device/queue are shared clones, + // so this tears down only the surface, never the shared device. + app.gpu = None; + Ok(()) +} + +struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + egui_ctx: egui::Context, + egui_renderer: egui_wgpu::Renderer, +} + +struct State { + // `gpu` holds a wgpu surface built from the raw `conn`/`layer` handles, so + // it MUST be dropped before them — struct fields drop in declaration order, + // so keep it first. Dropping the wl_surface first would leave wgpu tearing + // down a surface backed by a destroyed object (segfault on teardown). + gpu: Option, + registry_state: RegistryState, + output_state: OutputState, + seat_state: SeatState, + shm: Shm, + compositor: CompositorState, + layer: LayerSurface, + conn: Connection, + // wgpu handles shared with eframe's main-window renderer (see + // create_shared_gpu). `instance` builds the layer-shell surface; the + // adapter/device/queue are the very ones eframe uses, so there is a single + // Vulkan stack. These are clones, so closing the overlay drops only its + // surface, never the shared device. + instance: wgpu::Instance, + adapter: wgpu::Adapter, + device: wgpu::Device, + queue: wgpu::Queue, + width: u32, + height: u32, + data: OverlayData, + needs_redraw: bool, + stop: bool, + // "Move" mode: when on, the surface catches pointer input and a left-button + // drag repositions it; when off, an empty input region lets clicks fall + // through to the game. `margin` is the (top, left) offset from the TOP|LEFT + // anchor; `grab` is the surface-local point grabbed at drag start. + pointer: Option, + // Relative-pointer motion (independent of the surface position) drives the + // drag, so moving the surface can't feed back into the reported coordinates + // and make it jitter. `drag_delta` accumulates sub-pixel motion between + // frames; the whole-pixel part is applied to the margin each render. + rel_manager: Option, + rel_pointer: Option, + move_mode: bool, + dragging: bool, + pointer_pos: (f64, f64), + drag_delta: (f64, f64), + margin: (i32, i32), + // Logical size of the output the overlay is on, to keep it on-screen. + output_size: Option<(i32, i32)>, + // Shared with the app handle so it can persist the dragged position. + position: Arc>, + // Full egui style pushed from the main app to match the main window; + // applied to the overlay's egui context on the next render when `style_dirty`. + style: Option>, + style_dirty: bool, + // Toolbar (⛭/✋) lives on the overlay itself. `events_tx` reports column + // toggles back to the app; `settings_open` is the ⛭ popup state (also + // widens the input region); `egui_events` are pointer events fed into egui + // so its buttons work; `pointer_on_toolbar` gates dragging vs clicking. + events_tx: EventTx, + settings_open: bool, + egui_events: Vec, + pointer_on_toolbar: bool, + // The icon toolbar's rect in surface pixels, measured each render, so the + // input region and the drag/click hit-test match the icons exactly. + toolbar_rect: (i32, i32, i32, i32), +} + +/// Height (points, = pixels at scale 1) of the always-clickable toolbar strip +/// at the bottom of the overlay. +const TOOLBAR_H: u32 = 26; + +impl State { + /// Set the surface's input region for the current mode: the whole surface + /// while moving or with the settings popup open (so drags and popup clicks + /// land), otherwise just the top toolbar strip (its ⛭/✋ buttons stay + /// clickable while the rest passes clicks through to the game). + fn apply_input_region(&self) { + let surface = self.layer.wl_surface(); + if self.move_mode || self.settings_open { + surface.set_input_region(None); + } else if let Ok(region) = Region::new(&self.compositor) { + let (x, y, w, h) = self.toolbar_rect; + if w > 0 && h > 0 { + region.add(x, y, w, h); + } else { + // Before the first render: a bottom strip as a fallback. + let top = self.height.saturating_sub(TOOLBAR_H) as i32; + region.add(0, top, self.width.max(1) as i32, TOOLBAR_H as i32); + } + surface.set_input_region(Some(region.wl_region())); + } + surface.commit(); + } + + /// Applies the accumulated relative-pointer motion to the margin (once per + /// frame). Uses raw pointer deltas, so moving the surface never changes the + /// input and the drag stays jitter-free. The margin is only set pending; the + /// frame's wgpu present commits it together with the buffer (atomic move). + fn apply_drag(&mut self) { + let (max_left, max_top) = self.max_margin(); + let ix = self.drag_delta.0.trunc() as i32; + let iy = self.drag_delta.1.trunc() as i32; + if ix == 0 && iy == 0 { + return; + } + self.drag_delta.0 -= ix as f64; + self.drag_delta.1 -= iy as f64; + self.margin.1 = (self.margin.1 + ix).clamp(0, max_left); + self.margin.0 = (self.margin.0 + iy).clamp(0, max_top); + self.layer.set_margin(self.margin.0, 0, 0, self.margin.1); + *self.position.lock().unwrap() = self.margin; + } + + /// Largest (left, top) margin that keeps the overlay on its output; falls + /// back to unrestricted when the output size isn't known yet. + fn max_margin(&self) -> (i32, i32) { + match self.output_size { + Some((ow, oh)) => ( + (ow - self.width as i32).max(0), + (oh - self.height as i32).max(0), + ), + None => (i32::MAX, i32::MAX), + } + } + + /// Clamps the margin onto the output; returns whether it changed. + fn clamp_margin(&mut self) -> bool { + let (max_left, max_top) = self.max_margin(); + let clamped = (self.margin.0.clamp(0, max_top), self.margin.1.clamp(0, max_left)); + if clamped != self.margin { + self.margin = clamped; + true + } else { + false + } + } + + fn commit_margin(&mut self) { + self.layer.set_margin(self.margin.0, 0, 0, self.margin.1); + self.layer.commit(); + *self.position.lock().unwrap() = self.margin; + } + + fn init_gpu(&mut self) { + let display_ptr = + NonNull::new(self.conn.backend().display_ptr() as *mut _).expect("null wl_display"); + let surface_ptr = + NonNull::new(self.layer.wl_surface().id().as_ptr() as *mut _).expect("null wl_surface"); + let raw_display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(display_ptr)); + let raw_window = RawWindowHandle::Wayland(WaylandWindowHandle::new(surface_ptr)); + + // Build the surface from the shared instance and reuse the shared + // adapter/device/queue — no second adapter/device request here. + let surface = unsafe { + self.instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(raw_display), + raw_window_handle: raw_window, + }) + .expect("create_surface") + }; + + let caps = surface.get_capabilities(&self.adapter); + let format = caps + .formats + .iter() + .copied() + .find(|f| f.is_srgb()) + .unwrap_or(caps.formats[0]); + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: self.width, + height: self.height, + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + }; + surface.configure(&self.device, &config); + let egui_renderer = + egui_wgpu::Renderer::new(&self.device, format, egui_wgpu::RendererOptions::default()); + + self.gpu = Some(Gpu { + device: self.device.clone(), + queue: self.queue.clone(), + surface, + config, + egui_ctx: egui::Context::default(), + egui_renderer, + }); + } + + fn render(&mut self) { + if self.gpu.is_none() { + self.init_gpu(); + } + // Apply the frame's accumulated relative-pointer motion to the position. + if self.dragging { + self.apply_drag(); + } + let (w, h) = (self.width.max(1), self.height.max(1)); + let data = self.data.clone(); + let move_mode = self.move_mode; + let settings_open = self.settings_open; + let input_events = std::mem::take(&mut self.egui_events); + // Adopt the main window's full style (colors, spacing, text) pushed via + // set_style, so the overlay matches the main window. + let new_style = self.style_dirty.then(|| self.style.clone()).flatten(); + self.style_dirty = false; + let gpu = self.gpu.as_mut().unwrap(); + if let Some(style) = new_style { + gpu.egui_ctx.set_global_style(style); + } + if gpu.config.width != w || gpu.config.height != h { + gpu.config.width = w; + gpu.config.height = h; + gpu.surface.configure(&gpu.device, &gpu.config); + } + + let ppp = 1.0; + let raw_input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::pos2(0.0, 0.0), + egui::vec2(w as f32, h as f32) / ppp, + )), + events: input_events, + ..Default::default() + }; + // Rendered with the same custom `Table` as the desktop overlay so both + // paths look identical. The overlay owns its toolbar (⛭ column config, + // ✋ move); clicks are collected here and applied after the frame. + let mut required = egui::Vec2::ZERO; + let mut toggle_move = false; + let mut toggle_settings = false; + let mut toggled_columns: Vec = Vec::new(); + let mut toolbar_rect = egui::Rect::NOTHING; + // apply_input_region borrows all of `self`; `gpu` is borrowed until the + // end of render, so defer it behind this flag. + let mut region_dirty = false; + let full = gpu.egui_ctx.run_ui(raw_input, |ui| { + let style = ui.ctx().global_style(); + let frame = egui::Frame::central_panel(&style) + .stroke(style.visuals.window_stroke) + .inner_margin(4.0); + egui::CentralPanel::default().frame(frame).show_inside(ui, |ui| { + // The refreshing part: the DPS table. Its measured rect drives + // the surface size (content-sized, unlike ui.min_rect() which + // includes fill-width widgets and makes the surface oscillate). + let table_rect = Table::new(ui) + .min_scroll_height(f32::MAX) + .header(15.0, |h| { + h.cell(|ui| { + ui.label("Player"); + }); + for c in &data.columns { + h.cell(|ui| { + ui.label(c); + }); + } + }) + .body(25.0, |t| { + for row in &data.rows { + t.row(|r| { + r.cell(|ui| { + ui.label(&row.name); + }); + for v in &row.values { + r.cell_with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label(v); + }, + ); + } + }); + } + }); + + // Column popup (when open) and the icon toolbar at the bottom, + // like the live parser. Measure their height via the cursor so + // the surface can size to table + toolbar without fill-width + // widgets feeding back into the width. + let bottom_start = ui.cursor().top(); + if settings_open { + for (index, (name, enabled)) in data.all_columns.iter().enumerate() { + let mut enabled = *enabled; + if ui.checkbox(&mut enabled, name).clicked() { + toggled_columns.push(index); + } + } + } + toolbar_rect = ui + .horizontal(|ui| { + // Square, fully-clickable icon buttons (the plain label + // only reacts on the glyph itself). + let icon = egui::vec2(TOOLBAR_H as f32 - 6.0, TOOLBAR_H as f32 - 6.0); + if ui + .add_sized(icon, egui::Button::selectable(settings_open, "⛭")) + .clicked() + { + toggle_settings = true; + } + if ui + .add_sized(icon, egui::Button::selectable(move_mode, "✋")) + .clicked() + { + toggle_move = true; + } + }) + .response + .rect; + let bottom_height = ui.cursor().top() - bottom_start; + + required = egui::vec2(table_rect.width(), table_rect.height() + bottom_height) + + ui.spacing().window_margin.left_top() + + ui.spacing().window_margin.right_bottom(); + }); + }); + + // Remember the toolbar's rect (points == pixels at scale 1), padded a + // little, so the input region and hit-test cover the icons exactly. + if toolbar_rect.is_finite() && toolbar_rect.area() > 0.0 { + let pad = 2.0; + self.toolbar_rect = ( + (toolbar_rect.min.x - pad).floor().max(0.0) as i32, + (toolbar_rect.min.y - pad).floor().max(0.0) as i32, + (toolbar_rect.width() + 2.0 * pad).ceil() as i32, + (toolbar_rect.height() + 2.0 * pad).ceil() as i32, + ); + } + + // Apply the toolbar interactions collected during the frame. + for index in toggled_columns { + let _ = self.events_tx.send(OverlayEvent::ToggleColumn(index)); + // Flip the checkbox locally so it reacts instantly; the app's + // authoritative update (which also refreshes the table columns) + // arrives within a refresh and matches. + if let Some(column) = self.data.all_columns.get_mut(index) { + column.1 = !column.1; + } + self.needs_redraw = true; + } + if toggle_settings { + self.settings_open = !self.settings_open; + } + if toggle_move { + self.move_mode = !self.move_mode; + self.dragging = false; + } + if toggle_settings || toggle_move { + region_dirty = true; + // Redraw now so the new state shows immediately instead of waiting + // for the next ~500 ms data update (perceived as a click lag). + self.needs_redraw = true; + } + + // The table measures column widths over a couple of frames; keep + // redrawing until they settle. + if gpu.egui_ctx.has_requested_repaint() { + self.needs_redraw = true; + } + + // Auto-size the layer surface to the content on the next commit. + let desired = ( + (required.x.ceil() as u32).max(MIN_W), + (required.y.ceil() as u32).max(MIN_H), + ); + if desired != (self.width, self.height) { + self.width = desired.0; + self.height = desired.1; + // Growing (e.g. opening the settings popup) near an edge would push + // the overlay off-screen; pull it back so it stays fully visible. + // Inlined (clamp_margin borrows all of self while gpu is borrowed). + if let Some((ow, oh)) = self.output_size { + self.margin.1 = self.margin.1.clamp(0, (ow - self.width as i32).max(0)); + self.margin.0 = self.margin.0.clamp(0, (oh - self.height as i32).max(0)); + } + self.layer.set_margin(self.margin.0, 0, 0, self.margin.1); + self.layer.set_size(self.width, self.height); + self.layer.commit(); + *self.position.lock().unwrap() = self.margin; + // The toolbar input strip spans the surface width; refresh it. + region_dirty = true; + self.needs_redraw = true; + } + + let clipped = gpu.egui_ctx.tessellate(full.shapes, ppp); + for (id, delta) in &full.textures_delta.set { + gpu.egui_renderer.update_texture(&gpu.device, &gpu.queue, *id, delta); + } + let frame = match gpu.surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(f) | wgpu::CurrentSurfaceTexture::Suboptimal(f) => f, + _ => { + gpu.surface.configure(&gpu.device, &gpu.config); + return; + } + }; + let view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default()); + let screen = egui_wgpu::ScreenDescriptor { + size_in_pixels: [w, h], + pixels_per_point: ppp, + }; + let mut encoder = gpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + let user_buffers = + gpu.egui_renderer + .update_buffers(&gpu.device, &gpu.queue, &mut encoder, &clipped, &screen); + { + let mut rpass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("egui"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.02, + g: 0.02, + b: 0.02, + a: 0.85, + }), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + gpu.egui_renderer.render(&mut rpass, &clipped, &screen); + } + gpu.queue + .submit(user_buffers.into_iter().chain(std::iter::once(encoder.finish()))); + frame.present(); + for id in &full.textures_delta.free { + gpu.egui_renderer.free_texture(id); + } + + // gpu is no longer borrowed here, so the whole-`self` input-region + // update (mode/size changed) can run. + if region_dirty { + self.apply_input_region(); + } + } +} + +impl LayerShellHandler for State { + fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) { + self.stop = true; + } + fn configure( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &LayerSurface, + configure: LayerSurfaceConfigure, + _: u32, + ) { + if configure.new_size.0 != 0 { + self.width = configure.new_size.0; + } + if configure.new_size.1 != 0 { + self.height = configure.new_size.1; + } + self.needs_redraw = true; + } +} + +impl CompositorHandler for State { + fn scale_factor_changed(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: i32) {} + fn transform_changed(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: wl_output::Transform) {} + fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: u32) {} + fn surface_enter(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: &wl_output::WlOutput) {} + fn surface_leave(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: &wl_output::WlOutput) {} +} + +impl OutputHandler for State { + fn output_state(&mut self) -> &mut OutputState { + &mut self.output_state + } + fn new_output(&mut self, _: &Connection, _: &QueueHandle, output: wl_output::WlOutput) { + self.learn_output(&output); + } + fn update_output(&mut self, _: &Connection, _: &QueueHandle, output: wl_output::WlOutput) { + self.learn_output(&output); + } + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} +} + +impl State { + /// Records the output's size and pulls the overlay back on-screen if a + /// stale (e.g. restored) margin left it off the edge. + fn learn_output(&mut self, output: &wl_output::WlOutput) { + if let Some(size) = self.output_state.info(output).and_then(|i| i.logical_size) { + self.output_size = Some(size); + if self.clamp_margin() { + self.commit_margin(); + } + } + } +} + +impl ShmHandler for State { + fn shm_state(&mut self) -> &mut Shm { + &mut self.shm + } +} + +impl SeatHandler for State { + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat_state + } + fn new_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} + fn new_capability( + &mut self, + _: &Connection, + qh: &QueueHandle, + seat: wl_seat::WlSeat, + capability: Capability, + ) { + if capability == Capability::Pointer && self.pointer.is_none() { + let pointer = self.seat_state.get_pointer(qh, &seat).ok(); + // Attach a relative pointer to the same wl_pointer for jitter-free + // dragging (raw motion deltas, independent of the surface position). + if let (Some(pointer), Some(manager)) = (&pointer, &self.rel_manager) { + self.rel_pointer = Some(manager.get_relative_pointer(pointer, qh, ())); + } + self.pointer = pointer; + } + } + fn remove_capability( + &mut self, + _: &Connection, + _: &QueueHandle, + _: wl_seat::WlSeat, + capability: Capability, + ) { + if capability == Capability::Pointer { + if let Some(pointer) = self.pointer.take() { + pointer.release(); + } + } + } + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} +} + +impl PointerHandler for State { + fn pointer_frame( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_pointer::WlPointer, + events: &[PointerEvent], + ) { + for event in events { + let pos = event.position; + let (tx, ty, tw, th) = self.toolbar_rect; + let on_toolbar = tw > 0 + && th > 0 + && pos.0 >= tx as f64 + && pos.0 < (tx + tw) as f64 + && pos.1 >= ty as f64 + && pos.1 < (ty + th) as f64; + match event.kind { + PointerEventKind::Enter { .. } | PointerEventKind::Motion { .. } => { + self.pointer_pos = pos; + self.pointer_on_toolbar = on_toolbar; + self.egui_events.push(egui::Event::PointerMoved(egui_pos(pos))); + // Actual dragging happens once per rendered frame (see + // render); doing it per motion event double-counts the + // async coordinate shift and flings the surface around. + } + PointerEventKind::Leave { .. } => { + self.egui_events.push(egui::Event::PointerGone); + } + PointerEventKind::Press { button, .. } if button == BTN_LEFT => { + // In move mode a press on empty space starts a drag; presses + // on the toolbar, or anywhere while the settings popup is + // open, go to egui so its buttons/checkboxes work. + if self.move_mode && !on_toolbar && !self.settings_open { + self.dragging = true; + self.drag_delta = (0.0, 0.0); + } else { + self.egui_events.push(pointer_button(pos, true)); + } + } + PointerEventKind::Release { button, .. } if button == BTN_LEFT => { + if self.dragging { + self.dragging = false; + } else { + self.egui_events.push(pointer_button(pos, false)); + } + } + _ => {} + } + } + self.needs_redraw = true; + } +} + +fn egui_pos(pos: (f64, f64)) -> egui::Pos2 { + egui::pos2(pos.0 as f32, pos.1 as f32) +} + +fn pointer_button(pos: (f64, f64), pressed: bool) -> egui::Event { + egui::Event::PointerButton { + pos: egui_pos(pos), + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + } +} + +impl ProvidesRegistryState for State { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry_state + } + registry_handlers![OutputState, SeatState]; +} + +// The relative-pointer protocol isn't covered by an SCTK delegate, so dispatch +// it by hand. The manager has no events; the pointer reports raw motion. +impl Dispatch for State { + fn event( + _: &mut Self, + _: &ZwpRelativePointerManagerV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ZwpRelativePointerV1, + event: zwp_relative_pointer_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let zwp_relative_pointer_v1::Event::RelativeMotion { dx, dy, .. } = event { + if state.dragging { + state.drag_delta.0 += dx; + state.drag_delta.1 += dy; + state.needs_redraw = true; + } + } + } +} + +delegate_compositor!(State); +delegate_output!(State); +delegate_seat!(State); +delegate_pointer!(State); +delegate_shm!(State); +delegate_layer!(State); +delegate_registry!(State); + +#[cfg(test)] +mod tests { + use super::*; + + /// Exercises spawn -> render -> stop (the teardown path that segfaulted on + /// overlay close). Manual: needs a Wayland session and briefly shows the + /// overlay. Run with: `cargo test spawn_render_stop -- --ignored`. + #[test] + #[ignore = "requires a Wayland session"] + fn spawn_render_stop() { + let mut overlay = LayerOverlay::spawn(create_shared_gpu(), (100, 50)); + // The saved position is exposed back for persistence. + assert_eq!(overlay.position(), (100, 50)); + overlay.update(OverlayData { + columns: vec!["DPS".into()], + rows: vec![OverlayRow { + name: "Test".into(), + values: vec!["123.4k".into()], + }], + all_columns: vec![("DPS".into(), true), ("Deaths".into(), false)], + }); + std::thread::sleep(std::time::Duration::from_millis(800)); + overlay.stop(); // must return without crashing the process + } + + /// Reproduces toggling the overlay off and on again (drop -> re-spawn), + /// which crashed the app when it had been shown during a game. + #[test] + #[ignore = "requires a Wayland session"] + fn spawn_stop_respawn() { + // A single app-owned gpu kept alive across every cycle, matching how the + // app shares it (see LayerOverlay::spawn). + let gpu = create_shared_gpu(); + for _ in 0..3 { + let mut overlay = LayerOverlay::spawn(gpu.clone(), (0, 0)); + overlay.update(OverlayData { + columns: vec!["DPS".into()], + rows: vec![OverlayRow { + name: "Test".into(), + values: vec!["123.4k".into()], + }], + ..Default::default() + }); + std::thread::sleep(std::time::Duration::from_millis(400)); + overlay.stop(); + } + } +} diff --git a/src/app/overlay.rs b/src/app/overlay/mod.rs similarity index 57% rename from src/app/overlay.rs rename to src/app/overlay/mod.rs index 891ba5e..a521dc0 100644 --- a/src/app/overlay.rs +++ b/src/app/overlay/mod.rs @@ -5,24 +5,48 @@ use eframe::{egui::*, epaint::mutex::Mutex}; use crate::{ analyzer::{Combat, Player}, app::settings::Settings, - custom_widgets::{popup_button::PopupButton, table::Table}, + custom_widgets::table::Table, helpers::number_formatting::NumberFormatter, }; +// The column-config popup only lives in the main window on non-Linux; on Linux +// it moved onto the layer-shell overlay's own toolbar. +#[cfg(not(target_os = "linux"))] +use crate::custom_widgets::popup_button::PopupButton; use super::analysis_handling::{AnalysisHandler, AnalysisInfo}; +// The Linux wlr-layer-shell backend (always-on-top surface + its own thread). +#[cfg(target_os = "linux")] +pub mod layer_shell; + pub struct Overlay(Arc>); struct OverlayInner { + // Used by the eframe-viewport path (non-Linux); on Linux the layer-shell + // surface owns its own geometry. + #[cfg_attr(target_os = "linux", allow(dead_code))] position: Option, + #[cfg_attr(target_os = "linux", allow(dead_code))] current_size: Vec2, data: DisplayData, show: bool, + // Only the non-Linux viewport path toggles this; on Linux the overlay owns + // its own move state. + #[cfg_attr(target_os = "linux", allow(dead_code))] move_around: bool, columns: Vec, analysis_handler: AnalysisHandler, state: State, settings: Settings, + // On Linux the overlay is a wlr-layer-shell surface (always-on-top over + // full-screen games) instead of an eframe viewport; see layer_shell. + #[cfg(target_os = "linux")] + layer: Option, + // wgpu handles shared with eframe's main-window renderer, used to spawn the + // layer-shell overlay. Injected once at startup by App::new (see set_gpu); + // there is no second wgpu instance/device. + #[cfg(target_os = "linux")] + overlay_gpu: Option, } #[derive(Default)] @@ -184,16 +208,31 @@ impl Overlay { Self(Arc::new(Mutex::new(OverlayInner { move_around: true, columns: COLUMNS.iter().cloned().collect(), - current_size: Vec2::ZERO, + // Must start non-zero: Wayland rejects a 0x0 xdg_surface geometry, + // which crashes wgpu ("Surface is not configured for presentation"). + // Matches the min_inner_size used when building the viewport below. + current_size: vec2(240.0, 80.0), data: Default::default(), position: None, show: false, analysis_handler: root_handler.get_handler(true, Self::viewport_id()), state: State::Empty, settings: settings.clone(), + #[cfg(target_os = "linux")] + layer: None, + #[cfg(target_os = "linux")] + overlay_gpu: None, }))) } + /// Injects the shared wgpu handles the layer-shell overlay renders through. + /// Called once at startup by `App::new`; without them the overlay can't + /// start (it never creates its own wgpu instance/device). + #[cfg(target_os = "linux")] + pub fn set_gpu(&self, gpu: layer_shell::OverlayGpu) { + self.0.lock().overlay_gpu = Some(gpu); + } + pub fn show(self: &Self, ui: &mut Ui) { let mut inner = self.0.lock(); @@ -206,54 +245,116 @@ impl Overlay { inner.toggle_show(); } - PopupButton::new("⛭").show(ui, |ui| { - ui.label("Configure what columns are displayed in the Overlay"); + // On non-Linux the overlay is a plain window, so its column config (⛭) + // and move toggle (✋) live in the main window. On Linux both live on + // the layer-shell overlay's own toolbar (see layer_shell). + #[cfg(not(target_os = "linux"))] + { + PopupButton::new("⛭").show(ui, |ui| { + ui.label("Configure what columns are displayed in the Overlay"); + let mut config_changed = false; + for column in inner.columns.iter_mut() { + if ui.checkbox(&mut column.enabled, column.name).clicked() { + config_changed = true; + } + } + if config_changed { + inner.force_update(ui.ctx()); + } + }); + + ui.add_enabled_ui(inner.show, |ui: &mut Ui| { + if Button::new("✋") + .selected(inner.move_around) + .ui(ui) + .on_hover_text("Move the Overlay") + .clicked() + { + inner.move_around = !inner.move_around; + } + }); + } + + inner.poll_update(ui.ctx()); + if !inner.show { + return; + } + + // Linux/Wayland: render the overlay on a wlr-layer-shell surface so it + // stays above full-screen games (the winit always-on-top hint is + // ignored on Wayland). We push the freshly computed rows to that + // surface's own thread; there is no eframe viewport here. + #[cfg(target_os = "linux")] + { + // Apply column toggles raised by the overlay's own ⛭ popup. + let events = inner + .layer + .as_ref() + .map(|layer| layer.poll_events()) + .unwrap_or_default(); let mut config_changed = false; - for column in inner.columns.iter_mut() { - if ui.checkbox(&mut column.enabled, column.name).clicked() { - config_changed = true; + for event in events { + match event { + layer_shell::OverlayEvent::ToggleColumn(index) => { + if let Some(column) = inner.columns.get_mut(index) { + column.enabled = !column.enabled; + config_changed = true; + } + } } } if config_changed { inner.force_update(ui.ctx()); } - }); - ui.add_enabled_ui(inner.show, |ui: &mut Ui| { - if Button::new("✋") - .selected(inner.move_around) - .ui(ui) - .on_hover_text("Move the Overlay") - .clicked() - { - inner.move_around = !inner.move_around; + if inner.layer.is_none() { + if let Some(gpu) = inner.overlay_gpu.clone() { + let position = inner + .settings + .general + .overlay_position + .map_or((0, 0), |[top, left]| (top, left)); + inner.layer = + Some(layer_shell::LayerOverlay::spawn(gpu, position)); + } } - }); - - inner.poll_update(ui.ctx()); - if !inner.show { + inner.check_update(ui.ctx()); + let data = inner.to_overlay_data(); + let style = ui.style().clone(); + if let Some(layer) = &inner.layer { + layer.update(data); + layer.set_style(style); + } + // Keep the main app repainting so we keep feeding the overlay and + // polling its toolbar events (column toggles) promptly. + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(200)); return; } - let mut builder = ViewportBuilder::default() - .with_title("CLA Overlay") - .with_decorations(inner.move_around) - .with_minimize_button(false) - .with_maximize_button(false) - .with_close_button(true) - .with_resizable(false) - .with_min_inner_size(vec2(240.0, 80.0)) - .with_inner_size(inner.current_size) - .with_always_on_top() - .with_taskbar(false) - .with_mouse_passthrough(!inner.move_around); - builder.position = inner.position; - drop(inner); - let inner = self.0.clone(); - ui.ctx() - .show_viewport_deferred(Self::viewport_id(), builder, move |ui, _| { - inner.lock().show_overlay(ui); - }); + #[cfg(not(target_os = "linux"))] + { + let mut builder = ViewportBuilder::default() + .with_title("CLA Overlay") + .with_app_id("sto-cla-overlay") + .with_decorations(inner.move_around) + .with_minimize_button(false) + .with_maximize_button(false) + .with_close_button(true) + .with_resizable(false) + .with_min_inner_size(vec2(240.0, 80.0)) + .with_inner_size(inner.current_size) + .with_always_on_top() + .with_taskbar(false) + .with_mouse_passthrough(!inner.move_around); + builder.position = inner.position; + drop(inner); + let inner = self.0.clone(); + ui.ctx() + .show_viewport_deferred(Self::viewport_id(), builder, move |ui, _| { + inner.lock().show_overlay(ui); + }); + } } pub fn viewport_id() -> ViewportId { @@ -267,9 +368,23 @@ impl Overlay { pub fn settings_changed(&self, settings: &Settings) { self.0.lock().settings = settings.clone(); } + + /// Current overlay position as the (top, left) anchor margin, for + /// persisting it; `None` when the layer overlay isn't running. + #[cfg(target_os = "linux")] + pub fn position(&self) -> Option<[i32; 2]> { + let inner = self.0.lock(); + inner.layer.as_ref().map(|layer| { + let (top, left) = layer.position(); + [top, left] + }) + } } impl OverlayInner { + // The eframe-viewport render path (non-Linux). On Linux the overlay is a + // layer-shell surface rendered on its own thread (see layer_shell). + #[cfg_attr(target_os = "linux", allow(dead_code))] fn show_overlay(&mut self, ui: &mut Ui) { self.check_update(ui.ctx()); CentralPanel::default().show_inside(ui, |ui| { @@ -315,7 +430,10 @@ impl OverlayInner { + ui.spacing().window_margin.left_top() + ui.spacing().window_margin.right_bottom() + ui.spacing().item_spacing; - let required_size = required_size.ceil(); + // Never request below the viewport's min size: otherwise the + // requested size and the compositor-clamped actual size disagree, + // which keeps re-issuing resize commands (fragile on Wayland). + let required_size = required_size.ceil().max(vec2(240.0, 80.0)); if self.current_size != required_size { ui.ctx().send_viewport_cmd_to( Overlay::viewport_id(), @@ -329,6 +447,33 @@ impl OverlayInner { fn toggle_show(&mut self) { self.show = !self.show; self.analysis_handler.enable_auto_refresh(self.show); + #[cfg(target_os = "linux")] + if !self.show { + self.layer = None; // dropping stops the layer-shell overlay thread + } + } + + #[cfg(target_os = "linux")] + fn to_overlay_data(&self) -> layer_shell::OverlayData { + layer_shell::OverlayData { + columns: self.data.columns.iter().map(|c| c.name.to_string()).collect(), + rows: self + .data + .players + .iter() + .map(|p| layer_shell::OverlayRow { + name: p.name.clone(), + values: p.columns.iter().map(|c| c.value_string.clone()).collect(), + }) + .collect(), + // Full column list with enabled flags drives the overlay's ⛭ popup; + // its ToggleColumn(index) maps straight back into `self.columns`. + all_columns: self + .columns + .iter() + .map(|c| (c.name.to_string(), c.enabled)) + .collect(), + } } fn check_update(&mut self, ctx: &Context) { diff --git a/src/app/settings/app_settings.rs b/src/app/settings/app_settings.rs index a89547d..e921a31 100644 --- a/src/app/settings/app_settings.rs +++ b/src/app/settings/app_settings.rs @@ -19,6 +19,16 @@ pub struct Settings { #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] pub struct General { pub more_decimals: bool, + // Last main-window size (points, while not maximized) and maximized state, + // restored on the next launch. See App::ui / App::on_exit and main.rs. + #[serde(default)] + pub window_size: Option<[f32; 2]>, + #[serde(default)] + pub window_maximized: bool, + // Last overlay position as the (top, left) layer-shell anchor margin + // (Linux). Restored when the overlay is next shown. See app::overlay. + #[serde(default)] + pub overlay_position: Option<[i32; 2]>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/app/settings/mod.rs b/src/app/settings/mod.rs index 0e0fc3b..b9c06b9 100644 --- a/src/app/settings/mod.rs +++ b/src/app/settings/mod.rs @@ -74,7 +74,8 @@ impl SettingsWindow { } Window::new("Settings") .collapsible(false) - .auto_sized() + .resizable(true) + .default_size([760.0, 560.0]) .max_size([1080.0, 720.0]) .constrain(true) .show(ui.ctx(), |ui| { diff --git a/src/main.rs b/src/main.rs index 5f1afed..a994907 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,18 +26,43 @@ fn main() { })); logging::initialize(); - let native_options = eframe::NativeOptions { + + // Restore the last window size / maximized state (see app::App::on_exit). + let (saved_size, maximized) = app::saved_window_geometry(); + #[allow(unused_mut)] + let mut native_options = eframe::NativeOptions { viewport: ViewportBuilder::default() - .with_inner_size(vec2(1280.0, 720.0)) - .with_min_inner_size(vec2(480.0, 270.0)) + .with_inner_size(saved_size.unwrap_or(vec2(1280.0, 720.0))) + .with_min_inner_size(vec2(800.0, 600.0)) + .with_maximized(maximized) .with_icon(icon_data()), ..Default::default() }; + // On Linux, create the single wgpu instance/adapter/device/queue up front and + // share it: eframe's main window renders through it (WgpuSetup::Existing) and + // so does the layer-shell overlay (handed the same handles via App::new). One + // Vulkan stack for the whole app, no teardown races when the overlay closes. + #[cfg(target_os = "linux")] + let overlay_instance = { + let gpu = app::create_shared_gpu(); + let instance = gpu.instance.clone(); + native_options.wgpu_options.wgpu_setup = + eframe::egui_wgpu::WgpuSetup::Existing(eframe::egui_wgpu::WgpuSetupExisting { + instance: gpu.instance, + adapter: gpu.adapter, + device: gpu.device, + queue: gpu.queue, + }); + Some(instance) + }; + #[cfg(not(target_os = "linux"))] + let overlay_instance: Option = None; + let res = eframe::run_native( &format!("STO_CombatLogAnalyzer V{}", env!("CARGO_PKG_VERSION")), native_options, - Box::new(|cc| Ok(Box::new(app::App::new(cc)))), + Box::new(move |cc| Ok(Box::new(app::App::new(cc, overlay_instance)))), ); if let Err(err) = res { From 3b1d6a3803592d7cffb163cf9aac640221509bb8 Mon Sep 17 00:00:00 2001 From: Raman Date: Sun, 26 Jul 2026 20:22:57 +0200 Subject: [PATCH 2/3] Leave the main window's geometry out of the overlay change Remembering the main window size, raising its minimum size and the redraw-while-resizing tweak have nothing to do with the overlay, and carrying them here made this branch touch main.rs and the settings struct for unrelated reasons. The window size persistence is proposed separately. The minimum size and the resize redraw stay on the development branches for now. Co-Authored-By: Claude Opus 5 --- src/app/mod.rs | 62 +------------------------------- src/app/settings/app_settings.rs | 6 ---- src/main.rs | 8 ++--- 3 files changed, 3 insertions(+), 73 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 515e16e..8dc80c4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -38,18 +38,6 @@ pub struct App { upload: Upload, records: Records, state: AppState, - // Deferred persistence of the window size: written once resizing settles - // (see track_window_geometry). - window_geometry_dirty: bool, - last_geometry_change: f64, -} - -/// Window geometry to restore at startup: last size (points) and whether the -/// window was maximized. Read before the viewport is built (see main.rs). -pub fn saved_window_geometry() -> (Option, bool) { - let settings = Settings::load_or_default(); - let size = settings.general.window_size.map(|[w, h]| vec2(w, h)); - (size, settings.general.window_maximized) } impl App { @@ -73,8 +61,6 @@ impl App { upload: Default::default(), records: Default::default(), state, - window_geometry_dirty: false, - last_geometry_change: 0.0, }; // On Linux, hand the layer-shell overlay the shared wgpu handles: the @@ -101,7 +87,6 @@ impl App { impl eframe::App for App { fn ui(&mut self, ui: &mut eframe::egui::Ui, frame: &mut eframe::Frame) { self.handle_analysis_infos(); - self.track_window_geometry(ui.ctx()); // Remember where the overlay was dragged (persisted on exit). #[cfg(target_os = "linux")] if let Some(position) = self.state.overlay.position() { @@ -209,57 +194,12 @@ impl eframe::App for App { } fn on_exit(&mut self) { - // Backup flush of the latest geometry on close (see track_window_geometry). + // Persists the overlay position picked up in `ui` above. self.state.settings.save(); } } impl App { - /// Remembers the main window's size and maximized state so the next launch - /// restores them (see main.rs). The size comes from the egui viewport rect - /// because on Wayland the OS-reported `inner_rect` is `None`. The settings - /// file is written only once the size has settled (no change for a moment), - /// never while the edge is being dragged, so resizing stays smooth. - fn track_window_geometry(&mut self, ctx: &eframe::egui::Context) { - let now = ctx.input(|i| i.time); - let maximized = ctx.input(|i| i.viewport().maximized); - let size = ctx.viewport_rect().size(); - - // Only remember the windowed size, so un-maximizing restores something - // sane rather than the full-screen size. - if maximized != Some(true) { - let size = [size.x, size.y]; - if self.state.settings.general.window_size != Some(size) { - self.state.settings.general.window_size = Some(size); - self.window_geometry_dirty = true; - self.last_geometry_change = now; - } - } - if let Some(maximized) = maximized { - if self.state.settings.general.window_maximized != maximized { - self.state.settings.general.window_maximized = maximized; - self.window_geometry_dirty = true; - self.last_geometry_change = now; - } - } - - if self.window_geometry_dirty { - let idle = now - self.last_geometry_change; - if idle >= 2.0 { - // Settled for 2 s: write once, off the resize hot path. - self.state.settings.save(); - self.window_geometry_dirty = false; - } else if idle < 0.5 { - // Actively resizing: keep redrawing every frame so the content - // tracks the window instead of lagging behind the drag. - ctx.request_repaint(); - } else { - // Idle but not yet settled: check again to flush the size. - ctx.request_repaint_after(std::time::Duration::from_millis(300)); - } - } - } - fn handle_analysis_infos(&mut self) { let combatlog_file = &self.state.settings.analysis.combatlog_file; for info in self.state.analysis_handler.check_for_info() { diff --git a/src/app/settings/app_settings.rs b/src/app/settings/app_settings.rs index e921a31..05543e6 100644 --- a/src/app/settings/app_settings.rs +++ b/src/app/settings/app_settings.rs @@ -19,12 +19,6 @@ pub struct Settings { #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] pub struct General { pub more_decimals: bool, - // Last main-window size (points, while not maximized) and maximized state, - // restored on the next launch. See App::ui / App::on_exit and main.rs. - #[serde(default)] - pub window_size: Option<[f32; 2]>, - #[serde(default)] - pub window_maximized: bool, // Last overlay position as the (top, left) layer-shell anchor margin // (Linux). Restored when the overlay is next shown. See app::overlay. #[serde(default)] diff --git a/src/main.rs b/src/main.rs index a994907..f0d96d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,15 +26,11 @@ fn main() { })); logging::initialize(); - - // Restore the last window size / maximized state (see app::App::on_exit). - let (saved_size, maximized) = app::saved_window_geometry(); #[allow(unused_mut)] let mut native_options = eframe::NativeOptions { viewport: ViewportBuilder::default() - .with_inner_size(saved_size.unwrap_or(vec2(1280.0, 720.0))) - .with_min_inner_size(vec2(800.0, 600.0)) - .with_maximized(maximized) + .with_inner_size(vec2(1280.0, 720.0)) + .with_min_inner_size(vec2(480.0, 270.0)) .with_icon(icon_data()), ..Default::default() }; From d9c45cea06b980790fe9aab0b21013db57b10b12 Mon Sep 17 00:00:00 2001 From: Raman Date: Sun, 26 Jul 2026 20:51:22 +0200 Subject: [PATCH 3/3] Pick the overlay back end by session instead of by target OS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay chose its back end with #[cfg(target_os = "linux")], so every Linux build used wlr-layer-shell. That is a Wayland protocol, and in an X11 session there is nothing to connect to: the layer thread failed at Connection::connect_to_env, logged an error and exited, leaving the Overlay button doing nothing at all. Before this branch those users had a working always-on-top overlay window. Both back ends are now compiled on Linux and the choice is made while the app runs. App::new only injects the shared wgpu handles when eframe's CreationContext reports a RawDisplayHandle::Wayland, which is the back end winit actually picked rather than a guess, and having those handles is what OverlayInner::uses_layer_shell asks about. A session without them falls back to the viewport instead of failing, so the two can never disagree. main.rs makes the same call one step earlier, before any window exists, the way winit does it (WAYLAND_DISPLAY / WAYLAND_SOCKET), so an X11 session no longer builds a shared wgpu stack it will not use — and no longer runs the two expects inside create_shared_gpu for a back end it cannot reach. The chosen back end is logged once at startup. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++ docs/OVERLAY.md | 108 +++++++++++++++++++++++------------------ src/app/mod.rs | 29 +++++++++-- src/app/overlay/mod.rs | 76 +++++++++++++++++------------ src/main.rs | 28 +++++++++-- 5 files changed, 161 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 848cb96..64baa9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ ### Major Changes - decreased the decimal count of most number to reduce used space (can be changed to be like before in the settings) +### Other Changes +- on Linux/Wayland the overlay now stays above full-screen games, and carries its own buttons for picking columns and moving it around + ### Fixes - fixed auto refresh stopping to work when changing the logs path +- fixed the program crashing on Linux/Wayland when the overlay is switched on ## v1.4.0 ### Major Changes diff --git a/docs/OVERLAY.md b/docs/OVERLAY.md index bfe2713..e2d4cd9 100644 --- a/docs/OVERLAY.md +++ b/docs/OVERLAY.md @@ -8,8 +8,8 @@ full-screen game window. This document covers how it is rendered, why there are two rendering back ends, and the invariants that keep the Wayland back end from crashing. -Owned by `src/app/overlay.rs` (`Overlay`, the app-side controller) and -`src/app/layer_overlay.rs` (`LayerOverlay`, the Linux/Wayland surface). Driven +Owned by `src/app/overlay/mod.rs` (`Overlay`, the app-side controller) and +`src/app/overlay/layer_shell.rs` (`LayerOverlay`, the Wayland surface). Driven from the main tab UI via `Overlay::show()`. ## Context @@ -24,48 +24,64 @@ that stays above the game. │ App (main window) │ │ └─ Overlay ── Arc> │ │ │ │ - │ platform split in Overlay::show() │ + │ session split in Overlay::show() │ │ │ │ │ ┌──────┴───────────────┐ │ │ │ │ │ - │ non-Linux Linux │ - │ eframe deferred LayerOverlay ── calloop channel ───► │ - │ viewport (handle) │ + │ X11 / Windows / Wayland │ + │ macOS LayerOverlay ── calloop channel ───► │ + │ eframe deferred (handle) │ + │ viewport │ └───────────────────────────────────────────────────────────┘ │ - cla-layer-overlay thread (Linux only) + cla-layer-overlay thread (Wayland only) wlr-layer-shell surface + wgpu + egui ``` ## Why two back ends -`Overlay::show()` (`src/app/overlay.rs:210`) branches on -`#[cfg(target_os = "linux")]`. - -| Target | Back end | Mechanism | -|---|---|---| -| Windows / other | eframe **deferred viewport** | `show_viewport_deferred` (`overlay.rs:292`) with `.with_always_on_top()` | -| Linux | **wlr-layer-shell** surface on its own thread | `src/app/layer_overlay.rs` | - The split exists because on **Wayland** a normal top-level window (what an eframe viewport is, via `winit`) cannot force itself above a full-screen game: the always-on-top hint is advisory and compositors (KWin included) ignore it. -The `wlr-layer-shell` protocol's `overlay` layer *is* honored, so on Linux the +The `wlr-layer-shell` protocol's `overlay` layer *is* honored, so there the overlay is a layer surface instead of a viewport. The two back ends render the same content (see [Unified styling](#unified-styling)) but share no windowing code. -The non-Linux path is unchanged legacy behavior and is not described further -here; read `OverlayInner::show_overlay()` (`overlay.rs:315`) for it. +| Session | Back end | Mechanism | +|---|---|---| +| Wayland | **wlr-layer-shell** surface on its own thread | `src/app/overlay/layer_shell.rs` | +| X11, Windows, macOS | eframe **deferred viewport** | `show_viewport_deferred` with `.with_always_on_top()` | + +## Why the choice is made at runtime + +`layer_shell` is compiled on Linux (`#[cfg(target_os = "linux")]`), but which +back end is *used* is decided while the app runs, in +`OverlayInner::uses_layer_shell()`. A `cfg` on the target OS would be wrong: +one Linux binary has to serve both session types, and wlr-layer-shell is a +Wayland protocol with nothing to connect to under X11 — picking it there would +leave the Overlay button doing nothing at all, while the plain always-on-top +viewport works fine in an X11 session. + +The signal is the shared wgpu handles: `App::new` only injects them when +`is_wayland()` finds a `RawDisplayHandle::Wayland` on eframe's +`CreationContext`, which reports the back end `winit` actually chose rather than +a guess. `uses_layer_shell()` is then simply "were the handles injected", so the +two can never disagree, and a session without them falls back to the viewport +instead of failing. `main.rs` makes the same call one step earlier — before any +window exists, so from `WAYLAND_DISPLAY` / `WAYLAND_SOCKET`, the way winit does +it — to decide whether the shared wgpu stack is worth building at all. + +The selected back end is logged once at startup (`overlay backend: ...`). ## Invariants -- **I1 — Drop order (Linux).** In `run()` the wgpu surface (`State.gpu`) is +- **I1 — Drop order (Wayland).** In `run()` the wgpu surface (`State.gpu`) is built from raw handles of the `wl_surface`/`Connection`. It MUST be dropped before them, or wgpu tears down a surface backed by a destroyed object and the process segfaults. Enforced two ways: `gpu` is the first field of `State` - (fields drop in declaration order, `layer_overlay.rs:205`) and `run()` - explicitly sets `app.gpu = None` before returning (`layer_overlay.rs:192`). + (fields drop in declaration order, `overlay/layer_shell.rs`) and `run()` + explicitly sets `app.gpu = None` before returning (`overlay/layer_shell.rs`). - **I2 — Non-zero surface size.** Wayland rejects a 0×0 geometry. The surface never requests below `MIN_W`×`MIN_H` (240×80); the auto-size clamps to it. - **I3 — Passthrough by default.** Out of move mode the surface carries an @@ -77,19 +93,19 @@ here; read `OverlayInner::show_overlay()` (`overlay.rs:315`) for it. ## Data flow Combat data originates in the analyzer and reaches the overlay through an -`AnalysisHandler` (a per-consumer subscription to refreshed combats). On Linux -the formatted snapshot is then handed to the layer thread over a calloop -channel. +`AnalysisHandler` (a per-consumer subscription to refreshed combats). With the +layer-shell back end the formatted snapshot is then handed to the layer thread +over a calloop channel. ``` - analyzer ─► AnalysisHandler ─► OverlayInner.poll_update() (overlay.rs:409) + analyzer ─► AnalysisHandler ─► OverlayInner.poll_update() (overlay/mod.rs) │ AnalysisInfo::Refreshed ▼ - OverlayInner.perform_update() (overlay.rs:431) + OverlayInner.perform_update() (overlay/mod.rs) │ build DisplayData (sorted rows, │ formatted strings, enabled columns) ▼ - Linux only: OverlayInner.to_overlay_data() (overlay.rs:384) + layer-shell: OverlayInner.to_overlay_data() │ OverlayData { columns, rows } (plain) ▼ LayerOverlay.update(data) ── Msg::Data ──► calloop channel @@ -99,13 +115,13 @@ channel. ``` `Overlay::show()` pumps this every frame while the overlay is visible and asks -the main context to repaint every 500 ms (`overlay.rs:269`) so fresh data keeps +the main context to repaint every 500 ms (`overlay/mod.rs`) so fresh data keeps flowing to the thread. `set_move` is sent every frame too; the thread ignores -it unless the flag actually changed (`layer_overlay.rs`, `Msg::Move` handler). +it unless the flag actually changed (`overlay/layer_shell.rs`, `Msg::Move` handler). ### Message contract -`enum Msg` (`layer_overlay.rs`) is the only thing crossing the thread boundary: +`enum Msg` (`overlay/layer_shell.rs`) is the only thing crossing the thread boundary: | Variant | Payload | Effect on the layer thread | |---|---|---| @@ -119,21 +135,21 @@ A closed channel (`ChannelEvent::Closed`) is treated as `Stop`. | File / symbol | Responsibility | Called by | |---|---|---| -| `overlay.rs` `Overlay` | app-side controller, platform split, UI buttons | main tab UI | -| `overlay.rs` `OverlayInner` | polls analyzer, builds `DisplayData`, owns the `LayerOverlay` handle | `Overlay` | -| `layer_overlay.rs` `LayerOverlay` | thread handle: `spawn`/`update`/`set_move`/`stop`; stops thread on `Drop` | `OverlayInner` | -| `layer_overlay.rs` `run()` | thread body: Wayland globals, event loop, redraw loop | `spawn` | -| `layer_overlay.rs` `State` | per-surface state: wgpu, egui, geometry, pointer/drag | delegated handlers | +| `overlay/mod.rs` `Overlay` | app-side controller, back-end selection, UI buttons | main tab UI | +| `overlay/mod.rs` `OverlayInner` | polls analyzer, builds `DisplayData`, owns the `LayerOverlay` handle | `Overlay` | +| `overlay/layer_shell.rs` `LayerOverlay` | thread handle: `spawn`/`update`/`set_move`/`stop`; stops thread on `Drop` | `OverlayInner` | +| `overlay/layer_shell.rs` `run()` | thread body: Wayland globals, event loop, redraw loop | `spawn` | +| `overlay/layer_shell.rs` `State` | per-surface state: wgpu, egui, geometry, pointer/drag | delegated handlers | | `custom_widgets/table.rs` `Table` | shared table widget used by both back ends | both render paths | The `LayerOverlay` handle lives in `OverlayInner.layer` (`Option`). It is created lazily on first visible frame -(`overlay.rs:258`) and dropped when the overlay is hidden -(`toggle_show()`, `overlay.rs:374`), which stops the thread. +(`overlay/mod.rs`) and dropped when the overlay is hidden +(`toggle_show()`, `overlay/mod.rs`), which stops the thread. -## Layer thread internals (Linux) +## Layer thread internals (Wayland) -`run()` (`layer_overlay.rs:121`) sets up a self-contained Wayland client using +`run()` (`overlay/layer_shell.rs`) sets up a self-contained Wayland client using `smithay-client-toolkit` (SCTK): 1. Connect, bind globals: `CompositorState`, `LayerShell`, `Shm`, `SeatState`. @@ -148,13 +164,13 @@ The `LayerOverlay` handle lives in `OverlayInner.layer` ### wgpu surface from raw handles egui needs a wgpu surface, but SCTK owns the `wl_surface`. `State::init_gpu()` -(`layer_overlay.rs:260`) bridges them: it reads the `wl_display` pointer from +(`overlay/layer_shell.rs`) bridges them: it reads the `wl_display` pointer from the connection backend and the `wl_surface` pointer from the layer, wraps them as `raw-window-handle` `Wayland*Handle`s, and calls `Instance::create_surface_unsafe`. This is the coupling that makes I1 mandatory. ```rust -// src/app/layer_overlay.rs (init_gpu, elided) +// src/app/overlay/layer_shell.rs (init_gpu, elided) let display_ptr = NonNull::new(self.conn.backend().display_ptr() as *mut _)...; let surface_ptr = NonNull::new(self.layer.wl_surface().id().as_ptr() as *mut _)...; let raw_display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(display_ptr)); @@ -164,7 +180,7 @@ let raw_window = RawWindowHandle::Wayland(WaylandWindowHandle::new(surface_ptr) ### Render loop and auto-size -`State::render()` (`layer_overlay.rs:323`) runs egui headless +`State::render()` (`overlay/layer_shell.rs`) runs egui headless (`egui::Context::run` with a manual `RawInput` screen rect), tessellates, and submits via `egui_wgpu::Renderer`. `pixels_per_point` is fixed at `1.0`. @@ -189,13 +205,13 @@ frames, `render()` also forces a redraw while `LayerSurface` cannot be dragged like an xdg-toplevel, so movement is implemented with the input region + surface margins. -- **Input region** (`apply_input_region()`, `layer_overlay.rs:239`): in move +- **Input region** (`apply_input_region()`, `overlay/layer_shell.rs`): in move mode the whole surface takes pointer input (`set_input_region(None)`); out of move mode an empty `Region` is attached so clicks fall through (I3). - **Pointer** is obtained through `SeatHandler` when a seat advertises the pointer capability; `PointerHandler::pointer_frame` - (`layer_overlay.rs:539`) tracks position and left-button drag. -- **Drag** (`drag_to()`, `layer_overlay.rs:251`): on left-button press the + (`overlay/layer_shell.rs`) tracks position and left-button drag. +- **Drag** (`drag_to()`, `overlay/layer_shell.rs`): on left-button press the surface-local pointer position becomes the `grab` point. Each motion adjusts the `(top, left)` margin by `pointer - grab`, then `set_margin` + `commit`. @@ -224,7 +240,7 @@ identical. ## Testing -`layer_overlay.rs` has one `#[ignore]` integration test, `spawn_render_stop`, +`overlay/layer_shell.rs` has one `#[ignore]` integration test, `spawn_render_stop`, that spawns the overlay, pushes a row, toggles move mode both ways, and stops — exercising the I1 teardown path and the input-region swap. It needs a real Wayland session and briefly shows the overlay: diff --git a/src/app/mod.rs b/src/app/mod.rs index 8dc80c4..0ef2367 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -27,6 +27,18 @@ mod summary_copy; #[cfg(target_os = "linux")] pub use overlay::layer_shell::create_shared_gpu; +/// Whether the app came up on Wayland, which is where the overlay needs the +/// layer-shell backend. Asks the window system handle eframe was given, so it +/// reports the backend winit actually chose. +#[cfg(target_os = "linux")] +fn is_wayland(cc: &eframe::CreationContext) -> bool { + use raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; + matches!( + cc.display_handle().map(|handle| handle.as_raw()), + Ok(RawDisplayHandle::Wayland(_)) + ) +} + pub struct App { settings_window: SettingsWindow, combats: Vec, @@ -63,19 +75,30 @@ impl App { state, }; - // On Linux, hand the layer-shell overlay the shared wgpu handles: the - // instance we created up front (passed in) plus eframe's + // In a Wayland session, hand the layer-shell overlay the shared wgpu + // handles: the instance we created up front (passed in) plus eframe's // adapter/device/queue — which, thanks to WgpuSetup::Existing, are the // very ones we handed eframe. So both render through one device. + // + // In an X11 session there is no layer-shell to talk to, so the handles + // stay unset and the overlay falls back to the plain always-on-top + // viewport, which works there. `overlay_instance` is already `None` in + // that case (see main.rs); asking the window handle as well means the + // backend winit actually picked decides, not a guess. #[cfg(target_os = "linux")] - if let (Some(instance), Some(render_state)) = (overlay_instance, cc.wgpu_render_state.as_ref()) + if is_wayland(cc) + && let (Some(instance), Some(render_state)) = + (overlay_instance, cc.wgpu_render_state.as_ref()) { + log::info!("overlay backend: layer-shell (Wayland session)"); app.state.overlay.set_gpu(overlay::layer_shell::OverlayGpu { instance, adapter: render_state.adapter.clone(), device: render_state.device.clone(), queue: render_state.queue.clone(), }); + } else { + log::info!("overlay backend: always-on-top window"); } #[cfg(not(target_os = "linux"))] let _ = overlay_instance; diff --git a/src/app/overlay/mod.rs b/src/app/overlay/mod.rs index a521dc0..c0fa396 100644 --- a/src/app/overlay/mod.rs +++ b/src/app/overlay/mod.rs @@ -8,9 +8,6 @@ use crate::{ custom_widgets::table::Table, helpers::number_formatting::NumberFormatter, }; -// The column-config popup only lives in the main window on non-Linux; on Linux -// it moved onto the layer-shell overlay's own toolbar. -#[cfg(not(target_os = "linux"))] use crate::custom_widgets::popup_button::PopupButton; use super::analysis_handling::{AnalysisHandler, AnalysisInfo}; @@ -22,29 +19,27 @@ pub mod layer_shell; pub struct Overlay(Arc>); struct OverlayInner { - // Used by the eframe-viewport path (non-Linux); on Linux the layer-shell - // surface owns its own geometry. - #[cfg_attr(target_os = "linux", allow(dead_code))] + // Used by the eframe-viewport path; the layer-shell surface owns its own + // geometry instead. position: Option, - #[cfg_attr(target_os = "linux", allow(dead_code))] current_size: Vec2, data: DisplayData, show: bool, - // Only the non-Linux viewport path toggles this; on Linux the overlay owns - // its own move state. - #[cfg_attr(target_os = "linux", allow(dead_code))] + // Only the viewport path toggles this; the layer-shell overlay owns its own + // move state. move_around: bool, columns: Vec, analysis_handler: AnalysisHandler, state: State, settings: Settings, - // On Linux the overlay is a wlr-layer-shell surface (always-on-top over - // full-screen games) instead of an eframe viewport; see layer_shell. + // In a Wayland session the overlay is a wlr-layer-shell surface (always-on-top + // over full-screen games) instead of an eframe viewport; see layer_shell. #[cfg(target_os = "linux")] layer: Option, // wgpu handles shared with eframe's main-window renderer, used to spawn the - // layer-shell overlay. Injected once at startup by App::new (see set_gpu); - // there is no second wgpu instance/device. + // layer-shell overlay. Injected once at startup by App::new (see set_gpu), + // and only in a Wayland session — so their presence is what selects the + // layer-shell backend (see `uses_layer_shell`). #[cfg(target_os = "linux")] overlay_gpu: Option, } @@ -226,8 +221,9 @@ impl Overlay { } /// Injects the shared wgpu handles the layer-shell overlay renders through. - /// Called once at startup by `App::new`; without them the overlay can't - /// start (it never creates its own wgpu instance/device). + /// Called once at startup by `App::new`, and only when the app really is + /// running on Wayland — so this is also what selects the layer-shell + /// backend over the plain viewport one. #[cfg(target_os = "linux")] pub fn set_gpu(&self, gpu: layer_shell::OverlayGpu) { self.0.lock().overlay_gpu = Some(gpu); @@ -245,11 +241,10 @@ impl Overlay { inner.toggle_show(); } - // On non-Linux the overlay is a plain window, so its column config (⛭) - // and move toggle (✋) live in the main window. On Linux both live on - // the layer-shell overlay's own toolbar (see layer_shell). - #[cfg(not(target_os = "linux"))] - { + // With the viewport backend the overlay is a plain window, so its column + // config (⛭) and move toggle (✋) live in the main window. The + // layer-shell overlay carries both on its own toolbar instead. + if !inner.uses_layer_shell() { PopupButton::new("⛭").show(ui, |ui| { ui.label("Configure what columns are displayed in the Overlay"); let mut config_changed = false; @@ -280,12 +275,12 @@ impl Overlay { return; } - // Linux/Wayland: render the overlay on a wlr-layer-shell surface so it - // stays above full-screen games (the winit always-on-top hint is - // ignored on Wayland). We push the freshly computed rows to that - // surface's own thread; there is no eframe viewport here. + // Wayland: render the overlay on a wlr-layer-shell surface so it stays + // above full-screen games (the winit always-on-top hint is ignored + // there). We push the freshly computed rows to that surface's own + // thread; there is no eframe viewport in this case. #[cfg(target_os = "linux")] - { + if inner.uses_layer_shell() { // Apply column toggles raised by the overlay's own ⛭ popup. let events = inner .layer @@ -332,7 +327,8 @@ impl Overlay { return; } - #[cfg(not(target_os = "linux"))] + // Everywhere else — Windows, macOS and an X11 session on Linux — the + // overlay is a plain always-on-top eframe viewport. { let mut builder = ViewportBuilder::default() .with_title("CLA Overlay") @@ -382,9 +378,29 @@ impl Overlay { } impl OverlayInner { - // The eframe-viewport render path (non-Linux). On Linux the overlay is a - // layer-shell surface rendered on its own thread (see layer_shell). - #[cfg_attr(target_os = "linux", allow(dead_code))] + /// Whether the overlay runs on a wlr-layer-shell surface instead of an + /// eframe viewport. + /// + /// This is decided at runtime rather than by `cfg`, because a Linux build + /// has to serve both session types: layer-shell is a Wayland protocol and + /// there is nothing to talk to in an X11 session, where the plain + /// always-on-top viewport works anyway. `App::new` only hands over the + /// shared wgpu handles when the app really came up on Wayland, so having + /// them is the same thing as being able to use layer-shell. + fn uses_layer_shell(&self) -> bool { + #[cfg(target_os = "linux")] + { + self.overlay_gpu.is_some() + } + #[cfg(not(target_os = "linux"))] + { + false + } + } + + // The eframe-viewport render path, used everywhere except a Wayland + // session, where the overlay is a layer-shell surface rendered on its own + // thread instead (see layer_shell). fn show_overlay(&mut self, ui: &mut Ui) { self.check_update(ui.ctx()); CentralPanel::default().show_inside(ui, |ui| { diff --git a/src/main.rs b/src/main.rs index f0d96d9..c308ac2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,12 +35,18 @@ fn main() { ..Default::default() }; - // On Linux, create the single wgpu instance/adapter/device/queue up front and - // share it: eframe's main window renders through it (WgpuSetup::Existing) and - // so does the layer-shell overlay (handed the same handles via App::new). One - // Vulkan stack for the whole app, no teardown races when the overlay closes. + // In a Wayland session, create the single wgpu instance/adapter/device/queue + // up front and share it: eframe's main window renders through it + // (WgpuSetup::Existing) and so does the layer-shell overlay (handed the same + // handles via App::new). One Vulkan stack for the whole app, no teardown + // races when the overlay closes. + // + // An X11 session uses the plain viewport overlay and needs none of this, so + // it is skipped there and eframe sets up its renderer as usual. The window + // does not exist yet, so the session is determined the same way winit + // determines it (see winit's platform_impl/linux). #[cfg(target_os = "linux")] - let overlay_instance = { + let overlay_instance = if is_wayland_session() { let gpu = app::create_shared_gpu(); let instance = gpu.instance.clone(); native_options.wgpu_options.wgpu_setup = @@ -51,6 +57,8 @@ fn main() { queue: gpu.queue, }); Some(instance) + } else { + None }; #[cfg(not(target_os = "linux"))] let overlay_instance: Option = None; @@ -66,6 +74,16 @@ fn main() { } } +/// Whether this is a Wayland session, decided the same way winit decides which +/// backend to use: a non-empty `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` means +/// Wayland, otherwise X11. +#[cfg(target_os = "linux")] +fn is_wayland_session() -> bool { + ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"] + .iter() + .any(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty())) +} + fn icon_data() -> IconData { const ICON: &[u8] = include_bytes!("../icon/icon.png"); let decoder = png::Decoder::new(Cursor::new(ICON));