enBoxer/src/layout.rs
en 863f7e584e Round 6 (Grok verification): fix the live-path breakers.
Grok round-6 verification was No-Go. Fixes:

A. LayoutSlot.id was written as 0 by every constructor
   (generate Stacked/Grid/main_strip, capture_from, the GUI pad
   literals), so the find-by-id lookups in resize_slot / move_slot
   never hit a tile and Free-mode Apply size/position always
   failed with "no layout slot for slot id N". Constructors now
   assign real 1-based ids (index+1, out.len()+1, i+2 for the
   strip); capture_from uses the slot id from the window tuple.
   layout::apply and reset_slot_lock now find the tile by id
   instead of by Vec index.

B. The `slots` IPC formatter emitted "{id} 0x{address} ..." while
   addresses already carry their own 0x prefix ("0xa"), producing
   "1 0x0xa ..."; the parser split on whitespace so any multi-word
   window title broke the field alignment. Both sides now use a
   tab separator and the address passes through unchanged.

C. examples/profile.yaml still shipped the dropped schema
   (window_match block + passthrough list). Replaced with a note
   that matching is by process tree and every mapped hotkey is
   intercepted.

D. CHANGELOG 0.1.0 still advertised passthrough (lines 19, 24)
   and window_match (27, 78). Annotated as removed.

E. Lying comments: launcher.rs called the prefix "per-team" (it is
   per-character); gui.rs::arm_auto_apply doc claimed it matched a
   regex.

F. arm_auto_apply still hardcoded an empty spawned-pid set and
   fell back to matching any client with a non-empty class -- the
   round-4 Item-3 placeholder was what actually ran. It now takes
   the real child pid and matches via pid_is_ancestor.

G. spawn_plan dropped the Child with no wait thread (zombie, same
   bug round-6 fixed in launch_game). Now reaps in a background
   thread.

H. page_session never refreshed the games list, so the dropdown
   was empty on first paint. Added a games_loaded flag and a
   one-shot refresh_games on first paint.

Plus: vfx env-var test race. toplevel_enabled_defaults_off_... and
capture_toplevel_is_gated_when_disabled both touch
ENBOXER_ENABLE_TOPLEVEL and cargo runs unit tests in parallel, so
the gated test intermittently saw the var set by its sibling (the
per-function `static` in session.rs does not serialise across
functions). Added a module-level ENV_LOCK in vfx::tests and
guarded both tests. Verified with three consecutive full runs.

Also: clippy unnecessary_cast in main_strip, and two rustdoc
warnings (raw <pid> and Arc<GbmDevice> read as HTML tags).

cargo test 103/103 (x3); clippy --all-targets -D warnings clean;
cargo doc --no-deps clean.
2026-09-17 08:24:03 +02:00

426 lines
12 KiB
Rust

//! Place captured game windows: stacked, equal grid, or one big + a strip of minions.
use crate::hypr::{self, Client};
use crate::profile::{Layout, LayoutPreset, LayoutSlot, Profile};
use anyhow::Result;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Monitor {
pub name: String,
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
}
pub async fn monitors() -> Result<Vec<Monitor>> {
let raw = hypr::hyprctl(["-j", "monitors"]).await?;
Ok(serde_json::from_str(&raw)?)
}
fn pick_monitor<'a>(mons: &'a [Monitor], name: &str) -> &'a Monitor {
if !name.is_empty() {
if let Some(m) = mons.iter().find(|m| m.name == name) {
return m;
}
}
mons.iter()
.max_by_key(|m| m.width as i64 * m.height as i64)
.unwrap_or(&mons[0])
}
fn split_for_n(n: u32) -> (u32, u32) {
let n = n.max(1);
let mut y = (n as f64).sqrt().floor() as u32;
y = y.max(1);
let mut x = (n as f64).sqrt().ceil() as u32;
x = x.max(1);
while x * y < n {
y += 1;
}
(x, y)
}
pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec<LayoutSlot> {
if mons.is_empty() || n == 0 {
return vec![];
}
let m = pick_monitor(mons, &layout.monitor);
let mut out = match layout.preset {
LayoutPreset::Stacked => {
let (w, h) = constrain(m.width, m.height);
(0..n)
.enumerate()
.map(|(i, _)| LayoutSlot {
x: m.x,
id: (i as u32) + 1,
y: m.y,
w,
h,
pin: layout.pin,
..Default::default()
})
.collect()
}
LayoutPreset::Grid => grid(m, n, layout.pin),
LayoutPreset::MainStrip => main_strip(m, n, layout),
};
for s in &mut out {
clamp_to_monitor(s, m);
}
out
}
fn clamp_to_monitor(s: &mut LayoutSlot, m: &Monitor) {
s.w = s.w.clamp(64, m.width.max(64));
s.h = s.h.clamp(64, m.height.max(64));
let max_x = m.x + m.width - s.w;
let max_y = m.y + m.height - s.h;
s.x = s.x.clamp(m.x.min(max_x), max_x.max(m.x));
s.y = s.y.clamp(m.y.min(max_y), max_y.max(m.y));
}
fn constrain(w: i32, h: i32) -> (i32, i32) {
(w.max(1), h.max(1))
}
fn grid(m: &Monitor, n: u32, pin: bool) -> Vec<LayoutSlot> {
let (cols, rows) = split_for_n(n);
let ww = m.width / cols as i32;
let wh = m.height / rows as i32;
let mut out = Vec::new();
for y in 0..rows {
for x in 0..cols {
if out.len() as u32 >= n {
break;
}
out.push(LayoutSlot {
x: m.x + x as i32 * ww,
id: out.len() as u32 + 1,
y: m.y + y as i32 * wh,
w: ww,
h: wh,
pin,
..Default::default()
});
}
}
out
}
fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec<LayoutSlot> {
if n <= 1 {
return grid(m, n, layout.pin);
}
let small_n = n - 1;
let (bw, bh, strip_h, strip_w, horizontal) = if layout.one_row {
let c = small_n as i32;
// wide monitor: strip along the bottom (or top)
let bh = (m.height as f64 * c as f64 / (c + 1) as f64).round() as i32;
(m.width, bh, m.height - bh, m.width, true)
} else {
let c = (n as i32 - 2).max(2);
let bw = (m.width as f64 * c as f64 / (c + 1) as f64).round() as i32;
let bh = (m.height as f64 * c as f64 / (c + 1) as f64).round() as i32;
(bw, bh, m.height - bh, m.width - bw, false)
};
let mut out = Vec::new();
let main_y = if layout.main_at_bottom {
m.y + (m.height - bh)
} else {
m.y
};
out.push(LayoutSlot {
x: m.x,
id: 1,
y: main_y,
w: bw,
h: bh,
pin: true, ..Default::default()
});
let sw = if horizontal {
m.width / small_n.max(1) as i32
} else {
strip_w.max(1)
};
let sh = if horizontal {
strip_h.max(1)
} else {
bh / small_n.max(1) as i32
};
let strip_y = if layout.main_at_bottom { m.y } else { m.y + bh };
for i in 0..small_n {
let (x, y) = if horizontal {
(m.x + i as i32 * sw, strip_y)
} else {
(m.x + bw, m.y + i as i32 * sh)
};
out.push(LayoutSlot {
x,
id: i + 2,
y,
w: sw.max(1),
h: sh.max(1),
pin: layout.pin,
..Default::default()
});
}
out
}
// Grok round 5 Item 6: select_windows used to return a fake
// first-N-visible list that disagreed with the daemon's real
// slot assignment (stable IDs from process-tree walk in
// refresh_slots). Callers must now read the slot list from the
// daemon over IPC so the GUI's view matches what refresh_slots
// produced. This helper shells out to `enboxer ipc slots` and
// parses the tab-separated response.
pub fn select_windows(_profile: &Profile, _clients: Vec<Client>) -> Vec<(u32, Client)> {
crate::layout::slots_from_daemon().unwrap_or_default()
}
/// Item 6: ask the daemon for the current slot assignments.
/// Returns a `Vec<(slot_id, Client)>` so the same shape as
/// `select_windows` is preserved.
pub fn slots_from_daemon() -> anyhow::Result<Vec<(u32, Client)>> {
let sock = crate::session::default_sock();
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("enboxer"));
let out = std::process::Command::new(exe)
.arg("ipc").arg("--sock").arg(&sock).arg("slots").output()?;
if !out.status.success() {
return Ok(Vec::new());
}
let text = String::from_utf8_lossy(&out.stdout);
// Round-6 B: the daemon emits tab-separated fields:
// <id>\t<address>\t<pid>\t<class>\t<title>
// (space-splitting broke any title with a space in it).
let mut out = Vec::new();
for line in text.lines() {
let mut it = line.split('\t');
if let (Some(id), Some(addr), Some(pid)) = (it.next(), it.next(), it.next()) {
let class = it.next().unwrap_or("").to_string();
let title = it.next().unwrap_or("").to_string();
if let (Ok(id), Ok(pid)) = (id.parse::<u32>(), pid.parse::<i32>()) {
out.push((id, Client {
address: addr.to_string(),
pid,
class,
title,
mapped: true,
hidden: false,
at: [0, 0],
size: [0, 0],
xwayland: false,
focus_history_id: 0,
}));
}
}
}
Ok(out)
}
/// Window move/resize/pin is off unless ENBOXER_ALLOW_LAYOUT=1.
pub fn moves_allowed() -> bool {
std::env::var("ENBOXER_ALLOW_LAYOUT")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
pub async fn apply_for_profile(profile: &mut Profile) -> Result<usize> {
if !moves_allowed() {
anyhow::bail!("layout moves disabled (set ENBOXER_ALLOW_LAYOUT=1)");
}
let mons = monitors().await?;
if profile.layout.slots.len() as u32 != profile.slots {
profile.layout.slots = generate(&profile.layout, profile.slots, &mons);
}
let clients = hypr::clients().await?;
let windows = select_windows(profile, clients);
apply(&profile.layout.slots, &windows).await?;
Ok(windows.len())
}
pub async fn apply(slots: &[LayoutSlot], windows: &[(u32, Client)]) -> Result<()> {
if !moves_allowed() {
anyhow::bail!("layout moves disabled (set ENBOXER_ALLOW_LAYOUT=1)");
}
let mons = monitors().await.unwrap_or_default();
for (i, win) in windows {
// Round-6: find the tile by its stable id, not by a
// Vec index. Session slot ids can have gaps after a hide.
let Some(geom0) = slots.iter().find(|s| s.id == *i) else {
continue;
};
let mut geom = geom0.clone();
if let Some(m) = mons.iter().find(|m| {
geom.x >= m.x && geom.y >= m.y && geom.x < m.x + m.width && geom.y < m.y + m.height
}) {
clamp_to_monitor(&mut geom, m);
} else if let Some(m) = mons.first() {
clamp_to_monitor(&mut geom, m);
}
if geom.w < 64 || geom.h < 64 {
continue;
}
let sel = win.address_selector();
hypr::dispatch_lua(&format!(
"hl.dsp.window.float({{ window = {sel:?}, action = \"on\" }})"
))
.await
.ok();
hypr::move_resize_window(&sel, geom.x, geom.y, geom.w, geom.h).await?;
let pin = if geom.pin { "on" } else { "off" };
hypr::dispatch_lua(&format!(
"hl.dsp.window.pin({{ window = {sel:?}, action = {pin:?} }})"
))
.await
.ok();
}
Ok(())
}
pub fn capture_from(windows: &[(u32, Client)]) -> Vec<LayoutSlot> {
let mut out = Vec::new();
for (id, c) in windows {
out.push(LayoutSlot {
x: c.at[0],
id: *id,
y: c.at[1],
w: c.size[0],
h: c.size[1],
pin: false,
..Default::default()
});
}
out
}
/// Swap geometry of slot `n` with slot 1 (the main tile).
pub fn swap_main(slots: &mut [LayoutSlot], n: usize) {
if n == 0 || n >= slots.len() {
return;
}
slots.swap(0, n);
}
#[cfg(test)]
mod tests {
use super::*;
fn mon() -> Monitor {
Monitor {
name: "DP-1".into(),
x: 0,
y: 0,
width: 1920,
height: 1080,
}
}
#[test]
fn stacked_same_rect() {
let l = Layout {
preset: LayoutPreset::Stacked,
..Default::default()
};
let s = generate(&l, 3, &[mon()]);
assert_eq!(s.len(), 3);
assert!(s
.iter()
.all(|w| w.w == 1920 && w.h == 1080 && w.x == 0 && w.y == 0));
}
#[test]
fn moves_allowed_defaults_off() {
std::env::remove_var("ENBOXER_ALLOW_LAYOUT");
assert!(!moves_allowed());
std::env::set_var("ENBOXER_ALLOW_LAYOUT", "1");
assert!(moves_allowed());
std::env::remove_var("ENBOXER_ALLOW_LAYOUT");
}
#[test]
fn clamp_rejects_negative() {
let m = mon();
let mut s = LayoutSlot {
x: -400,
id: 0,
y: -500,
w: 1440,
h: 1440,
pin: false,
..Default::default()
};
clamp_to_monitor(&mut s, &m);
assert!(s.x >= 0 && s.y >= 0);
assert!(s.x + s.w <= m.width);
assert!(s.y + s.h <= m.height);
}
#[test]
fn grid_five() {
let l = Layout {
preset: LayoutPreset::Grid,
..Default::default()
};
let s = generate(&l, 5, &[mon()]);
assert_eq!(s.len(), 5);
assert!(s.iter().all(|w| w.w > 0 && w.h > 0));
}
#[test]
fn swap_main_exchanges_first() {
let mut s = vec![
LayoutSlot {
x: 0,
id: 0,
y: 0,
w: 100,
h: 100,
pin: true,
..Default::default()
},
LayoutSlot {
x: 100,
id: 0,
y: 0,
w: 50,
h: 50,
pin: false,
..Default::default()
},
];
swap_main(&mut s, 1);
assert_eq!(s[0].w, 50);
assert_eq!(s[1].w, 100);
}
#[test]
fn main_strip_has_big_first() {
let l = Layout {
preset: LayoutPreset::MainStrip,
one_row: true,
..Default::default()
};
let s = generate(&l, 4, &[mon()]);
assert_eq!(s.len(), 4);
assert!(s[0].w * s[0].h > s[1].w * s[1].h);
}
#[tokio::test]
async fn apply_short_circuits_when_layout_disallowed() {
// T12: apply_layout must short-circuit when ENBOXER_ALLOW_LAYOUT is unset
// (or anything other than "1"/"true"). It must NOT touch hyprctl.
std::env::remove_var("ENBOXER_ALLOW_LAYOUT");
let err = apply(&[], &[]).await.unwrap_err();
assert!(err.to_string().contains("ENBOXER_ALLOW_LAYOUT"));
std::env::set_var("ENBOXER_ALLOW_LAYOUT", "0");
let err = apply(&[], &[]).await.unwrap_err();
assert!(err.to_string().contains("ENBOXER_ALLOW_LAYOUT"));
std::env::remove_var("ENBOXER_ALLOW_LAYOUT");
}
}