commit 4daf0ea86abf74c020b7d0ed61655423a5774117 Author: en Date: Tue Sep 15 07:19:15 2026 +0200 Initial enBoxer: mapped-key routing and Video FX on Hyprland. Phase 1 tickets T1–T6, ponytail dead-code cuts, docs and plan in-tree. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28be6be --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target/ +**/*.rs.bk +.idea/ +.vscode/ +*.swp +.env +/tmp/ diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..caa8efd --- /dev/null +++ b/AGENT.md @@ -0,0 +1,48 @@ +# AGENT.md — enBoxer + +Contract for this repo. The **plan** lives in [PLAN.md](PLAN.md). Tickets live in [TASKS.md](TASKS.md). Do not mix roles. + +## Roles + +| Role | Who | Job | +| --- | --- | --- | +| Designer + project manager + reviewer | Grok | Own the product. Write `PLAN.md` and every ticket for a phase **before** the worker runs that phase. After the worker finishes the phase, check the diff against the tickets. If wrong, file a smaller ticket and send it back. Do not implement worker tickets. | +| Worker | OpenCode | Implement **one** open ticket at a time. Must load **ponytail** (`opencode.json`). Must not use `--pure`. Must not run ponytail-review. | + +## Sequence (do not skip) + +1. Grok **finishes giving tasks** (all tickets for the phase are on the Open list in `TASKS.md`). +2. OpenCode works those tickets. +3. Grok does **not** run ponytail-review until step 1 is done **and** the worker has closed those tickets (or blocked). +4. Grok reviews against tickets (ordinary accept / bounce). +5. Only when Grok accepts the phase does **Grok** run ponytail-review. +6. Then Grok pushes the git remote. + +While OpenCode is running, Grok does not edit the same source files. + +## OpenCode + +1. Directory: `~/git/enBoxer` +2. Plugin: `@dietrichgebert/ponytail` via this repo’s `opencode.json` +3. No `--pure` +4. Read `PLAN.md` + `TASKS.md`; first **Open** ticket only (or the tickets named in the run prompt, in order) +5. Leave ponytail on +6. `cargo test` green before stop +7. No extra features + +```bash +opencode run --dir /home/en/git/enBoxer --auto --title "enBoxer tickets" \ + "Read AGENT.md, PLAN.md, TASKS.md. Ponytail plugin required. Do open tickets in order. Do not run ponytail-review." +``` + +## Product + +Mapped keys to several WoW windows on Hyprland. Unmapped keys stay on the primary. `passthrough` is optional (example ESDF). No injection. Macros, not addons. Video FX is a crop with hover pass-through. + +## Review (Grok, after the worker, still not ponytail) + +- Diff matches the tickets +- `cargo test` green +- Example `passthrough` still ESDF; empty list still means no skip +- No game injection +- No new deps without a ticket diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3530e43 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 0.1.0 — unreleased + +- Rust CLI daemon: `enboxer run|press|status|macros|doctor` +- YAML profile: maps, targets (`current` / `others` / `all` / groups), `game_binds` +- Configurable `passthrough` (empty by default; example profile uses ESDF) +- Hyprland 0.56 Lua binds installed only while a managed game or VFX overlay is focused +- Key delivery via `hl.dsp.send_shortcut` / `send_key_state` (no game injection) +- 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 +- Unit tests for hotkey parse, passthrough, targets, loot sequence, example profile load diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5c6b01e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,684 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "enboxer" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "directories", + "pretty_assertions", + "regex", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..66fe765 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "enboxer" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-or-later" +description = "Mapped-key routing and Video FX for World of Warcraft on Hyprland" +repository = "https://gitea.nettsi.de/en/enBoxer" +readme = "README.md" +authors = ["en"] + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +directories = "6" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +tokio = { version = "1", features = [ + "fs", + "io-util", + "macros", + "net", + "process", + "rt-multi-thread", + "sync", + "time", +] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +pretty_assertions = "1" diff --git a/DESCRIPTION.md b/DESCRIPTION.md new file mode 100644 index 0000000..c321bb8 --- /dev/null +++ b/DESCRIPTION.md @@ -0,0 +1,11 @@ +# enBoxer + +Linux (Arch + Hyprland) controller for several World of Warcraft windows. Rust. GPL-3.0-or-later. + +It intercepts **named mapped keys** and sends steps to chosen slots. Unmapped keys stay on the focused primary. `passthrough` can leave movement alone (example: ESDF). Alt-tab away and the machine is a normal PC. + +Keys are compositor input (`hl.dsp.send_shortcut`). Nothing is injected into the game. In-game macros are documented; no addon in v1. + +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/enBoxer diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a6865f --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..e100647 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,68 @@ +# Plan + +enBoxer is a new Hyprland program. It routes keys to several World of Warcraft windows and can show a live crop of one window on another. + +## How work is split + +Two roles. They do not share a keyboard. + +| Role | Who | Does | +| --- | --- | --- | +| Designer + PM | Grok | Writes this file and `TASKS.md`. Splits the product into **small** tickets **before** the worker starts a phase. Does not implement those tickets. | +| Worker | OpenCode | Implements open tickets, **one at a time**, with the **ponytail plugin** on (`opencode.json`). Never `--pure`. Never runs ponytail-review. | + +Sequence for a phase: + +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`. + +OpenCode must not invent tickets, add features, or “clean up” docs unless a ticket says so. + +## Product rules (do not regress) + +- 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. + +## Phase 0 — tree and contract (Grok) + +- [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 +opencode run --dir /home/en/git/enBoxer --auto --title "enBoxer T4-T6" \ + "Read AGENT.md, PLAN.md, TASKS.md. Ponytail plugin required. Do open tickets in order, one at a time. Do not run ponytail-review." +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..df9dcb6 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# enBoxer + +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 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`. You bind macros in WoW yourself. + +| Doc | What | +| --- | --- | +| [PLAN.md](PLAN.md) | Product plan and how Grok / OpenCode split work | +| [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 | +| [docs/NOTES.md](docs/NOTES.md) | WoW interact / Hyprland capture | +| [docs/MACROS.md](docs/MACROS.md) | In-game macros | + +## Routing + +- A **map** is a hotkey plus steps (send a key, send a named `game_bind`, wait) plus a **target**. +- Unmapped keys are not intercepted. +- `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 + +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). + +## Video FX + +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. + +Optional Hyprland snippet: [examples/hyprland.lua](examples/hyprland.lua). + +## Build + +```bash +cd ~/git/enBoxer +cargo build --release +install -Dm755 target/release/enboxer ~/.local/bin/enboxer +``` + +Needs Hyprland 0.56 (Lua dispatchers), `hyprctl`, `grim`. A viewer (`imv` or `feh`) for Video FX overlays. + +## Use + +```bash +mkdir -p ~/.config/enboxer +cp examples/profile.yaml ~/.config/enboxer/profile.yaml +# edit window_match, passthrough, maps, character names + +enboxer doctor -c ~/.config/enboxer/profile.yaml +enboxer macros -c ~/.config/enboxer/profile.yaml +enboxer run -c ~/.config/enboxer/profile.yaml +``` + +`enboxer press Alt+G` fires a map without a Hyprland bind (daemon must be running). + +## License + +GPL-3.0-or-later. diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..3db4913 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,43 @@ +# OpenCode tickets + +Grok writes these (see `PLAN.md`). OpenCode takes **one ticket at a time**. Ponytail plugin required. Do not invent tickets. Do not start the next ticket until the current one is done and `cargo test` passes. + +Grok does **not** run ponytail-review until every ticket in this phase has been assigned (they all are) **and** the Open list is empty. + +## Open + +_(none — Phase 1 tickets all assigned and closed. Grok may run ponytail-review now.)_ + +## Closed + +### T1 — format + +`cargo fmt`. Done. + +### T2 — clippy, only what it names + +`cargo clippy --all-targets -- -D warnings`. Done. + +### T3 — stop if fmt/clippy/test are already clean + +Done (fmt/clippy/test were clean after T1–T2). + +### T4 — Video FX click pass-through + +Done (`mouse:272`/`273` → `map_click` + cursor + send to source; unit test). + +### T5 — log slots when the set changes + +Done. + +### T6 — empty passthrough can map `e` + +Done. + +## Out of scope for OpenCode + +- Gitea / git remotes (Grok) +- `hyprland-toplevel-export` +- Game addons +- New step kinds +- ponytail-review (Grok, after this phase is accepted) diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..e8f0c96 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,43 @@ +# Design + +enBoxer is a Hyprland session that sits beside several WoW windows. It is not loaded into the game. + +## Process + +``` +keyboard / mouse + │ +Hyprland (only while a managed WoW window or an enBoxer overlay is focused) + │ hl.bind (mapped keys, consumed) + ▼ +enboxer daemon ← unix socket ($XDG_RUNTIME_DIR/enboxer/enboxer.sock) + │ + ├─ mapped-key engine (targets, delays, hold, game_binds) + ├─ Lua: hl.dsp.send_shortcut | send_key_state + └─ Video FX: grim → overlay window +``` + +Delivery is compositor input to a window address. + +## Routing + +1. **Session off** (focus is not a captured slot and not an enBoxer overlay): no binds. Normal desktop. +2. **Mode `maps` (default):** only `maps[].hotkey` are intercepted. Everything else, including movement, goes to the focused primary. +3. **`passthrough`:** never intercepted, even if a map lists them. Default `[]`. Put `e s d f` here to keep those letters on the primary. +4. **Mode `repeater`:** also clone `repeater.keys` to other slots (explicit list). +5. **Mode `disabled`:** same as session off, with a hotkey to turn maps back on. +6. **Video FX hover + `pass_through`:** keys and clicks inside the viewer go to the **source slot only**. + +A map with `target: others` never delivers to the primary. A map with `target: all` consumes the OS key and re-sends to every slot including primary (no double-tap). + +## Interact / loot + +See `docs/MACROS.md`. Stock `loot` map: assist + CTM on + Interact with Target + delay + CTM off, all `others`. Delay is configurable; we do not spam interact. + +## Video FX + +Each feed is a source rect (slot + normalized box) plus a viewer rect on the current primary. Overlay class `enboxer-vfx`. Capture is `grim` of visible pixels. Covered windows need `hyprland-toplevel-export` (later). + +## Config + +One YAML profile (`~/.config/enboxer/profile.yaml` or `-c`). Reload maps without restarting WoW. diff --git a/docs/MACROS.md b/docs/MACROS.md new file mode 100644 index 0000000..bb847fc --- /dev/null +++ b/docs/MACROS.md @@ -0,0 +1,61 @@ +# In-game macros (no addon) + +enBoxer only sends keystrokes. Bind these in WoW on **every** account. Forever is not out; these are **retail-shaped**. If Interact with Target is missing on that client, the loot map will not walk or loot until a later helper (not shipped). + +Match keys to `game_binds` in the profile. + +## Click to Move + +**CTM On** — `game_binds.ctm_on` (example Shift+F3): + +``` +/console autoInteract 1 +``` + +**CTM Off** — `game_binds.ctm_off` (example Shift+F4): + +``` +/console autoInteract 0 +``` + +If `/console autoInteract` is rejected: + +``` +/run C_CVar.SetCVar("autoInteract", "1") +``` + +and `"0"` for off. + +## Assist / Follow + +Replace `MainName` with the character you play from. When you swap who is primary, update the names (no-addon tradeoff). Later we can emit one follow/assist bind per slot. + +**Assist** — `game_binds.assist` (example Shift+F2): + +``` +/assist MainName +``` + +**Follow** — `game_binds.follow` (example Shift+F1): + +``` +/follow MainName +``` + +## Interact with Target + +Key Bindings → Targeting → Interact with Target. No default. Example: `G`. Put the same combo in `game_binds.interact`. + +Do not bind it to a mouse button; that is unreliable while routing clicks. + +Turn **auto loot** on in the game if you want a corpse looted when they arrive. + +## One loot press + +1. Target the corpse or NPC on the primary. +2. Press the loot hotkey (example Alt+G). That key is consumed; the primary does not interact unless the map targets `current`. +3. Alts assist, CTM on, Interact with Target once, walk, interact or loot, CTM off after `interact.walk_delay_ms`. + +Do not mash. A second interact while they are still pathing is how characters run circles. + +If they stop short, raise `walk_delay_ms` (2500–4000), use `interact.style: auto` (CTM stays on), or `interact.style: hold` (CTM on until you release the hotkey). diff --git a/docs/NOTES.md b/docs/NOTES.md new file mode 100644 index 0000000..d0d5dd0 --- /dev/null +++ b/docs/NOTES.md @@ -0,0 +1,31 @@ +# Notes (WoW + Hyprland) + +## Interact with Target + +The game bind does what a right-click on the current target would do: attack, loot, gossip. With Click to Move (`autoInteract`), the character faces and walks to the target. They stop if the interact happens. They do not stop if they miss. Spamming the bind makes them circle. That is the game, not a timer in enBoxer. + +Walking is the client. `walk_delay_ms` only holds Click to Move on long enough to arrive, then turns it off so a stray click does not send people running. + +Styles: + +| Style | Click to Move | Interact | +| --- | --- | --- | +| standard | on, then off after the delay | once | +| auto | left on | once | +| hold | on while the hotkey is down | on press; CTM off on release | + +Macros: `docs/MACROS.md`. + +## Hyprland 0.56 + +Lua dispatchers. Example: + +```lua +hl.dispatch(hl.dsp.send_shortcut({ window = "address:0x…", mods = "ALT", key = "g" })) +hl.dispatch(hl.dsp.send_key_state({ window = "address:0x…", mods = "", key = "e", state = "down" })) +hl.bind("ALT + G", function() … end, { description = "enboxer:loot" }) +``` + +Binds exist only while a managed game window or an `enboxer-vfx` overlay is focused. + +Visible-pixel capture: `grim`. Covered windows: `hyprland-toplevel-export` (not wired yet). Overlay class: `enboxer-vfx`. diff --git a/examples/hyprland.lua b/examples/hyprland.lua new file mode 100644 index 0000000..80fee2b --- /dev/null +++ b/examples/hyprland.lua @@ -0,0 +1,13 @@ +-- Optional Hyprland 0.56 snippet. Merge into a user config file. +-- Overlay for Video FX viewers. + +hl.window_rule({ + match = { class = "enboxer-vfx" }, + float = true, + pin = true, + no_anim = true, + no_blur = true, + no_shadow = true, + rounding = 0, + border_size = 1, +}) diff --git a/examples/profile.yaml b/examples/profile.yaml new file mode 100644 index 0000000..1f0d8b5 --- /dev/null +++ b/examples/profile.yaml @@ -0,0 +1,112 @@ +# Copy to ~/.config/enboxer/profile.yaml and edit. +# Keys use Mod+Key (Alt+G, Shift+F3). Letters are case-insensitive. + +name: esdf-team +client: wow-retail +slots: 2 + +# Hyprland window match (regex). Wine/WoW class is often wow.exe or the wine prefix name. +window_match: + class: "(?i)wow|warcraft" + title: null + +# Keys that are NEVER intercepted. Empty = no skip list. +# ESDF movement stays on the primary even if you later add a map for those letters. +passthrough: ["e", "s", "d", "f"] + +mode_default: maps + +repeater: + enabled: false + keys: [] + +game_binds: + interact: "g" + ctm_on: "Shift+F3" + ctm_off: "Shift+F4" + assist: "Shift+F2" + follow: "Shift+F1" + +interact: + # standard: CTM on → IWT → wait → CTM off + # auto: CTM on → IWT (CTM stays on) + # hold: CTM on → IWT on press; CTM off on release + style: standard + walk_delay_ms: 2500 + +session_hotkeys: + mode_cycle: "Shift+Alt+M" + +characters: + - slot: 1 + name: Main + - slot: 2 + name: Alt + +groups: + alts: + slots: [2] + +maps: + - name: bar_1 + hotkey: "1" + steps: + - bind: assist + target: others + - key: "1" + target: all + + - name: bar_2 + hotkey: "2" + steps: + - bind: assist + target: others + - key: "2" + target: all + + - name: follow_me + hotkey: "Alt+F" + steps: + - bind: follow + target: others + + - name: assist_me + hotkey: "Alt+A" + steps: + - bind: assist + target: others + + - name: loot + hotkey: "Alt+G" + steps: + - bind: assist + target: others + - bind: ctm_on + target: others + - bind: interact + target: others + - delay_ms: 2500 + - bind: ctm_off + target: others + + - name: interact_hold + hotkey: "Alt+H" + hold: true + steps: + - bind: ctm_on + target: others + - bind: interact + target: others + release_steps: + - bind: ctm_off + target: others + +# Viewer sits on the current primary. Hover + pass_through sends keys/clicks to source_slot only. +video_fx: + - name: alt_party + enabled: true + source_slot: 2 + source: { x: 0.0, y: 0.0, w: 0.40, h: 0.40 } + viewer: { x: 0.02, y: 0.02, w: 0.28, h: 0.28 } + pass_through: true + fps: 12 diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..fb655e1 --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@dietrichgebert/ponytail"] +} diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..00c58c2 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,390 @@ +use crate::hotkey::passthrough_id; +use crate::profile::{Map, Mode, Profile, Step}; +use anyhow::{bail, Result}; +use std::collections::HashSet; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct Engine { + pub profile: Profile, + pub mode: Mode, + pub leader_slot: u32, + passthrough: HashSet, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Send { + key: String, + slots: Vec, + hold: Hold, + }, + Sleep(Duration), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Hold { + Tap, + Down, + Up, +} + +impl Engine { + pub fn new(profile: Profile) -> Result { + let mode = profile.mode_default; + let passthrough = profile.passthrough_set()?; + Ok(Self { + leader_slot: 1, + mode, + passthrough, + profile, + }) + } + + pub fn set_leader(&mut self, slot: u32) { + if slot >= 1 && slot <= self.profile.slots { + self.leader_slot = slot; + } + } + + pub fn cycle_mode(&mut self) -> Mode { + self.mode = self.mode.cycle(); + self.mode + } + + pub fn should_intercept(&self, hotkey: &str) -> bool { + if self.mode == Mode::Disabled { + return false; + } + if let Ok(id) = passthrough_id(hotkey) { + if self.passthrough.contains(&id) { + return false; + } + } + self.profile.map_by_hotkey(hotkey).is_some() + || (self.mode == Mode::Repeater && self.is_repeater_key(hotkey)) + } + + fn is_repeater_key(&self, hotkey: &str) -> bool { + let want = passthrough_id(hotkey).unwrap_or_else(|_| hotkey.to_string()); + self.profile.repeater.keys.iter().any(|k| { + k.eq_ignore_ascii_case(hotkey) || passthrough_id(k).ok().is_some_and(|id| id == want) + }) + } + + pub fn resolve_targets(&self, target: &str) -> Result> { + let all: Vec = (1..=self.profile.slots).collect(); + let leader = self.leader_slot; + match target { + "current" => Ok(vec![leader]), + "others" | "all_except_current" => { + Ok(all.into_iter().filter(|s| *s != leader).collect()) + } + "all" => Ok(all), + other if other.starts_with("group:") => { + let name = &other[6..]; + let g = self + .profile + .groups + .get(name) + .ok_or_else(|| anyhow::anyhow!("unknown group {name}"))?; + Ok(g.slots.clone()) + } + other if other.starts_with("slots:") => { + let mut out = Vec::new(); + for p in other[6..].split(',') { + let n: u32 = p.trim().parse()?; + out.push(n); + } + Ok(out) + } + other if other.starts_with("slot:") => Ok(vec![other[5..].parse()?]), + other => bail!("bad target {other:?}"), + } + } + + pub fn fire(&self, hotkey: &str, edge: Hold) -> Result> { + if !self.should_intercept(hotkey) { + return Ok(vec![]); + } + if let Some(map) = self.profile.map_by_hotkey(hotkey) { + let edge = if map.hold && matches!(edge, Hold::Tap) { + Hold::Down + } else { + edge + }; + return self.fire_map(map, edge); + } + if self.mode == Mode::Repeater && matches!(edge, Hold::Tap | Hold::Down) { + let slots = self.resolve_targets("others")?; + return Ok(vec![Action::Send { + key: hotkey.to_string(), + slots, + hold: if matches!(edge, Hold::Down) { + Hold::Down + } else { + Hold::Tap + }, + }]); + } + Ok(vec![]) + } + + fn fire_map(&self, map: &Map, edge: Hold) -> Result> { + let steps = match edge { + Hold::Up => &map.release_steps, + _ => &map.steps, + }; + let hold = if map.hold && matches!(edge, Hold::Down) { + Hold::Down + } else if map.hold && matches!(edge, Hold::Up) { + Hold::Up + } else { + Hold::Tap + }; + self.compile_steps(steps, hold) + } + + fn compile_steps(&self, steps: &[Step], hold: Hold) -> Result> { + let mut out = Vec::new(); + for step in steps { + if let Some(ms) = step.delay_ms { + out.push(Action::Sleep(Duration::from_millis(ms))); + continue; + } + let key = if let Some(k) = &step.key { + k.clone() + } else if let Some(b) = &step.bind { + self.profile.resolve_bind(b)? + } else { + continue; + }; + let slots = self.resolve_targets(&step.target)?; + out.push(Action::Send { key, slots, hold }); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hotkey::Hotkey; + use crate::profile::{Group, Interact, InteractStyle, Map, Profile, Step}; + use pretty_assertions::assert_eq; + use std::collections::BTreeMap; + + fn sample() -> Engine { + let mut game_binds = BTreeMap::new(); + game_binds.insert("interact".into(), "g".into()); + game_binds.insert("ctm_on".into(), "Shift+F3".into()); + game_binds.insert("ctm_off".into(), "Shift+F4".into()); + game_binds.insert("assist".into(), "Shift+F2".into()); + let mut groups = BTreeMap::new(); + groups.insert("alts".into(), Group { slots: vec![2, 3] }); + let profile = Profile { + name: "t".into(), + client: "wow-retail".into(), + slots: 3, + window_match: Default::default(), + passthrough: vec!["e".into(), "s".into(), "d".into(), "f".into()], + mode_default: Mode::Maps, + repeater: Default::default(), + game_binds, + interact: Interact { + style: InteractStyle::Standard, + walk_delay_ms: 2500, + }, + session_hotkeys: BTreeMap::new(), + characters: vec![], + groups, + maps: vec![ + Map { + name: "bar1".into(), + hotkey: Hotkey("1".into()), + hold: false, + steps: vec![ + Step { + key: None, + bind: Some("assist".into()), + delay_ms: None, + target: "others".into(), + }, + Step { + key: Some("1".into()), + bind: None, + delay_ms: None, + target: "all".into(), + }, + ], + release_steps: vec![], + }, + Map { + name: "loot".into(), + hotkey: Hotkey("Alt+G".into()), + hold: false, + steps: vec![ + Step { + bind: Some("assist".into()), + key: None, + delay_ms: None, + target: "others".into(), + }, + Step { + bind: Some("ctm_on".into()), + key: None, + delay_ms: None, + target: "others".into(), + }, + Step { + bind: Some("interact".into()), + key: None, + delay_ms: None, + target: "others".into(), + }, + Step { + delay_ms: Some(2500), + key: None, + bind: None, + target: "others".into(), + }, + Step { + bind: Some("ctm_off".into()), + key: None, + delay_ms: None, + target: "others".into(), + }, + ], + release_steps: vec![], + }, + ], + video_fx: vec![], + }; + Engine::new(profile).unwrap() + } + + #[test] + fn example_profile_loads() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/profile.yaml"); + crate::profile::Profile::load(&path).unwrap(); + } + + #[test] + fn esdf_is_not_intercepted() { + let e = sample(); + assert!(!e.should_intercept("e")); + assert!(!e.should_intercept("s")); + assert!(e.should_intercept("1")); + assert!(e.should_intercept("Alt+G")); + } + + #[test] + fn empty_passthrough_allows_mapping_esdf() { + let mut e = sample(); + e.passthrough.clear(); + e.profile.passthrough.clear(); + // still no map for e, so not intercepted + assert!(!e.should_intercept("e")); + } + + #[test] + fn empty_passthrough_can_map_e() { + let profile = Profile { + name: "t".into(), + client: "wow-retail".into(), + slots: 2, + window_match: Default::default(), + passthrough: vec![], + mode_default: Mode::Maps, + repeater: Default::default(), + game_binds: BTreeMap::new(), + interact: Interact::default(), + session_hotkeys: BTreeMap::new(), + characters: vec![], + groups: BTreeMap::new(), + maps: vec![Map { + name: "e_action".into(), + hotkey: Hotkey("e".into()), + hold: false, + steps: vec![Step { + key: Some("e".into()), + bind: None, + delay_ms: None, + target: "current".into(), + }], + release_steps: vec![], + }], + video_fx: vec![], + }; + let e = Engine::new(profile).unwrap(); + assert!(e.should_intercept("e")); + } + + #[test] + fn others_skips_leader() { + let mut e = sample(); + e.set_leader(2); + assert_eq!(e.resolve_targets("others").unwrap(), vec![1, 3]); + assert_eq!(e.resolve_targets("current").unwrap(), vec![2]); + assert_eq!(e.resolve_targets("group:alts").unwrap(), vec![2, 3]); + } + + #[test] + fn loot_is_single_iwt_with_delay() { + let e = sample(); + let actions = e.fire("Alt+G", Hold::Tap).unwrap(); + match &actions[..] { + [Action::Send { + key: a, slots: s1, .. + }, Action::Send { + key: b, slots: s2, .. + }, Action::Send { + key: c, slots: s3, .. + }, Action::Sleep(d), Action::Send { + key: ekey, + slots: s4, + .. + }] => { + assert_eq!(a, "Shift+F2"); + assert_eq!(b, "Shift+F3"); + assert_eq!(c, "g"); + assert_eq!(*d, Duration::from_millis(2500)); + assert_eq!(ekey, "Shift+F4"); + assert_eq!(s1, &vec![2, 3]); + assert_eq!(s2, &vec![2, 3]); + assert_eq!(s3, &vec![2, 3]); + assert_eq!(s4, &vec![2, 3]); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn disabled_mode_intercepts_nothing() { + let mut e = sample(); + e.mode = Mode::Disabled; + assert!(!e.should_intercept("1")); + assert!(e.fire("1", Hold::Tap).unwrap().is_empty()); + } + + #[test] + fn bar_sends_assist_to_others_and_key_to_all() { + let e = sample(); + let actions = e.fire("1", Hold::Tap).unwrap(); + assert_eq!( + actions, + vec![ + Action::Send { + key: "Shift+F2".into(), + slots: vec![2, 3], + hold: Hold::Tap, + }, + Action::Send { + key: "1".into(), + slots: vec![1, 2, 3], + hold: Hold::Tap, + }, + ] + ); + } +} diff --git a/src/hotkey.rs b/src/hotkey.rs new file mode 100644 index 0000000..6ae1af0 --- /dev/null +++ b/src/hotkey.rs @@ -0,0 +1,216 @@ +//! Parse `Alt+G` / `Shift+F3` into Hyprland Lua bind strings and send_shortcut args. + +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Hotkey(pub String); + +impl Hotkey { + pub fn parse(&self) -> Result { + parse(&self.0) + } + + pub fn normalized(&self) -> Result { + Ok(self.parse()?.canonical()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedHotkey { + pub ctrl: bool, + pub alt: bool, + pub shift: bool, + pub super_key: bool, + pub key: String, +} + +impl ParsedHotkey { + pub fn canonical(&self) -> String { + let mut parts = Vec::new(); + if self.ctrl { + parts.push("Ctrl"); + } + if self.alt { + parts.push("Alt"); + } + if self.shift { + parts.push("Shift"); + } + if self.super_key { + parts.push("Super"); + } + parts.push(self.key.as_str()); + parts.join("+") + } + + /// Hyprland 0.56 bind string, e.g. `ALT + G`. + pub fn hypr_bind(&self) -> String { + let mut parts = Vec::new(); + if self.super_key { + parts.push("SUPER"); + } + if self.ctrl { + parts.push("CTRL"); + } + if self.shift { + parts.push("SHIFT"); + } + if self.alt { + parts.push("ALT"); + } + parts.push(self.hypr_key()); + parts.join(" + ") + } + + pub fn hypr_mods(&self) -> String { + let mut parts = Vec::new(); + if self.super_key { + parts.push("SUPER"); + } + if self.ctrl { + parts.push("CTRL"); + } + if self.shift { + parts.push("SHIFT"); + } + if self.alt { + parts.push("ALT"); + } + parts.join(" ") + } + + pub fn hypr_key(&self) -> &str { + match self.key.as_str() { + "Return" | "Enter" => "Return", + "Esc" | "Escape" => "Escape", + "Space" => "Space", + other => other, + } + } + + pub fn is_letter(&self) -> bool { + self.key.len() == 1 && self.key.chars().all(|c| c.is_ascii_alphabetic()) + } +} + +impl fmt::Display for ParsedHotkey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.canonical()) + } +} + +pub fn parse(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + bail!("empty hotkey"); + } + let tokens: Vec<&str> = trimmed + .split(['+', '-', ' ']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + if tokens.is_empty() { + bail!("empty hotkey"); + } + let mut ctrl = false; + let mut alt = false; + let mut shift = false; + let mut super_key = false; + let mut key: Option = None; + for (i, tok) in tokens.iter().enumerate() { + match normalize_token(tok) { + Token::Ctrl => ctrl = true, + Token::Alt => alt = true, + Token::Shift => shift = true, + Token::Super => super_key = true, + Token::Key(k) => { + if i + 1 != tokens.len() && key.is_some() { + bail!("hotkey {raw:?} has extra key {tok:?}"); + } + key = Some(k); + } + } + } + let Some(key) = key else { + bail!("hotkey {raw:?} has no key"); + }; + Ok(ParsedHotkey { + ctrl, + alt, + shift, + super_key, + key, + }) +} + +enum Token { + Ctrl, + Alt, + Shift, + Super, + Key(String), +} + +fn normalize_token(tok: &str) -> Token { + match tok.to_ascii_lowercase().as_str() { + "ctrl" | "control" | "lctrl" | "rctrl" | "ctl" => Token::Ctrl, + "alt" | "lalt" | "ralt" | "mod1" => Token::Alt, + "shift" | "lshift" | "rshift" => Token::Shift, + "super" | "win" | "meta" | "mod4" => Token::Super, + "enter" | "return" => Token::Key("Return".into()), + "esc" | "escape" => Token::Key("Escape".into()), + "space" | "spc" => Token::Key("Space".into()), + other => { + if other.len() == 1 { + Token::Key(other.to_ascii_lowercase()) + } else if other.starts_with('f') && other[1..].chars().all(|c| c.is_ascii_digit()) { + Token::Key(other.to_ascii_uppercase()) + } else { + Token::Key(other.to_string()) + } + } + } +} + +/// Lowercase letter or canonical combo, for passthrough set membership. +pub fn passthrough_id(raw: &str) -> Result { + let p = parse(raw)?; + if !p.ctrl && !p.alt && !p.shift && !p.super_key && p.is_letter() { + Ok(p.key) + } else { + Ok(p.canonical()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_esdf() { + let p = parse("e").unwrap(); + assert_eq!(p.key, "e"); + assert!(!p.ctrl); + assert_eq!(p.hypr_bind(), "e"); + } + + #[test] + fn parses_alt_g() { + let p = parse("Alt+G").unwrap(); + assert!(p.alt); + assert_eq!(p.key, "g"); + assert_eq!(p.hypr_bind(), "ALT + g"); + assert_eq!(p.hypr_mods(), "ALT"); + } + + #[test] + fn parses_shift_f3() { + let p = parse("Shift+F3").unwrap(); + assert!(p.shift); + assert_eq!(p.key, "F3"); + assert_eq!(p.hypr_bind(), "SHIFT + F3"); + } +} diff --git a/src/hypr.rs b/src/hypr.rs new file mode 100644 index 0000000..f571545 --- /dev/null +++ b/src/hypr.rs @@ -0,0 +1,227 @@ +//! Hyprland 0.56 IPC: JSON queries + Lua eval/dispatch. No game injection. + +use crate::hotkey::ParsedHotkey; +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::net::UnixStream; +use tokio::process::Command; + +#[derive(Debug, Clone, Deserialize)] +pub struct Client { + pub address: String, + pub class: String, + pub title: String, + pub pid: i32, + pub at: [i32; 2], + pub size: [i32; 2], + #[serde(default)] + pub mapped: bool, + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub xwayland: bool, + #[serde(rename = "focusHistoryID", default)] + pub focus_history_id: i64, +} + +impl Client { + pub fn address_selector(&self) -> String { + let a = self.address.trim(); + if a.starts_with("address:") { + a.to_string() + } else { + format!("address:{a}") + } + } +} + +pub async fn clients() -> Result> { + let raw = hyprctl(["-j", "clients"]).await?; + serde_json::from_str(&raw).context("parse hyprctl clients") +} + +pub async fn cursor_pos() -> Result<(i32, i32)> { + let raw = hyprctl(["-j", "cursorpos"]).await?; + #[derive(Deserialize)] + struct P { + x: i32, + y: i32, + } + let p: P = serde_json::from_str(&raw).context("cursorpos")?; + Ok((p.x, p.y)) +} + +pub async fn hyprctl(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let out = Command::new("hyprctl") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .context("spawn hyprctl")?; + if !out.status.success() { + bail!("hyprctl failed: {}", String::from_utf8_lossy(&out.stderr)); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +pub async fn eval_lua(code: &str) -> Result { + hyprctl(["eval", code]).await +} + +pub async fn dispatch_lua(expr: &str) -> Result { + hyprctl(["dispatch", expr]).await +} + +pub fn send_shortcut_expr(window: &str, parsed: &ParsedHotkey) -> String { + let mods = parsed.hypr_mods(); + format!( + "hl.dsp.send_shortcut({{ window = {window:?}, mods = {mods:?}, key = {:?} }})", + parsed.hypr_key() + ) +} + +pub fn send_key_state_expr(window: &str, parsed: &ParsedHotkey, state: &str) -> String { + let mods = parsed.hypr_mods(); + format!( + "hl.dsp.send_key_state({{ window = {window:?}, mods = {mods:?}, key = {:?}, state = {state:?} }})", + parsed.hypr_key() + ) +} + +pub async fn send_key(window: &str, combo: &str, state: Option<&str>) -> Result<()> { + let parsed = crate::hotkey::parse(combo)?; + let expr = match state { + Some(s) => send_key_state_expr(window, &parsed, s), + None => send_shortcut_expr(window, &parsed), + }; + dispatch_lua(&expr).await?; + Ok(()) +} + +pub async fn send_mouse_click(window: &str, button: u32) -> Result<()> { + // 272 = BTN_LEFT, 273 = BTN_RIGHT + let expr = format!( + "hl.dsp.send_shortcut({{ window = {window:?}, mods = \"\", key = \"mouse:{button}\" }})" + ); + dispatch_lua(&expr).await?; + Ok(()) +} + +pub async fn move_cursor(x: i32, y: i32) -> Result<()> { + dispatch_lua(&format!("hl.dsp.cursor.move({{ x = {x}, y = {y} }})")).await?; + Ok(()) +} + +pub async fn move_resize_window(selector: &str, x: i32, y: i32, w: i32, h: i32) -> Result<()> { + dispatch_lua(&format!( + "hl.dsp.window.move({{ window = {selector:?}, x = {x}, y = {y}, relative = false }})" + )) + .await?; + dispatch_lua(&format!( + "hl.dsp.window.resize({{ window = {selector:?}, x = {w}, y = {h}, relative = false }})" + )) + .await?; + Ok(()) +} + +const CLEAR_BINDS: &str = r#" +_G.enboxer = _G.enboxer or { binds = {} } +for _, b in ipairs(_G.enboxer.binds) do + pcall(function() b:remove() end) +end +_G.enboxer.binds = {} +"#; + +/// Install or replace the enBoxer bind table in Hyprland's Lua VM. +pub async fn replace_binds(binds: &[BindSpec], ipc: &str) -> Result<()> { + let mut body = String::from(CLEAR_BINDS); + for b in binds { + let opts = match (b.non_consuming, b.release) { + (true, true) => "{ description = \"enboxer\", non_consuming = true, release = true }", + (true, false) => "{ description = \"enboxer\", non_consuming = true }", + (false, true) => "{ description = \"enboxer\", release = true }", + (false, false) => "{ description = \"enboxer\" }", + }; + let cmd = format!( + "{} ipc --sock {} {}", + shell_single(&b.ipc_bin), + shell_single(ipc), + b.ipc_args + ); + body.push_str(&format!( + r#" +do + local b = hl.bind({bind:?}, function() + hl.dispatch(hl.dsp.exec_cmd({cmd:?})) + end, {opts}) + table.insert(_G.enboxer.binds, b) +end +"#, + bind = b.bind, + cmd = cmd, + opts = opts, + )); + } + eval_lua(&body).await?; + Ok(()) +} + +pub async fn clear_binds() -> Result<()> { + eval_lua(CLEAR_BINDS).await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct BindSpec { + pub bind: String, + pub ipc_bin: String, + pub ipc_args: String, + pub non_consuming: bool, + pub release: bool, +} + +fn shell_single(s: &str) -> String { + format!("'{}'", s.replace('\'', r#"'"'"'"#)) +} + +pub async fn active_window() -> Result> { + let raw = hyprctl(["-j", "activewindow"]).await?; + if raw.trim() == "{}" || raw.trim().is_empty() { + return Ok(None); + } + Ok(serde_json::from_str(&raw).ok()) +} + +pub fn socket2_path() -> Result { + let sig = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") + .context("HYPRLAND_INSTANCE_SIGNATURE not set — not in a Hyprland session")?; + let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + Ok(PathBuf::from(runtime) + .join("hypr") + .join(sig) + .join(".socket2.sock")) +} + +pub async fn listen_events(mut on_line: F) -> Result<()> +where + F: FnMut(String) -> Fut, + Fut: std::future::Future>, +{ + let path = socket2_path()?; + let stream = UnixStream::connect(&path) + .await + .with_context(|| format!("connect {}", path.display()))?; + let mut lines = BufReader::new(stream).lines(); + while let Some(line) = lines.next_line().await? { + on_line(line).await?; + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2e9234d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod engine; +pub mod hotkey; +pub mod hypr; +pub mod macros; +pub mod profile; +pub mod session; +pub mod vfx; diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 0000000..c65ee67 --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,52 @@ +use crate::profile::Profile; + +pub fn print_macros(profile: &Profile) -> String { + let bind = |name: &str, fallback: &str| -> String { + profile + .game_binds + .get(name) + .cloned() + .unwrap_or_else(|| fallback.to_string()) + }; + let main = profile + .characters + .iter() + .find(|c| c.slot == 1) + .map(|c| c.name.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("MainName"); + + format!( + r#"# enBoxer in-game setup — {client} +# Bind these on EVERY account. No addon. See docs/MACROS.md. + +## Key Bindings → Targeting +Interact with Target = {interact} + +## Macros + +# CTM On bind {ctm_on} +/console autoInteract 1 + +# CTM Off bind {ctm_off} +/console autoInteract 0 + +# Assist bind {assist} +/assist {main} + +# Follow bind {follow} +/follow {main} + +# If /console autoInteract is rejected: +# /run C_CVar.SetCVar("autoInteract", "1") +# /run C_CVar.SetCVar("autoInteract", "0") +"#, + client = profile.client, + interact = bind("interact", "g"), + ctm_on = bind("ctm_on", "Shift+F3"), + ctm_off = bind("ctm_off", "Shift+F4"), + assist = bind("assist", "Shift+F2"), + follow = bind("follow", "Shift+F1"), + main = main, + ) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..755d27f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,164 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use enboxer::macros::print_macros; +use enboxer::profile::{self, Profile}; +use enboxer::session; +use std::path::PathBuf; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command( + name = "enboxer", + version, + about = "Mapped-key routing for WoW on Hyprland" +)] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Load a profile and route keys while a managed game is focused + Run { + #[arg(short, long)] + config: Option, + }, + /// Fire a mapped hotkey (daemon must be running) + Press { + hotkey: String, + #[arg(long)] + sock: Option, + }, + Status { + #[arg(long)] + sock: Option, + }, + /// Print the in-game macros / binds to create + Macros { + #[arg(short, long)] + config: Option, + }, + /// Check Hyprland + grim + profile + Doctor { + #[arg(short, long)] + config: Option, + }, + /// Called by Hyprland binds; not for humans + Ipc { + #[arg(long)] + sock: PathBuf, + verb: String, + #[arg(trailing_var_arg = true)] + rest: Vec, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive("enboxer=info".parse()?)) + .init(); + let cli = Cli::parse(); + match cli.cmd { + Cmd::Run { config } => { + let path = config.unwrap_or_else(profile::default_config_path); + let profile = Profile::load(&path)?; + tracing::info!("profile {} ({} slots)", profile.name, profile.slots); + session::run(profile, session::default_sock()).await + } + Cmd::Press { hotkey, sock } => { + let sock = sock.unwrap_or_else(session::default_sock); + println!( + "{}", + session::ipc_send(&sock, &format!("hotkey {hotkey}")).await? + ); + Ok(()) + } + Cmd::Status { sock } => { + let sock = sock.unwrap_or_else(session::default_sock); + println!("{}", session::ipc_send(&sock, "status").await?); + Ok(()) + } + Cmd::Macros { config } => { + let path = config.unwrap_or_else(profile::default_config_path); + let profile = Profile::load(&path)?; + print!("{}", print_macros(&profile)); + Ok(()) + } + Cmd::Doctor { config } => doctor(config).await, + Cmd::Ipc { sock, verb, rest } => { + let arg = rest.join(" "); + let line = if arg.is_empty() { + verb + } else { + format!("{verb} {arg}") + }; + println!("{}", session::ipc_send(&sock, &line).await?); + Ok(()) + } + } +} + +async fn doctor(config: Option) -> Result<()> { + let mut ok = true; + match std::env::var("HYPRLAND_INSTANCE_SIGNATURE") { + Ok(s) => println!("hyprland session: {s}"), + Err(_) => { + println!("hyprland session: MISSING (not in Hyprland?)"); + ok = false; + } + } + match enboxer::hypr::eval_lua("return 'ok'").await { + Ok(s) => println!("hyprctl eval: {s}"), + Err(e) => { + println!("hyprctl eval: FAIL {e}"); + ok = false; + } + } + match tokio::process::Command::new("grim") + .arg("-h") + .output() + .await + { + Ok(_) => println!("grim: present"), + Err(_) => println!("grim: MISSING (Video FX capture needs grim)"), + } + if let Some(path) = config.or_else(|| { + let p = profile::default_config_path(); + p.exists().then_some(p) + }) { + match Profile::load(&path) { + Ok(p) => { + println!( + "profile: {} maps={} passthrough={:?} vfx={}", + path.display(), + p.maps.len(), + p.passthrough, + p.video_fx.len() + ); + } + Err(e) => { + println!("profile {}: FAIL {e}", path.display()); + ok = false; + } + } + } else { + println!( + "profile: none at {}", + profile::default_config_path().display() + ); + } + if let Ok(clients) = enboxer::hypr::clients().await { + println!("windows: {}", clients.len()); + for c in clients.iter().take(12) { + println!(" {} {} {:?}", c.address, c.class, c.title); + } + } + if ok { + println!("doctor: ok"); + Ok(()) + } else { + anyhow::bail!("doctor found problems") + } +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..f3604d1 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,321 @@ +use crate::hotkey::{passthrough_id, Hotkey}; +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + pub name: String, + #[serde(default = "default_client")] + pub client: String, + #[serde(default = "default_slots")] + pub slots: u32, + #[serde(default)] + pub window_match: WindowMatch, + /// Keys that are never intercepted. Empty = no skip list. + #[serde(default)] + pub passthrough: Vec, + #[serde(default = "default_mode")] + pub mode_default: Mode, + #[serde(default)] + pub repeater: Repeater, + #[serde(default)] + pub game_binds: BTreeMap, + #[serde(default)] + pub interact: Interact, + #[serde(default)] + pub session_hotkeys: BTreeMap, + #[serde(default)] + pub characters: Vec, + #[serde(default)] + pub groups: BTreeMap, + #[serde(default)] + pub maps: Vec, + #[serde(default)] + pub video_fx: Vec, +} + +fn default_client() -> String { + "wow-retail".into() +} +fn default_slots() -> u32 { + 2 +} +fn default_mode() -> Mode { + Mode::Maps +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Mode { + Maps, + Repeater, + Disabled, +} + +impl Mode { + pub fn cycle(self) -> Self { + match self { + Mode::Maps => Mode::Repeater, + Mode::Repeater => Mode::Disabled, + Mode::Disabled => Mode::Maps, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Mode::Maps => "maps", + Mode::Repeater => "repeater", + Mode::Disabled => "disabled", + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WindowMatch { + pub class: Option, + pub title: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Repeater { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub keys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Interact { + #[serde(default = "default_interact_style")] + pub style: InteractStyle, + #[serde(default = "default_walk_delay")] + pub walk_delay_ms: u64, +} + +fn default_interact_style() -> InteractStyle { + InteractStyle::Standard +} +fn default_walk_delay() -> u64 { + 2500 +} + +impl Default for Interact { + fn default() -> Self { + Self { + style: InteractStyle::Standard, + walk_delay_ms: 2500, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InteractStyle { + Standard, + Auto, + Hold, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Character { + pub slot: u32, + #[serde(default)] + pub name: String, + #[serde(default)] + pub match_title: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Group { + pub slots: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Map { + pub name: String, + pub hotkey: Hotkey, + #[serde(default)] + pub hold: bool, + #[serde(default)] + pub steps: Vec, + #[serde(default)] + pub release_steps: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Step { + #[serde(default)] + pub key: Option, + #[serde(default)] + pub bind: Option, + #[serde(default)] + pub delay_ms: Option, + #[serde(default = "default_target")] + pub target: String, +} + +fn default_target() -> String { + "others".into() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoFx { + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + pub source_slot: u32, + pub source: NormRect, + #[serde(default)] + pub viewer: NormRect, + #[serde(default = "default_true")] + pub pass_through: bool, + #[serde(default = "default_fps")] + pub fps: u32, +} + +fn default_true() -> bool { + true +} +fn default_fps() -> u32 { + 12 +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct NormRect { + #[serde(default)] + pub x: f64, + #[serde(default)] + pub y: f64, + #[serde(default = "default_one")] + pub w: f64, + #[serde(default = "default_one")] + pub h: f64, +} + +fn default_one() -> f64 { + 1.0 +} + +impl Default for NormRect { + fn default() -> Self { + Self { + x: 0.0, + y: 0.0, + w: 0.28, + h: 0.28, + } + } +} + +impl NormRect { + pub fn to_pixels( + &self, + win_x: i32, + win_y: i32, + win_w: i32, + win_h: i32, + ) -> (i32, i32, i32, 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 w = (self.w * win_w as f64).round() as i32; + let h = (self.h * win_h as f64).round() as i32; + (x, y, w.max(1), h.max(1)) + } +} + +impl Profile { + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("read profile {}", path.display()))?; + let profile: Profile = serde_yaml::from_str(&text).context("parse profile YAML")?; + profile.validate()?; + Ok(profile) + } + + pub fn validate(&self) -> Result<()> { + if self.slots < 1 { + bail!("slots must be >= 1"); + } + let mut seen = HashSet::new(); + for m in &self.maps { + let id = m.hotkey.normalized()?; + if !seen.insert(id.clone()) { + bail!("duplicate map hotkey {id}"); + } + if m.steps.is_empty() && m.release_steps.is_empty() { + bail!("map {} has no steps", m.name); + } + for s in m.steps.iter().chain(m.release_steps.iter()) { + s.validate()?; + } + } + for name in self.game_binds.keys() { + if name.trim().is_empty() { + bail!("empty game_binds key"); + } + } + for fx in &self.video_fx { + if fx.source_slot < 1 || fx.source_slot > self.slots { + bail!("video_fx {} source_slot out of range", fx.name); + } + } + Ok(()) + } + + pub fn passthrough_set(&self) -> Result> { + let mut set = HashSet::new(); + for k in &self.passthrough { + set.insert(passthrough_id(k)?); + } + Ok(set) + } + + pub fn resolve_bind(&self, name: &str) -> Result { + self.game_binds + .get(name) + .cloned() + .with_context(|| format!("unknown game_bind {name:?}")) + } + + pub fn map_by_hotkey(&self, canonical: &str) -> Option<&Map> { + self.maps.iter().find(|m| { + m.hotkey + .normalized() + .ok() + .is_some_and(|n| n.eq_ignore_ascii_case(canonical)) + }) + } +} + +impl Step { + fn validate(&self) -> Result<()> { + let kinds = [ + self.key.is_some(), + self.bind.is_some(), + self.delay_ms.is_some(), + ] + .into_iter() + .filter(|b| *b) + .count(); + if kinds != 1 { + bail!("step must be exactly one of key, bind, delay_ms"); + } + Ok(()) + } +} + +pub fn default_config_path() -> PathBuf { + directories::ProjectDirs::from("de", "nettsi", "enboxer") + .map(|p| p.config_dir().join("profile.yaml")) + .unwrap_or_else(|| PathBuf::from("profile.yaml")) +} + +pub fn runtime_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { + PathBuf::from(xdg).join("enboxer") + } else { + std::env::temp_dir().join("enboxer") + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..5545d6a --- /dev/null +++ b/src/session.rs @@ -0,0 +1,505 @@ +use crate::engine::{Action, Engine, Hold}; +use crate::hotkey::{self, passthrough_id}; +use crate::hypr::{self, BindSpec, Client}; +use crate::profile::{runtime_dir, Mode, Profile}; +use crate::vfx::{self, FeedHit}; +use anyhow::{Context, Result}; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixListener; +use tokio::sync::{watch, Mutex}; +use tokio::time::{sleep, Duration}; + +pub struct Session { + pub engine: Engine, + pub slots: Vec<(u32, Client)>, + pub binds_on: bool, + pub exe: PathBuf, + pub sock: PathBuf, + pub vfx: Vec, + pub vfx_source: Option, +} + +impl Session { + pub fn new(profile: Profile, exe: PathBuf, sock: PathBuf) -> Result { + Ok(Self { + engine: Engine::new(profile)?, + slots: Vec::new(), + binds_on: false, + exe, + sock, + vfx: Vec::new(), + vfx_source: None, + }) + } +} + +pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> { + std::fs::create_dir_all(runtime_dir()).ok(); + if sock.exists() { + let _ = std::fs::remove_file(&sock); + } + let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer")); + let session = Arc::new(Mutex::new(Session::new(profile, exe, sock.clone())?)); + let (vfx_tx, vfx_rx) = watch::channel(Vec::new()); + + let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?; + tracing::info!("ipc {}", sock.display()); + + let s1 = session.clone(); + tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, _)) => { + let s = s1.clone(); + tokio::spawn(async move { + if let Err(e) = handle_ipc(s, stream).await { + tracing::debug!("ipc: {e}"); + } + }); + } + Err(e) => tracing::warn!("accept: {e}"), + } + } + }); + + tokio::spawn(vfx::capture_loop(vfx_rx)); + + let s2 = session.clone(); + tokio::spawn(async move { + loop { + if let Err(e) = refresh_slots(&s2, &vfx_tx).await { + tracing::debug!("refresh: {e}"); + } + sleep(Duration::from_millis(400)).await; + } + }); + + let s3 = session.clone(); + hypr::listen_events(move |line| { + let s = s3.clone(); + async move { + on_event(&s, &line).await; + Ok(()) + } + }) + .await +} + +async fn refresh_slots( + session: &Arc>, + vfx_tx: &watch::Sender>, +) -> Result<()> { + let clients = hypr::clients().await?; + let mut g = session.lock().await; + let class_re = g + .engine + .profile + .window_match + .class + .as_deref() + .and_then(|p| Regex::new(p).ok()); + let title_re = g + .engine + .profile + .window_match + .title + .as_deref() + .and_then(|p| Regex::new(p).ok()); + + let mut matched: Vec = 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)); + + let n = g.engine.profile.slots as usize; + let new_slots: Vec<(u32, Client)> = matched + .into_iter() + .take(n) + .enumerate() + .map(|(i, c)| ((i as u32) + 1, c)) + .collect(); + let old_keys: Vec<(u32, &str)> = g + .slots + .iter() + .map(|(s, c)| (*s, c.address.as_str())) + .collect(); + let new_keys: Vec<(u32, &str)> = new_slots + .iter() + .map(|(s, c)| (*s, c.address.as_str())) + .collect(); + if old_keys != new_keys { + for (slot, c) in &new_slots { + tracing::info!("slot {} {} {} {}", slot, c.class, c.title, c.address); + } + } + g.slots = new_slots; + + if let Ok(Some(aw)) = hypr::active_window().await { + let leader = g + .slots + .iter() + .find(|(_, c)| c.address == aw.address) + .map(|(s, _)| *s); + if let Some(slot) = leader { + g.engine.set_leader(slot); + } + let managed = g.slots.iter().any(|(_, c)| c.address == aw.address) + || aw.class == "enboxer-vfx" + || aw.title.starts_with("enboxer-vfx"); + if managed && g.engine.mode != Mode::Disabled { + if !g.binds_on { + install_binds(&mut g).await?; + } + } else if g.binds_on { + hypr::clear_binds().await.ok(); + g.binds_on = false; + g.vfx_source = None; + tracing::info!("routing off (focus left the team)"); + } + } + + if let Some((_, primary)) = g + .slots + .iter() + .find(|(s, _)| *s == g.engine.leader_slot) + .cloned() + { + let feeds = vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots); + g.vfx = feeds.clone(); + 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) + .filter(|h| h.pass_through) + .map(|h| h.source_slot); + if src != g.vfx_source { + g.vfx_source = src; + g.binds_on = false; // reinstall: VFX hover grabs extra keys + } + } + } + Ok(()) +} + +async fn on_event(session: &Arc>, line: &str) { + if let Some(rest) = line.strip_prefix("activewindowv2>>") { + let addr = rest.trim(); + let mut g = session.lock().await; + let want = addr.trim().trim_start_matches("0x"); + let leader = g + .slots + .iter() + .find(|(_, c)| { + c.address + .trim() + .trim_start_matches("0x") + .eq_ignore_ascii_case(want) + }) + .map(|(s, _)| *s); + if let Some(slot) = leader { + g.engine.set_leader(slot); + } + } +} + +async fn install_binds(g: &mut Session) -> Result<()> { + let passthrough = g.engine.profile.passthrough_set()?; + let mut specs = Vec::new(); + let bin = g.exe.display().to_string(); + for m in &g.engine.profile.maps { + let id = passthrough_id(&m.hotkey.0).unwrap_or_else(|_| m.hotkey.0.clone()); + if passthrough.contains(&id) { + continue; + } + let parsed = m.hotkey.parse()?; + specs.push(BindSpec { + bind: parsed.hypr_bind(), + ipc_bin: bin.clone(), + ipc_args: format!("ipc hotkey {}", m.hotkey.0), + non_consuming: false, + release: false, + }); + if m.hold { + specs.push(BindSpec { + bind: parsed.hypr_bind(), + ipc_bin: bin.clone(), + ipc_args: format!("ipc hotkey-release {}", m.hotkey.0), + non_consuming: false, + release: true, + }); + } + } + if g.engine.mode == Mode::Repeater { + for k in &g.engine.profile.repeater.keys { + let id = passthrough_id(k).unwrap_or_else(|_| k.clone()); + if passthrough.contains(&id) { + continue; + } + if g.engine.profile.map_by_hotkey(k).is_some() { + continue; + } + let parsed = hotkey::parse(k)?; + specs.push(BindSpec { + bind: parsed.hypr_bind(), + ipc_bin: bin.clone(), + ipc_args: format!("ipc hotkey {k}"), + non_consuming: true, + release: false, + }); + } + } + if let Some(cycle) = g.engine.profile.session_hotkeys.get("mode_cycle") { + let parsed = hotkey::parse(cycle)?; + specs.push(BindSpec { + bind: parsed.hypr_bind(), + ipc_bin: bin.clone(), + ipc_args: "ipc mode-cycle".into(), + non_consuming: false, + release: false, + }); + } + if g.vfx_source.is_some() { + for k in vfx_hover_keys() { + if specs.iter().any(|s| s.bind == k) { + continue; + } + specs.push(BindSpec { + bind: k.clone(), + ipc_bin: bin.clone(), + ipc_args: format!("ipc vfxkey {k}"), + non_consuming: false, + release: false, + }); + } + } + 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>, stream: tokio::net::UnixStream) -> Result<()> { + let (r, mut w) = stream.into_split(); + let mut lines = BufReader::new(r).lines(); + while let Some(line) = lines.next_line().await? { + let reply = dispatch_cmd(&session, &line).await; + w.write_all(reply.as_bytes()).await?; + w.write_all(b"\n").await?; + } + Ok(()) +} + +async fn dispatch_cmd(session: &Arc>, line: &str) -> String { + let mut parts = line.splitn(2, ' '); + let cmd = parts.next().unwrap_or(""); + let arg = parts.next().unwrap_or("").trim(); + match cmd { + "ping" => "ok".into(), + "status" => { + let g = session.lock().await; + format!( + "mode={} leader={} binds={} slots={} vfx_source={:?}", + g.engine.mode.as_str(), + g.engine.leader_slot, + g.binds_on, + g.slots.len(), + g.vfx_source + ) + } + "mode-cycle" => { + let mut g = session.lock().await; + let m = g.engine.cycle_mode(); + g.binds_on = false; + format!("mode={}", m.as_str()) + } + "hotkey" => match fire(session, arg, Hold::Tap).await { + Ok(()) => "ok".into(), + Err(e) => format!("err {e}"), + }, + "hotkey-release" => match fire(session, arg, Hold::Up).await { + Ok(()) => "ok".into(), + Err(e) => format!("err {e}"), + }, + "vfxkey" => { + if let Some(rest) = arg.strip_prefix("mouse:") { + match rest.parse::() { + Ok(btn) => match fire_vfx_click(session, btn).await { + Ok(()) => "ok".into(), + Err(e) => format!("err {e}"), + }, + Err(_) => format!("err bad mouse {arg}"), + } + } else { + match fire_vfx(session, arg).await { + Ok(()) => "ok".into(), + Err(e) => format!("err {e}"), + } + } + } + _ => format!("err unknown {cmd}"), + } +} + +fn vfx_hover_keys() -> Vec { + let mut v: Vec = [ + "Space", + "Return", + "Escape", + "Tab", + "BackSpace", + "grave", + "minus", + "equal", + "mouse:272", + "mouse:273", + ] + .into_iter() + .map(str::to_string) + .collect(); + for c in b'a'..=b'z' { + v.push((c as char).to_string()); + } + for c in b'0'..=b'9' { + v.push((c as char).to_string()); + } + v +} + +async fn fire_vfx(session: &Arc>, hotkey: &str) -> Result<()> { + let (src, slots) = { + let g = session.lock().await; + let src = g + .vfx_source + .ok_or_else(|| anyhow::anyhow!("no vfx hover"))?; + (src, g.slots.clone()) + }; + execute( + vec![Action::Send { + key: hotkey.to_string(), + slots: vec![src], + hold: Hold::Tap, + }], + &slots, + ) + .await +} + +async fn fire_vfx_click(session: &Arc>, button: u32) -> Result<()> { + let (cx, cy) = hypr::cursor_pos().await?; + let (feed, src_addr) = { + let g = session.lock().await; + let src = g + .vfx_source + .ok_or_else(|| anyhow::anyhow!("no vfx hover"))?; + let feed = vfx::hit_test(&g.vfx, cx, cy) + .filter(|h| h.pass_through && h.source_slot == src) + .ok_or_else(|| anyhow::anyhow!("vfx hover lost"))? + .clone(); + let addr = g + .slots + .iter() + .find(|(s, _)| *s == src) + .map(|(_, c)| c.address_selector()) + .ok_or_else(|| anyhow::anyhow!("source slot gone"))?; + (feed, addr) + }; + let (sx, sy) = vfx::map_click(&feed, cx, cy); + hypr::move_cursor(sx, sy).await?; + hypr::send_mouse_click(&src_addr, button).await?; + Ok(()) +} + +async fn fire(session: &Arc>, hotkey: &str, edge: Hold) -> Result<()> { + let (actions, slots) = { + let g = session.lock().await; + if let Some(src) = g.vfx_source { + if g.engine.profile.map_by_hotkey(hotkey).is_none() + && g.engine + .profile + .session_hotkeys + .values() + .all(|h| !h.eq_ignore_ascii_case(hotkey)) + { + let hold = if matches!(edge, Hold::Up) { + Hold::Up + } else { + Hold::Tap + }; + ( + vec![Action::Send { + key: hotkey.to_string(), + slots: vec![src], + hold, + }], + g.slots.clone(), + ) + } else { + (g.engine.fire(hotkey, edge)?, g.slots.clone()) + } + } else { + (g.engine.fire(hotkey, edge)?, g.slots.clone()) + } + }; + execute(actions, &slots).await +} + +async fn execute(actions: Vec, slots: &[(u32, Client)]) -> Result<()> { + for a in actions { + match a { + Action::Sleep(d) => sleep(d).await, + Action::Send { + key, + slots: ids, + hold, + } => { + let parsed_state = match hold { + Hold::Down => Some("down"), + Hold::Up => Some("up"), + Hold::Tap => None, + }; + for id in ids { + if let Some((_, c)) = slots.iter().find(|(s, _)| *s == id) { + hypr::send_key(&c.address_selector(), &key, parsed_state).await?; + } + } + } + } + } + Ok(()) +} + +pub async fn ipc_send(sock: &Path, line: &str) -> Result { + use tokio::io::AsyncBufReadExt; + let mut stream = tokio::net::UnixStream::connect(sock) + .await + .with_context(|| format!("connect {}", sock.display()))?; + stream.write_all(line.as_bytes()).await?; + stream.write_all(b"\n").await?; + let mut reader = BufReader::new(stream); + let mut reply = String::new(); + reader.read_line(&mut reply).await?; + Ok(reply.trim().to_string()) +} + +pub fn default_sock() -> PathBuf { + runtime_dir().join("enboxer.sock") +} diff --git a/src/vfx.rs b/src/vfx.rs new file mode 100644 index 0000000..da1e248 --- /dev/null +++ b/src/vfx.rs @@ -0,0 +1,164 @@ +//! Video FX: crop a region of a source slot onto an overlay on the primary. +//! +//! Capture uses `grim` (visible pixels). Stacked/occluded sources need +//! hyprland-toplevel-export; that protocol exists on Hyprland 0.56 but the +//! client is not wired yet. Pass-through still works from viewer geometry. + +use crate::hypr::{self, Client}; +use crate::profile::{runtime_dir, VideoFx}; +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tokio::process::Command; +use tokio::time::{interval, Duration}; + +#[derive(Debug, Clone)] +pub struct FeedHit { + pub name: String, + pub source_slot: u32, + pub pass_through: bool, + pub viewer: (i32, i32, i32, i32), + pub source: (i32, i32, i32, i32), +} + +pub fn hit_test(feeds: &[FeedHit], x: i32, y: i32) -> Option<&FeedHit> { + feeds.iter().rev().find(|f| { + let (vx, vy, vw, vh) = f.viewer; + x >= vx && y >= vy && x < vx + vw && y < vy + vh + }) +} + +pub fn map_click(feed: &FeedHit, x: i32, y: i32) -> (i32, i32) { + let (vx, vy, vw, vh) = feed.viewer; + let (sx, sy, sw, sh) = feed.source; + let nx = (x - vx) as f64 / vw.max(1) as f64; + let ny = (y - vy) as f64 / vh.max(1) as f64; + ( + sx + (nx * sw as f64).round() as i32, + sy + (ny * sh as f64).round() as i32, + ) +} + +pub fn build_hits(feeds: &[VideoFx], primary: &Client, slots: &[(u32, Client)]) -> Vec { + let mut out = Vec::new(); + for fx in feeds.iter().filter(|f| f.enabled) { + let Some((_, src)) = slots.iter().find(|(s, _)| *s == fx.source_slot) else { + continue; + }; + if src.address == primary.address { + continue; // do not view a window on itself + } + let viewer = fx.viewer.to_pixels( + primary.at[0], + primary.at[1], + primary.size[0], + primary.size[1], + ); + let source = fx + .source + .to_pixels(src.at[0], src.at[1], src.size[0], src.size[1]); + out.push(FeedHit { + name: fx.name.clone(), + source_slot: fx.source_slot, + pass_through: fx.pass_through, + viewer, + source, + }); + } + out +} + +pub fn frame_path(name: &str) -> PathBuf { + runtime_dir().join(format!("vfx-{name}.png")) +} + +pub async fn capture_region(x: i32, y: i32, w: i32, h: i32, dest: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent).await.ok(); + } + let geom = format!("{x},{y} {w}x{h}"); + let out = Command::new("grim") + .args(["-g", &geom, dest.to_str().unwrap_or("/dev/null")]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await + .context("spawn grim")?; + if !out.status.success() { + anyhow::bail!("grim: {}", String::from_utf8_lossy(&out.stderr)); + } + Ok(()) +} + +/// Keep PNG files fresh so an external viewer (`imv`, `mpv`, `feh --reload`) can show them. +pub async fn capture_loop(rx: tokio::sync::watch::Receiver>) { + let mut tick = interval(Duration::from_millis(80)); + loop { + tick.tick().await; + let feeds = rx.borrow().clone(); + for f in feeds { + let (x, y, w, h) = f.source; + 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); + } + } + } +} + +pub async fn ensure_overlay_window(feed: &FeedHit) -> Result<()> { + let path = frame_path(&feed.name); + let class = "enboxer-vfx"; + let already = hypr::clients() + .await? + .into_iter() + .any(|c| c.class == class && c.title.contains(&feed.name)); + 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; + hypr::move_resize_window(&win.address_selector(), x, y, w, h) + .await + .ok(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feed(viewer: (i32, i32, i32, i32), source: (i32, i32, i32, i32)) -> FeedHit { + FeedHit { + name: "test".into(), + source_slot: 2, + pass_through: true, + viewer, + source, + } + } + + #[test] + fn map_click_routes_viewer_to_source() { + let f = feed((100, 100, 200, 200), (1000, 500, 400, 300)); + assert_eq!(map_click(&f, 100, 100), (1000, 500), "top-left"); + assert_eq!(map_click(&f, 200, 200), (1200, 650), "center"); + assert_eq!(map_click(&f, 250, 250), (1300, 725), "lower-right"); + } +}