diff --git a/examples/profile.yaml b/examples/profile.yaml index f41028d..78ecf2d 100644 --- a/examples/profile.yaml +++ b/examples/profile.yaml @@ -105,6 +105,15 @@ maps: - bind: ctm_off target: others +# Window layout (pixels). Generate from the Layout page, then Save and Apply. +layout: + preset: main_strip + one_row: true + main_at_bottom: false + auto_apply: false + monitor: "" + slots: [] + # Viewer sits on the current primary. Hover + pass_through sends keys/clicks to source_slot only. video_fx: - name: alt_party diff --git a/src/engine.rs b/src/engine.rs index 004d3ac..5ae703f 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -262,6 +262,7 @@ mod tests { }, ], video_fx: vec![], + layout: Default::default(), }; Engine::new(profile).unwrap() } @@ -318,6 +319,7 @@ mod tests { release_steps: vec![], }], video_fx: vec![], + layout: Default::default(), }; let e = Engine::new(profile).unwrap(); assert!(e.should_intercept("e")); diff --git a/src/gui.rs b/src/gui.rs index 8d00fc1..5ee379b 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -3,7 +3,8 @@ use crate::hotkey::Hotkey; use crate::macros::print_macros; use crate::profile::{ - default_config_path, Group, Map, Mode, NormRect, Profile, Repeater, Step, VideoFx, WindowMatch, + default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step, + VideoFx, WindowMatch, }; use anyhow::Result; use eframe::egui::{self, Color32}; @@ -14,6 +15,7 @@ use std::process::{Child, Command, Stdio}; #[derive(Clone, Copy, PartialEq, Eq)] enum Page { Session, + Layout, Maps, Video, Macros, @@ -78,6 +80,7 @@ fn default_profile() -> Profile { groups: BTreeMap::::new(), maps: vec![], video_fx: vec![], + layout: Default::default(), } } @@ -286,6 +289,16 @@ impl eframe::App for App { ui.close_menu(); } }); + ui.menu_button("Layout", |ui| { + if ui.button("Window layout…").clicked() { + self.page = Page::Layout; + ui.close_menu(); + } + if ui.button("Apply layout now").clicked() { + self.apply_layout(); + ui.close_menu(); + } + }); ui.menu_button("Video", |ui| { if ui.button("Add crop").clicked() { self.profile.video_fx.push(VideoFx { @@ -340,6 +353,7 @@ impl eframe::App for App { .show(ctx, |ui| { ui.heading("enBoxer"); ui.selectable_value(&mut self.page, Page::Session, "Session"); + ui.selectable_value(&mut self.page, Page::Layout, "Layout"); ui.selectable_value(&mut self.page, Page::Maps, "Maps"); ui.selectable_value(&mut self.page, Page::Video, "Video FX"); ui.selectable_value(&mut self.page, Page::Macros, "Game macros"); @@ -357,6 +371,7 @@ impl eframe::App for App { egui::CentralPanel::default().show(ctx, |ui| match self.page { Page::Session => self.page_session(ui), + Page::Layout => self.page_layout(ui), Page::Maps => self.page_maps(ui), Page::Video => self.page_video(ui), Page::Macros => self.page_macros(ui), @@ -603,6 +618,131 @@ impl App { } } + fn apply_layout(&mut self) { + self.save(); + match Command::new(Self::exe()) + .args(["layout-apply", "-c"]) + .arg(&self.path) + .output() + { + Ok(o) => { + self.status = String::from_utf8_lossy(&o.stdout).trim().to_string(); + if !o.status.success() { + self.error = Some(String::from_utf8_lossy(&o.stderr).into()); + } + } + Err(e) => self.error = Some(e.to_string()), + } + } + + fn generate_layout_preview(&mut self) { + let out = Command::new("hyprctl").args(["-j", "monitors"]).output(); + let Ok(out) = out else { + self.error = Some("hyprctl monitors failed".into()); + return; + }; + let mons: Vec = + serde_json::from_slice(&out.stdout).unwrap_or_default(); + self.profile.layout.slots = + crate::layout::generate(&self.profile.layout, self.profile.slots, &mons); + self.status = format!("generated {} tiles", self.profile.layout.slots.len()); + } + + fn capture_layout(&mut self) { + let out = Command::new("hyprctl").args(["-j", "clients"]).output(); + let Ok(out) = out else { + return; + }; + let Ok(clients) = serde_json::from_slice::>(&out.stdout) else { + self.error = Some("parse clients".into()); + return; + }; + let windows = crate::layout::select_windows(&self.profile, clients); + self.profile.layout.slots = crate::layout::capture_from(&windows); + self.status = format!("captured {} window positions", windows.len()); + } + + fn page_layout(&mut self, ui: &mut egui::Ui) { + ui.heading("Window layout"); + ui.label("Place each game client: stacked, equal grid, or one large main plus a strip of minions. Save and Apply moves captured windows."); + ui.horizontal(|ui| { + ui.label("Windows"); + ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16)); + ui.label("Monitor"); + ui.text_edit_singleline(&mut self.profile.layout.monitor); + ui.label("(empty = largest)"); + }); + ui.horizontal(|ui| { + ui.selectable_value( + &mut self.profile.layout.preset, + LayoutPreset::Stacked, + "All stacked", + ); + ui.selectable_value( + &mut self.profile.layout.preset, + LayoutPreset::Grid, + "Same size grid", + ); + ui.selectable_value( + &mut self.profile.layout.preset, + LayoutPreset::MainStrip, + "Main + strip", + ); + }); + ui.horizontal(|ui| { + ui.checkbox( + &mut self.profile.layout.one_row, + "One row/col for small windows", + ); + ui.checkbox(&mut self.profile.layout.main_at_bottom, "Main at bottom"); + ui.checkbox(&mut self.profile.layout.pin, "Stay on top"); + ui.checkbox( + &mut self.profile.layout.auto_apply, + "Auto-apply when captured", + ); + }); + ui.horizontal(|ui| { + if ui.button("Generate").clicked() { + self.generate_layout_preview(); + } + if ui.button("Capture current positions").clicked() { + self.capture_layout(); + } + if ui + .add(egui::Button::new("Save and Apply").fill(Color32::from_rgb(180, 120, 40))) + .clicked() + { + self.generate_layout_preview(); + self.apply_layout(); + } + }); + ui.separator(); + ui.label("Per-window x y w h (pixels). Slot 1 is the main tile."); + while self.profile.layout.slots.len() < self.profile.slots as usize { + self.profile.layout.slots.push(crate::profile::LayoutSlot { + x: 0, + y: 0, + w: 800, + h: 600, + pin: false, + }); + } + self.profile + .layout + .slots + .truncate(self.profile.slots as usize); + for (i, s) in self.profile.layout.slots.iter_mut().enumerate() { + ui.horizontal(|ui| { + ui.label(format!("#{}", i + 1)); + ui.add(egui::DragValue::new(&mut s.x).prefix("x ")); + ui.add(egui::DragValue::new(&mut s.y).prefix("y ")); + ui.add(egui::DragValue::new(&mut s.w).prefix("w ")); + ui.add(egui::DragValue::new(&mut s.h).prefix("h ")); + ui.checkbox(&mut s.pin, "pin"); + }); + } + } + fn page_macros(&mut self, ui: &mut egui::Ui) { ui.heading("In-game macros"); ui.label("Bind these on every account. enBoxer only sends keystrokes."); diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..404b5fb --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,294 @@ +//! 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> { + 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); + 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), + } +} + +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, + }); + } + } + 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, + }); + 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) -> 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 = 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() +} + +pub async fn apply_for_profile(profile: &mut Profile) -> Result { + 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<()> { + for (i, win) in windows { + let Some(geom) = slots.get((*i as usize).saturating_sub(1)) else { + 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, + }); + } + 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)); + } + + #[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 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7de4dfc..ddb89d1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod engine; pub mod gui; pub mod hotkey; pub mod hypr; +pub mod layout; pub mod macros; pub mod profile; pub mod session; diff --git a/src/main.rs b/src/main.rs index a109ad1..f9f68f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,11 @@ enum Cmd { }, /// Control panel (default if you run `enboxer` with no command) Gui, + /// Generate and apply the window layout to captured game clients + LayoutApply { + #[arg(short, long)] + config: Option, + }, /// Called by Hyprland binds; not for humans Ipc { #[arg(long)] @@ -71,6 +76,16 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd.unwrap_or(Cmd::Gui) { Cmd::Gui => enboxer::gui::run(), + Cmd::LayoutApply { config } => { + let path = config.unwrap_or_else(profile::default_config_path); + let mut profile = Profile::load(&path)?; + let n = enboxer::layout::apply_for_profile(&mut profile).await?; + if let Ok(text) = serde_yaml::to_string(&profile) { + let _ = std::fs::write(&path, text); + } + println!("laid out {n} windows"); + Ok(()) + } Cmd::Run { config } => { let path = config.unwrap_or_else(profile::default_config_path); let profile = Profile::load(&path)?; diff --git a/src/profile.rs b/src/profile.rs index 8446b0c..c82fb42 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -34,6 +34,8 @@ pub struct Profile { pub maps: Vec, #[serde(default)] pub video_fx: Vec, + #[serde(default)] + pub layout: Layout, } fn default_client() -> String { @@ -200,6 +202,61 @@ pub struct VideoFx { fn default_true() -> bool { true } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum LayoutPreset { + Stacked, + Grid, + #[default] + MainStrip, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Layout { + #[serde(default)] + pub preset: LayoutPreset, + #[serde(default)] + pub same_size: bool, + #[serde(default)] + pub main_at_bottom: bool, + #[serde(default = "default_true")] + pub one_row: bool, + #[serde(default)] + pub pin: bool, + #[serde(default)] + pub auto_apply: bool, + /// Hyprland monitor name, empty = largest. + #[serde(default)] + pub monitor: String, + #[serde(default)] + pub slots: Vec, +} + +impl Default for Layout { + fn default() -> Self { + Self { + preset: LayoutPreset::MainStrip, + same_size: false, + main_at_bottom: false, + one_row: true, + pin: false, + auto_apply: true, + monitor: String::new(), + slots: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LayoutSlot { + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, + #[serde(default)] + pub pin: bool, +} fn default_fps() -> u32 { 12 }