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) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-04-03 08:36:22 -06:00
commit e22d8251ed
12 files changed
+3307

No files matched your search

+48
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
/target
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
+21
View File
@@ -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
Generated
+597
View File
@@ -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"
+21
View File
@@ -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 <steve@waits.net>"]
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"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stephen Waits <steve@waits.net>
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.
+80
View File
@@ -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)
+36
View File
@@ -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<fontdue::Font> = 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<fontdue::Font> {
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()
}
+399
View File
@@ -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<char> {
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<Self> {
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<Self> {
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<Self> {
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");
}
}
}
}
+267
View File
@@ -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<KeyAction> {
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<KeyAction> {
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<KeyAction> {
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<KeyAction> {
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<KeyAction> {
// 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)
}
+840
View File
@@ -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<Span> {
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<Span>) {
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<Span>) {
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<tiny_skia::Path> {
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));
}
}
+970
View File
@@ -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<KeyEvent> {
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<WaylandState>,
queue_handle: QueueHandle<WaylandState>,
}
impl WaylandBackend {
pub fn new() -> Result<Self> {
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<Option<KeyEvent>> {
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<wl_compositor::WlCompositor>,
shm: Option<wl_shm::WlShm>,
seat: Option<wl_seat::WlSeat>,
layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
vp_manager: Option<zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1>,
// Per-session objects.
surface: Option<wl_surface::WlSurface>,
layer_surface: Option<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1>,
virtual_pointer: Option<zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1>,
// Screen geometry reported by the layer-surface configure.
screen_w: u32,
screen_h: u32,
configured: bool,
// Keyboard state.
pending_keys: VecDeque<KeyEvent>,
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<Self>) -> 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<wl_registry::WlRegistry, ()> for WaylandState {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<wl_seat::WlSeat, ()> for WaylandState {
fn event(
_state: &mut Self,
seat: &wl_seat::WlSeat,
event: wl_seat::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities {
capabilities: WEnum::Value(caps),
} = event
{
if caps.contains(wl_seat::Capability::Keyboard) {
seat.get_keyboard(qh, ());
}
}
}
}
impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandState {
fn event(
state: &mut Self,
_: &wl_keyboard::WlKeyboard,
event: wl_keyboard::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
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<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for WaylandState {
fn event(
state: &mut Self,
layer_surface: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
event: zwlr_layer_surface_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
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<PhysicalKey> {
// 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();
}
}