commit 81d6237e2f48d0e108310a22f7918bb61bfe0892 Author: Stephen Waits Date: Tue Apr 7 10:39:22 2026 -0600 feat: initial project with CPU-rendered ring light overlay Wayland layer-shell overlay using tiny-skia for rendering a glowing rounded rectangle ring. Includes PID-based lock file toggle via SIGUSR1, keyboard capture (Escape to exit), and pointer tracking for cursor avoidance. Configurable ring geometry and glow parameters. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1adf765 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,697 @@ +# 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 = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[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.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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 = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ringa" +version = "0.1.0" +dependencies = [ + "anyhow", + "nix", + "tempfile", + "tiny-skia", + "wayland-client", + "wayland-protocols-wlr", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1761b96 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ringa" +version = "0.1.0" +edition = "2021" +description = "Wayland ring light overlay" +authors = ["Stephen Waits "] +rust-version = "1.85" + +[dependencies] +anyhow = "1.0" +nix = { version = "0.29", features = ["signal", "process", "poll"] } +tempfile = "3" +tiny-skia = "0.12.0" +wayland-client = "0.31" +wayland-protocols-wlr = { version = "0.3", features = ["client"] } diff --git a/src/lock.rs b/src/lock.rs new file mode 100644 index 0000000..52ecf65 --- /dev/null +++ b/src/lock.rs @@ -0,0 +1,105 @@ +//! PID-based lock file for toggle-on/toggle-off behavior. +//! +//! The first invocation creates a lock file and runs the overlay. +//! A second invocation sees the lock, sends SIGUSR1 to the running +//! instance, and exits immediately. + +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; + +use anyhow::{Context, Result}; +use nix::sys::signal::{self, SaFlags, SigAction, SigHandler, SigSet, Signal}; +use nix::unistd::Pid; + +static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); + +extern "C" fn handle_sigusr1(_: nix::libc::c_int) { + SHOULD_EXIT.store(true, Ordering::Relaxed); +} + +pub enum ToggleResult { + RunNew, + SignaledExisting, +} + +/// Check if another instance is running and signal it, or indicate we should run. +pub fn try_toggle() -> Result { + let path = lock_file_path(); + + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ToggleResult::RunNew), + Err(e) => return Err(e).context("read lock file"), + }; + + let pid: i32 = contents + .trim() + .parse() + .context("parse PID from lock file")?; + let pid = Pid::from_raw(pid); + + // Check if the process is alive. + match signal::kill(pid, None) { + Ok(()) => {} + Err(nix::errno::Errno::ESRCH) => { + // Stale lock — process is gone. + let _ = fs::remove_file(&path); + return Ok(ToggleResult::RunNew); + } + Err(e) => return Err(e).context("check if PID is alive"), + } + + // Process is alive — send SIGUSR1 to tell it to exit. + match signal::kill(pid, Signal::SIGUSR1) { + Ok(()) => Ok(ToggleResult::SignaledExisting), + Err(nix::errno::Errno::ESRCH) => { + // Raced — process died between check and signal. + let _ = fs::remove_file(&path); + Ok(ToggleResult::RunNew) + } + Err(e) => Err(e).context("send SIGUSR1"), + } +} + +/// Returns true if SIGUSR1 has been received. +pub fn should_exit() -> bool { + SHOULD_EXIT.load(Ordering::Relaxed) +} + +/// Create the lock file and install the SIGUSR1 handler. Returns a guard +/// that removes the lock file on drop. +pub fn create_lock() -> Result { + let path = lock_file_path(); + + // Install SIGUSR1 handler before writing the lock file. + let action = SigAction::new( + SigHandler::Handler(handle_sigusr1), + SaFlags::SA_RESTART, + SigSet::empty(), + ); + unsafe { signal::sigaction(Signal::SIGUSR1, &action) }.context("install SIGUSR1 handler")?; + + let pid = std::process::id(); + fs::write(&path, pid.to_string()).context("write lock file")?; + + Ok(LockGuard { path }) +} + +pub struct LockGuard { + path: PathBuf, +} + +impl Drop for LockGuard { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn lock_file_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + PathBuf::from(dir).join("ringa.lock") + } else { + PathBuf::from("/tmp/ringa.lock") + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..19ab4c6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,74 @@ +mod lock; +mod render; +mod wayland; + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use tiny_skia::Pixmap; + +use lock::ToggleResult; +use wayland::{OverlayEvent, WaylandBackend}; + +const DISPLAY_DURATION: Duration = Duration::from_secs(5); + +fn main() -> Result<()> { + match lock::try_toggle()? { + ToggleResult::SignaledExisting => return Ok(()), + ToggleResult::RunNew => {} + } + + let _lock = lock::create_lock()?; + + let mut wl = WaylandBackend::new()?; + let (w, h) = wl.screen_size(); + let mut pixmap = Pixmap::new(w, h).context("create pixmap")?; + let mut argb_buf = vec![0u8; (w as usize) * (h as usize) * 4]; + + let mut cursor_x = w as f64 / 2.0; + let mut cursor_y = h as f64 / 2.0; + + draw(&mut wl, &mut pixmap, &mut argb_buf, cursor_x, cursor_y)?; + + let start = Instant::now(); + + loop { + let elapsed = start.elapsed(); + if elapsed >= DISPLAY_DURATION || lock::should_exit() { + break; + } + let remaining = DISPLAY_DURATION - elapsed; + let event = match wl.next_event(remaining)? { + Some(ev) => ev, + None => continue, // poll timeout — loop back to check elapsed time + }; + match event { + OverlayEvent::Close => break, + OverlayEvent::CursorMoved(x, y) => { + cursor_x = x; + cursor_y = y; + draw(&mut wl, &mut pixmap, &mut argb_buf, cursor_x, cursor_y)?; + } + OverlayEvent::Configure(new_w, new_h) => { + if new_w != w || new_h != h { + break; + } + } + } + } + + Ok(()) +} + +fn draw( + wl: &mut WaylandBackend, + pixmap: &mut Pixmap, + argb_buf: &mut [u8], + cursor_x: f64, + cursor_y: f64, +) -> Result<()> { + render::render_ring_light(pixmap, cursor_x, cursor_y); + render::pixmap_to_argb8888(pixmap, argb_buf); + let (w, h) = wl.screen_size(); + wl.present(argb_buf, w, h) +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..251168b --- /dev/null +++ b/src/render.rs @@ -0,0 +1,183 @@ +//! Ring light glow rendering. +//! +//! The ring is a thick stroke centered on a single rounded rectangle SDF. +//! This guarantees uniform width everywhere, including at corners. +//! Glow bleeds outward and inward from the stroke edges. + +use tiny_skia::Pixmap; + +// The rounded rect centerline is inset this far from the screen edge. +// The visible ring extends RING_HALF_WIDTH on either side of this line. +const CENTERLINE_INSET: f32 = 80.0; +// Half the stroke width. Total ring thickness = 2 * RING_HALF_WIDTH. +const RING_HALF_WIDTH: f32 = 30.0; +// Corner radius of the centerline rounded rect. +const CORNER_RADIUS: f32 = 30.0; + +// Soft glow spread outward and inward from the ring edges. +const GLOW_SPREAD: f32 = 50.0; +const RING_ALPHA: f32 = 1.00; + +// Cursor avoidance. +const CURSOR_AVOID_RADIUS: f32 = 120.0; +const CURSOR_AVOID_FALLOFF: f32 = 60.0; + +/// Render the ring light glow into `pixmap`, avoiding the cursor at `(cx, cy)`. +pub fn render_ring_light(pixmap: &mut Pixmap, cx: f64, cy: f64) { + let w = pixmap.width() as f32; + let h = pixmap.height() as f32; + let cx = cx as f32; + let cy = cy as f32; + let wi = pixmap.width() as usize; + let hi = pixmap.height() as usize; + + let data = pixmap.data_mut(); + data.fill(0); + + let screen_cx = w / 2.0; + let screen_cy = h / 2.0; + + // Single rounded rect centerline. + let hw = w / 2.0 - CENTERLINE_INSET; + let hh = h / 2.0 - CENTERLINE_INSET; + + // The affected band: from (CENTERLINE_INSET - RING_HALF_WIDTH - GLOW_SPREAD) + // to (CENTERLINE_INSET + RING_HALF_WIDTH + GLOW_SPREAD) from each screen edge. + let band_start = (CENTERLINE_INSET - RING_HALF_WIDTH - GLOW_SPREAD) + .max(0.0) + .floor() as usize; + let band_end = (CENTERLINE_INSET + RING_HALF_WIDTH + GLOW_SPREAD).ceil() as usize + 1; + + // Top band + for py in band_start..band_end.min(hi) { + for px in 0..wi { + let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy); + write_pixel(data, py * wi + px, a); + } + } + + // Bottom band + let bottom_start = hi.saturating_sub(band_end); + let bottom_end = hi.saturating_sub(band_start); + for py in bottom_start..bottom_end.min(hi) { + if py < band_end { + continue; + } + for px in 0..wi { + let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy); + write_pixel(data, py * wi + px, a); + } + } + + // Left band (between top and bottom) + let vert_start = band_end.min(hi); + let vert_end = hi.saturating_sub(band_end); + for py in vert_start..vert_end { + for px in band_start..band_end.min(wi) { + let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy); + write_pixel(data, py * wi + px, a); + } + } + + // Right band (between top and bottom) + let right_start = wi.saturating_sub(band_end); + let right_end = wi.saturating_sub(band_start); + for py in vert_start..vert_end { + for px in right_start..right_end.min(wi) { + if px < band_end { + continue; + } + let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy); + write_pixel(data, py * wi + px, a); + } + } +} + +/// 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()); + 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 helpers -------------------------------------------------------- + +/// Compute glow alpha for a single pixel using a single rounded rect SDF. +/// +/// The ring is all pixels where |sdf| <= RING_HALF_WIDTH (a thick stroke). +/// Glow fades from the ring edges outward/inward over GLOW_SPREAD pixels. +#[allow(clippy::too_many_arguments)] +fn pixel_alpha( + px: f32, + py: f32, + scx: f32, + scy: f32, + hw: f32, + hh: f32, + cursor_x: f32, + cursor_y: f32, +) -> u8 { + // Signed distance to the rounded rect centerline. + let sd = rounded_rect_sdf(px, py, scx, scy, hw, hh, CORNER_RADIUS); + let abs_sd = sd.abs(); + + let glow = if abs_sd <= RING_HALF_WIDTH { + // Inside the ring stroke — full brightness. + RING_ALPHA + } else { + // Distance from the nearest ring edge. + let dist = abs_sd - RING_HALF_WIDTH; + if dist >= GLOW_SPREAD { + return 0; + } + let t = dist / GLOW_SPREAD; + smoothstep(1.0 - t) * RING_ALPHA + }; + + // Cursor avoidance. + let dx = px - cursor_x; + let dy = py - cursor_y; + let cursor_dist = (dx * dx + dy * dy).sqrt(); + let cursor_factor = + smoothstep(((cursor_dist - CURSOR_AVOID_RADIUS) / CURSOR_AVOID_FALLOFF).clamp(0.0, 1.0)); + + let alpha = glow * cursor_factor; + (alpha * 255.0) as u8 +} + +/// Signed distance field for a rounded rectangle. +/// +/// Returns negative inside, positive outside, zero on the boundary. +fn rounded_rect_sdf(px: f32, py: f32, cx: f32, cy: f32, hw: f32, hh: f32, r: f32) -> f32 { + let dx = (px - cx).abs() - (hw - r); + let dy = (py - cy).abs() - (hh - r); + let outside = dx.max(0.0); + let outside_y = dy.max(0.0); + let outside_len = (outside * outside + outside_y * outside_y).sqrt(); + let inside = dx.max(dy).min(0.0); + outside_len + inside - r +} + +/// Write a premultiplied white pixel with the given alpha. +fn write_pixel(data: &mut [u8], idx: usize, alpha: u8) { + if alpha == 0 { + return; + } + let offset = idx * 4; + data[offset] = alpha; + data[offset + 1] = alpha; + data[offset + 2] = alpha; + data[offset + 3] = alpha; +} + +/// Hermite smoothstep: 3t^2 - 2t^3 +fn smoothstep(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} diff --git a/src/wayland.rs b/src/wayland.rs new file mode 100644 index 0000000..b697a41 --- /dev/null +++ b/src/wayland.rs @@ -0,0 +1,409 @@ +//! Wayland backend: layer-shell overlay with keyboard and pointer tracking. +//! +//! Creates a full-screen layer-shell surface for the ring light overlay. +//! Tracks pointer position for cursor avoidance and captures keyboard +//! for Escape to exit. + +use std::collections::VecDeque; +use std::io::Write; +use std::os::fd::AsFd; +use std::time::Duration; + +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}; + +use anyhow::{Context, Result}; + +// Linux evdev key code for Escape. +const KEY_ESCAPE: u32 = 1; + +/// Events emitted by the Wayland backend to the main loop. +pub enum OverlayEvent { + /// Escape pressed or surface closed by compositor. + Close, + /// Pointer moved to (x, y) in surface-local coordinates. + CursorMoved(f64, f64), + /// Surface (re-)configured with new dimensions. + Configure(u32, u32), +} + +// =========================================================================== +// WaylandBackend — public API. +// =========================================================================== + +pub struct WaylandBackend { + state: WaylandState, + event_queue: EventQueue, + queue_handle: QueueHandle, +} + +impl WaylandBackend { + pub fn new() -> Result { + let conn = Connection::connect_to_env().context("connect to Wayland display")?; + let mut event_queue = conn.new_event_queue(); + let queue_handle = event_queue.handle(); + + conn.display().get_registry(&queue_handle, ()); + + let mut state = WaylandState { + conn, + compositor: None, + shm: None, + surface: None, + layer_shell: None, + layer_surface: None, + seat: None, + screen_w: 0, + screen_h: 0, + configured: false, + pending_events: VecDeque::new(), + }; + + event_queue + .roundtrip(&mut state) + .context("initial roundtrip")?; + + 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." + ); + } + + 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, + }) + } + + pub fn screen_size(&self) -> (u32, u32) { + (self.state.screen_w, self.state.screen_h) + } + + /// Wait up to `timeout` for the next event. Returns `None` on timeout + /// or if the surface has been torn down. + pub fn next_event(&mut self, timeout: Duration) -> Result> { + // First drain any already-buffered events. + if let Some(ev) = self.state.pending_events.pop_front() { + return Ok(Some(ev)); + } + if self.state.surface.is_none() { + return Ok(None); + } + + // Flush outgoing requests, then poll the fd with a timeout. + self.event_queue + .flush() + .context("flush before poll")?; + + let read_guard = self.event_queue.prepare_read().unwrap(); + let fd = read_guard.connection_fd(); + let mut pollfd = [nix::poll::PollFd::new(fd, nix::poll::PollFlags::POLLIN)]; + let millis = timeout.as_millis().min(u16::MAX as u128) as u16; + let n = nix::poll::poll(&mut pollfd, millis).context("poll")?; + + if n > 0 { + // Data available — read and dispatch. + let _ = read_guard.read(); + } else { + // Timeout — drop the read guard (cancels the read). + drop(read_guard); + return Ok(None); + } + + self.event_queue + .dispatch_pending(&mut self.state) + .context("dispatch_pending")?; + + Ok(self.state.pending_events.pop_front()) + } + + /// Copy an ARGB8888 pixel buffer to the overlay surface via wl_shm. + pub fn present(&mut self, pixels: &[u8], width: u32, height: u32) -> Result<()> { + 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(()) + } +} + +impl Drop for WaylandBackend { + fn drop(&mut self) { + if let Some(ls) = self.state.layer_surface.take() { + ls.destroy(); + } + if let Some(s) = self.state.surface.take() { + s.destroy(); + } + let _ = self.state.conn.flush(); + } +} + +// =========================================================================== +// Internal state. +// =========================================================================== + +struct WaylandState { + conn: Connection, + + compositor: Option, + shm: Option, + seat: Option, + layer_shell: Option, + + surface: Option, + layer_surface: Option, + + screen_w: u32, + screen_h: u32, + configured: bool, + + pending_events: VecDeque, +} + +impl WaylandState { + fn init_layer_surface(&mut self, qh: &QueueHandle) -> Result<()> { + let compositor = self + .compositor + .as_ref() + .context("wl_compositor not available")?; + let layer_shell = self + .layer_shell + .as_ref() + .context("zwlr_layer_shell_v1 not available")?; + + let surface = compositor.create_surface(qh, ()); + let layer_surface = layer_shell.get_layer_surface( + &surface, + None, + zwlr_layer_shell_v1::Layer::Overlay, + "ringa".to_string(), + qh, + (), + ); + + layer_surface.set_size(0, 0); + layer_surface.set_anchor( + zwlr_layer_surface_v1::Anchor::Top + | zwlr_layer_surface_v1::Anchor::Bottom + | zwlr_layer_surface_v1::Anchor::Left + | zwlr_layer_surface_v1::Anchor::Right, + ); + layer_surface.set_exclusive_zone(-1); + layer_surface + .set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::Exclusive); + + surface.commit(); + + self.surface = Some(surface); + self.layer_surface = Some(layer_surface); + Ok(()) + } +} + +// =========================================================================== +// Wayland dispatch implementations. +// =========================================================================== + +impl Dispatch for WaylandState { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "wl_compositor" => { + state.compositor = Some(registry.bind(name, version.min(4), qh, ())); + } + "wl_shm" => { + state.shm = Some(registry.bind(name, 1, qh, ())); + } + "wl_seat" => { + state.seat = Some(registry.bind(name, version.min(7), qh, ())); + } + "zwlr_layer_shell_v1" => { + state.layer_shell = Some(registry.bind(name, version.min(4), qh, ())); + } + _ => {} + } + } +} + +impl Dispatch for WaylandState { + fn event( + _state: &mut Self, + seat: &wl_seat::WlSeat, + event: wl_seat::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_seat::Event::Capabilities { + capabilities: WEnum::Value(caps), + } = event + { + if caps.contains(wl_seat::Capability::Keyboard) { + seat.get_keyboard(qh, ()); + } + if caps.contains(wl_seat::Capability::Pointer) { + seat.get_pointer(qh, ()); + } + } + } +} + +impl Dispatch for WaylandState { + fn event( + state: &mut Self, + _: &wl_keyboard::WlKeyboard, + event: wl_keyboard::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_keyboard::Event::Key { + key, + state: WEnum::Value(wl_keyboard::KeyState::Pressed), + .. + } = event + { + if key == KEY_ESCAPE { + state.pending_events.push_back(OverlayEvent::Close); + } + } + } +} + +impl Dispatch for WaylandState { + fn event( + state: &mut Self, + _: &wl_pointer::WlPointer, + event: wl_pointer::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + wl_pointer::Event::Enter { + surface_x, + surface_y, + .. + } => { + state + .pending_events + .push_back(OverlayEvent::CursorMoved(surface_x, surface_y)); + } + wl_pointer::Event::Motion { + surface_x, + surface_y, + .. + } => { + state + .pending_events + .push_back(OverlayEvent::CursorMoved(surface_x, surface_y)); + } + _ => {} + } + } +} + +impl Dispatch for WaylandState { + fn event( + state: &mut Self, + layer_surface: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + event: zwlr_layer_surface_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwlr_layer_surface_v1::Event::Configure { + serial, + width, + height, + } => { + layer_surface.ack_configure(serial); + const MAX_SCREEN_DIM: u32 = 16384; + if width > 0 && height > 0 && width <= MAX_SCREEN_DIM && height <= MAX_SCREEN_DIM { + state.screen_w = width; + state.screen_h = height; + state.configured = true; + state + .pending_events + .push_back(OverlayEvent::Configure(width, height)); + } + } + zwlr_layer_surface_v1::Event::Closed => { + state.pending_events.push_back(OverlayEvent::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 zwlr_layer_shell_v1::ZwlrLayerShellV1);