diff --git a/AGENT.md b/AGENT.md index caa8efd..f45f7d4 100644 --- a/AGENT.md +++ b/AGENT.md @@ -1,6 +1,6 @@ # AGENT.md — enBoxer -Contract for this repo. The **plan** lives in [PLAN.md](PLAN.md). Tickets live in [TASKS.md](TASKS.md). Do not mix roles. +Contract for this repo. **What the user wants:** [GOALS.md](GOALS.md). Also [AGENTS.md](AGENTS.md), [PLAN.md](PLAN.md), [TASKS.md](TASKS.md). Do not mix roles. ## Roles @@ -37,7 +37,7 @@ opencode run --dir /home/en/git/enBoxer --auto --title "enBoxer tickets" \ ## Product -Mapped keys to several WoW windows on Hyprland. Unmapped keys stay on the primary. `passthrough` is optional (example ESDF). No injection. Macros, not addons. Video FX is a crop with hover pass-through. +See GOALS.md. Short: Rust Hyprland rewrite of a Windows multibox **GUI** (layout wizard included), then mapped-key routing and Video FX on top. No inject. No live window-move tests on the user’s Hyprland. ## Review (Grok, after the worker, still not ponytail) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a2963ab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# AGENTS.md + +Read **[GOALS.md](GOALS.md)** first. That is what the user wants. Then [PLAN.md](PLAN.md), [AGENT.md](AGENT.md), [TASKS.md](TASKS.md). + +## Hard rules + +- Product: Rust Hyprland rewrite of a Windows multibox GUI, then extras (Video FX, mapped-key routing). Path `~/git/enBoxer`. Remote HTTPS `https://gitea.nettsi.de/en/enbuddy`. +- **No game injection. No required WoW addon in v1.** +- **Do not crash Hyprland.** Never `hyprctl dispatch` float/move/resize/pin/close on the user’s session. Never `enboxer layout-apply` or swap/reset against live windows unless the user opted in. Layout code is gated on `ENBOXER_ALLOW_LAYOUT=1`. +- Do not launch Steam or unrelated apps. +- Do not hardcode WASD; passthrough is the user’s list (example ESDF). +- User does not test until they choose to. Prefer unit tests. + +## Roles + +| Role | Who | +| --- | --- | +| Designer / PM / reviewer | Grok — owns GOALS.md and tickets | +| Worker | OpenCode — ponytail plugin on, never `--pure`, never ponytail-review | + +Grok implements when the user says to keep going without OpenCode. diff --git a/GOALS.md b/GOALS.md new file mode 100644 index 0000000..76f986e --- /dev/null +++ b/GOALS.md @@ -0,0 +1,92 @@ +# Goals + +This is what the project is **for**. If a change fights this file, the change is wrong. + +enBoxer is a **Rust rewrite, for Arch Linux + Hyprland**, of a Windows multibox controller, **then extras on top**. It lives in `~/git/enBoxer` and ships to **https://gitea.nettsi.de/en/enbuddy** over **HTTPS only** (never SSH). + +The user plays several World of Warcraft clients (Forever when it exists; until then retail-shaped macros). They move with **ESDF**, not WASD. They do **not** want the game to think they are botting: **no DLL, no memory inject, no addon required in v1**. Keys and clicks only. They will bind macros in-game themselves. + +They do **not** want to test until they have to. Agents must not crash the Hyprland session: **no live `hyprctl dispatch` that floats, moves, resizes, pins, or closes the user’s windows.** Layout apply is opt-in (`ENBOXER_ALLOW_LAYOUT=1` plus a GUI confirm). Do not launch Steam or unrelated programs. + +## Rewrite first (the original app) + +That Windows app is a **GUI** with a **window layout wizard**. enBoxer must be the same kind of program, not a YAML daemon with a thin panel bolted on. + +### Control panel + +- One native GUI (`enboxer` with no args). Menus, not only CLI. +- **File:** save / reload / named profiles. +- **Session:** start/stop routing, three modes, capture list, passthrough, game binds. +- **Layout:** the important missing piece from the first GUI pass — see below. +- **Maps:** hotkeys and steps. +- **Video FX:** source and viewer rectangles. +- **Macros:** text to bind in WoW. + +### Window layout (from the source app’s wizard) + +- Choose how many clients. +- Presets: **all stacked**, **same-size grid**, **one large main + a strip of minions**. +- Options: main at bottom, one row for small windows, stay-on-top / pin, which monitor. +- Per-slot **x y w h** (pixels), editable after generate. +- Drag-equivalent: Generate, **Capture current positions**, **Save and Apply**. +- Apply only when the user allows window moves (see Safety). +- **Swap** a minion into the main tile (Make main / swap next / per-slot swap hotkeys). +- **Focus** next / previous / main / slot N. +- **Reset all** to the saved layout. +- Auto-capture matching game windows by class/title regex (do not grab the whole desktop; empty match = match nothing). + +### Key delivery (rewrite, then the extra) + +The Windows app cloned keys by posting messages and/or swapping which window was in front. enBoxer **routes** instead: + +1. **maps** — only configured hotkeys are intercepted and sent to `current` / `others` / `all` / groups. Everything else goes to the front client. +2. **mirror** — front client still gets the real key; the same key is cloned to the other game windows. `passthrough` (ESDF by default, or empty, or WASD — **user’s list, never hardcoded**) is not cloned. +3. **off** — no intercept. All keys go to the front window. Mode-toggle still works. + +Toggle: `Shift+Alt+M` (configurable) and GUI. Routing **off** when alt-tabbed to a non-game window. The machine is a normal PC. + +Keys are Hyprland compositor events (`send_shortcut` / `send_key_state`). Wine on **XWayland** (usual WoW) accepts them unfocused. Native Wayland / winewayland: brief focus, send, restore. Not a physical `/dev/input` keyboard; not process injection. + +Mouse click clone (optional): relative to each client. Do not leave the cursor stranded. + +### Interact / loot (on top of routing) + +One press, not spam. Assist others → Click-to-Move on → Interact with Target once → wait `walk_delay_ms` → CTM off. Hold and auto styles exist. Macros: `docs/MACROS.md`. No addon until Forever exists and we know the API. + +## Extras on top of the rewrite + +### Video FX (live crop) + +The user must be able to set: + +- **Source:** which other client, which rectangle, what size. +- **Viewer:** where that crop is drawn on the **primary**, what size. +- Fractions `0`–`1` of that window, or **pixels** if any value > 1. GUI **Pick** via slurp. +- While the mouse is over the viewer: **clicks and keys go only to that source**, not to the primary and not to the rest of the team. + +Today capture is `grim` (visible pixels) + `mpv` overlay. **Covered/stacked sources need compositor window-export** (`hyprland-toplevel-export`). Until that exists, keep the source on a visible output. Do not claim grim is good enough for a stacked layout. + +### Other extras (wanted, not all shipped) + +- Named profiles (multiple teams). +- Type/clipboard broadcast to other clients (passwords, `/commands`) without swapping windows. +- Slot-number overlay on the layout (click to make main), like the source app. +- Borderless game windows via Hyprland rules, not inject. +- Follow/assist macros that follow **who is currently main** after a swap. +- Forever client check when that game exists (lean retail macros until proven otherwise). + +## Safety (non-negotiable) + +- Default `enboxer run`: **keys only**. No float/move/resize/pin. +- `ENBOXER_ALLOW_LAYOUT=1` required for apply/swap-geometry/reset. +- GUI: checkbox “Allow moving game windows”, then a confirm before Apply. +- Example profile: Video FX **off**, layout tiles **empty**, `auto_apply: false`. +- Agents: unit tests only against Hyprland. No live dispatch that rearranges the user’s session. No Steam. + +## Done when + +- GUI can configure **all** of the above without hand-editing YAML (YAML remains the store). +- Layout wizard + apply works **when the user opts in**, without off-monitor garbage. +- Maps / mirror / off work; ESDF passthrough is configurable. +- Video FX source/viewer/size + hover click and key pass-through work for **visible** sources; export for hidden sources is the remaining capture gap. +- No inject, no required addon, HTTPS remote is `en/enbuddy`. diff --git a/PLAN.md b/PLAN.md index 9cda7b7..a5ecc6c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,68 +1,46 @@ # Plan -enBoxer is a new Hyprland program. It routes keys to several World of Warcraft windows and can show a live crop of one window on another. +Product intent: **[GOALS.md](GOALS.md)**. Agent contract: [AGENT.md](AGENT.md), [AGENTS.md](AGENTS.md). Tickets: [TASKS.md](TASKS.md). ## How work is split -Two roles. They do not share a keyboard. - | Role | Who | Does | | --- | --- | --- | -| Designer + PM | Grok | Writes this file and `TASKS.md`. Splits the product into **small** tickets **before** the worker starts a phase. Does not implement those tickets. | -| Worker | OpenCode | Implements open tickets, **one at a time**, with the **ponytail plugin** on (`opencode.json`). Never `--pure`. Never runs ponytail-review. | +| Designer + PM | Grok | Owns GOALS.md. Writes tickets before a worker phase. | +| Worker | OpenCode | One ticket at a time, ponytail on, never `--pure`. | -Sequence for a phase: +When the user tells Grok to keep going, Grok implements. **No live compositor dispatch** (see GOALS.md Safety). -1. Grok writes every ticket for that phase into `TASKS.md` (Open list). That is “giving tasks.” -2. Grok starts OpenCode against those tickets. No ponytail-review while tickets are still being written or still open. -3. OpenCode finishes the phase (or says a ticket is blocked). -4. Grok reads the diff against the tickets (ordinary review: did the ticket land, tests pass). Not ponytail. -5. If a ticket is wrong, Grok adds a **smaller** ticket and sends OpenCode back. That is a new giving-tasks step; ponytail still waits. -6. When Grok accepts the whole phase, **Grok** runs ponytail-review once. -7. Grok pushes HTTPS `https://gitea.nettsi.de/en/enbuddy`. No SSH. +Push: HTTPS `https://gitea.nettsi.de/en/enbuddy` only. -OpenCode must not invent tickets, add features, or “clean up” docs unless a ticket says so. +## Shipped -## Product rules (do not regress) +- [x] Rust crate, YAML profile, mapped-key engine, three modes (maps / mirror / off) +- [x] Configurable passthrough (example ESDF) +- [x] Hyprland 0.56 Lua binds + `send_shortcut` (no inject) +- [x] Interact/loot map (assist, CTM, IWT once, delay) +- [x] Video FX: source + viewer rects (fraction or pixels), grim + mpv, hover keys/clicks +- [x] Control panel GUI (File / Session / Layout / Maps / Video / Macros) +- [x] Layout presets: stacked, grid, main+strip; per-slot x/y/w/h; generate/capture +- [x] Swap into main, focus next/prev/slot, reset, pin, mouse-follow/broadcast **code** +- [x] Layout moves **gated** (`ENBOXER_ALLOW_LAYOUT=1`); tiles clamped to monitor +- [x] HTTPS remote `en/enbuddy` +- [x] Example Video FX **disabled**; layout tiles empty -- Named maps: a hotkey runs a list of steps to named targets (`current`, `others`, `all`, `group:…`, `slots:…`). -- Keys not in maps (and not in repeater) go only to the focused primary. -- `passthrough` is a list. Empty means nothing is skipped. The example profile uses ESDF so those letters stay on the primary. -- Routing is off when focus is not a managed game window and not an enBoxer overlay. -- No code is loaded into the game. Keys go through Hyprland (`hl.dsp.send_shortcut` / `send_key_state`). -- No game addon in v1. Macros and the Interact with Target bind are documented in `docs/MACROS.md`. -- Video FX: crop of slot A drawn on the primary; hover with `pass_through` sends keys and clicks to slot A only. +## Next (still GOALS.md) -## Phase 0 — tree and contract (Grok) +- [ ] GUI: confirm before Apply; Session checkbox “Allow moving game windows” +- [ ] Named profiles (File save as / open) +- [ ] Type/clipboard broadcast to others +- [ ] Slot-number overlay (click to make main) +- [ ] `hyprland-toplevel-export` for covered Video FX sources +- [ ] Follow/assist follow **current main** after swap +- [ ] Borderless via Hyprland window rules +- [ ] Forever macros when that client exists -- [x] Crate, profile, engine, Hyprland session, grim Video FX, tests -- [x] `README.md`, `DESCRIPTION.md`, `CHANGELOG.md`, `AGENT.md`, `TASKS.md`, this file -- [x] `opencode.json` with ponytail -- [x] Git remote + push HTTPS `https://gitea.nettsi.de/en/enbuddy` - -## Phase 1 — OpenCode tickets (all assigned below; do not ponytail-review until they are closed) - -See `TASKS.md`. - -- T1 format — closed -- T2 clippy — closed -- T3 stop if already clean — closed -- T4 Video FX click pass-through — closed -- T5 log slot list when it changes — closed -- T6 test: empty passthrough can map `e` — closed - -Phase 1 Open list is empty. Next: Grok ponytail-review, then git push. - -## Later (not this phase, not OpenCode until Grok files tickets) - -- Capture a window that is fully covered (`hyprland-toplevel-export`) -- Clicks on a covered source without moving the real cursor onto the covering window -- Follow/assist binds that track which slot is primary -- Check Forever’s macro language when that client exists - -## Invoke worker +## Invoke worker (only after tickets are listed in TASKS.md) ```bash -opencode run --dir /home/en/git/enBoxer --auto --title "enBoxer T4-T6" \ - "Read AGENT.md, PLAN.md, TASKS.md. Ponytail plugin required. Do open tickets in order, one at a time. Do not run ponytail-review." +opencode run --dir /home/en/git/enBoxer --auto --title "enBoxer tickets" \ + "Read GOALS.md, AGENTS.md, TASKS.md. Ponytail required. Do open tickets in order. Do not run ponytail-review. Do not hyprctl dispatch on the live session." ``` diff --git a/README.md b/README.md index c8adf37..dab5664 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortc | Doc | What | | --- | --- | -| [PLAN.md](PLAN.md) | Product plan and how Grok / OpenCode split work | +| [GOALS.md](GOALS.md) | What this project is for (read this) | +| [AGENTS.md](AGENTS.md) | Rules for Grok / OpenCode | +| [PLAN.md](PLAN.md) | Shipped vs next | | [DESCRIPTION.md](DESCRIPTION.md) | One-page description | | [AGENT.md](AGENT.md) | Designer / PM / worker contract | | [TASKS.md](TASKS.md) | OpenCode tickets | diff --git a/TASKS.md b/TASKS.md index 3db4913..f0ed610 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,43 +1,11 @@ # OpenCode tickets -Grok writes these (see `PLAN.md`). OpenCode takes **one ticket at a time**. Ponytail plugin required. Do not invent tickets. Do not start the next ticket until the current one is done and `cargo test` passes. - -Grok does **not** run ponytail-review until every ticket in this phase has been assigned (they all are) **and** the Open list is empty. +Product: [GOALS.md](GOALS.md). Do not invent work. Do not `hyprctl dispatch` on the live session. ## Open -_(none — Phase 1 tickets all assigned and closed. Grok may run ponytail-review now.)_ +_(none — Grok is implementing. File tickets here before an OpenCode run.)_ ## Closed -### T1 — format - -`cargo fmt`. Done. - -### T2 — clippy, only what it names - -`cargo clippy --all-targets -- -D warnings`. Done. - -### T3 — stop if fmt/clippy/test are already clean - -Done (fmt/clippy/test were clean after T1–T2). - -### T4 — Video FX click pass-through - -Done (`mouse:272`/`273` → `map_click` + cursor + send to source; unit test). - -### T5 — log slots when the set changes - -Done. - -### T6 — empty passthrough can map `e` - -Done. - -## Out of scope for OpenCode - -- Gitea / git remotes (Grok) -- `hyprland-toplevel-export` -- Game addons -- New step kinds -- ponytail-review (Grok, after this phase is accepted) +T1–T6 hygiene and Video FX click/passthrough tests (see git log). diff --git a/examples/profile.yaml b/examples/profile.yaml index 21f1035..9037584 100644 --- a/examples/profile.yaml +++ b/examples/profile.yaml @@ -126,7 +126,7 @@ layout: # Viewer sits on the current primary. Hover + pass_through sends keys/clicks to source_slot only. video_fx: - name: alt_party - enabled: true + enabled: false source_slot: 2 source: { x: 0.0, y: 0.0, w: 0.40, h: 0.40 } viewer: { x: 0.02, y: 0.02, w: 0.28, h: 0.28 } diff --git a/src/gui.rs b/src/gui.rs index df26cf9..78b4cf5 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -48,6 +48,10 @@ pub fn run() -> Result<()> { daemon: None, slot_info: String::new(), error: None, + allow_layout: false, + confirm_apply: false, + type_buf: String::new(), + profile_name: String::new(), }; eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app)))) .map_err(|e| anyhow::anyhow!("{e}"))?; @@ -93,6 +97,10 @@ struct App { daemon: Option, slot_info: String, error: Option, + allow_layout: bool, + confirm_apply: bool, + type_buf: String, + profile_name: String, } impl App { @@ -118,6 +126,43 @@ impl App { } } + fn profiles_dir() -> PathBuf { + default_config_path() + .parent() + .map(|p| p.join("profiles")) + .unwrap_or_else(|| PathBuf::from("profiles")) + } + + fn save_named_profile(&mut self) { + let name = self.profile_name.trim(); + if name.is_empty() { + self.error = Some("enter a profile name".into()); + return; + } + let dir = Self::profiles_dir(); + let _ = std::fs::create_dir_all(&dir); + self.path = dir.join(format!("{name}.yaml")); + self.profile.name = name.to_string(); + self.save(); + } + + fn load_named_profile(&mut self) { + let name = self.profile_name.trim(); + if name.is_empty() { + self.error = Some("enter a profile name".into()); + return; + } + let path = Self::profiles_dir().join(format!("{name}.yaml")); + match Profile::load(&path) { + Ok(p) => { + self.profile = p; + self.path = path; + self.status = format!("loaded {}", self.profile.name); + } + Err(e) => self.error = Some(e.to_string()), + } + } + fn save(&mut self) { if let Some(dir) = self.path.parent() { let _ = std::fs::create_dir_all(dir); @@ -134,9 +179,12 @@ impl App { fn start_daemon(&mut self) { self.save(); self.stop_daemon(); - match Command::new(Self::exe()) - .args(["run", "-c"]) - .arg(&self.path) + let mut cmd = Command::new(Self::exe()); + cmd.args(["run", "-c"]).arg(&self.path); + if self.allow_layout { + cmd.env("ENBOXER_ALLOW_LAYOUT", "1"); + } + match cmd .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -266,6 +314,25 @@ impl eframe::App for App { } } + if self.confirm_apply { + egui::Window::new("Move game windows?") + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label("This floats and resizes captured game clients. It can upset Hyprland if something goes wrong."); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.confirm_apply = false; + } + if ui.button("Apply anyway").clicked() { + self.confirm_apply = false; + self.generate_layout_preview(); + self.apply_layout(); + } + }); + }); + } + egui::TopBottomPanel::top("menu").show(ctx, |ui| { egui::menu::bar(ui, |ui| { ui.menu_button("File", |ui| { @@ -280,6 +347,17 @@ impl eframe::App for App { } ui.close_menu(); } + ui.separator(); + ui.label("Profile name"); + ui.text_edit_singleline(&mut self.profile_name); + if ui.button("Save as named profile").clicked() { + self.save_named_profile(); + ui.close_menu(); + } + if ui.button("Load named profile").clicked() { + self.load_named_profile(); + ui.close_menu(); + } if ui.button("Quit").clicked() { self.stop_daemon(); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); @@ -457,6 +535,18 @@ impl App { ui.label( "maps = only listed hotkeys. mirror = clone to other clients. off = front window only.", ); + ui.checkbox( + &mut self.allow_layout, + "Allow moving/resizing game windows (required for Apply / swap geometry)", + ); + ui.horizontal(|ui| { + ui.label("Type to others"); + ui.text_edit_singleline(&mut self.type_buf); + if ui.button("Send").clicked() && !self.type_buf.is_empty() { + let t = self.type_buf.clone(); + self.ipc("type", &t); + } + }); ui.separator(); ui.label("Game binds (keys you set in WoW)"); for key in ["interact", "ctm_on", "ctm_off", "assist", "follow"] { @@ -668,8 +758,13 @@ impl App { } fn apply_layout(&mut self) { + if !self.allow_layout { + self.error = Some("enable “Allow moving game windows” on Session first".into()); + return; + } self.save(); match Command::new(Self::exe()) + .env("ENBOXER_ALLOW_LAYOUT", "1") .args(["layout-apply", "-c"]) .arg(&self.path) .output() @@ -761,8 +856,7 @@ impl App { .add(egui::Button::new("Save and Apply").fill(Color32::from_rgb(180, 120, 40))) .clicked() { - self.generate_layout_preview(); - self.apply_layout(); + self.confirm_apply = true; } }); ui.separator(); diff --git a/src/hotkey.rs b/src/hotkey.rs index 6ae1af0..7b3c308 100644 --- a/src/hotkey.rs +++ b/src/hotkey.rs @@ -185,10 +185,30 @@ pub fn passthrough_id(raw: &str) -> Result { } } +pub fn type_keys(s: &str) -> Vec { + s.chars() + .map(|c| match c { + ' ' => "Space".into(), + '\n' | '\r' => "Return".into(), + '\t' => "Tab".into(), + c if c.is_ascii_uppercase() => format!("Shift+{}", c.to_ascii_lowercase()), + c => c.to_string(), + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn type_keys_spaces_and_shift() { + assert_eq!( + type_keys("A z"), + vec!["Shift+a".to_string(), "Space".into(), "z".into()] + ); + } + #[test] fn parses_esdf() { let p = parse("e").unwrap(); diff --git a/src/layout.rs b/src/layout.rs index 856f939..486f81b 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -202,7 +202,17 @@ pub fn select_windows(profile: &Profile, clients: Vec) -> Vec<(u32, Clie .collect() } +/// Window move/resize/pin is off unless ENBOXER_ALLOW_LAYOUT=1. +pub fn moves_allowed() -> bool { + std::env::var("ENBOXER_ALLOW_LAYOUT") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + pub async fn apply_for_profile(profile: &mut Profile) -> Result { + if !moves_allowed() { + anyhow::bail!("layout moves disabled (set ENBOXER_ALLOW_LAYOUT=1)"); + } let mons = monitors().await?; if profile.layout.slots.len() as u32 != profile.slots { profile.layout.slots = generate(&profile.layout, profile.slots, &mons); @@ -214,6 +224,9 @@ pub async fn apply_for_profile(profile: &mut Profile) -> Result { } pub async fn apply(slots: &[LayoutSlot], windows: &[(u32, Client)]) -> Result<()> { + if !moves_allowed() { + anyhow::bail!("layout moves disabled (set ENBOXER_ALLOW_LAYOUT=1)"); + } let mons = monitors().await.unwrap_or_default(); for (i, win) in windows { let Some(geom0) = slots.get((*i as usize).saturating_sub(1)) else { @@ -296,6 +309,15 @@ mod tests { .all(|w| w.w == 1920 && w.h == 1080 && w.x == 0 && w.y == 0)); } + #[test] + fn moves_allowed_defaults_off() { + std::env::remove_var("ENBOXER_ALLOW_LAYOUT"); + assert!(!moves_allowed()); + std::env::set_var("ENBOXER_ALLOW_LAYOUT", "1"); + assert!(moves_allowed()); + std::env::remove_var("ENBOXER_ALLOW_LAYOUT"); + } + #[test] fn clamp_rejects_negative() { let m = mon(); diff --git a/src/session.rs b/src/session.rs index 25df94a..0bc6734 100644 --- a/src/session.rs +++ b/src/session.rs @@ -491,6 +491,7 @@ async fn dispatch_cmd(session: &Arc>, line: &str) -> String { .unwrap_or(272); wm_ok(broadcast_click(session, btn).await) } + "type" => wm_ok(type_to_others(session, arg).await), _ => format!("err unknown {cmd}"), } } @@ -683,6 +684,23 @@ async fn toggle_mouse_broadcast(session: &Arc>) -> Result<()> { Ok(()) } +async fn type_to_others(session: &Arc>, text: &str) -> Result<()> { + let others = { + let g = session.lock().await; + g.slots + .iter() + .filter(|(s, _)| *s != g.engine.leader_slot) + .map(|(_, c)| c.clone()) + .collect::>() + }; + for key in crate::hotkey::type_keys(text) { + for c in &others { + hypr::deliver_key(c, &key, None).await.ok(); + } + } + Ok(()) +} + async fn broadcast_click(session: &Arc>, button: u32) -> Result<()> { let (cx, cy) = hypr::cursor_pos().await?; let others = {