Item 1 (Grok round 4): window layout manager rewrite + free-arrange mode.

Master wanted two modes:
- Managed (existing wizard behaviour): auto-arrange grid via the
  layout wizard.
- Free-arrange: launch windows without applying layout. Per-window
  initial resolution. Apply-size button (not position). Daemon does
  NOT keep overwriting resolution if operator manually resized.

Reference: KWin scripts apply-size / apply-position for the live
resize plumbing.

Implementation:

src/profile.rs:
  * LayoutMode enum: Managed (default) | Free. Serde round-trips
    as "managed" / "free".
  * Layout.mode field added (default = Managed via LayoutMode derive).
  * LayoutSlot split into initial_size (Option<(u32,u32)>),
    pos (Option<(i32,i32)>), size_locked (bool). The legacy x/y/w/h
    fields stay for Managed mode (cached geometry).

src/hypr.rs:
  * move_resize_window split into resize_window(w, h) + move_window(x, y).
  * move_resize_window kept as a thin wrapper so the original
    layout-apply IPC path still works.

src/layout.rs:
  * apply(slots, windows) split into apply_size + apply_pos; apply()
    is now the thin wrapper that calls both.
  * All 4 LayoutSlot literals (Stacked preset, Grid helper, main_strip,
    capture_from) gained ..Default::default().

src/session.rs:
  * New IPC verbs: resize-slot N, move-slot N, reset-slot N.
  * resize_slot: applies initial_size (or cached w/h), sets
    size_locked=true so the daemon does not undo future operator
    resizes.
  * move_slot: applies pos (or cached x/y).
  * reset_slot_lock: clears size_locked on slot-N.
  * All three bail with a clear message if the mode is not Free.

src/gui.rs:
  * Mode toggle (Managed / Free) at the top of page_layout.
  * page_layout_managed: existing wizard (presets, drag tiles,
    Save and Apply).
  * page_layout_free: per-window initial W/H picker, Apply-size,
    Apply-position, Reset-size buttons. Per-slot lock indicator.
    Uses DragValue::range (clamp_range is deprecated).
  * App.pending_ipc: Vec<String> queue; the main loop will drain
    it and fire the IPC verbs.

cargo test 103/103 (unchanged from Item 4 + 3 games tests); clippy clean.
This commit is contained in:
en 2026-09-17 07:22:13 +02:00
parent 9052f61228
commit 8cb96c1f25
5 changed files with 241 additions and 9 deletions

View File

@ -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<String>,
}
#[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

View File

@ -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

View File

@ -57,6 +57,7 @@ pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec<LayoutSlot> {
w,
h,
pin: layout.pin,
..Default::default()
})
.collect()
}
@ -98,6 +99,7 @@ fn grid(m: &Monitor, n: u32, pin: bool) -> Vec<LayoutSlot> {
w: ww,
h: wh,
pin,
..Default::default()
});
}
}
@ -131,7 +133,7 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec<LayoutSlot> {
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<LayoutSlot> {
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<LayoutSlot> {
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);

View File

@ -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,
}

View File

@ -574,6 +574,18 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
.unwrap_or_default();
games.iter().map(|g| g.name.clone()).collect::<Vec<_>>().join("\n")
}
"resize-slot" => {
let n = arg.parse::<u32>().unwrap_or(0);
wm_ok(resize_slot(session, n).await)
}
"move-slot" => {
let n = arg.parse::<u32>().unwrap_or(0);
wm_ok(move_slot(session, n).await)
}
"reset-slot" => {
let n = arg.parse::<u32>().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<Mutex<Session>>, 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<Mutex<Session>>, 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<Mutex<Session>>, 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<Mutex<Session>>, 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