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/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..e2d4cd9 --- /dev/null +++ b/docs/OVERLAY.md @@ -0,0 +1,270 @@ +# 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/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 + +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> │ + │ │ │ + │ session split in Overlay::show() │ + │ │ │ + │ ┌──────┴───────────────┐ │ + │ │ │ │ + │ X11 / Windows / Wayland │ + │ macOS LayerOverlay ── calloop channel ───► │ + │ eframe deferred (handle) │ + │ viewport │ + └───────────────────────────────────────────────────────────┘ + │ + cla-layer-overlay thread (Wayland only) + wlr-layer-shell surface + wgpu + egui +``` + +## Why two back ends + +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 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. + +| 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 (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, `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 + 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). 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/mod.rs) + │ AnalysisInfo::Refreshed + ▼ + OverlayInner.perform_update() (overlay/mod.rs) + │ build DisplayData (sorted rows, + │ formatted strings, enabled columns) + ▼ + layer-shell: OverlayInner.to_overlay_data() + │ 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/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 (`overlay/layer_shell.rs`, `Msg::Move` handler). + +### Message contract + +`enum Msg` (`overlay/layer_shell.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/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/mod.rs`) and dropped when the overlay is hidden +(`toggle_show()`, `overlay/mod.rs`), which stops the thread. + +## Layer thread internals (Wayland) + +`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`. +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()` +(`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/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)); +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()` (`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`. + +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()`, `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` + (`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`. + +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 + +`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: + +``` +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..0ef2367 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -22,6 +22,23 @@ 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; + +/// 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, @@ -36,13 +53,16 @@ pub struct App { } 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,48 @@ impl App { upload: Default::default(), records: Default::default(), state, + }; + + // 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 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; + + app } } impl eframe::App for App { fn ui(&mut self, ui: &mut eframe::egui::Ui, frame: &mut eframe::Frame) { self.handle_analysis_infos(); + // 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,6 +215,11 @@ 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) { + // Persists the overlay position picked up in `ui` above. + self.state.settings.save(); + } } impl App { 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 55% rename from src/app/overlay.rs rename to src/app/overlay/mod.rs index 891ba5e..c0fa396 100644 --- a/src/app/overlay.rs +++ b/src/app/overlay/mod.rs @@ -5,24 +5,43 @@ 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, }; +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; the layer-shell surface owns its own + // geometry instead. position: Option, current_size: Vec2, data: DisplayData, show: bool, + // 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, + // 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), + // 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, } #[derive(Default)] @@ -184,16 +203,32 @@ 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`, 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); + } + pub fn show(self: &Self, ui: &mut Ui) { let mut inner = self.0.lock(); @@ -206,54 +241,116 @@ impl Overlay { inner.toggle_show(); } - PopupButton::new("⛭").show(ui, |ui| { - ui.label("Configure what columns are displayed in the Overlay"); + // 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; + 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; + } + + // 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 + .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); - }); + // 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") + .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 +364,43 @@ 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 { + /// 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| { @@ -315,7 +446,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 +463,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..05543e6 100644 --- a/src/app/settings/app_settings.rs +++ b/src/app/settings/app_settings.rs @@ -19,6 +19,10 @@ pub struct Settings { #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] pub struct General { pub more_decimals: 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..c308ac2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,8 @@ fn main() { })); logging::initialize(); - let native_options = eframe::NativeOptions { + #[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)) @@ -34,10 +35,38 @@ fn main() { ..Default::default() }; + // 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 = if is_wayland_session() { + 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) + } else { + None + }; + #[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 { @@ -45,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));