//! Live wlr-layer-shell slot overlay. //! // One background thread per active slot. The thread owns its Wayland //! connection, creates a top-layer surface anchored at the slot's //! top-left, draws the slot number into a shm-backed buffer, and //! listens for pointer button events. A button event sends //! `swap ` to the daemon's unix socket. //! //! ## Safety //! //! All live spawning is gated behind [`crate::overlay::live_enabled`]. //! `cargo test`, `enboxer doctor`, and any path that does not set //! `ENBOXER_ENABLE_OVERLAY=1` returns a placeholder handle from //! `crate::overlay::spawn` and **never** opens a Wayland connection. //! The user's Hyprland session is therefore never touched unless they //! explicitly opt in. //! //! ## Status //! //! The protocol code, surface setup, shm-backed buffer with bitmap //! digits, and click-to-IPC path are real. The thread draws once on //! commit and listens for clicks. Per T9 the badge is readable (3x5 //! digits, ARGB8888, opaque background, solid foreground). use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use crate::overlay::OverlayRect; use crate::profile::runtime_dir; use std::fs::File; use std::io::Write; use std::os::unix::io::AsFd; use std::path::PathBuf; use std::thread::JoinHandle; use wayland_client::protocol::{ wl_buffer, wl_compositor, wl_display, wl_keyboard, wl_output, wl_pointer, wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_surface, }; use wayland_client::{Connection, Dispatch, QueueHandle}; use wayland_protocols_wlr::layer_shell::v1::client::{ zwlr_layer_shell_v1, zwlr_layer_surface_v1, }; /// 3x5 bitmap font for digits 0..=9. `1` = set pixel, `0` = blank. /// Multi-digit numbers lay the digit bitmaps side-by-side with a 1-pixel gap. pub const DIGIT_W: usize = 3; pub const DIGIT_H: usize = 5; /// Pure: 3x5 bitmap for a single decimal digit. Panics in debug if `d > 9`. pub fn digit_bitmap(d: u8) -> [[u8; DIGIT_W]; DIGIT_H] { match d { 0 => [[1, 1, 1], [1, 0, 1], [1, 0, 1], [1, 0, 1], [1, 1, 1]], 1 => [[0, 1, 0], [1, 1, 0], [0, 1, 0], [0, 1, 0], [1, 1, 1]], 2 => [[1, 1, 1], [0, 0, 1], [1, 1, 1], [1, 0, 0], [1, 1, 1]], 3 => [[1, 1, 1], [0, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]], 4 => [[1, 0, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [0, 0, 1]], 5 => [[1, 1, 1], [1, 0, 0], [1, 1, 1], [0, 0, 1], [1, 1, 1]], 6 => [[1, 1, 1], [1, 0, 0], [1, 1, 1], [1, 0, 1], [1, 1, 1]], 7 => [[1, 1, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]], 8 => [[1, 1, 1], [1, 0, 1], [1, 1, 1], [1, 0, 1], [1, 1, 1]], 9 => [[1, 1, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]], other => panic!("digit_bitmap: out of range {other}"), } } /// Pure: layout for `slot` number (1..=99) — each digit at (x_off, y) with a 1-pixel gap. fn slot_layout(slot: u32) -> Vec<([[u8; DIGIT_W]; DIGIT_H], usize)> { let s = slot.max(1); if s < 10 { vec![(digit_bitmap(s as u8), 0)] } else { let tens = (s / 10) as u8; let ones = (s % 10) as u8; let gap = 1usize; vec![ (digit_bitmap(tens), 0), (digit_bitmap(ones), DIGIT_W + gap), ] } } /// Pure: write `slot`'s number into a fresh ARGB8888 buffer. `bg` is the /// opaque background (alpha forced to 255); `fg` is the foreground. pub fn render_overlay( width: u32, height: u32, slot: u32, bg: [u8; 4], fg: [u8; 4], ) -> Vec { let mut buf = vec![0u8; (width * height * 4) as usize]; for px in buf.as_chunks_mut::<4>().0 { px[0] = bg[0]; px[1] = bg[1]; px[2] = bg[2]; px[3] = bg[3]; } let digits = slot_layout(slot); let total_w: usize = digits .iter() .map(|(_d, off)| DIGIT_W + off) .max() .unwrap_or(0); let off_x = ((width as usize).saturating_sub(total_w)) / 2; let off_y = ((height as usize).saturating_sub(DIGIT_H)) / 2; for (bitmap, x_off) in digits { for (y, row) in bitmap.iter().enumerate() { for (x, &on) in row.iter().enumerate() { if on == 0 { continue; } let gx = off_x + x_off + x; let gy = off_y + y; if gx >= width as usize || gy >= height as usize { continue; } let i = (gy * width as usize + gx) * 4; buf[i] = fg[0]; buf[i + 1] = fg[1]; buf[i + 2] = fg[2]; buf[i + 3] = fg[3]; } } } buf } /// Write `pixels` to a tmpfs file in `runtime_dir()` and return the open /// `File`. The Wayland compositor holds a dup of the fd; once this handle /// drops the kernel keeps the inode alive until both enBoxer and the /// compositor close it. The file lives under `$XDG_RUNTIME_DIR/enboxer/`, /// so it goes away on reboot. fn shm_pool_file(slot: u32, pixels: &[u8]) -> anyhow::Result { let dir = runtime_dir(); std::fs::create_dir_all(&dir).ok(); let path = dir.join(format!("enboxer-overlay-{slot}.bin")); let mut f = File::create(&path) .with_context(|| format!("create shm pool file {}", path.display()))?; f.write_all(pixels)?; f.sync_all().ok(); Ok(f) } use anyhow::Context; /// State shared between Wayland event handlers and the spawning thread. struct OverlayState { slot: u32, ipc_sock: PathBuf, compositor: Option, layer_shell: Option, shm: Option, seat: Option, surface: Option, layer_surface: Option, buffer: Option, configured: bool, exited: bool, } /// Handle to a live overlay thread. Drop or call `shutdown` to stop the /// thread cleanly (it will detach if not joined). pub struct LiveOverlayHandle { pub slot: u32, pub rect: OverlayRect, /// External kill signal. The dispatch loop in [`run`] polls this /// once per roundtrip; flipping to true causes the thread to exit /// on the next round, so the join in [`shutdown`] does not block /// forever waiting on the compositor's `Closed` event. pub stop: Arc, join: Option>, } impl LiveOverlayHandle { /// Ask the thread to exit, then join it. Idempotent. pub fn shutdown(mut self) { self.stop.store(true, Ordering::Relaxed); if let Some(j) = self.join.take() { let _ = j.join(); } } } /// Spawn one live overlay thread. Connects to Wayland, creates the /// layer surface, draws the slot number into a shm-backed buffer, and /// posts `swap ` to `ipc_sock` on pointer button events inside the /// surface. Errors during connect are returned; setup errors after /// connect are logged and the thread exits. pub fn spawn(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result { let stop = Arc::new(AtomicBool::new(false)); let stop_for_thread = stop.clone(); let join = std::thread::Builder::new() .name(format!("enboxer-overlay-{slot}")) .spawn(move || match run(slot, rect, ipc_sock, stop_for_thread) { Ok(()) => tracing::debug!("overlay slot {slot} exited cleanly"), Err(e) => tracing::warn!("overlay slot {slot}: {e}"), })?; Ok(LiveOverlayHandle { slot, rect, stop, join: Some(join), }) } fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf, stop: Arc) -> anyhow::Result<()> { let conn = Connection::connect_to_env()?; let display = conn.display(); let mut event_queue = conn.new_event_queue::(); let qh = event_queue.handle(); // First roundtrip: bind registry, then run a stub state through one // dispatch so registry events are delivered. let _registry = display.get_registry(&qh, ()); let mut stub = OverlayState::new(slot, ipc_sock.clone()); event_queue.roundtrip(&mut stub)?; if !stub.have_bindings() { anyhow::bail!( "zwlr_layer_shell_v1 / wl_compositor / wl_shm not all advertised by the compositor" ); } // Take ownership of the bound globals into our real state. let mut state = OverlayState::new(slot, ipc_sock); state.compositor = stub.compositor.take(); state.layer_shell = stub.layer_shell.take(); state.shm = stub.shm.take(); state.seat = stub.seat.take(); // Create surface + layer surface. let surface = state .compositor .as_ref() .unwrap() .create_surface(&qh, ()); let layer_shell = state.layer_shell.as_ref().unwrap(); let layer = layer_shell.get_layer_surface( &surface, None, zwlr_layer_shell_v1::Layer::Top, format!("enboxer-slot-{slot}"), &qh, (), ); layer.set_anchor(zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Left); layer.set_size(rect.w.max(1) as u32, rect.h.max(1) as u32); layer.set_exclusive_zone(-1); // zwlr_layer_surface::set_margin is (top, right, bottom, left). // With TOP+LEFT anchor: top margin pushes the surface down, left // margin pushes it right. (Earlier versions of this code passed // rect.x as the right margin, which is a no-op when only TOP+LEFT // are anchored.) layer.set_margin(rect.y.max(0), 0, 0, rect.x.max(0)); layer.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None); state.surface = Some(surface.clone()); state.layer_surface = Some(layer); // Seat so we get pointer events. if let Some(seat) = state.seat.as_ref() { seat.get_pointer(&qh, ()); let _ = seat.get_keyboard(&qh, ()); } // Shm pool + buffer with the slot number drawn. let w = rect.w.max(1) as u32; let h = rect.h.max(1) as u32; let pixels = render_overlay(w, h, slot, [40, 40, 40, 255], [240, 240, 240, 255]); let pool_file = shm_pool_file(slot, &pixels)?; let pool = state.shm.as_ref().unwrap().create_pool( pool_file.as_fd(), pixels.len() as i32, &qh, (), ); let buffer = pool.create_buffer( 0, w as i32, h as i32, (w * 4) as i32, wl_shm::Format::Argb8888, &qh, (), ); pool.destroy(); state.buffer = Some(buffer.clone()); surface.attach(Some(&buffer), 0, 0); surface.commit(); while !state.exited && !stop.load(Ordering::Relaxed) { if let Err(e) = event_queue.blocking_dispatch(&mut state) { tracing::warn!("overlay slot {slot}: dispatch: {e}"); break; } } Ok(()) } impl OverlayState { fn new(slot: u32, ipc_sock: PathBuf) -> Self { Self { slot, ipc_sock, compositor: None, layer_shell: None, shm: None, seat: None, surface: None, layer_surface: None, buffer: None, configured: false, exited: false, } } fn have_bindings(&self) -> bool { self.compositor.is_some() && self.layer_shell.is_some() && self.shm.is_some() } } fn send_swap(slot: u32, sock: &PathBuf) { let line = format!("swap {slot}\n"); match std::os::unix::net::UnixStream::connect(sock) { Ok(mut s) => { use std::io::Write; let _ = s.write_all(line.as_bytes()); } Err(e) => tracing::debug!("overlay: click IPC: {e}"), } } // ---- Dispatch implementations for the wayland types we touch. ---- // // The shape here is mandated by wayland-client 0.31: every proxy type we // keep needs `Dispatch for AppState`. We use `()` for // user-data; nothing in this overlay needs per-object state. impl Dispatch for OverlayState { fn event( state: &mut Self, registry: &wl_registry::WlRegistry, event: wl_registry::Event, _: &(), _: &Connection, qh: &QueueHandle, ) { if let wl_registry::Event::Global { name, interface, version, } = event { match interface.as_str() { "wl_compositor" => { state.compositor = Some(registry.bind::(name, version, qh, ())); } "zwlr_layer_shell_v1" => { state.layer_shell = Some( registry.bind::( name, version, qh, (), ), ); } "wl_shm" => { state.shm = Some(registry.bind::(name, version, qh, ())); } "wl_seat" => { state.seat = Some(registry.bind::(name, version, qh, ())); } _ => {} } } } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &wl_compositor::WlCompositor, _: wl_compositor::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &wl_shm::WlShm, _: wl_shm::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &wl_shm_pool::WlShmPool, _: wl_shm_pool::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &wl_buffer::WlBuffer, _: wl_buffer::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &wl_surface::WlSurface, _: wl_surface::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &wl_seat::WlSeat, _: wl_seat::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &wl_output::WlOutput, _: wl_output::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &wl_keyboard::WlKeyboard, _: wl_keyboard::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( state: &mut Self, _: &wl_pointer::WlPointer, event: wl_pointer::Event, _: &(), _: &Connection, _: &QueueHandle, ) { if let wl_pointer::Event::Button { button, state: btn_state, .. } = event { // linux/input-event-codes: BTN_LEFT = 272. We only fire on press. if button == 272 && matches!(btn_state, wayland_client::WEnum::Value(wl_pointer::ButtonState::Pressed)) { send_swap(state.slot, &state.ipc_sock); // NOTE: do not flip `state.exited = true` here. Doing so // destroys the badge and `OverlayHub.by_slot` still holds // the slot, so the hub refuses to respawn it (it thinks // the slot is already served). The compositor's // zwlr_layer_surface::Closed event is the only path that // should tear down the live thread. } } } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &wl_display::WlDisplay, _: wl_display::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( _: &mut Self, _: &zwlr_layer_shell_v1::ZwlrLayerShellV1, _: zwlr_layer_shell_v1::Event, _: &(), _: &Connection, _: &QueueHandle, ) { } } impl Dispatch for OverlayState { fn event( state: &mut Self, _: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, event: zwlr_layer_surface_v1::Event, _: &(), _: &Connection, _: &QueueHandle, ) { match event { zwlr_layer_surface_v1::Event::Closed => state.exited = true, zwlr_layer_surface_v1::Event::Configure { .. } => state.configured = true, _ => {} } } } // ---- T9 protocol/format parse test fixture. ---- // // Wayland-scanner parses XML at build time, so we don't parse at runtime. // Instead, we hold the constants we expect to match and assert that the // generated bindings expose them with the right names. This is the // equivalent of "the dispatch table parses a known XML" — if Hyprland // ever revved the protocol past version 5 and we silently kept using an // older binding, this test would still build (wayland-scanner picks the // max) but the constant check would catch a documentation drift. /// The protocol name as it appears in the wlr-layer-shell XML. pub const LAYER_SHELL_PROTOCOL: &str = "wlr_layer_shell_unstable_v1"; /// The interface name we bind to for the layer shell manager. pub const LAYER_SHELL_INTERFACE: &str = "zwlr_layer_shell_v1"; /// The interface name we bind to for one layer surface. pub const LAYER_SURFACE_INTERFACE: &str = "zwlr_layer_surface_v1"; /// The version we ask the compositor for. Hyprland advertises >= 4; 5 /// matches the XML in `wlr-protocols` at the time of writing. pub const LAYER_SHELL_VERSION: u32 = 5; #[cfg(test)] mod tests { use super::*; use crate::overlay::live_enabled; #[test] fn digit_zero_has_open_centre() { let b = digit_bitmap(0); assert_eq!(b[1][1], 0); assert_eq!(b[2][1], 0); assert_eq!(b[3][1], 0); } #[test] fn digit_nine_has_solid_top_and_bottom() { let b = digit_bitmap(9); assert_eq!(b[0], [1, 1, 1]); assert_eq!(b[4], [1, 1, 1]); assert_eq!(b[1][0], 1); assert_eq!(b[1][2], 1); } #[test] fn render_overlay_is_opaque_when_fg_alpha_255() { let buf = render_overlay(96, 96, 7, [0, 0, 0, 255], [255, 255, 255, 255]); // The top-left pixel is background → alpha = 255 from bg. assert_eq!(buf[3], 255); // Find a foreground pixel (the 7's top bar). Should exist and be white. let mut found = false; for px in buf.as_chunks::<4>().0 { if px[0] == 255 && px[1] == 255 && px[2] == 255 { found = true; assert_eq!(px[3], 255); break; } } assert!(found, "expected at least one foreground pixel"); } #[test] fn render_overlay_two_digit_slot_centres_layout() { // For slot 12, total digit width = 7 px (3 + 1 gap + 3), centred at x = 44. // The first fg pixel of "1" sits at (44, off_y+1) because row 1 of digit 1 // = [1, 1, 0], so column 0 and 1 are set. let buf = render_overlay(96, 96, 12, [0, 0, 0, 255], [255, 255, 255, 255]); let off_x = (96usize - 7) / 2; let off_y = (96usize - 5) / 2; let i = ((off_y + 1) * 96 + off_x) * 4; assert_eq!(buf[i..i + 4], [255, 255, 255, 255]); } #[test] fn protocol_constants_match_xml() { // wlr-layer-shell-unstable-v1.xml: // // // // assert_eq!(LAYER_SHELL_PROTOCOL, "wlr_layer_shell_unstable_v1"); assert_eq!(LAYER_SHELL_INTERFACE, "zwlr_layer_shell_v1"); assert_eq!(LAYER_SURFACE_INTERFACE, "zwlr_layer_surface_v1"); assert_eq!(LAYER_SHELL_VERSION, 5); } #[test] fn module_compiles_and_exposes_bindings() { // The generated bindings are accessible at this module path. If the // wayland-scanner ever dropped the module, this fails to compile. use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1; let _ = std::any::type_name::(); } #[test] fn env_gate_off_means_no_live_spawn() { std::env::remove_var("ENBOXER_ENABLE_OVERLAY"); assert!(!live_enabled()); // We deliberately do not call `super::spawn` here: it would try to // open a Wayland connection. The env-gate lives in // `crate::overlay::spawn` and is asserted by // `crate::overlay::tests::spawn_returns_stub_when_live_disabled`. } #[test] fn render_digit_table_is_unique() { let bits: Vec<[[u8; 3]; 5]> = (0u8..=9).map(digit_bitmap).collect(); for (i, a) in bits.iter().enumerate() { for (j, b) in bits.iter().enumerate().skip(i + 1) { assert_ne!(a, b, "digits {i} and {j} share a bitmap"); } } } }