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:
parent
af45b054cc
commit
ed75a8b899
4
AGENT.md
4
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)
|
||||
|
||||
|
||||
21
AGENTS.md
Normal file
21
AGENTS.md
Normal 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 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.
|
||||
92
GOALS.md
Normal file
92
GOALS.md
Normal 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 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`.
|
||||
80
PLAN.md
80
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."
|
||||
```
|
||||
|
||||
@ -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 |
|
||||
|
||||
38
TASKS.md
38
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).
|
||||
|
||||
@ -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 }
|
||||
|
||||
104
src/gui.rs
104
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<Child>,
|
||||
slot_info: String,
|
||||
error: Option<String>,
|
||||
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();
|
||||
|
||||
@ -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)]
|
||||
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();
|
||||
|
||||
@ -202,7 +202,17 @@ pub fn select_windows(profile: &Profile, clients: Vec<Client>) -> 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<usize> {
|
||||
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<usize> {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@ -491,6 +491,7 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, 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<Mutex<Session>>) -> Result<()> {
|
||||
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<()> {
|
||||
let (cx, cy) = hypr::cursor_pos().await?;
|
||||
let others = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user