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
261 lines
8.2 KiB
Rust
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());
|
|
}
|
|
}
|