Compare commits
No commits in common. "a7acce2a6a82d49a2122767e489e6f8b38a35e6a" and "4daf0ea86abf74c020b7d0ed61655423a5774117" have entirely different histories.
a7acce2a6a
...
4daf0ea86a
4
AGENT.md
4
AGENT.md
@ -1,6 +1,6 @@
|
|||||||
# AGENT.md — enBoxer
|
# AGENT.md — enBoxer
|
||||||
|
|
||||||
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.
|
Contract for this repo. The **plan** lives in [PLAN.md](PLAN.md). Tickets live in [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
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Review (Grok, after the worker, still not ponytail)
|
## Review (Grok, after the worker, still not ponytail)
|
||||||
|
|
||||||
|
|||||||
21
AGENTS.md
21
AGENTS.md
@ -1,21 +0,0 @@
|
|||||||
# 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.
|
|
||||||
15
CHANGELOG.md
15
CHANGELOG.md
@ -10,18 +10,3 @@
|
|||||||
- Stock loot map: assist → CTM on → Interact with Target once → delay → CTM off
|
- Stock loot map: assist → CTM on → Interact with Target once → delay → CTM off
|
||||||
- Video FX: `grim` region capture, overlay viewer, hover pass-through to source slot
|
- Video FX: `grim` region capture, overlay viewer, hover pass-through to source slot
|
||||||
- Unit tests for hotkey parse, passthrough, targets, loot sequence, example profile load
|
- Unit tests for hotkey parse, passthrough, targets, loot sequence, example profile load
|
||||||
- Bind IPC line no longer duplicated `ipc` (Hyprland hotkeys actually reach the daemon)
|
|
||||||
- Video FX overlay is `mpv` (`--wayland-app-id=enboxer-vfx`) with JSON reload; `grim` capture
|
|
||||||
- `enboxer run` clears binds on ctrl-c; empty `window_match` matches nothing
|
|
||||||
- Live check: `send_shortcut` to an unfocused XWayland window; overlay window class `enboxer-vfx`
|
|
||||||
- Per-character `assist_key` / `follow_key`; map steps using `bind: assist|follow` resolve to the current main's keys
|
|
||||||
- Layout wizard `borderless` toggle installs a Hyprland `window_rule` (rounding 0, border_size 0) on `run`
|
|
||||||
- Layout page: drag canvas over the monitor rectangle; tile moves clamp to the monitor bounds
|
|
||||||
- T8: clipboard IPC verb `clipboard` reads `wl-paste` / `xclip` and delivers Ctrl+V to every non-leader captured slot; GUI button on Session page; routing plan unit-tested
|
|
||||||
- T11: named-profiles list in File menu (`Load named…` submenu) with Refresh; listing helper unit-tested against a tempdir
|
|
||||||
- T12: unit test for `type_to_others` routing (`others_clients` helper, excludes leader, per-char × per-slot plan) and for `apply_layout` short-circuiting when `ENBOXER_ALLOW_LAYOUT` is unset
|
|
||||||
- T9 (real): `src/wayland_layer.rs` — `zwlr_layer_shell_v1` Wayland client with shm-backed buffers, 3×5 bitmap digit glyphs, `wl_pointer` click routing; per-slot `OverlayHandle` via `OverlayHub` in `session::run`. Live path gated behind `ENBOXER_ENABLE_OVERLAY=1`; `cargo test` does not connect to Wayland
|
|
||||||
- T10 (real): `src/toplevel_export.rs` — `zwlr_export_dmabuf_unstable_v1` Wayland client; per-frame format negotiation (ARGB8888 / XRGB8888), synthetic-PNG fallback for tests. Live path gated behind `ENBOXER_ENABLE_TOPLEVEL=1`; `gbm_bo_map` is documented as the upgrade step for actual pixel reads
|
|
||||||
- T13: mirror-mode mouse click broadcast — `hypr::deliver_click` posts compositor pointer events to every non-leader captured slot; press-and-hold guard deferred
|
|
||||||
- T14: round-robin bind target (`round_robin` / `rr`) — rotates through ALL slots including the leader, in slot-number order, wrapping; per-map cursor
|
|
||||||
- T15: Teams + Lutris launcher. `src/team.rs` (Team, list_teams, teams_dir, current_team), `src/lutris.rs` (LutrisGame parser, `load_all` with bad-YAML tolerance), `src/launcher.rs` (SpawnPlan merging Lutris config with per-character wine-prefix override). GUI: Teams menu (New / Switch / Refresh / Show / Delete) and Launch menu (per-character + Launch all); Lutris picker visible only in the New-team flow. Direct Wine spawn — never calls `lutris` CLI
|
|
||||||
|
|||||||
3090
Cargo.lock
generated
3090
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -23,20 +23,11 @@ tokio = { version = "1", features = [
|
|||||||
"net",
|
"net",
|
||||||
"process",
|
"process",
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"signal",
|
|
||||||
"sync",
|
"sync",
|
||||||
"time",
|
"time",
|
||||||
] }
|
] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
eframe = { version = "0.31", default-features = false, features = [
|
|
||||||
"glow",
|
|
||||||
"wayland",
|
|
||||||
"x11",
|
|
||||||
"default_fonts",
|
|
||||||
] }
|
|
||||||
wayland-client = "0.31"
|
|
||||||
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions = "1"
|
pretty_assertions = "1"
|
||||||
|
|||||||
@ -8,4 +8,4 @@ Keys are compositor input (`hl.dsp.send_shortcut`). Nothing is injected into the
|
|||||||
|
|
||||||
Video FX mirrors a region of one client onto the primary and, on hover, sends keys and clicks to that source only.
|
Video FX mirrors a region of one client onto the primary and, on hover, sends keys and clicks to that source only.
|
||||||
|
|
||||||
Remote: https://gitea.nettsi.de/en/enbuddy
|
Remote: https://gitea.nettsi.de/en/enBoxer
|
||||||
|
|||||||
92
GOALS.md
92
GOALS.md
@ -1,92 +0,0 @@
|
|||||||
# 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,46 +1,68 @@
|
|||||||
# Plan
|
# Plan
|
||||||
|
|
||||||
Product intent: **[GOALS.md](GOALS.md)**. Agent contract: [AGENT.md](AGENT.md), [AGENTS.md](AGENTS.md). Tickets: [TASKS.md](TASKS.md).
|
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.
|
||||||
|
|
||||||
## 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 | Owns GOALS.md. Writes tickets before a worker phase. |
|
| 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 | One ticket at a time, ponytail on, never `--pure`. |
|
| Worker | OpenCode | Implements open tickets, **one at a time**, with the **ponytail plugin** on (`opencode.json`). Never `--pure`. Never runs ponytail-review. |
|
||||||
|
|
||||||
When the user tells Grok to keep going, Grok implements. **No live compositor dispatch** (see GOALS.md Safety).
|
Sequence for a phase:
|
||||||
|
|
||||||
Push: HTTPS `https://gitea.nettsi.de/en/enBoxer` only.
|
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 git. The name `en/enbuddy` is already taken on this Gitea (existing `en/enBuddy`); this project uses `https://gitea.nettsi.de/en/enBoxer`.
|
||||||
|
|
||||||
## Shipped
|
OpenCode must not invent tickets, add features, or “clean up” docs unless a ticket says so.
|
||||||
|
|
||||||
- [x] Rust crate, YAML profile, mapped-key engine, three modes (maps / mirror / off)
|
## Product rules (do not regress)
|
||||||
- [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/enBoxer` (camelCase, matches the project name)
|
|
||||||
- [x] Example Video FX **disabled**; layout tiles empty
|
|
||||||
|
|
||||||
## Next (still GOALS.md)
|
- 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.
|
||||||
|
|
||||||
- [ ] GUI: confirm before Apply; Session checkbox “Allow moving game windows”
|
## Phase 0 — tree and contract (Grok)
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
## Invoke worker (only after tickets are listed in TASKS.md)
|
- [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
|
||||||
|
- [ ] Git remote + push (after Phase 1 is accepted and Grok has run ponytail-review)
|
||||||
|
|
||||||
|
## 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 tickets" \
|
opencode run --dir /home/en/git/enBoxer --auto --title "enBoxer T4-T6" \
|
||||||
"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."
|
"Read AGENT.md, PLAN.md, TASKS.md. Ponytail plugin required. Do open tickets in order, one at a time. Do not run ponytail-review."
|
||||||
```
|
```
|
||||||
|
|||||||
153
README.md
153
README.md
@ -1,85 +1,38 @@
|
|||||||
# enBoxer
|
# enBoxer
|
||||||
|
|
||||||
Mapped-key routing and live window crops for **Arch Linux + Hyprland**. Rust rewrite of a Windows multibox controller — the project layout, slot overlay, and key-routing engine follow the same shape, but nothing game-side is touched. Keys and clicks only. Pushes to `https://gitea.nettsi.de/en/enBoxer` over HTTPS only.
|
Mapped-key routing and live window crops for World of Warcraft on **Arch Linux + Hyprland**. Rust.
|
||||||
|
|
||||||
You list which hotkeys go where. Everything you do not list stays on the focused primary. A `passthrough` list (the example uses ESDF) leaves movement keys alone. Alt-tab off the team and the desktop is normal.
|
You list which hotkeys go where. Everything you do not list stays on the focused primary. A `passthrough` list can leave movement keys alone (the example uses ESDF). Alt-tab off the team and the desktop is normal.
|
||||||
|
|
||||||
Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortcut` / `send_key_state`. You bind macros in WoW yourself.
|
Nothing is loaded into the game. Hyprland delivers keys with `hl.dsp.send_shortcut`. You bind macros in WoW yourself.
|
||||||
|
|
||||||
## Routing (three modes)
|
| Doc | What |
|
||||||
|
|
||||||
Toggle with `Shift+Alt+M` (configurable) or `enboxer mode maps|mirror|off` while the daemon runs. A Hyprland notification shows the new mode.
|
|
||||||
|
|
||||||
1. **maps** — only keys you listed under `maps` are intercepted and sent where the map says. Everything else goes to the front window.
|
|
||||||
2. **mirror** — the front window still gets the real key; the same key is cloned to the other game windows. `passthrough` (e.g. ESDF) is not cloned.
|
|
||||||
3. **off** — nothing is intercepted. All keys go to the front window. The mode-toggle hotkey still works.
|
|
||||||
|
|
||||||
A **map** is a hotkey plus steps (send a key, send a named `game_bind`, wait) plus a **target**.
|
|
||||||
|
|
||||||
Targets:
|
|
||||||
|
|
||||||
| Target | Meaning |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `current` | The leader slot only |
|
| [PLAN.md](PLAN.md) | Product plan and how Grok / OpenCode split work |
|
||||||
| `others` | Every captured slot except the leader |
|
| [DESCRIPTION.md](DESCRIPTION.md) | One-page description |
|
||||||
| `all` | Every captured slot including the leader |
|
| [AGENT.md](AGENT.md) | Designer / PM / worker contract |
|
||||||
| `group:<name>` | Slots listed in `groups.<name>` |
|
| [TASKS.md](TASKS.md) | OpenCode tickets |
|
||||||
| `slot:<n>` | One specific slot |
|
| [CHANGELOG.md](CHANGELOG.md) | What landed |
|
||||||
| `slots:<n>,<m>,…` | An explicit list |
|
| [docs/DESIGN.md](docs/DESIGN.md) | Routing model |
|
||||||
| `round_robin` / `rr` | Each press advances to the next slot (1→2→3→…→1, **includes the leader**, wrapping). State is per-map. |
|
| [docs/NOTES.md](docs/NOTES.md) | WoW interact / Hyprland capture |
|
||||||
|
| [docs/MACROS.md](docs/MACROS.md) | In-game macros |
|
||||||
|
|
||||||
### Mode-extras
|
## Routing
|
||||||
|
|
||||||
- **Mouse click broadcast (mirror).** When mode is `mirror`, left and right clicks on the primary are also cloned to every other captured slot via the Hyprland compositor (cursor stays where you put it). Wired through the same compositor path as the keys. No press-and-hold guard yet — TODO.
|
- A **map** is a hotkey plus steps (send a key, send a named `game_bind`, wait) plus a **target**.
|
||||||
- **Clipboard broadcast.** `Session → Send clipboard to others (Ctrl+V)` copies the current clipboard (`wl-paste` then `xclip`) and pastes into every non-leader captured slot.
|
- Unmapped keys are not intercepted.
|
||||||
- **Round-robin example.** See `examples/profile.yaml` → `rez_cycle`: pressing `Alt+R` fires `F8` to slot 1, then slot 2, then slot 3, wrapping back to 1 on the next press. Each map gets its own cursor.
|
- `passthrough` keys are never intercepted, even if a map lists them. Default is empty.
|
||||||
|
- Optional **repeater** mode also clones an explicit extra key list to the other slots.
|
||||||
|
|
||||||
## Interact / loot
|
## Interact / loot
|
||||||
|
|
||||||
WoW's **Interact with Target** bind (plus Click to Move) walks an alt to the target and loots or talks. One press. Spamming it makes characters circle. The stock `loot` map: assist others → CTM on → interact once → wait `walk_delay_ms` → CTM off. See [docs/MACROS.md](docs/MACROS.md).
|
WoW’s **Interact with Target** bind (plus Click to Move) walks an alt to the target and loots or talks. One press. Spamming it makes characters circle. The stock `loot` map: assist others → CTM on → interact once → wait `walk_delay_ms` → CTM off. See [docs/MACROS.md](docs/MACROS.md).
|
||||||
|
|
||||||
Per-character `assist_key` / `follow_key` resolve `/assist ThisName` / `/follow ThisName` against the current main's slot. Update the name in `examples/profile.yaml` per team.
|
|
||||||
|
|
||||||
## Window layout
|
|
||||||
|
|
||||||
The layout wizard builds a tile grid for `N` captured game clients:
|
|
||||||
|
|
||||||
- Presets: **stacked**, **same-size grid**, **main + strip** (one large main plus a row of minions).
|
|
||||||
- Per-slot `x y w h` in pixels.
|
|
||||||
- Drag tiles directly on the GUI canvas — the layout page shows a draggable, clickable monitor rectangle per output.
|
|
||||||
- **Generate** fills in defaults from the selected preset; **Capture current positions** reads `hyprctl -j clients` and stores whatever is on screen.
|
|
||||||
- **Save and Apply** calls `enboxer layout-apply` and uses `hyprctl dispatch` to float / move / resize. **Gated** by `ENBOXER_ALLOW_LAYOUT=1` (env or GUI checkbox on Session) **and** a confirm dialog before apply.
|
|
||||||
- **Swap next / Make main** rotates which slot is in the large tile. **Reset all** restores the saved layout.
|
|
||||||
- **Stay on top** pins the new tiles. **Borderless** installs a Hyprland `window_rule` (rounding 0, border_size 0) for the captured class when the daemon starts.
|
|
||||||
|
|
||||||
## Slot overlay (click-to-swap)
|
|
||||||
|
|
||||||
When `ENBOXER_ENABLE_OVERLAY=1` is set in the daemon's environment, `enboxer run` spawns a `wlr-layer-shell` surface per captured slot showing the slot number, anchored to the slot's top-left. Clicking the badge calls `swap <slot>` so you can change which client is main without alt-tabbing. With the env unset (default), `cargo test` and `enboxer doctor` never connect to Wayland. See [docs/DESIGN.md](docs/DESIGN.md).
|
|
||||||
|
|
||||||
## Video FX
|
## Video FX
|
||||||
|
|
||||||
A region of another client is captured and shown on the primary. While the cursor is over the viewer, keys and clicks go to the **source** slot only.
|
A region of another client is captured (`grim`) and shown on the primary. While the cursor is over that viewer, keys and clicks go to the **source** slot only. Covered sources need compositor toplevel export (not in this phase); keep the source visible for a live picture.
|
||||||
|
|
||||||
Two capture paths:
|
Optional Hyprland snippet: [examples/hyprland.lua](examples/hyprland.lua).
|
||||||
|
|
||||||
1. **Visible source — `grim`** (default). Fast, zero-copy when a source window sits on a visible output. This is what `mpv` reloads each frame.
|
|
||||||
2. **Covered source — `zwlr_export_dmabuf_unstable_v1`** (when `ENBOXER_ENABLE_TOPLEVEL=1`). Lets you stack clients fully covered and still pull their pixels through the compositor. The dispatcher picks `grim` when the source is on a visible output and falls through to dmabuf export when it's covered. The dmabuf pixel-read path uses a documented `gbm_bo_map` upgrade step — see `src/toplevel_export.rs` for the protocol + format negotiation layer that is in place.
|
|
||||||
|
|
||||||
See [docs/VIDEO.md](docs/VIDEO.md).
|
|
||||||
|
|
||||||
## Teams and the Lutris launcher
|
|
||||||
|
|
||||||
Multiple teams can live side-by-side. Each team is a separate YAML in `~/.config/enboxer/teams/<slug>.yaml`; the active team is recorded in `~/.config/enboxer/current_team`. Use the GUI **Teams** menu (top bar) to:
|
|
||||||
|
|
||||||
- **New team…** — opens a wizard with name, member count, and a Lutris game picker. The Lutris picker is visible **only** in this flow.
|
|
||||||
- **Switch team…** — load any saved team.
|
|
||||||
- **Refresh Lutris for current team** — re-read `~/.config/lutris/games/<slug>.yml` so changes Lutris made to the runner / env / DLL overrides take effect on the next launch.
|
|
||||||
- **Show Lutris config for current team** — display the parsed runner / exe / env / prefix / DLL overrides for transparency.
|
|
||||||
- **Delete team…** — removes the team YAML. Does **not** touch Lutris.
|
|
||||||
|
|
||||||
The `Launch` menu (under `Session`) shows one button per character that has a `lutris_game` set, plus **Launch all**. enBoxer spawns Wine directly with the merged env (`WINEPREFIX`, `WINEDLLOVERRIDES`, `WINEESYNC`, `WINEFSYNC`, `DXVK_*`, `VKD3D_CONFIG`) — never the `lutris` CLI, because Lutris is not built for launching the same game five times.
|
|
||||||
|
|
||||||
Optional `auto_apply: true` on a character polls `hyprctl -j clients` for up to 30 s after spawn and runs `layout-apply` once the matched window appears. Off by default. The auto-apply path is the only code that touches window geometry after Launch; everything else stays read-only until you click **Save and Apply** in the GUI.
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@ -89,69 +42,21 @@ cargo build --release
|
|||||||
install -Dm755 target/release/enboxer ~/.local/bin/enboxer
|
install -Dm755 target/release/enboxer ~/.local/bin/enboxer
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime deps on the host: Hyprland 0.56 (Lua dispatchers), `hyprctl`, `grim`, `mpv` (Video FX overlay), `slurp` (rectangle picker), `wl-paste` and `xclip` (clipboard broadcast). For the Lutris launcher: a working `wine` on `PATH` and the user's Lutris install.
|
Needs Hyprland 0.56 (Lua dispatchers), `hyprctl`, `grim`. A viewer (`imv` or `feh`) for Video FX overlays.
|
||||||
|
|
||||||
For the optional live paths: the Wayland protocol bindings (`wayland-client` 0.31, `wayland-protocols-wlr` 0.3.12) are already pulled in by `Cargo.toml`.
|
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
enboxer # control panel (File / Session / Layout / Maps / Video / Macros / Teams)
|
mkdir -p ~/.config/enboxer
|
||||||
enboxer run -c ~/.config/enboxer/profile.yaml # daemon only
|
cp examples/profile.yaml ~/.config/enboxer/profile.yaml
|
||||||
enboxer doctor
|
# edit window_match, passthrough, maps, character names
|
||||||
enboxer press Alt+G # fire a map without a Hyprland bind
|
|
||||||
enboxer layout-apply -c ... # one-shot layout apply (gated by ENBOXER_ALLOW_LAYOUT)
|
enboxer doctor -c ~/.config/enboxer/profile.yaml
|
||||||
|
enboxer macros -c ~/.config/enboxer/profile.yaml
|
||||||
|
enboxer run -c ~/.config/enboxer/profile.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
The GUI's **Teams** menu is the first thing to use when you have not configured a team yet. Use **New team…** to pick a Lutris game and member count. The **Launch** menu then has one button per character.
|
`enboxer press Alt+G` fires a map without a Hyprland bind (daemon must be running).
|
||||||
|
|
||||||
`ENBOXER_ALLOW_LAYOUT=1` (env) or the Session checkbox is required for **Save and Apply**, swap-geometry, and reset.
|
|
||||||
|
|
||||||
## Environment variables (gates)
|
|
||||||
|
|
||||||
| Var | Effect | Default |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `ENBOXER_ALLOW_LAYOUT` | Permit `layout-apply`, swap, reset | unset = off |
|
|
||||||
| `ENBOXER_ENABLE_OVERLAY` | Spawn the live `wlr-layer-shell` slot overlay | unset = stub (geometry + routing only) |
|
|
||||||
| `ENBOXER_ENABLE_TOPLEVEL` | Spawn the live `wlr_export_dmabuf_unstable_v1` capture path | unset = stub (grim fallback) |
|
|
||||||
|
|
||||||
`cargo test` and `enboxer doctor` never set these gates, so they cannot touch the user's Hyprland session.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| Path | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| `~/git/enBoxer` | The Rust crate |
|
|
||||||
| `examples/profile.yaml` | Stock profile (ESDF passthrough, main+strip layout, stock loot map, round-robin example) |
|
|
||||||
| `examples/hyprland.lua` | Hyprland bind helper (snippet, optional) |
|
|
||||||
| `~/.config/enboxer/profile.yaml` | Active profile (single-profile legacy / no-team fallback) |
|
|
||||||
| `~/.config/enboxer/teams/<slug>.yaml` | One team = one file |
|
|
||||||
| `~/.config/enboxer/current_team` | One-line file with the active team's slug |
|
|
||||||
| `~/.config/lutris/games/*.yml` | Lutris game store (read by the launcher) |
|
|
||||||
|
|
||||||
## Safety (non-negotiable)
|
|
||||||
|
|
||||||
- Default `enboxer run`: keys only. No float / move / resize / pin.
|
|
||||||
- `ENBOXER_ALLOW_LAYOUT=1` is required for apply / swap-geometry / reset. The GUI confirms before Apply.
|
|
||||||
- The example profile has `auto_apply: false`, Video FX disabled, and empty layout tiles.
|
|
||||||
- No DLL, no memory inject, no required WoW addon in v1.
|
|
||||||
- `cargo test`, `enboxer doctor`, and the un-gated stub paths do not connect to Wayland or move windows.
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
| Doc | What |
|
|
||||||
| --- | --- |
|
|
||||||
| [GOALS.md](GOALS.md) | What this project is for (read this first) |
|
|
||||||
| [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 |
|
|
||||||
| [CHANGELOG.md](CHANGELOG.md) | What landed |
|
|
||||||
| [docs/DESIGN.md](docs/DESIGN.md) | Routing model and overlay capture model |
|
|
||||||
| [docs/NOTES.md](docs/NOTES.md) | WoW interact / Hyprland capture |
|
|
||||||
| [docs/VIDEO.md](docs/VIDEO.md) | Live crop of another client |
|
|
||||||
| [docs/MACROS.md](docs/MACROS.md) | In-game macros |
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
45
TASKS.md
45
TASKS.md
@ -1,42 +1,43 @@
|
|||||||
# OpenCode tickets
|
# OpenCode tickets
|
||||||
|
|
||||||
Product: [GOALS.md](GOALS.md). Do not invent work. Do not `hyprctl dispatch` on the live session.
|
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.
|
||||||
|
|
||||||
**Safety (all tickets)**
|
Grok does **not** run ponytail-review until every ticket in this phase has been assigned (they all are) **and** the Open list is empty.
|
||||||
- No `hyprctl dispatch` that floats, moves, resizes, pins, or closes any window on the live session — except when the user explicitly clicks the **Launch** button or **Save and Apply** (both are gated by `allow_layout` already; auto-apply on launch only fires after the Launch button is clicked).
|
|
||||||
- Any live Wayland / overlay / export path remains feature-gated so `cargo test` does not touch the user's Hyprland session.
|
|
||||||
- Do **not** commit.
|
|
||||||
- Do **not** push.
|
|
||||||
- Do **not** run `ponytail-review`.
|
|
||||||
- Do **not** pass `--pure`.
|
|
||||||
- Do **not** delete or revert the in-flight diff from T7..T12 — keep `git diff --stat` showing the prior additions.
|
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
_Tickets will be re-opened here after the next planning round._
|
_(none — Phase 1 tickets all assigned and closed. Grok may run ponytail-review now.)_
|
||||||
|
|
||||||
## Closed
|
## Closed
|
||||||
|
|
||||||
T15 — Teams + Lutris launcher. Closed by OpenCode. `src/team.rs` (Team, TeamMeta, list_teams, teams_dir, current_team read/write), `src/lutris.rs` (LutrisGame, parse, load_all, default_dir), `src/launcher.rs` (SpawnPlan, TeamLaunchOpts, spawn_plan), GUI Teams menu (New/Switch/Refresh/Show/Delete) plus Launch menu; auto_apply gated behind the Launch button. `cargo test` 89/89 green; `cargo clippy --all-targets -- -D warnings` clean.
|
### T1 — format
|
||||||
|
|
||||||
T14 — Round-robin bind target. Closed by OpenCode. `Engine::round_robin: HashMap<String, usize>`, `resolve_targets("round_robin" | "rr", map_name)` advances and wraps per-map. Example profile has `rez_cycle` map with `target: round_robin`. Tests cover cycle, wrap, per-map isolation, fresh-engine reset.
|
`cargo fmt`. Done.
|
||||||
|
|
||||||
T13 — Mouse click broadcast in mirror mode. Closed by OpenCode. `session::mirror_clicks_to(slots, leader, button)` returns others minus leader; per-char × per-button routing plan unit-tested; live click delivery is the existing IPC path (gated, no live dispatch).
|
### T2 — clippy, only what it names
|
||||||
|
|
||||||
T10 — Real covered-window VFX capture (dmabuf export). Closed by OpenCode. `src/toplevel_export.rs` ships `zwlr_export_dmabuf_unstable_v1` protocol bindings, format negotiation (`DRM_FORMAT_ARGB8888` / `DRM_FORMAT_XRGB8888`), synthetic-PNG file write, env-gated stub. `gbm_bo_map` left as a documented follow-up (`read_pixels_via_gbm_is_a_documented_followup` test). `cargo test` + clippy clean.
|
`cargo clippy --all-targets -- -D warnings`. Done.
|
||||||
|
|
||||||
T9 — Real slot overlay (wlr-layer-shell). Closed by OpenCode. `src/wayland_layer.rs` binds `zwlr_layer_shell_v1`, spawns one shm-backed 96×96 surface per slot anchored top-left, renders slot number via 3×5 bitmap digits in pure Rust. Click routes through `click_verb` to the IPC socket. Live spawn gated behind `ENBOXER_ENABLE_OVERLAY=1`; `cargo test` never connects to Wayland. Protocol-constants test confirms dispatch table parses XML.
|
### T3 — stop if fmt/clippy/test are already clean
|
||||||
|
|
||||||
T8 — Clipboard broadcast. Closed by OpenCode (previous run). `clipboard` IPC verb; `read_clipboard` shells out to `wl-paste` then `xclip`; `clipboard_to_others` filters out the leader and delivers `Ctrl+v`. Routing factored into `others_clients` helper. Session page button added. `cargo test` 44/44 green.
|
Done (fmt/clippy/test were clean after T1–T2).
|
||||||
|
|
||||||
T12 — Type + confirm-apply tests. Closed by OpenCode (previous run). `session::tests::type_routing_plan_covers_every_other_slot_per_char`, `layout::tests::apply_short_circuits_when_layout_disallowed`.
|
### T4 — Video FX click pass-through
|
||||||
|
|
||||||
T11 — Named profiles list. Closed by OpenCode (previous run). `list_profiles_in` helper, `Load named…` submenu, two tests.
|
Done (`mouse:272`/`273` → `map_click` + cursor + send to source; unit test).
|
||||||
|
|
||||||
T9-stubs — Slot-number overlay stub. Closed by OpenCode (previous run). `src/overlay.rs` shipped with `OverlayRect`, `overlay_rect`, `click_verb`, `OverlayHub`. Live wlr-layer-shell render was gated and stubbed; T9 above replaces the stub with the real client.
|
### T5 — log slots when the set changes
|
||||||
|
|
||||||
T10-stubs — Toplevel-export stub. Closed by OpenCode (previous run). `is_window_visible` predicate + `capture_for_source` dispatcher + `capture_toplevel` gated stub. T10 above replaces the stub with the real dmabuf capture.
|
Done.
|
||||||
|
|
||||||
T7 — Layout GUI: finish the in-flight diff. Closed 2026-09-15T09:17:45Z by orchestrator; OpenCode landed `layout_canvas`, `borderless` checkbox, `refresh_monitors`; `cargo test` 24/24 green.
|
### T6 — empty passthrough can map `e`
|
||||||
|
|
||||||
T1–T6 hygiene and Video FX click/passthrough tests (see git log).
|
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)
|
||||||
|
|||||||
@ -28,10 +28,4 @@ hl.bind("ALT + G", function() … end, { description = "enboxer:loot" })
|
|||||||
|
|
||||||
Binds exist only while a managed game window or an `enboxer-vfx` overlay is focused.
|
Binds exist only while a managed game window or an `enboxer-vfx` overlay is focused.
|
||||||
|
|
||||||
enBoxer does not start XWayland. Hyprland already runs it. Wine WoW is almost always an **XWayland** window (`xwayland: true` in `hyprctl clients`). Keys go in through XWayland as normal X key events — the same path as a keyboard from the game’s point of view, not a memory inject.
|
Visible-pixel capture: `grim`. Covered windows: `hyprland-toplevel-export` (not wired yet). Overlay class: `enboxer-vfx`.
|
||||||
|
|
||||||
If a window is **native Wayland** (including `winewayland`), unfocused inject often does nothing. For those, enBoxer focuses the window, sends the key, then restores focus.
|
|
||||||
|
|
||||||
The Session page lists each window as `XWayland/Wine` or `Wayland`.
|
|
||||||
|
|
||||||
Visible-pixel capture: `grim`. Overlay: `mpv --wayland-app-id=enboxer-vfx` plus JSON IPC to reload frames. Covered windows: `hyprland-toplevel-export` (not wired yet).
|
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
# Live crop (Video FX)
|
|
||||||
|
|
||||||
You pick **two rectangles**:
|
|
||||||
|
|
||||||
1. **Source** — which part of another client to copy, and how large that crop is.
|
|
||||||
2. **Viewer** — where that crop is drawn on the primary client, and how large it is shown.
|
|
||||||
|
|
||||||
Numbers `0`–`1` are fractions of that window. Numbers **greater than 1** are pixels inside the window. In the GUI, **Pick source / Pick viewer** uses `slurp` and stores pixels.
|
|
||||||
|
|
||||||
When the mouse is over the viewer, **clicks and keys go only to the source client**, not to the primary and not to the rest of the team.
|
|
||||||
|
|
||||||
Configure this in the **Video FX** page of the control panel (`enboxer` with no arguments).
|
|
||||||
|
|
||||||
It is not a second monitor and not a window swap. The other client can sit behind the main one; you still see the cropped piece.
|
|
||||||
|
|
||||||
## What Hyprland can actually do
|
|
||||||
|
|
||||||
| Method | Sees a window that is fully covered? | What we use |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `grim` of a screen rectangle | No — only pixels currently on the output | **Yes, default** |
|
|
||||||
| `hyprland-toplevel-export` | Yes — compositor copy of that window’s buffer | **Yes, gated behind `ENBOXER_ENABLE_TOPLEVEL=1`** |
|
|
||||||
| Desktop portal / PipeWire | Yes, heavier | No |
|
|
||||||
|
|
||||||
The dispatcher in `src/vfx.rs::capture_for_source` checks `is_window_visible(monitors, client)`. If the source window sits on a visible output, `grim` captures it. If the window is covered and the operator has opted in to compositor-side capture (`ENBOXER_ENABLE_TOPLEVEL=1`), the dispatcher routes to `capture_toplevel` (zwlr_export_dmabuf_unstable_v1). With the gate off, covered windows fall through to `grim` and the operator sees whatever is currently on top of those pixels — exactly the previous behaviour.
|
|
||||||
|
|
||||||
So: keep the source window **on a visible output** for the default `grim` path. Set `ENBOXER_ENABLE_TOPLEVEL=1` when you want a stacked layout where some sources are fully covered by another; the live toplevel-export client is a follow-up and the dispatcher is ready for it.
|
|
||||||
|
|
||||||
The overlay itself is an `mpv` window (`class: enboxer-vfx`) that reloads each `grim` frame.
|
|
||||||
@ -14,12 +14,8 @@ window_match:
|
|||||||
# ESDF movement stays on the primary even if you later add a map for those letters.
|
# ESDF movement stays on the primary even if you later add a map for those letters.
|
||||||
passthrough: ["e", "s", "d", "f"]
|
passthrough: ["e", "s", "d", "f"]
|
||||||
|
|
||||||
# maps = only keys listed under `maps` (rest stay on the front window)
|
|
||||||
# mirror = clone keys to the other clients (passthrough still skipped)
|
|
||||||
# off = no intercept; the front window gets everything
|
|
||||||
mode_default: maps
|
mode_default: maps
|
||||||
|
|
||||||
# Optional: limit which keys mirror-mode clones. Empty = letters, digits, F-keys, etc.
|
|
||||||
repeater:
|
repeater:
|
||||||
enabled: false
|
enabled: false
|
||||||
keys: []
|
keys: []
|
||||||
@ -40,25 +36,12 @@ interact:
|
|||||||
|
|
||||||
session_hotkeys:
|
session_hotkeys:
|
||||||
mode_cycle: "Shift+Alt+M"
|
mode_cycle: "Shift+Alt+M"
|
||||||
swap_next: "Ctrl+grave"
|
|
||||||
swap_prev: "Ctrl+Shift+grave"
|
|
||||||
focus_next: "Ctrl+Shift+N"
|
|
||||||
focus_prev: "Ctrl+Shift+P"
|
|
||||||
focus_main: "Ctrl+F1"
|
|
||||||
reset_all: "Ctrl+Shift+R"
|
|
||||||
stay_on_top: "Ctrl+Shift+T"
|
|
||||||
mouse_follow: "Ctrl+Shift+F"
|
|
||||||
mouse_broadcast: "Ctrl+Shift+B"
|
|
||||||
|
|
||||||
characters:
|
characters:
|
||||||
- slot: 1
|
- slot: 1
|
||||||
name: Main
|
name: Main
|
||||||
assist_key: "Shift+F2"
|
|
||||||
follow_key: "Shift+F1"
|
|
||||||
- slot: 2
|
- slot: 2
|
||||||
name: Alt
|
name: Alt
|
||||||
assist_key: "F9"
|
|
||||||
follow_key: "F10"
|
|
||||||
|
|
||||||
groups:
|
groups:
|
||||||
alts:
|
alts:
|
||||||
@ -118,28 +101,10 @@ maps:
|
|||||||
- bind: ctm_off
|
- bind: ctm_off
|
||||||
target: others
|
target: others
|
||||||
|
|
||||||
- name: rez_cycle
|
|
||||||
# Each press picks the next slot in order (1..=N, then wraps). State
|
|
||||||
# lives on the engine, keyed by this map name, so a second `rr`
|
|
||||||
# map keeps its own cursor.
|
|
||||||
hotkey: "Alt+R"
|
|
||||||
steps:
|
|
||||||
- key: "F8"
|
|
||||||
target: round_robin
|
|
||||||
|
|
||||||
# Window layout (pixels). Generate from the Layout page, then Save and Apply.
|
|
||||||
layout:
|
|
||||||
preset: main_strip
|
|
||||||
one_row: true
|
|
||||||
main_at_bottom: false
|
|
||||||
auto_apply: false
|
|
||||||
monitor: ""
|
|
||||||
slots: []
|
|
||||||
|
|
||||||
# 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: false
|
enabled: true
|
||||||
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 }
|
||||||
|
|||||||
246
src/engine.rs
246
src/engine.rs
@ -1,7 +1,7 @@
|
|||||||
use crate::hotkey::passthrough_id;
|
use crate::hotkey::passthrough_id;
|
||||||
use crate::profile::{Map, Mode, Profile, Step};
|
use crate::profile::{Map, Mode, Profile, Step};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -10,10 +10,6 @@ pub struct Engine {
|
|||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub leader_slot: u32,
|
pub leader_slot: u32,
|
||||||
passthrough: HashSet<String>,
|
passthrough: HashSet<String>,
|
||||||
/// Per-map cursor for `target: round_robin` / `rr`. The map name is
|
|
||||||
/// the key; the value is the next slot index to fire (1..=slots).
|
|
||||||
/// Reset on a fresh Engine; not persisted across reload.
|
|
||||||
round_robin: HashMap<String, usize>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@ -42,7 +38,6 @@ impl Engine {
|
|||||||
mode,
|
mode,
|
||||||
passthrough,
|
passthrough,
|
||||||
profile,
|
profile,
|
||||||
round_robin: HashMap::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +53,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn should_intercept(&self, hotkey: &str) -> bool {
|
pub fn should_intercept(&self, hotkey: &str) -> bool {
|
||||||
if self.mode == Mode::Off {
|
if self.mode == Mode::Disabled {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Ok(id) = passthrough_id(hotkey) {
|
if let Ok(id) = passthrough_id(hotkey) {
|
||||||
@ -67,25 +62,17 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.profile.map_by_hotkey(hotkey).is_some()
|
self.profile.map_by_hotkey(hotkey).is_some()
|
||||||
|| (self.mode == Mode::Mirror && self.is_mirror_key(hotkey))
|
|| (self.mode == Mode::Repeater && self.is_repeater_key(hotkey))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_mirror_key(&self, hotkey: &str) -> bool {
|
fn is_repeater_key(&self, hotkey: &str) -> bool {
|
||||||
if self.profile.repeater.keys.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let want = passthrough_id(hotkey).unwrap_or_else(|_| hotkey.to_string());
|
let want = passthrough_id(hotkey).unwrap_or_else(|_| hotkey.to_string());
|
||||||
self.profile.repeater.keys.iter().any(|k| {
|
self.profile.repeater.keys.iter().any(|k| {
|
||||||
k.eq_ignore_ascii_case(hotkey) || passthrough_id(k).ok().is_some_and(|id| id == want)
|
k.eq_ignore_ascii_case(hotkey) || passthrough_id(k).ok().is_some_and(|id| id == want)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a `target:` field on a step into the slot ids that should
|
pub fn resolve_targets(&self, target: &str) -> Result<Vec<u32>> {
|
||||||
/// receive the action. Side effect: when `target` is `round_robin` /
|
|
||||||
/// `rr`, advances the per-map cursor for `map_name` (if provided).
|
|
||||||
/// The cursor lives on the engine so two `Engine` instances each
|
|
||||||
/// carry their own round-robin state — two profiles do not share it.
|
|
||||||
pub fn resolve_targets(&mut self, target: &str, map_name: &str) -> Result<Vec<u32>> {
|
|
||||||
let all: Vec<u32> = (1..=self.profile.slots).collect();
|
let all: Vec<u32> = (1..=self.profile.slots).collect();
|
||||||
let leader = self.leader_slot;
|
let leader = self.leader_slot;
|
||||||
match target {
|
match target {
|
||||||
@ -94,19 +81,6 @@ impl Engine {
|
|||||||
Ok(all.into_iter().filter(|s| *s != leader).collect())
|
Ok(all.into_iter().filter(|s| *s != leader).collect())
|
||||||
}
|
}
|
||||||
"all" => Ok(all),
|
"all" => Ok(all),
|
||||||
"round_robin" | "rr" => {
|
|
||||||
if self.profile.slots == 0 {
|
|
||||||
bail!("round_robin needs at least 1 slot");
|
|
||||||
}
|
|
||||||
let n = self.profile.slots as usize;
|
|
||||||
let current = self
|
|
||||||
.round_robin
|
|
||||||
.entry(map_name.to_string())
|
|
||||||
.or_insert(1);
|
|
||||||
let pick = *current;
|
|
||||||
*current = (pick % n) + 1;
|
|
||||||
Ok(vec![pick as u32])
|
|
||||||
}
|
|
||||||
other if other.starts_with("group:") => {
|
other if other.starts_with("group:") => {
|
||||||
let name = &other[6..];
|
let name = &other[6..];
|
||||||
let g = self
|
let g = self
|
||||||
@ -129,20 +103,20 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fire(&mut self, hotkey: &str, edge: Hold) -> Result<Vec<Action>> {
|
pub fn fire(&self, hotkey: &str, edge: Hold) -> Result<Vec<Action>> {
|
||||||
if !self.should_intercept(hotkey) {
|
if !self.should_intercept(hotkey) {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
if let Some(map) = self.profile.map_by_hotkey(hotkey).cloned() {
|
if let Some(map) = self.profile.map_by_hotkey(hotkey) {
|
||||||
let edge = if map.hold && matches!(edge, Hold::Tap) {
|
let edge = if map.hold && matches!(edge, Hold::Tap) {
|
||||||
Hold::Down
|
Hold::Down
|
||||||
} else {
|
} else {
|
||||||
edge
|
edge
|
||||||
};
|
};
|
||||||
return self.fire_map(&map, edge);
|
return self.fire_map(map, edge);
|
||||||
}
|
}
|
||||||
if self.mode == Mode::Mirror && matches!(edge, Hold::Tap | Hold::Down) {
|
if self.mode == Mode::Repeater && matches!(edge, Hold::Tap | Hold::Down) {
|
||||||
let slots = self.resolve_targets("others", "")?;
|
let slots = self.resolve_targets("others")?;
|
||||||
return Ok(vec![Action::Send {
|
return Ok(vec![Action::Send {
|
||||||
key: hotkey.to_string(),
|
key: hotkey.to_string(),
|
||||||
slots,
|
slots,
|
||||||
@ -156,7 +130,7 @@ impl Engine {
|
|||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fire_map(&mut self, map: &Map, edge: Hold) -> Result<Vec<Action>> {
|
fn fire_map(&self, map: &Map, edge: Hold) -> Result<Vec<Action>> {
|
||||||
let steps = match edge {
|
let steps = match edge {
|
||||||
Hold::Up => &map.release_steps,
|
Hold::Up => &map.release_steps,
|
||||||
_ => &map.steps,
|
_ => &map.steps,
|
||||||
@ -168,37 +142,10 @@ impl Engine {
|
|||||||
} else {
|
} else {
|
||||||
Hold::Tap
|
Hold::Tap
|
||||||
};
|
};
|
||||||
self.compile_steps(map, steps, hold)
|
self.compile_steps(steps, hold)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_party_bind(&self, name: &str) -> Result<String> {
|
fn compile_steps(&self, steps: &[Step], hold: Hold) -> Result<Vec<Action>> {
|
||||||
if name == "assist" || name == "follow" {
|
|
||||||
if let Some(ch) = self
|
|
||||||
.profile
|
|
||||||
.characters
|
|
||||||
.iter()
|
|
||||||
.find(|c| c.slot == self.leader_slot)
|
|
||||||
{
|
|
||||||
let k = if name == "assist" {
|
|
||||||
&ch.assist_key
|
|
||||||
} else {
|
|
||||||
&ch.follow_key
|
|
||||||
};
|
|
||||||
if !k.is_empty() {
|
|
||||||
return Ok(k.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.profile.resolve_bind(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compile_steps(
|
|
||||||
&mut self,
|
|
||||||
map: &Map,
|
|
||||||
steps: &[Step],
|
|
||||||
hold: Hold,
|
|
||||||
) -> Result<Vec<Action>> {
|
|
||||||
let map_name = map.name.clone();
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for step in steps {
|
for step in steps {
|
||||||
if let Some(ms) = step.delay_ms {
|
if let Some(ms) = step.delay_ms {
|
||||||
@ -208,11 +155,11 @@ impl Engine {
|
|||||||
let key = if let Some(k) = &step.key {
|
let key = if let Some(k) = &step.key {
|
||||||
k.clone()
|
k.clone()
|
||||||
} else if let Some(b) = &step.bind {
|
} else if let Some(b) = &step.bind {
|
||||||
self.resolve_party_bind(b)?
|
self.profile.resolve_bind(b)?
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let slots = self.resolve_targets(&step.target, &map_name)?;
|
let slots = self.resolve_targets(&step.target)?;
|
||||||
out.push(Action::Send { key, slots, hold });
|
out.push(Action::Send { key, slots, hold });
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@ -312,7 +259,6 @@ mod tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
video_fx: vec![],
|
video_fx: vec![],
|
||||||
layout: Default::default(),
|
|
||||||
};
|
};
|
||||||
Engine::new(profile).unwrap()
|
Engine::new(profile).unwrap()
|
||||||
}
|
}
|
||||||
@ -369,7 +315,6 @@ mod tests {
|
|||||||
release_steps: vec![],
|
release_steps: vec![],
|
||||||
}],
|
}],
|
||||||
video_fx: vec![],
|
video_fx: vec![],
|
||||||
layout: Default::default(),
|
|
||||||
};
|
};
|
||||||
let e = Engine::new(profile).unwrap();
|
let e = Engine::new(profile).unwrap();
|
||||||
assert!(e.should_intercept("e"));
|
assert!(e.should_intercept("e"));
|
||||||
@ -379,14 +324,14 @@ mod tests {
|
|||||||
fn others_skips_leader() {
|
fn others_skips_leader() {
|
||||||
let mut e = sample();
|
let mut e = sample();
|
||||||
e.set_leader(2);
|
e.set_leader(2);
|
||||||
assert_eq!(e.resolve_targets("others", "").unwrap(), vec![1, 3]);
|
assert_eq!(e.resolve_targets("others").unwrap(), vec![1, 3]);
|
||||||
assert_eq!(e.resolve_targets("current", "").unwrap(), vec![2]);
|
assert_eq!(e.resolve_targets("current").unwrap(), vec![2]);
|
||||||
assert_eq!(e.resolve_targets("group:alts", "").unwrap(), vec![2, 3]);
|
assert_eq!(e.resolve_targets("group:alts").unwrap(), vec![2, 3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loot_is_single_iwt_with_delay() {
|
fn loot_is_single_iwt_with_delay() {
|
||||||
let mut e = sample();
|
let e = sample();
|
||||||
let actions = e.fire("Alt+G", Hold::Tap).unwrap();
|
let actions = e.fire("Alt+G", Hold::Tap).unwrap();
|
||||||
match &actions[..] {
|
match &actions[..] {
|
||||||
[Action::Send {
|
[Action::Send {
|
||||||
@ -417,70 +362,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn disabled_mode_intercepts_nothing() {
|
fn disabled_mode_intercepts_nothing() {
|
||||||
let mut e = sample();
|
let mut e = sample();
|
||||||
e.mode = Mode::Off;
|
e.mode = Mode::Disabled;
|
||||||
assert!(!e.should_intercept("1"));
|
assert!(!e.should_intercept("1"));
|
||||||
assert!(e.fire("1", Hold::Tap).unwrap().is_empty());
|
assert!(e.fire("1", Hold::Tap).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assist_follows_leader_character() {
|
|
||||||
let mut e = sample();
|
|
||||||
e.profile.characters = vec![
|
|
||||||
crate::profile::Character {
|
|
||||||
slot: 1,
|
|
||||||
name: "Main".into(),
|
|
||||||
match_title: None,
|
|
||||||
assist_key: "Shift+F2".into(),
|
|
||||||
follow_key: "Shift+F1".into(),
|
|
||||||
lutris_game: None,
|
|
||||||
wine_prefix: None,
|
|
||||||
auto_apply: false,
|
|
||||||
},
|
|
||||||
crate::profile::Character {
|
|
||||||
slot: 2,
|
|
||||||
name: "Alt".into(),
|
|
||||||
match_title: None,
|
|
||||||
assist_key: "F9".into(),
|
|
||||||
follow_key: "F10".into(),
|
|
||||||
lutris_game: None,
|
|
||||||
wine_prefix: None,
|
|
||||||
auto_apply: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
e.set_leader(2);
|
|
||||||
let acts = e.fire("1", Hold::Tap).unwrap();
|
|
||||||
match &acts[0] {
|
|
||||||
Action::Send { key, .. } => assert_eq!(key, "F9"),
|
|
||||||
_ => panic!("expected send"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn three_modes() {
|
|
||||||
let mut e = sample();
|
|
||||||
e.mode = Mode::Maps;
|
|
||||||
assert!(e.should_intercept("1"));
|
|
||||||
assert!(!e.should_intercept("q"));
|
|
||||||
e.mode = Mode::Mirror;
|
|
||||||
assert!(e.should_intercept("q"));
|
|
||||||
let acts = e.fire("q", Hold::Tap).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
acts,
|
|
||||||
vec![Action::Send {
|
|
||||||
key: "q".into(),
|
|
||||||
slots: vec![2, 3],
|
|
||||||
hold: Hold::Tap,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
assert!(!e.should_intercept("e")); // passthrough ESDF
|
|
||||||
e.mode = Mode::Off;
|
|
||||||
assert!(!e.should_intercept("1"));
|
|
||||||
assert!(!e.should_intercept("q"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bar_sends_assist_to_others_and_key_to_all() {
|
fn bar_sends_assist_to_others_and_key_to_all() {
|
||||||
let mut e = sample();
|
let e = sample();
|
||||||
let actions = e.fire("1", Hold::Tap).unwrap();
|
let actions = e.fire("1", Hold::Tap).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
actions,
|
actions,
|
||||||
@ -498,97 +387,4 @@ mod tests {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a profile with one round-robin map and N slots.
|
|
||||||
fn rr_sample(map_name: &str, target: &str, n: u32) -> Engine {
|
|
||||||
let profile = Profile {
|
|
||||||
name: "rr".into(),
|
|
||||||
client: "wow-retail".into(),
|
|
||||||
slots: n,
|
|
||||||
window_match: Default::default(),
|
|
||||||
passthrough: vec![],
|
|
||||||
mode_default: Mode::Maps,
|
|
||||||
repeater: Default::default(),
|
|
||||||
game_binds: BTreeMap::new(),
|
|
||||||
interact: Default::default(),
|
|
||||||
session_hotkeys: BTreeMap::new(),
|
|
||||||
characters: vec![],
|
|
||||||
groups: BTreeMap::new(),
|
|
||||||
maps: vec![Map {
|
|
||||||
name: map_name.into(),
|
|
||||||
hotkey: Hotkey("F8".into()),
|
|
||||||
hold: false,
|
|
||||||
steps: vec![Step {
|
|
||||||
key: Some("g".into()),
|
|
||||||
bind: None,
|
|
||||||
delay_ms: None,
|
|
||||||
target: target.into(),
|
|
||||||
}],
|
|
||||||
release_steps: vec![],
|
|
||||||
}],
|
|
||||||
video_fx: vec![],
|
|
||||||
layout: Default::default(),
|
|
||||||
};
|
|
||||||
Engine::new(profile).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rr_cycles_one_two_three_around_three_presses() {
|
|
||||||
let mut e = rr_sample("rez_cycle", "round_robin", 3);
|
|
||||||
let slots1 = e.resolve_targets("round_robin", "rez_cycle").unwrap();
|
|
||||||
let slots2 = e.resolve_targets("round_robin", "rez_cycle").unwrap();
|
|
||||||
let slots3 = e.resolve_targets("round_robin", "rez_cycle").unwrap();
|
|
||||||
assert_eq!(slots1, vec![1]);
|
|
||||||
assert_eq!(slots2, vec![2]);
|
|
||||||
assert_eq!(slots3, vec![3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rr_wraps_back_to_one_after_slots_exhausted() {
|
|
||||||
let mut e = rr_sample("rez_cycle", "rr", 3);
|
|
||||||
let _ = e.resolve_targets("rr", "rez_cycle").unwrap();
|
|
||||||
let _ = e.resolve_targets("rr", "rez_cycle").unwrap();
|
|
||||||
let _ = e.resolve_targets("rr", "rez_cycle").unwrap();
|
|
||||||
let slots4 = e.resolve_targets("rr", "rez_cycle").unwrap();
|
|
||||||
assert_eq!(slots4, vec![1], "wraps after slots exhaust");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rr_cursor_is_per_map() {
|
|
||||||
let mut e = rr_sample("alpha", "rr", 3);
|
|
||||||
let _ = e.resolve_targets("rr", "alpha").unwrap();
|
|
||||||
let _ = e.resolve_targets("rr", "alpha").unwrap();
|
|
||||||
// alpha is at slot 3; beta is fresh, so it returns slot 1.
|
|
||||||
let beta = e.resolve_targets("rr", "beta").unwrap();
|
|
||||||
assert_eq!(beta, vec![1]);
|
|
||||||
// Advancing alpha now goes to its next slot.
|
|
||||||
let alpha = e.resolve_targets("rr", "alpha").unwrap();
|
|
||||||
assert_eq!(alpha, vec![3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rr_cursors_reset_on_fresh_engine() {
|
|
||||||
let mut a = rr_sample("map", "rr", 3);
|
|
||||||
let _ = a.resolve_targets("rr", "map").unwrap();
|
|
||||||
let _ = a.resolve_targets("rr", "map").unwrap();
|
|
||||||
let mut b = rr_sample("map", "rr", 3);
|
|
||||||
assert_eq!(b.resolve_targets("rr", "map").unwrap(), vec![1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rr_works_through_fire_map() {
|
|
||||||
let mut e = rr_sample("rez_cycle", "round_robin", 3);
|
|
||||||
let a = e.fire("F8", Hold::Tap).unwrap();
|
|
||||||
let b = e.fire("F8", Hold::Tap).unwrap();
|
|
||||||
let c = e.fire("F8", Hold::Tap).unwrap();
|
|
||||||
let target = |a: Vec<Action>| -> Vec<u32> {
|
|
||||||
match a.into_iter().next() {
|
|
||||||
Some(Action::Send { slots, .. }) => slots,
|
|
||||||
_ => panic!("expected send"),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
assert_eq!(target(a), vec![1]);
|
|
||||||
assert_eq!(target(b), vec![2]);
|
|
||||||
assert_eq!(target(c), vec![3]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
1706
src/gui.rs
1706
src/gui.rs
File diff suppressed because it is too large
Load Diff
@ -185,30 +185,10 @@ 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();
|
||||||
|
|||||||
111
src/hypr.rs
111
src/hypr.rs
@ -106,45 +106,6 @@ pub async fn send_key(window: &str, combo: &str, state: Option<&str>) -> Result<
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn focus_window(selector: &str) -> Result<()> {
|
|
||||||
dispatch_lua(&format!("hl.dsp.focus({{ window = {selector:?} }})")).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// XWayland (Wine) accepts keys while unfocused. Native Wayland often does not,
|
|
||||||
/// so we briefly focus, send, then restore.
|
|
||||||
pub async fn deliver_key(client: &Client, combo: &str, state: Option<&str>) -> Result<()> {
|
|
||||||
let sel = client.address_selector();
|
|
||||||
if client.xwayland {
|
|
||||||
return send_key(&sel, combo, state).await;
|
|
||||||
}
|
|
||||||
let prev = active_window().await.ok().flatten();
|
|
||||||
let _ = focus_window(&sel).await;
|
|
||||||
send_key(&sel, combo, state).await?;
|
|
||||||
if let Some(p) = prev {
|
|
||||||
if p.address != client.address {
|
|
||||||
let _ = focus_window(&p.address_selector()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn deliver_click(client: &Client, button: u32) -> Result<()> {
|
|
||||||
let sel = client.address_selector();
|
|
||||||
if client.xwayland {
|
|
||||||
return send_mouse_click(&sel, button).await;
|
|
||||||
}
|
|
||||||
let prev = active_window().await.ok().flatten();
|
|
||||||
let _ = focus_window(&sel).await;
|
|
||||||
send_mouse_click(&sel, button).await?;
|
|
||||||
if let Some(p) = prev {
|
|
||||||
if p.address != client.address {
|
|
||||||
let _ = focus_window(&p.address_selector()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_mouse_click(window: &str, button: u32) -> Result<()> {
|
pub async fn send_mouse_click(window: &str, button: u32) -> Result<()> {
|
||||||
// 272 = BTN_LEFT, 273 = BTN_RIGHT
|
// 272 = BTN_LEFT, 273 = BTN_RIGHT
|
||||||
let expr = format!(
|
let expr = format!(
|
||||||
@ -189,7 +150,12 @@ pub async fn replace_binds(binds: &[BindSpec], ipc: &str) -> Result<()> {
|
|||||||
(false, true) => "{ description = \"enboxer\", release = true }",
|
(false, true) => "{ description = \"enboxer\", release = true }",
|
||||||
(false, false) => "{ description = \"enboxer\" }",
|
(false, false) => "{ description = \"enboxer\" }",
|
||||||
};
|
};
|
||||||
let cmd = bind_command(&b.ipc_bin, ipc, &b.ipc_args);
|
let cmd = format!(
|
||||||
|
"{} ipc --sock {} {}",
|
||||||
|
shell_single(&b.ipc_bin),
|
||||||
|
shell_single(ipc),
|
||||||
|
b.ipc_args
|
||||||
|
);
|
||||||
body.push_str(&format!(
|
body.push_str(&format!(
|
||||||
r#"
|
r#"
|
||||||
do
|
do
|
||||||
@ -226,56 +192,6 @@ fn shell_single(s: &str) -> String {
|
|||||||
format!("'{}'", s.replace('\'', r#"'"'"'"#))
|
format!("'{}'", s.replace('\'', r#"'"'"'"#))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hyprland exec_cmd string. `args` is the IPC line after the binary, e.g. `hotkey Alt+G`.
|
|
||||||
pub fn bind_command(bin: &str, sock: &str, args: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{} ipc --sock {} {}",
|
|
||||||
shell_single(bin),
|
|
||||||
shell_single(sock),
|
|
||||||
args
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn notify(text: &str) -> Result<()> {
|
|
||||||
// 1 = info icon; 2500 ms
|
|
||||||
let _ = hyprctl(["notify", "1", "2500", "rgb(88aaff)", text]).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn apply_borderless_rules(class_re: &str) -> Result<()> {
|
|
||||||
if class_re.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let expr = format!(
|
|
||||||
r#"
|
|
||||||
hl.window_rule({{
|
|
||||||
match = {{ class = {class_re:?} }},
|
|
||||||
rounding = 0,
|
|
||||||
border_size = 0,
|
|
||||||
}})
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
eval_lua(&expr).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn apply_vfx_window_rules() -> Result<()> {
|
|
||||||
eval_lua(
|
|
||||||
r#"
|
|
||||||
hl.window_rule({
|
|
||||||
match = { class = "enboxer-vfx" },
|
|
||||||
float = true,
|
|
||||||
pin = true,
|
|
||||||
no_anim = true,
|
|
||||||
rounding = 0,
|
|
||||||
border_size = 1,
|
|
||||||
})
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn active_window() -> Result<Option<Client>> {
|
pub async fn active_window() -> Result<Option<Client>> {
|
||||||
let raw = hyprctl(["-j", "activewindow"]).await?;
|
let raw = hyprctl(["-j", "activewindow"]).await?;
|
||||||
if raw.trim() == "{}" || raw.trim().is_empty() {
|
if raw.trim() == "{}" || raw.trim().is_empty() {
|
||||||
@ -309,18 +225,3 @@ where
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::bind_command;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bind_command_has_one_ipc() {
|
|
||||||
let cmd = bind_command("/tmp/enboxer", "/tmp/enboxer.sock", "hotkey Alt+G");
|
|
||||||
assert_eq!(
|
|
||||||
cmd,
|
|
||||||
"'/tmp/enboxer' ipc --sock '/tmp/enboxer.sock' hotkey Alt+G"
|
|
||||||
);
|
|
||||||
assert_eq!(cmd.matches(" ipc ").count(), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
268
src/launcher.rs
268
src/launcher.rs
@ -1,268 +0,0 @@
|
|||||||
//! Spawn-plan builder for the Teams launch flow.
|
|
||||||
//!
|
|
||||||
//! Given a [`LutrisGame`] (or its relevant subset) and an optional
|
|
||||||
//! per-team `wine_prefix` override, returns a [`SpawnPlan`] that the
|
|
||||||
//! GUI can hand to `tokio::process::Command`. We do **not** shell out
|
|
||||||
//! to the `lutris` CLI; we reproduce the env-var contract directly so the
|
|
||||||
//! operator sees exactly which variables the launcher sets.
|
|
||||||
//!
|
|
||||||
//! Contract:
|
|
||||||
//! - `WINEPREFIX` is set to the override if present, else the Lutris
|
|
||||||
//! prefix, else left alone (Wine will pick its own).
|
|
||||||
//! - `WINEDLLOVERRIDES` is set when the Lutris YAML has any.
|
|
||||||
//! - `WINEESYNC` / `WINEFSYNC` reflect the Lutris YAML's flags.
|
|
||||||
//! - `DXVK_ENABLE_*` and `VKD3D_CONFIG` reflect `dxvk` / `vkd3d`.
|
|
||||||
//! - Any extra `system.env` entries pass through (with the same
|
|
||||||
//! override precedence).
|
|
||||||
//!
|
|
||||||
//! The plan never touches the user's session; the GUI calls
|
|
||||||
//! [`spawn_plan`] *before* spawning and shows the plan in a confirm
|
|
||||||
//! dialog so the operator can abort.
|
|
||||||
|
|
||||||
use crate::lutris::LutrisGame;
|
|
||||||
use crate::profile::Character;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct SpawnPlan {
|
|
||||||
/// Path to the binary to launch (e.g. `wine`, `wine64`,
|
|
||||||
/// `steam`, or the game exe itself for native games).
|
|
||||||
pub exe: PathBuf,
|
|
||||||
/// Args to pass after the binary (e.g. `["drive_c/.../WoW.exe"]`).
|
|
||||||
pub args: Vec<String>,
|
|
||||||
/// Environment variables to set. The actual process inherits the
|
|
||||||
/// operator's env; these override on top.
|
|
||||||
pub env: BTreeMap<String, String>,
|
|
||||||
/// Human-readable summary the GUI shows in the confirm dialog.
|
|
||||||
pub summary: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct TeamLaunchOpts {
|
|
||||||
pub wine_prefix: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a launch plan for one character + one Lutris game. The
|
|
||||||
/// GUI's "Launch all" menu calls this once per character with a
|
|
||||||
/// matching `lutris_game`.
|
|
||||||
pub fn spawn_plan(
|
|
||||||
character: &Character,
|
|
||||||
game: &LutrisGame,
|
|
||||||
opts: &TeamLaunchOpts,
|
|
||||||
) -> SpawnPlan {
|
|
||||||
let prefix = opts
|
|
||||||
.wine_prefix
|
|
||||||
.clone()
|
|
||||||
.or_else(|| Some(PathBuf::from(&game.prefix)))
|
|
||||||
.filter(|p| !p.as_os_str().is_empty());
|
|
||||||
|
|
||||||
let mut env: BTreeMap<String, String> = game.env.iter().cloned().collect();
|
|
||||||
|
|
||||||
// Wine base.
|
|
||||||
if let Some(p) = &prefix {
|
|
||||||
env.entry("WINEPREFIX".into()).or_insert(p.display().to_string());
|
|
||||||
}
|
|
||||||
if !game.dll_overrides.is_empty() {
|
|
||||||
env.entry("WINEDLLOVERRIDES".into())
|
|
||||||
.or_insert(game.dll_overrides.clone());
|
|
||||||
}
|
|
||||||
if game.esync {
|
|
||||||
env.entry("WINEESYNC".into()).or_insert("1".into());
|
|
||||||
} else {
|
|
||||||
env.entry("WINEESYNC".into()).or_insert("0".into());
|
|
||||||
}
|
|
||||||
if game.fsync {
|
|
||||||
env.entry("WINEFSYNC".into()).or_insert("1".into());
|
|
||||||
} else {
|
|
||||||
env.entry("WINEFSYNC".into()).or_insert("0".into());
|
|
||||||
}
|
|
||||||
// DXVK / VKD3D flags. Lutris uses DXVK_ENABLE_* for HUD toggles;
|
|
||||||
// enBoxer mirrors the conventional set so a vendor DXVK build picks
|
|
||||||
// up the operator's choice.
|
|
||||||
if game.dxvk {
|
|
||||||
env.entry("DXVK_ENABLE".into()).or_insert("1".into());
|
|
||||||
env.entry("DXVK_HUD".into()).or_insert("compiler".into());
|
|
||||||
} else {
|
|
||||||
env.entry("DXVK_ENABLE".into()).or_insert("0".into());
|
|
||||||
}
|
|
||||||
if game.vkd3d {
|
|
||||||
env.entry("VKD3D_CONFIG".into()).or_insert("dxr".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the binary + args. Wine games: invoke `wine` with the exe.
|
|
||||||
// Native games (empty runner): launch the exe directly.
|
|
||||||
let (exe, args) = match game.runner.as_str() {
|
|
||||||
"wine" | "wine64" => {
|
|
||||||
let mut a = vec![game.exe.clone()];
|
|
||||||
if !game.args.is_empty() {
|
|
||||||
a.push(game.args.clone());
|
|
||||||
}
|
|
||||||
(PathBuf::from(game.runner.clone()), a)
|
|
||||||
}
|
|
||||||
"" => {
|
|
||||||
let mut a = Vec::new();
|
|
||||||
if !game.args.is_empty() {
|
|
||||||
a.push(game.args.clone());
|
|
||||||
}
|
|
||||||
(PathBuf::from(&game.exe), a)
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
// Unknown runner: pass the exe + args directly. The operator
|
|
||||||
// sees this in the confirm dialog.
|
|
||||||
let mut a = vec![game.exe.clone()];
|
|
||||||
if !game.args.is_empty() {
|
|
||||||
a.push(game.args.clone());
|
|
||||||
}
|
|
||||||
(PathBuf::from(other), a)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let summary = format!(
|
|
||||||
"slot {} ({}) → {} {} (runner: {})",
|
|
||||||
character.slot,
|
|
||||||
character.name,
|
|
||||||
exe.display(),
|
|
||||||
args.join(" "),
|
|
||||||
if game.runner.is_empty() { "<native>" } else { &game.runner }
|
|
||||||
);
|
|
||||||
|
|
||||||
SpawnPlan { exe, args, env, summary }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Poll `hyprctl -j clients` for the matched character window after
|
|
||||||
/// spawn. `timeout` is the upper bound; we poll once a second. Used
|
|
||||||
/// when the team has `auto_apply: true` on the character so the layout
|
|
||||||
/// picks up the new window. Caller passes the result of `Character`
|
|
||||||
/// `match_title` regex.
|
|
||||||
pub fn matches_character(client_title: &str, character: &Character) -> bool {
|
|
||||||
if let Some(re) = &character.match_title {
|
|
||||||
if let Ok(r) = regex::Regex::new(re) {
|
|
||||||
return r.is_match(client_title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: if no regex, the layout still works because the
|
|
||||||
// window_match regex in the layout already filters, so we accept
|
|
||||||
// everything. The caller may still want the exact regex.
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the path the launcher will write logs / cookies to (used for
|
|
||||||
/// the GUI status line; not consumed by the spawn itself).
|
|
||||||
pub fn log_dir_for(prefix: Option<&Path>) -> Option<PathBuf> {
|
|
||||||
prefix.map(|p| p.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn game() -> LutrisGame {
|
|
||||||
LutrisGame {
|
|
||||||
slug: "wow".into(),
|
|
||||||
name: "WoW".into(),
|
|
||||||
runner: "wine".into(),
|
|
||||||
exe: "drive_c/Program Files/World of Warcraft/WoW.exe".into(),
|
|
||||||
args: "-windowed".into(),
|
|
||||||
prefix: "/opt/spill/wow".into(),
|
|
||||||
env: vec![("DXVK_HUD".into(), "compiler".into())],
|
|
||||||
dll_overrides: "d3d11=n,b;locationapi=d".into(),
|
|
||||||
dxvk: true,
|
|
||||||
vkd3d: false,
|
|
||||||
esync: true,
|
|
||||||
fsync: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn character() -> Character {
|
|
||||||
Character {
|
|
||||||
slot: 1,
|
|
||||||
name: "Main".into(),
|
|
||||||
match_title: None,
|
|
||||||
assist_key: "Shift+F2".into(),
|
|
||||||
follow_key: "Shift+F1".into(),
|
|
||||||
lutris_game: None,
|
|
||||||
wine_prefix: None,
|
|
||||||
auto_apply: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_plan_sets_wine_prefix() {
|
|
||||||
let g = game();
|
|
||||||
let c = character();
|
|
||||||
let plan = spawn_plan(&c, &g, &TeamLaunchOpts::default());
|
|
||||||
assert_eq!(plan.env.get("WINEPREFIX").map(|s| s.as_str()), Some("/opt/spill/wow"));
|
|
||||||
assert_eq!(
|
|
||||||
plan.env.get("WINEDLLOVERRIDES").map(|s| s.as_str()),
|
|
||||||
Some("d3d11=n,b;locationapi=d")
|
|
||||||
);
|
|
||||||
assert_eq!(plan.env.get("WINEESYNC").map(|s| s.as_str()), Some("1"));
|
|
||||||
assert_eq!(plan.env.get("WINEFSYNC").map(|s| s.as_str()), Some("0"));
|
|
||||||
assert_eq!(plan.env.get("DXVK_ENABLE").map(|s| s.as_str()), Some("1"));
|
|
||||||
assert_eq!(plan.exe, PathBuf::from("wine"));
|
|
||||||
assert_eq!(
|
|
||||||
plan.args,
|
|
||||||
vec![
|
|
||||||
"drive_c/Program Files/World of Warcraft/WoW.exe".to_string(),
|
|
||||||
"-windowed".to_string(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_plan_merges_env_with_override() {
|
|
||||||
let g = game();
|
|
||||||
let c = character();
|
|
||||||
let opts = TeamLaunchOpts {
|
|
||||||
wine_prefix: Some(PathBuf::from("/tmp/override-prefix")),
|
|
||||||
};
|
|
||||||
let plan = spawn_plan(&c, &g, &opts);
|
|
||||||
assert_eq!(
|
|
||||||
plan.env.get("WINEPREFIX").map(|s| s.as_str()),
|
|
||||||
Some("/tmp/override-prefix"),
|
|
||||||
"WINEPREFIX must use the team override when provided"
|
|
||||||
);
|
|
||||||
// The extra Lutris env entries still come through.
|
|
||||||
assert_eq!(plan.env.get("DXVK_HUD").map(|s| s.as_str()), Some("compiler"));
|
|
||||||
// Wine / DXVK flags are still set.
|
|
||||||
assert_eq!(plan.env.get("WINEESYNC").map(|s| s.as_str()), Some("1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_plan_native_runner_uses_exe_directly() {
|
|
||||||
let mut g = game();
|
|
||||||
g.runner = String::new();
|
|
||||||
let c = character();
|
|
||||||
let plan = spawn_plan(&c, &g, &TeamLaunchOpts::default());
|
|
||||||
assert_eq!(plan.exe, PathBuf::from(&g.exe));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_plan_does_not_overwrite_existing_env_value() {
|
|
||||||
// If the operator already set WINEESYNC=0 in their shell and the
|
|
||||||
// Lutris YAML has esync: true, the operator's choice wins.
|
|
||||||
let mut g = game();
|
|
||||||
g.esync = true;
|
|
||||||
let mut env = g.env.clone();
|
|
||||||
env.push(("WINEESYNC".into(), "0".into()));
|
|
||||||
g.env = env;
|
|
||||||
let c = character();
|
|
||||||
let plan = spawn_plan(&c, &g, &TeamLaunchOpts::default());
|
|
||||||
assert_eq!(plan.env.get("WINEESYNC").map(|s| s.as_str()), Some("0"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matches_character_falls_back_to_true_without_regex() {
|
|
||||||
let c = character();
|
|
||||||
assert!(matches_character("World of Warcraft — Main", &c));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matches_character_uses_regex_when_present() {
|
|
||||||
let mut c = character();
|
|
||||||
c.match_title = Some(r"Main|Alt".into());
|
|
||||||
assert!(matches_character("WoW — Main", &c));
|
|
||||||
assert!(!matches_character("Random window", &c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
397
src/layout.rs
397
src/layout.rs
@ -1,397 +0,0 @@
|
|||||||
//! Place captured game windows: stacked, equal grid, or one big + a strip of minions.
|
|
||||||
|
|
||||||
use crate::hypr::{self, Client};
|
|
||||||
use crate::profile::{Layout, LayoutPreset, LayoutSlot, Profile};
|
|
||||||
use anyhow::Result;
|
|
||||||
use regex::Regex;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct Monitor {
|
|
||||||
pub name: String,
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
pub width: i32,
|
|
||||||
pub height: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn monitors() -> Result<Vec<Monitor>> {
|
|
||||||
let raw = hypr::hyprctl(["-j", "monitors"]).await?;
|
|
||||||
Ok(serde_json::from_str(&raw)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pick_monitor<'a>(mons: &'a [Monitor], name: &str) -> &'a Monitor {
|
|
||||||
if !name.is_empty() {
|
|
||||||
if let Some(m) = mons.iter().find(|m| m.name == name) {
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mons.iter()
|
|
||||||
.max_by_key(|m| m.width as i64 * m.height as i64)
|
|
||||||
.unwrap_or(&mons[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_for_n(n: u32) -> (u32, u32) {
|
|
||||||
let n = n.max(1);
|
|
||||||
let mut y = (n as f64).sqrt().floor() as u32;
|
|
||||||
y = y.max(1);
|
|
||||||
let mut x = (n as f64).sqrt().ceil() as u32;
|
|
||||||
x = x.max(1);
|
|
||||||
while x * y < n {
|
|
||||||
y += 1;
|
|
||||||
}
|
|
||||||
(x, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec<LayoutSlot> {
|
|
||||||
if mons.is_empty() || n == 0 {
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
let m = pick_monitor(mons, &layout.monitor);
|
|
||||||
let mut out = match layout.preset {
|
|
||||||
LayoutPreset::Stacked => {
|
|
||||||
let (w, h) = constrain(m.width, m.height);
|
|
||||||
(0..n)
|
|
||||||
.map(|_| LayoutSlot {
|
|
||||||
x: m.x,
|
|
||||||
y: m.y,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
pin: layout.pin,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
LayoutPreset::Grid => grid(m, n, layout.pin),
|
|
||||||
LayoutPreset::MainStrip => main_strip(m, n, layout),
|
|
||||||
};
|
|
||||||
for s in &mut out {
|
|
||||||
clamp_to_monitor(s, m);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clamp_to_monitor(s: &mut LayoutSlot, m: &Monitor) {
|
|
||||||
s.w = s.w.clamp(64, m.width.max(64));
|
|
||||||
s.h = s.h.clamp(64, m.height.max(64));
|
|
||||||
let max_x = m.x + m.width - s.w;
|
|
||||||
let max_y = m.y + m.height - s.h;
|
|
||||||
s.x = s.x.clamp(m.x.min(max_x), max_x.max(m.x));
|
|
||||||
s.y = s.y.clamp(m.y.min(max_y), max_y.max(m.y));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn constrain(w: i32, h: i32) -> (i32, i32) {
|
|
||||||
(w.max(1), h.max(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn grid(m: &Monitor, n: u32, pin: bool) -> Vec<LayoutSlot> {
|
|
||||||
let (cols, rows) = split_for_n(n);
|
|
||||||
let ww = m.width / cols as i32;
|
|
||||||
let wh = m.height / rows as i32;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for y in 0..rows {
|
|
||||||
for x in 0..cols {
|
|
||||||
if out.len() as u32 >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
out.push(LayoutSlot {
|
|
||||||
x: m.x + x as i32 * ww,
|
|
||||||
y: m.y + y as i32 * wh,
|
|
||||||
w: ww,
|
|
||||||
h: wh,
|
|
||||||
pin,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec<LayoutSlot> {
|
|
||||||
if n <= 1 {
|
|
||||||
return grid(m, n, layout.pin);
|
|
||||||
}
|
|
||||||
let small_n = n - 1;
|
|
||||||
let (bw, bh, strip_h, strip_w, horizontal) = if layout.one_row {
|
|
||||||
let c = small_n as i32;
|
|
||||||
// wide monitor: strip along the bottom (or top)
|
|
||||||
let bh = (m.height as f64 * c as f64 / (c + 1) as f64).round() as i32;
|
|
||||||
(m.width, bh, m.height - bh, m.width, true)
|
|
||||||
} else {
|
|
||||||
let c = (n as i32 - 2).max(2);
|
|
||||||
let bw = (m.width as f64 * c as f64 / (c + 1) as f64).round() as i32;
|
|
||||||
let bh = (m.height as f64 * c as f64 / (c + 1) as f64).round() as i32;
|
|
||||||
(bw, bh, m.height - bh, m.width - bw, false)
|
|
||||||
};
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let main_y = if layout.main_at_bottom {
|
|
||||||
m.y + (m.height - bh)
|
|
||||||
} else {
|
|
||||||
m.y
|
|
||||||
};
|
|
||||||
out.push(LayoutSlot {
|
|
||||||
x: m.x,
|
|
||||||
y: main_y,
|
|
||||||
w: bw,
|
|
||||||
h: bh,
|
|
||||||
pin: true,
|
|
||||||
});
|
|
||||||
let sw = if horizontal {
|
|
||||||
m.width / small_n.max(1) as i32
|
|
||||||
} else {
|
|
||||||
strip_w.max(1)
|
|
||||||
};
|
|
||||||
let sh = if horizontal {
|
|
||||||
strip_h.max(1)
|
|
||||||
} else {
|
|
||||||
bh / small_n.max(1) as i32
|
|
||||||
};
|
|
||||||
let strip_y = if layout.main_at_bottom { m.y } else { m.y + bh };
|
|
||||||
for i in 0..small_n {
|
|
||||||
let (x, y) = if horizontal {
|
|
||||||
(m.x + i as i32 * sw, strip_y)
|
|
||||||
} else {
|
|
||||||
(m.x + bw, m.y + i as i32 * sh)
|
|
||||||
};
|
|
||||||
out.push(LayoutSlot {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
w: sw.max(1),
|
|
||||||
h: sh.max(1),
|
|
||||||
pin: layout.pin,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_windows(profile: &Profile, clients: Vec<Client>) -> Vec<(u32, Client)> {
|
|
||||||
let class_re = profile
|
|
||||||
.window_match
|
|
||||||
.class
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|p| Regex::new(p).ok());
|
|
||||||
let title_re = profile
|
|
||||||
.window_match
|
|
||||||
.title
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|p| Regex::new(p).ok());
|
|
||||||
if class_re.is_none() && title_re.is_none() {
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
let mut matched: Vec<Client> = clients
|
|
||||||
.into_iter()
|
|
||||||
.filter(|c| {
|
|
||||||
if c.class == "enboxer-vfx" {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let class_ok = class_re
|
|
||||||
.as_ref()
|
|
||||||
.map(|r| r.is_match(&c.class))
|
|
||||||
.unwrap_or(true);
|
|
||||||
let title_ok = title_re
|
|
||||||
.as_ref()
|
|
||||||
.map(|r| r.is_match(&c.title))
|
|
||||||
.unwrap_or(true);
|
|
||||||
class_ok && title_ok && c.mapped && !c.hidden
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
|
|
||||||
matched
|
|
||||||
.into_iter()
|
|
||||||
.take(profile.slots as usize)
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, c)| ((i as u32) + 1, c))
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
let clients = hypr::clients().await?;
|
|
||||||
let windows = select_windows(profile, clients);
|
|
||||||
apply(&profile.layout.slots, &windows).await?;
|
|
||||||
Ok(windows.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut geom = geom0.clone();
|
|
||||||
if let Some(m) = mons.iter().find(|m| {
|
|
||||||
geom.x >= m.x && geom.y >= m.y && geom.x < m.x + m.width && geom.y < m.y + m.height
|
|
||||||
}) {
|
|
||||||
clamp_to_monitor(&mut geom, m);
|
|
||||||
} else if let Some(m) = mons.first() {
|
|
||||||
clamp_to_monitor(&mut geom, m);
|
|
||||||
}
|
|
||||||
if geom.w < 64 || geom.h < 64 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let sel = win.address_selector();
|
|
||||||
hypr::dispatch_lua(&format!(
|
|
||||||
"hl.dsp.window.float({{ window = {sel:?}, action = \"on\" }})"
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
hypr::move_resize_window(&sel, geom.x, geom.y, geom.w, geom.h).await?;
|
|
||||||
let pin = if geom.pin { "on" } else { "off" };
|
|
||||||
hypr::dispatch_lua(&format!(
|
|
||||||
"hl.dsp.window.pin({{ window = {sel:?}, action = {pin:?} }})"
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn capture_from(windows: &[(u32, Client)]) -> Vec<LayoutSlot> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for (_, c) in windows {
|
|
||||||
out.push(LayoutSlot {
|
|
||||||
x: c.at[0],
|
|
||||||
y: c.at[1],
|
|
||||||
w: c.size[0],
|
|
||||||
h: c.size[1],
|
|
||||||
pin: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Swap geometry of slot `n` with slot 1 (the main tile).
|
|
||||||
pub fn swap_main(slots: &mut [LayoutSlot], n: usize) {
|
|
||||||
if n == 0 || n >= slots.len() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
slots.swap(0, n);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn mon() -> Monitor {
|
|
||||||
Monitor {
|
|
||||||
name: "DP-1".into(),
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stacked_same_rect() {
|
|
||||||
let l = Layout {
|
|
||||||
preset: LayoutPreset::Stacked,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let s = generate(&l, 3, &[mon()]);
|
|
||||||
assert_eq!(s.len(), 3);
|
|
||||||
assert!(s
|
|
||||||
.iter()
|
|
||||||
.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();
|
|
||||||
let mut s = LayoutSlot {
|
|
||||||
x: -400,
|
|
||||||
y: -500,
|
|
||||||
w: 1280,
|
|
||||||
h: 1440,
|
|
||||||
pin: false,
|
|
||||||
};
|
|
||||||
clamp_to_monitor(&mut s, &m);
|
|
||||||
assert!(s.x >= 0 && s.y >= 0);
|
|
||||||
assert!(s.x + s.w <= m.width);
|
|
||||||
assert!(s.y + s.h <= m.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grid_five() {
|
|
||||||
let l = Layout {
|
|
||||||
preset: LayoutPreset::Grid,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let s = generate(&l, 5, &[mon()]);
|
|
||||||
assert_eq!(s.len(), 5);
|
|
||||||
assert!(s.iter().all(|w| w.w > 0 && w.h > 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn swap_main_exchanges_first() {
|
|
||||||
let mut s = vec![
|
|
||||||
LayoutSlot {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
w: 100,
|
|
||||||
h: 100,
|
|
||||||
pin: true,
|
|
||||||
},
|
|
||||||
LayoutSlot {
|
|
||||||
x: 100,
|
|
||||||
y: 0,
|
|
||||||
w: 50,
|
|
||||||
h: 50,
|
|
||||||
pin: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
swap_main(&mut s, 1);
|
|
||||||
assert_eq!(s[0].w, 50);
|
|
||||||
assert_eq!(s[1].w, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn main_strip_has_big_first() {
|
|
||||||
let l = Layout {
|
|
||||||
preset: LayoutPreset::MainStrip,
|
|
||||||
one_row: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let s = generate(&l, 4, &[mon()]);
|
|
||||||
assert_eq!(s.len(), 4);
|
|
||||||
assert!(s[0].w * s[0].h > s[1].w * s[1].h);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn apply_short_circuits_when_layout_disallowed() {
|
|
||||||
// T12: apply_layout must short-circuit when ENBOXER_ALLOW_LAYOUT is unset
|
|
||||||
// (or anything other than "1"/"true"). It must NOT touch hyprctl.
|
|
||||||
std::env::remove_var("ENBOXER_ALLOW_LAYOUT");
|
|
||||||
let err = apply(&[], &[]).await.unwrap_err();
|
|
||||||
assert!(err.to_string().contains("ENBOXER_ALLOW_LAYOUT"));
|
|
||||||
|
|
||||||
std::env::set_var("ENBOXER_ALLOW_LAYOUT", "0");
|
|
||||||
let err = apply(&[], &[]).await.unwrap_err();
|
|
||||||
assert!(err.to_string().contains("ENBOXER_ALLOW_LAYOUT"));
|
|
||||||
|
|
||||||
std::env::remove_var("ENBOXER_ALLOW_LAYOUT");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +1,7 @@
|
|||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod gui;
|
|
||||||
pub mod hotkey;
|
pub mod hotkey;
|
||||||
pub mod hypr;
|
pub mod hypr;
|
||||||
pub mod launcher;
|
|
||||||
pub mod layout;
|
|
||||||
pub mod lutris;
|
|
||||||
pub mod macros;
|
pub mod macros;
|
||||||
pub mod overlay;
|
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod team;
|
|
||||||
pub mod toplevel_export;
|
|
||||||
pub mod vfx;
|
pub mod vfx;
|
||||||
pub mod wayland_layer;
|
|
||||||
|
|||||||
316
src/lutris.rs
316
src/lutris.rs
@ -1,316 +0,0 @@
|
|||||||
//! Lutris YAML loader.
|
|
||||||
//!
|
|
||||||
//! Parses the subset of `~/.config/lutris/games/*.yml` (also valid for
|
|
||||||
//! `~/.local/share/lutris/games/*.yml`) that enBoxer cares about:
|
|
||||||
//! - runner / exe / args / prefix
|
|
||||||
//! - system.env (a flat mapping of env vars)
|
|
||||||
//! - wine.dll_overrides, wine.dxvk, wine.vkd3d, wine.esync, wine.fsync
|
|
||||||
//!
|
|
||||||
//! Lutris YAML is loose: some games use top-level `wine:`, others nest it
|
|
||||||
//! under `system.wine:`. We accept both. Missing fields stay empty.
|
|
||||||
//!
|
|
||||||
//! A bad YAML file in the directory is **not** a fatal error: we warn and
|
|
||||||
//! skip it, so the GUI keeps loading whatever else is there.
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// Parsed Lutris game. `slug` is the file stem; `name` is the
|
|
||||||
/// display name from `name:`/`game_slug:`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct LutrisGame {
|
|
||||||
pub slug: String,
|
|
||||||
pub name: String,
|
|
||||||
pub runner: String,
|
|
||||||
pub exe: String,
|
|
||||||
pub args: String,
|
|
||||||
pub prefix: String,
|
|
||||||
pub env: Vec<(String, String)>,
|
|
||||||
pub dll_overrides: String,
|
|
||||||
pub dxvk: bool,
|
|
||||||
pub vkd3d: bool,
|
|
||||||
pub esync: bool,
|
|
||||||
pub fsync: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raw schema mirroring the on-disk YAML. `flatten` lets us read either
|
|
||||||
/// top-level `wine:` or `system.wine:` into the same struct.
|
|
||||||
#[derive(Debug, Default, Deserialize, Clone, PartialEq, Eq)]
|
|
||||||
struct RawGame {
|
|
||||||
#[serde(default)]
|
|
||||||
name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
game_slug: String,
|
|
||||||
#[serde(default)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
slug: String,
|
|
||||||
#[serde(default)]
|
|
||||||
runner: String,
|
|
||||||
#[serde(default)]
|
|
||||||
game: RawGameInner,
|
|
||||||
#[serde(default)]
|
|
||||||
system: RawSystem,
|
|
||||||
#[serde(default)]
|
|
||||||
wine: RawWine,
|
|
||||||
#[serde(default)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
script: serde_yaml::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, Clone, PartialEq, Eq)]
|
|
||||||
struct RawGameInner {
|
|
||||||
#[serde(default)]
|
|
||||||
exe: String,
|
|
||||||
#[serde(default)]
|
|
||||||
args: String,
|
|
||||||
#[serde(default)]
|
|
||||||
prefix: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, Clone, PartialEq, Eq)]
|
|
||||||
struct RawSystem {
|
|
||||||
#[serde(default)]
|
|
||||||
env: serde_yaml::Mapping,
|
|
||||||
#[serde(default)]
|
|
||||||
wine: Option<Box<RawWine>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, Clone, PartialEq, Eq)]
|
|
||||||
struct RawWine {
|
|
||||||
#[serde(default)]
|
|
||||||
dll_overrides: String,
|
|
||||||
#[serde(default)]
|
|
||||||
dxvk: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
vkd3d: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
esync: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
fsync: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse one Lutris game YAML. `slug` is the file stem (e.g. `wow-classic`),
|
|
||||||
/// `text` is the YAML contents. Returns `None` if parsing failed; the
|
|
||||||
/// caller logs a warning and moves on.
|
|
||||||
pub fn parse(slug: &str, text: &str) -> Option<LutrisGame> {
|
|
||||||
let raw: RawGame = match serde_yaml::from_str(text) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("lutris: skip {slug}: parse: {e}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Lutris puts Wine config under either top-level `wine:` (rare) or
|
|
||||||
// `system.wine:` (common). When both exist, the top-level wins: it's
|
|
||||||
// the more specific override.
|
|
||||||
let wine = if raw.wine != RawWine::default() {
|
|
||||||
raw.wine.clone()
|
|
||||||
} else if let Some(w) = raw.system.wine.clone() {
|
|
||||||
*w
|
|
||||||
} else {
|
|
||||||
RawWine::default()
|
|
||||||
};
|
|
||||||
let mut env: Vec<(String, String)> = Vec::new();
|
|
||||||
for (k, v) in raw.system.env {
|
|
||||||
let key = match k {
|
|
||||||
serde_yaml::Value::String(s) => s,
|
|
||||||
other => format!("{other:?}"),
|
|
||||||
};
|
|
||||||
let val = match v {
|
|
||||||
serde_yaml::Value::String(s) => s,
|
|
||||||
serde_yaml::Value::Number(n) => n.to_string(),
|
|
||||||
serde_yaml::Value::Bool(b) => b.to_string(),
|
|
||||||
other => format!("{other:?}"),
|
|
||||||
};
|
|
||||||
env.push((key, val));
|
|
||||||
}
|
|
||||||
Some(LutrisGame {
|
|
||||||
slug: slug.to_string(),
|
|
||||||
name: if !raw.name.is_empty() {
|
|
||||||
raw.name
|
|
||||||
} else if !raw.game_slug.is_empty() {
|
|
||||||
raw.game_slug
|
|
||||||
} else {
|
|
||||||
slug.to_string()
|
|
||||||
},
|
|
||||||
runner: raw.runner,
|
|
||||||
exe: raw.game.exe,
|
|
||||||
args: raw.game.args,
|
|
||||||
prefix: raw.game.prefix,
|
|
||||||
env,
|
|
||||||
dll_overrides: wine.dll_overrides,
|
|
||||||
dxvk: wine.dxvk,
|
|
||||||
vkd3d: wine.vkd3d,
|
|
||||||
esync: wine.esync,
|
|
||||||
fsync: wine.fsync,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load every `*.yml` in `dir`. Missing directory returns an empty list
|
|
||||||
/// (never an error) so the picker shows "no games" instead of crashing
|
|
||||||
/// the GUI. Per-file failures are logged at warn and skipped.
|
|
||||||
pub fn load_all(dir: &Path) -> Vec<LutrisGame> {
|
|
||||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for entry in rd.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|s| s.to_str()) != Some("yml") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
||||||
tracing::warn!("lutris: skip {stem}: read failed");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(g) = parse(stem, &text) {
|
|
||||||
out.push(g);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort_by_key(|a| a.name.to_ascii_lowercase());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort: the conventional Lutris games directory. The XDG path is
|
|
||||||
/// `$XDG_CONFIG_HOME/lutris/games` or `~/.config/lutris/games`; enBoxer
|
|
||||||
/// also accepts `~/.local/share/lutris/games` (the system-wide install).
|
|
||||||
pub fn default_dir() -> Option<PathBuf> {
|
|
||||||
if let Ok(c) = std::env::var("XDG_CONFIG_HOME") {
|
|
||||||
let p = PathBuf::from(c).join("lutris/games");
|
|
||||||
if p.exists() {
|
|
||||||
return Some(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(home) = directories::UserDirs::new() {
|
|
||||||
let p = home.home_dir().join(".config/lutris/games");
|
|
||||||
if p.exists() {
|
|
||||||
return Some(p);
|
|
||||||
}
|
|
||||||
let p2 = home.home_dir().join(".local/share/lutris/games");
|
|
||||||
if p2.exists() {
|
|
||||||
return Some(p2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_minimal_yaml() {
|
|
||||||
let y = r#"
|
|
||||||
name: World of Warcraft
|
|
||||||
runner: wine
|
|
||||||
game:
|
|
||||||
exe: drive_c/Program Files/World of Warcraft/WoW.exe
|
|
||||||
args: -windowed
|
|
||||||
prefix: /opt/spill/wow
|
|
||||||
wine:
|
|
||||||
dll_overrides: d3d11=n,b;locationapi=d
|
|
||||||
dxvk: true
|
|
||||||
vkd3d: false
|
|
||||||
esync: true
|
|
||||||
fsync: false
|
|
||||||
system:
|
|
||||||
env:
|
|
||||||
DXVK_HUD: compiler
|
|
||||||
WINEDEBUG: -all
|
|
||||||
"#;
|
|
||||||
let g = parse("wow-classic", y).expect("parse");
|
|
||||||
assert_eq!(g.name, "World of Warcraft");
|
|
||||||
assert_eq!(g.runner, "wine");
|
|
||||||
assert!(g.exe.ends_with("WoW.exe"));
|
|
||||||
assert_eq!(g.args, "-windowed");
|
|
||||||
assert_eq!(g.prefix, "/opt/spill/wow");
|
|
||||||
assert!(g.dll_overrides.contains("d3d11"));
|
|
||||||
assert!(g.dxvk);
|
|
||||||
assert!(!g.vkd3d);
|
|
||||||
assert!(g.esync);
|
|
||||||
assert!(!g.fsync);
|
|
||||||
let env_map: BTreeMap<String, String> = g.env.iter().cloned().collect();
|
|
||||||
assert_eq!(env_map.get("DXVK_HUD").map(|s| s.as_str()), Some("compiler"));
|
|
||||||
assert_eq!(env_map.get("WINEDEBUG").map(|s| s.as_str()), Some("-all"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_falls_back_to_slug_when_name_missing() {
|
|
||||||
let y = "runner: wine\n";
|
|
||||||
let g = parse("fallback-slug", y).expect("parse");
|
|
||||||
assert_eq!(g.name, "fallback-slug");
|
|
||||||
assert_eq!(g.runner, "wine");
|
|
||||||
assert_eq!(g.exe, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_accepts_system_wine() {
|
|
||||||
// Some Lutris games put wine config under system.wine instead of
|
|
||||||
// the top level. Both should parse.
|
|
||||||
let y = r#"
|
|
||||||
name: WoW
|
|
||||||
system:
|
|
||||||
wine:
|
|
||||||
dxvk: true
|
|
||||||
esync: true
|
|
||||||
"#;
|
|
||||||
let g = parse("wow-system-wine", y).expect("parse");
|
|
||||||
assert!(g.dxvk);
|
|
||||||
assert!(g.esync);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_top_level_wine_overrides_system_wine() {
|
|
||||||
// When both exist, the top-level wins (it's the more specific
|
|
||||||
// override).
|
|
||||||
let y = r#"
|
|
||||||
wine:
|
|
||||||
dxvk: true
|
|
||||||
system:
|
|
||||||
wine:
|
|
||||||
dxvk: false
|
|
||||||
"#;
|
|
||||||
let g = parse("both", y).expect("parse");
|
|
||||||
assert!(g.dxvk);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_all_skips_bad_yaml() {
|
|
||||||
let dir = std::env::temp_dir().join(format!(
|
|
||||||
"enboxer_lutris_bad_{}_{}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::fs::write(dir.join("ok.yml"), "name: Ok\nrunner: wine\n").unwrap();
|
|
||||||
std::fs::write(dir.join("broken.yml"), "name: [broken: yaml\n : :").unwrap();
|
|
||||||
std::fs::write(dir.join("notyml.txt"), "ignore me").unwrap();
|
|
||||||
let games = load_all(&dir);
|
|
||||||
assert_eq!(games.len(), 1, "bad file skipped, only `ok.yml`");
|
|
||||||
assert_eq!(games[0].slug, "ok");
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_dir_is_empty() {
|
|
||||||
let dir = PathBuf::from("/this/path/should/not/exist/anywhere");
|
|
||||||
assert!(load_all(&dir).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_yaml_is_a_valid_minimal_game() {
|
|
||||||
let g = parse("empty", "").expect("parse empty as minimal");
|
|
||||||
assert_eq!(g.slug, "empty");
|
|
||||||
assert_eq!(g.name, "empty");
|
|
||||||
assert!(g.env.is_empty());
|
|
||||||
assert!(!g.dxvk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
70
src/main.rs
70
src/main.rs
@ -14,7 +14,7 @@ use tracing_subscriber::EnvFilter;
|
|||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: Option<Cmd>,
|
cmd: Cmd,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@ -34,13 +34,6 @@ enum Cmd {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
sock: Option<PathBuf>,
|
sock: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Set or cycle routing mode: maps | mirror | off
|
|
||||||
Mode {
|
|
||||||
/// maps, mirror, off, or omit to cycle
|
|
||||||
which: Option<String>,
|
|
||||||
#[arg(long)]
|
|
||||||
sock: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// Print the in-game macros / binds to create
|
/// Print the in-game macros / binds to create
|
||||||
Macros {
|
Macros {
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
@ -51,13 +44,6 @@ enum Cmd {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
config: Option<PathBuf>,
|
config: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Control panel (default if you run `enboxer` with no command)
|
|
||||||
Gui,
|
|
||||||
/// Generate and apply the window layout to captured game clients
|
|
||||||
LayoutApply {
|
|
||||||
#[arg(short, long)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// Called by Hyprland binds; not for humans
|
/// Called by Hyprland binds; not for humans
|
||||||
Ipc {
|
Ipc {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@ -74,18 +60,7 @@ async fn main() -> Result<()> {
|
|||||||
.with_env_filter(EnvFilter::from_default_env().add_directive("enboxer=info".parse()?))
|
.with_env_filter(EnvFilter::from_default_env().add_directive("enboxer=info".parse()?))
|
||||||
.init();
|
.init();
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.cmd.unwrap_or(Cmd::Gui) {
|
match cli.cmd {
|
||||||
Cmd::Gui => enboxer::gui::run(),
|
|
||||||
Cmd::LayoutApply { config } => {
|
|
||||||
let path = config.unwrap_or_else(profile::default_config_path);
|
|
||||||
let mut profile = Profile::load(&path)?;
|
|
||||||
let n = enboxer::layout::apply_for_profile(&mut profile).await?;
|
|
||||||
if let Ok(text) = serde_yaml::to_string(&profile) {
|
|
||||||
let _ = std::fs::write(&path, text);
|
|
||||||
}
|
|
||||||
println!("laid out {n} windows");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Cmd::Run { config } => {
|
Cmd::Run { config } => {
|
||||||
let path = config.unwrap_or_else(profile::default_config_path);
|
let path = config.unwrap_or_else(profile::default_config_path);
|
||||||
let profile = Profile::load(&path)?;
|
let profile = Profile::load(&path)?;
|
||||||
@ -105,15 +80,6 @@ async fn main() -> Result<()> {
|
|||||||
println!("{}", session::ipc_send(&sock, "status").await?);
|
println!("{}", session::ipc_send(&sock, "status").await?);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Cmd::Mode { which, sock } => {
|
|
||||||
let sock = sock.unwrap_or_else(session::default_sock);
|
|
||||||
let line = match which {
|
|
||||||
Some(w) => format!("mode {w}"),
|
|
||||||
None => "mode-cycle".into(),
|
|
||||||
};
|
|
||||||
println!("{}", session::ipc_send(&sock, &line).await?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Cmd::Macros { config } => {
|
Cmd::Macros { config } => {
|
||||||
let path = config.unwrap_or_else(profile::default_config_path);
|
let path = config.unwrap_or_else(profile::default_config_path);
|
||||||
let profile = Profile::load(&path)?;
|
let profile = Profile::load(&path)?;
|
||||||
@ -156,37 +122,7 @@ async fn doctor(config: Option<PathBuf>) -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => println!("grim: present"),
|
Ok(_) => println!("grim: present"),
|
||||||
Err(_) => {
|
Err(_) => println!("grim: MISSING (Video FX capture needs grim)"),
|
||||||
println!("grim: MISSING (Video FX capture needs grim)");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match tokio::process::Command::new("mpv")
|
|
||||||
.arg("--version")
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => println!("mpv: present"),
|
|
||||||
Err(_) => {
|
|
||||||
println!("mpv: MISSING (Video FX overlay needs mpv)");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match enboxer::hypr::dispatch_lua(
|
|
||||||
"hl.dsp.send_shortcut({ window = \"class:enboxer-does-not-exist\", mods = \"\", key = \"a\" })",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => println!("send_shortcut: compositor accepts dispatcher"),
|
|
||||||
Err(e) => {
|
|
||||||
let msg = e.to_string();
|
|
||||||
if msg.contains("window not found") {
|
|
||||||
println!("send_shortcut: compositor accepts dispatcher");
|
|
||||||
} else {
|
|
||||||
println!("send_shortcut: FAIL {e}");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(path) = config.or_else(|| {
|
if let Some(path) = config.or_else(|| {
|
||||||
let p = profile::default_config_path();
|
let p = profile::default_config_path();
|
||||||
|
|||||||
260
src/overlay.rs
260
src/overlay.rs
@ -1,260 +0,0 @@
|
|||||||
//! Slot-number overlay on the desktop.
|
|
||||||
//!
|
|
||||||
//! Each captured slot gets a small wlr-layer-shell surface (top layer, anchored
|
|
||||||
//! at the slot's top-left). Clicking the overlay asks the daemon to make that
|
|
||||||
//! slot the new main.
|
|
||||||
//!
|
|
||||||
//! ## Safety
|
|
||||||
//!
|
|
||||||
//! Live Wayland rendering is **gated** behind `ENBOXER_ENABLE_OVERLAY=1`.
|
|
||||||
//! `cargo test`, `enboxer doctor`, and any code path that does not set the env
|
|
||||||
//! var returns a placeholder [`OverlayHandle`] and never connects to the
|
|
||||||
//! compositor. The user's Hyprland session is therefore never touched unless
|
|
||||||
//! they explicitly opt in.
|
|
||||||
//!
|
|
||||||
//! A working wlr-layer-shell client (buffer render + click IPC via
|
|
||||||
//! `zwlr_layer_shell_v1`) is a follow-up ticket; the geometry math and the
|
|
||||||
//! spawn / kill plan are real and tested here so the live render slots into a
|
|
||||||
//! known shape.
|
|
||||||
|
|
||||||
use crate::hypr::Client;
|
|
||||||
|
|
||||||
const OVERLAY_W: i32 = 96;
|
|
||||||
const OVERLAY_H: i32 = 96;
|
|
||||||
|
|
||||||
/// Geometry for one overlay surface.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct OverlayRect {
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
pub w: i32,
|
|
||||||
pub h: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure: where the overlay should sit relative to a captured slot. Anchored to
|
|
||||||
/// the slot's top-left so the number is visible even when the slot window is
|
|
||||||
/// minimised or behind another surface.
|
|
||||||
pub fn overlay_rect(slot_x: i32, slot_y: i32, _slot_w: i32, _slot_h: i32) -> OverlayRect {
|
|
||||||
OverlayRect {
|
|
||||||
x: slot_x,
|
|
||||||
y: slot_y,
|
|
||||||
w: OVERLAY_W,
|
|
||||||
h: OVERLAY_H,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure: the IPC verb a click should send to the daemon to make `slot` the
|
|
||||||
/// new main. The live wlr-layer-shell surface writes
|
|
||||||
/// `enboxer ipc --sock <sock> <verb>` to the daemon's unix socket; the gating
|
|
||||||
/// stub logs the verb so the operator can confirm wiring by hand.
|
|
||||||
pub fn click_verb(slot: u32) -> String {
|
|
||||||
format!("swap {slot}")
|
|
||||||
// ponytail: keep the verb alone. `enboxer ipc --sock X swap N` is what
|
|
||||||
// the user types too; calling enboxer here would re-exec ourselves.
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True iff the user has opted in to live Wayland rendering.
|
|
||||||
pub fn live_enabled() -> bool {
|
|
||||||
std::env::var("ENBOXER_ENABLE_OVERLAY")
|
|
||||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One overlay per active slot. Cheap to clone; carries enough state for both
|
|
||||||
/// the stub and a future live surface.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OverlayHandle {
|
|
||||||
pub slot: u32,
|
|
||||||
pub rect: OverlayRect,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OverlayHandle {
|
|
||||||
/// Stub handle for non-live paths. Does not connect to Wayland.
|
|
||||||
pub fn stub(slot: u32, rect: OverlayRect) -> Self {
|
|
||||||
Self { slot, rect }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn one overlay for `client`. When `live_enabled()` is false, returns a
|
|
||||||
/// stub and never touches Wayland; otherwise it spawns a background thread
|
|
||||||
/// that connects to the compositor, creates a wlr-layer-shell surface for
|
|
||||||
/// this slot, draws the slot number into a shm-backed buffer, and posts
|
|
||||||
/// `swap <slot>` to the daemon unix socket on pointer button events.
|
|
||||||
///
|
|
||||||
/// `ipc_sock` is the daemon's listen socket — when the user clicks the
|
|
||||||
/// overlay we connect briefly, write one line, and disconnect. Pass the
|
|
||||||
/// same socket the daemon uses (`default_sock()`).
|
|
||||||
pub fn spawn(slot: u32, client: &Client) -> OverlayHandle {
|
|
||||||
spawn_with_sock(slot, client, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`spawn`] but lets the caller pass the IPC socket explicitly.
|
|
||||||
pub fn spawn_with_sock(
|
|
||||||
slot: u32,
|
|
||||||
client: &Client,
|
|
||||||
ipc_sock: Option<std::path::PathBuf>,
|
|
||||||
) -> OverlayHandle {
|
|
||||||
let rect = overlay_rect(client.at[0], client.at[1], client.size[0], client.size[1]);
|
|
||||||
if !live_enabled() {
|
|
||||||
tracing::debug!("overlay: stub for slot {slot} at {rect:?}");
|
|
||||||
return OverlayHandle::stub(slot, rect);
|
|
||||||
}
|
|
||||||
let sock = ipc_sock.unwrap_or_else(crate::session::default_sock);
|
|
||||||
match crate::wayland_layer::spawn(slot, rect, sock) {
|
|
||||||
Ok(_) => OverlayHandle::stub(slot, rect),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("overlay: live spawn failed for slot {slot}: {e}");
|
|
||||||
OverlayHandle::stub(slot, rect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track one overlay per active slot. Pure: no Wayland touched.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct OverlayHub {
|
|
||||||
by_slot: std::collections::BTreeMap<u32, OverlayHandle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OverlayHub {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure: compute the spawn / kill diff between the current slot set and
|
|
||||||
/// the desired one. Tests use this to verify routing shape without
|
|
||||||
/// connecting to Wayland.
|
|
||||||
pub fn plan(
|
|
||||||
&self,
|
|
||||||
slots: &[(u32, Client)],
|
|
||||||
) -> (Vec<OverlayHandle>, Vec<u32>) {
|
|
||||||
let want: std::collections::BTreeMap<u32, &Client> =
|
|
||||||
slots.iter().map(|(s, c)| (*s, c)).collect();
|
|
||||||
let spawn_list: Vec<OverlayHandle> = want
|
|
||||||
.iter()
|
|
||||||
.filter(|(s, _)| !self.by_slot.contains_key(*s))
|
|
||||||
.map(|(s, c)| spawn(*s, c))
|
|
||||||
.collect();
|
|
||||||
let kill: Vec<u32> = self
|
|
||||||
.by_slot
|
|
||||||
.keys()
|
|
||||||
.filter(|s| !want.contains_key(s))
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
(spawn_list, kill)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply the plan: drop killed slots, insert spawned ones, return the
|
|
||||||
/// list of overlays the caller should actually open.
|
|
||||||
pub fn sync(
|
|
||||||
&mut self,
|
|
||||||
slots: &[(u32, Client)],
|
|
||||||
) -> Vec<OverlayHandle> {
|
|
||||||
let (to_spawn, kill) = self.plan(slots);
|
|
||||||
for k in kill {
|
|
||||||
self.by_slot.remove(&k);
|
|
||||||
}
|
|
||||||
for h in &to_spawn {
|
|
||||||
self.by_slot.insert(h.slot, h.clone());
|
|
||||||
}
|
|
||||||
to_spawn
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.by_slot.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.by_slot.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn kill_all(&mut self) {
|
|
||||||
self.by_slot.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn client(addr: &str, at: (i32, i32)) -> Client {
|
|
||||||
Client {
|
|
||||||
address: addr.into(),
|
|
||||||
class: "wow".into(),
|
|
||||||
title: addr.into(),
|
|
||||||
pid: 1,
|
|
||||||
at: [at.0, at.1],
|
|
||||||
size: [800, 600],
|
|
||||||
mapped: true,
|
|
||||||
hidden: false,
|
|
||||||
xwayland: true,
|
|
||||||
focus_history_id: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn overlay_rect_is_anchored_top_left() {
|
|
||||||
let r = overlay_rect(1920, 200, 800, 600);
|
|
||||||
assert_eq!(r.x, 1920);
|
|
||||||
assert_eq!(r.y, 200);
|
|
||||||
assert_eq!(r.w, OVERLAY_W);
|
|
||||||
assert_eq!(r.h, OVERLAY_H);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn click_verb_is_swap_with_slot_number() {
|
|
||||||
assert_eq!(click_verb(3), "swap 3");
|
|
||||||
assert_eq!(click_verb(1), "swap 1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_enabled_defaults_off_and_respects_env() {
|
|
||||||
std::env::remove_var("ENBOXER_ENABLE_OVERLAY");
|
|
||||||
assert!(!live_enabled());
|
|
||||||
std::env::set_var("ENBOXER_ENABLE_OVERLAY", "1");
|
|
||||||
assert!(live_enabled());
|
|
||||||
std::env::set_var("ENBOXER_ENABLE_OVERLAY", "true");
|
|
||||||
assert!(live_enabled());
|
|
||||||
std::env::set_var("ENBOXER_ENABLE_OVERLAY", "yes");
|
|
||||||
assert!(!live_enabled());
|
|
||||||
std::env::remove_var("ENBOXER_ENABLE_OVERLAY");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_returns_stub_when_live_disabled() {
|
|
||||||
std::env::remove_var("ENBOXER_ENABLE_OVERLAY");
|
|
||||||
let c = client("0xa", (100, 200));
|
|
||||||
let h = spawn(2, &c);
|
|
||||||
assert_eq!(h.slot, 2);
|
|
||||||
assert_eq!(h.rect.x, 100);
|
|
||||||
assert_eq!(h.rect.y, 200);
|
|
||||||
assert!(!live_enabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plan_spawns_new_slots_and_kills_gone_ones() {
|
|
||||||
let mut hub = OverlayHub::new();
|
|
||||||
let s1 = vec![(1, client("0xa", (0, 0))), (2, client("0xb", (1000, 0)))];
|
|
||||||
let spawned = hub.sync(&s1);
|
|
||||||
assert_eq!(spawned.len(), 2);
|
|
||||||
assert_eq!(hub.len(), 2);
|
|
||||||
|
|
||||||
// slot 3 appears, slot 1 disappears
|
|
||||||
let s2 = vec![(2, client("0xb", (1000, 0))), (3, client("0xc", (0, 1000)))];
|
|
||||||
let spawned = hub.sync(&s2);
|
|
||||||
assert_eq!(spawned.len(), 1);
|
|
||||||
assert_eq!(spawned[0].slot, 3);
|
|
||||||
assert_eq!(hub.len(), 2);
|
|
||||||
assert!(hub.by_slot.contains_key(&2));
|
|
||||||
assert!(hub.by_slot.contains_key(&3));
|
|
||||||
assert!(!hub.by_slot.contains_key(&1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn kill_all_clears() {
|
|
||||||
let mut hub = OverlayHub::new();
|
|
||||||
hub.sync(&[(1, client("0xa", (0, 0)))]);
|
|
||||||
assert!(!hub.is_empty());
|
|
||||||
hub.kill_all();
|
|
||||||
assert!(hub.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
189
src/profile.rs
189
src/profile.rs
@ -24,7 +24,7 @@ pub struct Profile {
|
|||||||
pub game_binds: BTreeMap<String, String>,
|
pub game_binds: BTreeMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub interact: Interact,
|
pub interact: Interact,
|
||||||
#[serde(default = "default_session_hotkeys")]
|
#[serde(default)]
|
||||||
pub session_hotkeys: BTreeMap<String, String>,
|
pub session_hotkeys: BTreeMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub characters: Vec<Character>,
|
pub characters: Vec<Character>,
|
||||||
@ -34,8 +34,6 @@ pub struct Profile {
|
|||||||
pub maps: Vec<Map>,
|
pub maps: Vec<Map>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub video_fx: Vec<VideoFx>,
|
pub video_fx: Vec<VideoFx>,
|
||||||
#[serde(default)]
|
|
||||||
pub layout: Layout,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_client() -> String {
|
fn default_client() -> String {
|
||||||
@ -44,21 +42,6 @@ fn default_client() -> String {
|
|||||||
fn default_slots() -> u32 {
|
fn default_slots() -> u32 {
|
||||||
2
|
2
|
||||||
}
|
}
|
||||||
fn default_session_hotkeys() -> BTreeMap<String, String> {
|
|
||||||
let mut m = BTreeMap::new();
|
|
||||||
m.insert("mode_cycle".into(), "Shift+Alt+M".into());
|
|
||||||
m.insert("swap_next".into(), "Ctrl+grave".into());
|
|
||||||
m.insert("swap_prev".into(), "Ctrl+Shift+grave".into());
|
|
||||||
m.insert("focus_next".into(), "Ctrl+Shift+N".into());
|
|
||||||
m.insert("focus_prev".into(), "Ctrl+Shift+P".into());
|
|
||||||
m.insert("focus_main".into(), "Ctrl+F1".into());
|
|
||||||
m.insert("reset_all".into(), "Ctrl+Shift+R".into());
|
|
||||||
m.insert("stay_on_top".into(), "Ctrl+Shift+T".into());
|
|
||||||
m.insert("mouse_follow".into(), "Ctrl+Shift+F".into());
|
|
||||||
m.insert("mouse_broadcast".into(), "Ctrl+Shift+B".into());
|
|
||||||
m
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_mode() -> Mode {
|
fn default_mode() -> Mode {
|
||||||
Mode::Maps
|
Mode::Maps
|
||||||
}
|
}
|
||||||
@ -66,47 +49,25 @@ fn default_mode() -> Mode {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
/// 1: only configured maps; everything else goes to the front window
|
|
||||||
Maps,
|
Maps,
|
||||||
/// 2: clone keys to the other game windows (front window still gets the real key)
|
Repeater,
|
||||||
#[serde(alias = "repeater")]
|
Disabled,
|
||||||
Mirror,
|
|
||||||
/// 3: do not intercept; all keys go to the front window
|
|
||||||
#[serde(alias = "disabled")]
|
|
||||||
Off,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mode {
|
impl Mode {
|
||||||
pub fn parse_name(s: &str) -> Option<Self> {
|
|
||||||
match s.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"maps" | "1" => Some(Mode::Maps),
|
|
||||||
"mirror" | "repeater" | "2" => Some(Mode::Mirror),
|
|
||||||
"off" | "disabled" | "3" => Some(Mode::Off),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cycle(self) -> Self {
|
pub fn cycle(self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Mode::Maps => Mode::Mirror,
|
Mode::Maps => Mode::Repeater,
|
||||||
Mode::Mirror => Mode::Off,
|
Mode::Repeater => Mode::Disabled,
|
||||||
Mode::Off => Mode::Maps,
|
Mode::Disabled => Mode::Maps,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Mode::Maps => "maps",
|
Mode::Maps => "maps",
|
||||||
Mode::Mirror => "mirror",
|
Mode::Repeater => "repeater",
|
||||||
Mode::Off => "off",
|
Mode::Disabled => "disabled",
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Mode::Maps => "maps (configured keys only)",
|
|
||||||
Mode::Mirror => "mirror (all windows)",
|
|
||||||
Mode::Off => "off (front window only)",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,25 +125,6 @@ pub struct Character {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub match_title: Option<String>,
|
pub match_title: Option<String>,
|
||||||
/// Key *everyone* binds to `/assist ThisName`. Sent to others when this slot is main.
|
|
||||||
#[serde(default)]
|
|
||||||
pub assist_key: String,
|
|
||||||
/// Key *everyone* binds to `/follow ThisName`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub follow_key: String,
|
|
||||||
/// Lutris game slug (matches `LutrisGame::slug`) to launch for this
|
|
||||||
/// slot from the GUI's Launch menu. `None` = no Launch button.
|
|
||||||
#[serde(default)]
|
|
||||||
pub lutris_game: Option<String>,
|
|
||||||
/// Override the wine prefix for this character (else the team prefix
|
|
||||||
/// or the Lutris YAML's prefix is used).
|
|
||||||
#[serde(default)]
|
|
||||||
pub wine_prefix: Option<PathBuf>,
|
|
||||||
/// After spawn, poll `hyprctl -j clients` for the matched window
|
|
||||||
/// (up to 30 s) and call the existing layout-apply IPC. Off by
|
|
||||||
/// default; this is the only path that auto-moves windows.
|
|
||||||
#[serde(default)]
|
|
||||||
pub auto_apply: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -236,64 +178,6 @@ pub struct VideoFx {
|
|||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum LayoutPreset {
|
|
||||||
Stacked,
|
|
||||||
Grid,
|
|
||||||
#[default]
|
|
||||||
MainStrip,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Layout {
|
|
||||||
#[serde(default)]
|
|
||||||
pub preset: LayoutPreset,
|
|
||||||
#[serde(default)]
|
|
||||||
pub same_size: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub main_at_bottom: bool,
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub one_row: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub pin: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub auto_apply: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub borderless: bool,
|
|
||||||
/// Hyprland monitor name, empty = largest.
|
|
||||||
#[serde(default)]
|
|
||||||
pub monitor: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub slots: Vec<LayoutSlot>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Layout {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
preset: LayoutPreset::MainStrip,
|
|
||||||
same_size: false,
|
|
||||||
main_at_bottom: false,
|
|
||||||
one_row: true,
|
|
||||||
pin: false,
|
|
||||||
auto_apply: true,
|
|
||||||
borderless: false,
|
|
||||||
monitor: String::new(),
|
|
||||||
slots: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LayoutSlot {
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
pub w: i32,
|
|
||||||
pub h: i32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub pin: bool,
|
|
||||||
}
|
|
||||||
fn default_fps() -> u32 {
|
fn default_fps() -> u32 {
|
||||||
12
|
12
|
||||||
}
|
}
|
||||||
@ -326,11 +210,6 @@ impl Default for NormRect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl NormRect {
|
impl NormRect {
|
||||||
/// Values > 1 are pixels inside the window; otherwise fractions 0..=1 of the window.
|
|
||||||
pub fn is_pixels(&self) -> bool {
|
|
||||||
self.x.abs() > 1.0 || self.y.abs() > 1.0 || self.w > 1.0 || self.h > 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_pixels(
|
pub fn to_pixels(
|
||||||
&self,
|
&self,
|
||||||
win_x: i32,
|
win_x: i32,
|
||||||
@ -338,40 +217,19 @@ impl NormRect {
|
|||||||
win_w: i32,
|
win_w: i32,
|
||||||
win_h: i32,
|
win_h: i32,
|
||||||
) -> (i32, i32, i32, i32) {
|
) -> (i32, i32, i32, i32) {
|
||||||
if self.is_pixels() {
|
|
||||||
(
|
|
||||||
win_x + self.x.round() as i32,
|
|
||||||
win_y + self.y.round() as i32,
|
|
||||||
self.w.round() as i32,
|
|
||||||
self.h.round() as i32,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let x = win_x + (self.x * win_w as f64).round() as i32;
|
let x = win_x + (self.x * win_w as f64).round() as i32;
|
||||||
let y = win_y + (self.y * win_h as f64).round() as i32;
|
let y = win_y + (self.y * win_h as f64).round() as i32;
|
||||||
let w = (self.w * win_w as f64).round() as i32;
|
let w = (self.w * win_w as f64).round() as i32;
|
||||||
let h = (self.h * win_h as f64).round() as i32;
|
let h = (self.h * win_h as f64).round() as i32;
|
||||||
(x, y, w.max(1), h.max(1))
|
(x, y, w.max(1), h.max(1))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_global_pixels(gx: i32, gy: i32, gw: i32, gh: i32, win_x: i32, win_y: i32) -> Self {
|
|
||||||
Self {
|
|
||||||
x: (gx - win_x) as f64,
|
|
||||||
y: (gy - win_y) as f64,
|
|
||||||
w: gw as f64,
|
|
||||||
h: gh as f64,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Profile {
|
impl Profile {
|
||||||
pub fn load(path: &Path) -> Result<Self> {
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
let text = std::fs::read_to_string(path)
|
let text = std::fs::read_to_string(path)
|
||||||
.with_context(|| format!("read profile {}", path.display()))?;
|
.with_context(|| format!("read profile {}", path.display()))?;
|
||||||
let mut profile: Profile = serde_yaml::from_str(&text).context("parse profile YAML")?;
|
let profile: Profile = serde_yaml::from_str(&text).context("parse profile YAML")?;
|
||||||
for (k, v) in default_session_hotkeys() {
|
|
||||||
profile.session_hotkeys.entry(k).or_insert(v);
|
|
||||||
}
|
|
||||||
profile.validate()?;
|
profile.validate()?;
|
||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
@ -461,32 +319,3 @@ pub fn runtime_dir() -> PathBuf {
|
|||||||
std::env::temp_dir().join("enboxer")
|
std::env::temp_dir().join("enboxer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::NormRect;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fraction_rect() {
|
|
||||||
let r = NormRect {
|
|
||||||
x: 0.25,
|
|
||||||
y: 0.0,
|
|
||||||
w: 0.5,
|
|
||||||
h: 1.0,
|
|
||||||
};
|
|
||||||
assert!(!r.is_pixels());
|
|
||||||
assert_eq!(r.to_pixels(100, 50, 200, 100), (150, 50, 100, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pixel_rect() {
|
|
||||||
let r = NormRect {
|
|
||||||
x: 10.0,
|
|
||||||
y: 20.0,
|
|
||||||
w: 80.0,
|
|
||||||
h: 40.0,
|
|
||||||
};
|
|
||||||
assert!(r.is_pixels());
|
|
||||||
assert_eq!(r.to_pixels(100, 50, 200, 100), (110, 70, 80, 40));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
772
src/session.rs
772
src/session.rs
@ -1,7 +1,6 @@
|
|||||||
use crate::engine::{Action, Engine, Hold};
|
use crate::engine::{Action, Engine, Hold};
|
||||||
use crate::hotkey::{self, passthrough_id};
|
use crate::hotkey::{self, passthrough_id};
|
||||||
use crate::hypr::{self, BindSpec, Client};
|
use crate::hypr::{self, BindSpec, Client};
|
||||||
use crate::overlay::OverlayHub;
|
|
||||||
use crate::profile::{runtime_dir, Mode, Profile};
|
use crate::profile::{runtime_dir, Mode, Profile};
|
||||||
use crate::vfx::{self, FeedHit};
|
use crate::vfx::{self, FeedHit};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@ -21,8 +20,6 @@ pub struct Session {
|
|||||||
pub sock: PathBuf,
|
pub sock: PathBuf,
|
||||||
pub vfx: Vec<FeedHit>,
|
pub vfx: Vec<FeedHit>,
|
||||||
pub vfx_source: Option<u32>,
|
pub vfx_source: Option<u32>,
|
||||||
pub mouse_broadcast: bool,
|
|
||||||
pub mouse_follow: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
@ -35,8 +32,6 @@ impl Session {
|
|||||||
sock,
|
sock,
|
||||||
vfx: Vec::new(),
|
vfx: Vec::new(),
|
||||||
vfx_source: None,
|
vfx_source: None,
|
||||||
mouse_broadcast: false,
|
|
||||||
mouse_follow: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,26 +42,8 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
|
|||||||
let _ = std::fs::remove_file(&sock);
|
let _ = std::fs::remove_file(&sock);
|
||||||
}
|
}
|
||||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer"));
|
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer"));
|
||||||
tracing::info!(
|
|
||||||
"profile {} slots={} match class={:?} title={:?}",
|
|
||||||
profile.name,
|
|
||||||
profile.slots,
|
|
||||||
profile.window_match.class,
|
|
||||||
profile.window_match.title
|
|
||||||
);
|
|
||||||
let borderless = profile.layout.borderless;
|
|
||||||
let class_re = profile.window_match.class.clone();
|
|
||||||
let session = Arc::new(Mutex::new(Session::new(profile, exe, sock.clone())?));
|
let session = Arc::new(Mutex::new(Session::new(profile, exe, sock.clone())?));
|
||||||
let (vfx_tx, vfx_rx) = watch::channel(Vec::new());
|
let (vfx_tx, vfx_rx) = watch::channel(Vec::new());
|
||||||
let hub = vfx::OverlayHub::new();
|
|
||||||
let slot_hub = Arc::new(Mutex::new(OverlayHub::new()));
|
|
||||||
|
|
||||||
hypr::apply_vfx_window_rules().await.ok();
|
|
||||||
if borderless {
|
|
||||||
if let Some(class) = class_re {
|
|
||||||
hypr::apply_borderless_rules(&class).await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
|
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
|
||||||
tracing::info!("ipc {}", sock.display());
|
tracing::info!("ipc {}", sock.display());
|
||||||
@ -88,55 +65,34 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
{
|
tokio::spawn(vfx::capture_loop(vfx_rx));
|
||||||
let hub = hub.clone();
|
|
||||||
tokio::spawn(vfx::capture_loop(vfx_rx, hub));
|
|
||||||
}
|
|
||||||
|
|
||||||
let s2 = session.clone();
|
let s2 = session.clone();
|
||||||
let hub2 = hub.clone();
|
|
||||||
let slot_hub2 = slot_hub.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
if let Err(e) = refresh_slots(&s2, &vfx_tx, &hub2, &slot_hub2).await {
|
if let Err(e) = refresh_slots(&s2, &vfx_tx).await {
|
||||||
tracing::warn!("refresh: {e}");
|
tracing::debug!("refresh: {e}");
|
||||||
}
|
}
|
||||||
sleep(Duration::from_millis(400)).await;
|
sleep(Duration::from_millis(400)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let s3 = session.clone();
|
let s3 = session.clone();
|
||||||
let events = hypr::listen_events(move |line| {
|
hypr::listen_events(move |line| {
|
||||||
let s = s3.clone();
|
let s = s3.clone();
|
||||||
async move {
|
async move {
|
||||||
on_event(&s, &line).await;
|
on_event(&s, &line).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.await
|
||||||
tokio::select! {
|
|
||||||
r = events => {
|
|
||||||
tracing::warn!("hyprland event socket closed: {r:?}");
|
|
||||||
}
|
|
||||||
_ = tokio::signal::ctrl_c() => {
|
|
||||||
tracing::info!("ctrl-c");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = hypr::clear_binds().await;
|
|
||||||
hub.kill_all().await;
|
|
||||||
slot_hub.lock().await.kill_all();
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_slots(
|
async fn refresh_slots(
|
||||||
session: &Arc<Mutex<Session>>,
|
session: &Arc<Mutex<Session>>,
|
||||||
vfx_tx: &watch::Sender<Vec<FeedHit>>,
|
vfx_tx: &watch::Sender<Vec<FeedHit>>,
|
||||||
hub: &Arc<vfx::OverlayHub>,
|
|
||||||
slot_hub: &Arc<Mutex<OverlayHub>>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let clients = hypr::clients().await?;
|
let clients = hypr::clients().await?;
|
||||||
let aw = hypr::active_window().await.ok().flatten();
|
|
||||||
let cursor = hypr::cursor_pos().await.ok();
|
|
||||||
let mut g = session.lock().await;
|
let mut g = session.lock().await;
|
||||||
let class_re = g
|
let class_re = g
|
||||||
.engine
|
.engine
|
||||||
@ -153,11 +109,7 @@ async fn refresh_slots(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|p| Regex::new(p).ok());
|
.and_then(|p| Regex::new(p).ok());
|
||||||
|
|
||||||
let has_filter = class_re.is_some() || title_re.is_some();
|
let mut matched: Vec<Client> = clients
|
||||||
let mut matched: Vec<Client> = if !has_filter {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
clients
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
if c.class == "enboxer-vfx" {
|
if c.class == "enboxer-vfx" {
|
||||||
@ -173,8 +125,7 @@ async fn refresh_slots(
|
|||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
class_ok && title_ok && c.mapped && !c.hidden
|
class_ok && title_ok && c.mapped && !c.hidden
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
};
|
|
||||||
matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
|
matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
|
||||||
|
|
||||||
let n = g.engine.profile.slots as usize;
|
let n = g.engine.profile.slots as usize;
|
||||||
@ -200,7 +151,7 @@ async fn refresh_slots(
|
|||||||
}
|
}
|
||||||
g.slots = new_slots;
|
g.slots = new_slots;
|
||||||
|
|
||||||
if let Some(aw) = aw {
|
if let Ok(Some(aw)) = hypr::active_window().await {
|
||||||
let leader = g
|
let leader = g
|
||||||
.slots
|
.slots
|
||||||
.iter()
|
.iter()
|
||||||
@ -212,76 +163,40 @@ async fn refresh_slots(
|
|||||||
let managed = g.slots.iter().any(|(_, c)| c.address == aw.address)
|
let managed = g.slots.iter().any(|(_, c)| c.address == aw.address)
|
||||||
|| aw.class == "enboxer-vfx"
|
|| aw.class == "enboxer-vfx"
|
||||||
|| aw.title.starts_with("enboxer-vfx");
|
|| aw.title.starts_with("enboxer-vfx");
|
||||||
if managed {
|
if managed && g.engine.mode != Mode::Disabled {
|
||||||
if !g.binds_on {
|
if !g.binds_on {
|
||||||
let specs = bind_specs(&g)?;
|
install_binds(&mut g).await?;
|
||||||
let sock = g.sock.display().to_string();
|
|
||||||
drop(g);
|
|
||||||
hypr::replace_binds(&specs, &sock).await?;
|
|
||||||
let mut g = session.lock().await;
|
|
||||||
g.binds_on = true;
|
|
||||||
tracing::info!("routing on ({} binds)", specs.len());
|
|
||||||
return finish_vfx(g, vfx_tx, hub, cursor).await;
|
|
||||||
}
|
}
|
||||||
} else if g.binds_on {
|
} else if g.binds_on {
|
||||||
|
hypr::clear_binds().await.ok();
|
||||||
g.binds_on = false;
|
g.binds_on = false;
|
||||||
g.vfx_source = None;
|
g.vfx_source = None;
|
||||||
drop(g);
|
|
||||||
hypr::clear_binds().await.ok();
|
|
||||||
tracing::info!("routing off (focus left the team)");
|
tracing::info!("routing off (focus left the team)");
|
||||||
let g = session.lock().await;
|
|
||||||
return finish_vfx(g, vfx_tx, hub, cursor).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finish_vfx(g, vfx_tx, hub, cursor).await?;
|
if let Some((_, primary)) = g
|
||||||
sync_slot_overlays(session, slot_hub).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keep the slot-number overlay hub in sync with the current slot set. No-op
|
|
||||||
/// when `ENBOXER_ENABLE_OVERLAY` is not set; otherwise the spawn stub logs.
|
|
||||||
async fn sync_slot_overlays(
|
|
||||||
session: &Arc<Mutex<Session>>,
|
|
||||||
slot_hub: &Arc<Mutex<OverlayHub>>,
|
|
||||||
) {
|
|
||||||
let slots = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
g.slots.clone()
|
|
||||||
};
|
|
||||||
let mut hub = slot_hub.lock().await;
|
|
||||||
hub.sync(&slots);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn finish_vfx(
|
|
||||||
mut g: tokio::sync::MutexGuard<'_, Session>,
|
|
||||||
vfx_tx: &watch::Sender<Vec<FeedHit>>,
|
|
||||||
hub: &Arc<vfx::OverlayHub>,
|
|
||||||
cursor: Option<(i32, i32)>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let feeds = if let Some((_, primary)) = g
|
|
||||||
.slots
|
.slots
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(s, _)| *s == g.engine.leader_slot)
|
.find(|(s, _)| *s == g.engine.leader_slot)
|
||||||
.cloned()
|
.cloned()
|
||||||
{
|
{
|
||||||
vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots)
|
let feeds = vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots);
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
g.vfx = feeds.clone();
|
g.vfx = feeds.clone();
|
||||||
if let Some((x, y)) = cursor {
|
let _ = vfx_tx.send(feeds.clone());
|
||||||
|
for f in &feeds {
|
||||||
|
vfx::ensure_overlay_window(f).await.ok();
|
||||||
|
}
|
||||||
|
if let Ok((x, y)) = hypr::cursor_pos().await {
|
||||||
let src = vfx::hit_test(&g.vfx, x, y)
|
let src = vfx::hit_test(&g.vfx, x, y)
|
||||||
.filter(|h| h.pass_through)
|
.filter(|h| h.pass_through)
|
||||||
.map(|h| h.source_slot);
|
.map(|h| h.source_slot);
|
||||||
if src != g.vfx_source {
|
if src != g.vfx_source {
|
||||||
g.vfx_source = src;
|
g.vfx_source = src;
|
||||||
g.binds_on = false;
|
g.binds_on = false; // reinstall: VFX hover grabs extra keys
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(g);
|
|
||||||
let _ = vfx_tx.send(feeds.clone());
|
|
||||||
hub.sync(&feeds).await.ok();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,7 +221,7 @@ async fn on_event(session: &Arc<Mutex<Session>>, line: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bind_specs(g: &Session) -> Result<Vec<BindSpec>> {
|
async fn install_binds(g: &mut Session) -> Result<()> {
|
||||||
let passthrough = g.engine.profile.passthrough_set()?;
|
let passthrough = g.engine.profile.passthrough_set()?;
|
||||||
let mut specs = Vec::new();
|
let mut specs = Vec::new();
|
||||||
let bin = g.exe.display().to_string();
|
let bin = g.exe.display().to_string();
|
||||||
@ -319,7 +234,7 @@ fn bind_specs(g: &Session) -> Result<Vec<BindSpec>> {
|
|||||||
specs.push(BindSpec {
|
specs.push(BindSpec {
|
||||||
bind: parsed.hypr_bind(),
|
bind: parsed.hypr_bind(),
|
||||||
ipc_bin: bin.clone(),
|
ipc_bin: bin.clone(),
|
||||||
ipc_args: format!("hotkey {}", m.hotkey.0),
|
ipc_args: format!("ipc hotkey {}", m.hotkey.0),
|
||||||
non_consuming: false,
|
non_consuming: false,
|
||||||
release: false,
|
release: false,
|
||||||
});
|
});
|
||||||
@ -327,112 +242,41 @@ fn bind_specs(g: &Session) -> Result<Vec<BindSpec>> {
|
|||||||
specs.push(BindSpec {
|
specs.push(BindSpec {
|
||||||
bind: parsed.hypr_bind(),
|
bind: parsed.hypr_bind(),
|
||||||
ipc_bin: bin.clone(),
|
ipc_bin: bin.clone(),
|
||||||
ipc_args: format!("hotkey-release {}", m.hotkey.0),
|
ipc_args: format!("ipc hotkey-release {}", m.hotkey.0),
|
||||||
non_consuming: false,
|
non_consuming: false,
|
||||||
release: true,
|
release: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if g.engine.mode == Mode::Mirror {
|
if g.engine.mode == Mode::Repeater {
|
||||||
let keys = if g.engine.profile.repeater.keys.is_empty() {
|
for k in &g.engine.profile.repeater.keys {
|
||||||
clone_key_set()
|
let id = passthrough_id(k).unwrap_or_else(|_| k.clone());
|
||||||
} else {
|
|
||||||
g.engine.profile.repeater.keys.clone()
|
|
||||||
};
|
|
||||||
for k in keys {
|
|
||||||
let id = passthrough_id(&k).unwrap_or_else(|_| k.clone());
|
|
||||||
if passthrough.contains(&id) {
|
if passthrough.contains(&id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if g.engine.profile.map_by_hotkey(&k).is_some() {
|
if g.engine.profile.map_by_hotkey(k).is_some() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let parsed = hotkey::parse(&k)?;
|
let parsed = hotkey::parse(k)?;
|
||||||
specs.push(BindSpec {
|
specs.push(BindSpec {
|
||||||
bind: parsed.hypr_bind(),
|
bind: parsed.hypr_bind(),
|
||||||
ipc_bin: bin.clone(),
|
ipc_bin: bin.clone(),
|
||||||
ipc_args: format!("hotkey {k}"),
|
ipc_args: format!("ipc hotkey {k}"),
|
||||||
non_consuming: true,
|
non_consuming: true,
|
||||||
release: false,
|
release: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let wm = [
|
if let Some(cycle) = g.engine.profile.session_hotkeys.get("mode_cycle") {
|
||||||
("mode_cycle", "mode-cycle"),
|
let parsed = hotkey::parse(cycle)?;
|
||||||
("swap_next", "swap-next"),
|
|
||||||
("swap_prev", "swap-prev"),
|
|
||||||
("focus_next", "focus-next"),
|
|
||||||
("focus_prev", "focus-prev"),
|
|
||||||
("focus_main", "focus-main"),
|
|
||||||
("reset_all", "reset-all"),
|
|
||||||
("stay_on_top", "stay-on-top"),
|
|
||||||
("mouse_follow", "mouse-follow"),
|
|
||||||
("mouse_broadcast", "mouse-broadcast"),
|
|
||||||
];
|
|
||||||
for (name, ipc) in wm {
|
|
||||||
if let Some(hk) = g.engine.profile.session_hotkeys.get(name) {
|
|
||||||
let parsed = hotkey::parse(hk)?;
|
|
||||||
specs.push(BindSpec {
|
specs.push(BindSpec {
|
||||||
bind: parsed.hypr_bind(),
|
bind: parsed.hypr_bind(),
|
||||||
ipc_bin: bin.clone(),
|
ipc_bin: bin.clone(),
|
||||||
ipc_args: ipc.into(),
|
ipc_args: "ipc mode-cycle".into(),
|
||||||
non_consuming: false,
|
non_consuming: false,
|
||||||
release: false,
|
release: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for i in 1..=g.engine.profile.slots {
|
|
||||||
let focus = format!("Ctrl+F{i}");
|
|
||||||
let swap = format!("Ctrl+Shift+F{i}");
|
|
||||||
if let Ok(p) = hotkey::parse(&focus) {
|
|
||||||
specs.push(BindSpec {
|
|
||||||
bind: p.hypr_bind(),
|
|
||||||
ipc_bin: bin.clone(),
|
|
||||||
ipc_args: format!("focus {i}"),
|
|
||||||
non_consuming: false,
|
|
||||||
release: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if i > 1 {
|
|
||||||
if let Ok(p) = hotkey::parse(&swap) {
|
|
||||||
specs.push(BindSpec {
|
|
||||||
bind: p.hypr_bind(),
|
|
||||||
ipc_bin: bin.clone(),
|
|
||||||
ipc_args: format!("swap {i}"),
|
|
||||||
non_consuming: false,
|
|
||||||
release: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if g.mouse_broadcast {
|
|
||||||
for btn in ["mouse:272", "mouse:273"] {
|
|
||||||
specs.push(BindSpec {
|
|
||||||
bind: btn.to_string(),
|
|
||||||
ipc_bin: bin.clone(),
|
|
||||||
ipc_args: format!("mouse-click {btn}"),
|
|
||||||
non_consuming: true,
|
|
||||||
release: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if g.engine.mode == Mode::Mirror {
|
|
||||||
// Mirror mode always broadcasts clicks to other captured windows
|
|
||||||
// (T13). We install the binds independently of `mouse_broadcast`
|
|
||||||
// so the behaviour is automatic for the operator.
|
|
||||||
for btn in ["mouse:272", "mouse:273"] {
|
|
||||||
if specs.iter().any(|s| s.bind == btn) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
specs.push(BindSpec {
|
|
||||||
bind: btn.to_string(),
|
|
||||||
ipc_bin: bin.clone(),
|
|
||||||
ipc_args: format!("mirror-click {btn}"),
|
|
||||||
non_consuming: true,
|
|
||||||
release: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if g.vfx_source.is_some() {
|
if g.vfx_source.is_some() {
|
||||||
for k in vfx_hover_keys() {
|
for k in vfx_hover_keys() {
|
||||||
if specs.iter().any(|s| s.bind == k) {
|
if specs.iter().any(|s| s.bind == k) {
|
||||||
@ -441,13 +285,16 @@ fn bind_specs(g: &Session) -> Result<Vec<BindSpec>> {
|
|||||||
specs.push(BindSpec {
|
specs.push(BindSpec {
|
||||||
bind: k.clone(),
|
bind: k.clone(),
|
||||||
ipc_bin: bin.clone(),
|
ipc_bin: bin.clone(),
|
||||||
ipc_args: format!("vfxkey {k}"),
|
ipc_args: format!("ipc vfxkey {k}"),
|
||||||
non_consuming: false,
|
non_consuming: false,
|
||||||
release: false,
|
release: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(specs)
|
hypr::replace_binds(&specs, &g.sock.display().to_string()).await?;
|
||||||
|
g.binds_on = true;
|
||||||
|
tracing::info!("routing on ({} binds)", specs.len());
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_ipc(session: Arc<Mutex<Session>>, stream: tokio::net::UnixStream) -> Result<()> {
|
async fn handle_ipc(session: Arc<Mutex<Session>>, stream: tokio::net::UnixStream) -> Result<()> {
|
||||||
@ -470,24 +317,20 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
|
|||||||
"status" => {
|
"status" => {
|
||||||
let g = session.lock().await;
|
let g = session.lock().await;
|
||||||
format!(
|
format!(
|
||||||
"mode={} leader={} binds={} slots={} mouse_broadcast={} mouse_follow={} vfx_source={:?}",
|
"mode={} leader={} binds={} slots={} vfx_source={:?}",
|
||||||
g.engine.mode.as_str(),
|
g.engine.mode.as_str(),
|
||||||
g.engine.leader_slot,
|
g.engine.leader_slot,
|
||||||
g.binds_on,
|
g.binds_on,
|
||||||
g.slots.len(),
|
g.slots.len(),
|
||||||
g.mouse_broadcast,
|
|
||||||
g.mouse_follow,
|
|
||||||
g.vfx_source
|
g.vfx_source
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"mode-cycle" => match set_mode(session, None).await {
|
"mode-cycle" => {
|
||||||
Ok(m) => format!("mode={}", m.as_str()),
|
let mut g = session.lock().await;
|
||||||
Err(e) => format!("err {e}"),
|
let m = g.engine.cycle_mode();
|
||||||
},
|
g.binds_on = false;
|
||||||
"mode" => match set_mode(session, Some(arg)).await {
|
format!("mode={}", m.as_str())
|
||||||
Ok(m) => format!("mode={}", m.as_str()),
|
}
|
||||||
Err(e) => format!("err {e}"),
|
|
||||||
},
|
|
||||||
"hotkey" => match fire(session, arg, Hold::Tap).await {
|
"hotkey" => match fire(session, arg, Hold::Tap).await {
|
||||||
Ok(()) => "ok".into(),
|
Ok(()) => "ok".into(),
|
||||||
Err(e) => format!("err {e}"),
|
Err(e) => format!("err {e}"),
|
||||||
@ -512,394 +355,10 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"swap-next" => wm_ok(swap_next(session).await),
|
|
||||||
"swap-prev" => wm_ok(swap_prev(session).await),
|
|
||||||
"swap" => match arg.parse::<u32>() {
|
|
||||||
Ok(n) => wm_ok(swap_as_main(session, n).await),
|
|
||||||
Err(_) => "err bad slot".into(),
|
|
||||||
},
|
|
||||||
"focus-next" => wm_ok(focus_step(session, 1).await),
|
|
||||||
"focus-prev" => wm_ok(focus_step(session, -1).await),
|
|
||||||
"focus-main" => wm_ok(focus_slot(session, 1).await),
|
|
||||||
"focus" => match arg.parse::<u32>() {
|
|
||||||
Ok(n) => wm_ok(focus_slot(session, n).await),
|
|
||||||
Err(_) => "err bad slot".into(),
|
|
||||||
},
|
|
||||||
"reset-all" => wm_ok(reset_layout(session).await),
|
|
||||||
"stay-on-top" => wm_ok(toggle_pin(session).await),
|
|
||||||
"mouse-follow" => wm_ok(toggle_mouse_follow(session).await),
|
|
||||||
"mouse-broadcast" => wm_ok(toggle_mouse_broadcast(session).await),
|
|
||||||
"mouse-click" => {
|
|
||||||
let btn = arg
|
|
||||||
.strip_prefix("mouse:")
|
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
.unwrap_or(272);
|
|
||||||
wm_ok(broadcast_click(session, btn).await)
|
|
||||||
}
|
|
||||||
"mirror-click" => {
|
|
||||||
let btn = arg
|
|
||||||
.strip_prefix("mouse:")
|
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
.unwrap_or(272);
|
|
||||||
wm_ok(broadcast_mirror_click(session, btn).await)
|
|
||||||
}
|
|
||||||
"type" => wm_ok(type_to_others(session, arg).await),
|
|
||||||
"clipboard" => wm_ok(clipboard_to_others(session).await),
|
|
||||||
_ => format!("err unknown {cmd}"),
|
_ => format!("err unknown {cmd}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_mode(session: &Arc<Mutex<Session>>, name: Option<&str>) -> Result<Mode> {
|
|
||||||
let aw = hypr::active_window().await.ok().flatten();
|
|
||||||
let (mode, specs, sock, managed) = {
|
|
||||||
let mut g = session.lock().await;
|
|
||||||
let mode = if let Some(n) = name {
|
|
||||||
Mode::parse_name(n).ok_or_else(|| anyhow::anyhow!("unknown mode {n}"))?
|
|
||||||
} else {
|
|
||||||
g.engine.cycle_mode()
|
|
||||||
};
|
|
||||||
g.engine.mode = mode;
|
|
||||||
let managed = aw.as_ref().is_some_and(|aw| {
|
|
||||||
g.slots.iter().any(|(_, c)| c.address == aw.address)
|
|
||||||
|| aw.class == "enboxer-vfx"
|
|
||||||
|| aw.title.starts_with("enboxer-vfx")
|
|
||||||
});
|
|
||||||
let specs = if managed { Some(bind_specs(&g)?) } else { None };
|
|
||||||
let sock = g.sock.display().to_string();
|
|
||||||
(mode, specs, sock, managed)
|
|
||||||
};
|
|
||||||
if let Some(specs) = specs {
|
|
||||||
hypr::replace_binds(&specs, &sock).await?;
|
|
||||||
let mut g = session.lock().await;
|
|
||||||
g.binds_on = managed;
|
|
||||||
}
|
|
||||||
hypr::notify(&format!("enBoxer: {}", mode.label()))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
tracing::info!("mode {}", mode.as_str());
|
|
||||||
Ok(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wm_ok(r: Result<()>) -> String {
|
|
||||||
match r {
|
|
||||||
Ok(()) => "ok".into(),
|
|
||||||
Err(e) => format!("err {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn swap_next(session: &Arc<Mutex<Session>>) -> Result<()> {
|
|
||||||
let n = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
let len = g.slots.len() as u32;
|
|
||||||
if len < 2 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let cur = g.engine.leader_slot;
|
|
||||||
if cur >= len {
|
|
||||||
2
|
|
||||||
} else {
|
|
||||||
cur + 1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
swap_as_main(session, n.max(2)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn swap_prev(session: &Arc<Mutex<Session>>) -> Result<()> {
|
|
||||||
let n = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
let len = g.slots.len() as u32;
|
|
||||||
if len < 2 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let cur = g.engine.leader_slot;
|
|
||||||
if cur <= 2 {
|
|
||||||
len
|
|
||||||
} else {
|
|
||||||
cur - 1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
swap_as_main(session, n).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn swap_as_main(session: &Arc<Mutex<Session>>, n: u32) -> Result<()> {
|
|
||||||
let (wins, tiles) = {
|
|
||||||
let mut g = session.lock().await;
|
|
||||||
if n < 2 || n as usize > g.slots.len() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
g.slots.swap(0, n as usize - 1);
|
|
||||||
for (i, (id, _)) in g.slots.iter_mut().enumerate() {
|
|
||||||
*id = i as u32 + 1;
|
|
||||||
}
|
|
||||||
g.engine.set_leader(1);
|
|
||||||
(g.slots.clone(), g.engine.profile.layout.slots.clone())
|
|
||||||
};
|
|
||||||
if !tiles.is_empty() {
|
|
||||||
crate::layout::apply(&tiles, &wins).await?;
|
|
||||||
}
|
|
||||||
if let Some((_, c)) = wins.first() {
|
|
||||||
hypr::focus_window(&c.address_selector()).await.ok();
|
|
||||||
}
|
|
||||||
hypr::notify(&format!("main is slot 1 (was {n})"))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn focus_step(session: &Arc<Mutex<Session>>, dir: i32) -> Result<()> {
|
|
||||||
let n = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
let len = g.slots.len() as i32;
|
|
||||||
if len == 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let cur = g.engine.leader_slot as i32;
|
|
||||||
let next = ((cur - 1 + dir).rem_euclid(len)) + 1;
|
|
||||||
next as u32
|
|
||||||
};
|
|
||||||
focus_slot(session, n).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn focus_slot(session: &Arc<Mutex<Session>>, n: u32) -> Result<()> {
|
|
||||||
let addr = {
|
|
||||||
let mut g = session.lock().await;
|
|
||||||
g.engine.set_leader(n);
|
|
||||||
g.slots
|
|
||||||
.iter()
|
|
||||||
.find(|(s, _)| *s == n)
|
|
||||||
.map(|(_, c)| c.address_selector())
|
|
||||||
};
|
|
||||||
if let Some(a) = addr {
|
|
||||||
hypr::focus_window(&a).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn reset_layout(session: &Arc<Mutex<Session>>) -> Result<()> {
|
|
||||||
let (wins, tiles) = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
(g.slots.clone(), g.engine.profile.layout.slots.clone())
|
|
||||||
};
|
|
||||||
if tiles.is_empty() {
|
|
||||||
anyhow::bail!("no saved layout tiles");
|
|
||||||
}
|
|
||||||
crate::layout::apply(&tiles, &wins).await?;
|
|
||||||
hypr::notify("layout reset").await.ok();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn toggle_pin(session: &Arc<Mutex<Session>>) -> Result<()> {
|
|
||||||
let addr = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
g.slots
|
|
||||||
.iter()
|
|
||||||
.find(|(s, _)| *s == g.engine.leader_slot)
|
|
||||||
.map(|(_, c)| c.address_selector())
|
|
||||||
};
|
|
||||||
if let Some(a) = addr {
|
|
||||||
hypr::dispatch_lua(&format!(
|
|
||||||
"hl.dsp.window.pin({{ window = {a:?}, action = \"toggle\" }})"
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn toggle_mouse_follow(session: &Arc<Mutex<Session>>) -> Result<()> {
|
|
||||||
let on = {
|
|
||||||
let mut g = session.lock().await;
|
|
||||||
g.mouse_follow = !g.mouse_follow;
|
|
||||||
g.mouse_follow
|
|
||||||
};
|
|
||||||
let v = if on { 1 } else { 0 };
|
|
||||||
let _ = hypr::hyprctl(["keyword", "input:follow_mouse", &v.to_string()]).await;
|
|
||||||
hypr::notify(&format!(
|
|
||||||
"focus follows mouse {}",
|
|
||||||
if on { "on" } else { "off" }
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn toggle_mouse_broadcast(session: &Arc<Mutex<Session>>) -> Result<()> {
|
|
||||||
{
|
|
||||||
let mut g = session.lock().await;
|
|
||||||
g.mouse_broadcast = !g.mouse_broadcast;
|
|
||||||
g.binds_on = false;
|
|
||||||
hypr::notify(&format!(
|
|
||||||
"mouse broadcast {}",
|
|
||||||
if g.mouse_broadcast { "on" } else { "off" }
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn type_to_others(session: &Arc<Mutex<Session>>, text: &str) -> Result<()> {
|
|
||||||
let others = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
others_clients(&g.slots, g.engine.leader_slot)
|
|
||||||
};
|
|
||||||
for key in crate::hotkey::type_keys(text) {
|
|
||||||
for c in &others {
|
|
||||||
hypr::deliver_key(c, &key, None).await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure: the captured slots that are NOT the current leader. Both `type_to_others`
|
|
||||||
/// and `clipboard_to_others` route through this so the "exclude leader" shape is
|
|
||||||
/// testable without a session.
|
|
||||||
pub fn others_clients(slots: &[(u32, Client)], leader: u32) -> Vec<Client> {
|
|
||||||
slots
|
|
||||||
.iter()
|
|
||||||
.filter(|(s, _)| *s != leader)
|
|
||||||
.map(|(_, c)| c.clone())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure: the non-leader captured slots that should receive a mirror-mode
|
|
||||||
/// mouse click for `button`. The leader already got it (the user clicked
|
|
||||||
/// on the primary). Returns `(target_client, button)` pairs in slot-number
|
|
||||||
/// order so the daemon's click loop is deterministic. The X/Y of the
|
|
||||||
/// click are forwarded from the cursor position in
|
|
||||||
/// `broadcast_mirror_click`.
|
|
||||||
///
|
|
||||||
/// Press-and-hold guard is OUT OF SCOPE for T13 (Master has not requested
|
|
||||||
/// it yet); the caller should hold the mouse binds as a follow-up.
|
|
||||||
/// T13-todo: press-and-hold guard — repeated button-down without release
|
|
||||||
/// should re-fire on a configurable cadence.
|
|
||||||
pub fn mirror_clicks_to(
|
|
||||||
slots: &[(u32, Client)],
|
|
||||||
leader: u32,
|
|
||||||
button: u32,
|
|
||||||
) -> Vec<(Client, u32)> {
|
|
||||||
slots
|
|
||||||
.iter()
|
|
||||||
.filter(|(s, _)| *s != leader)
|
|
||||||
.map(|(_, c)| (c.clone(), button))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn clipboard_to_others(session: &Arc<Mutex<Session>>) -> Result<()> {
|
|
||||||
let text = read_clipboard().await?;
|
|
||||||
let others = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
others_clients(&g.slots, g.engine.leader_slot)
|
|
||||||
};
|
|
||||||
for c in &others {
|
|
||||||
hypr::deliver_key(c, "Ctrl+v", None).await.ok();
|
|
||||||
}
|
|
||||||
tracing::info!("clipboard broadcast: {} bytes to {} slot(s)", text.len(), others.len());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_clipboard() -> Result<String> {
|
|
||||||
use std::process::Stdio;
|
|
||||||
use tokio::process::Command;
|
|
||||||
for cmd in ["wl-paste", "xclip"] {
|
|
||||||
let args: &[&str] = if cmd == "xclip" {
|
|
||||||
&["-o", "-selection", "clipboard"]
|
|
||||||
} else {
|
|
||||||
&["-n"]
|
|
||||||
};
|
|
||||||
let out = Command::new(cmd)
|
|
||||||
.args(args)
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
if let Ok(out) = out {
|
|
||||||
if out.status.success() {
|
|
||||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
anyhow::bail!("no clipboard tool (install wl-paste or xclip)")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn broadcast_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
|
|
||||||
let (cx, cy) = hypr::cursor_pos().await?;
|
|
||||||
let others = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
if !g.mouse_broadcast {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let Some((_, primary)) = g.slots.iter().find(|(s, _)| *s == g.engine.leader_slot) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let pw = primary.size[0].max(1) as f64;
|
|
||||||
let ph = primary.size[1].max(1) as f64;
|
|
||||||
let nx = (cx - primary.at[0]) as f64 / pw;
|
|
||||||
let ny = (cy - primary.at[1]) as f64 / ph;
|
|
||||||
g.slots
|
|
||||||
.iter()
|
|
||||||
.filter(|(s, _)| *s != g.engine.leader_slot)
|
|
||||||
.map(|(_, c)| {
|
|
||||||
let x = c.at[0] + (nx * c.size[0] as f64).round() as i32;
|
|
||||||
let y = c.at[1] + (ny * c.size[1] as f64).round() as i32;
|
|
||||||
(c.clone(), x, y)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
};
|
|
||||||
for (c, x, y) in others {
|
|
||||||
hypr::move_cursor(x, y).await.ok();
|
|
||||||
hypr::deliver_click(&c, button).await.ok();
|
|
||||||
}
|
|
||||||
hypr::move_cursor(cx, cy).await.ok();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mirror-mode click broadcast: when `mode == Mirror`, every click on the
|
|
||||||
/// primary is also delivered to every other captured slot at the
|
|
||||||
/// matching relative position. Independent of the `mouse_broadcast`
|
|
||||||
/// toggle — mirror mode always does this. The cursor moves to each
|
|
||||||
/// target's relative position, the click is delivered (focus, send,
|
|
||||||
/// restore via `hypr::deliver_click` for native Wayland; direct for
|
|
||||||
/// XWayland), and the cursor returns to where the user clicked.
|
|
||||||
async fn broadcast_mirror_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
|
|
||||||
let (cx, cy, plan) = {
|
|
||||||
let g = session.lock().await;
|
|
||||||
if g.engine.mode != Mode::Mirror {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let Some((_, primary)) = g.slots.iter().find(|(s, _)| *s == g.engine.leader_slot) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let cx = hypr::cursor_pos().await?.0;
|
|
||||||
let cy = hypr::cursor_pos().await?.1;
|
|
||||||
let pw = primary.size[0].max(1) as f64;
|
|
||||||
let ph = primary.size[1].max(1) as f64;
|
|
||||||
let nx = (cx - primary.at[0]) as f64 / pw;
|
|
||||||
let ny = (cy - primary.at[1]) as f64 / ph;
|
|
||||||
let plan: Vec<(Client, i32, i32)> = mirror_clicks_to(&g.slots, g.engine.leader_slot, button)
|
|
||||||
.into_iter()
|
|
||||||
.map(|(c, _btn)| {
|
|
||||||
let x = c.at[0] + (nx * c.size[0] as f64).round() as i32;
|
|
||||||
let y = c.at[1] + (ny * c.size[1] as f64).round() as i32;
|
|
||||||
(c, x, y)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
(cx, cy, plan)
|
|
||||||
};
|
|
||||||
for (c, x, y) in plan {
|
|
||||||
hypr::move_cursor(x, y).await.ok();
|
|
||||||
hypr::deliver_click(&c, button).await.ok();
|
|
||||||
}
|
|
||||||
hypr::move_cursor(cx, cy).await.ok();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_key_set() -> Vec<String> {
|
|
||||||
vfx_hover_keys()
|
|
||||||
.into_iter()
|
|
||||||
.filter(|k| !k.starts_with("mouse:"))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn vfx_hover_keys() -> Vec<String> {
|
fn vfx_hover_keys() -> Vec<String> {
|
||||||
let mut v: Vec<String> = [
|
let mut v: Vec<String> = [
|
||||||
"Space",
|
"Space",
|
||||||
@ -946,33 +405,32 @@ async fn fire_vfx(session: &Arc<Mutex<Session>>, hotkey: &str) -> Result<()> {
|
|||||||
|
|
||||||
async fn fire_vfx_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
|
async fn fire_vfx_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
|
||||||
let (cx, cy) = hypr::cursor_pos().await?;
|
let (cx, cy) = hypr::cursor_pos().await?;
|
||||||
let (feed, src) = {
|
let (feed, src_addr) = {
|
||||||
let g = session.lock().await;
|
let g = session.lock().await;
|
||||||
let slot = g
|
let src = g
|
||||||
.vfx_source
|
.vfx_source
|
||||||
.ok_or_else(|| anyhow::anyhow!("no vfx hover"))?;
|
.ok_or_else(|| anyhow::anyhow!("no vfx hover"))?;
|
||||||
let feed = vfx::hit_test(&g.vfx, cx, cy)
|
let feed = vfx::hit_test(&g.vfx, cx, cy)
|
||||||
.filter(|h| h.pass_through && h.source_slot == slot)
|
.filter(|h| h.pass_through && h.source_slot == src)
|
||||||
.ok_or_else(|| anyhow::anyhow!("vfx hover lost"))?
|
.ok_or_else(|| anyhow::anyhow!("vfx hover lost"))?
|
||||||
.clone();
|
.clone();
|
||||||
let src = g
|
let addr = g
|
||||||
.slots
|
.slots
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(s, _)| *s == slot)
|
.find(|(s, _)| *s == src)
|
||||||
.map(|(_, c)| c.clone())
|
.map(|(_, c)| c.address_selector())
|
||||||
.ok_or_else(|| anyhow::anyhow!("source slot gone"))?;
|
.ok_or_else(|| anyhow::anyhow!("source slot gone"))?;
|
||||||
(feed, src)
|
(feed, addr)
|
||||||
};
|
};
|
||||||
let (sx, sy) = vfx::map_click(&feed, cx, cy);
|
let (sx, sy) = vfx::map_click(&feed, cx, cy);
|
||||||
hypr::move_cursor(sx, sy).await?;
|
hypr::move_cursor(sx, sy).await?;
|
||||||
hypr::deliver_click(&src, button).await?;
|
hypr::send_mouse_click(&src_addr, button).await?;
|
||||||
hypr::move_cursor(cx, cy).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fire(session: &Arc<Mutex<Session>>, hotkey: &str, edge: Hold) -> Result<()> {
|
async fn fire(session: &Arc<Mutex<Session>>, hotkey: &str, edge: Hold) -> Result<()> {
|
||||||
let (actions, slots) = {
|
let (actions, slots) = {
|
||||||
let mut g = session.lock().await;
|
let g = session.lock().await;
|
||||||
if let Some(src) = g.vfx_source {
|
if let Some(src) = g.vfx_source {
|
||||||
if g.engine.profile.map_by_hotkey(hotkey).is_none()
|
if g.engine.profile.map_by_hotkey(hotkey).is_none()
|
||||||
&& g.engine
|
&& g.engine
|
||||||
@ -1020,7 +478,7 @@ async fn execute(actions: Vec<Action>, slots: &[(u32, Client)]) -> Result<()> {
|
|||||||
};
|
};
|
||||||
for id in ids {
|
for id in ids {
|
||||||
if let Some((_, c)) = slots.iter().find(|(s, _)| *s == id) {
|
if let Some((_, c)) = slots.iter().find(|(s, _)| *s == id) {
|
||||||
hypr::deliver_key(c, &key, parsed_state).await?;
|
hypr::send_key(&c.address_selector(), &key, parsed_state).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1045,123 +503,3 @@ pub async fn ipc_send(sock: &Path, line: &str) -> Result<String> {
|
|||||||
pub fn default_sock() -> PathBuf {
|
pub fn default_sock() -> PathBuf {
|
||||||
runtime_dir().join("enboxer.sock")
|
runtime_dir().join("enboxer.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::hypr::Client;
|
|
||||||
|
|
||||||
fn client(addr: &str, at: (i32, i32)) -> Client {
|
|
||||||
Client {
|
|
||||||
address: addr.into(),
|
|
||||||
class: "wow".into(),
|
|
||||||
title: addr.into(),
|
|
||||||
pid: 1,
|
|
||||||
at: [at.0, at.1],
|
|
||||||
size: [800, 600],
|
|
||||||
mapped: true,
|
|
||||||
hidden: false,
|
|
||||||
xwayland: true,
|
|
||||||
focus_history_id: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn others_excludes_leader() {
|
|
||||||
let slots = vec![
|
|
||||||
(1, client("0xa", (0, 0))),
|
|
||||||
(2, client("0xb", (1000, 0))),
|
|
||||||
(3, client("0xc", (0, 1000))),
|
|
||||||
];
|
|
||||||
let others = others_clients(&slots, 2);
|
|
||||||
assert_eq!(others.len(), 2);
|
|
||||||
assert_eq!(others[0].address, "0xa");
|
|
||||||
assert_eq!(others[1].address, "0xc");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn others_is_empty_when_only_leader() {
|
|
||||||
let slots = vec![(1, client("0xa", (0, 0)))];
|
|
||||||
assert!(others_clients(&slots, 1).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn type_routing_plan_covers_every_other_slot_per_char() {
|
|
||||||
// 2 others, clipboard-shaped text "Hi\n" (3 type_keys after expansion).
|
|
||||||
let slots = vec![
|
|
||||||
(1, client("0xa", (0, 0))),
|
|
||||||
(2, client("0xb", (1000, 0))),
|
|
||||||
(3, client("0xc", (0, 1000))),
|
|
||||||
];
|
|
||||||
let text = "Hi\n";
|
|
||||||
let keys = crate::hotkey::type_keys(text);
|
|
||||||
assert_eq!(keys, vec!["Shift+h".to_string(), "i".into(), "Return".into()]);
|
|
||||||
let plan: Vec<(String, String)> = others_clients(&slots, 1)
|
|
||||||
.iter()
|
|
||||||
.flat_map(|c| keys.iter().map(move |k| (c.address.clone(), k.clone())))
|
|
||||||
.collect();
|
|
||||||
// 2 others × 3 keys = 6 deliver_key calls.
|
|
||||||
assert_eq!(plan.len(), 6);
|
|
||||||
// Each "other" gets every key.
|
|
||||||
for c in ["0xb", "0xc"] {
|
|
||||||
let ks: Vec<&String> = plan
|
|
||||||
.iter()
|
|
||||||
.filter(|(addr, _)| addr == c)
|
|
||||||
.map(|(_, k)| k)
|
|
||||||
.collect();
|
|
||||||
let expected: Vec<&String> = keys.iter().collect();
|
|
||||||
assert_eq!(ks, expected);
|
|
||||||
}
|
|
||||||
// Leader "0xa" never appears in the routing plan.
|
|
||||||
assert!(plan.iter().all(|(addr, _)| addr != "0xa"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clipboard_routes_ctrl_v_to_each_other_slot() {
|
|
||||||
let slots = vec![
|
|
||||||
(1, client("0xa", (0, 0))),
|
|
||||||
(2, client("0xb", (1000, 0))),
|
|
||||||
(3, client("0xc", (0, 1000))),
|
|
||||||
];
|
|
||||||
let others = others_clients(&slots, 2);
|
|
||||||
let plan: Vec<(&Client, &str)> =
|
|
||||||
others.iter().map(|c| (c, "Ctrl+v")).collect();
|
|
||||||
assert_eq!(plan.len(), 2);
|
|
||||||
assert_eq!(plan[0].0.address, "0xa");
|
|
||||||
assert_eq!(plan[1].0.address, "0xc");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mirror_clicks_to_excludes_leader() {
|
|
||||||
let slots = vec![
|
|
||||||
(1, client("0xa", (0, 0))),
|
|
||||||
(2, client("0xb", (1000, 0))),
|
|
||||||
(3, client("0xc", (0, 1000))),
|
|
||||||
];
|
|
||||||
let plan = mirror_clicks_to(&slots, 2, 272);
|
|
||||||
assert_eq!(plan.len(), 2);
|
|
||||||
assert_eq!(plan[0].0.address, "0xa");
|
|
||||||
assert_eq!(plan[0].1, 272);
|
|
||||||
assert_eq!(plan[1].0.address, "0xc");
|
|
||||||
assert_eq!(plan[1].1, 272);
|
|
||||||
// Leader ("0xb") is never in the plan.
|
|
||||||
assert!(plan.iter().all(|(c, _)| c.address != "0xb"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mirror_clicks_to_is_empty_when_only_leader() {
|
|
||||||
let slots = vec![(1, client("0xa", (0, 0)))];
|
|
||||||
assert!(mirror_clicks_to(&slots, 1, 273).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mirror_clicks_to_carries_button_code() {
|
|
||||||
let slots = vec![
|
|
||||||
(1, client("0xa", (0, 0))),
|
|
||||||
(2, client("0xb", (1000, 0))),
|
|
||||||
];
|
|
||||||
let plan = mirror_clicks_to(&slots, 1, 273);
|
|
||||||
assert_eq!(plan.len(), 1);
|
|
||||||
assert_eq!(plan[0].1, 273, "right-click survives the filter");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
272
src/team.rs
272
src/team.rs
@ -1,272 +0,0 @@
|
|||||||
//! Teams: a named bundle of `Profile` plus per-team launcher overrides.
|
|
||||||
//!
|
|
||||||
//! Each team lives in `~/.config/enboxer/teams/<slug>.yaml` (a YAML that
|
|
||||||
//! deserialises as `Team`, whose `profile` field is a full `Profile`).
|
|
||||||
//! The currently-active team is recorded as a single slug line in
|
|
||||||
//! `~/.config/enboxer/current_team`. On startup the daemon reads that
|
|
||||||
//! file and loads the matching team, falling back to the legacy flat
|
|
||||||
//! `~/.config/enboxer/profile.yaml` when no team is recorded.
|
|
||||||
//!
|
|
||||||
//! `Team` is a thin wrapper around `Profile` — same YAML fields, just
|
|
||||||
//! with extra optional `wine_prefix` / `lutris_game` fields that the
|
|
||||||
//! GUI's Launch menu consults. Loading a team gives you the profile
|
|
||||||
//! directly; only the GUI distinguishes "team" from "profile".
|
|
||||||
|
|
||||||
use crate::profile::{default_config_path, Profile};
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Team {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default = "default_slug")]
|
|
||||||
pub slug: String,
|
|
||||||
/// Default wine prefix override applied to any Character that does
|
|
||||||
/// not set its own. The Lutris YAML's `game.prefix` is used when
|
|
||||||
/// this is `None`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub wine_prefix: Option<PathBuf>,
|
|
||||||
/// The profile itself, embedded under `profile:` in the YAML. Using
|
|
||||||
/// an explicit field avoids the `flatten`-duplicate-name clash with
|
|
||||||
/// the top-level `name`.
|
|
||||||
pub profile: Profile,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_slug() -> String {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Team {
|
|
||||||
pub fn load(path: &Path) -> Result<Self> {
|
|
||||||
let text = std::fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("read team {}", path.display()))?;
|
|
||||||
let team: Team = serde_yaml::from_str(&text).context("parse team YAML")?;
|
|
||||||
team.profile.validate()?;
|
|
||||||
Ok(team)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self, path: &Path) -> Result<()> {
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
std::fs::create_dir_all(dir).ok();
|
|
||||||
}
|
|
||||||
let text = serde_yaml::to_string(self).context("serialise team YAML")?;
|
|
||||||
std::fs::write(path, text).with_context(|| format!("write {}", path.display()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience: build a team from a `Profile` with the same `name`
|
|
||||||
/// used as the slug. The GUI's "New team…" flow calls this.
|
|
||||||
pub fn from_profile(profile: Profile) -> Self {
|
|
||||||
let slug = profile.name.clone();
|
|
||||||
Self {
|
|
||||||
slug,
|
|
||||||
name: profile.name.clone(),
|
|
||||||
wine_prefix: None,
|
|
||||||
profile,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `~/.config/enboxer` — both the flat `profile.yaml` and the `teams/`
|
|
||||||
/// subdirectory live here.
|
|
||||||
pub fn config_dir() -> PathBuf {
|
|
||||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
|
||||||
return PathBuf::from(xdg).join("enboxer");
|
|
||||||
}
|
|
||||||
directories::ProjectDirs::from("de", "nettsi", "enboxer")
|
|
||||||
.map(|p| p.config_dir().to_path_buf())
|
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn teams_dir() -> PathBuf {
|
|
||||||
config_dir().join("teams")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_team_path() -> PathBuf {
|
|
||||||
config_dir().join("current_team")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the one-line `current_team` slug file. Returns `None` if the
|
|
||||||
/// file is missing, empty, or contains only whitespace.
|
|
||||||
pub fn read_current_team() -> Option<String> {
|
|
||||||
let path = current_team_path();
|
|
||||||
let text = std::fs::read_to_string(&path).ok()?;
|
|
||||||
let slug = text.trim();
|
|
||||||
if slug.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(slug.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write the one-line `current_team` slug file.
|
|
||||||
pub fn write_current_team(slug: &str) -> Result<()> {
|
|
||||||
if let Some(dir) = current_team_path().parent() {
|
|
||||||
std::fs::create_dir_all(dir).ok();
|
|
||||||
}
|
|
||||||
let mut text = slug.to_string();
|
|
||||||
text.push('\n');
|
|
||||||
std::fs::write(current_team_path(), text)
|
|
||||||
.with_context(|| format!("write {}", current_team_path().display()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the `current_team` marker. Used when the last team is deleted.
|
|
||||||
pub fn clear_current_team() {
|
|
||||||
let _ = std::fs::remove_file(current_team_path());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List every `*.yaml` team slug in `~/.config/enboxer/teams/`. Missing
|
|
||||||
/// directory = empty list (the GUI shows "no teams").
|
|
||||||
pub fn list_teams() -> Vec<String> {
|
|
||||||
let dir = teams_dir();
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let Ok(rd) = std::fs::read_dir(&dir) else {
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
for entry in rd.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
|
||||||
if !stem.is_empty() {
|
|
||||||
out.push(stem.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort();
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve which profile the daemon should load on startup:
|
|
||||||
/// 1. If `current_team` exists and points at a real team YAML → load
|
|
||||||
/// that team.
|
|
||||||
/// 2. Else fall back to the legacy flat `~/.config/enboxer/profile.yaml`.
|
|
||||||
/// 3. Else return `Ok(None)` so the GUI's team picker can run.
|
|
||||||
pub fn load_active_profile() -> Result<Option<(Profile, Option<TeamMeta>)>> {
|
|
||||||
if let Some(slug) = read_current_team() {
|
|
||||||
let path = teams_dir().join(format!("{slug}.yaml"));
|
|
||||||
if path.exists() {
|
|
||||||
let team = Team::load(&path)?;
|
|
||||||
return Ok(Some((team.profile.clone(), Some(TeamMeta { slug: team.slug, wine_prefix: team.wine_prefix }))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let path = default_config_path();
|
|
||||||
if path.exists() {
|
|
||||||
let profile = Profile::load(&path)?;
|
|
||||||
return Ok(Some((profile, None)));
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Side-band info that the GUI needs alongside the profile (team slug,
|
|
||||||
/// default wine prefix) but the engine itself does not.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TeamMeta {
|
|
||||||
pub slug: String,
|
|
||||||
pub wine_prefix: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Path to the YAML file for `slug` (used by the GUI's Switch / Delete).
|
|
||||||
pub fn team_path(slug: &str) -> PathBuf {
|
|
||||||
teams_dir().join(format!("{slug}.yaml"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::profile::Mode;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
fn tmp_dir(label: &str) -> PathBuf {
|
|
||||||
let dir = std::env::temp_dir().join(format!(
|
|
||||||
"enboxer_team_{}_{}_{}",
|
|
||||||
std::process::id(),
|
|
||||||
label,
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
dir
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_profile() -> Profile {
|
|
||||||
Profile {
|
|
||||||
name: "wow-team".into(),
|
|
||||||
client: "wow-retail".into(),
|
|
||||||
slots: 2,
|
|
||||||
window_match: Default::default(),
|
|
||||||
passthrough: vec!["e".into(), "s".into(), "d".into(), "f".into()],
|
|
||||||
mode_default: Mode::Maps,
|
|
||||||
repeater: Default::default(),
|
|
||||||
game_binds: BTreeMap::new(),
|
|
||||||
interact: Default::default(),
|
|
||||||
session_hotkeys: BTreeMap::new(),
|
|
||||||
characters: vec![],
|
|
||||||
groups: BTreeMap::new(),
|
|
||||||
maps: vec![],
|
|
||||||
video_fx: vec![],
|
|
||||||
layout: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn team_round_trips_yaml() {
|
|
||||||
let dir = tmp_dir("round");
|
|
||||||
let profile = sample_profile();
|
|
||||||
let team = Team::from_profile(profile.clone());
|
|
||||||
let path = dir.join("wow-team.yaml");
|
|
||||||
team.save(&path).unwrap();
|
|
||||||
let loaded = Team::load(&path).unwrap();
|
|
||||||
assert_eq!(loaded.name, "wow-team");
|
|
||||||
assert_eq!(loaded.slug, "wow-team");
|
|
||||||
assert_eq!(loaded.profile.name, profile.name);
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_teams_missing_dir_is_empty() {
|
|
||||||
// Force a config dir that does not exist by overriding XDG.
|
|
||||||
let unique = format!(
|
|
||||||
"enboxer_list_none_{}_{}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos()
|
|
||||||
);
|
|
||||||
let dir = std::env::temp_dir().join(unique);
|
|
||||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
|
||||||
// Note: the env override may persist into other tests; the
|
|
||||||
// assertion only requires the GUI does not panic on a missing
|
|
||||||
// teams directory.
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
let names = list_teams();
|
|
||||||
assert!(names.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_write_current_team_round_trips() {
|
|
||||||
let unique = format!(
|
|
||||||
"enboxer_current_team_{}_{}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos()
|
|
||||||
);
|
|
||||||
let dir = std::env::temp_dir().join(unique);
|
|
||||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
|
||||||
assert_eq!(read_current_team(), None);
|
|
||||||
write_current_team("wow").unwrap();
|
|
||||||
assert_eq!(read_current_team().as_deref(), Some("wow"));
|
|
||||||
clear_current_team();
|
|
||||||
assert_eq!(read_current_team(), None);
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,678 +0,0 @@
|
|||||||
//! `zwlr_export_dmabuf_unstable_v1` client: covered-window capture path.
|
|
||||||
//!
|
|
||||||
//! When a Video FX source window is **covered** (its rect does not
|
|
||||||
//! intersect any monitor) and the user has opted in
|
|
||||||
//! (`ENBOXER_ENABLE_TOPLEVEL=1`), we fall back to a compositor export
|
|
||||||
//! instead of `grim`. The export is a Wayland request:
|
|
||||||
//!
|
|
||||||
//! 1. `zwlr_export_dmabuf_manager_v1.capture_output(...)` → `frame` event
|
|
||||||
//! 2. `frame` event carries `format` (DRM fourcc), `width`, `height`,
|
|
||||||
//! `offset_x`, `offset_y`, and the per-plane `object` events carry
|
|
||||||
//! `fd`, `size`, `offset`, `stride`.
|
|
||||||
//! 3. After all `object` events, `ready` (success) or `cancel` (failure)
|
|
||||||
//! arrives.
|
|
||||||
//! 4. The client imports the dmabuf with gbm, maps the bo with
|
|
||||||
//! `gbm_bo_map`, and copies the pixels out.
|
|
||||||
//!
|
|
||||||
//! ## Status
|
|
||||||
//!
|
|
||||||
//! The protocol module, format negotiation, frame parser, and file-write
|
|
||||||
//! to a **synthetic** buffer (the `gbm_bo_map` read pixel call is a
|
|
||||||
//! follow-up) are implemented here. The compositor-facing parts compile
|
|
||||||
//! against `wayland-protocols-wlr` but are only ever touched when the
|
|
||||||
//! env gate is on; `cargo test` exercises only the pure negotiation and
|
|
||||||
//! parser code paths.
|
|
||||||
//!
|
|
||||||
//! `gbm` is genuinely gnarly to write inside this run: it requires a DRM
|
|
||||||
//! device, a gbm device handle, the drm fourcc + modifier matched to the
|
|
||||||
//! compositor's `mod_high/mod_low`, a `gbm_bo` import, and a `gbm_bo_map`
|
|
||||||
//! that returns a CPU pointer to the buffer. None of that fits the
|
|
||||||
//! "smallest working diff" knob, so the file-write in this module
|
|
||||||
//! currently produces a synthetic frame (a coloured rectangle that says
|
|
||||||
//! "EXPORT PENDING"). The caller (`capture_toplevel`) is wired so that
|
|
||||||
//! swapping in a real `gbm_bo_map` is one function change.
|
|
||||||
//!
|
|
||||||
//! `cargo test` does **not** touch Wayland or the DRM stack.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
use wayland_client::protocol::{wl_buffer, wl_output, wl_registry};
|
|
||||||
use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle};
|
|
||||||
use wayland_protocols_wlr::export_dmabuf::v1::client::{
|
|
||||||
zwlr_export_dmabuf_frame_v1, zwlr_export_dmabuf_manager_v1,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// DRM fourcc codes (little-endian uint32 packing of the 4-char name).
|
|
||||||
/// Kept as raw u32 so we don't pull `drm-fourcc` as a dep just for the
|
|
||||||
/// constants we actually need.
|
|
||||||
pub mod fourcc {
|
|
||||||
pub const ARGB8888: u32 = u32::from_le_bytes(*b"AR24");
|
|
||||||
pub const XRGB8888: u32 = u32::from_le_bytes(*b"XR24");
|
|
||||||
pub const ABGR8888: u32 = u32::from_le_bytes(*b"AB24");
|
|
||||||
pub const XBGR8888: u32 = u32::from_le_bytes(*b"XB24");
|
|
||||||
pub const RGBA8888: u32 = u32::from_le_bytes(*b"RA24");
|
|
||||||
pub const RGBX8888: u32 = u32::from_le_bytes(*b"RX24");
|
|
||||||
pub const BGRA8888: u32 = u32::from_le_bytes(*b"BGRA");
|
|
||||||
pub const BGRX8888: u32 = u32::from_le_bytes(*b"BGRX");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The set of formats we know how to read. Anything outside this set is
|
|
||||||
/// rejected by [`negotiate_format`].
|
|
||||||
pub const SUPPORTED_FORMATS: &[u32] = &[
|
|
||||||
fourcc::ARGB8888,
|
|
||||||
fourcc::XRGB8888,
|
|
||||||
fourcc::ABGR8888,
|
|
||||||
fourcc::XBGR8888,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Pretty name for a DRM fourcc. Used in error messages and the
|
|
||||||
/// PNG-side metadata so the operator can tell which format the
|
|
||||||
/// compositor handed us.
|
|
||||||
pub fn format_name(f: u32) -> &'static str {
|
|
||||||
match f {
|
|
||||||
fourcc::ARGB8888 => "ARGB8888",
|
|
||||||
fourcc::XRGB8888 => "XRGB8888",
|
|
||||||
fourcc::ABGR8888 => "ABGR8888",
|
|
||||||
fourcc::XBGR8888 => "XBGR8888",
|
|
||||||
fourcc::RGBA8888 => "RGBA8888",
|
|
||||||
fourcc::RGBX8888 => "RGBX8888",
|
|
||||||
fourcc::BGRA8888 => "BGRA8888",
|
|
||||||
fourcc::BGRX8888 => "BGRX8888",
|
|
||||||
_ => "UNKNOWN",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure: parse a four-byte ASCII code into a DRM fourcc. Used for
|
|
||||||
/// parsing YAML / config strings that name formats by their short code.
|
|
||||||
pub fn parse_format(s: &str) -> Option<u32> {
|
|
||||||
let bytes = s.as_bytes();
|
|
||||||
if bytes.len() != 4 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure: pick the first format from `advertised` that we know how to
|
|
||||||
/// read. Returns the format and its pretty name; rejects unknown formats
|
|
||||||
/// so the caller doesn't silently pick a colour-ordered buffer that
|
|
||||||
/// looks like garbage when interpreted as ARGB.
|
|
||||||
pub fn negotiate_format(advertised: &[u32]) -> Result<(u32, &'static str), String> {
|
|
||||||
for &f in advertised {
|
|
||||||
if SUPPORTED_FORMATS.contains(&f) {
|
|
||||||
return Ok((f, format_name(f)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(format!(
|
|
||||||
"no supported format in advertised {:?}",
|
|
||||||
advertised.iter().map(|f| format_name(*f)).collect::<Vec<_>>()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Frame metadata (one parsed `frame` + `object` events). ----
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DmabufPlane {
|
|
||||||
pub index: u32,
|
|
||||||
pub size: u32,
|
|
||||||
pub offset: u32,
|
|
||||||
pub stride: u32,
|
|
||||||
pub plane_index: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DmabufFrame {
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
pub offset_x: u32,
|
|
||||||
pub offset_y: u32,
|
|
||||||
pub format: u32,
|
|
||||||
pub mod_high: u32,
|
|
||||||
pub mod_low: u32,
|
|
||||||
pub planes: Vec<DmabufPlane>,
|
|
||||||
pub ready: bool,
|
|
||||||
pub cancel_reason: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DmabufFrame {
|
|
||||||
pub fn format_name(&self) -> &'static str {
|
|
||||||
format_name(self.format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// State shared between Wayland event handlers and the spawning thread.
|
|
||||||
struct ExportState {
|
|
||||||
manager: Option<zwlr_export_dmabuf_manager_v1::ZwlrExportDmabufManagerV1>,
|
|
||||||
/// Per-output globals keyed by Hyprland monitor name. We don't get
|
|
||||||
/// the name in the `Global` event directly; `wl_output::Event::Name`
|
|
||||||
/// delivers it. Tracked here so [`capture_via_export_for`] can look
|
|
||||||
/// up the right proxy by monitor name without a second roundtrip.
|
|
||||||
outputs: HashMap<String, wl_output::WlOutput>,
|
|
||||||
frame: Option<DmabufFrame>,
|
|
||||||
exited: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExportState {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
manager: None,
|
|
||||||
outputs: HashMap::new(),
|
|
||||||
frame: None,
|
|
||||||
exited: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_output(&self, name: &str) -> Option<wl_output::WlOutput> {
|
|
||||||
self.outputs.get(name).cloned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_output::WlOutput, ()> for ExportState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
output: &wl_output::WlOutput,
|
|
||||||
event: wl_output::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
if let wl_output::Event::Name { name } = event {
|
|
||||||
state.outputs.insert(name, output.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capture via export, looking up the right `wl_output` by name from the
|
|
||||||
/// compositor. The caller passes the monitor name (e.g. `DP-1`).
|
|
||||||
pub async fn capture_via_export_for(
|
|
||||||
output_name: &str,
|
|
||||||
dest: &Path,
|
|
||||||
) -> anyhow::Result<(u32, u32, u32)> {
|
|
||||||
let conn = Connection::connect_to_env()?;
|
|
||||||
let display = conn.display();
|
|
||||||
let mut event_queue = conn.new_event_queue::<ExportState>();
|
|
||||||
let qh = event_queue.handle();
|
|
||||||
let _registry = display.get_registry(&qh, ());
|
|
||||||
let mut state = ExportState::new();
|
|
||||||
event_queue.roundtrip(&mut state)?;
|
|
||||||
let manager = state
|
|
||||||
.manager
|
|
||||||
.take()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("zwlr_export_dmabuf_manager_v1 not advertised"))?;
|
|
||||||
let output = state
|
|
||||||
.find_output(output_name)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("wl_output for {output_name:?} not found"))?;
|
|
||||||
capture_with_state(conn, manager, output, dest, event_queue).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public entry: the dmabuf path of `capture_toplevel`. Connects to
|
|
||||||
/// Wayland, requests an export, waits for the frame + object + ready
|
|
||||||
/// events, then **without** touching gbm writes a synthetic PNG-sized
|
|
||||||
/// byte slice to `dest`. The gbm bo map is a documented follow-up.
|
|
||||||
pub async fn capture_via_export(
|
|
||||||
output: &wl_output::WlOutput,
|
|
||||||
dest: &Path,
|
|
||||||
) -> anyhow::Result<(u32, u32, u32)> {
|
|
||||||
let conn = Connection::connect_to_env()?;
|
|
||||||
let display = conn.display();
|
|
||||||
let mut event_queue = conn.new_event_queue::<ExportState>();
|
|
||||||
let qh = event_queue.handle();
|
|
||||||
let _registry = display.get_registry(&qh, ());
|
|
||||||
let mut state = ExportState::new();
|
|
||||||
event_queue.roundtrip(&mut state)?;
|
|
||||||
let Some(manager) = state.manager.take() else {
|
|
||||||
anyhow::bail!("zwlr_export_dmabuf_manager_v1 not advertised");
|
|
||||||
};
|
|
||||||
capture_with_state(conn, manager, output.clone(), dest, event_queue).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn capture_with_state(
|
|
||||||
_conn: Connection,
|
|
||||||
manager: zwlr_export_dmabuf_manager_v1::ZwlrExportDmabufManagerV1,
|
|
||||||
output: wl_output::WlOutput,
|
|
||||||
dest: &Path,
|
|
||||||
mut event_queue: EventQueue<ExportState>,
|
|
||||||
) -> anyhow::Result<(u32, u32, u32)> {
|
|
||||||
let qh = event_queue.handle();
|
|
||||||
let _frame = manager.capture_output(0, &output, &qh, ());
|
|
||||||
let mut state = ExportState {
|
|
||||||
manager: Some(manager),
|
|
||||||
outputs: HashMap::new(),
|
|
||||||
frame: None,
|
|
||||||
exited: false,
|
|
||||||
};
|
|
||||||
while !state.exited && state.frame.is_none() {
|
|
||||||
if let Err(e) = event_queue.blocking_dispatch(&mut state) {
|
|
||||||
anyhow::bail!("export dispatch: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let frame = state.frame.ok_or_else(|| anyhow::anyhow!("export: no frame"))?;
|
|
||||||
if let Some(reason) = frame.cancel_reason {
|
|
||||||
anyhow::bail!("export cancelled (reason {reason})");
|
|
||||||
}
|
|
||||||
if !frame.ready {
|
|
||||||
anyhow::bail!("export: frame never became ready");
|
|
||||||
}
|
|
||||||
if let Some(parent) = dest.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await.ok();
|
|
||||||
}
|
|
||||||
write_synthetic_frame(dest, frame.width, frame.height, frame.format_name())?;
|
|
||||||
Ok((frame.width, frame.height, frame.format))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a PNG-sized, solid-coloured placeholder PNG that is shaped like
|
|
||||||
/// the requested frame. Until `gbm_bo_map` is wired in, this is what the
|
|
||||||
/// caller sees — a frame of the right dimensions and a stripe banner
|
|
||||||
/// saying which format the compositor handed us. The size and format
|
|
||||||
/// metadata prove the protocol worked end-to-end.
|
|
||||||
fn write_synthetic_frame(dest: &Path, width: u32, height: u32, label: &str) -> anyhow::Result<()> {
|
|
||||||
let bytes = png_synthetic(width, height, label);
|
|
||||||
std::fs::write(dest, bytes).with_context(|| format!("write {}", dest.display()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hand-rolled PNG writer for a uniform-colour rectangle with a single
|
|
||||||
/// text "stripe" (just a row of pixels across the top to show the
|
|
||||||
/// format). Avoids pulling in the `png` crate.
|
|
||||||
fn png_synthetic(width: u32, height: u32, label: &str) -> Vec<u8> {
|
|
||||||
let label_bytes = label.as_bytes();
|
|
||||||
let mut raw = Vec::with_capacity(((width * 3 + 1) * height) as usize);
|
|
||||||
let (sr, sg, sb) = (220u8, 40u8, 40u8);
|
|
||||||
let (br, bg, bb) = (60u8, 60u8, 60u8);
|
|
||||||
for y in 0..height {
|
|
||||||
raw.push(0u8);
|
|
||||||
for x in 0..width {
|
|
||||||
let x_us = x as usize;
|
|
||||||
let in_stripe = (y as usize) < 12 && x_us < label_bytes.len() * 6;
|
|
||||||
let (r, g, b) = if in_stripe {
|
|
||||||
let ch = label_bytes[x_us / 6];
|
|
||||||
if ch != b' ' && (x_us % 6) < 3 {
|
|
||||||
(sr, sg, sb)
|
|
||||||
} else {
|
|
||||||
(br, bg, bb)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(br, bg, bb)
|
|
||||||
};
|
|
||||||
raw.push(r);
|
|
||||||
raw.push(g);
|
|
||||||
raw.push(b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut out = Vec::with_capacity(raw.len() + 256);
|
|
||||||
out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
|
|
||||||
write_png_chunk(&mut out, b"IHDR", &ihdr(width, height));
|
|
||||||
write_png_chunk(&mut out, b"tEXt", &png_tEXt("enboxer", label));
|
|
||||||
let idat = zlib_store(&raw);
|
|
||||||
write_png_chunk(&mut out, b"IDAT", &idat);
|
|
||||||
write_png_chunk(&mut out, b"IEND", &[]);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ihdr(width: u32, height: u32) -> [u8; 13] {
|
|
||||||
let mut b = [0u8; 13];
|
|
||||||
b[0..4].copy_from_slice(&width.to_be_bytes());
|
|
||||||
b[4..8].copy_from_slice(&height.to_be_bytes());
|
|
||||||
b[8] = 8;
|
|
||||||
b[9] = 2;
|
|
||||||
b[10] = 0;
|
|
||||||
b[11] = 0;
|
|
||||||
b[12] = 0;
|
|
||||||
b
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
fn png_tEXt(key: &str, value: &str) -> Vec<u8> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
out.extend_from_slice(key.as_bytes());
|
|
||||||
out.push(0);
|
|
||||||
out.extend_from_slice(value.as_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store-only zlib stream. PNG requires zlib headers; we wrap the raw
|
|
||||||
/// bytes with the "deflate stored blocks" envelope and an adler32
|
|
||||||
/// checksum.
|
|
||||||
fn zlib_store(data: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::with_capacity(data.len() + 16);
|
|
||||||
out.push(0x78);
|
|
||||||
out.push(0x01);
|
|
||||||
let chunks: Vec<&[u8]> = data.chunks(u16::MAX as usize).collect();
|
|
||||||
for (i, chunk) in chunks.iter().enumerate() {
|
|
||||||
let is_last = i + 1 == chunks.len();
|
|
||||||
let mut header = vec![if is_last { 1 } else { 0 }];
|
|
||||||
let len = chunk.len() as u16;
|
|
||||||
header.extend_from_slice(&len.to_le_bytes());
|
|
||||||
let nlen = !len;
|
|
||||||
header.extend_from_slice(&nlen.to_le_bytes());
|
|
||||||
out.extend_from_slice(&header);
|
|
||||||
out.extend_from_slice(chunk);
|
|
||||||
}
|
|
||||||
if chunks.is_empty() {
|
|
||||||
// Empty input: emit a single stored empty block so the IDAT is
|
|
||||||
// not malformed.
|
|
||||||
out.extend_from_slice(&[1, 0, 0, 0xFF, 0xFF]);
|
|
||||||
}
|
|
||||||
let adler = adler32(data);
|
|
||||||
out.extend_from_slice(&adler.to_be_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn adler32(data: &[u8]) -> u32 {
|
|
||||||
let mut a: u32 = 1;
|
|
||||||
let mut b: u32 = 0;
|
|
||||||
for &x in data {
|
|
||||||
a = (a + x as u32) % 65521;
|
|
||||||
b = (b + a) % 65521;
|
|
||||||
}
|
|
||||||
(b << 16) | a
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_png_chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
|
||||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
|
||||||
out.extend_from_slice(kind);
|
|
||||||
out.extend_from_slice(data);
|
|
||||||
let crc = crc32_ieee(&[kind, data].concat());
|
|
||||||
out.extend_from_slice(&crc.to_be_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn crc32_ieee(data: &[u8]) -> u32 {
|
|
||||||
let mut crc: u32 = 0xFFFF_FFFF;
|
|
||||||
for &b in data {
|
|
||||||
crc ^= b as u32;
|
|
||||||
for _ in 0..8 {
|
|
||||||
crc = if crc & 1 != 0 {
|
|
||||||
0xEDB8_8320 ^ (crc >> 1)
|
|
||||||
} else {
|
|
||||||
crc >> 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
!crc
|
|
||||||
}
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
|
|
||||||
// ---- Dispatch impls ----
|
|
||||||
|
|
||||||
impl Dispatch<wl_registry::WlRegistry, ()> for ExportState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
registry: &wl_registry::WlRegistry,
|
|
||||||
event: wl_registry::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
qh: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
if let wl_registry::Event::Global {
|
|
||||||
name,
|
|
||||||
interface,
|
|
||||||
version,
|
|
||||||
} = event
|
|
||||||
{
|
|
||||||
match interface.as_str() {
|
|
||||||
"zwlr_export_dmabuf_manager_v1" => {
|
|
||||||
state.manager = Some(
|
|
||||||
registry.bind::<zwlr_export_dmabuf_manager_v1::ZwlrExportDmabufManagerV1, _, _>(
|
|
||||||
name, version, qh, (),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"wl_output" => {
|
|
||||||
let _ = registry.bind::<wl_output::WlOutput, _, _>(name, version, qh, ());
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<zwlr_export_dmabuf_manager_v1::ZwlrExportDmabufManagerV1, ()> for ExportState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &zwlr_export_dmabuf_manager_v1::ZwlrExportDmabufManagerV1,
|
|
||||||
_: zwlr_export_dmabuf_manager_v1::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<zwlr_export_dmabuf_frame_v1::ZwlrExportDmabufFrameV1, ()> for ExportState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
_: &zwlr_export_dmabuf_frame_v1::ZwlrExportDmabufFrameV1,
|
|
||||||
event: zwlr_export_dmabuf_frame_v1::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
match event {
|
|
||||||
zwlr_export_dmabuf_frame_v1::Event::Frame {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
offset_x,
|
|
||||||
offset_y,
|
|
||||||
format,
|
|
||||||
mod_high,
|
|
||||||
mod_low,
|
|
||||||
num_objects,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
state.frame = Some(DmabufFrame {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
offset_x,
|
|
||||||
offset_y,
|
|
||||||
format,
|
|
||||||
mod_high,
|
|
||||||
mod_low,
|
|
||||||
planes: Vec::with_capacity(num_objects as usize),
|
|
||||||
ready: false,
|
|
||||||
cancel_reason: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
zwlr_export_dmabuf_frame_v1::Event::Object {
|
|
||||||
index,
|
|
||||||
fd: _fd,
|
|
||||||
size,
|
|
||||||
offset,
|
|
||||||
stride,
|
|
||||||
plane_index,
|
|
||||||
} => {
|
|
||||||
if let Some(f) = state.frame.as_mut() {
|
|
||||||
f.planes.push(DmabufPlane {
|
|
||||||
index,
|
|
||||||
size,
|
|
||||||
offset,
|
|
||||||
stride,
|
|
||||||
plane_index,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
zwlr_export_dmabuf_frame_v1::Event::Ready { .. } => {
|
|
||||||
if let Some(f) = state.frame.as_mut() {
|
|
||||||
f.ready = true;
|
|
||||||
}
|
|
||||||
state.exited = true;
|
|
||||||
}
|
|
||||||
zwlr_export_dmabuf_frame_v1::Event::Cancel { reason, .. } => {
|
|
||||||
if let Some(f) = state.frame.as_mut() {
|
|
||||||
f.cancel_reason = Some(match reason {
|
|
||||||
wayland_client::WEnum::Value(v) => v as u32,
|
|
||||||
wayland_client::WEnum::Unknown(v) => v,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
state.exited = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_buffer::WlBuffer, ()> for ExportState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_buffer::WlBuffer,
|
|
||||||
_: wl_buffer::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- T10 follow-up note ----
|
|
||||||
//
|
|
||||||
// The actual `gbm_bo_map` (import dmabuf → gbm_bo → map → read pixels →
|
|
||||||
// copy into a PNG-encoded buffer) is a documented follow-up. Wiring it
|
|
||||||
// in is one new dep (`gbm` + `drm-fourcc` + a DRM device handle) plus a
|
|
||||||
// ~50-line renderer that:
|
|
||||||
// 1. opens /dev/dri/renderD128,
|
|
||||||
// 2. creates a gbm_device,
|
|
||||||
// 3. imports the dmabuf fd (we have it from the `object` event),
|
|
||||||
// 4. gbm_bo_map(...) → *mut u8,
|
|
||||||
// 5. reads stride*height bytes, swizzles into the PNG writer above.
|
|
||||||
//
|
|
||||||
// Until then `write_synthetic_frame` produces the right-sized, format-
|
|
||||||
// labelled placeholder so callers can verify the wire path.
|
|
||||||
|
|
||||||
/// Public stub: where the gbm_bo_map read belongs. Kept as a function
|
|
||||||
/// so the test below can assert its shape.
|
|
||||||
pub fn read_pixels_via_gbm(_frame: &DmabufFrame) -> anyhow::Result<Vec<u8>> {
|
|
||||||
anyhow::bail!(
|
|
||||||
"gbm_bo_map not implemented; see toplevel_export module note for the upgrade path"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best effort: which Hyprland output to capture from for `client`.
|
|
||||||
///
|
|
||||||
/// We don't need a Wayland roundtrip to answer that — `hyprctl -j clients` and `-j monitors` already tell us which monitor a window is on. This returns the monitor name so the caller can ask Hyprland for the matching `wl_output` proxy later.
|
|
||||||
pub async fn pick_output_for(client: &crate::hypr::Client) -> anyhow::Result<String> {
|
|
||||||
let monitors = crate::layout::monitors().await?;
|
|
||||||
let m = monitors
|
|
||||||
.iter()
|
|
||||||
.find(|m| {
|
|
||||||
client.at[0] >= m.x
|
|
||||||
&& client.at[1] >= m.y
|
|
||||||
&& client.at[0] < m.x + m.width
|
|
||||||
&& client.at[1] < m.y + m.height
|
|
||||||
})
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("client has no monitor"))?;
|
|
||||||
Ok(m.name.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_name_round_trip() {
|
|
||||||
assert_eq!(format_name(fourcc::ARGB8888), "ARGB8888");
|
|
||||||
assert_eq!(format_name(fourcc::XRGB8888), "XRGB8888");
|
|
||||||
assert_eq!(format_name(fourcc::ABGR8888), "ABGR8888");
|
|
||||||
assert_eq!(format_name(fourcc::XBGR8888), "XBGR8888");
|
|
||||||
assert_eq!(format_name(0xDEAD_BEEF), "UNKNOWN");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_format_recognises_fourcc_codes() {
|
|
||||||
assert_eq!(parse_format("AR24"), Some(fourcc::ARGB8888));
|
|
||||||
assert_eq!(parse_format("XR24"), Some(fourcc::XRGB8888));
|
|
||||||
assert_eq!(parse_format("AB24"), Some(fourcc::ABGR8888));
|
|
||||||
assert_eq!(parse_format("XB24"), Some(fourcc::XBGR8888));
|
|
||||||
assert_eq!(parse_format("RA24"), Some(fourcc::RGBA8888));
|
|
||||||
assert_eq!(parse_format("BGRA"), Some(fourcc::BGRA8888));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_format_rejects_wrong_length() {
|
|
||||||
assert_eq!(parse_format("ARG"), None);
|
|
||||||
assert_eq!(parse_format("ARGBS"), None);
|
|
||||||
assert_eq!(parse_format(""), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn negotiate_format_picks_known_format_from_advertised_list() {
|
|
||||||
let advertised = [fourcc::XBGR8888, fourcc::XRGB8888, 0x3231564E];
|
|
||||||
let (f, name) = negotiate_format(&advertised).unwrap();
|
|
||||||
assert_eq!(f, fourcc::XBGR8888);
|
|
||||||
assert_eq!(name, "XBGR8888");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn negotiate_format_rejects_only_unknown_formats() {
|
|
||||||
let advertised = [0x3231564E, 0x3231564D, 0xDEAD_BEEF];
|
|
||||||
let err = negotiate_format(&advertised).unwrap_err();
|
|
||||||
assert!(err.contains("no supported format"));
|
|
||||||
assert!(err.contains("UNKNOWN"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn negotiate_format_handles_empty_advertised_list() {
|
|
||||||
let err = negotiate_format(&[]).unwrap_err();
|
|
||||||
assert!(err.contains("no supported format"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn env_gate_off_means_no_live_export() {
|
|
||||||
std::env::remove_var("ENBOXER_ENABLE_TOPLEVEL");
|
|
||||||
assert!(!crate::vfx::toplevel_enabled());
|
|
||||||
// We do NOT call capture_via_export here: it would try to open a
|
|
||||||
// Wayland connection. The gate is asserted by vfx::tests.
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protocol_constants_match_xml() {
|
|
||||||
// The wayland-scanner generates the bindings at compile time; we
|
|
||||||
// pin the names of the two interfaces we depend on so a wire
|
|
||||||
// drift shows up here.
|
|
||||||
let manager = std::any::type_name::<zwlr_export_dmabuf_manager_v1::ZwlrExportDmabufManagerV1>();
|
|
||||||
assert!(
|
|
||||||
manager.contains("zwlr_export_dmabuf_manager_v1"),
|
|
||||||
"manager type_name drift: {manager}"
|
|
||||||
);
|
|
||||||
let frame = std::any::type_name::<zwlr_export_dmabuf_frame_v1::ZwlrExportDmabufFrameV1>();
|
|
||||||
assert!(
|
|
||||||
frame.contains("zwlr_export_dmabuf_frame_v1"),
|
|
||||||
"frame type_name drift: {frame}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn module_compiles_and_exposes_bindings() {
|
|
||||||
let _: Option<zwlr_export_dmabuf_manager_v1::ZwlrExportDmabufManagerV1> = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn synthetic_png_has_valid_signature() {
|
|
||||||
let png = png_synthetic(96, 32, "ARGB8888");
|
|
||||||
assert!(png.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]));
|
|
||||||
let tail = &png[png.len() - 8..];
|
|
||||||
assert_eq!(&tail[0..4], b"IEND");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn synthetic_png_handles_empty_label() {
|
|
||||||
let png = png_synthetic(8, 4, "");
|
|
||||||
assert!(png.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_pixels_via_gbm_is_a_documented_followup() {
|
|
||||||
let frame = DmabufFrame {
|
|
||||||
width: 320,
|
|
||||||
height: 200,
|
|
||||||
offset_x: 0,
|
|
||||||
offset_y: 0,
|
|
||||||
format: fourcc::ARGB8888,
|
|
||||||
mod_high: 0,
|
|
||||||
mod_low: 0,
|
|
||||||
planes: vec![],
|
|
||||||
ready: true,
|
|
||||||
cancel_reason: None,
|
|
||||||
};
|
|
||||||
let err = read_pixels_via_gbm(&frame).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("gbm_bo_map not implemented"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
361
src/vfx.rs
361
src/vfx.rs
@ -1,28 +1,16 @@
|
|||||||
//! Video FX: crop a region of a source slot onto an overlay on the primary.
|
//! Video FX: crop a region of a source slot onto an overlay on the primary.
|
||||||
//!
|
//!
|
||||||
//! Capture uses `grim` (visible pixels). The overlay is `mpv` with
|
//! Capture uses `grim` (visible pixels). Stacked/occluded sources need
|
||||||
//! `--wayland-app-id=enboxer-vfx` and a JSON IPC socket so frames reload.
|
//! hyprland-toplevel-export; that protocol exists on Hyprland 0.56 but the
|
||||||
//!
|
//! client is not wired yet. Pass-through still works from viewer geometry.
|
||||||
//! When the source window is **covered** (its rect does not intersect any
|
|
||||||
//! `hyprctl monitors` output) and the operator has opted in to toplevel
|
|
||||||
//! export (`ENBOXER_ENABLE_TOPLEVEL=1`), a second capture path can read the
|
|
||||||
//! window's contents directly from the compositor via
|
|
||||||
//! `zwlr_export_dmabuf_unstable_v1`. That live path is gated so `cargo test`
|
|
||||||
//! and `enboxer doctor` never touch the user's Hyprland session.
|
|
||||||
|
|
||||||
use crate::hypr::{self, Client};
|
use crate::hypr::{self, Client};
|
||||||
use crate::layout::Monitor;
|
|
||||||
use crate::profile::{runtime_dir, VideoFx};
|
use crate::profile::{runtime_dir, VideoFx};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
use tokio::process::Command;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::time::{interval, Duration};
|
||||||
use tokio::net::UnixStream;
|
|
||||||
use tokio::process::{Child, Command};
|
|
||||||
use tokio::sync::{watch, Mutex};
|
|
||||||
use tokio::time::{interval, sleep, Duration};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FeedHit {
|
pub struct FeedHit {
|
||||||
@ -84,10 +72,6 @@ pub fn frame_path(name: &str) -> PathBuf {
|
|||||||
runtime_dir().join(format!("vfx-{name}.png"))
|
runtime_dir().join(format!("vfx-{name}.png"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ipc_path(name: &str) -> PathBuf {
|
|
||||||
runtime_dir().join(format!("vfx-{name}.mpv"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn capture_region(x: i32, y: i32, w: i32, h: i32, dest: &Path) -> Result<()> {
|
pub async fn capture_region(x: i32, y: i32, w: i32, h: i32, dest: &Path) -> Result<()> {
|
||||||
if let Some(parent) = dest.parent() {
|
if let Some(parent) = dest.parent() {
|
||||||
tokio::fs::create_dir_all(parent).await.ok();
|
tokio::fs::create_dir_all(parent).await.ok();
|
||||||
@ -106,235 +90,54 @@ pub async fn capture_region(x: i32, y: i32, w: i32, h: i32, dest: &Path) -> Resu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure: does the client's rect intersect any of the supplied monitors?
|
/// Keep PNG files fresh so an external viewer (`imv`, `mpv`, `feh --reload`) can show them.
|
||||||
/// Empty monitors list = nothing is visible. The predicate intentionally
|
pub async fn capture_loop(rx: tokio::sync::watch::Receiver<Vec<FeedHit>>) {
|
||||||
/// ignores z-order; a window stacked under another that still paints onto
|
let mut tick = interval(Duration::from_millis(80));
|
||||||
/// the same monitor returns `true`. `capture_for_source` will then fall
|
loop {
|
||||||
/// back to `grim`, which is the correct behaviour for visible-but-covered
|
tick.tick().await;
|
||||||
/// windows (you cannot fix stacking from inside enBoxer).
|
let feeds = rx.borrow().clone();
|
||||||
pub fn is_window_visible(monitors: &[Monitor], client: &Client) -> bool {
|
|
||||||
let x = client.at[0];
|
|
||||||
let y = client.at[1];
|
|
||||||
let w = client.size[0];
|
|
||||||
let h = client.size[1];
|
|
||||||
if w <= 0 || h <= 0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
monitors.iter().any(|m| {
|
|
||||||
x < m.x + m.width && x + w > m.x && y < m.y + m.height && y + h > m.y
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True iff the operator has opted in to compositor-side capture of covered
|
|
||||||
/// source windows. Off by default; the gate exists so `cargo test` and
|
|
||||||
/// `enboxer doctor` never connect to Wayland.
|
|
||||||
pub fn toplevel_enabled() -> bool {
|
|
||||||
std::env::var("ENBOXER_ENABLE_TOPLEVEL")
|
|
||||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capture the source window via `zwlr_export_dmabuf_unstable_v1` and write
|
|
||||||
/// the resulting frame to `dest`. Gated by `toplevel_enabled()`; until the
|
|
||||||
/// live Wayland path lands, this errors out so callers fall back to `grim`.
|
|
||||||
/// The wlr-export-dmabuf protocol is in `wayland-protocols-wlr`; wiring it
|
|
||||||
/// (buffer management + format negotiation + post-import blit) is a
|
|
||||||
/// follow-up ticket.
|
|
||||||
pub async fn capture_toplevel(client: &Client, dest: &Path) -> Result<()> {
|
|
||||||
if !toplevel_enabled() {
|
|
||||||
anyhow::bail!("toplevel export disabled (set ENBOXER_ENABLE_TOPLEVEL=1)");
|
|
||||||
}
|
|
||||||
// The protocol code, format negotiation, and event dispatch all live in
|
|
||||||
// `crate::toplevel_export`. The pixel read still goes through a
|
|
||||||
// synthetic buffer (see `toplevel_export::capture_via_export_for`) —
|
|
||||||
// the real `gbm_bo_map` is a documented follow-up.
|
|
||||||
let output_name = crate::toplevel_export::pick_output_for(client)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("pick output for {}", client.address))?;
|
|
||||||
crate::toplevel_export::capture_via_export_for(&output_name, dest)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("toplevel export for {}", client.address))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pick the right capture path for one source window. Visible on an output
|
|
||||||
/// → `grim`. Covered and `ENBOXER_ENABLE_TOPLEVEL=1` → compositor export.
|
|
||||||
/// Covered and the gate is off → fall back to `grim` (which returns an
|
|
||||||
/// error if nothing is on screen, which is the correct behaviour; the
|
|
||||||
/// operator sees the empty frame and either brings the source back or sets
|
|
||||||
/// the gate).
|
|
||||||
pub async fn capture_for_source(
|
|
||||||
monitors: &[Monitor],
|
|
||||||
client: &Client,
|
|
||||||
dest: &Path,
|
|
||||||
) -> Result<()> {
|
|
||||||
if !is_window_visible(monitors, client) && toplevel_enabled() {
|
|
||||||
return capture_toplevel(client, dest).await;
|
|
||||||
}
|
|
||||||
capture_region(client.at[0], client.at[1], client.size[0], client.size[1], dest).await
|
|
||||||
}
|
|
||||||
|
|
||||||
struct OverlayProc {
|
|
||||||
child: Child,
|
|
||||||
ipc: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct OverlayHub {
|
|
||||||
procs: Mutex<HashMap<String, OverlayProc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OverlayHub {
|
|
||||||
pub fn new() -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
procs: Mutex::new(HashMap::new()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn sync(&self, feeds: &[FeedHit]) -> Result<()> {
|
|
||||||
let names: Vec<String> = feeds.iter().map(|f| f.name.clone()).collect();
|
|
||||||
{
|
|
||||||
let mut procs = self.procs.lock().await;
|
|
||||||
let stale: Vec<String> = procs
|
|
||||||
.iter_mut()
|
|
||||||
.filter_map(|(name, proc)| {
|
|
||||||
let dead = matches!(proc.child.try_wait(), Ok(Some(_)));
|
|
||||||
if !names.contains(name) || dead {
|
|
||||||
Some(name.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
for name in stale {
|
|
||||||
if let Some(mut proc) = procs.remove(&name) {
|
|
||||||
let _ = proc.child.start_kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for f in feeds {
|
for f in feeds {
|
||||||
self.ensure(f).await?;
|
let (x, y, w, h) = f.source;
|
||||||
self.position(f).await?;
|
let dest = frame_path(&f.name);
|
||||||
|
if let Err(e) = capture_region(x, y, w, h, &dest).await {
|
||||||
|
tracing::debug!("vfx capture {}: {e}", f.name);
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure(&self, feed: &FeedHit) -> Result<()> {
|
pub async fn ensure_overlay_window(feed: &FeedHit) -> Result<()> {
|
||||||
{
|
let path = frame_path(&feed.name);
|
||||||
let procs = self.procs.lock().await;
|
let class = "enboxer-vfx";
|
||||||
if procs.contains_key(&feed.name) {
|
let already = hypr::clients()
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::fs::create_dir_all(runtime_dir()).await.ok();
|
|
||||||
let png = frame_path(&feed.name);
|
|
||||||
if !png.exists() {
|
|
||||||
capture_region(
|
|
||||||
feed.source.0,
|
|
||||||
feed.source.1,
|
|
||||||
feed.source.2,
|
|
||||||
feed.source.3,
|
|
||||||
&png,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
let ipc = ipc_path(&feed.name);
|
|
||||||
let _ = tokio::fs::remove_file(&ipc).await;
|
|
||||||
let title = format!("enboxer-vfx:{}", feed.name);
|
|
||||||
let mut cmd = Command::new("mpv");
|
|
||||||
cmd.args([
|
|
||||||
"--wayland-app-id=enboxer-vfx",
|
|
||||||
&format!("--title={title}"),
|
|
||||||
"--idle=yes",
|
|
||||||
"--force-window=immediate",
|
|
||||||
&format!("--input-ipc-server={}", ipc.display()),
|
|
||||||
"--no-input-default-bindings",
|
|
||||||
"--no-osc",
|
|
||||||
"--really-quiet",
|
|
||||||
"--msg-level=all=error",
|
|
||||||
"--image-display-duration=inf",
|
|
||||||
"--loop-file=inf",
|
|
||||||
"--no-config",
|
|
||||||
"--no-audio",
|
|
||||||
])
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null());
|
|
||||||
if png.exists() {
|
|
||||||
cmd.arg(&png);
|
|
||||||
}
|
|
||||||
let child = cmd.spawn().context("spawn mpv overlay")?;
|
|
||||||
self.procs.lock().await.insert(
|
|
||||||
feed.name.clone(),
|
|
||||||
OverlayProc {
|
|
||||||
child,
|
|
||||||
ipc: ipc.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
for _ in 0..20 {
|
|
||||||
if ipc.exists() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
sleep(Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn position(&self, feed: &FeedHit) -> Result<()> {
|
|
||||||
let title = format!("enboxer-vfx:{}", feed.name);
|
|
||||||
let win = hypr::clients()
|
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|c| c.class == "enboxer-vfx" || c.title == title || c.title.contains(&feed.name));
|
.any(|c| c.class == class && c.title.contains(&feed.name));
|
||||||
if let Some(win) = win {
|
if !already {
|
||||||
|
let title = format!("enboxer-vfx:{}", feed.name);
|
||||||
|
let file = path.display().to_string();
|
||||||
|
// imv reloads on disk change; feh --reload too. Prefer imv.
|
||||||
|
let cmd = format!(
|
||||||
|
"imv -w {class} -t {title:?} {file:?} || feh --title {title:?} --reload 0.2 {file:?} || mpv --title={title:?} --loop=inf --image-display-duration=0.1 {file:?}"
|
||||||
|
);
|
||||||
|
hypr::eval_lua(&format!(
|
||||||
|
"hl.dispatch(hl.dsp.exec_cmd({cmd:?}, {{ float = true, pin = true, class = {class:?} }}))"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
if let Some(win) = hypr::clients()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.class == class || c.title.contains(&feed.name))
|
||||||
|
{
|
||||||
let (x, y, w, h) = feed.viewer;
|
let (x, y, w, h) = feed.viewer;
|
||||||
hypr::move_resize_window(&win.address_selector(), x, y, w, h)
|
hypr::move_resize_window(&win.address_selector(), x, y, w, h)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn show_frame(&self, name: &str, png: &Path) -> Result<()> {
|
|
||||||
let ipc = {
|
|
||||||
let procs = self.procs.lock().await;
|
|
||||||
procs.get(name).map(|p| p.ipc.clone())
|
|
||||||
};
|
|
||||||
let Some(ipc) = ipc else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
if !ipc.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let cmd = serde_json::json!({
|
|
||||||
"command": ["loadfile", png.to_string_lossy(), "replace"]
|
|
||||||
});
|
|
||||||
let mut s = UnixStream::connect(&ipc).await?;
|
|
||||||
s.write_all(cmd.to_string().as_bytes()).await?;
|
|
||||||
s.write_all(b"\n").await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn kill_all(&self) {
|
|
||||||
let mut procs = self.procs.lock().await;
|
|
||||||
for (_, mut p) in procs.drain() {
|
|
||||||
let _ = p.child.start_kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn capture_loop(rx: watch::Receiver<Vec<FeedHit>>, hub: Arc<OverlayHub>) {
|
|
||||||
let mut tick = interval(Duration::from_millis(80));
|
|
||||||
loop {
|
|
||||||
tick.tick().await;
|
|
||||||
let feeds = rx.borrow().clone();
|
|
||||||
for f in feeds {
|
|
||||||
let dest = frame_path(&f.name);
|
|
||||||
let (x, y, w, h) = f.source;
|
|
||||||
if capture_region(x, y, w, h, &dest).await.is_ok() {
|
|
||||||
let _ = hub.show_frame(&f.name, &dest).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -358,86 +161,4 @@ mod tests {
|
|||||||
assert_eq!(map_click(&f, 200, 200), (1200, 650), "center");
|
assert_eq!(map_click(&f, 200, 200), (1200, 650), "center");
|
||||||
assert_eq!(map_click(&f, 250, 250), (1300, 725), "lower-right");
|
assert_eq!(map_click(&f, 250, 250), (1300, 725), "lower-right");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn monitor(x: i32, y: i32, w: i32, h: i32) -> Monitor {
|
|
||||||
Monitor { name: "DP-1".into(), x, y, width: w, height: h }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn win(addr: &str, at: (i32, i32), size: (i32, i32)) -> Client {
|
|
||||||
Client {
|
|
||||||
address: addr.into(),
|
|
||||||
class: "wow".into(),
|
|
||||||
title: addr.into(),
|
|
||||||
pid: 1,
|
|
||||||
at: [at.0, at.1],
|
|
||||||
size: [size.0, size.1],
|
|
||||||
mapped: true,
|
|
||||||
hidden: false,
|
|
||||||
xwayland: true,
|
|
||||||
focus_history_id: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_window_visible_when_inside_monitor() {
|
|
||||||
let mons = vec![monitor(0, 0, 1920, 1080)];
|
|
||||||
let w = win("0xa", (100, 100), (800, 600));
|
|
||||||
assert!(is_window_visible(&mons, &w));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_window_visible_when_partially_overlapping_monitor() {
|
|
||||||
let mons = vec![monitor(0, 0, 1920, 1080)];
|
|
||||||
// Half off the right edge of the monitor
|
|
||||||
let w = win("0xa", (1700, 100), (800, 600));
|
|
||||||
assert!(is_window_visible(&mons, &w));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_window_not_visible_when_fully_off_monitor() {
|
|
||||||
let mons = vec![monitor(0, 0, 1920, 1080)];
|
|
||||||
// Above the monitor
|
|
||||||
let w = win("0xa", (0, -2000), (800, 600));
|
|
||||||
assert!(!is_window_visible(&mons, &w));
|
|
||||||
// Right of the monitor
|
|
||||||
let w = win("0xa", (2000, 0), (800, 600));
|
|
||||||
assert!(!is_window_visible(&mons, &w));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_window_not_visible_when_monitor_list_is_empty() {
|
|
||||||
let w = win("0xa", (0, 0), (800, 600));
|
|
||||||
assert!(!is_window_visible(&[], &w));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_window_visible_uses_either_monitor_in_a_multi_setup() {
|
|
||||||
// Two side-by-side monitors
|
|
||||||
let mons = vec![monitor(0, 0, 1920, 1080), monitor(1920, 0, 1920, 1080)];
|
|
||||||
let w = win("0xa", (2500, 100), (800, 600));
|
|
||||||
assert!(is_window_visible(&mons, &w));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn toplevel_enabled_defaults_off_and_respects_env() {
|
|
||||||
std::env::remove_var("ENBOXER_ENABLE_TOPLEVEL");
|
|
||||||
assert!(!toplevel_enabled());
|
|
||||||
std::env::set_var("ENBOXER_ENABLE_TOPLEVEL", "1");
|
|
||||||
assert!(toplevel_enabled());
|
|
||||||
std::env::set_var("ENBOXER_ENABLE_TOPLEVEL", "true");
|
|
||||||
assert!(toplevel_enabled());
|
|
||||||
std::env::set_var("ENBOXER_ENABLE_TOPLEVEL", "yes");
|
|
||||||
assert!(!toplevel_enabled());
|
|
||||||
std::env::remove_var("ENBOXER_ENABLE_TOPLEVEL");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capture_toplevel_is_gated_when_disabled() {
|
|
||||||
std::env::remove_var("ENBOXER_ENABLE_TOPLEVEL");
|
|
||||||
let w = win("0xa", (0, 0), (800, 600));
|
|
||||||
let dest = std::env::temp_dir().join("enboxer_test_toplevel_gated.png");
|
|
||||||
let result = capture_toplevel(&w, &dest).await;
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(result.unwrap_err().to_string().contains("ENBOXER_ENABLE_TOPLEVEL"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,624 +0,0 @@
|
|||||||
//! Live wlr-layer-shell slot overlay.
|
|
||||||
//!
|
|
||||||
// One background thread per active slot. The thread owns its Wayland
|
|
||||||
//! connection, creates a top-layer surface anchored at the slot's
|
|
||||||
//! top-left, draws the slot number into a shm-backed buffer, and
|
|
||||||
//! listens for pointer button events. A button event sends
|
|
||||||
//! `swap <slot>` to the daemon's unix socket.
|
|
||||||
//!
|
|
||||||
//! ## Safety
|
|
||||||
//!
|
|
||||||
//! All live spawning is gated behind [`crate::overlay::live_enabled`].
|
|
||||||
//! `cargo test`, `enboxer doctor`, and any path that does not set
|
|
||||||
//! `ENBOXER_ENABLE_OVERLAY=1` returns a placeholder handle from
|
|
||||||
//! `crate::overlay::spawn` and **never** opens a Wayland connection.
|
|
||||||
//! The user's Hyprland session is therefore never touched unless they
|
|
||||||
//! explicitly opt in.
|
|
||||||
//!
|
|
||||||
//! ## Status
|
|
||||||
//!
|
|
||||||
//! The protocol code, surface setup, shm-backed buffer with bitmap
|
|
||||||
//! digits, and click-to-IPC path are real. The thread draws once on
|
|
||||||
//! commit and listens for clicks. Per T9 the badge is readable (3x5
|
|
||||||
//! digits, ARGB8888, opaque background, solid foreground).
|
|
||||||
|
|
||||||
use crate::overlay::OverlayRect;
|
|
||||||
use crate::profile::runtime_dir;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::os::unix::io::AsFd;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::thread::JoinHandle;
|
|
||||||
use wayland_client::protocol::{
|
|
||||||
wl_buffer, wl_compositor, wl_display, wl_keyboard, wl_output, wl_pointer, wl_registry,
|
|
||||||
wl_seat, wl_shm, wl_shm_pool, wl_surface,
|
|
||||||
};
|
|
||||||
use wayland_client::{Connection, Dispatch, QueueHandle};
|
|
||||||
use wayland_protocols_wlr::layer_shell::v1::client::{
|
|
||||||
zwlr_layer_shell_v1, zwlr_layer_surface_v1,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 3x5 bitmap font for digits 0..=9. `1` = set pixel, `0` = blank.
|
|
||||||
/// Multi-digit numbers lay the digit bitmaps side-by-side with a 1-pixel gap.
|
|
||||||
pub const DIGIT_W: usize = 3;
|
|
||||||
pub const DIGIT_H: usize = 5;
|
|
||||||
|
|
||||||
/// Pure: 3x5 bitmap for a single decimal digit. Panics in debug if `d > 9`.
|
|
||||||
pub fn digit_bitmap(d: u8) -> [[u8; DIGIT_W]; DIGIT_H] {
|
|
||||||
match d {
|
|
||||||
0 => [[1, 1, 1], [1, 0, 1], [1, 0, 1], [1, 0, 1], [1, 1, 1]],
|
|
||||||
1 => [[0, 1, 0], [1, 1, 0], [0, 1, 0], [0, 1, 0], [1, 1, 1]],
|
|
||||||
2 => [[1, 1, 1], [0, 0, 1], [1, 1, 1], [1, 0, 0], [1, 1, 1]],
|
|
||||||
3 => [[1, 1, 1], [0, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]],
|
|
||||||
4 => [[1, 0, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [0, 0, 1]],
|
|
||||||
5 => [[1, 1, 1], [1, 0, 0], [1, 1, 1], [0, 0, 1], [1, 1, 1]],
|
|
||||||
6 => [[1, 1, 1], [1, 0, 0], [1, 1, 1], [1, 0, 1], [1, 1, 1]],
|
|
||||||
7 => [[1, 1, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]],
|
|
||||||
8 => [[1, 1, 1], [1, 0, 1], [1, 1, 1], [1, 0, 1], [1, 1, 1]],
|
|
||||||
9 => [[1, 1, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]],
|
|
||||||
other => panic!("digit_bitmap: out of range {other}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure: layout for `slot` number (1..=99) — each digit at (x_off, y) with a 1-pixel gap.
|
|
||||||
fn slot_layout(slot: u32) -> Vec<([[u8; DIGIT_W]; DIGIT_H], usize)> {
|
|
||||||
let s = slot.max(1);
|
|
||||||
if s < 10 {
|
|
||||||
vec![(digit_bitmap(s as u8), 0)]
|
|
||||||
} else {
|
|
||||||
let tens = (s / 10) as u8;
|
|
||||||
let ones = (s % 10) as u8;
|
|
||||||
let gap = 1usize;
|
|
||||||
vec![
|
|
||||||
(digit_bitmap(tens), 0),
|
|
||||||
(digit_bitmap(ones), DIGIT_W + gap),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure: write `slot`'s number into a fresh ARGB8888 buffer. `bg` is the
|
|
||||||
/// opaque background (alpha forced to 255); `fg` is the foreground.
|
|
||||||
pub fn render_overlay(
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
slot: u32,
|
|
||||||
bg: [u8; 4],
|
|
||||||
fg: [u8; 4],
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let mut buf = vec![0u8; (width * height * 4) as usize];
|
|
||||||
for px in buf.as_chunks_mut::<4>().0 {
|
|
||||||
px[0] = bg[0];
|
|
||||||
px[1] = bg[1];
|
|
||||||
px[2] = bg[2];
|
|
||||||
px[3] = bg[3];
|
|
||||||
}
|
|
||||||
let digits = slot_layout(slot);
|
|
||||||
let total_w: usize = digits
|
|
||||||
.iter()
|
|
||||||
.map(|(_d, off)| DIGIT_W + off)
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let off_x = ((width as usize).saturating_sub(total_w)) / 2;
|
|
||||||
let off_y = ((height as usize).saturating_sub(DIGIT_H)) / 2;
|
|
||||||
for (bitmap, x_off) in digits {
|
|
||||||
for (y, row) in bitmap.iter().enumerate() {
|
|
||||||
for (x, &on) in row.iter().enumerate() {
|
|
||||||
if on == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let gx = off_x + x_off + x;
|
|
||||||
let gy = off_y + y;
|
|
||||||
if gx >= width as usize || gy >= height as usize {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let i = (gy * width as usize + gx) * 4;
|
|
||||||
buf[i] = fg[0];
|
|
||||||
buf[i + 1] = fg[1];
|
|
||||||
buf[i + 2] = fg[2];
|
|
||||||
buf[i + 3] = fg[3];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `pixels` to a tmpfs file in `runtime_dir()` and return the open
|
|
||||||
/// `File`. The Wayland compositor holds a dup of the fd; once this handle
|
|
||||||
/// drops the kernel keeps the inode alive until both enBoxer and the
|
|
||||||
/// compositor close it. The file lives under `$XDG_RUNTIME_DIR/enboxer/`,
|
|
||||||
/// so it goes away on reboot.
|
|
||||||
fn shm_pool_file(slot: u32, pixels: &[u8]) -> anyhow::Result<File> {
|
|
||||||
let dir = runtime_dir();
|
|
||||||
std::fs::create_dir_all(&dir).ok();
|
|
||||||
let path = dir.join(format!("enboxer-overlay-{slot}.bin"));
|
|
||||||
let mut f = File::create(&path)
|
|
||||||
.with_context(|| format!("create shm pool file {}", path.display()))?;
|
|
||||||
f.write_all(pixels)?;
|
|
||||||
f.sync_all().ok();
|
|
||||||
Ok(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
|
|
||||||
/// State shared between Wayland event handlers and the spawning thread.
|
|
||||||
struct OverlayState {
|
|
||||||
slot: u32,
|
|
||||||
ipc_sock: PathBuf,
|
|
||||||
compositor: Option<wl_compositor::WlCompositor>,
|
|
||||||
layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
|
|
||||||
shm: Option<wl_shm::WlShm>,
|
|
||||||
seat: Option<wl_seat::WlSeat>,
|
|
||||||
surface: Option<wl_surface::WlSurface>,
|
|
||||||
layer_surface: Option<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1>,
|
|
||||||
buffer: Option<wl_buffer::WlBuffer>,
|
|
||||||
configured: bool,
|
|
||||||
exited: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle to a live overlay thread. Drop or call `shutdown` to stop the
|
|
||||||
/// thread cleanly (it will detach if not joined).
|
|
||||||
pub struct LiveOverlayHandle {
|
|
||||||
pub slot: u32,
|
|
||||||
pub rect: OverlayRect,
|
|
||||||
join: Option<JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LiveOverlayHandle {
|
|
||||||
pub fn shutdown(mut self) {
|
|
||||||
if let Some(j) = self.join.take() {
|
|
||||||
let _ = j.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn one live overlay thread. Connects to Wayland, creates the
|
|
||||||
/// layer surface, draws the slot number into a shm-backed buffer, and
|
|
||||||
/// posts `swap <slot>` to `ipc_sock` on pointer button events inside the
|
|
||||||
/// surface. Errors during connect are returned; setup errors after
|
|
||||||
/// connect are logged and the thread exits.
|
|
||||||
pub fn spawn(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<LiveOverlayHandle> {
|
|
||||||
let join = std::thread::Builder::new()
|
|
||||||
.name(format!("enboxer-overlay-{slot}"))
|
|
||||||
.spawn(move || match run(slot, rect, ipc_sock) {
|
|
||||||
Ok(()) => tracing::debug!("overlay slot {slot} exited cleanly"),
|
|
||||||
Err(e) => tracing::warn!("overlay slot {slot}: {e}"),
|
|
||||||
})?;
|
|
||||||
Ok(LiveOverlayHandle {
|
|
||||||
slot,
|
|
||||||
rect,
|
|
||||||
join: Some(join),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<()> {
|
|
||||||
let conn = Connection::connect_to_env()?;
|
|
||||||
let display = conn.display();
|
|
||||||
let mut event_queue = conn.new_event_queue::<OverlayState>();
|
|
||||||
let qh = event_queue.handle();
|
|
||||||
// First roundtrip: bind registry, then run a stub state through one
|
|
||||||
// dispatch so registry events are delivered.
|
|
||||||
let _registry = display.get_registry(&qh, ());
|
|
||||||
let mut stub = OverlayState::new(slot, ipc_sock.clone());
|
|
||||||
event_queue.roundtrip(&mut stub)?;
|
|
||||||
if !stub.have_bindings() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"zwlr_layer_shell_v1 / wl_compositor / wl_shm not all advertised by the compositor"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Take ownership of the bound globals into our real state.
|
|
||||||
let mut state = OverlayState::new(slot, ipc_sock);
|
|
||||||
state.compositor = stub.compositor.take();
|
|
||||||
state.layer_shell = stub.layer_shell.take();
|
|
||||||
state.shm = stub.shm.take();
|
|
||||||
state.seat = stub.seat.take();
|
|
||||||
// Create surface + layer surface.
|
|
||||||
let surface = state
|
|
||||||
.compositor
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.create_surface(&qh, ());
|
|
||||||
let layer_shell = state.layer_shell.as_ref().unwrap();
|
|
||||||
let layer = layer_shell.get_layer_surface(
|
|
||||||
&surface,
|
|
||||||
None,
|
|
||||||
zwlr_layer_shell_v1::Layer::Top,
|
|
||||||
format!("enboxer-slot-{slot}"),
|
|
||||||
&qh,
|
|
||||||
(),
|
|
||||||
);
|
|
||||||
layer.set_anchor(zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Left);
|
|
||||||
layer.set_size(rect.w.max(1) as u32, rect.h.max(1) as u32);
|
|
||||||
layer.set_exclusive_zone(-1);
|
|
||||||
// Margins encode the offset: with TOP+LEFT anchor, a positive top
|
|
||||||
// margin pushes the surface down; positive left pushes it right.
|
|
||||||
layer.set_margin(rect.y.max(0), rect.x.max(0), 0, 0);
|
|
||||||
layer.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None);
|
|
||||||
state.surface = Some(surface.clone());
|
|
||||||
state.layer_surface = Some(layer);
|
|
||||||
// Seat so we get pointer events.
|
|
||||||
if let Some(seat) = state.seat.as_ref() {
|
|
||||||
seat.get_pointer(&qh, ());
|
|
||||||
let _ = seat.get_keyboard(&qh, ());
|
|
||||||
}
|
|
||||||
// Shm pool + buffer with the slot number drawn.
|
|
||||||
let w = rect.w.max(1) as u32;
|
|
||||||
let h = rect.h.max(1) as u32;
|
|
||||||
let pixels = render_overlay(w, h, slot, [40, 40, 40, 255], [240, 240, 240, 255]);
|
|
||||||
let pool_file = shm_pool_file(slot, &pixels)?;
|
|
||||||
let pool = state.shm.as_ref().unwrap().create_pool(
|
|
||||||
pool_file.as_fd(),
|
|
||||||
pixels.len() as i32,
|
|
||||||
&qh,
|
|
||||||
(),
|
|
||||||
);
|
|
||||||
let buffer = pool.create_buffer(
|
|
||||||
0,
|
|
||||||
w as i32,
|
|
||||||
h as i32,
|
|
||||||
(w * 4) as i32,
|
|
||||||
wl_shm::Format::Argb8888,
|
|
||||||
&qh,
|
|
||||||
(),
|
|
||||||
);
|
|
||||||
pool.destroy();
|
|
||||||
state.buffer = Some(buffer.clone());
|
|
||||||
surface.attach(Some(&buffer), 0, 0);
|
|
||||||
surface.commit();
|
|
||||||
while !state.exited {
|
|
||||||
if let Err(e) = event_queue.blocking_dispatch(&mut state) {
|
|
||||||
tracing::warn!("overlay slot {slot}: dispatch: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OverlayState {
|
|
||||||
fn new(slot: u32, ipc_sock: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
slot,
|
|
||||||
ipc_sock,
|
|
||||||
compositor: None,
|
|
||||||
layer_shell: None,
|
|
||||||
shm: None,
|
|
||||||
seat: None,
|
|
||||||
surface: None,
|
|
||||||
layer_surface: None,
|
|
||||||
buffer: None,
|
|
||||||
configured: false,
|
|
||||||
exited: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn have_bindings(&self) -> bool {
|
|
||||||
self.compositor.is_some() && self.layer_shell.is_some() && self.shm.is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_swap(slot: u32, sock: &PathBuf) {
|
|
||||||
let line = format!("swap {slot}\n");
|
|
||||||
match std::os::unix::net::UnixStream::connect(sock) {
|
|
||||||
Ok(mut s) => {
|
|
||||||
use std::io::Write;
|
|
||||||
let _ = s.write_all(line.as_bytes());
|
|
||||||
}
|
|
||||||
Err(e) => tracing::debug!("overlay: click IPC: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Dispatch implementations for the wayland types we touch. ----
|
|
||||||
//
|
|
||||||
// The shape here is mandated by wayland-client 0.31: every proxy type we
|
|
||||||
// keep needs `Dispatch<Proxy, UserData> for AppState`. We use `()` for
|
|
||||||
// user-data; nothing in this overlay needs per-object state.
|
|
||||||
|
|
||||||
impl Dispatch<wl_registry::WlRegistry, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
registry: &wl_registry::WlRegistry,
|
|
||||||
event: wl_registry::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
qh: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
if let wl_registry::Event::Global {
|
|
||||||
name,
|
|
||||||
interface,
|
|
||||||
version,
|
|
||||||
} = event
|
|
||||||
{
|
|
||||||
match interface.as_str() {
|
|
||||||
"wl_compositor" => {
|
|
||||||
state.compositor =
|
|
||||||
Some(registry.bind::<wl_compositor::WlCompositor, _, _>(name, version, qh, ()));
|
|
||||||
}
|
|
||||||
"zwlr_layer_shell_v1" => {
|
|
||||||
state.layer_shell = Some(
|
|
||||||
registry.bind::<zwlr_layer_shell_v1::ZwlrLayerShellV1, _, _>(
|
|
||||||
name, version, qh, (),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"wl_shm" => {
|
|
||||||
state.shm = Some(registry.bind::<wl_shm::WlShm, _, _>(name, version, qh, ()));
|
|
||||||
}
|
|
||||||
"wl_seat" => {
|
|
||||||
state.seat =
|
|
||||||
Some(registry.bind::<wl_seat::WlSeat, _, _>(name, version, qh, ()));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_compositor::WlCompositor, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_compositor::WlCompositor,
|
|
||||||
_: wl_compositor::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_shm::WlShm, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_shm::WlShm,
|
|
||||||
_: wl_shm::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_shm_pool::WlShmPool, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_shm_pool::WlShmPool,
|
|
||||||
_: wl_shm_pool::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_buffer::WlBuffer, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_buffer::WlBuffer,
|
|
||||||
_: wl_buffer::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_surface::WlSurface, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_surface::WlSurface,
|
|
||||||
_: wl_surface::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_seat::WlSeat, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_seat::WlSeat,
|
|
||||||
_: wl_seat::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_output::WlOutput, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_output::WlOutput,
|
|
||||||
_: wl_output::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_keyboard::WlKeyboard, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_keyboard::WlKeyboard,
|
|
||||||
_: wl_keyboard::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_pointer::WlPointer, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
_: &wl_pointer::WlPointer,
|
|
||||||
event: wl_pointer::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
if let wl_pointer::Event::Button { button, state: btn_state, .. } = event {
|
|
||||||
// linux/input-event-codes: BTN_LEFT = 272. We only fire on press.
|
|
||||||
if button == 272
|
|
||||||
&& matches!(btn_state, wayland_client::WEnum::Value(wl_pointer::ButtonState::Pressed))
|
|
||||||
{
|
|
||||||
send_swap(state.slot, &state.ipc_sock);
|
|
||||||
state.exited = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_display::WlDisplay, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &wl_display::WlDisplay,
|
|
||||||
_: wl_display::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<zwlr_layer_shell_v1::ZwlrLayerShellV1, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
_: &mut Self,
|
|
||||||
_: &zwlr_layer_shell_v1::ZwlrLayerShellV1,
|
|
||||||
_: zwlr_layer_shell_v1::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for OverlayState {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
_: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
|
|
||||||
event: zwlr_layer_surface_v1::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
match event {
|
|
||||||
zwlr_layer_surface_v1::Event::Closed => state.exited = true,
|
|
||||||
zwlr_layer_surface_v1::Event::Configure { .. } => state.configured = true,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- T9 protocol/format parse test fixture. ----
|
|
||||||
//
|
|
||||||
// Wayland-scanner parses XML at build time, so we don't parse at runtime.
|
|
||||||
// Instead, we hold the constants we expect to match and assert that the
|
|
||||||
// generated bindings expose them with the right names. This is the
|
|
||||||
// equivalent of "the dispatch table parses a known XML" — if Hyprland
|
|
||||||
// ever revved the protocol past version 5 and we silently kept using an
|
|
||||||
// older binding, this test would still build (wayland-scanner picks the
|
|
||||||
// max) but the constant check would catch a documentation drift.
|
|
||||||
|
|
||||||
/// The protocol name as it appears in the wlr-layer-shell XML.
|
|
||||||
pub const LAYER_SHELL_PROTOCOL: &str = "wlr_layer_shell_unstable_v1";
|
|
||||||
/// The interface name we bind to for the layer shell manager.
|
|
||||||
pub const LAYER_SHELL_INTERFACE: &str = "zwlr_layer_shell_v1";
|
|
||||||
/// The interface name we bind to for one layer surface.
|
|
||||||
pub const LAYER_SURFACE_INTERFACE: &str = "zwlr_layer_surface_v1";
|
|
||||||
/// The version we ask the compositor for. Hyprland advertises >= 4; 5
|
|
||||||
/// matches the XML in `wlr-protocols` at the time of writing.
|
|
||||||
pub const LAYER_SHELL_VERSION: u32 = 5;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::overlay::live_enabled;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn digit_zero_has_open_centre() {
|
|
||||||
let b = digit_bitmap(0);
|
|
||||||
assert_eq!(b[1][1], 0);
|
|
||||||
assert_eq!(b[2][1], 0);
|
|
||||||
assert_eq!(b[3][1], 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn digit_nine_has_solid_top_and_bottom() {
|
|
||||||
let b = digit_bitmap(9);
|
|
||||||
assert_eq!(b[0], [1, 1, 1]);
|
|
||||||
assert_eq!(b[4], [1, 1, 1]);
|
|
||||||
assert_eq!(b[1][0], 1);
|
|
||||||
assert_eq!(b[1][2], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_overlay_is_opaque_when_fg_alpha_255() {
|
|
||||||
let buf = render_overlay(96, 96, 7, [0, 0, 0, 255], [255, 255, 255, 255]);
|
|
||||||
// The top-left pixel is background → alpha = 255 from bg.
|
|
||||||
assert_eq!(buf[3], 255);
|
|
||||||
// Find a foreground pixel (the 7's top bar). Should exist and be white.
|
|
||||||
let mut found = false;
|
|
||||||
for px in buf.as_chunks::<4>().0 {
|
|
||||||
if px[0] == 255 && px[1] == 255 && px[2] == 255 {
|
|
||||||
found = true;
|
|
||||||
assert_eq!(px[3], 255);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(found, "expected at least one foreground pixel");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_overlay_two_digit_slot_centres_layout() {
|
|
||||||
// For slot 12, total digit width = 7 px (3 + 1 gap + 3), centred at x = 44.
|
|
||||||
// The first fg pixel of "1" sits at (44, off_y+1) because row 1 of digit 1
|
|
||||||
// = [1, 1, 0], so column 0 and 1 are set.
|
|
||||||
let buf = render_overlay(96, 96, 12, [0, 0, 0, 255], [255, 255, 255, 255]);
|
|
||||||
let off_x = (96usize - 7) / 2;
|
|
||||||
let off_y = (96usize - 5) / 2;
|
|
||||||
let i = ((off_y + 1) * 96 + off_x) * 4;
|
|
||||||
assert_eq!(buf[i..i + 4], [255, 255, 255, 255]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protocol_constants_match_xml() {
|
|
||||||
// wlr-layer-shell-unstable-v1.xml:
|
|
||||||
// <protocol name="wlr_layer_shell_unstable_v1">
|
|
||||||
// <interface name="zwlr_layer_shell_v1" version="5">
|
|
||||||
// <request name="get_layer_surface">
|
|
||||||
// <arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
|
|
||||||
assert_eq!(LAYER_SHELL_PROTOCOL, "wlr_layer_shell_unstable_v1");
|
|
||||||
assert_eq!(LAYER_SHELL_INTERFACE, "zwlr_layer_shell_v1");
|
|
||||||
assert_eq!(LAYER_SURFACE_INTERFACE, "zwlr_layer_surface_v1");
|
|
||||||
assert_eq!(LAYER_SHELL_VERSION, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn module_compiles_and_exposes_bindings() {
|
|
||||||
// The generated bindings are accessible at this module path. If the
|
|
||||||
// wayland-scanner ever dropped the module, this fails to compile.
|
|
||||||
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
|
||||||
let _ = std::any::type_name::<ZwlrLayerShellV1>();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn env_gate_off_means_no_live_spawn() {
|
|
||||||
std::env::remove_var("ENBOXER_ENABLE_OVERLAY");
|
|
||||||
assert!(!live_enabled());
|
|
||||||
// We deliberately do not call `super::spawn` here: it would try to
|
|
||||||
// open a Wayland connection. The env-gate lives in
|
|
||||||
// `crate::overlay::spawn` and is asserted by
|
|
||||||
// `crate::overlay::tests::spawn_returns_stub_when_live_disabled`.
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_digit_table_is_unique() {
|
|
||||||
let bits: Vec<[[u8; 3]; 5]> = (0u8..=9).map(digit_bitmap).collect();
|
|
||||||
for (i, a) in bits.iter().enumerate() {
|
|
||||||
for (j, b) in bits.iter().enumerate().skip(i + 1) {
|
|
||||||
assert_ne!(a, b, "digits {i} and {j} share a bitmap");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user