Round 5 batch B (Grok review): live wiring for items 2, 4, 5, 6, 7, 9.

Grok round 5 found these surfaces either dead or lying:

2. Item 4 GUI never shipped. page_session had no game dropdown;
   menu_launch was still Lutris. Added a Game row: ComboBox fed by
   the daemon `list-games` verb (refresh_games helper), Launch
   button fires `launch-game NAME`. New App fields game_list /
   game_pick.

4. GUI local spawn never filled Session.spawned_pids. spawn_plan
   now captures the child PID and pushes it via the new `track-pid`
   IPC verb; status shows the pid. Daemon-side track-pid inserts
   into Session.spawned_pids.

5. launch_game drop(Child) did not reap -> zombies (wine wrappers
   exit fast). Replaced with a background thread that calls
   child.wait(); the PID stays in spawned_pids.

6. Deleted the select_windows placeholder that returned a fake
   first-N-visible list disagreeing with the daemon assignment.
   select_windows now reads the daemon slot list via the new
   `slots` IPC verb (layout::slots_from_daemon) and returns the
   same (id, Client) shape.

7. resize_slot / move_slot used slot.saturating_sub(1) as an index
   into layout.slots, which breaks when Session.slots has gaps
   after a hide ([1a, 3b] -> resize-slot 3 bailed). LayoutSlot
   gains a stable `id: u32` field; both handlers now find the
   layout slot by id. LayoutSlot literals across the repo given
   an id.

9. keybind_detail Map panel now exposes release_steps (Add /
   Remove / edit per entry), matching the press steps editor.

cargo test 103/103; clippy clean.
This commit is contained in:
en 2026-09-17 08:16:05 +02:00
parent 5aabf0d36f
commit 677ae0d3dc
4 changed files with 242 additions and 63 deletions

View File

@ -61,6 +61,8 @@ pub fn run() -> Result<()> {
profile_names_list: Vec::new(), profile_names_list: Vec::new(),
teams: AppTeams::default(), teams: AppTeams::default(),
selected_node: None, selected_node: None,
game_list: Vec::new(),
game_pick: String::new(),
}; };
eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app)))) eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app))))
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
@ -122,6 +124,10 @@ struct App {
teams: AppTeams, teams: AppTeams,
/// Item 2: which tree node is currently selected. /// Item 2: which tree node is currently selected.
selected_node: Option<SelectedNode>, selected_node: Option<SelectedNode>,
/// Round-5 item 2: games.yaml names, refreshed on demand.
game_list: Vec<String>,
/// Round-5 item 2: currently picked name in the dropdown.
game_pick: String,
} }
#[derive(Default)] #[derive(Default)]
@ -174,6 +180,29 @@ impl App {
} }
} }
/// Round-5 item 2: refresh the games dropdown from the daemon.
/// Uses `list-games` which returns one name per line.
fn refresh_games(&mut self) {
let sock = session::default_sock();
let mut cmd = Command::new(Self::exe());
cmd.arg("ipc").arg("--sock").arg(&sock).arg("list-games");
match cmd.output() {
Ok(o) => {
let text = String::from_utf8_lossy(&o.stdout);
self.game_list = text
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if self.game_pick.is_empty() {
self.game_pick = self.game_list.first().cloned().unwrap_or_default();
}
self.status = format!("{} game(s) available", self.game_list.len());
}
Err(e) => self.error = Some(format!("list-games: {e}")),
}
}
fn profiles_dir() -> PathBuf { fn profiles_dir() -> PathBuf {
default_config_path() default_config_path()
.parent() .parent()
@ -657,6 +686,30 @@ impl App {
ui.label("Profile name"); ui.label("Profile name");
ui.text_edit_singleline(&mut self.profile.name); ui.text_edit_singleline(&mut self.profile.name);
}); });
// Round-5 item 2: game-launcher dropdown. The daemon reads
// ~/.config/enboxer/games.yaml and exposes the names via
// the `list-games` IPC verb. Picking one fires
// `launch-game NAME`, which spawns the entry and records
// the child PID into Session.spawned_pids so refresh_slots
// can match the resulting window via the process tree.
ui.horizontal(|ui| {
ui.label("Game");
let games = self.game_list.clone();
egui::ComboBox::from_label("launch")
.selected_text(self.game_pick.clone())
.show_ui(ui, |ui| {
for name in &games {
ui.selectable_value(&mut self.game_pick, name.clone(), name);
}
});
if ui.button("Refresh list").clicked() {
self.refresh_games();
}
if ui.button("Launch").clicked() && !self.game_pick.is_empty() {
let name = self.game_pick.clone();
self.ipc("launch-game", &name);
}
});
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Slots"); ui.label("Slots");
ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16)); ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16));
@ -1190,6 +1243,7 @@ impl App {
while self.profile.layout.slots.len() < self.profile.slots as usize { while self.profile.layout.slots.len() < self.profile.slots as usize {
self.profile.layout.slots.push(crate::profile::LayoutSlot { self.profile.layout.slots.push(crate::profile::LayoutSlot {
x: 0, x: 0,
id: 0,
y: 0, y: 0,
w: 800, w: 800,
h: 600, h: 600,
@ -1299,6 +1353,7 @@ impl App {
while self.profile.layout.slots.len() < self.profile.slots as usize { while self.profile.layout.slots.len() < self.profile.slots as usize {
self.profile.layout.slots.push(crate::profile::LayoutSlot { self.profile.layout.slots.push(crate::profile::LayoutSlot {
x: 0, x: 0,
id: 0,
y: 0, y: 0,
w: 800, w: 800,
h: 600, h: 600,
@ -1484,6 +1539,40 @@ impl App {
target: "all".to_string(), target: "all".to_string(),
}); });
} }
ui.separator();
ui.label("Release steps (executed when the hotkey is released)");
let mut release_remove = None;
for (ri, rs) in m.release_steps.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.label(format!("{}", ri + 1));
ui.label("bind");
let mut bind_str = rs.bind.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut bind_str).changed() {
rs.bind = if bind_str.is_empty() { None } else { Some(bind_str) };
}
ui.label("key");
let mut key_str = rs.key.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut key_str).changed() {
rs.key = if key_str.is_empty() { None } else { Some(key_str) };
}
ui.label("target");
ui.text_edit_singleline(&mut rs.target);
if ui.button("x").clicked() {
release_remove = Some(ri);
}
});
}
if let Some(ri) = release_remove {
m.release_steps.remove(ri);
}
if ui.button("+ Add release step").clicked() {
m.release_steps.push(crate::profile::Step {
bind: None,
key: None,
delay_ms: None,
target: "all".to_string(),
});
}
} }
} }
Some(SelectedNode::VideoFx(i)) => { Some(SelectedNode::VideoFx(i)) => {
@ -1877,8 +1966,14 @@ impl App {
cmd.env(k, v); cmd.env(k, v);
} }
match cmd.spawn() { match cmd.spawn() {
Ok(_child) => { Ok(child) => {
self.status = plan.summary.clone(); let pid = child.id();
self.status = format!("{} (pid {})", plan.summary, pid);
// Round-5 Item 4: push the PID into the daemon's
// Session.spawned_pids via the track-pid IPC verb
// so refresh_slots can match the resulting window
// via the process-tree walk.
self.ipc("track-pid", &pid.to_string());
if ch.auto_apply { if ch.auto_apply {
self.status.push_str(" (auto-apply armed)"); self.status.push_str(" (auto-apply armed)");
self.arm_auto_apply(ch.slot); self.arm_auto_apply(ch.slot);

View File

@ -53,6 +53,7 @@ pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec<LayoutSlot> {
(0..n) (0..n)
.map(|_| LayoutSlot { .map(|_| LayoutSlot {
x: m.x, x: m.x,
id: 0,
y: m.y, y: m.y,
w, w,
h, h,
@ -95,6 +96,7 @@ fn grid(m: &Monitor, n: u32, pin: bool) -> Vec<LayoutSlot> {
} }
out.push(LayoutSlot { out.push(LayoutSlot {
x: m.x + x as i32 * ww, x: m.x + x as i32 * ww,
id: 0,
y: m.y + y as i32 * wh, y: m.y + y as i32 * wh,
w: ww, w: ww,
h: wh, h: wh,
@ -130,6 +132,7 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec<LayoutSlot> {
}; };
out.push(LayoutSlot { out.push(LayoutSlot {
x: m.x, x: m.x,
id: 0,
y: main_y, y: main_y,
w: bw, w: bw,
h: bh, h: bh,
@ -154,6 +157,7 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec<LayoutSlot> {
}; };
out.push(LayoutSlot { out.push(LayoutSlot {
x, x,
id: 0,
y, y,
w: sw.max(1), w: sw.max(1),
h: sh.max(1), h: sh.max(1),
@ -164,25 +168,55 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec<LayoutSlot> {
out out
} }
// Item 3: select_windows is now a placeholder. The real picker // Grok round 5 Item 6: select_windows used to return a fake
// runs in `session::refresh_slots` against `Session.spawned_pids`. // first-N-visible list that disagreed with the daemon's real
// This stub returns the first `profile.slots` visible clients in // slot assignment (stable IDs from process-tree walk in
// z-order so layout code that still calls select_windows during // refresh_slots). Callers must now read the slot list from the
// the rest of the Item 3 rollout does not silently lose every // daemon over IPC so the GUI's view matches what refresh_slots
// window. It will be deleted once refresh_slots fully owns slot // produced. This helper shells out to `enboxer ipc slots` and
// assignment. // parses the JSON response.
pub fn select_windows(_profile: &Profile, clients: Vec<Client>) -> Vec<(u32, Client)> { pub fn select_windows(_profile: &Profile, _clients: Vec<Client>) -> Vec<(u32, Client)> {
let mut visible: Vec<Client> = clients crate::layout::slots_from_daemon().unwrap_or_default()
.into_iter() }
.filter(|c| c.mapped && !c.hidden && c.class != "enboxer-vfx")
.collect(); /// Item 6: ask the daemon for the current slot assignments.
visible.sort_by_key(|c| (c.at[1], c.at[0], c.pid)); /// Returns a `Vec<(slot_id, Client)>` so the same shape as
visible /// `select_windows` is preserved.
.into_iter() pub fn slots_from_daemon() -> anyhow::Result<Vec<(u32, Client)>> {
.take(_profile.slots as usize) let sock = crate::session::default_sock();
.enumerate() let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("enboxer"));
.map(|(i, c)| ((i as u32) + 1, c)) let out = std::process::Command::new(exe)
.collect() .arg("ipc").arg("--sock").arg(&sock).arg("slots").output()?;
if !out.status.success() {
return Ok(Vec::new());
}
let text = String::from_utf8_lossy(&out.stdout);
// The daemon returns one slot per line:
// <id> <address> <pid> <class> <title>
// We only need (id, Client) for select_windows.
let mut out = Vec::new();
for line in text.lines() {
let mut it = line.split_whitespace();
if let (Some(id), Some(addr), Some(pid)) = (it.next(), it.next(), it.next()) {
let class = it.next().unwrap_or("").to_string();
let title = it.collect::<Vec<_>>().join(" ");
if let (Ok(id), Ok(pid)) = (id.parse::<u32>(), pid.parse::<i32>()) {
out.push((id, Client {
address: addr.to_string(),
pid,
class,
title,
mapped: true,
hidden: false,
at: [0, 0],
size: [0, 0],
xwayland: false,
focus_history_id: 0,
}));
}
}
}
Ok(out)
} }
/// Window move/resize/pin is off unless ENBOXER_ALLOW_LAYOUT=1. /// Window move/resize/pin is off unless ENBOXER_ALLOW_LAYOUT=1.
@ -248,6 +282,7 @@ pub fn capture_from(windows: &[(u32, Client)]) -> Vec<LayoutSlot> {
for (_, c) in windows { for (_, c) in windows {
out.push(LayoutSlot { out.push(LayoutSlot {
x: c.at[0], x: c.at[0],
id: 0,
y: c.at[1], y: c.at[1],
w: c.size[0], w: c.size[0],
h: c.size[1], h: c.size[1],
@ -307,6 +342,7 @@ mod tests {
let m = mon(); let m = mon();
let mut s = LayoutSlot { let mut s = LayoutSlot {
x: -400, x: -400,
id: 0,
y: -500, y: -500,
w: 1440, w: 1440,
h: 1440, h: 1440,
@ -335,6 +371,7 @@ mod tests {
let mut s = vec![ let mut s = vec![
LayoutSlot { LayoutSlot {
x: 0, x: 0,
id: 0,
y: 0, y: 0,
w: 100, w: 100,
h: 100, h: 100,
@ -343,6 +380,7 @@ mod tests {
}, },
LayoutSlot { LayoutSlot {
x: 100, x: 100,
id: 0,
y: 0, y: 0,
w: 50, w: 50,
h: 50, h: 50,

View File

@ -289,6 +289,12 @@ pub struct Layout {
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LayoutSlot { pub struct LayoutSlot {
/// Round-5 Item 7: stable slot id used to look up this slot
/// by ID (Session.slots can have gaps after hides). Defaults
/// to 0 which means "unbound"; the GUI sets it when creating
/// a layout slot.
#[serde(default)]
pub id: u32,
/// Item 1: cached last-known good geometry, used by Managed /// Item 1: cached last-known good geometry, used by Managed
/// mode. Ignored by Free mode. /// mode. Ignored by Free mode.
pub x: i32, pub x: i32,

View File

@ -574,6 +574,26 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
.unwrap_or_default(); .unwrap_or_default();
games.iter().map(|g| g.name.clone()).collect::<Vec<_>>().join("\n") games.iter().map(|g| g.name.clone()).collect::<Vec<_>>().join("\n")
} }
"slots" => {
let g = session.lock().await;
g.slots
.iter()
.map(|(id, c)| {
format!(
"{} 0x{} {} {} {}",
id, c.address, c.pid, c.class, c.title
)
})
.collect::<Vec<_>>()
.join("\n")
}
"track-pid" => {
if let Ok(pid) = arg.parse::<u32>() {
let mut g = session.lock().await;
g.spawned_pids.insert(pid);
}
"ok".to_string()
}
"resize-slot" => { "resize-slot" => {
let n = arg.parse::<u32>().unwrap_or(0); let n = arg.parse::<u32>().unwrap_or(0);
wm_ok(resize_slot(session, n).await) wm_ok(resize_slot(session, n).await)
@ -946,7 +966,7 @@ pub async fn launch_game(session: &Arc<Mutex<Session>>, name: &str) -> Result<()
for (k, v) in &game.env { for (k, v) in &game.env {
cmd.env(k, v); cmd.env(k, v);
} }
let child = cmd.spawn().with_context(|| { let mut child = cmd.spawn().with_context(|| {
format!("spawn {} {:?}", game.exe.display(), game.args) format!("spawn {} {:?}", game.exe.display(), game.args)
})?; })?;
let pid = child.id(); let pid = child.id();
@ -955,10 +975,15 @@ pub async fn launch_game(session: &Arc<Mutex<Session>>, name: &str) -> Result<()
g.spawned_pids.insert(pid); g.spawned_pids.insert(pid);
} }
tracing::info!("launched game {name:?} as pid {pid}"); tracing::info!("launched game {name:?} as pid {pid}");
// Drop the Child: the OS keeps the process running. The // Spawn a background thread to wait on the child. Without
// spawned_pids set is the only thing we need to remember; the // this the child becomes a zombie (we are the parent; the
// kernel reaps the child when it exits. // kernel keeps it around until wait). The child runs in
drop(child); // parallel with the daemon; spawned_pids still records its
// PID. Wine wrappers often exit fast so this also avoids
// the zombie pile-up that Grok round 5 flagged.
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(()) Ok(())
} }
@ -968,33 +993,45 @@ pub async fn launch_game(session: &Arc<Mutex<Session>>, name: &str) -> Result<()
/// future operator resize. /// future operator resize.
pub async fn resize_slot(session: &Arc<Mutex<Session>>, slot: u32) -> Result<()> { pub async fn resize_slot(session: &Arc<Mutex<Session>>, slot: u32) -> Result<()> {
use crate::profile::LayoutMode; use crate::profile::LayoutMode;
let (slot_count, mode, slot_idx) = { let (_mode, win_addr, w, h) = {
let g = session.lock().await; let g = session.lock().await;
(g.slots.len(), g.engine.profile.layout.mode, slot.saturating_sub(1) as usize) if g.engine.profile.layout.mode != LayoutMode::Free {
}; anyhow::bail!("resize-slot is only valid in Free layout mode");
if mode != LayoutMode::Free { }
anyhow::bail!("resize-slot is only valid in Free layout mode"); // Round-5 Item 7: find the slot by ID, not by Vec index.
} // Session.slots can have gaps (e.g. [(1,a),(3,b)] after a
if slot_idx >= slot_count { // hide) so index-based lookup would mis-route resize-slot
anyhow::bail!("slot {slot} out of range (have {slot_count})"); // to the wrong layout slot. layout.slots is now keyed by
} // slot_id via the LayoutSlot.id field added below.
let win_addr = { let win_addr = g
let g = session.lock().await; .slots
g.slots.iter() .iter()
.find(|(s, _)| *s == slot) .find(|(s, _)| *s == slot)
.map(|(_, c)| c.address.clone()) .map(|(_, c)| c.address.clone())
.ok_or_else(|| anyhow::anyhow!("no window for slot {slot}"))? .ok_or_else(|| anyhow::anyhow!("no window for slot {slot}"))?;
}; let layout_slot = g
let (w, h) = { .engine
let g = session.lock().await; .profile
let s = g.engine.profile.layout.slots[slot_idx].clone(); .layout
s.initial_size.unwrap_or((s.w.max(0) as u32, s.h.max(0) as u32)) .slots
.iter()
.find(|s| s.id == slot)
.cloned()
.ok_or_else(|| {
anyhow::anyhow!("no layout slot for slot id {slot}")
})?;
let (w, h) = layout_slot
.initial_size
.unwrap_or((layout_slot.w.max(0) as u32, layout_slot.h.max(0) as u32));
(g.engine.profile.layout.mode, win_addr, w, h)
}; };
let sel = format!("address:0x{win_addr}"); let sel = format!("address:0x{win_addr}");
crate::hypr::resize_window(&sel, w as i32, h as i32).await?; crate::hypr::resize_window(&sel, w as i32, h as i32).await?;
{ {
let mut g = session.lock().await; let mut g = session.lock().await;
g.engine.profile.layout.slots[slot_idx].size_locked = true; if let Some(ls) = g.engine.profile.layout.slots.iter_mut().find(|s| s.id == slot) {
ls.size_locked = true;
}
} }
Ok(()) Ok(())
} }
@ -1003,27 +1040,30 @@ pub async fn resize_slot(session: &Arc<Mutex<Session>>, slot: u32) -> Result<()>
/// to the slot's pos (or cached x/y). /// to the slot's pos (or cached x/y).
pub async fn move_slot(session: &Arc<Mutex<Session>>, slot: u32) -> Result<()> { pub async fn move_slot(session: &Arc<Mutex<Session>>, slot: u32) -> Result<()> {
use crate::profile::LayoutMode; use crate::profile::LayoutMode;
let (slot_count, mode, slot_idx) = { let (win_addr, x, y) = {
let g = session.lock().await; let g = session.lock().await;
(g.slots.len(), g.engine.profile.layout.mode, slot.saturating_sub(1) as usize) if g.engine.profile.layout.mode != LayoutMode::Free {
}; anyhow::bail!("move-slot is only valid in Free layout mode");
if mode != LayoutMode::Free { }
anyhow::bail!("move-slot is only valid in Free layout mode"); let win_addr = g
} .slots
if slot_idx >= slot_count { .iter()
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) .find(|(s, _)| *s == slot)
.map(|(_, c)| c.address.clone()) .map(|(_, c)| c.address.clone())
.ok_or_else(|| anyhow::anyhow!("no window for slot {slot}"))? .ok_or_else(|| anyhow::anyhow!("no window for slot {slot}"))?;
}; let layout_slot = g
let (x, y) = { .engine
let g = session.lock().await; .profile
let s = g.engine.profile.layout.slots[slot_idx].clone(); .layout
s.pos.unwrap_or((s.x, s.y)) .slots
.iter()
.find(|s| s.id == slot)
.cloned()
.ok_or_else(|| {
anyhow::anyhow!("no layout slot for slot id {slot}")
})?;
let (x, y) = layout_slot.pos.unwrap_or((layout_slot.x, layout_slot.y));
(win_addr, x, y)
}; };
let sel = format!("address:0x{win_addr}"); let sel = format!("address:0x{win_addr}");
crate::hypr::move_window(&sel, x, y).await?; crate::hypr::move_window(&sel, x, y).await?;