Document full product goals; gate layout; named profiles; type-to-others.

GOALS.md / AGENTS.md are the source of truth. Layout apply requires
ENBOXER_ALLOW_LAYOUT=1 and a GUI confirm. Example Video FX is off.
This commit is contained in:
en 2026-09-15 09:11:31 +02:00
parent af45b054cc
commit ed75a8b899
11 changed files with 310 additions and 95 deletions

View File

@ -1,6 +1,6 @@
# AGENT.md — enBoxer # 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 ## Roles
@ -37,7 +37,7 @@ opencode run --dir /home/en/git/enBoxer --auto --title "enBoxer tickets" \
## Product ## 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 users Hyprland.
## Review (Grok, after the worker, still not ponytail) ## Review (Grok, after the worker, still not ponytail)

21
AGENTS.md Normal file
View File

@ -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 users 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 users 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.

92
GOALS.md Normal file
View File

@ -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 users 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 apps 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 — **users 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 users 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`.

80
PLAN.md
View File

@ -1,68 +1,46 @@
# Plan # 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 ## How work is split
Two roles. They do not share a keyboard.
| Role | Who | Does | | 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. | | Designer + PM | Grok | Owns GOALS.md. Writes tickets before a worker phase. |
| Worker | OpenCode | Implements open tickets, **one at a time**, with the **ponytail plugin** on (`opencode.json`). Never `--pure`. Never runs ponytail-review. | | 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.” Push: HTTPS `https://gitea.nettsi.de/en/enbuddy` only.
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.
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:…`). ## Next (still GOALS.md)
- 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.
## 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 ## Invoke worker (only after tickets are listed in TASKS.md)
- [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 Forevers macro language when that client exists
## Invoke worker
```bash ```bash
opencode run --dir /home/en/git/enBoxer --auto --title "enBoxer T4-T6" \ opencode run --dir /home/en/git/enBoxer --auto --title "enBoxer tickets" \
"Read AGENT.md, PLAN.md, TASKS.md. Ponytail plugin required. Do open tickets in order, one at a time. Do not run ponytail-review." "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."
``` ```

View File

@ -8,7 +8,9 @@ Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortc
| Doc | What | | 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 | | [DESCRIPTION.md](DESCRIPTION.md) | One-page description |
| [AGENT.md](AGENT.md) | Designer / PM / worker contract | | [AGENT.md](AGENT.md) | Designer / PM / worker contract |
| [TASKS.md](TASKS.md) | OpenCode tickets | | [TASKS.md](TASKS.md) | OpenCode tickets |

View File

@ -1,43 +1,11 @@
# OpenCode tickets # 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. Product: [GOALS.md](GOALS.md). Do not invent work. Do not `hyprctl dispatch` on the live session.
Grok does **not** run ponytail-review until every ticket in this phase has been assigned (they all are) **and** the Open list is empty.
## Open ## 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 ## Closed
### T1 — format T1T6 hygiene and Video FX click/passthrough tests (see git log).
`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 T1T2).
### 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)

View File

@ -126,7 +126,7 @@ layout:
# Viewer sits on the current primary. Hover + pass_through sends keys/clicks to source_slot only. # Viewer sits on the current primary. Hover + pass_through sends keys/clicks to source_slot only.
video_fx: video_fx:
- name: alt_party - name: alt_party
enabled: true enabled: false
source_slot: 2 source_slot: 2
source: { x: 0.0, y: 0.0, w: 0.40, h: 0.40 } 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 } viewer: { x: 0.02, y: 0.02, w: 0.28, h: 0.28 }

View File

@ -48,6 +48,10 @@ pub fn run() -> Result<()> {
daemon: None, daemon: None,
slot_info: String::new(), slot_info: String::new(),
error: None, 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)))) eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app))))
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
@ -93,6 +97,10 @@ struct App {
daemon: Option<Child>, daemon: Option<Child>,
slot_info: String, slot_info: String,
error: Option<String>, error: Option<String>,
allow_layout: bool,
confirm_apply: bool,
type_buf: String,
profile_name: String,
} }
impl App { 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) { fn save(&mut self) {
if let Some(dir) = self.path.parent() { if let Some(dir) = self.path.parent() {
let _ = std::fs::create_dir_all(dir); let _ = std::fs::create_dir_all(dir);
@ -134,9 +179,12 @@ impl App {
fn start_daemon(&mut self) { fn start_daemon(&mut self) {
self.save(); self.save();
self.stop_daemon(); self.stop_daemon();
match Command::new(Self::exe()) let mut cmd = Command::new(Self::exe());
.args(["run", "-c"]) cmd.args(["run", "-c"]).arg(&self.path);
.arg(&self.path) if self.allow_layout {
cmd.env("ENBOXER_ALLOW_LAYOUT", "1");
}
match cmd
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(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::TopBottomPanel::top("menu").show(ctx, |ui| {
egui::menu::bar(ui, |ui| { egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| { ui.menu_button("File", |ui| {
@ -280,6 +347,17 @@ impl eframe::App for App {
} }
ui.close_menu(); 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() { if ui.button("Quit").clicked() {
self.stop_daemon(); self.stop_daemon();
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
@ -457,6 +535,18 @@ impl App {
ui.label( ui.label(
"maps = only listed hotkeys. mirror = clone to other clients. off = front window only.", "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.separator();
ui.label("Game binds (keys you set in WoW)"); ui.label("Game binds (keys you set in WoW)");
for key in ["interact", "ctm_on", "ctm_off", "assist", "follow"] { for key in ["interact", "ctm_on", "ctm_off", "assist", "follow"] {
@ -668,8 +758,13 @@ impl App {
} }
fn apply_layout(&mut self) { fn apply_layout(&mut self) {
if !self.allow_layout {
self.error = Some("enable “Allow moving game windows” on Session first".into());
return;
}
self.save(); self.save();
match Command::new(Self::exe()) match Command::new(Self::exe())
.env("ENBOXER_ALLOW_LAYOUT", "1")
.args(["layout-apply", "-c"]) .args(["layout-apply", "-c"])
.arg(&self.path) .arg(&self.path)
.output() .output()
@ -761,8 +856,7 @@ impl App {
.add(egui::Button::new("Save and Apply").fill(Color32::from_rgb(180, 120, 40))) .add(egui::Button::new("Save and Apply").fill(Color32::from_rgb(180, 120, 40)))
.clicked() .clicked()
{ {
self.generate_layout_preview(); self.confirm_apply = true;
self.apply_layout();
} }
}); });
ui.separator(); ui.separator();

View File

@ -185,10 +185,30 @@ pub fn passthrough_id(raw: &str) -> Result<String> {
} }
} }
pub fn type_keys(s: &str) -> Vec<String> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn parses_esdf() { fn parses_esdf() {
let p = parse("e").unwrap(); let p = parse("e").unwrap();

View File

@ -202,7 +202,17 @@ pub fn select_windows(profile: &Profile, clients: Vec<Client>) -> Vec<(u32, Clie
.collect() .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<usize> { pub async fn apply_for_profile(profile: &mut Profile) -> Result<usize> {
if !moves_allowed() {
anyhow::bail!("layout moves disabled (set ENBOXER_ALLOW_LAYOUT=1)");
}
let mons = monitors().await?; let mons = monitors().await?;
if profile.layout.slots.len() as u32 != profile.slots { if profile.layout.slots.len() as u32 != profile.slots {
profile.layout.slots = generate(&profile.layout, profile.slots, &mons); profile.layout.slots = generate(&profile.layout, profile.slots, &mons);
@ -214,6 +224,9 @@ pub async fn apply_for_profile(profile: &mut Profile) -> Result<usize> {
} }
pub async fn apply(slots: &[LayoutSlot], windows: &[(u32, Client)]) -> 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(); let mons = monitors().await.unwrap_or_default();
for (i, win) in windows { for (i, win) in windows {
let Some(geom0) = slots.get((*i as usize).saturating_sub(1)) else { 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)); .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] #[test]
fn clamp_rejects_negative() { fn clamp_rejects_negative() {
let m = mon(); let m = mon();

View File

@ -491,6 +491,7 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
.unwrap_or(272); .unwrap_or(272);
wm_ok(broadcast_click(session, btn).await) wm_ok(broadcast_click(session, btn).await)
} }
"type" => wm_ok(type_to_others(session, arg).await),
_ => format!("err unknown {cmd}"), _ => format!("err unknown {cmd}"),
} }
} }
@ -683,6 +684,23 @@ async fn toggle_mouse_broadcast(session: &Arc<Mutex<Session>>) -> Result<()> {
Ok(()) Ok(())
} }
async fn type_to_others(session: &Arc<Mutex<Session>>, 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::<Vec<_>>()
};
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<Mutex<Session>>, button: u32) -> Result<()> { async fn broadcast_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
let (cx, cy) = hypr::cursor_pos().await?; let (cx, cy) = hypr::cursor_pos().await?;
let others = { let others = {