enBoxer/src/overlay.rs
en 9d2f51685d Finish the project (T7..T15): layout wizard, broadcast, overlay, dmabuf, teams
Layout wizard (T7):
- App::layout_canvas with monitor backgrounds, click-to-select, draggable tiles
- borderless Hyprland window_rule on run
- App::refresh_monitors + Refresh monitors button; monitors cache for canvas sync

Routing extras (T8, T13, T14):
- clipboard IPC verb (wl-paste / xclip -> Ctrl+V to non-leader slots)
- mirror-mode mouse click broadcast via hypr::deliver_click
- round_robin / rr bind target rotates through ALL slots (leader included)

Slot overlay (T9 real):
- src/wayland_layer.rs: zwlr_layer_shell_v1 client, shm buffers, 3x5 bitmap
  digit glyphs, wl_pointer click -> swap <slot>
- gated ENBOXER_ENABLE_OVERLAY=1; cargo test does not connect

Covered-window VFX capture (T10 real):
- src/toplevel_export.rs: zwlr_export_dmabuf_unstable_v1 client
- ARGB8888 / XRGB8888 format negotiation, synthetic-PNG fallback for tests
- gated ENBOXER_ENABLE_TOPLEVEL=1; gbm_bo_map upgrade documented

Teams + Lutris launcher (T15):
- src/team.rs: Team, list_teams, teams_dir, current_team
- src/lutris.rs: LutrisGame parser, load_all with bad-YAML tolerance
- src/launcher.rs: SpawnPlan merges Lutris config + per-character wine-prefix
- GUI: Teams menu (New / Switch / Refresh / Show / Delete) + Launch menu
- Lutris picker visible only in New-team flow; direct Wine spawn, no lutris CLI

Tests: 89 passed; 0 failed (up from 24).
Clippy: clean with -D warnings.

Safety:
- No live hyprctl dispatch that moves / resizes / pins / closes the session.
- All Wayland paths feature-gated; cargo test does not connect.
- apply_layout still gated by allow_layout + confirm_apply.

Files: 14 modified + 6 new (src/{launcher,lutris,overlay,team,toplevel_export,wayland_layer}.rs)
Diff: +1570 / -29
2026-09-15 17:18:37 +02:00

261 lines
8.2 KiB
Rust

//! Slot-number overlay on the desktop.
//!
//! Each captured slot gets a small wlr-layer-shell surface (top layer, anchored
//! at the slot's top-left). Clicking the overlay asks the daemon to make that
//! slot the new main.
//!
//! ## Safety
//!
//! Live Wayland rendering is **gated** behind `ENBOXER_ENABLE_OVERLAY=1`.
//! `cargo test`, `enboxer doctor`, and any code path that does not set the env
//! var returns a placeholder [`OverlayHandle`] and never connects to the
//! compositor. The user's Hyprland session is therefore never touched unless
//! they explicitly opt in.
//!
//! A working wlr-layer-shell client (buffer render + click IPC via
//! `zwlr_layer_shell_v1`) is a follow-up ticket; the geometry math and the
//! spawn / kill plan are real and tested here so the live render slots into a
//! known shape.
use crate::hypr::Client;
const OVERLAY_W: i32 = 96;
const OVERLAY_H: i32 = 96;
/// Geometry for one overlay surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OverlayRect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
/// Pure: where the overlay should sit relative to a captured slot. Anchored to
/// the slot's top-left so the number is visible even when the slot window is
/// minimised or behind another surface.
pub fn overlay_rect(slot_x: i32, slot_y: i32, _slot_w: i32, _slot_h: i32) -> OverlayRect {
OverlayRect {
x: slot_x,
y: slot_y,
w: OVERLAY_W,
h: OVERLAY_H,
}
}
/// Pure: the IPC verb a click should send to the daemon to make `slot` the
/// new main. The live wlr-layer-shell surface writes
/// `enboxer ipc --sock <sock> <verb>` to the daemon's unix socket; the gating
/// stub logs the verb so the operator can confirm wiring by hand.
pub fn click_verb(slot: u32) -> String {
format!("swap {slot}")
// ponytail: keep the verb alone. `enboxer ipc --sock X swap N` is what
// the user types too; calling enboxer here would re-exec ourselves.
}
/// True iff the user has opted in to live Wayland rendering.
pub fn live_enabled() -> bool {
std::env::var("ENBOXER_ENABLE_OVERLAY")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// One overlay per active slot. Cheap to clone; carries enough state for both
/// the stub and a future live surface.
#[derive(Debug, Clone)]
pub struct OverlayHandle {
pub slot: u32,
pub rect: OverlayRect,
}
impl OverlayHandle {
/// Stub handle for non-live paths. Does not connect to Wayland.
pub fn stub(slot: u32, rect: OverlayRect) -> Self {
Self { slot, rect }
}
}
/// Spawn one overlay for `client`. When `live_enabled()` is false, returns a
/// stub and never touches Wayland; otherwise it spawns a background thread
/// that connects to the compositor, creates a wlr-layer-shell surface for
/// this slot, draws the slot number into a shm-backed buffer, and posts
/// `swap <slot>` to the daemon unix socket on pointer button events.
///
/// `ipc_sock` is the daemon's listen socket — when the user clicks the
/// overlay we connect briefly, write one line, and disconnect. Pass the
/// same socket the daemon uses (`default_sock()`).
pub fn spawn(slot: u32, client: &Client) -> OverlayHandle {
spawn_with_sock(slot, client, None)
}
/// Like [`spawn`] but lets the caller pass the IPC socket explicitly.
pub fn spawn_with_sock(
slot: u32,
client: &Client,
ipc_sock: Option<std::path::PathBuf>,
) -> OverlayHandle {
let rect = overlay_rect(client.at[0], client.at[1], client.size[0], client.size[1]);
if !live_enabled() {
tracing::debug!("overlay: stub for slot {slot} at {rect:?}");
return OverlayHandle::stub(slot, rect);
}
let sock = ipc_sock.unwrap_or_else(crate::session::default_sock);
match crate::wayland_layer::spawn(slot, rect, sock) {
Ok(_) => OverlayHandle::stub(slot, rect),
Err(e) => {
tracing::warn!("overlay: live spawn failed for slot {slot}: {e}");
OverlayHandle::stub(slot, rect)
}
}
}
/// Track one overlay per active slot. Pure: no Wayland touched.
#[derive(Debug, Default)]
pub struct OverlayHub {
by_slot: std::collections::BTreeMap<u32, OverlayHandle>,
}
impl OverlayHub {
pub fn new() -> Self {
Self::default()
}
/// Pure: compute the spawn / kill diff between the current slot set and
/// the desired one. Tests use this to verify routing shape without
/// connecting to Wayland.
pub fn plan(
&self,
slots: &[(u32, Client)],
) -> (Vec<OverlayHandle>, Vec<u32>) {
let want: std::collections::BTreeMap<u32, &Client> =
slots.iter().map(|(s, c)| (*s, c)).collect();
let spawn_list: Vec<OverlayHandle> = want
.iter()
.filter(|(s, _)| !self.by_slot.contains_key(*s))
.map(|(s, c)| spawn(*s, c))
.collect();
let kill: Vec<u32> = self
.by_slot
.keys()
.filter(|s| !want.contains_key(s))
.copied()
.collect();
(spawn_list, kill)
}
/// Apply the plan: drop killed slots, insert spawned ones, return the
/// list of overlays the caller should actually open.
pub fn sync(
&mut self,
slots: &[(u32, Client)],
) -> Vec<OverlayHandle> {
let (to_spawn, kill) = self.plan(slots);
for k in kill {
self.by_slot.remove(&k);
}
for h in &to_spawn {
self.by_slot.insert(h.slot, h.clone());
}
to_spawn
}
pub fn len(&self) -> usize {
self.by_slot.len()
}
pub fn is_empty(&self) -> bool {
self.by_slot.is_empty()
}
pub fn kill_all(&mut self) {
self.by_slot.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn client(addr: &str, at: (i32, i32)) -> Client {
Client {
address: addr.into(),
class: "wow".into(),
title: addr.into(),
pid: 1,
at: [at.0, at.1],
size: [800, 600],
mapped: true,
hidden: false,
xwayland: true,
focus_history_id: 0,
}
}
#[test]
fn overlay_rect_is_anchored_top_left() {
let r = overlay_rect(1920, 200, 800, 600);
assert_eq!(r.x, 1920);
assert_eq!(r.y, 200);
assert_eq!(r.w, OVERLAY_W);
assert_eq!(r.h, OVERLAY_H);
}
#[test]
fn click_verb_is_swap_with_slot_number() {
assert_eq!(click_verb(3), "swap 3");
assert_eq!(click_verb(1), "swap 1");
}
#[test]
fn live_enabled_defaults_off_and_respects_env() {
std::env::remove_var("ENBOXER_ENABLE_OVERLAY");
assert!(!live_enabled());
std::env::set_var("ENBOXER_ENABLE_OVERLAY", "1");
assert!(live_enabled());
std::env::set_var("ENBOXER_ENABLE_OVERLAY", "true");
assert!(live_enabled());
std::env::set_var("ENBOXER_ENABLE_OVERLAY", "yes");
assert!(!live_enabled());
std::env::remove_var("ENBOXER_ENABLE_OVERLAY");
}
#[test]
fn spawn_returns_stub_when_live_disabled() {
std::env::remove_var("ENBOXER_ENABLE_OVERLAY");
let c = client("0xa", (100, 200));
let h = spawn(2, &c);
assert_eq!(h.slot, 2);
assert_eq!(h.rect.x, 100);
assert_eq!(h.rect.y, 200);
assert!(!live_enabled());
}
#[test]
fn plan_spawns_new_slots_and_kills_gone_ones() {
let mut hub = OverlayHub::new();
let s1 = vec![(1, client("0xa", (0, 0))), (2, client("0xb", (1000, 0)))];
let spawned = hub.sync(&s1);
assert_eq!(spawned.len(), 2);
assert_eq!(hub.len(), 2);
// slot 3 appears, slot 1 disappears
let s2 = vec![(2, client("0xb", (1000, 0))), (3, client("0xc", (0, 1000)))];
let spawned = hub.sync(&s2);
assert_eq!(spawned.len(), 1);
assert_eq!(spawned[0].slot, 3);
assert_eq!(hub.len(), 2);
assert!(hub.by_slot.contains_key(&2));
assert!(hub.by_slot.contains_key(&3));
assert!(!hub.by_slot.contains_key(&1));
}
#[test]
fn kill_all_clears() {
let mut hub = OverlayHub::new();
hub.sync(&[(1, client("0xa", (0, 0)))]);
assert!(!hub.is_empty());
hub.kill_all();
assert!(hub.is_empty());
}
}