//! 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> { 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 { 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) .map(|_| LayoutSlot { x: m.x, 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 { 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, 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 { 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, 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, y, w: sw.max(1), h: sh.max(1), pin: layout.pin, ..Default::default() }); } out } // Item 3: select_windows is now a placeholder. The real picker // runs in `session::refresh_slots` against `Session.spawned_pids`. // This stub returns the first `profile.slots` visible clients in // z-order so layout code that still calls select_windows during // the rest of the Item 3 rollout does not silently lose every // window. It will be deleted once refresh_slots fully owns slot // assignment. pub fn select_windows(_profile: &Profile, clients: Vec) -> Vec<(u32, Client)> { let mut visible: Vec = clients .into_iter() .filter(|c| c.mapped && !c.hidden && c.class != "enboxer-vfx") .collect(); visible.sort_by_key(|c| (c.at[1], c.at[0], c.pid)); visible .into_iter() .take(_profile.slots as usize) .enumerate() .map(|(i, c)| ((i as u32) + 1, c)) .collect() } /// 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 { 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 { let Some(geom0) = slots.get((*i as usize).saturating_sub(1)) 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 { let mut out = Vec::new(); for (_, c) in windows { out.push(LayoutSlot { x: c.at[0], 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, 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, y: 0, w: 100, h: 100, pin: true, ..Default::default() }, LayoutSlot { x: 100, 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"); } }