Round 5 batch A (Grok review): games.yaml seed, pending_ipc fix, doc residue, dead passthrough_id.

Grok round 5 found:

1. examples/games.yaml was actually src/main.rs (227 lines of clap
   CLI; my round-4 Item 4 commit message was wrong). Replaced with
   a real seed YAML. The example shows the env format as
   [[KEY, VALUE], ...] (a list of pairs), NOT KEY=*** (serde_yaml
   will not parse KEY=*** into Vec<(String, String)>).

3. App.pending_ipc was written by Free-mode buttons but never
   drained by the main loop. Replaced with direct self.ipc(...)
   calls. Hoisted the closure borrow by collecting verbs into a
   Vec<(&str, u32)> and draining after the slots borrow is
   released. Dropped the pending_ipc field + App literal init.

8. Doc residue: passthrough / window_match claims still in
   README.md, CHANGELOG.md, docs/DESIGN.md. Updated README.md to
   point at round-4 Item 6 + Item 3. Added a round-4 section at
   the top of CHANGELOG.md. Dropped the duplicate passthrough
   bullet in docs/DESIGN.md.

10. Dead passthrough_id helper in hotkey.rs (unused after the
    passthrough feature dropped in round-4 Item 6). Dropped the
    function + its doc comment.

cargo test 103/103; clippy clean.
This commit is contained in:
en 2026-09-17 07:40:24 +02:00
parent 24ca3bac0c
commit 5aabf0d36f
6 changed files with 57 additions and 252 deletions

View File

@ -1,5 +1,17 @@
# Changelog # 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 ## 0.1.0 — unreleased
- Rust CLI daemon: `enboxer run|press|status|macros|doctor` - Rust CLI daemon: `enboxer run|press|status|macros|doctor`

View File

@ -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. 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. 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. 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. 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. 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**. 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 | | Path | Purpose |
| --- | --- | | --- | --- |
| `~/git/enBoxer` | The Rust crate | | `~/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) | | `examples/hyprland.lua` | Hyprland bind helper (snippet, optional) |
| `~/.config/enboxer/profile.yaml` | Active profile (single-profile legacy / no-team fallback) | | `~/.config/enboxer/profile.yaml` | Active profile (single-profile legacy / no-team fallback) |
| `~/.config/enboxer/teams/<slug>.yaml` | One team = one file | | `~/.config/enboxer/teams/<slug>.yaml` | One team = one file |

View File

@ -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. 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. 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. 3. **Passthrough:** removed in round-4 Item 6. Every mapped hotkey is intercepted.
4. **`passthrough`:** never intercepted, even if a map lists them. Default `[]`. Put `e s d f` here to keep those letters on the primary.
5. **Video FX hover + `pass_through`:** keys and clicks inside the viewer go to the **source slot only**. 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). 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).

View File

@ -1,227 +1,30 @@
use anyhow::Result; # enBoxer game-launcher list (Item 4).
use clap::{Parser, Subcommand}; #
use enboxer::macros::print_macros; # The daemon reads this file (default path: ~/.config/enboxer/games.yaml)
use enboxer::profile::{self, Profile}; # and exposes the entries over IPC. The GUI's Session page renders the
use enboxer::session; # names as a dropdown; picking one fires `enboxer ipc launch-game NAME`
use std::path::PathBuf; # which spawns the entry and records the child PID into
use tracing_subscriber::EnvFilter; # 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)] # - name: World of Warcraft (Retail)
#[command( # exe: /usr/bin/wine
name = "enboxer", # args:
version, # - /path/to/drive_c/Program Files/World of Warcraft/_retail_/Wow.exe
about = "Mapped-key routing for WoW on Hyprland" # env:
)] # - - WINEPREFIX
struct Cli { # - /home/en/.wine
#[command(subcommand)] # cwd: /path/to/drive_c/Program Files/World of Warcraft/_retail_
cmd: Option<Cmd>, # note: retail WoW via Wine
}
#[derive(Subcommand)] games: []
enum Cmd {
/// Load a profile and route keys while a managed game is focused
Run {
#[arg(short, long)]
config: Option<PathBuf>,
},
/// Fire a mapped hotkey (daemon must be running)
Press {
hotkey: String,
#[arg(long)]
sock: Option<PathBuf>,
},
Status {
#[arg(long)]
sock: Option<PathBuf>,
},
/// Set or cycle routing mode: maps | mirror | off
Mode {
/// maps, mirror, off, or omit to cycle
which: Option<String>,
#[arg(long)]
sock: Option<PathBuf>,
},
/// Print the in-game macros / binds to create
Macros {
#[arg(short, long)]
config: Option<PathBuf>,
},
/// Check Hyprland + grim + profile
Doctor {
#[arg(short, long)]
config: Option<PathBuf>,
},
/// 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<PathBuf>,
},
/// Called by Hyprland binds; not for humans
Ipc {
#[arg(long)]
sock: PathBuf,
verb: String,
#[arg(trailing_var_arg = true)]
rest: Vec<String>,
},
}
#[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<PathBuf>) -> 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")
}
}

View File

@ -60,9 +60,6 @@ pub fn run() -> Result<()> {
layout_drag: None, layout_drag: None,
profile_names_list: Vec::new(), profile_names_list: Vec::new(),
teams: AppTeams::default(), 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, selected_node: None,
}; };
eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app)))) 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 /// T15 state. The Lutris picker and the team builder are scoped
/// here so they never leak onto other pages. /// here so they never leak onto other pages.
teams: AppTeams, 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>,
/// Item 2: which tree node is currently selected. /// Item 2: which tree node is currently selected.
selected_node: Option<SelectedNode>, selected_node: Option<SelectedNode>,
} }
@ -1203,6 +1197,11 @@ impl App {
}); });
} }
self.profile.layout.slots.truncate(self.profile.slots as usize); 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() { for (i, s) in self.profile.layout.slots.iter_mut().enumerate() {
let slot = (i + 1) as u32; 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 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() { if ui.button("Apply size").clicked() {
s.initial_size = Some((w_val.max(0) as u32, h_val.max(0) as u32)); s.initial_size = Some((w_val.max(0) as u32, h_val.max(0) as u32));
s.size_locked = true; s.size_locked = true;
self.pending_ipc.push(format!("resize-slot {}", slot)); pending_verbs.push(("resize-slot", slot));
} }
if ui.button("Apply position").clicked() { 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() { if ui.button("Reset size").clicked() {
s.size_locked = false; s.size_locked = false;
self.pending_ipc.push(format!("reset-slot {}", slot)); pending_verbs.push(("reset-slot", slot));
} }
if s.size_locked { if s.size_locked {
ui.label("(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). /// Item 1: managed-mode layout editor (existing wizard).

View File

@ -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<String> {
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<String> { pub fn type_keys(s: &str) -> Vec<String> {
s.chars() s.chars()
.map(|c| match c { .map(|c| match c {