diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3c845..7296733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Round 4 (2026-09-17) +- Dropped the `passthrough` skip-list feature (Item 6). +- Dropped `WindowMatch` regex matching; replaced with process-tree + tracking via `crate::process::pid_is_ancestor` (Item 3). +- Renamed default profile from `esdf-team` to `team` (Item 5). +- Window layout now supports Managed + Free-arrange modes + (Item 1) with `resize-slot` / `move-slot` / `reset-slot` IPC. +- Session game-launcher dropdown reads `~/.config/enboxer/games.yaml` + via `launch-game` / `list-games` IPC (Item 4). +- Keybinds + VFX page replaced with isboxer-style tree UI + grouped by category (Item 2). + ## 0.1.0 — unreleased - Rust CLI daemon: `enboxer run|press|status|macros|doctor` diff --git a/README.md b/README.md index eb38aab..c665781 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Mapped-key routing and live window crops for **Arch Linux + Hyprland**. Rust rewrite of a Windows multibox controller — the project layout, slot overlay, and key-routing engine follow the same shape, but nothing game-side is touched. Keys and clicks only. Pushes to `https://gitea.nettsi.de/en/enBoxer` over HTTPS only. -You list which hotkeys go where. Everything you do not list stays on the focused primary. A `passthrough` list (the example uses ESDF) leaves movement keys alone. Alt-tab off the team and the desktop is normal. +You list which hotkeys go where. Everything you do not list stays on the focused primary. Round-4 (Item 6) removed the `passthrough` skip-list: every mapped hotkey is intercepted. Alt-tab off the team and the desktop is normal. Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortcut` / `send_key_state`. You bind macros in WoW yourself. @@ -11,7 +11,7 @@ Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortc Toggle with `Shift+Alt+M` (configurable) or `enboxer mode maps|mirror|off` while the daemon runs. A Hyprland notification shows the new mode. 1. **maps** — only keys you listed under `maps` are intercepted and sent where the map says. Everything else goes to the front window. -2. **mirror** — the front window still gets the real key; the same key is cloned to the other game windows. `passthrough` (e.g. ESDF) is not cloned. +2. **mirror** — the front window still gets the real key; the same key is cloned to the other game windows. Mirror cloning applies to every mapped hotkey (round-4 Item 6 dropped the `passthrough` skip-list). 3. **off** — nothing is intercepted. All keys go to the front window. The mode-toggle hotkey still works. A **map** is a hotkey plus steps (send a key, send a named `game_bind`, wait) plus a **target**. @@ -123,7 +123,7 @@ The GUI's **Teams** menu is the first thing to use when you have not configured | Path | Purpose | | --- | --- | | `~/git/enBoxer` | The Rust crate | -| `examples/profile.yaml` | Stock profile (ESDF passthrough, main+strip layout, stock loot map, round-robin example) | +| `examples/profile.yaml` | Stock profile (`team`, main+strip layout, stock loot map, round-robin example) | | `examples/hyprland.lua` | Hyprland bind helper (snippet, optional) | | `~/.config/enboxer/profile.yaml` | Active profile (single-profile legacy / no-team fallback) | | `~/.config/enboxer/teams/.yaml` | One team = one file | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0935e86..ff4ea8c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -23,8 +23,7 @@ Delivery is compositor input to a window address. 1. **Session off** (focus is not a captured slot and not an enBoxer overlay): no binds. Normal desktop. 2. **Mode `maps` (default):** only `maps[].hotkey` are intercepted. Everything else, including movement, goes to the focused primary. -3. **`passthrough`:** never intercepted, even if a map lists them. Default `[]`. Put `e s d f` here to keep those letters on the primary. -4. **`passthrough`:** never intercepted, even if a map lists them. Default `[]`. Put `e s d f` here to keep those letters on the primary. +3. **Passthrough:** removed in round-4 Item 6. Every mapped hotkey is intercepted. 5. **Video FX hover + `pass_through`:** keys and clicks inside the viewer go to the **source slot only**. A map with `target: others` never delivers to the primary. A map with `target: all` consumes the OS key and re-sends to every slot including primary (no double-tap). diff --git a/examples/games.yaml b/examples/games.yaml index 151e831..971bedf 100644 --- a/examples/games.yaml +++ b/examples/games.yaml @@ -1,227 +1,30 @@ -use anyhow::Result; -use clap::{Parser, Subcommand}; -use enboxer::macros::print_macros; -use enboxer::profile::{self, Profile}; -use enboxer::session; -use std::path::PathBuf; -use tracing_subscriber::EnvFilter; +# enBoxer game-launcher list (Item 4). +# +# The daemon reads this file (default path: ~/.config/enboxer/games.yaml) +# and exposes the entries over IPC. The GUI's Session page renders the +# names as a dropdown; picking one fires `enboxer ipc launch-game NAME` +# which spawns the entry and records the child PID into +# Session.spawned_pids. refresh_slots then matches the resulting window +# via the process-tree walk (Item 3). +# +# Each entry is a single game: +# name: display name in the dropdown +# exe: absolute path to the binary +# args: list of args (no pre-quoting; use a list, not a string) +# env: list of [KEY, VALUE] pairs (NOT KEY=***; serde_yaml needs a list) +# cwd: optional working directory +# note: optional human-readable note shown in the dropdown +# +# Example (uncomment + edit to fit your system): -#[derive(Parser)] -#[command( - name = "enboxer", - version, - about = "Mapped-key routing for WoW on Hyprland" -)] -struct Cli { - #[command(subcommand)] - cmd: Option, -} +# - name: World of Warcraft (Retail) +# exe: /usr/bin/wine +# args: +# - /path/to/drive_c/Program Files/World of Warcraft/_retail_/Wow.exe +# env: +# - - WINEPREFIX +# - /home/en/.wine +# cwd: /path/to/drive_c/Program Files/World of Warcraft/_retail_ +# note: retail WoW via Wine -#[derive(Subcommand)] -enum Cmd { - /// Load a profile and route keys while a managed game is focused - Run { - #[arg(short, long)] - config: Option, - }, - /// Fire a mapped hotkey (daemon must be running) - Press { - hotkey: String, - #[arg(long)] - sock: Option, - }, - Status { - #[arg(long)] - sock: Option, - }, - /// Set or cycle routing mode: maps | mirror | off - Mode { - /// maps, mirror, off, or omit to cycle - which: Option, - #[arg(long)] - sock: Option, - }, - /// Print the in-game macros / binds to create - Macros { - #[arg(short, long)] - config: Option, - }, - /// Check Hyprland + grim + profile - Doctor { - #[arg(short, long)] - config: Option, - }, - /// Control panel (default if you run `enboxer` with no command) - Gui, - /// Generate and apply the window layout to captured game clients - LayoutApply { - #[arg(short, long)] - config: Option, - }, - /// Called by Hyprland binds; not for humans - Ipc { - #[arg(long)] - sock: PathBuf, - verb: String, - #[arg(trailing_var_arg = true)] - rest: Vec, - }, -} - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env().add_directive("enboxer=info".parse()?)) - .init(); - let cli = Cli::parse(); - match cli.cmd.unwrap_or(Cmd::Gui) { - Cmd::Gui => enboxer::gui::run(), - Cmd::LayoutApply { config } => { - let path = config.unwrap_or_else(profile::default_config_path); - let mut profile = Profile::load(&path)?; - let n = enboxer::layout::apply_for_profile(&mut profile).await?; - if let Ok(text) = serde_yaml::to_string(&profile) { - let _ = std::fs::write(&path, text); - } - println!("laid out {n} windows"); - Ok(()) - } - Cmd::Run { config } => { - let path = config.unwrap_or_else(profile::default_config_path); - let profile = Profile::load(&path)?; - tracing::info!("profile {} ({} slots)", profile.name, profile.slots); - session::run(profile, session::default_sock()).await - } - Cmd::Press { hotkey, sock } => { - let sock = sock.unwrap_or_else(session::default_sock); - println!( - "{}", - session::ipc_send(&sock, &format!("hotkey {hotkey}")).await? - ); - Ok(()) - } - Cmd::Status { sock } => { - let sock = sock.unwrap_or_else(session::default_sock); - println!("{}", session::ipc_send(&sock, "status").await?); - Ok(()) - } - Cmd::Mode { which, sock } => { - let sock = sock.unwrap_or_else(session::default_sock); - let line = match which { - Some(w) => format!("mode {w}"), - None => "mode-cycle".into(), - }; - println!("{}", session::ipc_send(&sock, &line).await?); - Ok(()) - } - Cmd::Macros { config } => { - let path = config.unwrap_or_else(profile::default_config_path); - let profile = Profile::load(&path)?; - print!("{}", print_macros(&profile)); - Ok(()) - } - Cmd::Doctor { config } => doctor(config).await, - Cmd::Ipc { sock, verb, rest } => { - let arg = rest.join(" "); - let line = if arg.is_empty() { - verb - } else { - format!("{verb} {arg}") - }; - println!("{}", session::ipc_send(&sock, &line).await?); - Ok(()) - } - } -} - -async fn doctor(config: Option) -> Result<()> { - let mut ok = true; - match std::env::var("HYPRLAND_INSTANCE_SIGNATURE") { - Ok(s) => println!("hyprland session: {s}"), - Err(_) => { - println!("hyprland session: MISSING (not in Hyprland?)"); - ok = false; - } - } - match enboxer::hypr::eval_lua("return 'ok'").await { - Ok(s) => println!("hyprctl eval: {s}"), - Err(e) => { - println!("hyprctl eval: FAIL {e}"); - ok = false; - } - } - match tokio::process::Command::new("grim") - .arg("-h") - .output() - .await - { - Ok(_) => println!("grim: present"), - Err(_) => { - println!("grim: MISSING (Video FX capture needs grim)"); - ok = false; - } - } - match tokio::process::Command::new("mpv") - .arg("--version") - .output() - .await - { - Ok(_) => println!("mpv: present"), - Err(_) => { - println!("mpv: MISSING (Video FX overlay needs mpv)"); - ok = false; - } - } - match enboxer::hypr::dispatch_lua( - "hl.dsp.send_shortcut({ window = \"class:enboxer-does-not-exist\", mods = \"\", key = \"a\" })", - ) - .await - { - Ok(_) => println!("send_shortcut: compositor accepts dispatcher"), - Err(e) => { - let msg = e.to_string(); - if msg.contains("window not found") { - println!("send_shortcut: compositor accepts dispatcher"); - } else { - println!("send_shortcut: FAIL {e}"); - ok = false; - } - } - } - if let Some(path) = config.or_else(|| { - let p = profile::default_config_path(); - p.exists().then_some(p) - }) { - match Profile::load(&path) { - Ok(p) => { - println!( - "profile: {} maps={} vfx={}", - path.display(), - p.maps.len(), - p.video_fx.len() - ); - } - Err(e) => { - println!("profile {}: FAIL {e}", path.display()); - ok = false; - } - } - } else { - println!( - "profile: none at {}", - profile::default_config_path().display() - ); - } - if let Ok(clients) = enboxer::hypr::clients().await { - println!("windows: {}", clients.len()); - for c in clients.iter().take(12) { - println!(" {} {} {:?}", c.address, c.class, c.title); - } - } - if ok { - println!("doctor: ok"); - Ok(()) - } else { - anyhow::bail!("doctor found problems") - } -} +games: [] diff --git a/src/gui.rs b/src/gui.rs index e571ba8..5a5b895 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -60,9 +60,6 @@ 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(), selected_node: None, }; eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app)))) @@ -123,9 +120,6 @@ 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, /// Item 2: which tree node is currently selected. selected_node: Option, } @@ -1203,6 +1197,11 @@ impl App { }); } self.profile.layout.slots.truncate(self.profile.slots as usize); + // IPC verbs to fire AFTER the per-slot closures return. + // We can't call self.ipc inside the closure because the + // iter_mut borrow on self.profile.layout.slots is still + // live. + let mut pending_verbs: Vec<(&'static str, u32)> = Vec::new(); 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); @@ -1214,14 +1213,14 @@ impl App { 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)); + pending_verbs.push(("resize-slot", slot)); } if ui.button("Apply position").clicked() { - self.pending_ipc.push(format!("move-slot {}", slot)); + pending_verbs.push(("move-slot", slot)); } if ui.button("Reset size").clicked() { s.size_locked = false; - self.pending_ipc.push(format!("reset-slot {}", slot)); + pending_verbs.push(("reset-slot", slot)); } if s.size_locked { ui.label("(locked)"); @@ -1230,6 +1229,11 @@ impl App { } }); } + // Drain the verbs with self.ipc now that the slots + // borrow is released. + for (verb, slot) in pending_verbs { + self.ipc(verb, &slot.to_string()); + } } /// Item 1: managed-mode layout editor (existing wizard). diff --git a/src/hotkey.rs b/src/hotkey.rs index 1d1ea86..c4ae3f3 100644 --- a/src/hotkey.rs +++ b/src/hotkey.rs @@ -175,19 +175,6 @@ fn normalize_token(tok: &str) -> Token { } } -/// Lowercase letter or canonical combo. Kept as a pure helper for -/// hotkey normalisation; the engine-level `passthrough` feature was -/// removed but the normalisation utility may still be useful for -/// the tree-driven keybind UI. -pub fn passthrough_id(raw: &str) -> Result { - let p = parse(raw)?; - if !p.ctrl && !p.alt && !p.shift && !p.super_key && p.is_letter() { - Ok(p.key) - } else { - Ok(p.canonical()) - } -} - pub fn type_keys(s: &str) -> Vec { s.chars() .map(|c| match c {