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:
parent
9052f61228
commit
8cb96c1f25
84
src/gui.rs
84
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<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
|
||||
|
||||
24
src/hypr.rs
24
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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user