diff --git a/src/gui.rs b/src/gui.rs index f89bcf2..cc15938 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -3,7 +3,7 @@ use crate::hotkey::Hotkey; use crate::macros::print_macros; use crate::profile::{ - Character, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, + Character, default_config_path, Group, LayoutMode, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step, VideoFx, }; use crate::session; @@ -61,6 +61,9 @@ pub fn run() -> Result<()> { layout_drag: None, profile_names_list: Vec::new(), teams: AppTeams::default(), + // Item 1: Free-mode buttons push IPC verbs here; the main + // loop drains the queue and writes them to the socket. + pending_ipc: Vec::new(), }; eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app)))) .map_err(|e| anyhow::anyhow!("{e}"))?; @@ -112,6 +115,9 @@ struct App { /// T15 state. The Lutris picker and the team builder are scoped /// here so they never leak onto other pages. teams: AppTeams, + /// Item 1: Free-mode buttons push IPC verbs here; the main + /// loop drains the queue and writes them to the socket. + pending_ipc: Vec, } #[derive(Default)] @@ -1141,6 +1147,80 @@ impl App { fn page_layout(&mut self, ui: &mut egui::Ui) { ui.heading("Window layout"); + // Item 1: Managed vs Free-arrange mode toggle. The whole + // rest of the page adapts. + ui.horizontal(|ui| { + ui.label("Mode"); + ui.selectable_value( + &mut self.profile.layout.mode, + LayoutMode::Managed, + "Managed (auto-arrange grid)", + ); + ui.selectable_value( + &mut self.profile.layout.mode, + LayoutMode::Free, + "Free-arrange (per-window size, no overwrite)", + ); + }); + if self.profile.layout.mode == LayoutMode::Free { + self.page_layout_free(ui); + } else { + self.page_layout_managed(ui); + } + } + + /// Item 1: free-arrange layout editor. Per-window W/H picker, + /// Apply-size button (no position overwrite), Apply-position + /// button, Reset-size button. No auto-apply, no preset grid. + fn page_layout_free(&mut self, ui: &mut egui::Ui) { + ui.label("Free-arrange: pick an initial resolution per window. The daemon applies it ONCE; later manual resizes are respected. Use Apply-size to force a resize without restarting the game."); + ui.horizontal(|ui| { + ui.label("Windows"); + ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16)); + }); + ui.separator(); + ui.label("Per-window initial size. The daemon applies it once; the slot is then locked unless you click Reset-size."); + 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, + ..Default::default() + }); + } + self.profile.layout.slots.truncate(self.profile.slots as usize); + for (i, s) in self.profile.layout.slots.iter_mut().enumerate() { + let slot = (i + 1) as u32; + let mut w_val = s.initial_size.map(|t| t.0 as i32).unwrap_or(s.w); + let mut h_val = s.initial_size.map(|t| t.1 as i32).unwrap_or(s.h); + ui.horizontal(|ui| { + ui.label(format!("#{} initial", i + 1)); + ui.add(egui::DragValue::new(&mut w_val).prefix("w ").range(64..=7680)); + ui.add(egui::DragValue::new(&mut h_val).prefix("h ").range(64..=4320)); + if ui.button("Apply size").clicked() { + s.initial_size = Some((w_val.max(0) as u32, h_val.max(0) as u32)); + s.size_locked = true; + self.pending_ipc.push(format!("resize-slot {}", slot)); + } + if ui.button("Apply position").clicked() { + self.pending_ipc.push(format!("move-slot {}", slot)); + } + if ui.button("Reset size").clicked() { + s.size_locked = false; + self.pending_ipc.push(format!("reset-slot {}", slot)); + } + if s.size_locked { + ui.label("(locked)"); + } else { + ui.label("(unlocked)"); + } + }); + } + } + + /// Item 1: managed-mode layout editor (existing wizard). + fn page_layout_managed(&mut self, ui: &mut egui::Ui) { ui.label("Place each game client: stacked, equal grid, or one large main plus a strip of minions. Drag tiles below. Save and Apply moves captured windows (only if allowed)."); self.layout_canvas(ui); ui.horizontal(|ui| { @@ -1205,7 +1285,7 @@ impl App { y: 0, w: 800, h: 600, - pin: false, + pin: false, ..Default::default() }); } self.profile diff --git a/src/hypr.rs b/src/hypr.rs index 371733e..79ec5e9 100644 --- a/src/hypr.rs +++ b/src/hypr.rs @@ -159,11 +159,8 @@ pub async fn move_cursor(x: i32, y: i32) -> Result<()> { Ok(()) } -pub async fn move_resize_window(selector: &str, x: i32, y: i32, w: i32, h: i32) -> Result<()> { - dispatch_lua(&format!( - "hl.dsp.window.move({{ window = {selector:?}, x = {x}, y = {y}, relative = false }})" - )) - .await?; +/// Item 1: resize a window without touching its position. +pub async fn resize_window(selector: &str, w: i32, h: i32) -> Result<()> { dispatch_lua(&format!( "hl.dsp.window.resize({{ window = {selector:?}, x = {w}, y = {h}, relative = false }})" )) @@ -171,6 +168,23 @@ pub async fn move_resize_window(selector: &str, x: i32, y: i32, w: i32, h: i32) Ok(()) } +/// Item 1: move a window without touching its size. +pub async fn move_window(selector: &str, x: i32, y: i32) -> Result<()> { + dispatch_lua(&format!( + "hl.dsp.window.move({{ window = {selector:?}, x = {x}, y = {y}, relative = false }})" + )) + .await?; + Ok(()) +} + +/// Item 1: combined move + resize. Kept as a thin wrapper so the +/// original layout-apply IPC path keeps working unchanged. +pub async fn move_resize_window(selector: &str, x: i32, y: i32, w: i32, h: i32) -> Result<()> { + move_window(selector, x, y).await?; + resize_window(selector, w, h).await?; + Ok(()) +} + const CLEAR_BINDS: &str = r#" _G.enboxer = _G.enboxer or { binds = {} } for _, b in ipairs(_G.enboxer.binds) do diff --git a/src/layout.rs b/src/layout.rs index 691ee30..07ff7b4 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -57,6 +57,7 @@ pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec { w, h, pin: layout.pin, + ..Default::default() }) .collect() } @@ -98,6 +99,7 @@ fn grid(m: &Monitor, n: u32, pin: bool) -> Vec { w: ww, h: wh, pin, + ..Default::default() }); } } @@ -131,7 +133,7 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec { y: main_y, w: bw, h: bh, - pin: true, + pin: true, ..Default::default() }); let sw = if horizontal { m.width / small_n.max(1) as i32 @@ -156,6 +158,7 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec { w: sw.max(1), h: sh.max(1), pin: layout.pin, + ..Default::default() }); } out @@ -249,6 +252,7 @@ pub fn capture_from(windows: &[(u32, Client)]) -> Vec { w: c.size[0], h: c.size[1], pin: false, + ..Default::default() }); } out @@ -304,9 +308,10 @@ mod tests { let mut s = LayoutSlot { x: -400, y: -500, - w: 1280, + w: 1440, h: 1440, pin: false, + ..Default::default() }; clamp_to_monitor(&mut s, &m); assert!(s.x >= 0 && s.y >= 0); @@ -334,6 +339,7 @@ mod tests { w: 100, h: 100, pin: true, + ..Default::default() }, LayoutSlot { x: 100, @@ -341,6 +347,7 @@ mod tests { w: 50, h: 50, pin: false, + ..Default::default() }, ]; swap_main(&mut s, 1); diff --git a/src/profile.rs b/src/profile.rs index f515f30..66d8ed9 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -233,8 +233,30 @@ pub enum LayoutPreset { MainStrip, } +/// Item 1: layout execution mode. +/// +/// Managed: daemon regenerates slot geometry + applies on capture +/// (the existing wizard behaviour). +/// +/// Free: daemon only applies the per-window initial size ONCE +/// (when size_locked is false). Once the operator resizes, the +/// daemon does NOT keep overwriting. The GUI exposes +/// Apply-size / Apply-position buttons that fire on demand. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum LayoutMode { + #[default] + Managed, + Free, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Layout { + /// Item 1: Managed (default) vs Free-arrange mode. + #[serde(default)] + pub mode: LayoutMode, #[serde(default)] pub preset: LayoutPreset, #[serde(default)] @@ -259,10 +281,26 @@ pub struct Layout { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct LayoutSlot { + /// Item 1: cached last-known good geometry, used by Managed + /// mode. Ignored by Free mode. pub x: i32, pub y: i32, pub w: i32, pub h: i32, + /// Item 1: initial size applied ONCE in Free mode (and never + /// again once size_locked is true). Ignored by Managed mode. + #[serde(default)] + pub initial_size: Option<(u32, u32)>, + /// Item 1: explicit position for Free mode (drag-and-drop + /// target). Ignored by Managed mode. + #[serde(default)] + pub pos: Option<(i32, i32)>, + /// Item 1: when true, Free mode stops applying size to this + /// slot on refresh (operator resized and we should not undo + /// their change). Set by `reset-slot N` IPC verb to allow + /// re-application. + #[serde(default)] + pub size_locked: bool, #[serde(default)] pub pin: bool, } diff --git a/src/session.rs b/src/session.rs index 7663f7d..3cee531 100644 --- a/src/session.rs +++ b/src/session.rs @@ -574,6 +574,18 @@ async fn dispatch_cmd(session: &Arc>, line: &str) -> String { .unwrap_or_default(); games.iter().map(|g| g.name.clone()).collect::>().join("\n") } + "resize-slot" => { + let n = arg.parse::().unwrap_or(0); + wm_ok(resize_slot(session, n).await) + } + "move-slot" => { + let n = arg.parse::().unwrap_or(0); + wm_ok(move_slot(session, n).await) + } + "reset-slot" => { + let n = arg.parse::().unwrap_or(0); + wm_ok(reset_slot_lock(session, n).await) + } "mouse-click" => { let btn = arg .strip_prefix("mouse:") @@ -950,6 +962,87 @@ pub async fn launch_game(session: &Arc>, name: &str) -> Result<() Ok(()) } +/// Item 1: free-mode Apply-size. Finds the slot-N window, +/// applies the slot's initial_size (or cached w/h), and +/// sets size_locked=true so the daemon will not undo a +/// future operator resize. +pub async fn resize_slot(session: &Arc>, slot: u32) -> Result<()> { + use crate::profile::LayoutMode; + let (slot_count, mode, slot_idx) = { + let g = session.lock().await; + (g.slots.len(), g.engine.profile.layout.mode, slot.saturating_sub(1) as usize) + }; + if mode != LayoutMode::Free { + anyhow::bail!("resize-slot is only valid in Free layout mode"); + } + if slot_idx >= slot_count { + anyhow::bail!("slot {slot} out of range (have {slot_count})"); + } + let win_addr = { + let g = session.lock().await; + g.slots.iter() + .find(|(s, _)| *s == slot) + .map(|(_, c)| c.address.clone()) + .ok_or_else(|| anyhow::anyhow!("no window for slot {slot}"))? + }; + let (w, h) = { + let g = session.lock().await; + let s = g.engine.profile.layout.slots[slot_idx].clone(); + s.initial_size.unwrap_or((s.w.max(0) as u32, s.h.max(0) as u32)) + }; + let sel = format!("address:0x{win_addr}"); + crate::hypr::resize_window(&sel, w as i32, h as i32).await?; + { + let mut g = session.lock().await; + g.engine.profile.layout.slots[slot_idx].size_locked = true; + } + Ok(()) +} + +/// Item 1: free-mode Apply-position. Sets the slot-N window +/// to the slot's pos (or cached x/y). +pub async fn move_slot(session: &Arc>, slot: u32) -> Result<()> { + use crate::profile::LayoutMode; + let (slot_count, mode, slot_idx) = { + let g = session.lock().await; + (g.slots.len(), g.engine.profile.layout.mode, slot.saturating_sub(1) as usize) + }; + if mode != LayoutMode::Free { + anyhow::bail!("move-slot is only valid in Free layout mode"); + } + if slot_idx >= slot_count { + anyhow::bail!("slot {slot} out of range (have {slot_count})"); + } + let win_addr = { + let g = session.lock().await; + g.slots.iter() + .find(|(s, _)| *s == slot) + .map(|(_, c)| c.address.clone()) + .ok_or_else(|| anyhow::anyhow!("no window for slot {slot}"))? + }; + let (x, y) = { + let g = session.lock().await; + let s = g.engine.profile.layout.slots[slot_idx].clone(); + s.pos.unwrap_or((s.x, s.y)) + }; + let sel = format!("address:0x{win_addr}"); + crate::hypr::move_window(&sel, x, y).await?; + Ok(()) +} + +/// Item 1: clear size_locked on slot-N so the next refresh +/// re-applies the initial size (operator wants the default back). +pub async fn reset_slot_lock(session: &Arc>, slot: u32) -> Result<()> { + let slot_idx = slot.saturating_sub(1) as usize; + let mut g = session.lock().await; + let slot_count = g.engine.profile.layout.slots.len(); + if slot_idx >= slot_count { + anyhow::bail!("slot {slot} out of range (have {slot_count})"); + } + g.engine.profile.layout.slots[slot_idx].size_locked = false; + Ok(()) +} + /// Per-button repeat loop. Fires `broadcast_click` (or /// `broadcast_mirror_click` in mirror mode) every `cadence_ms` until