From e22d8251ed89f61e9dc287a1e7755df80a42ca2b Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 2 Apr 2026 19:11:11 -0600 Subject: [PATCH] feat: initial implementation of mousefree Keyboard-driven mouse navigation overlay for Wayland using wlr-layer-shell and wlr-virtual-pointer. Two-key grid selection, click/double-click/right-click/drag, three-tier cursor nudge (hjkl/HJKL/Ctrl+hjkl), and scroll passthrough. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 48 ++ .gitignore | 7 + CHANGELOG.md | 21 + Cargo.lock | 597 ++++++++++++++++++++++++ Cargo.toml | 21 + LICENSE | 21 + README.md | 80 ++++ src/font.rs | 36 ++ src/input.rs | 399 ++++++++++++++++ src/main.rs | 267 +++++++++++ src/render.rs | 840 +++++++++++++++++++++++++++++++++ src/wayland.rs | 970 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 3307 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/font.rs create mode 100644 src/input.rs create mode 100644 src/main.rs create mode 100644 src/render.rs create mode 100644 src/wayland.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f2c5f1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + rust: ["1.75", stable] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwayland-dev + - run: cargo test + + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwayland-dev + - run: cargo clippy --all-targets -- -D warnings + + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9dfdc3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4887b82 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## [1.0.0] - 2026-04-03 + +### Added + +- Keyboard-driven grid overlay for Wayland compositors +- Two-key cell selection (column + row) with 10x16 grid +- Cursor nudging with `hjkl` / `HJKL` / `Ctrl+hjkl` / `Alt+hjkl` (1 px) +- Left-click, double-click, triple-click (`Shift+Enter`), and right-click +- Click-and-drag with animated pointer interpolation +- Scroll pass-through via arrow keys +- Undo/backspace to step back one selection phase +- Context-sensitive status bar with key hints +- System font discovery via fontconfig (`fc-match`) + +### Requirements + +- Wayland compositor with `wlr-layer-shell` and `wlr-virtual-pointer` +- fontconfig +- US QWERTY keyboard layout diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f0a8b75 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,597 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "cc" +version = "1.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown", + "ttf-parser", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mousefree" +version = "1.0.0" +dependencies = [ + "anyhow", + "fontdue", + "tempfile", + "tiny-skia", + "wayland-client", + "wayland-protocols-wlr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.0.7", + "windows-sys 0.60.2", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "wasi" +version = "0.14.4+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a5f4a424faf49c3c2c344f166f0662341d470ea185e939657aaff130f0ec4a" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +dependencies = [ + "cc", + "downcast-rs", + "rustix 0.38.44", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +dependencies = [ + "bitflags", + "rustix 0.38.44", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..da9e306 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "mousefree" +version = "1.0.0" +edition = "2021" +license = "MIT" +description = "Keyboard-driven mouse navigation for Wayland" +repository = "https://github.com/swaits/mousefree" +homepage = "https://github.com/swaits/mousefree" +readme = "README.md" +keywords = ["wayland", "keyboard", "mouse", "accessibility", "navigation"] +categories = ["command-line-utilities", "accessibility"] +authors = ["Stephen Waits "] +rust-version = "1.75" + +[dependencies] +anyhow = "1.0" +wayland-client = "0.31" +wayland-protocols-wlr = { version = "0.3", features = ["client"] } +tempfile = "3" +fontdue = "0.9" +tiny-skia = "0.12.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..694cb77 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Stephen Waits + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5acaae --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# mousefree + +Keyboard-driven mouse navigation for Wayland. Launch the overlay, type two keys +to jump the cursor to a grid cell, then click, drag, or scroll — all without +touching the mouse. + +## Requirements + +- A Wayland compositor with `wlr-layer-shell` and `wlr-virtual-pointer` support + (Sway, Hyprland, river, etc.) +- `fontconfig` (`fc-match`) for system font discovery +- US QWERTY keyboard layout (other layouts are not yet supported) + +## Install + +With [mise](https://mise.jdx.dev/): + +```sh +mise use -g cargo:mousefree +``` + +Or with cargo directly: + +```sh +cargo install mousefree +``` + +Or build from source: + +```sh +git clone https://github.com/swaits/mousefree.git +cd mousefree +cargo install --path . +``` + +Then bind `mousefree` to a hotkey in your compositor. Every Wayland compositor +has its own way to bind a key to launch a program — consult your compositor's +docs. For example, in Sway: + +``` +bindsym $mod+semicolon exec mousefree +``` + +### Controls + +| Key | Action | +|-----|--------| +| `a`–`p` | Select column | +| `q`,`w`,`e`,… | Select row (completes cell selection) | +| `Space` | Click | +| `Enter` | Double-click | +| `Shift`+`Enter` | Triple-click (select line/paragraph) | +| `.` | Right-click | +| `/` | Start drag (type a second cell to drop) | +| `h` `j` `k` `l` | Nudge cursor (8 px) | +| `H` `J` `K` `L` | Medium nudge (16 px) | +| `Ctrl`+`h` `j` `k` `l` | Large nudge (32 px) | +| `Alt`+`h` `j` `k` `l` | Pixel-perfect nudge (1 px) | +| Arrow keys | Scroll | +| `Backspace` | Undo / go back | +| `Escape` | Quit | + +Keys like `h`, `j`, `k`, `l` are context-sensitive: before a cell is selected +they choose a column/row; after a cell is locked they nudge the cursor. + +## Troubleshooting + +- **"could not find a system font"** — Install fontconfig (`sudo apt install + fontconfig` / `sudo pacman -S fontconfig`) and verify with `fc-match + sans-serif`. +- **Overlay does not appear** — Your compositor may not support + `wlr-layer-shell` or `wlr-virtual-pointer`. Try Sway, Hyprland, or river. +- **Keys produce wrong characters** — mousefree currently assumes a US QWERTY + layout. Other layouts are not yet supported. +- **Virtual pointer stuck after crash** — If the process is killed mid-drag, a + mouse button may stay pressed. Restarting your compositor session clears it. + +## License + +[MIT](LICENSE) diff --git a/src/font.rs b/src/font.rs new file mode 100644 index 0000000..6757bdf --- /dev/null +++ b/src/font.rs @@ -0,0 +1,36 @@ +//! System font loading via fontconfig and fontdue. +//! +//! Discovers a sans-serif font using `fc-match`, loads it once at startup, +//! and exposes it for glyph rasterization during rendering. + +use anyhow::{Context, Result}; +use std::sync::OnceLock; + +static FONT: OnceLock = OnceLock::new(); + +/// Discover and load a system sans-serif font. Must be called once at startup. +pub fn init() -> Result<()> { + let font = try_fc_match().context( + "could not find a system font via fc-match. Is fontconfig installed? Try: fc-match sans-serif", + )?; + FONT.set(font).ok(); + Ok(()) +} + +/// Returns the loaded font, or `None` if [`init`] has not been called. +pub fn font() -> Option<&'static fontdue::Font> { + FONT.get() +} + +fn try_fc_match() -> Option { + let output = std::process::Command::new("fc-match") + .args(["--format=%{file}", "sans-serif"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8_lossy(&output.stdout); + let data = std::fs::read(path.trim()).ok()?; + fontdue::Font::from_bytes(data, fontdue::FontSettings::default()).ok() +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..d0d61be --- /dev/null +++ b/src/input.rs @@ -0,0 +1,399 @@ +//! Grid selection state machine and application state. +//! +//! Tracks the user's progress through column selection, row selection, and +//! post-selection actions (nudge, undo). The key arrays define the grid layout. + +/// Keys displayed along the top axis of the grid (columns). +/// +/// Letters only — no digits — so every pill is unambiguously two letters. +pub const COL_KEYS: &[char] = &['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'p']; + +/// Keys displayed along the side axis of the grid (rows). +/// +/// Inspired by Vimium's easy-to-reach character set. Overlaps with column +/// keys on purpose — the state machine disambiguates (first press = column, +/// second = row), so combos like "aa" and "ss" are valid and fast to type. +/// Ordered by keyboard row (top → home → bottom ≈ screen top → bottom). +pub const ROW_KEYS: &[char] = &[ + 'w', 'e', 'r', 't', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'p', 'c', 'm', +]; + +/// Returns the number of columns in the selection grid. +pub fn grid_cols() -> u32 { + COL_KEYS.len() as u32 +} + +/// Returns the number of rows in the selection grid. +pub fn grid_rows() -> u32 { + ROW_KEYS.len() as u32 +} + +#[cfg(test)] +fn is_col_key(ch: char) -> bool { + COL_KEYS.contains(&ch) +} + +/// Returns `true` if `ch` is a valid row-selection key. +pub fn is_row_key(ch: char) -> bool { + ROW_KEYS.contains(&ch) +} + +// --------------------------------------------------------------------------- +// Selection phase — enforces valid state transitions at the type level. +// --------------------------------------------------------------------------- + +/// Tracks how far the user has progressed through the two-key grid selection. +#[derive(Clone, Debug)] +pub enum SelectionPhase { + /// No key pressed yet; cursor sits at screen center. + Initial { cursor: (u32, u32) }, + /// Column key pressed; waiting for the row key. + ColumnSelected { col: char, cursor: (u32, u32) }, + /// Both keys pressed; cell is locked and cursor is centered in it. + CellSelected { + col: char, + row: char, + cursor: (u32, u32), + }, +} + +impl SelectionPhase { + pub fn new(w: u32, h: u32) -> Self { + Self::Initial { + cursor: (w / 2, h / 2), + } + } + + pub fn cursor(&self) -> (u32, u32) { + match *self { + Self::Initial { cursor } + | Self::ColumnSelected { cursor, .. } + | Self::CellSelected { cursor, .. } => cursor, + } + } + + pub fn is_cell_selected(&self) -> bool { + matches!(self, Self::CellSelected { .. }) + } + + /// Returns the pending column key, if in the `ColumnSelected` phase. + pub fn pending_col(&self) -> Option { + match *self { + Self::ColumnSelected { col, .. } => Some(col), + _ => None, + } + } + + /// Builds the two-character label shown in the status bar (e.g. "AQ"). + pub fn cell_label(&self) -> String { + match *self { + Self::CellSelected { col, row, .. } => { + let mut s = String::with_capacity(2); + s.push(col); + s.push(row); + s + } + _ => String::new(), + } + } + + /// Transition: accept a column key, snap cursor horizontally. + pub fn select_column(&self, col: char, w: u32, h: u32) -> Option { + let col_idx = COL_KEYS.iter().position(|&c| c == col)? as u32; + let cols = grid_cols(); + if cols == 0 || w < cols { + return None; + } + let cell_w = w / cols; + Some(Self::ColumnSelected { + col, + cursor: (col_idx * cell_w + cell_w / 2, h / 2), + }) + } + + /// Transition: accept a row key (must already have a column), lock the cell. + pub fn select_cell(&self, row: char, w: u32, h: u32) -> Option { + let col = self.pending_col()?; + let col_idx = COL_KEYS.iter().position(|&c| c == col)? as u32; + let row_idx = ROW_KEYS.iter().position(|&c| c == row)? as u32; + let cols = grid_cols(); + let rows = grid_rows(); + if cols == 0 || rows == 0 || w < cols || h < rows { + return None; + } + let cell_w = w / cols; + let cell_h = h / rows; + Some(Self::CellSelected { + col, + row, + cursor: (col_idx * cell_w + cell_w / 2, row_idx * cell_h + cell_h / 2), + }) + } + + /// Offset the cursor by `(dx, dy)` pixels, clamping to screen bounds. + pub fn nudge(&self, dx: i32, dy: i32, w: u32, h: u32) -> Self { + let (cx, cy) = self.cursor(); + let mut out = self.clone(); + let new_cursor = ( + (cx as i32 + dx).clamp(0, w as i32 - 1) as u32, + (cy as i32 + dy).clamp(0, h as i32 - 1) as u32, + ); + match &mut out { + Self::Initial { cursor } + | Self::ColumnSelected { cursor, .. } + | Self::CellSelected { cursor, .. } => *cursor = new_cursor, + } + out + } + + /// Step back one phase. Returns `None` from `Initial` (caller decides + /// whether that means "exit" or "ignore"). + pub fn undo(&self, w: u32, h: u32) -> Option { + match self { + Self::CellSelected { .. } | Self::ColumnSelected { .. } => Some(Self::new(w, h)), + Self::Initial { .. } => None, + } + } +} + +// --------------------------------------------------------------------------- +// Top-level application state. +// --------------------------------------------------------------------------- + +/// Complete UI state: the selection phase plus optional drag origin. +pub struct AppState { + pub phase: SelectionPhase, + pub drag_origin: Option<(u32, u32)>, +} + +impl AppState { + pub fn new(w: u32, h: u32) -> Self { + Self { + phase: SelectionPhase::new(w, h), + drag_origin: None, + } + } + + pub fn is_dragging(&self) -> bool { + self.drag_origin.is_some() + } +} + +// =========================================================================== +// Tests. +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + const W: u32 = 1920; + const H: u32 = 1080; + + // -- Grid constants ----------------------------------------------------- + + #[test] + fn grid_dimensions_match_key_arrays() { + assert_eq!(grid_cols() as usize, COL_KEYS.len()); + assert_eq!(grid_rows() as usize, ROW_KEYS.len()); + } + + #[test] + fn is_col_key_accepts_all_col_keys() { + for &k in COL_KEYS { + assert!(is_col_key(k), "{k} should be a column key"); + } + } + + #[test] + fn is_row_key_accepts_all_row_keys() { + for &k in ROW_KEYS { + assert!(is_row_key(k), "{k} should be a row key"); + } + } + + #[test] + fn row_keys_cover_all_col_keys() { + // Every column key is also a valid row key, enabling fast double-tap + // combos like "aa" and "ss". + for &k in COL_KEYS { + assert!(is_row_key(k), "{k} is a col key but not a row key"); + } + } + + // -- SelectionPhase transitions ----------------------------------------- + + #[test] + fn initial_cursor_is_screen_center() { + let phase = SelectionPhase::new(W, H); + assert_eq!(phase.cursor(), (W / 2, H / 2)); + assert!(!phase.is_cell_selected()); + assert_eq!(phase.pending_col(), None); + } + + #[test] + fn select_column_transitions_to_column_selected() { + let phase = SelectionPhase::new(W, H); + let next = phase.select_column('a', W, H).unwrap(); + assert!(next.pending_col().is_some()); + assert_eq!(next.pending_col(), Some('a')); + assert!(!next.is_cell_selected()); + } + + #[test] + fn select_column_snaps_cursor_x_to_cell_center() { + let phase = SelectionPhase::new(W, H); + let cell_w = W / grid_cols(); + + // First column ('a' at index 0). + let next = phase.select_column('a', W, H).unwrap(); + assert_eq!(next.cursor().0, cell_w / 2); + + // Last column ('p' at index 9). + let next = phase.select_column('p', W, H).unwrap(); + assert_eq!(next.cursor().0, 9 * cell_w + cell_w / 2); + } + + #[test] + fn select_cell_from_column_selected() { + let phase = SelectionPhase::new(W, H).select_column('a', W, H).unwrap(); + let next = phase.select_cell('w', W, H); + assert!(next.is_some()); + let next = next.unwrap(); + assert!(next.is_cell_selected()); + + let cell_w = W / grid_cols(); + let cell_h = H / grid_rows(); + assert_eq!(next.cursor(), (cell_w / 2, cell_h / 2)); + } + + #[test] + fn select_cell_from_initial_returns_none() { + let phase = SelectionPhase::new(W, H); + assert!(phase.select_cell('w', W, H).is_none()); + } + + #[test] + fn select_cell_with_overlapping_key() { + // 'a' is both a col key and a row key — "aa" should work. + let phase = SelectionPhase::new(W, H).select_column('a', W, H).unwrap(); + let next = phase.select_cell('a', W, H); + assert!(next.is_some()); + assert!(next.unwrap().is_cell_selected()); + } + + #[test] + fn select_cell_with_invalid_row_key_returns_none() { + let phase = SelectionPhase::new(W, H).select_column('a', W, H).unwrap(); + // '!' is not a row key. + assert!(phase.select_cell('!', W, H).is_none()); + } + + #[test] + fn cell_label_only_when_selected() { + let initial = SelectionPhase::new(W, H); + assert_eq!(initial.cell_label(), ""); + + let col = initial.select_column('d', W, H).unwrap(); + assert_eq!(col.cell_label(), ""); + + let cell = col.select_cell('w', W, H).unwrap(); + assert_eq!(cell.cell_label(), "dw"); + } + + // -- Nudge -------------------------------------------------------------- + + #[test] + fn nudge_moves_cursor() { + let phase = SelectionPhase::new(W, H); + let nudged = phase.nudge(10, -5, W, H); + let (cx, cy) = nudged.cursor(); + assert_eq!(cx, W / 2 + 10); + assert_eq!(cy, H / 2 - 5); + } + + #[test] + fn nudge_clamps_to_zero() { + let phase = SelectionPhase::new(W, H); + let nudged = phase.nudge(-10000, -10000, W, H); + assert_eq!(nudged.cursor(), (0, 0)); + } + + #[test] + fn nudge_clamps_to_screen_edge() { + let phase = SelectionPhase::new(W, H); + let nudged = phase.nudge(10000, 10000, W, H); + assert_eq!(nudged.cursor(), (W - 1, H - 1)); + } + + #[test] + fn nudge_preserves_phase_variant() { + let cell = SelectionPhase::new(W, H) + .select_column('a', W, H) + .unwrap() + .select_cell('w', W, H) + .unwrap(); + let nudged = cell.nudge(5, 5, W, H); + assert!(nudged.is_cell_selected()); + } + + // -- Undo --------------------------------------------------------------- + + #[test] + fn undo_from_cell_selected_returns_initial() { + let cell = SelectionPhase::new(W, H) + .select_column('a', W, H) + .unwrap() + .select_cell('w', W, H) + .unwrap(); + let prev = cell.undo(W, H); + assert!(prev.is_some()); + assert!(!prev.unwrap().is_cell_selected()); + } + + #[test] + fn undo_from_column_selected_returns_initial() { + let col = SelectionPhase::new(W, H).select_column('a', W, H).unwrap(); + let prev = col.undo(W, H); + assert!(prev.is_some()); + assert_eq!(prev.unwrap().pending_col(), None); + } + + #[test] + fn undo_from_initial_returns_none() { + let initial = SelectionPhase::new(W, H); + assert!(initial.undo(W, H).is_none()); + } + + // -- AppState ----------------------------------------------------------- + + #[test] + fn app_state_initial_not_dragging() { + let state = AppState::new(W, H); + assert!(!state.is_dragging()); + } + + #[test] + fn app_state_dragging_when_origin_set() { + let mut state = AppState::new(W, H); + state.drag_origin = Some((100, 200)); + assert!(state.is_dragging()); + } + + // -- Grid math covers full screen --------------------------------------- + + #[test] + fn grid_cells_tile_screen() { + let cell_w = W / grid_cols(); + let cell_h = H / grid_rows(); + // Every cell center should be within screen bounds. + for col in 0..grid_cols() { + for row in 0..grid_rows() { + let cx = col * cell_w + cell_w / 2; + let cy = row * cell_h + cell_h / 2; + assert!(cx < W, "col {col} center x={cx} out of bounds"); + assert!(cy < H, "row {row} center y={cy} out of bounds"); + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..904e625 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,267 @@ +//! mousefree — keyboard-driven mouse navigation for Wayland. +//! +//! Displays a full-screen overlay grid, lets the user select a cell with two +//! keystrokes, then performs mouse actions (click, drag, scroll) at that +//! location via a virtual pointer. + +mod font; +mod input; +mod render; +mod wayland; + +use anyhow::Context; +use tiny_skia::Pixmap; + +use input::{AppState, SelectionPhase}; +use wayland::{KeyEvent, WaylandBackend}; + +/// Pixels per pixel-perfect cursor nudge (Alt+hjkl). +const CURSOR_NUDGE_XS: i32 = 1; +/// Pixels per cursor nudge (hjkl). +const CURSOR_NUDGE_SM: i32 = 8; +/// Pixels per large cursor nudge (HJKL). +const CURSOR_NUDGE_MD: i32 = 16; +/// Pixels per extra-large cursor nudge (Ctrl+hjkl). +const CURSOR_NUDGE_LG: i32 = 32; + +fn main() -> anyhow::Result<()> { + font::init()?; + + let mut wl = WaylandBackend::new()?; + let (w, h) = wl.screen_size(); + let mut pixmap = Pixmap::new(w, h).context("failed to create pixmap")?; + let buf_size = (w as usize) + .checked_mul(h as usize) + .and_then(|n| n.checked_mul(4)) + .context("screen dimensions too large for pixel buffer")?; + let mut argb_buf = vec![0u8; buf_size]; + let mut state = AppState::new(w, h); + + draw(&mut wl, &mut pixmap, &mut argb_buf, &state)?; + + while let Some(key) = wl.next_key()? { + match handle_key(&key, &mut state, &mut wl, w, h)? { + KeyAction::Redraw => draw(&mut wl, &mut pixmap, &mut argb_buf, &state)?, + KeyAction::Exit => { + wl.exit()?; + break; + } + KeyAction::None => {} + } + } + Ok(()) +} + +/// Result of processing a single key event. +enum KeyAction { + None, + Redraw, + Exit, +} + +fn draw( + wl: &mut WaylandBackend, + pixmap: &mut Pixmap, + argb_buf: &mut [u8], + state: &AppState, +) -> anyhow::Result<()> { + render::render_grid(pixmap, state); + render::render_status_bar(pixmap, state); + render::pixmap_to_argb8888(pixmap, argb_buf); + let (w, h) = wl.screen_size(); + wl.present(argb_buf, w, h) +} + +// --------------------------------------------------------------------------- +// Key dispatch — thin router that delegates to focused handlers. +// --------------------------------------------------------------------------- + +fn handle_key( + key: &KeyEvent, + state: &mut AppState, + wl: &mut WaylandBackend, + w: u32, + h: u32, +) -> anyhow::Result { + match key { + KeyEvent::Close => { + wl.wait_for_key_release()?; + Ok(KeyAction::Exit) + } + + KeyEvent::Undo => handle_undo(state, wl, w, h), + + // Nudge cursor (only when a cell is locked). + KeyEvent::Char('h' | 'j' | 'k' | 'l' | 'H' | 'J' | 'K' | 'L') + | KeyEvent::CtrlChar('h' | 'j' | 'k' | 'l') + | KeyEvent::AltChar('h' | 'j' | 'k' | 'l') + if state.phase.is_cell_selected() => + { + handle_nudge(key, state, wl, w, h) + } + + // Click / drag actions (only when a cell is locked). + KeyEvent::Click + | KeyEvent::DoubleClick + | KeyEvent::TripleClick + | KeyEvent::RightClick + | KeyEvent::Char('/') + if state.phase.is_cell_selected() => + { + handle_action(key, state, wl) + } + + // Two-key grid selection (only before a cell is locked). + KeyEvent::Char(ch) if !state.phase.is_cell_selected() => { + handle_grid_input(*ch, state, wl, w, h) + } + + // Scroll passes through to the underlying window. + KeyEvent::ScrollUp => { + wl.scroll_up()?; + Ok(KeyAction::Redraw) + } + KeyEvent::ScrollDown => { + wl.scroll_down()?; + Ok(KeyAction::Redraw) + } + KeyEvent::ScrollLeft => { + wl.scroll_left()?; + Ok(KeyAction::Redraw) + } + KeyEvent::ScrollRight => { + wl.scroll_right()?; + Ok(KeyAction::Redraw) + } + + _ => Ok(KeyAction::None), + } +} + +// --------------------------------------------------------------------------- +// Focused handlers. +// --------------------------------------------------------------------------- + +fn handle_undo( + state: &mut AppState, + wl: &mut WaylandBackend, + w: u32, + h: u32, +) -> anyhow::Result { + if let Some(prev) = state.phase.undo(w, h) { + state.phase = prev; + state.drag_origin = None; + let (cx, cy) = state.phase.cursor(); + wl.move_mouse(cx, cy)?; + Ok(KeyAction::Redraw) + } else { + // Already at Initial — back out of the overlay entirely. + wl.wait_for_key_release()?; + Ok(KeyAction::Exit) + } +} + +fn handle_nudge( + key: &KeyEvent, + state: &mut AppState, + wl: &mut WaylandBackend, + w: u32, + h: u32, +) -> anyhow::Result { + let (dx, dy) = match key { + KeyEvent::Char('h' | 'H') | KeyEvent::CtrlChar('h') | KeyEvent::AltChar('h') => (-1, 0), + KeyEvent::Char('j' | 'J') | KeyEvent::CtrlChar('j') | KeyEvent::AltChar('j') => (0, 1), + KeyEvent::Char('k' | 'K') | KeyEvent::CtrlChar('k') | KeyEvent::AltChar('k') => (0, -1), + KeyEvent::Char('l' | 'L') | KeyEvent::CtrlChar('l') | KeyEvent::AltChar('l') => (1, 0), + _ => unreachable!("handle_nudge called with non-hjkl key"), + }; + let step = match key { + KeyEvent::AltChar(_) => CURSOR_NUDGE_XS, + KeyEvent::CtrlChar(_) => CURSOR_NUDGE_LG, + KeyEvent::Char('H' | 'J' | 'K' | 'L') => CURSOR_NUDGE_MD, + _ => CURSOR_NUDGE_SM, + }; + state.phase = state.phase.nudge(dx * step, dy * step, w, h); + let (cx, cy) = state.phase.cursor(); + wl.move_mouse(cx, cy)?; + Ok(KeyAction::Redraw) +} + +/// Handle click, double-click, right-click, and drag start/complete. +fn handle_action( + key: &KeyEvent, + state: &mut AppState, + wl: &mut WaylandBackend, +) -> anyhow::Result { + let (cx, cy) = state.phase.cursor(); + + // If already dragging, complete the drag on click or '/'. + if let Some((ox, oy)) = state.drag_origin { + if matches!(key, KeyEvent::Click | KeyEvent::Char('/')) { + wl.wait_for_key_release()?; + wl.drag_select(ox, oy, cx, cy)?; + return Ok(KeyAction::Exit); + } + } + + match key { + KeyEvent::Click => { + wl.wait_for_key_release()?; + wl.click(cx, cy)?; + Ok(KeyAction::Exit) + } + KeyEvent::DoubleClick => { + wl.wait_for_key_release()?; + wl.double_click(cx, cy)?; + Ok(KeyAction::Exit) + } + KeyEvent::TripleClick => { + wl.wait_for_key_release()?; + wl.triple_click(cx, cy)?; + Ok(KeyAction::Exit) + } + KeyEvent::RightClick if !state.is_dragging() => { + wl.wait_for_key_release()?; + wl.right_click(cx, cy)?; + Ok(KeyAction::Exit) + } + // '/' starts a drag: remember origin, reset selection for drop target. + KeyEvent::Char('/') if !state.is_dragging() => { + state.drag_origin = Some((cx, cy)); + let (w, h) = wl.screen_size(); + state.phase = SelectionPhase::new(w, h); + Ok(KeyAction::Redraw) + } + _ => Ok(KeyAction::None), + } +} + +fn handle_grid_input( + ch: char, + state: &mut AppState, + wl: &mut WaylandBackend, + w: u32, + h: u32, +) -> anyhow::Result { + // Second key: select row. Checked FIRST so that overlapping col/row + // keys (e.g. "aa", "ss") complete the cell rather than re-selecting + // the column. + if state.phase.pending_col().is_some() && input::is_row_key(ch) { + if let Some(next) = state.phase.select_cell(ch, w, h) { + state.phase = next; + let (cx, cy) = state.phase.cursor(); + wl.move_mouse(cx, cy)?; + return Ok(KeyAction::Redraw); + } + } + + // First key (or column re-selection if ch isn't a valid row key). + if let Some(next) = state.phase.select_column(ch, w, h) { + state.phase = next; + let (cx, cy) = state.phase.cursor(); + wl.move_mouse(cx, cy)?; + return Ok(KeyAction::Redraw); + } + + Ok(KeyAction::None) +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..db996e8 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,840 @@ +//! Overlay rendering: hint-pill grid, crosshair, and status bar. +//! +//! All drawing targets a `tiny_skia::Pixmap` which is then converted to +//! Wayland's ARGB8888 format for presentation. + +use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Rect, Shader, Stroke, Transform}; + +use crate::{ + font::font, + input::{self, AppState, SelectionPhase}, +}; + +// =========================================================================== +// Color palette. +// +// Design: "dark glass" — semi-transparent dark pills that read well over any +// background. One accent (blue) for interactive states, warm amber for drag. +// Three-tier visual hierarchy: matched > default > dimmed. +// =========================================================================== + +// -- Hint pills (two-letter bubbles on the grid) ---------------------------- + +const PILL_W: f32 = 36.0; +const PILL_H: f32 = 22.0; +const PILL_RADIUS: f32 = 6.0; +const PILL_FONT_SIZE: f32 = 14.0; +const PILL_LETTER_GAP: f32 = 2.0; +const PILL_BOLD_PASSES: i32 = 2; + +const PILL_SHADOW_OFFSET: f32 = 1.5; +const PILL_BORDER_WIDTH: f32 = 1.0; + +// Default state — dark glass with a hint of blue, bright border frames the text. +fn pill_bg() -> Color { + Color::from_rgba8(16, 20, 38, 0xD8) +} +fn pill_border() -> Color { + Color::from_rgba8(130, 135, 150, 0xB0) +} +fn pill_text() -> Color { + Color::from_rgba8(240, 242, 250, 0xFF) +} + +// Dimmed state — columns that don't match the pending selection fade away. +fn pill_dim_bg() -> Color { + Color::from_rgba8(15, 17, 28, 0x20) +} +fn pill_dim_border() -> Color { + Color::from_rgba8(15, 17, 28, 0x20) +} +fn pill_dim_text() -> Color { + Color::from_rgba8(90, 95, 110, 0x30) +} + +// Match state — the active column blazes with the accent blue. +fn pill_match_bg() -> Color { + Color::from_rgba8(45, 110, 230, 0xF0) +} +fn pill_match_border() -> Color { + Color::from_rgba8(110, 170, 255, 0xE0) +} +fn pill_match_text() -> Color { + Color::from_rgba8(255, 255, 255, 0xFF) +} + +fn pill_shadow() -> Color { + Color::from_rgba8(0, 0, 0, 0x48) +} + +// -- Crosshair (shown after cell selection) --------------------------------- + +fn crosshair_color() -> Color { + Color::from_rgba8(55, 120, 235, 0xFF) // same accent blue +} +fn crosshair_drag_color() -> Color { + Color::from_rgba8(240, 175, 55, 0xFF) // warm amber +} +fn crosshair_outline() -> Color { + Color::from_rgba8(0, 0, 0, 0xB0) +} +const CROSSHAIR_ARM: f32 = 20.0; +const CROSSHAIR_GAP: f32 = 4.0; +const CROSSHAIR_THICK: f32 = 2.0; + +// -- Screen overlay --------------------------------------------------------- + +fn screen_dim() -> Color { + Color::from_rgba8(0, 0, 0, 0x30) +} + +// -- Status bar (bottom-center help text) ----------------------------------- + +fn status_bg() -> Color { + Color::from_rgba8(16, 18, 28, 0xE8) +} +/// Accent blue for key labels — matches the pill match / crosshair palette. +fn status_key_color() -> Color { + Color::from_rgba8(110, 170, 255, 0xFF) +} +/// Muted text for action descriptions. +fn status_desc_color() -> Color { + Color::from_rgba8(170, 175, 190, 0xD0) +} +/// Dim separator between key groups. +fn status_sep_color() -> Color { + Color::from_rgba8(80, 85, 100, 0x80) +} +/// Bright highlight for the cell label / state indicator. +fn status_label_color() -> Color { + Color::from_rgba8(255, 255, 255, 0xFF) +} +/// Warm amber for drag state indicator. +fn status_drag_color() -> Color { + Color::from_rgba8(240, 175, 55, 0xFF) +} +const STATUS_FONT_SIZE: f32 = 15.5; +const STATUS_PAD_X: f32 = 14.0; +const STATUS_PAD_Y: f32 = 7.0; +const STATUS_BAR_RADIUS: f32 = 8.0; +const STATUS_BOLD_PASSES: i32 = 2; + +// =========================================================================== +// Public rendering entry points. +// =========================================================================== + +/// Renders either the hint-pill grid (pre-selection) or the crosshair +/// (post-selection) into `pixmap`. +pub fn render_grid(pixmap: &mut Pixmap, state: &AppState) { + pixmap.fill(Color::TRANSPARENT); + + // Dim the desktop so the overlay elements stand out. + let w = pixmap.width() as f32; + let h = pixmap.height() as f32; + fill_rect(pixmap, 0.0, 0.0, w, h, screen_dim()); + + if state.phase.is_cell_selected() { + draw_crosshair(pixmap, state.phase.cursor(), state.is_dragging()); + } else { + draw_pill_grid(pixmap, &state.phase); + } +} + +/// Renders the context-sensitive help bar at the bottom of the screen. +pub fn render_status_bar(pixmap: &mut Pixmap, state: &AppState) { + let segments = status_segments(state); + let w = pixmap.width() as f32; + let h = pixmap.height() as f32; + + let Some(ft) = font() else { return }; + + // Pre-rasterize all glyphs to measure total width. + let rasterized: Vec<_> = segments + .iter() + .map(|seg| { + let glyphs: Vec<_> = seg + .text + .chars() + .map(|ch| ft.rasterize(ch, STATUS_FONT_SIZE)) + .collect(); + let w: f32 = glyphs.iter().map(|(m, _)| m.advance_width).sum(); + (glyphs, w) + }) + .collect(); + let text_w: f32 = rasterized.iter().map(|(_, w)| w).sum(); + + let bar_h = STATUS_FONT_SIZE + STATUS_PAD_Y * 2.0; + let bar_w = text_w + STATUS_PAD_X * 2.0; + let bar_x = (w - bar_w) / 2.0; + let bar_y = h - bar_h - 8.0; + + fill_rounded_rect( + pixmap, + bar_x, + bar_y, + bar_w, + bar_h, + STATUS_BAR_RADIUS, + status_bg(), + ); + + let mut gx = bar_x + STATUS_PAD_X; + let baseline_y = bar_y + STATUS_PAD_Y + STATUS_FONT_SIZE; + + for (i, seg) in segments.iter().enumerate() { + let bold = seg.bold; + let (ref glyphs, _) = rasterized[i]; + for (metrics, bitmap) in glyphs { + let passes = if bold { STATUS_BOLD_PASSES } else { 1 }; + for off in 0..passes { + blit_glyph( + pixmap, + gx + metrics.xmin as f32 + off as f32, + baseline_y - metrics.height as f32 - metrics.ymin as f32, + metrics.width, + metrics.height, + bitmap, + seg.color, + ); + } + gx += metrics.advance_width; + } + } +} + +/// Convert a tiny_skia `Pixmap` (premultiplied RGBA) to Wayland's ARGB8888 +/// format (premultiplied BGRA in memory on little-endian). +pub fn pixmap_to_argb8888(pixmap: &Pixmap, out: &mut [u8]) { + let src = pixmap.data(); + assert_eq!( + out.len(), + src.len(), + "output buffer size must match pixmap data size" + ); + // Swap R and B channels: RGBA → BGRA. + for (dst, src) in out.chunks_exact_mut(4).zip(src.chunks_exact(4)) { + dst[0] = src[2]; // B + dst[1] = src[1]; // G + dst[2] = src[0]; // R + dst[3] = src[3]; // A + } +} + +// =========================================================================== +// Private drawing helpers. +// =========================================================================== + +fn draw_crosshair(pixmap: &mut Pixmap, (cx, cy): (u32, u32), dragging: bool) { + let cx = cx as f32; + let cy = cy as f32; + let color = if dragging { + crosshair_drag_color() + } else { + crosshair_color() + }; + let half = CROSSHAIR_THICK / 2.0; + let arm = CROSSHAIR_ARM; + let gap = CROSSHAIR_GAP; + + // Outline: slightly larger rectangles behind each arm. + let outline = crosshair_outline(); + let o = 1.0; // outline padding + fill_rect( + pixmap, + cx - arm - o, + cy - half - o, + arm - gap + o, + CROSSHAIR_THICK + o * 2.0, + outline, + ); + fill_rect( + pixmap, + cx + gap, + cy - half - o, + arm - gap + o, + CROSSHAIR_THICK + o * 2.0, + outline, + ); + fill_rect( + pixmap, + cx - half - o, + cy - arm - o, + CROSSHAIR_THICK + o * 2.0, + arm - gap + o, + outline, + ); + fill_rect( + pixmap, + cx - half - o, + cy + gap, + CROSSHAIR_THICK + o * 2.0, + arm - gap + o, + outline, + ); + + // Colored arms. + fill_rect( + pixmap, + cx - arm, + cy - half, + arm - gap, + CROSSHAIR_THICK, + color, + ); + fill_rect( + pixmap, + cx + gap, + cy - half, + arm - gap, + CROSSHAIR_THICK, + color, + ); + fill_rect( + pixmap, + cx - half, + cy - arm, + CROSSHAIR_THICK, + arm - gap, + color, + ); + fill_rect( + pixmap, + cx - half, + cy + gap, + CROSSHAIR_THICK, + arm - gap, + color, + ); +} + +fn draw_pill_grid(pixmap: &mut Pixmap, phase: &SelectionPhase) { + let w = pixmap.width() as f32; + let h = pixmap.height() as f32; + let cols = input::grid_cols() as f32; + let rows = input::grid_rows() as f32; + let cell_w = w / cols; + let cell_h = h / rows; + let pending = phase.pending_col(); + + for (row_idx, &row_key) in input::ROW_KEYS.iter().enumerate() { + for (col_idx, &col_key) in input::COL_KEYS.iter().enumerate() { + let cx = col_idx as f32 * cell_w + cell_w / 2.0; + let cy = row_idx as f32 * cell_h + cell_h / 2.0; + + let col_match = pending == Some(col_key); + let (bg, border, text) = match (pending.is_some(), col_match) { + (true, false) => (pill_dim_bg(), pill_dim_border(), pill_dim_text()), + (_, true) => (pill_match_bg(), pill_match_border(), pill_match_text()), + _ => (pill_bg(), pill_border(), pill_text()), + }; + + draw_hint_pill( + pixmap, + cx, + cy, + col_key.to_ascii_uppercase(), + row_key.to_ascii_uppercase(), + bg, + border, + text, + ); + } + } +} + +/// Draw a single hint pill: shadow, rounded-rect fill, border stroke, text. +#[allow(clippy::too_many_arguments)] +fn draw_hint_pill( + pixmap: &mut Pixmap, + cx: f32, + cy: f32, + ch1: char, + ch2: char, + bg: Color, + border: Color, + text_color: Color, +) { + let px = cx - PILL_W / 2.0; + let py = cy - PILL_H / 2.0; + + // Shadow (offset down-right). + fill_rounded_rect( + pixmap, + px + PILL_SHADOW_OFFSET, + py + PILL_SHADOW_OFFSET, + PILL_W, + PILL_H, + PILL_RADIUS, + pill_shadow(), + ); + + // Background fill. + fill_rounded_rect(pixmap, px, py, PILL_W, PILL_H, PILL_RADIUS, bg); + + // Border stroke. + stroke_rounded_rect( + pixmap, + px, + py, + PILL_W, + PILL_H, + PILL_RADIUS, + PILL_BORDER_WIDTH, + border, + ); + + // Text (two characters, faux-bold via horizontal repetition). + let Some(ft) = font() else { return }; + let (m1, bm1) = ft.rasterize(ch1, PILL_FONT_SIZE); + let (m2, bm2) = ft.rasterize(ch2, PILL_FONT_SIZE); + let text_w = m1.advance_width + PILL_LETTER_GAP + m2.advance_width; + let start_x = px + (PILL_W - text_w) / 2.0; + let baseline_y = py + PILL_H / 2.0 + PILL_FONT_SIZE / 3.0; + + let g1x = start_x + m1.xmin as f32; + let g1y = baseline_y - m1.height as f32 - m1.ymin as f32; + let g2x = start_x + m1.advance_width + PILL_LETTER_GAP + m2.xmin as f32; + let g2y = baseline_y - m2.height as f32 - m2.ymin as f32; + + for off in 0..PILL_BOLD_PASSES { + blit_glyph( + pixmap, + g1x + off as f32, + g1y, + m1.width, + m1.height, + &bm1, + text_color, + ); + blit_glyph( + pixmap, + g2x + off as f32, + g2y, + m2.width, + m2.height, + &bm2, + text_color, + ); + } +} + +/// A styled text segment in the status bar. +struct Span { + text: String, + color: Color, + bold: bool, +} + +impl Span { + fn key(s: &str) -> Self { + Self { + text: s.to_string(), + color: status_key_color(), + bold: true, + } + } + fn desc(s: &str) -> Self { + Self { + text: s.to_string(), + color: status_desc_color(), + bold: false, + } + } + fn sep() -> Self { + Self { + text: " \u{00b7} ".to_string(), // middle dot + color: status_sep_color(), + bold: false, + } + } + fn label(s: &str) -> Self { + Self { + text: s.to_string(), + color: status_label_color(), + bold: true, + } + } + fn drag(s: &str) -> Self { + Self { + text: s.to_string(), + color: status_drag_color(), + bold: true, + } + } +} + +/// Builds styled segments for the status bar. +fn status_segments(state: &AppState) -> Vec { + let dragging = state.is_dragging(); + let phase = &state.phase; + + if phase.is_cell_selected() && dragging { + let mut s = vec![ + Span::drag("DRAG "), + Span::label(&phase.cell_label()), + Span::sep(), + Span::key("/ "), + Span::key("Space"), + Span::desc(" drop"), + ]; + push_nudge_spans(&mut s); + push_nav_spans(&mut s); + s + } else if phase.is_cell_selected() { + let mut s = vec![ + Span::label(&phase.cell_label()), + Span::sep(), + Span::key("Space"), + Span::desc(" click"), + Span::sep(), + Span::key("Enter"), + Span::desc(" double"), + Span::sep(), + Span::key("S-Enter"), + Span::desc(" triple"), + Span::sep(), + Span::key(" . "), + Span::desc(" right"), + Span::sep(), + Span::key("/"), + Span::desc(" drag"), + ]; + push_nudge_spans(&mut s); + push_nav_spans(&mut s); + s + } else if let Some(col) = phase.pending_col() { + vec![ + Span::label(&format!("{col}_")), + Span::desc(" type row key"), + Span::sep(), + Span::key("BS"), + Span::desc(" back"), + Span::sep(), + Span::key("Esc"), + Span::desc(" quit"), + ] + } else if dragging { + vec![ + Span::drag("DRAG"), + Span::desc(" type col+row to pick drop target"), + Span::sep(), + Span::key("Esc"), + Span::desc(" quit"), + ] + } else { + vec![ + Span::desc("type col+row to select"), + Span::sep(), + Span::key("Esc"), + Span::desc(" quit"), + ] + } +} + +fn push_nudge_spans(s: &mut Vec) { + s.push(Span::sep()); + s.push(Span::key("hjkl")); + s.push(Span::desc(" nudge")); + s.push(Span::sep()); + s.push(Span::key("HJKL")); + s.push(Span::desc(" big")); + s.push(Span::sep()); + s.push(Span::key("C-hjkl")); + s.push(Span::desc(" bigger")); + s.push(Span::sep()); + s.push(Span::key("A-hjkl")); + s.push(Span::desc(" 1px")); +} + +fn push_nav_spans(s: &mut Vec) { + s.push(Span::sep()); + s.push(Span::key("BS")); + s.push(Span::desc(" back")); + s.push(Span::sep()); + s.push(Span::key("Esc")); + s.push(Span::desc(" quit")); +} + +/// Plain-text version of the status bar content for testing. +#[cfg(test)] +pub(crate) fn status_text(state: &AppState) -> String { + status_segments(state) + .iter() + .map(|s| s.text.as_str()) + .collect() +} + +// =========================================================================== +// tiny_skia drawing primitives. +// =========================================================================== + +fn paint(color: Color) -> Paint<'static> { + Paint { + shader: Shader::SolidColor(color), + anti_alias: true, + ..Paint::default() + } +} + +fn fill_rect(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, color: Color) { + if let Some(rect) = Rect::from_xywh(x, y, w, h) { + let path = PathBuilder::from_rect(rect); + pixmap.fill_path( + &path, + &paint(color), + FillRule::Winding, + Transform::identity(), + None, + ); + } +} + +fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Option { + let r = r.min(w / 2.0).min(h / 2.0); + let mut pb = PathBuilder::new(); + pb.move_to(x + r, y); + pb.line_to(x + w - r, y); + pb.quad_to(x + w, y, x + w, y + r); + pb.line_to(x + w, y + h - r); + pb.quad_to(x + w, y + h, x + w - r, y + h); + pb.line_to(x + r, y + h); + pb.quad_to(x, y + h, x, y + h - r); + pb.line_to(x, y + r); + pb.quad_to(x, y, x + r, y); + pb.close(); + pb.finish() +} + +fn fill_rounded_rect(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, r: f32, color: Color) { + if let Some(path) = rounded_rect_path(x, y, w, h, r) { + pixmap.fill_path( + &path, + &paint(color), + FillRule::Winding, + Transform::identity(), + None, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn stroke_rounded_rect( + pixmap: &mut Pixmap, + x: f32, + y: f32, + w: f32, + h: f32, + r: f32, + width: f32, + color: Color, +) { + if let Some(path) = rounded_rect_path(x, y, w, h, r) { + let stroke = Stroke { + width, + ..Stroke::default() + }; + pixmap.stroke_path(&path, &paint(color), &stroke, Transform::identity(), None); + } +} + +/// Composite a fontdue glyph bitmap onto the pixmap. The bitmap contains +/// per-pixel coverage values; we blend them with `color` using standard +/// source-over compositing. +fn blit_glyph( + pixmap: &mut Pixmap, + x: f32, + y: f32, + gw: usize, + gh: usize, + bitmap: &[u8], + color: Color, +) { + let pw = pixmap.width() as i32; + let ph = pixmap.height() as i32; + let ix = x.round() as i32; + let iy = y.round() as i32; + + let data = pixmap.data_mut(); + let cr = (color.red() * 255.0 + 0.5) as u32; + let cg = (color.green() * 255.0 + 0.5) as u32; + let cb = (color.blue() * 255.0 + 0.5) as u32; + let ca = (color.alpha() * 255.0 + 0.5) as u32; + + for row in 0..gh { + let py = iy + row as i32; + if py < 0 || py >= ph { + continue; + } + for col in 0..gw { + let px = ix + col as i32; + if px < 0 || px >= pw { + continue; + } + let coverage = bitmap[row * gw + col] as u32; + if coverage == 0 { + continue; + } + let off = (py as usize * pw as usize + px as usize) * 4; + let dst = &mut data[off..off + 4]; + let src_a = (ca * coverage + 127) / 255; + let inv = 255 - src_a; + // tiny_skia stores premultiplied RGBA. + dst[0] = ((cr * src_a + dst[0] as u32 * inv + 127) / 255) as u8; + dst[1] = ((cg * src_a + dst[1] as u32 * inv + 127) / 255) as u8; + dst[2] = ((cb * src_a + dst[2] as u32 * inv + 127) / 255) as u8; + dst[3] = ((src_a * 255 + dst[3] as u32 * inv + 127) / 255) as u8; + } + } +} + +// =========================================================================== +// Tests. +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rounded_rect_path_builds_successfully() { + assert!(rounded_rect_path(0.0, 0.0, 40.0, 24.0, 6.0).is_some()); + } + + #[test] + fn rounded_rect_path_clamps_radius() { + // Radius larger than half the dimension should still produce a path. + assert!(rounded_rect_path(0.0, 0.0, 10.0, 10.0, 20.0).is_some()); + } + + #[test] + fn fill_rect_does_not_panic_on_zero_size() { + let mut pm = Pixmap::new(4, 4).unwrap(); + fill_rect(&mut pm, 0.0, 0.0, 0.0, 0.0, Color::BLACK); + } + + #[test] + fn fill_rect_paints_pixels() { + let mut pm = Pixmap::new(4, 4).unwrap(); + fill_rect( + &mut pm, + 0.0, + 0.0, + 4.0, + 4.0, + Color::from_rgba8(255, 0, 0, 255), + ); + // Center pixel should be opaque red (premultiplied RGBA). + let off = (2 * 4 + 2) * 4; + let px = &pm.data()[off..off + 4]; + assert_eq!(px, &[255, 0, 0, 255]); + } + + #[test] + fn blit_glyph_full_coverage_opaque() { + let mut pm = Pixmap::new(4, 4).unwrap(); + blit_glyph(&mut pm, 0.0, 0.0, 1, 1, &[255], Color::WHITE); + assert_eq!(&pm.data()[0..4], &[255, 255, 255, 255]); + } + + #[test] + fn blit_glyph_zero_coverage_leaves_buffer_unchanged() { + let mut pm = Pixmap::new(4, 4).unwrap(); + let before = pm.data().to_vec(); + blit_glyph(&mut pm, 0.0, 0.0, 1, 1, &[0], Color::WHITE); + assert_eq!(pm.data(), &before[..]); + } + + #[test] + fn blit_glyph_negative_coords_do_not_panic() { + let mut pm = Pixmap::new(4, 4).unwrap(); + blit_glyph(&mut pm, -5.0, -5.0, 2, 2, &[255; 4], Color::WHITE); + } + + #[test] + fn pixmap_to_argb8888_swaps_channels() { + let mut pm = Pixmap::new(1, 1).unwrap(); + // Paint a single red pixel. + fill_rect( + &mut pm, + 0.0, + 0.0, + 1.0, + 1.0, + Color::from_rgba8(255, 0, 0, 255), + ); + let mut out = [0u8; 4]; + pixmap_to_argb8888(&pm, &mut out); + // RGBA [255, 0, 0, 255] → BGRA [0, 0, 255, 255]. + assert_eq!(out, [0, 0, 255, 255]); + } + + // -- Status text ---------------------------------------------------------- + + #[test] + fn status_text_initial() { + let state = AppState::new(1920, 1080); + let text = status_text(&state); + assert!(text.contains("col+row")); + assert!(text.contains("Esc")); + } + + #[test] + fn status_text_column_selected() { + let mut state = AppState::new(1920, 1080); + state.phase = state.phase.select_column('a', 1920, 1080).unwrap(); + let text = status_text(&state); + assert!(text.contains("a_")); + assert!(text.contains("row key")); + } + + #[test] + fn status_text_cell_selected() { + let mut state = AppState::new(1920, 1080); + state.phase = state.phase.select_column('a', 1920, 1080).unwrap(); + state.phase = state.phase.select_cell('w', 1920, 1080).unwrap(); + let text = status_text(&state); + assert!(text.contains("aw")); + assert!(text.contains("Space")); + assert!(text.contains("click")); + assert!(text.contains("triple")); + } + + #[test] + fn status_text_dragging() { + let mut state = AppState::new(1920, 1080); + state.drag_origin = Some((100, 200)); + let text = status_text(&state); + assert!(text.contains("DRAG")); + } + + #[test] + fn status_text_drag_with_cell() { + let mut state = AppState::new(1920, 1080); + state.phase = state.phase.select_column('a', 1920, 1080).unwrap(); + state.phase = state.phase.select_cell('w', 1920, 1080).unwrap(); + state.drag_origin = Some((100, 200)); + let text = status_text(&state); + assert!(text.contains("DRAG")); + assert!(text.contains("drop")); + } + + // -- Render smoke tests --------------------------------------------------- + + #[test] + fn render_grid_initial_does_not_panic() { + let mut pm = Pixmap::new(320, 240).unwrap(); + let state = AppState::new(320, 240); + render_grid(&mut pm, &state); + } + + #[test] + fn render_grid_cell_selected_draws_crosshair() { + let mut pm = Pixmap::new(320, 240).unwrap(); + let mut state = AppState::new(320, 240); + state.phase = state.phase.select_column('a', 320, 240).unwrap(); + state.phase = state.phase.select_cell('w', 320, 240).unwrap(); + render_grid(&mut pm, &state); + // The pixmap should have non-transparent pixels from the crosshair. + assert!(pm.data().iter().any(|&b| b != 0)); + } +} diff --git a/src/wayland.rs b/src/wayland.rs new file mode 100644 index 0000000..36c6621 --- /dev/null +++ b/src/wayland.rs @@ -0,0 +1,970 @@ +//! Wayland backend: layer-shell overlay, virtual pointer, and keyboard input. +//! +//! Manages the Wayland connection, creates a full-screen layer-shell surface for +//! the overlay, and translates raw keyboard events into semantic [`KeyEvent`]s. + +use std::collections::VecDeque; +use std::io::Write; +use std::os::fd::AsFd; +use std::time::{SystemTime, UNIX_EPOCH}; + +use wayland_client::protocol::wl_pointer::{Axis, AxisSource, ButtonState}; +use wayland_client::{ + delegate_noop, + protocol::{ + wl_buffer, wl_compositor, wl_keyboard, wl_pointer, wl_region, wl_registry, wl_seat, wl_shm, + wl_shm_pool, wl_surface, + }, + Connection, Dispatch, EventQueue, QueueHandle, WEnum, +}; +use wayland_protocols_wlr::{ + layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}, + virtual_pointer::v1::client::{zwlr_virtual_pointer_manager_v1, zwlr_virtual_pointer_v1}, +}; + +use anyhow::{Context, Result}; + +// Linux evdev button codes. +const BTN_LEFT: u32 = 0x110; +const BTN_RIGHT: u32 = 0x111; + +// Linux evdev key codes for modifier keys. +const KEY_LCTRL: u32 = 29; +const KEY_LSHIFT: u32 = 42; +const KEY_LALT: u32 = 56; +const KEY_RSHIFT: u32 = 54; +const KEY_RCTRL: u32 = 97; +const KEY_RALT: u32 = 100; + +// Modifier bitmasks in the Wayland `mods_depressed` field. +const MOD_SHIFT: u32 = 1; +const MOD_CTRL: u32 = 4; +const MOD_ALT: u32 = 8; + +// Drag animation timing (~150ms total). +const DRAG_SETTLE_MS: u64 = 25; +const DRAG_PRESS_SETTLE_MS: u64 = 30; +const DRAG_INTERP_STEPS: u32 = 12; +const DRAG_STEP_DELAY_MS: u64 = 5; +const DRAG_RELEASE_SETTLE_MS: u64 = 35; + +// Maximum number of event loop iterations to wait for a key release before +// giving up. Prevents an infinite hang if the compositor drops the event. +const KEY_RELEASE_MAX_DISPATCHES: u32 = 200; + +// =========================================================================== +// Public key event type. +// =========================================================================== + +/// Semantic key event emitted by the backend and consumed by the main loop. +pub enum KeyEvent { + /// A printable character (post-shift). + Char(char), + /// Ctrl held while pressing a character key. + CtrlChar(char), + /// Alt held while pressing a character key. + AltChar(char), + /// Space — mapped to left-click. + Click, + /// Enter — mapped to double-click. + DoubleClick, + /// Shift+Enter — mapped to triple-click. + TripleClick, + /// Period — mapped to right-click. + RightClick, + /// Escape or Ctrl-[ — close the overlay. + Close, + /// Backspace — undo one selection step. + Undo, + ScrollUp, + ScrollDown, + ScrollLeft, + ScrollRight, +} + +// =========================================================================== +// Internal physical key abstraction. +// =========================================================================== + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PhysicalKey { + Char(char), + Space, + Enter, + Escape, + Backspace, + Up, + Down, + Left, + Right, +} + +/// Maps a physical key to the semantic `KeyEvent` the main loop cares about. +fn physical_to_event(key: PhysicalKey) -> Option { + match key { + PhysicalKey::Space => Some(KeyEvent::Click), + PhysicalKey::Enter => Some(KeyEvent::DoubleClick), + PhysicalKey::Escape => Some(KeyEvent::Close), + PhysicalKey::Backspace => Some(KeyEvent::Undo), + PhysicalKey::Up => Some(KeyEvent::ScrollUp), + PhysicalKey::Down => Some(KeyEvent::ScrollDown), + PhysicalKey::Left => Some(KeyEvent::ScrollLeft), + PhysicalKey::Right => Some(KeyEvent::ScrollRight), + PhysicalKey::Char('.') => Some(KeyEvent::RightClick), + PhysicalKey::Char(c) => Some(KeyEvent::Char(c)), + } +} + +// =========================================================================== +// WaylandBackend — public API for the main loop. +// =========================================================================== + +/// Owns the Wayland connection, event queue, and all protocol objects. +/// +/// Designed so that public methods can freely call `roundtrip` / +/// `blocking_dispatch` without borrowing conflicts. +pub struct WaylandBackend { + state: WaylandState, + event_queue: EventQueue, + queue_handle: QueueHandle, +} + +impl WaylandBackend { + pub fn new() -> Result { + let conn = Connection::connect_to_env().context("connect to Wayland display")?; + let mut event_queue = conn.new_event_queue(); + let queue_handle = event_queue.handle(); + + conn.display().get_registry(&queue_handle, ()); + + let mut state = WaylandState { + conn, + compositor: None, + shm: None, + surface: None, + layer_shell: None, + layer_surface: None, + seat: None, + vp_manager: None, + virtual_pointer: None, + screen_w: 0, + screen_h: 0, + configured: false, + pending_keys: VecDeque::new(), + shift_held: false, + ctrl_held: false, + alt_held: false, + awaiting_key_release: false, + }; + + event_queue + .roundtrip(&mut state) + .context("initial roundtrip")?; + + // Verify required protocol support before proceeding. + if state.compositor.is_none() { + anyhow::bail!("compositor does not advertise wl_compositor"); + } + if state.shm.is_none() { + anyhow::bail!("compositor does not advertise wl_shm"); + } + if state.layer_shell.is_none() { + anyhow::bail!( + "compositor does not support wlr-layer-shell-unstable-v1. \ + Supported compositors: Sway, Hyprland, river, etc." + ); + } + if state.vp_manager.is_none() { + anyhow::bail!( + "compositor does not support wlr-virtual-pointer-unstable-v1. \ + Supported compositors: Sway, Hyprland, river, etc." + ); + } + + // Bind the virtual pointer now that we know which globals are available. + if let Some(manager) = state.vp_manager.take() { + state.virtual_pointer = + Some(manager.create_virtual_pointer(state.seat.as_ref(), &queue_handle, ())); + } + + state.init_layer_surface(&queue_handle)?; + event_queue + .roundtrip(&mut state) + .context("roundtrip after layer surface")?; + + while !state.configured { + event_queue + .blocking_dispatch(&mut state) + .context("waiting for configure")?; + } + + Ok(Self { + state, + event_queue, + queue_handle, + }) + } + + // -- Queries ------------------------------------------------------------ + + pub fn screen_size(&self) -> (u32, u32) { + (self.state.screen_w, self.state.screen_h) + } + + // -- Event loop --------------------------------------------------------- + + /// Blocks until the next key event arrives, or returns `None` when the + /// surface has been torn down (i.e. the overlay is closed). + pub fn next_key(&mut self) -> Result> { + loop { + if let Some(key) = self.state.pending_keys.pop_front() { + return Ok(Some(key)); + } + if self.state.surface.is_none() { + return Ok(None); + } + self.event_queue + .blocking_dispatch(&mut self.state) + .context("blocking_dispatch")?; + } + } + + // -- Rendering ---------------------------------------------------------- + + /// Copy an ARGB8888 pixel buffer to the overlay surface via wl_shm. + pub fn present(&mut self, pixels: &[u8], width: u32, height: u32) -> Result<()> { + // Check availability before allocating resources. + let shm = self.state.shm.as_ref().context("wl_shm not available")?; + let surface = self + .state + .surface + .as_ref() + .context("wl_surface not available")?; + + let stride = width * 4; + let mut file = tempfile::tempfile().context("create shm tempfile")?; + file.write_all(pixels).context("write pixel buffer")?; + + let pool = shm.create_pool(file.as_fd(), pixels.len() as i32, &self.queue_handle, ()); + let buffer = pool.create_buffer( + 0, + width as i32, + height as i32, + stride as i32, + wl_shm::Format::Argb8888, + &self.queue_handle, + (), + ); + + surface.attach(Some(&buffer), 0, 0); + surface.damage_buffer(0, 0, width as i32, height as i32); + surface.commit(); + pool.destroy(); + Ok(()) + } + + // -- Mouse actions ------------------------------------------------------ + + /// Warp the virtual pointer to absolute screen coordinates. + pub fn move_mouse(&mut self, x: u32, y: u32) -> Result<()> { + if let Some(vp) = &self.state.virtual_pointer { + vp.motion_absolute(timestamp(), x, y, self.state.screen_w, self.state.screen_h); + vp.frame(); + } + self.state.conn.flush().context("flush after move_mouse")?; + Ok(()) + } + + /// Tear down the overlay, move to `(x, y)`, and left-click once. + pub fn click(&mut self, x: u32, y: u32) -> Result<()> { + self.click_at(x, y, BTN_LEFT, 1) + } + + /// Tear down the overlay, move to `(x, y)`, and left-click twice. + pub fn double_click(&mut self, x: u32, y: u32) -> Result<()> { + self.click_at(x, y, BTN_LEFT, 2) + } + + /// Tear down the overlay, move to `(x, y)`, and left-click three times. + pub fn triple_click(&mut self, x: u32, y: u32) -> Result<()> { + self.click_at(x, y, BTN_LEFT, 3) + } + + /// Tear down the overlay, move to `(x, y)`, and right-click once. + pub fn right_click(&mut self, x: u32, y: u32) -> Result<()> { + self.click_at(x, y, BTN_RIGHT, 1) + } + + /// Tear down the overlay, then perform a click-drag from `(x1, y1)` to + /// `(x2, y2)`. + /// + /// Simulates a realistic drag by sending intermediate motion events + /// over time so that compositors and applications recognize the gesture. + pub fn drag_select(&mut self, x1: u32, y1: u32, x2: u32, y2: u32) -> Result<()> { + use std::thread::sleep; + use std::time::Duration; + + self.teardown_surface()?; + let (sw, sh) = (self.state.screen_w, self.state.screen_h); + + // 1. Move to the drag origin and let focus settle. + self.send_motion(x1, y1, sw, sh)?; + self.roundtrip("motion to drag start")?; + sleep(Duration::from_millis(DRAG_SETTLE_MS)); + + // 2. Press and hold — give the app time to register the press. + self.send_button(BTN_LEFT, ButtonState::Pressed)?; + self.roundtrip("press at drag start")?; + sleep(Duration::from_millis(DRAG_PRESS_SETTLE_MS)); + + // 3. Animate the pointer from start to end so apps see continuous + // motion and cross their drag threshold. + let step_delay = Duration::from_millis(DRAG_STEP_DELAY_MS); + for i in 1..=DRAG_INTERP_STEPS { + let t = i as f64 / DRAG_INTERP_STEPS as f64; + let x = x1 as f64 + (x2 as f64 - x1 as f64) * t; + let y = y1 as f64 + (y2 as f64 - y1 as f64) * t; + let cx = (x.clamp(0.0, sw.saturating_sub(1) as f64)) as u32; + let cy = (y.clamp(0.0, sh.saturating_sub(1) as f64)) as u32; + self.send_motion(cx, cy, sw, sh)?; + self.state + .conn + .flush() + .context("flush during drag motion")?; + sleep(step_delay); + } + self.roundtrip("motion to drag end")?; + sleep(Duration::from_millis(DRAG_RELEASE_SETTLE_MS)); + + // 4. Release the button at the destination. + self.send_button(BTN_LEFT, ButtonState::Released)?; + self.roundtrip("release at drag end") + } + + // -- Scroll ------------------------------------------------------------- + + pub fn scroll_up(&mut self) -> Result<()> { + self.scroll(Axis::VerticalScroll, -15.0, -1) + } + + pub fn scroll_down(&mut self) -> Result<()> { + self.scroll(Axis::VerticalScroll, 15.0, 1) + } + + pub fn scroll_left(&mut self) -> Result<()> { + self.scroll(Axis::HorizontalScroll, -15.0, -1) + } + + pub fn scroll_right(&mut self) -> Result<()> { + self.scroll(Axis::HorizontalScroll, 15.0, 1) + } + + // -- Lifecycle ---------------------------------------------------------- + + /// Block until the key that triggered the current action is released. + /// This prevents the key-up event from leaking to the underlying window + /// after the overlay is torn down. + pub fn wait_for_key_release(&mut self) -> Result<()> { + self.state.awaiting_key_release = true; + let mut dispatches = 0u32; + while self.state.awaiting_key_release { + self.event_queue + .blocking_dispatch(&mut self.state) + .context("waiting for key release")?; + dispatches += 1; + if dispatches >= KEY_RELEASE_MAX_DISPATCHES { + self.state.awaiting_key_release = false; + break; + } + } + Ok(()) + } + + /// Destroy the virtual pointer and tear down the overlay surface. + /// + /// Does NOT send safety button releases — the compositor releases all + /// buttons when the virtual pointer object is destroyed. Sending + /// redundant releases here caused spurious extra clicks (e.g. double-click + /// turning into triple-click). + pub fn exit(&mut self) -> Result<()> { + if let Some(vp) = self.state.virtual_pointer.take() { + vp.destroy(); + } + self.state.conn.flush().context("flush vp destroy")?; + + self.teardown_surface() + } + + /// Re-create the overlay after it was torn down (e.g. for scroll). + pub fn reopen(&mut self) -> Result<()> { + self.state.configured = false; + self.state.init_layer_surface(&self.queue_handle)?; + self.roundtrip("reopen")?; + while !self.state.configured { + self.event_queue + .blocking_dispatch(&mut self.state) + .context("waiting for configure after reopen")?; + } + Ok(()) + } + + // -- Private helpers ---------------------------------------------------- + + /// Shared implementation for click, double_click, and right_click. + /// + /// Tears down the overlay so the compositor updates focus, moves the + /// virtual pointer to `(x, y)`, then sends `count` press/release pairs + /// on `button`. + fn click_at(&mut self, x: u32, y: u32, button: u32, count: u32) -> Result<()> { + self.teardown_surface()?; + + self.send_motion(x, y, self.state.screen_w, self.state.screen_h)?; + self.roundtrip("motion before click")?; + + if let Some(vp) = &self.state.virtual_pointer { + for _ in 0..count { + let ts = timestamp(); + vp.button(ts, button, ButtonState::Pressed); + vp.frame(); + vp.button(ts, button, ButtonState::Released); + vp.frame(); + } + } + self.roundtrip("click") + } + + fn scroll(&mut self, axis: Axis, value: f64, discrete: i32) -> Result<()> { + self.teardown_surface()?; + + if let Some(vp) = &self.state.virtual_pointer { + // axis_source tells the compositor this is a wheel event, not a + // touchpad gesture. axis_discrete provides the notch count that + // many compositors require alongside the continuous value. + vp.axis_source(AxisSource::Wheel); + vp.axis_discrete(timestamp(), axis, value, discrete); + vp.frame(); + } + self.state.conn.flush().context("flush after scroll")?; + + self.reopen() + } + + fn teardown_surface(&mut self) -> Result<()> { + if let Some(layer_surface) = self.state.layer_surface.take() { + layer_surface.destroy(); + } + if let Some(surface) = self.state.surface.take() { + surface.destroy(); + } + self.roundtrip("surface teardown") + } + + /// Convenience wrapper around `event_queue.roundtrip` with context. + fn roundtrip(&mut self, context: &str) -> Result<()> { + self.event_queue + .roundtrip(&mut self.state) + .with_context(|| format!("roundtrip ({context})"))?; + Ok(()) + } + + fn send_motion(&self, x: u32, y: u32, sw: u32, sh: u32) -> Result<()> { + if let Some(vp) = &self.state.virtual_pointer { + vp.motion_absolute(timestamp(), x, y, sw, sh); + vp.frame(); + } + Ok(()) + } + + fn send_button(&self, button: u32, button_state: ButtonState) -> Result<()> { + if let Some(vp) = &self.state.virtual_pointer { + vp.button(timestamp(), button, button_state); + vp.frame(); + } + Ok(()) + } +} + +impl Drop for WaylandBackend { + fn drop(&mut self) { + let _ = self.exit(); + } +} + +// =========================================================================== +// Internal Wayland dispatch state. +// =========================================================================== + +struct WaylandState { + conn: Connection, + + // Globals bound during registry enumeration. + compositor: Option, + shm: Option, + seat: Option, + layer_shell: Option, + vp_manager: Option, + + // Per-session objects. + surface: Option, + layer_surface: Option, + virtual_pointer: Option, + + // Screen geometry reported by the layer-surface configure. + screen_w: u32, + screen_h: u32, + configured: bool, + + // Keyboard state. + pending_keys: VecDeque, + shift_held: bool, + ctrl_held: bool, + alt_held: bool, + awaiting_key_release: bool, +} + +impl WaylandState { + /// Create and configure a full-screen layer-shell overlay surface. + fn init_layer_surface(&mut self, qh: &QueueHandle) -> Result<()> { + let compositor = self + .compositor + .as_ref() + .context("wl_compositor not available")?; + let layer_shell = self + .layer_shell + .as_ref() + .context("zwlr_layer_shell_v1 not available")?; + + let surface = compositor.create_surface(qh, ()); + let layer_surface = layer_shell.get_layer_surface( + &surface, + None, + zwlr_layer_shell_v1::Layer::Overlay, + "mousefree".to_string(), + qh, + (), + ); + + layer_surface.set_size(0, 0); + layer_surface.set_anchor( + zwlr_layer_surface_v1::Anchor::Top + | zwlr_layer_surface_v1::Anchor::Bottom + | zwlr_layer_surface_v1::Anchor::Left + | zwlr_layer_surface_v1::Anchor::Right, + ); + layer_surface.set_exclusive_zone(-1); + layer_surface + .set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::Exclusive); + + surface.commit(); + + self.surface = Some(surface); + self.layer_surface = Some(layer_surface); + Ok(()) + } +} + +// =========================================================================== +// Wayland dispatch implementations. +// =========================================================================== + +impl Dispatch for WaylandState { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "wl_compositor" => { + state.compositor = Some(registry.bind(name, version.min(4), qh, ())); + } + "wl_shm" => { + state.shm = Some(registry.bind(name, 1, qh, ())); + } + "wl_seat" => { + state.seat = Some(registry.bind(name, version.min(7), qh, ())); + } + "zwlr_layer_shell_v1" => { + state.layer_shell = Some(registry.bind(name, version.min(4), qh, ())); + } + "zwlr_virtual_pointer_manager_v1" => { + state.vp_manager = Some(registry.bind(name, version.min(2), qh, ())); + } + _ => {} + } + } +} + +impl Dispatch for WaylandState { + fn event( + _state: &mut Self, + seat: &wl_seat::WlSeat, + event: wl_seat::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_seat::Event::Capabilities { + capabilities: WEnum::Value(caps), + } = event + { + if caps.contains(wl_seat::Capability::Keyboard) { + seat.get_keyboard(qh, ()); + } + } + } +} + +impl Dispatch for WaylandState { + fn event( + state: &mut Self, + _: &wl_keyboard::WlKeyboard, + event: wl_keyboard::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + wl_keyboard::Event::Modifiers { mods_depressed, .. } => { + state.shift_held = (mods_depressed & MOD_SHIFT) != 0; + state.ctrl_held = (mods_depressed & MOD_CTRL) != 0; + state.alt_held = (mods_depressed & MOD_ALT) != 0; + } + wl_keyboard::Event::Key { + key, + state: WEnum::Value(wl_keyboard::KeyState::Pressed), + .. + } => { + // Ignore bare modifier press events; we track modifiers via + // the Modifiers event instead. + if matches!(key, KEY_LSHIFT | KEY_RSHIFT | KEY_LCTRL | KEY_RCTRL | KEY_LALT | KEY_RALT) { + return; + } + // Ctrl-[ acts as Escape (evdev keycode 26 = '['). + if key == 26 && state.ctrl_held { + state.pending_keys.push_back(KeyEvent::Close); + return; + } + if let Some(physical) = keycode_to_key(key, state.shift_held) { + // Shift+Enter → triple-click. + if physical == PhysicalKey::Enter && state.shift_held { + state.pending_keys.push_back(KeyEvent::TripleClick); + return; + } + // Modifier+letter emits CtrlChar/AltChar so the main loop + // can distinguish nudge tiers. + if let PhysicalKey::Char(c) = physical { + if state.ctrl_held { + state.pending_keys.push_back(KeyEvent::CtrlChar(c)); + return; + } + if state.alt_held { + state.pending_keys.push_back(KeyEvent::AltChar(c)); + return; + } + } + if let Some(ev) = physical_to_event(physical) { + state.pending_keys.push_back(ev); + } + } + } + wl_keyboard::Event::Key { + state: WEnum::Value(wl_keyboard::KeyState::Released), + .. + } => { + state.awaiting_key_release = false; + } + _ => {} + } + } +} + +impl Dispatch for WaylandState { + fn event( + state: &mut Self, + layer_surface: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + event: zwlr_layer_surface_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwlr_layer_surface_v1::Event::Configure { + serial, + width, + height, + } => { + layer_surface.ack_configure(serial); + const MAX_SCREEN_DIM: u32 = 16384; + if width > 0 && height > 0 && width <= MAX_SCREEN_DIM && height <= MAX_SCREEN_DIM { + state.screen_w = width; + state.screen_h = height; + state.configured = true; + } + } + zwlr_layer_surface_v1::Event::Closed => { + state.pending_keys.push_back(KeyEvent::Close); + } + _ => {} + } + } +} + +delegate_noop!(WaylandState: ignore wl_compositor::WlCompositor); +delegate_noop!(WaylandState: ignore wl_region::WlRegion); +delegate_noop!(WaylandState: ignore wl_surface::WlSurface); +delegate_noop!(WaylandState: ignore wl_shm::WlShm); +delegate_noop!(WaylandState: ignore wl_shm_pool::WlShmPool); +delegate_noop!(WaylandState: ignore wl_buffer::WlBuffer); +delegate_noop!(WaylandState: ignore wl_pointer::WlPointer); +delegate_noop!(WaylandState: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1); +delegate_noop!(WaylandState: ignore zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1); +delegate_noop!(WaylandState: ignore zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1); + +// =========================================================================== +// Helpers. +// =========================================================================== + +/// Returns a u32 millisecond timestamp for Wayland pointer events. +/// +/// Wayland timestamps are u32 milliseconds from an arbitrary epoch. +/// Wrapping every ~49 days is expected and harmless per the protocol. +fn timestamp() -> u32 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u32 +} + +/// Translates a Linux evdev key code into a [`PhysicalKey`]. +/// +/// **Note:** This table assumes a US QWERTY layout. Other layouts will +/// produce incorrect characters. A proper fix requires xkb integration. +fn keycode_to_key(kc: u32, shift_held: bool) -> Option { + // Non-character keys — independent of shift state. + match kc { + 1 => return Some(PhysicalKey::Escape), + 14 => return Some(PhysicalKey::Backspace), + 28 => return Some(PhysicalKey::Enter), + 57 => return Some(PhysicalKey::Space), + 104 => return Some(PhysicalKey::Up), + 105 => return Some(PhysicalKey::Left), + 106 => return Some(PhysicalKey::Right), + 109 => return Some(PhysicalKey::Down), + _ => {} + } + + // Character keys — shift selects the alternate mapping. + let ch = if shift_held { + match kc { + 2 => '!', + 3 => '@', + 4 => '#', + 5 => '$', + 6 => '%', + 7 => '^', + 8 => '&', + 9 => '*', + 10 => '(', + 11 => ')', + 12 => '_', + 13 => '+', + 26 => '{', + 27 => '}', + 43 => '|', + 39 => ':', + 40 => '"', + 41 => '~', + 51 => '<', + 52 => '>', + 53 => '?', + 16 => 'Q', + 17 => 'W', + 18 => 'E', + 19 => 'R', + 20 => 'T', + 21 => 'Y', + 22 => 'U', + 23 => 'I', + 24 => 'O', + 25 => 'P', + 30 => 'A', + 31 => 'S', + 32 => 'D', + 33 => 'F', + 34 => 'G', + 35 => 'H', + 36 => 'J', + 37 => 'K', + 38 => 'L', + 44 => 'Z', + 45 => 'X', + 46 => 'C', + 47 => 'V', + 48 => 'B', + 49 => 'N', + 50 => 'M', + _ => return None, + } + } else { + match kc { + 2 => '1', + 3 => '2', + 4 => '3', + 5 => '4', + 6 => '5', + 7 => '6', + 8 => '7', + 9 => '8', + 10 => '9', + 11 => '0', + 12 => '-', + 13 => '=', + 26 => '[', + 27 => ']', + 43 => '\\', + 39 => ';', + 40 => '\'', + 41 => '`', + 51 => ',', + 52 => '.', + 53 => '/', + 16 => 'q', + 17 => 'w', + 18 => 'e', + 19 => 'r', + 20 => 't', + 21 => 'y', + 22 => 'u', + 23 => 'i', + 24 => 'o', + 25 => 'p', + 30 => 'a', + 31 => 's', + 32 => 'd', + 33 => 'f', + 34 => 'g', + 35 => 'h', + 36 => 'j', + 37 => 'k', + 38 => 'l', + 44 => 'z', + 45 => 'x', + 46 => 'c', + 47 => 'v', + 48 => 'b', + 49 => 'n', + 50 => 'm', + _ => return None, + } + }; + Some(PhysicalKey::Char(ch)) +} + +// =========================================================================== +// Tests. +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keycode_letters_unshifted() { + assert_eq!(keycode_to_key(16, false), Some(PhysicalKey::Char('q'))); + assert_eq!(keycode_to_key(30, false), Some(PhysicalKey::Char('a'))); + assert_eq!(keycode_to_key(44, false), Some(PhysicalKey::Char('z'))); + assert_eq!(keycode_to_key(50, false), Some(PhysicalKey::Char('m'))); + } + + #[test] + fn keycode_letters_shifted() { + assert_eq!(keycode_to_key(16, true), Some(PhysicalKey::Char('Q'))); + assert_eq!(keycode_to_key(30, true), Some(PhysicalKey::Char('A'))); + assert_eq!(keycode_to_key(50, true), Some(PhysicalKey::Char('M'))); + } + + #[test] + fn keycode_digits() { + assert_eq!(keycode_to_key(2, false), Some(PhysicalKey::Char('1'))); + assert_eq!(keycode_to_key(11, false), Some(PhysicalKey::Char('0'))); + assert_eq!(keycode_to_key(2, true), Some(PhysicalKey::Char('!'))); + assert_eq!(keycode_to_key(11, true), Some(PhysicalKey::Char(')'))); + } + + #[test] + fn keycode_special_keys() { + assert_eq!(keycode_to_key(1, false), Some(PhysicalKey::Escape)); + assert_eq!(keycode_to_key(14, false), Some(PhysicalKey::Backspace)); + assert_eq!(keycode_to_key(28, false), Some(PhysicalKey::Enter)); + assert_eq!(keycode_to_key(57, false), Some(PhysicalKey::Space)); + // Delete (111) is intentionally unmapped — returns None so the key is + // consumed by the overlay without triggering any action. + assert_eq!(keycode_to_key(111, false), None); + } + + #[test] + fn keycode_special_keys_ignore_shift() { + assert_eq!(keycode_to_key(1, true), Some(PhysicalKey::Escape)); + assert_eq!(keycode_to_key(57, true), Some(PhysicalKey::Space)); + } + + #[test] + fn keycode_unknown_returns_none() { + assert_eq!(keycode_to_key(999, false), None); + assert_eq!(keycode_to_key(999, true), None); + } + + #[test] + fn keycode_punctuation() { + assert_eq!(keycode_to_key(52, false), Some(PhysicalKey::Char('.'))); + assert_eq!(keycode_to_key(53, false), Some(PhysicalKey::Char('/'))); + assert_eq!(keycode_to_key(52, true), Some(PhysicalKey::Char('>'))); + assert_eq!(keycode_to_key(53, true), Some(PhysicalKey::Char('?'))); + } + + #[test] + fn physical_to_event_mappings() { + assert!(matches!( + physical_to_event(PhysicalKey::Space), + Some(KeyEvent::Click) + )); + assert!(matches!( + physical_to_event(PhysicalKey::Enter), + Some(KeyEvent::DoubleClick) + )); + assert!(matches!( + physical_to_event(PhysicalKey::Escape), + Some(KeyEvent::Close) + )); + assert!(matches!( + physical_to_event(PhysicalKey::Backspace), + Some(KeyEvent::Undo) + )); + assert!(matches!( + physical_to_event(PhysicalKey::Char('.')), + Some(KeyEvent::RightClick) + )); + assert!(matches!( + physical_to_event(PhysicalKey::Char('a')), + Some(KeyEvent::Char('a')) + )); + // All PhysicalKey variants now map to a KeyEvent (no dead paths). + } + + #[test] + fn timestamp_does_not_panic() { + // Mainly confirms unwrap_or_default works even if the clock is weird. + let _ = timestamp(); + } +}