diff --git a/src/gui.rs b/src/gui.rs index 5a5b895..5c33b72 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -61,6 +61,8 @@ pub fn run() -> Result<()> { profile_names_list: Vec::new(), teams: AppTeams::default(), selected_node: None, + game_list: Vec::new(), + game_pick: String::new(), }; eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app)))) .map_err(|e| anyhow::anyhow!("{e}"))?; @@ -122,6 +124,10 @@ struct App { teams: AppTeams, /// Item 2: which tree node is currently selected. selected_node: Option, + /// Round-5 item 2: games.yaml names, refreshed on demand. + game_list: Vec, + /// Round-5 item 2: currently picked name in the dropdown. + game_pick: String, } #[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 { default_config_path() .parent() @@ -657,6 +686,30 @@ impl App { ui.label("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.label("Slots"); 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 { self.profile.layout.slots.push(crate::profile::LayoutSlot { x: 0, + id: 0, y: 0, w: 800, h: 600, @@ -1299,6 +1353,7 @@ impl App { while self.profile.layout.slots.len() < self.profile.slots as usize { self.profile.layout.slots.push(crate::profile::LayoutSlot { x: 0, + id: 0, y: 0, w: 800, h: 600, @@ -1484,6 +1539,40 @@ impl App { 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)) => { @@ -1877,8 +1966,14 @@ impl App { cmd.env(k, v); } match cmd.spawn() { - Ok(_child) => { - self.status = plan.summary.clone(); + Ok(child) => { + 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 { self.status.push_str(" (auto-apply armed)"); self.arm_auto_apply(ch.slot); diff --git a/src/layout.rs b/src/layout.rs index 07ff7b4..7edf8f3 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -53,6 +53,7 @@ pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec { (0..n) .map(|_| LayoutSlot { x: m.x, + id: 0, y: m.y, w, h, @@ -95,6 +96,7 @@ fn grid(m: &Monitor, n: u32, pin: bool) -> Vec { } out.push(LayoutSlot { x: m.x + x as i32 * ww, + id: 0, y: m.y + y as i32 * wh, w: ww, h: wh, @@ -130,6 +132,7 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec { }; out.push(LayoutSlot { x: m.x, + id: 0, y: main_y, w: bw, h: bh, @@ -154,6 +157,7 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec { }; out.push(LayoutSlot { x, + id: 0, y, w: sw.max(1), h: sh.max(1), @@ -164,25 +168,55 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec { out } -// Item 3: select_windows is now a placeholder. The real picker -// runs in `session::refresh_slots` against `Session.spawned_pids`. -// This stub returns the first `profile.slots` visible clients in -// z-order so layout code that still calls select_windows during -// the rest of the Item 3 rollout does not silently lose every -// window. It will be deleted once refresh_slots fully owns slot -// assignment. -pub fn select_windows(_profile: &Profile, clients: Vec) -> Vec<(u32, Client)> { - let mut visible: Vec = clients - .into_iter() - .filter(|c| c.mapped && !c.hidden && c.class != "enboxer-vfx") - .collect(); - visible.sort_by_key(|c| (c.at[1], c.at[0], c.pid)); - visible - .into_iter() - .take(_profile.slots as usize) - .enumerate() - .map(|(i, c)| ((i as u32) + 1, c)) - .collect() +// Grok round 5 Item 6: select_windows used to return a fake +// first-N-visible list that disagreed with the daemon's real +// slot assignment (stable IDs from process-tree walk in +// refresh_slots). Callers must now read the slot list from the +// daemon over IPC so the GUI's view matches what refresh_slots +// produced. This helper shells out to `enboxer ipc slots` and +// parses the JSON response. +pub fn select_windows(_profile: &Profile, _clients: Vec) -> Vec<(u32, Client)> { + crate::layout::slots_from_daemon().unwrap_or_default() +} + +/// Item 6: ask the daemon for the current slot assignments. +/// Returns a `Vec<(slot_id, Client)>` so the same shape as +/// `select_windows` is preserved. +pub fn slots_from_daemon() -> anyhow::Result> { + let sock = crate::session::default_sock(); + let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("enboxer")); + let out = std::process::Command::new(exe) + .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: + //
+ // 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. @@ -248,6 +282,7 @@ pub fn capture_from(windows: &[(u32, Client)]) -> Vec<LayoutSlot> { for (_, c) in windows { out.push(LayoutSlot { x: c.at[0], + id: 0, y: c.at[1], w: c.size[0], h: c.size[1], @@ -307,6 +342,7 @@ mod tests { let m = mon(); let mut s = LayoutSlot { x: -400, + id: 0, y: -500, w: 1440, h: 1440, @@ -335,6 +371,7 @@ mod tests { let mut s = vec![ LayoutSlot { x: 0, + id: 0, y: 0, w: 100, h: 100, @@ -343,6 +380,7 @@ mod tests { }, LayoutSlot { x: 100, + id: 0, y: 0, w: 50, h: 50, diff --git a/src/profile.rs b/src/profile.rs index f189707..bb4aad5 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -289,6 +289,12 @@ pub struct Layout { #[derive(Debug, Clone, Default, Serialize, Deserialize)] 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 /// mode. Ignored by Free mode. pub x: i32, diff --git a/src/session.rs b/src/session.rs index 3cee531..a85f60f 100644 --- a/src/session.rs +++ b/src/session.rs @@ -574,6 +574,26 @@ 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") } + "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" => { let n = arg.parse::<u32>().unwrap_or(0); 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 { cmd.env(k, v); } - let child = cmd.spawn().with_context(|| { + let mut child = cmd.spawn().with_context(|| { format!("spawn {} {:?}", game.exe.display(), game.args) })?; 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); } tracing::info!("launched game {name:?} as pid {pid}"); - // Drop the Child: the OS keeps the process running. The - // spawned_pids set is the only thing we need to remember; the - // kernel reaps the child when it exits. - drop(child); + // Spawn a background thread to wait on the child. Without + // this the child becomes a zombie (we are the parent; the + // kernel keeps it around until wait). The child runs in + // 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(()) } @@ -968,33 +993,45 @@ pub async fn launch_game(session: &Arc<Mutex<Session>>, name: &str) -> Result<() /// 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 (_mode, win_addr, w, h) = { 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() + if g.engine.profile.layout.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 + // hide) so index-based lookup would mis-route resize-slot + // to the wrong layout slot. layout.slots is now keyed by + // slot_id via the LayoutSlot.id field added below. + let win_addr = 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)) + .ok_or_else(|| anyhow::anyhow!("no window for slot {slot}"))?; + let layout_slot = g + .engine + .profile + .layout + .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}"); 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; + if let Some(ls) = g.engine.profile.layout.slots.iter_mut().find(|s| s.id == slot) { + ls.size_locked = true; + } } 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). pub async fn move_slot(session: &Arc<Mutex<Session>>, slot: u32) -> Result<()> { use crate::profile::LayoutMode; - let (slot_count, mode, slot_idx) = { + let (win_addr, x, y) = { 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() + if g.engine.profile.layout.mode != LayoutMode::Free { + anyhow::bail!("move-slot is only valid in Free layout mode"); + } + let win_addr = 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)) + .ok_or_else(|| anyhow::anyhow!("no window for slot {slot}"))?; + let layout_slot = g + .engine + .profile + .layout + .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}"); crate::hypr::move_window(&sel, x, y).await?;