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
398 lines
11 KiB
Rust
398 lines
11 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 regex::Regex;
|
|
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)
|
|
.map(|_| LayoutSlot {
|
|
x: m.x,
|
|
y: m.y,
|
|
w,
|
|
h,
|
|
pin: layout.pin,
|
|
})
|
|
.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,
|
|
y: m.y + y as i32 * wh,
|
|
w: ww,
|
|
h: wh,
|
|
pin,
|
|
});
|
|
}
|
|
}
|
|
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,
|
|
y: main_y,
|
|
w: bw,
|
|
h: bh,
|
|
pin: true,
|
|
});
|
|
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,
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn select_windows(profile: &Profile, clients: Vec<Client>) -> Vec<(u32, Client)> {
|
|
let class_re = profile
|
|
.window_match
|
|
.class
|
|
.as_deref()
|
|
.and_then(|p| Regex::new(p).ok());
|
|
let title_re = profile
|
|
.window_match
|
|
.title
|
|
.as_deref()
|
|
.and_then(|p| Regex::new(p).ok());
|
|
if class_re.is_none() && title_re.is_none() {
|
|
return vec![];
|
|
}
|
|
let mut matched: Vec<Client> = clients
|
|
.into_iter()
|
|
.filter(|c| {
|
|
if c.class == "enboxer-vfx" {
|
|
return false;
|
|
}
|
|
let class_ok = class_re
|
|
.as_ref()
|
|
.map(|r| r.is_match(&c.class))
|
|
.unwrap_or(true);
|
|
let title_ok = title_re
|
|
.as_ref()
|
|
.map(|r| r.is_match(&c.title))
|
|
.unwrap_or(true);
|
|
class_ok && title_ok && c.mapped && !c.hidden
|
|
})
|
|
.collect();
|
|
matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
|
|
matched
|
|
.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<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 {
|
|
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<LayoutSlot> {
|
|
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,
|
|
});
|
|
}
|
|
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: 1280,
|
|
h: 1440,
|
|
pin: false,
|
|
};
|
|
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,
|
|
},
|
|
LayoutSlot {
|
|
x: 100,
|
|
y: 0,
|
|
w: 50,
|
|
h: 50,
|
|
pin: false,
|
|
},
|
|
];
|
|
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");
|
|
}
|
|
}
|