//! 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 ` 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 ` 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, ) -> 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, } 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, Vec) { let want: std::collections::BTreeMap = slots.iter().map(|(s, c)| (*s, c)).collect(); let spawn_list: Vec = want .iter() .filter(|(s, _)| !self.by_slot.contains_key(*s)) .map(|(s, c)| spawn(*s, c)) .collect(); let kill: Vec = 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 { 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()); } }