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:
12 files changed
+3307
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
/target
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user