Three routing modes: maps, mirror, off.

Mirror clones the real key to the other clients (passthrough still skipped).
Off leaves the front window alone except the mode-toggle hotkey.
This commit is contained in:
en 2026-09-15 08:14:08 +02:00
parent feb2cdb4dc
commit 936f123b18
8 changed files with 171 additions and 29 deletions

View File

@ -15,14 +15,18 @@ Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortc
| [CHANGELOG.md](CHANGELOG.md) | What landed | | [CHANGELOG.md](CHANGELOG.md) | What landed |
| [docs/DESIGN.md](docs/DESIGN.md) | Routing model | | [docs/DESIGN.md](docs/DESIGN.md) | Routing model |
| [docs/NOTES.md](docs/NOTES.md) | WoW interact / Hyprland capture | | [docs/NOTES.md](docs/NOTES.md) | WoW interact / Hyprland capture |
| [docs/VIDEO.md](docs/VIDEO.md) | Live crop of another client |
| [docs/MACROS.md](docs/MACROS.md) | In-game macros | | [docs/MACROS.md](docs/MACROS.md) | In-game macros |
## Routing ## Routing (three modes)
- A **map** is a hotkey plus steps (send a key, send a named `game_bind`, wait) plus a **target**. Toggle with `Shift+Alt+M` (configurable) or `enboxer mode maps|mirror|off` while the daemon runs. A Hyprland notification shows the new mode.
- Unmapped keys are not intercepted.
- `passthrough` keys are never intercepted, even if a map lists them. Default is empty. 1. **maps** — only keys you listed under `maps` are intercepted and sent where the map says. Everything else goes to the front window.
- Optional **repeater** mode also clones an explicit extra key list to the other slots. 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.
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**.
## Interact / loot ## Interact / loot

19
docs/VIDEO.md Normal file
View File

@ -0,0 +1,19 @@
# Live crop (Video FX)
This is a picture-in-picture of **another** game window, drawn on top of the one you are playing.
Example: your main character fills the monitor. A rectangle in the corner shows your second characters party frames or bags. When the mouse is in that rectangle, clicks and keys go to the second client, not to the main.
It is not a second monitor and not a window swap. The other client can sit behind the main one; you still see the cropped piece.
## What Hyprland can actually do
| Method | Sees a window that is fully covered? | What we use |
| --- | --- | --- |
| `grim` of a screen rectangle | No — only pixels currently on the output | **Yes, today** |
| `hyprland-toplevel-export` | Yes — compositor copy of that windows buffer | Not wired yet |
| Desktop portal / PipeWire | Yes, heavier | No |
So: keep the source window **on a visible output** (another monitor, or a slice of the same one). If you stack every client on the same pixels, the crop will show whatever is on top, not the hidden client. Wiring toplevel-export is the next capture step when you want a stacked layout.
The overlay itself is an `mpv` window (`class: enboxer-vfx`) that reloads each `grim` frame.

View File

@ -14,8 +14,12 @@ window_match:
# ESDF movement stays on the primary even if you later add a map for those letters. # ESDF movement stays on the primary even if you later add a map for those letters.
passthrough: ["e", "s", "d", "f"] passthrough: ["e", "s", "d", "f"]
# maps = only keys listed under `maps` (rest stay on the front window)
# mirror = clone keys to the other clients (passthrough still skipped)
# off = no intercept; the front window gets everything
mode_default: maps mode_default: maps
# Optional: limit which keys mirror-mode clones. Empty = letters, digits, F-keys, etc.
repeater: repeater:
enabled: false enabled: false
keys: [] keys: []

View File

@ -53,7 +53,7 @@ impl Engine {
} }
pub fn should_intercept(&self, hotkey: &str) -> bool { pub fn should_intercept(&self, hotkey: &str) -> bool {
if self.mode == Mode::Disabled { if self.mode == Mode::Off {
return false; return false;
} }
if let Ok(id) = passthrough_id(hotkey) { if let Ok(id) = passthrough_id(hotkey) {
@ -62,10 +62,13 @@ impl Engine {
} }
} }
self.profile.map_by_hotkey(hotkey).is_some() self.profile.map_by_hotkey(hotkey).is_some()
|| (self.mode == Mode::Repeater && self.is_repeater_key(hotkey)) || (self.mode == Mode::Mirror && self.is_mirror_key(hotkey))
} }
fn is_repeater_key(&self, hotkey: &str) -> bool { fn is_mirror_key(&self, hotkey: &str) -> bool {
if self.profile.repeater.keys.is_empty() {
return true;
}
let want = passthrough_id(hotkey).unwrap_or_else(|_| hotkey.to_string()); let want = passthrough_id(hotkey).unwrap_or_else(|_| hotkey.to_string());
self.profile.repeater.keys.iter().any(|k| { self.profile.repeater.keys.iter().any(|k| {
k.eq_ignore_ascii_case(hotkey) || passthrough_id(k).ok().is_some_and(|id| id == want) k.eq_ignore_ascii_case(hotkey) || passthrough_id(k).ok().is_some_and(|id| id == want)
@ -115,7 +118,7 @@ impl Engine {
}; };
return self.fire_map(map, edge); return self.fire_map(map, edge);
} }
if self.mode == Mode::Repeater && matches!(edge, Hold::Tap | Hold::Down) { if self.mode == Mode::Mirror && matches!(edge, Hold::Tap | Hold::Down) {
let slots = self.resolve_targets("others")?; let slots = self.resolve_targets("others")?;
return Ok(vec![Action::Send { return Ok(vec![Action::Send {
key: hotkey.to_string(), key: hotkey.to_string(),
@ -362,11 +365,34 @@ mod tests {
#[test] #[test]
fn disabled_mode_intercepts_nothing() { fn disabled_mode_intercepts_nothing() {
let mut e = sample(); let mut e = sample();
e.mode = Mode::Disabled; e.mode = Mode::Off;
assert!(!e.should_intercept("1")); assert!(!e.should_intercept("1"));
assert!(e.fire("1", Hold::Tap).unwrap().is_empty()); assert!(e.fire("1", Hold::Tap).unwrap().is_empty());
} }
#[test]
fn three_modes() {
let mut e = sample();
e.mode = Mode::Maps;
assert!(e.should_intercept("1"));
assert!(!e.should_intercept("q"));
e.mode = Mode::Mirror;
assert!(e.should_intercept("q"));
let acts = e.fire("q", Hold::Tap).unwrap();
assert_eq!(
acts,
vec![Action::Send {
key: "q".into(),
slots: vec![2, 3],
hold: Hold::Tap,
}]
);
assert!(!e.should_intercept("e")); // passthrough ESDF
e.mode = Mode::Off;
assert!(!e.should_intercept("1"));
assert!(!e.should_intercept("q"));
}
#[test] #[test]
fn bar_sends_assist_to_others_and_key_to_all() { fn bar_sends_assist_to_others_and_key_to_all() {
let e = sample(); let e = sample();

View File

@ -197,6 +197,12 @@ pub fn bind_command(bin: &str, sock: &str, args: &str) -> String {
) )
} }
pub async fn notify(text: &str) -> Result<()> {
// 1 = info icon; 2500 ms
let _ = hyprctl(["notify", "1", "2500", "rgb(88aaff)", text]).await;
Ok(())
}
pub async fn apply_vfx_window_rules() -> Result<()> { pub async fn apply_vfx_window_rules() -> Result<()> {
eval_lua( eval_lua(
r#" r#"

View File

@ -34,6 +34,13 @@ enum Cmd {
#[arg(long)] #[arg(long)]
sock: Option<PathBuf>, 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 /// Print the in-game macros / binds to create
Macros { Macros {
#[arg(short, long)] #[arg(short, long)]
@ -80,6 +87,15 @@ async fn main() -> Result<()> {
println!("{}", session::ipc_send(&sock, "status").await?); println!("{}", session::ipc_send(&sock, "status").await?);
Ok(()) 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 } => { Cmd::Macros { config } => {
let path = config.unwrap_or_else(profile::default_config_path); let path = config.unwrap_or_else(profile::default_config_path);
let profile = Profile::load(&path)?; let profile = Profile::load(&path)?;

View File

@ -49,25 +49,47 @@ fn default_mode() -> Mode {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Mode { pub enum Mode {
/// 1: only configured maps; everything else goes to the front window
Maps, Maps,
Repeater, /// 2: clone keys to the other game windows (front window still gets the real key)
Disabled, #[serde(alias = "repeater")]
Mirror,
/// 3: do not intercept; all keys go to the front window
#[serde(alias = "disabled")]
Off,
} }
impl Mode { impl Mode {
pub fn parse_name(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"maps" | "1" => Some(Mode::Maps),
"mirror" | "repeater" | "2" => Some(Mode::Mirror),
"off" | "disabled" | "3" => Some(Mode::Off),
_ => None,
}
}
pub fn cycle(self) -> Self { pub fn cycle(self) -> Self {
match self { match self {
Mode::Maps => Mode::Repeater, Mode::Maps => Mode::Mirror,
Mode::Repeater => Mode::Disabled, Mode::Mirror => Mode::Off,
Mode::Disabled => Mode::Maps, Mode::Off => Mode::Maps,
} }
} }
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Mode::Maps => "maps", Mode::Maps => "maps",
Mode::Repeater => "repeater", Mode::Mirror => "mirror",
Mode::Disabled => "disabled", Mode::Off => "off",
}
}
pub fn label(self) -> &'static str {
match self {
Mode::Maps => "maps (configured keys only)",
Mode::Mirror => "mirror (all windows)",
Mode::Off => "off (front window only)",
} }
} }
} }

View File

@ -196,7 +196,7 @@ async fn refresh_slots(
let managed = g.slots.iter().any(|(_, c)| c.address == aw.address) let managed = g.slots.iter().any(|(_, c)| c.address == aw.address)
|| aw.class == "enboxer-vfx" || aw.class == "enboxer-vfx"
|| aw.title.starts_with("enboxer-vfx"); || aw.title.starts_with("enboxer-vfx");
if managed && g.engine.mode != Mode::Disabled { if managed {
if !g.binds_on { if !g.binds_on {
let specs = bind_specs(&g)?; let specs = bind_specs(&g)?;
let sock = g.sock.display().to_string(); let sock = g.sock.display().to_string();
@ -301,16 +301,21 @@ fn bind_specs(g: &Session) -> Result<Vec<BindSpec>> {
}); });
} }
} }
if g.engine.mode == Mode::Repeater { if g.engine.mode == Mode::Mirror {
for k in &g.engine.profile.repeater.keys { let keys = if g.engine.profile.repeater.keys.is_empty() {
let id = passthrough_id(k).unwrap_or_else(|_| k.clone()); clone_key_set()
} else {
g.engine.profile.repeater.keys.clone()
};
for k in keys {
let id = passthrough_id(&k).unwrap_or_else(|_| k.clone());
if passthrough.contains(&id) { if passthrough.contains(&id) {
continue; continue;
} }
if g.engine.profile.map_by_hotkey(k).is_some() { if g.engine.profile.map_by_hotkey(&k).is_some() {
continue; continue;
} }
let parsed = hotkey::parse(k)?; let parsed = hotkey::parse(&k)?;
specs.push(BindSpec { specs.push(BindSpec {
bind: parsed.hypr_bind(), bind: parsed.hypr_bind(),
ipc_bin: bin.clone(), ipc_bin: bin.clone(),
@ -375,12 +380,14 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
g.vfx_source g.vfx_source
) )
} }
"mode-cycle" => { "mode-cycle" => match set_mode(session, None).await {
let mut g = session.lock().await; Ok(m) => format!("mode={}", m.as_str()),
let m = g.engine.cycle_mode(); Err(e) => format!("err {e}"),
g.binds_on = false; },
format!("mode={}", m.as_str()) "mode" => match set_mode(session, Some(arg)).await {
} Ok(m) => format!("mode={}", m.as_str()),
Err(e) => format!("err {e}"),
},
"hotkey" => match fire(session, arg, Hold::Tap).await { "hotkey" => match fire(session, arg, Hold::Tap).await {
Ok(()) => "ok".into(), Ok(()) => "ok".into(),
Err(e) => format!("err {e}"), Err(e) => format!("err {e}"),
@ -409,6 +416,44 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
} }
} }
async fn set_mode(session: &Arc<Mutex<Session>>, name: Option<&str>) -> Result<Mode> {
let aw = hypr::active_window().await.ok().flatten();
let (mode, specs, sock, managed) = {
let mut g = session.lock().await;
let mode = if let Some(n) = name {
Mode::parse_name(n).ok_or_else(|| anyhow::anyhow!("unknown mode {n}"))?
} else {
g.engine.cycle_mode()
};
g.engine.mode = mode;
let managed = aw.as_ref().is_some_and(|aw| {
g.slots.iter().any(|(_, c)| c.address == aw.address)
|| aw.class == "enboxer-vfx"
|| aw.title.starts_with("enboxer-vfx")
});
let specs = if managed { Some(bind_specs(&g)?) } else { None };
let sock = g.sock.display().to_string();
(mode, specs, sock, managed)
};
if let Some(specs) = specs {
hypr::replace_binds(&specs, &sock).await?;
let mut g = session.lock().await;
g.binds_on = managed;
}
hypr::notify(&format!("enBoxer: {}", mode.label()))
.await
.ok();
tracing::info!("mode {}", mode.as_str());
Ok(mode)
}
fn clone_key_set() -> Vec<String> {
vfx_hover_keys()
.into_iter()
.filter(|k| !k.starts_with("mouse:"))
.collect()
}
fn vfx_hover_keys() -> Vec<String> { fn vfx_hover_keys() -> Vec<String> {
let mut v: Vec<String> = [ let mut v: Vec<String> = [
"Space", "Space",