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
|
# 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 user’s Hyprland.
|
||||||
|
|
||||||
## Review (Grok, after the worker, still not ponytail)
|
## 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
|
# 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 Forever’s 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."
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|||||||
38
TASKS.md
38
TASKS.md
@ -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
|
T1–T6 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 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)
|
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
104
src/gui.rs
104
src/gui.rs
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user