diff --git a/README.md b/README.md index ee88642..5f66d7a 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,18 @@ Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortc | [CHANGELOG.md](CHANGELOG.md) | What landed | | [docs/DESIGN.md](docs/DESIGN.md) | Routing model | | [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 | -## Routing +## Routing (three modes) -- A **map** is a hotkey plus steps (send a key, send a named `game_bind`, wait) plus a **target**. -- Unmapped keys are not intercepted. -- `passthrough` keys are never intercepted, even if a map lists them. Default is empty. -- Optional **repeater** mode also clones an explicit extra key list to the other slots. +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. +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 diff --git a/docs/VIDEO.md b/docs/VIDEO.md new file mode 100644 index 0000000..ab239d8 --- /dev/null +++ b/docs/VIDEO.md @@ -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 character’s 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 window’s 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. diff --git a/examples/profile.yaml b/examples/profile.yaml index 1f0d8b5..f41028d 100644 --- a/examples/profile.yaml +++ b/examples/profile.yaml @@ -14,8 +14,12 @@ window_match: # ESDF movement stays on the primary even if you later add a map for those letters. 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 +# Optional: limit which keys mirror-mode clones. Empty = letters, digits, F-keys, etc. repeater: enabled: false keys: [] diff --git a/src/engine.rs b/src/engine.rs index 00c58c2..004d3ac 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -53,7 +53,7 @@ impl Engine { } pub fn should_intercept(&self, hotkey: &str) -> bool { - if self.mode == Mode::Disabled { + if self.mode == Mode::Off { return false; } if let Ok(id) = passthrough_id(hotkey) { @@ -62,10 +62,13 @@ impl Engine { } } 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()); self.profile.repeater.keys.iter().any(|k| { 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); } - 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")?; return Ok(vec![Action::Send { key: hotkey.to_string(), @@ -362,11 +365,34 @@ mod tests { #[test] fn disabled_mode_intercepts_nothing() { let mut e = sample(); - e.mode = Mode::Disabled; + e.mode = Mode::Off; assert!(!e.should_intercept("1")); 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] fn bar_sends_assist_to_others_and_key_to_all() { let e = sample(); diff --git a/src/hypr.rs b/src/hypr.rs index 6ee8cf9..66c6d69 100644 --- a/src/hypr.rs +++ b/src/hypr.rs @@ -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<()> { eval_lua( r#" diff --git a/src/main.rs b/src/main.rs index dfc52ec..8f719a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,13 @@ enum Cmd { #[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)] @@ -80,6 +87,15 @@ async fn main() -> Result<()> { 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)?; diff --git a/src/profile.rs b/src/profile.rs index f3604d1..21b772b 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -49,25 +49,47 @@ fn default_mode() -> Mode { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Mode { + /// 1: only configured maps; everything else goes to the front window Maps, - Repeater, - Disabled, + /// 2: clone keys to the other game windows (front window still gets the real key) + #[serde(alias = "repeater")] + Mirror, + /// 3: do not intercept; all keys go to the front window + #[serde(alias = "disabled")] + Off, } impl Mode { + pub fn parse_name(s: &str) -> Option { + 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 { match self { - Mode::Maps => Mode::Repeater, - Mode::Repeater => Mode::Disabled, - Mode::Disabled => Mode::Maps, + Mode::Maps => Mode::Mirror, + Mode::Mirror => Mode::Off, + Mode::Off => Mode::Maps, } } pub fn as_str(self) -> &'static str { match self { Mode::Maps => "maps", - Mode::Repeater => "repeater", - Mode::Disabled => "disabled", + Mode::Mirror => "mirror", + 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)", } } } diff --git a/src/session.rs b/src/session.rs index 0c8db22..0cad280 100644 --- a/src/session.rs +++ b/src/session.rs @@ -196,7 +196,7 @@ async fn refresh_slots( let managed = g.slots.iter().any(|(_, c)| c.address == aw.address) || aw.class == "enboxer-vfx" || aw.title.starts_with("enboxer-vfx"); - if managed && g.engine.mode != Mode::Disabled { + if managed { if !g.binds_on { let specs = bind_specs(&g)?; let sock = g.sock.display().to_string(); @@ -301,16 +301,21 @@ fn bind_specs(g: &Session) -> Result> { }); } } - if g.engine.mode == Mode::Repeater { - for k in &g.engine.profile.repeater.keys { - let id = passthrough_id(k).unwrap_or_else(|_| k.clone()); + if g.engine.mode == Mode::Mirror { + let keys = if g.engine.profile.repeater.keys.is_empty() { + 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) { continue; } - if g.engine.profile.map_by_hotkey(k).is_some() { + if g.engine.profile.map_by_hotkey(&k).is_some() { continue; } - let parsed = hotkey::parse(k)?; + let parsed = hotkey::parse(&k)?; specs.push(BindSpec { bind: parsed.hypr_bind(), ipc_bin: bin.clone(), @@ -375,12 +380,14 @@ async fn dispatch_cmd(session: &Arc>, line: &str) -> String { g.vfx_source ) } - "mode-cycle" => { - let mut g = session.lock().await; - let m = g.engine.cycle_mode(); - g.binds_on = false; - format!("mode={}", m.as_str()) - } + "mode-cycle" => match set_mode(session, None).await { + Ok(m) => format!("mode={}", m.as_str()), + Err(e) => format!("err {e}"), + }, + "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 { Ok(()) => "ok".into(), Err(e) => format!("err {e}"), @@ -409,6 +416,44 @@ async fn dispatch_cmd(session: &Arc>, line: &str) -> String { } } +async fn set_mode(session: &Arc>, name: Option<&str>) -> Result { + 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 { + vfx_hover_keys() + .into_iter() + .filter(|k| !k.starts_with("mouse:")) + .collect() +} + fn vfx_hover_keys() -> Vec { let mut v: Vec = [ "Space",