commit facd7fabd833ac451add5e2af5ff1bce66599eef Author: Lightbar contributors Date: Tue Sep 29 13:01:10 2026 +0200 Initial Lightbar panel with native audio controls diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d0931d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target/ +*.profraw diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..53dfd92 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2134 @@ +# 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 = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "nix", + "polling", + "rustix", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop", + "rustix", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.13.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[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 = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.4", + "once_cell", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dc9ee14b0fdcb535f9556141bacac070c994a977d2240ee455d7438a617f00" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "log", + "memmap2", + "pkg-config", + "rustix", + "thiserror", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkbcommon", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[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 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20251230.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c6c3e7178e553d093d46999032bc79aafea9dedd71dd702c8ace7bbc25cf13" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "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 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +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 = "lightbar" +version = "0.1.0" +dependencies = [ + "anyhow", + "calloop", + "chrono", + "clap", + "cosmic-text", + "crossbeam-channel", + "futures-lite", + "libc", + "notify", + "serde", + "serde_json", + "smithay-client-toolkit", + "tempfile", + "thiserror", + "tiny-skia", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", + "zbus", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9b0df24 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "lightbar" +version = "0.1.0" +edition = "2024" +rust-version = "1.93" +description = "A small, event-driven native Wayland panel for Sway" +license = "MIT" + +[dependencies] +anyhow = "1.0" +calloop = { version = "0.14", features = ["signals"] } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +clap = { version = "4.5", features = ["derive"] } +cosmic-text = "0.19" +crossbeam-channel = "0.5" +futures-lite = "2.6" +libc = "0.2" +notify = "8.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +smithay-client-toolkit = { version = "0.21", features = ["calloop"] } +thiserror = "2.0" +tiny-skia = "0.11" +toml = "0.9" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client"] } +wayland-protocols-wlr = { version = "0.3", features = ["client"] } +zbus = { version = "5.11", default-features = false, features = ["blocking-api", "tokio"] } +tokio = { version = "1.47", features = ["rt", "sync", "time", "process", "io-util", "macros"] } + +[dev-dependencies] +tempfile = "3.20" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "debuginfo" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "warn" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be5bc61 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lightbar contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e90ad85 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# Lightbar + +Lightbar is a small native Wayland panel for Sway. It draws directly into +shared-memory layer-shell surfaces and updates only when module state changes or +the compositor grants a frame. There is no GTK widget tree, CSS engine, browser, +or free-running render loop. + +The initial target is Ubuntu 26.04 and Sway 1.11. The implementation checklist +and the deliberately deferred stress tests are tracked in [TOOD.md](TOOD.md). + +## What works + +- one top or bottom bar on every selected output, with output-local workspaces; +- left, centered, and right module groups with hover/click/scroll hit testing; +- Sway workspaces, binding mode, scratchpad list and direct IPC actions; +- exact-boundary clock and calendar popup; +- NetworkManager, PipeWire audio, battery, backlight, and hwmon temperature; +- default output and microphone selection, volume and mute controls, backlight + sliders, and external advanced settings launchers; +- interval and streaming custom commands with limits, timeout, stale state, and + explicit shell opt-in; +- StatusNotifier watcher/host, validated ARGB pixmaps, attention state, + activation, secondary activation, item context menus, and scrolling; +- strict TOML validation and atomic config/theme hot reload; +- `check-config` and `doctor` commands. + +Tray items that only publish an icon-theme name currently use a dot fallback. +Pixmap icons are rendered directly. DBusMenu is also still delegated to the tray +item through `ContextMenu`; native nested DBusMenu rendering is tracked in the +checklist. + +## Build and try it + +Rust 1.93 or newer is required. On Ubuntu 26.04, install the normal Rust build +toolchain and `pkg-config`; the Wayland and D-Bus protocol clients themselves are +pure Rust. + +```sh +cargo test --all-targets +cargo run -- check-config --config examples/config.toml +cargo run -- doctor --config examples/config.toml +cargo run -- --config examples/config.toml +``` + +The example uses `UbuntuMono Nerd Font`. Install that font for the intended icon +glyphs; cosmic-text/fontconfig will otherwise select an installed fallback such +as Noto Sans. + +## Install without replacing Waybar + +```sh +./scripts/install.sh +``` + +This builds a locked release, installs `~/.local/bin/lightbar`, and copies the +example files only when the destination does not already exist. It also writes +`~/.config/lightbar/bar.conf`, a generated Sway fragment containing the +current user's absolute binary and configuration paths. It does not edit your +Sway or Waybar configuration. + +Validate the installed configuration: + +```sh +~/.local/bin/lightbar check-config +~/.local/bin/lightbar doctor +``` + +Then remove or comment out your existing top-level `bar { ... }` block, add the +portable include below at top level, and reload Sway: + +```text +include ~/.config/lightbar/bar.conf +``` + +The included file is generated separately for each user, so a shared Sway +configuration never contains a username or `/home/...` path. Absolute paths in +that generated file also avoid depending on the graphical Sway session's +`PATH`, which does not always include `~/.local/bin`. If `XDG_CONFIG_HOME` is +customized, use the absolute `include` line printed by the installer instead. + +Sway appends `-b lightbar`; the CLI accepts that automatically. To roll back, +remove the include, restore the old bar block, and reload. For a Waybar setup the +bar block can contain: + +```text +bar { + swaybar_command waybar +} +``` + +Lightbar never edits or removes Waybar's files. + +## Configuration + +The configuration defaults to `$XDG_CONFIG_HOME/lightbar/config.toml` (or +`~/.config/lightbar/config.toml`). `theme` is resolved relative to that file. Both +files reject unknown fields, and an invalid live edit is logged while the last +valid configuration remains active. + +Module actions live below `common.actions` and support four explicit types: + +```toml +[modules.example] +kind = "command" +mode = "interval" # or "stream" +argv = ["date", "+%s"] # no shell interpretation +interval = "30s" +timeout = "2s" +max_output_bytes = 65536 + +[modules.example.common.actions.left] +type = "exec" +argv = ["notify-send", "clicked"] +``` + +Use `type = "shell"` only when shell expansion is intentional. Command output +may be plain text or a JSON object: + +```json +{"text":"42","tooltip":"answer","state":"warning","visible":true} +``` + +See [examples/config.toml](examples/config.toml) and +[examples/theme.toml](examples/theme.toml) for every built-in used by the +current layout. + +### Audio + +Audio requires PipeWire, WirePlumber (`wpctl`), and `pw-dump` on `PATH`. +The bar shows the current default output and microphone independently. With +the example actions, middle-click toggles mute and scrolling changes volume +for the speaker or microphone segment under the pointer. Left-click opens +the audio popup. + +Click a device name in the popup to choose a default output or microphone. +Each default has its own volume slider and mute button. Click the slider or +scroll over it to adjust volume between 0% and 150%; changing volume preserves +mute state. External changes and device connections update the bar and open +popup automatically. A missing default is shown as unavailable while the other +endpoint remains usable. If the audio service disconnects, the popup closes +and the module reconnects automatically. + +Use Tab/Shift+Tab or Up/Down to focus controls, Enter/Space to activate buttons, +Left/Right to adjust a focused slider, Home/End for its limits, and Escape to +close. Long device lists support scrolling and Previous/More buttons. + +`format` and `muted_format` configure the output segment; +`microphone_format` and `microphone_muted_format` configure the microphone +segment. All four accept `{volume}`, `{device}` (description), and `{name}` +(PipeWire node name). Existing output formats remain valid. The complete +[example configuration](examples/config.toml) includes both microphone formats. + +The popup's **Open pavucontrol** button provides per-application routing and +device profile controls when `pavucontrol` is installed. Default-device +selection uses [WirePlumber's `set-default` command](https://pipewire.pages.freedesktop.org/wireplumber/man/wpctl.html#set-default); +routing of existing streams follows the session manager's policy. + +## CPU and memory comparison + +Build release binaries, run one bar configuration at a time, and sample the +process for at least ten minutes: + +```sh +./scripts/sample-process.sh $(pgrep -n lightbar) 600 > lightbar-core.tsv +./scripts/sample-process.sh $(pgrep -n waybar) 600 > waybar.tsv +``` + +Repeat with the tray removed from each layout, then interact with workspaces, +audio, popups, and tray items. Compare the samples and inspect whether RSS or +CPU trends upward. An eight-hour soak and event-storm testing remain release +gates in `TOOD.md`; a short local sample is not evidence that those gates pass. + +## Design guardrails + +- Module events are dirty-compared before drawing. +- Frame callbacks prevent drawing faster than the compositor. +- D-Bus tray signal queues, process output, IPC replies, and pixmaps are bounded. +- Module workers back off or wait on events; low-frequency hardware values use + sparse polling. +- Dropping a configuration generation stops its workers and monitored children. diff --git a/TOOD.md b/TOOD.md new file mode 100644 index 0000000..aab67f8 --- /dev/null +++ b/TOOD.md @@ -0,0 +1,136 @@ +# Lightbar implementation checklist + +This file tracks the implementation of the agreed native, low-CPU Sway panel. +The first target is Ubuntu 26.04 with Sway 1.11. The core bar ships before the +StatusNotifier tray. + +## 1. Project and configuration + +- [ ] Rename the project and source folder to Lightbar, migrate the installed + configuration, and verify the running build and tests. +- [ ] Initialize Git and push the reviewed source to the Lightbar repository; + verify that the remote branch matches the local commit. +- [x] Rename the package, executable, configuration, documentation, and source + folder to Lightbar; migrate the installation and verify the running build. +- [x] Create a locked Rust application named `lightbar`. +- [x] Add CLI support for Sway's `-b `, `--config`, `check-config`, + `doctor`, and `--version`. +- [x] Define strict, documented TOML models for the bar, layout, modules, + actions, custom commands, and theme. +- [x] Load `$XDG_CONFIG_HOME/lightbar/config.toml` and its relative theme file. +- [x] Validate module references, colors, dimensions, durations, formats, and + command limits before starting the UI. +- [x] Watch configuration and theme files; reload atomically and keep the last + valid state after an invalid edit. +- [x] Filter and coalesce configuration filesystem events so reads and unrelated + files cannot trigger reload loops. +- [ ] Add rate-limited structured logging and useful exit codes. +- [x] Add `doctor` checks for configuration, fonts, Sway/Wayland environment, + D-Bus services, hardware, and optional helper applications. + +## 2. Native Wayland shell and rendering + +- [x] Connect through `WAYLAND_SOCKET` when Sway launches the bar and through + the normal Wayland environment during development. +- [x] Create a top layer-shell surface with a 24 logical-pixel exclusive zone + for every active output. +- [ ] Handle output creation/removal, integer and fractional scaling, surface + configure events, and graceful compositor shutdown. +- [x] Implement reusable shared-memory buffers and compositor-frame-throttled + drawing without a free-running render loop. +- [x] Render backgrounds, borders, state colors, and shaped system-font text. +- [x] Implement left/center/right layout, width measurement, clipping, and + stable pointer hit boxes. +- [x] Handle hover, left/middle/right click, scrolling, and keyboard focus only + while a popup needs it. +- [x] Implement one clamped `xdg_popup` per seat with dismissal, buttons, + sliders, scrolling, and calendar/menu layouts. + +## 3. Core data model and modules + +- [x] Define a common module state/event/action interface with dirty-state + comparison so unchanged data cannot trigger redraws. +- [ ] Use bounded latest-value channels and coalesce bursts into one frame. +- [x] Implement Sway IPC framing, initialization, persistent request and event + connections, commands, output-local workspaces, binding mode, and + scratchpad discovery. +- [x] Preserve the last workspace list and focused selection across recoverable + Sway IPC disconnects instead of replacing it with an empty snapshot. +- [x] Implement a navigable scratchpad window popover and reveal/move actions. +- [x] Implement an exact-boundary clock and navigable calendar popover. +- [ ] Implement NetworkManager status and detail through D-Bus, with an + `nm-connection-editor` launcher for advanced management. +- [ ] Implement UPower battery status and detail through D-Bus. +- [x] Implement PulseAudio/PipeWire-Pulse volume events, mute/volume controls, + and a `pavucontrol` launcher. +- [x] Extend audio with current default output and microphone names, independent + volume/mute controls, and default-device selection in the popup. +- [x] Verify audio event synchronization, missing/disconnected devices, control + routing, and popup keyboard interaction with regression tests. + Verified with 37 automated tests, a read-only comparison with live + PipeWire/WirePlumber, and an isolated Sway session using simulated devices. +- [x] Install the updated audio build, validate the installed configuration, + and verify that the running bar uses the installed binary. +- [x] Implement sysfs/logind backlight discovery and adjustment. +- [x] Implement configurable `hwmon` temperature discovery and sparse polling. +- [x] Auto-detect current hardware names while retaining explicit overrides. +- [x] Hide or mark an unavailable optional module without crashing the bar. + +## 4. Custom command modules + +- [x] Support interval commands and long-running newline streams. +- [x] Accept plain text or JSON objects containing `text`, `tooltip`, `state`, + and `visible`. +- [x] Enforce argument-array execution by default, explicit shell opt-in, + two-second default timeouts, 64 KiB limits, and child cleanup. +- [x] Preserve the last good value as stale after transient failures and use + bounded exponential restart backoff. +- [x] Interrupt and reap monitored children during reload without holding child + locks across blocking waits. +- [x] Support typed built-in, Sway, executable, and explicit-shell actions for + clicks and scroll directions. + +## 5. StatusNotifier tray (second milestone) + +- [x] Reuse an existing `org.kde.StatusNotifierWatcher`, or own and implement + it when no watcher exists. +- [x] Register a StatusNotifierHost and track item registration, property + changes, and duplicate notifications. +- [x] Remove owned-watcher registrations immediately when their D-Bus name + disappears, and never render ownerless or unreadable items as fallback + circles. +- [x] Validate and render item-provided ARGB pixmaps. +- [ ] Resolve icon-name-only items from installed XDG icon themes; do not bundle + a vector icon set. +- [x] Implement activation, secondary activation, context menus, and scrolling. +- [ ] Implement DBusMenu layout, updates, nested menus, separators, toggles, + enabled/visible state, and activation in native popups. +- [ ] Reject malformed/oversized pixmaps, cap menu depth and item count, + isolate broken items, and rate-limit event floods. + +## 6. Tests, profiling, and rollout + +- [x] Unit-test config validation, duration/color parsing, format expansion, + hit testing, Sway frames/filtering, custom output, sysfs selection, and + hostile tray pixmaps. +- [ ] Add focused tests for state transitions, retries, popup placement, output + scoping, and rendering cache eviction. +- [ ] Test modules through fake Sway streams, D-Bus services, audio events, + sysfs trees, and hostile custom processes. +- [ ] Add rendering snapshots for multiple scales, Unicode, missing glyphs, + urgent states, and narrow outputs. +- [ ] Run end-to-end tests in a nested headless Sway session. +- [ ] Add adversarial tray tests for malformed pixmaps/menus, duplicate items, + disappearing services, and signal floods. +- [ ] Compare Waybar with/without tray against Lightbar core/with tray in repeated + idle and interaction runs. +- [ ] Complete an eight-hour soak with flat memory and no sustained CPU spin + after disconnects, reloads, event storms, or malformed input. +- [x] Provide a current-layout example using UbuntuMono Nerd Font, while + warning and falling back to Noto Sans when it is absent. +- [x] Document development dependencies, release build, user installation, + Sway configuration, validation, profiling, and one-line Waybar rollback. +- [x] Generate a per-user Sway include fragment so shared configurations do not + hardcode usernames or depend on the graphical session's `PATH`. +- [ ] Install as `~/.local/bin/lightbar`, preserve the existing Waybar files, and + switch only `swaybar_command` after all release checks pass. diff --git a/examples/config.toml b/examples/config.toml new file mode 100644 index 0000000..811bc4c --- /dev/null +++ b/examples/config.toml @@ -0,0 +1,126 @@ +version = 1 +theme = "theme.toml" + +[bar] +position = "top" +height = 24 +layer = "top" +outputs = ["*"] +namespace = "lightbar" +popup_width = 320 +popup_max_height = 480 + +[layout] +left = ["workspaces", "scratchpad", "mode"] +center = [] +right = ["network", "temperature", "backlight", "audio", "battery", "tray", "clock"] + +[modules.workspaces] +kind = "workspaces" +format = "{index}" + +[modules.scratchpad] +kind = "scratchpad" +format = "^{count}" +empty_format = "^0" + +[modules.scratchpad.common.actions.left] +type = "builtin" +name = "toggle-popover" + +[modules.scratchpad.common.actions.right] +type = "sway" +command = "move scratchpad" + +[modules.mode] +kind = "mode" +format = "{mode}" + +[modules.network] +kind = "network" +format_connected = " {ssid}" +format_disconnected = "睊 Disconnected" + +[modules.network.common.actions.left] +type = "builtin" +name = "toggle-popover" + +[modules.temperature] +kind = "temperature" +format = " {temperature_c:.0}°C" +interval = "30s" +warning_c = 75.0 +critical_c = 90.0 + +[modules.temperature.common.actions.left] +type = "builtin" +name = "toggle-popover" + +[modules.backlight] +kind = "backlight" +format = " {percent}%" +step_percent = 5 + +[modules.backlight.common.actions.left] +type = "builtin" +name = "toggle-popover" + +[modules.backlight.common.actions.scroll_up] +type = "builtin" +name = "brightness-up" + +[modules.backlight.common.actions.scroll_down] +type = "builtin" +name = "brightness-down" + +[modules.audio] +kind = "audio" +format = " {volume}%" +muted_format = "婢 Mute" +microphone_format = " {volume}%" +microphone_muted_format = " Mute" +step_percent = 5 + +[modules.audio.common.actions.left] +type = "builtin" +name = "toggle-popover" + +[modules.audio.common.actions.middle] +type = "builtin" +name = "toggle-mute" + +[modules.audio.common.actions.scroll_up] +type = "builtin" +name = "volume-up" + +[modules.audio.common.actions.scroll_down] +type = "builtin" +name = "volume-down" + +[modules.battery] +kind = "battery" +format = "{icon} {percent}%" +full_format = " {percent}%" +warning_percent = 20 +critical_percent = 10 + +[modules.battery.common.actions.left] +type = "builtin" +name = "toggle-popover" + +[modules.clock] +kind = "clock" +format = "%a, %d. %b %H:%M:%S" +interval = "1s" + +[modules.tray] +kind = "tray" +icon_size = 16 +spacing = 8 + +[modules.clock.common] +style = { margin_x = 6 } + +[modules.clock.common.actions.left] +type = "builtin" +name = "toggle-popover" diff --git a/examples/theme.toml b/examples/theme.toml new file mode 100644 index 0000000..2dacd7f --- /dev/null +++ b/examples/theme.toml @@ -0,0 +1,76 @@ +[font] +family = "UbuntuMono Nerd Font" +fallbacks = ["Noto Sans", "Noto Color Emoji"] +size = 12.0 + +[bar] +background = "#111111" +border = "#00000000" +border_width = 0 +padding = 0 + +[popup] +background = "#222222" +border = "#555555" +border_width = 1 +padding = 10 + +[module] +foreground = "#dddddd" +background = "#00000000" +border = "#00000000" +border_width = 0 +padding_x = 10 +padding_y = 0 +margin_x = 0 +min_width = 1 + +[states.focused] +foreground = "#000000" +background = "#7cafc2" +border = "#dddddd" +border_width = 1 + +[states.active] +foreground = "#dddddd" +background = "#323232" +border = "#555555" +border_width = 1 + +[states.urgent] +foreground = "#222222" +background = "#ab4642" +border = "#ab4642" +border_width = 1 + +[states.warning] +foreground = "#ffcc66" + +[states.critical] +foreground = "#ff5555" + +[states.disconnected] +foreground = "#ff5555" + +[states.hover] +background = "#3b3b3b" + +[states.stale] +foreground = "#888888" + +[modules.workspaces] +padding_x = 6 +border = "#555555" +border_width = 1 + +[modules.scratchpad] +foreground = "#b8b8b8" +background = "#323232" +padding_x = 9 + +[modules.mode] +foreground = "#ffffff" +background = "#900000" +border = "#2f343a" +border_width = 1 +padding_x = 5 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..d3aa640 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +data_home=${XDG_DATA_HOME:-"${HOME}/.local/share"} +config_home=${XDG_CONFIG_HOME:-"${HOME}/.config"} +binary_dir=${HOME}/.local/bin +bar_config=${config_home}/lightbar/bar.conf + +cargo build --locked --release --manifest-path "${project_dir}/Cargo.toml" +install -Dm755 "${project_dir}/target/release/lightbar" "${binary_dir}/lightbar" + +install -d "${config_home}/lightbar" +if [[ ! -e "${config_home}/lightbar/config.toml" ]]; then + install -m644 "${project_dir}/examples/config.toml" "${config_home}/lightbar/config.toml" +fi +if [[ ! -e "${config_home}/lightbar/theme.toml" ]]; then + install -m644 "${project_dir}/examples/theme.toml" "${config_home}/lightbar/theme.toml" +fi + +# Sway may be started with a PATH that does not contain ~/.local/bin. Keep the +# path resolution out of the shared Sway config by generating this per-user +# fragment with absolute paths at install time. +bar_config_tmp=$(mktemp) +trap 'rm -f "${bar_config_tmp}"' EXIT +printf '%s\n' \ + '# Generated by Lightbar. Re-run scripts/install.sh after moving the install.' \ + 'bar lightbar {' \ + " swaybar_command ${binary_dir}/lightbar --config ${config_home}/lightbar/config.toml" \ + '}' > "${bar_config_tmp}" +install -m644 "${bar_config_tmp}" "${bar_config}" +rm -f "${bar_config_tmp}" +trap - EXIT + +install -d "${data_home}/doc/lightbar" +install -m644 "${project_dir}/README.md" "${data_home}/doc/lightbar/README.md" +install -m644 "${project_dir}/TOOD.md" "${data_home}/doc/lightbar/TOOD.md" + +printf 'Installed %s\n' "${binary_dir}/lightbar" +printf 'Configuration: %s\n' "${config_home}/lightbar/config.toml" +printf 'Sway bar fragment: %s\n' "${bar_config}" +printf 'Run: %s doctor\n' "${binary_dir}/lightbar" +printf '\nAdd this at top level in your Sway config (and remove the old bar block):\n' +if [[ "${config_home}" == "${HOME}/.config" ]]; then + printf ' include ~/.config/lightbar/bar.conf\n' +else + printf ' include %s\n' "${bar_config}" +fi diff --git a/scripts/sample-process.sh b/scripts/sample-process.sh new file mode 100755 index 0000000..2103ded --- /dev/null +++ b/scripts/sample-process.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 || ! $1 =~ ^[0-9]+$ ]]; then + printf 'usage: %s PID [SECONDS]\n' "$0" >&2 + exit 64 +fi + +sample_pid=$1 +duration=${2:-600} +if [[ ! ${duration} =~ ^[0-9]+$ || ${duration} -lt 1 ]]; then + printf 'SECONDS must be a positive integer\n' >&2 + exit 64 +fi +if [[ ! -r "/proc/${sample_pid}/stat" ]]; then + printf 'PID %s is not readable\n' "${sample_pid}" >&2 + exit 66 +fi + +printf 'elapsed_s\tcpu_percent\trss_kib\n' +clock_ticks=$(getconf CLK_TCK) +read_ticks() { + local stat_line stat_fields + IFS= read -r stat_line < "/proc/${sample_pid}/stat" || return 1 + stat_fields=${stat_line##*) } + read -r -a stat_fields <<<"${stat_fields}" + printf '%s\n' "$((stat_fields[11] + stat_fields[12]))" +} + +previous_ticks=$(read_ticks) +for ((elapsed = 1; elapsed <= duration; elapsed++)); do + sleep 1 + if [[ ! -r "/proc/${sample_pid}/stat" ]] || ! current_ticks=$(read_ticks); then + printf 'process %s exited after %s seconds\n' "${sample_pid}" "${elapsed}" >&2 + exit 1 + fi + delta_ticks=$((current_ticks - previous_ticks)) + cpu=$(awk -v ticks="${delta_ticks}" -v hz="${clock_ticks}" \ + 'BEGIN { printf "%.2f", ticks * 100 / hz }') + rss=$(awk '/^VmRSS:/ { print $2; exit }' "/proc/${sample_pid}/status") + printf '%s\t%s\t%s\n' "${elapsed}" "${cpu}" "${rss}" + previous_ticks=${current_ticks} +done diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..0f88db2 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1770 @@ +use std::{ + collections::BTreeSet, + num::NonZeroU32, + path::{Path, PathBuf}, + process::Command, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use calloop::{ + EventLoop, + channel::{Event as ChannelEvent, Sender, channel}, + signals::{Signal, Signals}, +}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use smithay_client_toolkit::{ + compositor::{CompositorHandler, CompositorState, FrameCallbackData}, + delegate_registry, + output::{OutputHandler, OutputState}, + registry::{ProvidesRegistryState, RegistryState}, + registry_handlers, + seat::{ + Capability, SeatHandler, SeatState, + keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers}, + pointer::{ + BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent, PointerEventKind, PointerHandler, + }, + }, + shell::{ + WaylandSurface, + wlr_layer::{ + Anchor, KeyboardInteractivity, Layer as WlrLayer, LayerShell, LayerShellHandler, + LayerSurface, LayerSurfaceConfigure, + }, + xdg::{ + XdgPositioner, XdgShell, + popup::{Popup, PopupConfigure, PopupHandler}, + window::{Window, WindowConfigure, WindowHandler}, + }, + }, + shm::{ + Shm, ShmHandler, + slot::{Buffer, SlotPool}, + }, +}; +use wayland_client::{ + Connection, QueueHandle, + globals::registry_queue_init, + protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm, wl_surface}, +}; +use wayland_protocols::xdg::shell::client::xdg_positioner; + +use crate::{ + config::{Action, BarConfig, BuiltinAction, ConfigBundle, Layer, ModuleConfig, Position}, + model::{ + AudioView, CalendarModel, Interaction, ModuleEvent, ModuleStore, PointerButton, + PopupContent, PopupModel, ScrollDirection, + }, + modules::{ + AudioAction, AudioTarget, ModuleRuntime, action_for_button, action_for_scroll, + activate_tray_item, execute_action, execute_builtin_for_module, run_sway_command, + scroll_tray_item, + }, + render::{HitBox, Renderer, hit_test}, +}; + +use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; + +const CONFIG_RELOAD_DEBOUNCE: Duration = Duration::from_millis(100); + +pub fn run(bundle: ConfigBundle, bar_id: Option) -> Result<()> { + // Block handled signals before any module or watcher threads are spawned so + // every child thread inherits the mask and signalfd remains the sole + // receiver. Otherwise SIGHUP can land on a worker and terminate the process. + let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM, Signal::SIGHUP])?; + let connection = Connection::connect_to_env().context("could not connect to Wayland")?; + let (globals, event_queue) = + registry_queue_init(&connection).context("could not initialize the Wayland registry")?; + let queue_handle = event_queue.handle(); + + let mut event_loop: EventLoop<'static, App> = + EventLoop::try_new().context("could not create the event loop")?; + WaylandSource::new(connection.clone(), event_queue) + .insert(event_loop.handle()) + .context("could not register the Wayland event source")?; + + let compositor = + CompositorState::bind(&globals, &queue_handle).context("wl_compositor is unavailable")?; + let layer_shell = + LayerShell::bind(&globals, &queue_handle).context("wlr-layer-shell is unavailable")?; + let xdg_shell = XdgShell::bind(&globals, &queue_handle).context("xdg-shell is unavailable")?; + let shm = Shm::bind(&globals, &queue_handle).context("wl_shm is unavailable")?; + + let (module_sender, module_receiver) = channel(); + let module_runtime = ModuleRuntime::start_with_sender(&bundle.config, module_sender.clone())?; + let (app_sender, app_receiver) = channel(); + let config_watcher = ConfigWatcher::new( + [&bundle.config_path, &bundle.theme_path], + app_sender.clone(), + )?; + + let mut app = App { + registry_state: RegistryState::new(&globals), + seat_state: SeatState::new(&globals, &queue_handle), + output_state: OutputState::new(&globals, &queue_handle), + compositor, + layer_shell, + xdg_shell, + shm, + bundle, + bar_id, + bars: Vec::new(), + popup: None, + modules: ModuleStore::default(), + module_sender, + module_runtime, + config_watcher, + renderer: Renderer::default(), + pointer: None, + keyboard: None, + seat: None, + exit: false, + }; + + let qh = queue_handle.clone(); + event_loop + .handle() + .insert_source(module_receiver, move |event, _, app| { + if let ChannelEvent::Msg(event) = event { + app.handle_module_event(event, &qh); + } + }) + .map_err(|error| anyhow!("could not register the module event source: {error}"))?; + + let qh = queue_handle.clone(); + event_loop + .handle() + .insert_source(app_receiver, move |event, _, app| { + if let ChannelEvent::Msg(AppEvent::ReloadRequested) = event { + app.config_watcher.reload_handled(); + app.reload(&qh); + } + }) + .map_err(|error| anyhow!("could not register the configuration event source: {error}"))?; + + let qh = queue_handle.clone(); + event_loop + .handle() + .insert_source(signals, move |event, _, app| match event.signal() { + Signal::SIGHUP => app.reload(&qh), + Signal::SIGINT | Signal::SIGTERM => app.exit = true, + _ => {} + }) + .context("could not register signal handling")?; + + tracing::info!( + bar_id = ?app.bar_id, + "starting native Wayland panel" + ); + while !app.exit { + event_loop + .dispatch(None::, &mut app) + .context("event-loop dispatch failed")?; + } + Ok(()) +} + +struct App { + registry_state: RegistryState, + seat_state: SeatState, + output_state: OutputState, + compositor: CompositorState, + layer_shell: LayerShell, + xdg_shell: XdgShell, + shm: Shm, + bundle: ConfigBundle, + bar_id: Option, + bars: Vec, + popup: Option, + modules: ModuleStore, + module_sender: Sender, + module_runtime: ModuleRuntime, + config_watcher: ConfigWatcher, + renderer: Renderer, + pointer: Option, + keyboard: Option, + seat: Option, + exit: bool, +} + +struct BarSurface { + output: wl_output::WlOutput, + output_name: String, + layer: LayerSurface, + pool: SlotPool, + buffer: Option, + buffer_size: (u32, u32), + width: u32, + height: u32, + scale: u32, + configured: bool, + dirty: bool, + frame_pending: bool, + hit_boxes: Vec, + hovered: Option<(String, Option)>, +} + +struct ActivePopup { + popup: Popup, + module: String, + output_name: String, + model: PopupModel, + pool: SlotPool, + buffer: Option, + buffer_size: (u32, u32), + width: u32, + height: u32, + scale: u32, + configured: bool, + dirty: bool, + frame_pending: bool, + hit_boxes: Vec, + calendar_offset: i32, +} + +#[derive(Debug, Copy, Clone)] +enum AppEvent { + ReloadRequested, +} + +struct ConfigWatcher { + watcher: RecommendedWatcher, + directories: BTreeSet, + files: Arc>>, + reload_pending: Arc, +} + +impl ConfigWatcher { + fn new<'a>( + paths: impl IntoIterator, + sender: Sender, + ) -> Result { + let files = watched_files(paths); + let watched_files = Arc::new(Mutex::new(files.clone())); + let reload_pending = Arc::new(AtomicBool::new(false)); + let callback_files = Arc::clone(&watched_files); + let callback_pending = Arc::clone(&reload_pending); + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| match event { + Ok(event) => { + let relevant = callback_files + .lock() + .is_ok_and(|files| event_requires_reload(&event, &files)); + if relevant { + schedule_reload(&sender, &callback_pending); + } + } + Err(error) => tracing::warn!(%error, "configuration watch failed"), + })?; + let directories = watched_directories(&files); + for directory in &directories { + watcher.watch(directory, RecursiveMode::NonRecursive)?; + } + Ok(Self { + watcher, + directories, + files: watched_files, + reload_pending, + }) + } + + fn reset<'a>(&mut self, paths: impl IntoIterator) -> Result<()> { + let next_files = watched_files(paths); + let next_directories = watched_directories(&next_files); + for directory in self.directories.difference(&next_directories) { + self.watcher.unwatch(directory)?; + } + for directory in next_directories.difference(&self.directories) { + self.watcher.watch(directory, RecursiveMode::NonRecursive)?; + } + self.directories = next_directories; + *self + .files + .lock() + .map_err(|_| anyhow!("configuration watch paths lock was poisoned"))? = next_files; + Ok(()) + } + + fn reload_handled(&self) { + self.reload_pending.store(false, Ordering::Release); + } +} + +fn watched_files<'a>(paths: impl IntoIterator) -> BTreeSet { + paths.into_iter().cloned().collect() +} + +fn watched_directories(paths: &BTreeSet) -> BTreeSet { + paths + .iter() + .filter_map(|path| path.parent().map(Path::to_path_buf)) + .collect() +} + +fn event_requires_reload(event: ¬ify::Event, files: &BTreeSet) -> bool { + use notify::event::{AccessKind, AccessMode, EventKind, MetadataKind, ModifyKind}; + + let is_mutation = match event.kind { + // Imprecise backends use Any/Other for changes they cannot classify. + EventKind::Access(AccessKind::Close(AccessMode::Write)) + | EventKind::Any + | EventKind::Other => true, + EventKind::Access(_) + | EventKind::Modify(ModifyKind::Metadata(MetadataKind::AccessTime)) => false, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => true, + }; + is_mutation && event.paths.iter().any(|path| files.contains(path)) +} + +fn schedule_reload(sender: &Sender, pending: &Arc) { + if pending + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let sender = sender.clone(); + let pending_for_thread = Arc::clone(pending); + if let Err(error) = thread::Builder::new() + .name("lightbar-config-debounce".to_owned()) + .spawn(move || { + thread::sleep(CONFIG_RELOAD_DEBOUNCE); + if sender.send(AppEvent::ReloadRequested).is_err() { + pending_for_thread.store(false, Ordering::Release); + } + }) + { + pending.store(false, Ordering::Release); + tracing::warn!(%error, "could not schedule configuration reload"); + } +} + +impl App { + fn reload(&mut self, qh: &QueueHandle) { + let path = self.bundle.config_path.clone(); + match ConfigBundle::load(Some(&path)) { + Ok(bundle) => { + let runtime = match ModuleRuntime::start_with_sender( + &bundle.config, + self.module_sender.clone(), + ) { + Ok(runtime) => runtime, + Err(error) => { + tracing::error!(%error, "could not restart module workers; keeping old configuration"); + return; + } + }; + if let Err(error) = self + .config_watcher + .reset([&bundle.config_path, &bundle.theme_path]) + { + tracing::warn!(%error, "could not update configuration watches"); + } + self.close_popup(); + self.bundle = bundle; + self.module_runtime = runtime; + self.rebuild_bars(qh); + tracing::info!(config = %path.display(), "configuration reloaded"); + } + Err(error) => { + tracing::error!(error = %format_args!("{error:#}"), "configuration reload rejected"); + } + } + } + + fn handle_module_event(&mut self, event: ModuleEvent, qh: &QueueHandle) { + let popup_model = event.snapshot.popup.clone(); + let module = event.module.clone(); + if !self.modules.update(event) { + return; + } + if self + .popup + .as_ref() + .is_some_and(|popup| popup.module == module) + && popup_model.is_none() + { + self.close_popup(); + } + if let Some(popup) = self.popup.as_mut().filter(|popup| popup.module == module) + && popup.calendar_offset == 0 + && let Some(mut model) = popup_model + { + if let (PopupContent::Audio(old), PopupContent::Audio(new)) = + (&popup.model.content, &mut model.content) + { + new.view = old.view; + new.focused.clone_from(&old.focused); + new.offset = old.offset.min(new.controls().len().saturating_sub(1)); + } + popup.model = model; + popup.dirty = true; + } + self.invalidate_all(qh); + } + + fn rebuild_bars(&mut self, qh: &QueueHandle) { + self.bars.clear(); + let outputs: Vec<_> = self.output_state.outputs().collect(); + for output in outputs { + self.sync_output(output, qh); + } + } + + fn sync_output(&mut self, output: wl_output::WlOutput, qh: &QueueHandle) { + let Some(info) = self.output_state.info(&output) else { + return; + }; + let name = info + .name + .clone() + .unwrap_or_else(|| format!("output-{}", info.id)); + let enabled = self.bundle.config.bar.output_enabled(&name); + if let Some(index) = self.bars.iter().position(|bar| bar.output == output) { + if !enabled { + self.bars.remove(index); + return; + } + let scale = u32::try_from(info.scale_factor.max(1)).unwrap_or(1); + let bar = &mut self.bars[index]; + bar.output_name = name; + if bar.scale != scale { + bar.scale = scale; + bar.layer + .wl_surface() + .set_buffer_scale(i32::try_from(scale).unwrap_or(i32::MAX)); + bar.buffer = None; + bar.dirty = true; + } + self.invalidate(index, qh); + return; + } + if !enabled { + return; + } + match self.create_bar(output, name, info.scale_factor, qh) { + Ok(bar) => self.bars.push(bar), + Err(error) => tracing::error!(%error, "could not create bar surface"), + } + } + + fn create_bar( + &self, + output: wl_output::WlOutput, + output_name: String, + output_scale: i32, + qh: &QueueHandle, + ) -> Result { + let surface = self.compositor.create_surface(qh); + let layer = self.layer_shell.create_layer_surface( + qh, + surface, + map_layer(self.bundle.config.bar.layer), + Some(self.bundle.config.bar.namespace.clone()), + Some(&output), + ); + configure_layer(&layer, &self.bundle.config.bar); + let scale = u32::try_from(output_scale.max(1)).unwrap_or(1); + layer + .wl_surface() + .set_buffer_scale(i32::try_from(scale).unwrap_or(i32::MAX)); + layer.commit(); + let initial_width = 1920_u32.saturating_mul(scale); + let initial_height = self.bundle.config.bar.height.saturating_mul(scale); + let pool = SlotPool::new( + usize::try_from(initial_width) + .unwrap_or(usize::MAX) + .saturating_mul(initial_height as usize) + .saturating_mul(4), + &self.shm, + )?; + tracing::info!(output = %output_name, scale, "created bar surface"); + Ok(BarSurface { + output, + output_name, + layer, + pool, + buffer: None, + buffer_size: (0, 0), + width: 0, + height: self.bundle.config.bar.height, + scale, + configured: false, + dirty: true, + frame_pending: false, + hit_boxes: Vec::new(), + hovered: None, + }) + } + + fn invalidate_all(&mut self, qh: &QueueHandle) { + for index in 0..self.bars.len() { + self.bars[index].dirty = true; + self.invalidate(index, qh); + } + if let Some(popup) = &mut self.popup { + popup.dirty = true; + if popup.configured + && !popup.frame_pending + && let Err(error) = self.draw_popup(qh) + { + tracing::error!(%error, "popup draw failed"); + } + } + } + + fn invalidate(&mut self, index: usize, qh: &QueueHandle) { + if self + .bars + .get(index) + .is_some_and(|bar| bar.configured && !bar.frame_pending && bar.dirty) + && let Err(error) = self.draw_bar(index, qh) + { + tracing::error!(%error, "bar draw failed"); + } + } + + fn draw_bar(&mut self, index: usize, qh: &QueueHandle) -> Result<()> { + let Self { + bars, + renderer, + bundle, + modules, + .. + } = self; + let bar = bars.get_mut(index).context("unknown bar surface")?; + let physical_width = bar.width.saturating_mul(bar.scale); + let physical_height = bar.height.saturating_mul(bar.scale); + validate_buffer_size(physical_width, physical_height)?; + if bar.buffer_size != (physical_width, physical_height) { + bar.buffer = None; + bar.buffer_size = (physical_width, physical_height); + } + let stride = i32::try_from(physical_width.saturating_mul(4))?; + let buffer = bar.buffer.get_or_insert_with(|| { + bar.pool + .create_buffer( + i32::try_from(physical_width).expect("validated buffer width"), + i32::try_from(physical_height).expect("validated buffer height"), + stride, + wl_shm::Format::Argb8888, + ) + .expect("validated shared-memory buffer") + .0 + }); + let canvas = if let Some(canvas) = bar.pool.canvas(buffer) { + canvas + } else { + let (second_buffer, canvas) = bar.pool.create_buffer( + i32::try_from(physical_width)?, + i32::try_from(physical_height)?, + stride, + wl_shm::Format::Argb8888, + )?; + *buffer = second_buffer; + canvas + }; + let result = renderer.render_bar( + canvas, + physical_width, + physical_height, + bar.scale, + &bar.output_name, + &bundle.config, + &bundle.theme, + modules, + bar.hovered + .as_ref() + .map(|(module, segment)| (module.as_str(), segment.as_deref())), + ); + bar.hit_boxes = result.hit_boxes; + bar.layer.wl_surface().damage_buffer( + 0, + 0, + i32::try_from(physical_width)?, + i32::try_from(physical_height)?, + ); + bar.layer + .wl_surface() + .frame(qh, FrameCallbackData(bar.layer.wl_surface().clone())); + bar.buffer + .as_ref() + .context("bar buffer disappeared")? + .attach_to(bar.layer.wl_surface())?; + bar.layer.commit(); + bar.dirty = false; + bar.frame_pending = true; + Ok(()) + } + + fn draw_popup(&mut self, qh: &QueueHandle) -> Result<()> { + let Self { + popup, + renderer, + bundle, + .. + } = self; + let popup = popup.as_mut().context("no active popup")?; + let physical_width = popup.width.saturating_mul(popup.scale); + let physical_height = popup.height.saturating_mul(popup.scale); + validate_buffer_size(physical_width, physical_height)?; + if popup.buffer_size != (physical_width, physical_height) { + popup.buffer = None; + popup.buffer_size = (physical_width, physical_height); + } + let stride = i32::try_from(physical_width.saturating_mul(4))?; + let buffer = popup.buffer.get_or_insert_with(|| { + popup + .pool + .create_buffer( + i32::try_from(physical_width).expect("validated buffer width"), + i32::try_from(physical_height).expect("validated buffer height"), + stride, + wl_shm::Format::Argb8888, + ) + .expect("validated shared-memory buffer") + .0 + }); + let canvas = if let Some(canvas) = popup.pool.canvas(buffer) { + canvas + } else { + let (second_buffer, canvas) = popup.pool.create_buffer( + i32::try_from(physical_width)?, + i32::try_from(physical_height)?, + stride, + wl_shm::Format::Argb8888, + )?; + *buffer = second_buffer; + canvas + }; + let result = renderer.render_popup( + canvas, + physical_width, + physical_height, + popup.scale, + &popup.module, + &popup.model, + &bundle.theme, + ); + popup.hit_boxes = result.hit_boxes; + popup.popup.wl_surface().damage_buffer( + 0, + 0, + i32::try_from(physical_width)?, + i32::try_from(physical_height)?, + ); + popup + .popup + .wl_surface() + .frame(qh, FrameCallbackData(popup.popup.wl_surface().clone())); + popup + .buffer + .as_ref() + .context("popup buffer disappeared")? + .attach_to(popup.popup.wl_surface())?; + popup.popup.wl_surface().commit(); + popup.dirty = false; + popup.frame_pending = true; + Ok(()) + } + + fn open_popup( + &mut self, + bar_index: usize, + hit_box: &HitBox, + serial: u32, + qh: &QueueHandle, + ) -> Result<()> { + let model = self + .modules + .get(&hit_box.module) + .and_then(|snapshot| snapshot.popup.clone()) + .context("module has no popup content")?; + self.close_popup(); + let bar = self.bars.get(bar_index).context("unknown parent bar")?; + let (width, height) = Renderer::popup_logical_size( + &model, + self.bundle.config.bar.popup_width, + self.bundle.config.bar.popup_max_height, + ); + let positioner = XdgPositioner::new(&self.xdg_shell)?; + positioner.set_size(i32::try_from(width)?, i32::try_from(height)?); + positioner.set_anchor_rect( + hit_box.rect.x.round() as i32, + hit_box.rect.y.round() as i32, + hit_box.rect.width.max(1.0).round() as i32, + hit_box.rect.height.max(1.0).round() as i32, + ); + let right_side = hit_box.rect.x + hit_box.rect.width / 2.0 > f64::from(bar.width) / 2.0; + match (self.bundle.config.bar.position, right_side) { + (Position::Top, false) => { + positioner.set_anchor(xdg_positioner::Anchor::BottomLeft); + positioner.set_gravity(xdg_positioner::Gravity::BottomRight); + } + (Position::Top, true) => { + positioner.set_anchor(xdg_positioner::Anchor::BottomRight); + positioner.set_gravity(xdg_positioner::Gravity::BottomLeft); + } + (Position::Bottom, false) => { + positioner.set_anchor(xdg_positioner::Anchor::TopLeft); + positioner.set_gravity(xdg_positioner::Gravity::TopRight); + } + (Position::Bottom, true) => { + positioner.set_anchor(xdg_positioner::Anchor::TopRight); + positioner.set_gravity(xdg_positioner::Gravity::TopLeft); + } + } + positioner.set_constraint_adjustment( + xdg_positioner::ConstraintAdjustment::SlideX + | xdg_positioner::ConstraintAdjustment::FlipX + | xdg_positioner::ConstraintAdjustment::SlideY, + ); + let surface = self.compositor.create_surface(qh); + surface.set_buffer_scale(i32::try_from(bar.scale).unwrap_or(i32::MAX)); + let popup = Popup::from_surface(None, &positioner, qh, surface, &self.xdg_shell)?; + let physical_width = width.saturating_mul(bar.scale); + let physical_height = height.saturating_mul(bar.scale); + let pool = SlotPool::new( + (physical_width as usize) + .saturating_mul(physical_height as usize) + .saturating_mul(4), + &self.shm, + )?; + // Establish keyboard focus before the popup grab. Release it on dismissal. + bar.layer + .set_keyboard_interactivity(if matches!(model.content, PopupContent::Audio(_)) { + KeyboardInteractivity::Exclusive + } else { + KeyboardInteractivity::OnDemand + }); + bar.layer.commit(); + bar.layer.get_popup(popup.xdg_popup()); + if let Some(seat) = &self.seat { + popup.xdg_popup().grab(seat, serial); + } + popup.wl_surface().commit(); + + self.popup = Some(ActivePopup { + popup, + module: hit_box.module.clone(), + output_name: bar.output_name.clone(), + model, + pool, + buffer: None, + buffer_size: (0, 0), + width, + height, + scale: bar.scale, + configured: false, + dirty: true, + frame_pending: false, + hit_boxes: Vec::new(), + calendar_offset: 0, + }); + Ok(()) + } + + fn close_popup(&mut self) { + let Some(popup) = self.popup.take() else { + return; + }; + if let Some(bar) = self + .bars + .iter() + .find(|bar| bar.output_name == popup.output_name) + { + bar.layer + .set_keyboard_interactivity(KeyboardInteractivity::None); + bar.layer.commit(); + } + } + + fn handle_bar_interaction( + &mut self, + bar_index: usize, + hit_box: HitBox, + interaction: Interaction, + serial: Option, + qh: &QueueHandle, + ) { + let Some(module) = self.bundle.config.modules.get(&hit_box.module) else { + return; + }; + if matches!(module, ModuleConfig::Workspaces(_)) + && let Interaction::Click { + button: PointerButton::Left, + segment: Some(ref workspace), + .. + } = interaction + { + let command = format!("workspace {}", quote_sway_arg(workspace)); + if let Err(error) = run_sway_command(&command) { + tracing::warn!(%error, "workspace switch failed"); + } + return; + } + if matches!(module, ModuleConfig::Tray(_)) { + let Some(item) = hit_box.segment.as_deref() else { + return; + }; + let result = match interaction { + Interaction::Click { button, .. } => { + let method = match button { + PointerButton::Left => "Activate", + PointerButton::Middle => "SecondaryActivate", + PointerButton::Right => "ContextMenu", + }; + activate_tray_item(item, method, 0, 0) + } + Interaction::Scroll { direction, .. } => { + let delta = match direction { + ScrollDirection::Up => 120, + ScrollDirection::Down => -120, + }; + scroll_tray_item(item, delta, "vertical") + } + }; + if let Err(error) = result { + tracing::warn!(%error, "tray interaction failed"); + } + return; + } + + let action = match interaction { + Interaction::Click { button, .. } => action_for_button(module, button), + Interaction::Scroll { direction, .. } => action_for_scroll(module, direction), + } + .cloned(); + let Some(action) = action else { + return; + }; + match action { + Action::Builtin { + name: BuiltinAction::TogglePopover, + } => { + if self + .popup + .as_ref() + .is_some_and(|popup| popup.module == hit_box.module) + { + self.close_popup(); + } else if let Some(serial) = serial + && let Err(error) = self.open_popup(bar_index, &hit_box, serial, qh) + { + tracing::warn!(%error, module = %hit_box.module, "could not open popup"); + } + } + Action::Builtin { name } => { + if let ModuleConfig::Audio(settings) = module { + let target = if hit_box.segment.as_deref() == Some("input") { + AudioTarget::DefaultInput + } else { + AudioTarget::DefaultOutput + }; + let action = match name { + BuiltinAction::ToggleMute => Some(AudioAction::ToggleMute(target)), + BuiltinAction::VolumeUp => Some(AudioAction::ChangeVolume( + target, + i16::from(settings.step_percent), + )), + BuiltinAction::VolumeDown => Some(AudioAction::ChangeVolume( + target, + -i16::from(settings.step_percent), + )), + _ => None, + }; + if let Some(action) = action { + if let Err(error) = + self.module_runtime.audio_action(&hit_box.module, action) + { + tracing::warn!(%error, "could not control audio"); + } + return; + } + } + if let Err(error) = execute_builtin_for_module(name, module) { + tracing::warn!(%error, module = %hit_box.module, "built-in action failed"); + } + } + other => { + if let Err(error) = execute_action(&other) { + tracing::warn!(%error, module = %hit_box.module, "module action failed"); + } + } + } + } + + fn handle_popup_click(&mut self, hit_box: HitBox, x: f64, qh: &QueueHandle) { + let Some(id) = hit_box.segment.as_deref() else { + return; + }; + if id.starts_with("audio-") { + self.handle_audio_control(&hit_box, x, qh); + return; + } + match id { + "previous-month" => self.change_calendar_month(-1, qh), + "next-month" => self.change_calendar_month(1, qh), + "open-settings" => { + let program = match self.bundle.config.modules.get(&hit_box.module) { + Some(ModuleConfig::Network(_)) => Some("nm-connection-editor"), + Some(ModuleConfig::Audio(_)) => Some("pavucontrol"), + _ => None, + }; + if let Some(program) = program + && let Err(error) = Command::new(program).spawn() + { + tracing::warn!(%error, program, "could not open external manager"); + } + self.close_popup(); + } + "slider" => { + if let Some(popup) = &self.popup { + let padding = f64::from(self.bundle.theme.popup.padding) + 8.0; + let width = (f64::from(popup.width) - padding * 2.0).max(1.0); + let ratio = ((x - padding) / width).clamp(0.0, 1.0); + if let Some(ModuleConfig::Backlight(settings)) = + self.bundle.config.modules.get(&hit_box.module) + { + if let Err(error) = + crate::modules::set_backlight_percent(settings, ratio * 100.0) + { + tracing::warn!(%error, "could not set popup brightness"); + } + } + } + } + con_id + if self + .bundle + .config + .modules + .get(&hit_box.module) + .is_some_and(|module| matches!(module, ModuleConfig::Scratchpad(_))) => + { + if con_id.chars().all(|character| character.is_ascii_digit()) { + let command = format!("[con_id={con_id}] scratchpad show"); + if let Err(error) = run_sway_command(&command) { + tracing::warn!(%error, "could not show scratchpad window"); + } + } + self.close_popup(); + } + tray_item + if self + .bundle + .config + .modules + .get(&hit_box.module) + .is_some_and(|module| matches!(module, ModuleConfig::Tray(_))) => + { + if let Err(error) = activate_tray_item(tray_item, "Activate", 0, 0) { + tracing::warn!(%error, "tray popup activation failed"); + } + self.close_popup(); + } + _ => {} + } + } + + fn change_calendar_month(&mut self, delta: i32, qh: &QueueHandle) { + let Some(popup) = self.popup.as_mut() else { + return; + }; + let PopupContent::Calendar(calendar) = &mut popup.model.content else { + return; + }; + shift_calendar(calendar, delta); + popup.calendar_offset = popup.calendar_offset.saturating_add(delta); + popup.dirty = true; + if popup.configured + && !popup.frame_pending + && let Err(error) = self.draw_popup(qh) + { + tracing::warn!(%error, "could not redraw calendar"); + } + } + + fn handle_audio_control(&mut self, hit: &HitBox, x: f64, qh: &QueueHandle) { + let Some(popup) = self.popup.as_mut() else { + return; + }; + let PopupContent::Audio(audio) = &mut popup.model.content else { + return; + }; + let Some(id) = hit.segment.as_deref() else { + return; + }; + audio.focused = Some(id.to_owned()); + let action = match id { + "audio-outputs" => { + audio.set_view(AudioView::Outputs); + None + } + "audio-inputs" => { + audio.set_view(AudioView::Inputs); + None + } + "audio-back" => { + audio.set_view(AudioView::Mixer); + None + } + "audio-scroll-up" => { + audio.offset = audio.offset.saturating_sub(1); + None + } + "audio-scroll-down" => { + audio.offset = (audio.offset + 1).min(audio.controls().len().saturating_sub(1)); + None + } + _ => { + let Some((operation, device_id)) = id.split_once(':') else { + return; + }; + let Ok(device_id) = device_id.parse::() else { + return; + }; + let Some(device) = audio.devices.iter().find(|device| device.id == device_id) + else { + return; + }; + match operation { + "audio-volume" if device.volume.is_some() => { + let ratio = ((x - hit.rect.x - 8.0) / (hit.rect.width - 16.0).max(1.0)) + .clamp(0.0, 1.0); + Some(AudioAction::SetVolume(device_id, ratio * 150.0)) + } + "audio-mute" if device.muted.is_some() => { + Some(AudioAction::ToggleMute(AudioTarget::Device(device_id))) + } + "audio-default" => { + audio.set_view(AudioView::Mixer); + Some(AudioAction::SetDefault(device_id)) + } + _ => None, + } + } + }; + if let Some(action) = action + && let Err(error) = self.module_runtime.audio_action(&hit.module, action) + { + tracing::warn!(%error, "could not queue audio control"); + } + popup.dirty = true; + self.invalidate_all(qh); + } + + fn handle_audio_key(&mut self, key: Keysym, qh: &QueueHandle) -> bool { + let Some(popup) = self.popup.as_mut() else { + return false; + }; + let PopupContent::Audio(audio) = &mut popup.model.content else { + return false; + }; + let available = + (f64::from(popup.height) - 2.0 * f64::from(self.bundle.theme.popup.padding) - 58.0) + .max(0.0); + match key { + Keysym::Tab | Keysym::Down => audio.move_focus(false, available), + Keysym::ISO_Left_Tab | Keysym::Up => audio.move_focus(true, available), + Keysym::Left | Keysym::Right | Keysym::Home | Keysym::End => { + if let Some(id) = audio + .focused + .as_deref() + .and_then(|id| id.strip_prefix("audio-volume:")) + .and_then(|id| id.parse::().ok()) + { + let step = match self.bundle.config.modules.get(&popup.module) { + Some(ModuleConfig::Audio(settings)) => i16::from(settings.step_percent), + _ => 5, + }; + let action = match key { + Keysym::Home => AudioAction::SetVolume(id, 0.0), + Keysym::End => AudioAction::SetVolume(id, 150.0), + _ => AudioAction::ChangeVolume( + AudioTarget::Device(id), + if key == Keysym::Left { -step } else { step }, + ), + }; + if let Err(error) = self.module_runtime.audio_action(&popup.module, action) { + tracing::warn!(%error, "could not adjust audio volume"); + } + } + } + Keysym::Return | Keysym::KP_Enter | Keysym::space => { + if let Some(hit) = popup + .hit_boxes + .iter() + .find(|hit| hit.segment.is_some() && hit.segment == audio.focused) + .cloned() + { + // Slider activation does not reset its value; arrow keys adjust it. + if !hit + .segment + .as_deref() + .is_some_and(|id| id.starts_with("audio-volume:")) + { + self.handle_popup_click(hit.clone(), hit.rect.x, qh); + } + return true; + } + } + _ => return true, + } + popup.dirty = true; + self.invalidate_all(qh); + true + } +} + +fn configure_layer(layer: &LayerSurface, config: &BarConfig) { + let anchor = match config.position { + Position::Top => Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, + Position::Bottom => Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT, + }; + layer.set_anchor(anchor); + layer.set_size(0, config.height); + layer.set_exclusive_zone(i32::try_from(config.height).unwrap_or(i32::MAX)); + layer.set_keyboard_interactivity(KeyboardInteractivity::None); +} + +const fn map_layer(layer: Layer) -> WlrLayer { + match layer { + Layer::Background => WlrLayer::Background, + Layer::Bottom => WlrLayer::Bottom, + Layer::Top => WlrLayer::Top, + Layer::Overlay => WlrLayer::Overlay, + } +} + +fn validate_buffer_size(width: u32, height: u32) -> Result<()> { + if width == 0 || height == 0 { + bail!("surface has zero-sized buffer"); + } + let bytes = u64::from(width) + .checked_mul(u64::from(height)) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| anyhow!("surface buffer size overflow"))?; + if bytes > 256 * 1024 * 1024 { + bail!("surface buffer exceeds 256 MiB safety limit"); + } + Ok(()) +} + +fn quote_sway_arg(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +fn shift_calendar(calendar: &mut CalendarModel, delta: i32) { + let month_index = calendar + .year + .saturating_mul(12) + .saturating_add(i32::try_from(calendar.month).unwrap_or(1) - 1) + .saturating_add(delta); + calendar.year = month_index.div_euclid(12); + calendar.month = u32::try_from(month_index.rem_euclid(12) + 1).unwrap_or(1); + calendar.selected_day = calendar + .selected_day + .min(days_in_month(calendar.year, calendar.month)); +} + +fn days_in_month(year: i32, month: u32) -> u32 { + use chrono::{Datelike, NaiveDate}; + let next = if month == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1) + } else { + NaiveDate::from_ymd_opt(year, month + 1, 1) + }; + next.and_then(|next| next.pred_opt()) + .map_or(28, |last| last.day()) +} + +impl CompositorHandler for App { + fn scale_factor_changed( + &mut self, + _connection: &Connection, + qh: &QueueHandle, + surface: &wl_surface::WlSurface, + new_factor: i32, + ) { + let scale = u32::try_from(new_factor.max(1)).unwrap_or(1); + if let Some(index) = self + .bars + .iter() + .position(|bar| bar.layer.wl_surface() == surface) + { + let bar = &mut self.bars[index]; + bar.scale = scale; + bar.buffer = None; + bar.dirty = true; + surface.set_buffer_scale(new_factor.max(1)); + self.invalidate(index, qh); + } else if let Some(popup) = self + .popup + .as_mut() + .filter(|popup| popup.popup.wl_surface() == surface) + { + popup.scale = scale; + popup.buffer = None; + popup.dirty = true; + surface.set_buffer_scale(new_factor.max(1)); + } + } + + fn transform_changed( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _new_transform: wl_output::Transform, + ) { + } + + fn frame( + &mut self, + _connection: &Connection, + qh: &QueueHandle, + surface: &wl_surface::WlSurface, + _time: u32, + ) { + if let Some(index) = self + .bars + .iter() + .position(|bar| bar.layer.wl_surface() == surface) + { + self.bars[index].frame_pending = false; + self.invalidate(index, qh); + } else if let Some(popup) = self + .popup + .as_mut() + .filter(|popup| popup.popup.wl_surface() == surface) + { + popup.frame_pending = false; + if popup.dirty + && let Err(error) = self.draw_popup(qh) + { + tracing::error!(%error, "popup redraw failed"); + } + } + } + + fn surface_enter( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _output: &wl_output::WlOutput, + ) { + } + + fn surface_leave( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _output: &wl_output::WlOutput, + ) { + } +} + +impl OutputHandler for App { + fn output_state(&mut self) -> &mut OutputState { + &mut self.output_state + } + + fn new_output( + &mut self, + _connection: &Connection, + qh: &QueueHandle, + output: wl_output::WlOutput, + ) { + self.sync_output(output, qh); + } + + fn update_output( + &mut self, + _connection: &Connection, + qh: &QueueHandle, + output: wl_output::WlOutput, + ) { + self.sync_output(output, qh); + } + + fn output_destroyed( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + output: wl_output::WlOutput, + ) { + self.bars.retain(|bar| bar.output != output); + } +} + +impl LayerShellHandler for App { + fn closed(&mut self, _connection: &Connection, _qh: &QueueHandle, layer: &LayerSurface) { + self.bars.retain(|bar| &bar.layer != layer); + } + + fn configure( + &mut self, + _connection: &Connection, + qh: &QueueHandle, + layer: &LayerSurface, + configure: LayerSurfaceConfigure, + _serial: u32, + ) { + let Some(index) = self.bars.iter().position(|bar| &bar.layer == layer) else { + return; + }; + let bar = &mut self.bars[index]; + let width = NonZeroU32::new(configure.new_size.0).map_or(bar.width.max(1), NonZeroU32::get); + let height = NonZeroU32::new(configure.new_size.1) + .map_or(self.bundle.config.bar.height, NonZeroU32::get); + if bar.width != width || bar.height != height { + bar.width = width; + bar.height = height; + bar.buffer = None; + } + bar.configured = true; + bar.dirty = true; + self.invalidate(index, qh); + } +} + +impl PopupHandler for App { + fn configure( + &mut self, + _connection: &Connection, + qh: &QueueHandle, + popup: &Popup, + config: PopupConfigure, + ) { + let Some(active) = self.popup.as_mut().filter(|active| &active.popup == popup) else { + return; + }; + if config.width > 0 && config.height > 0 { + active.width = u32::try_from(config.width).unwrap_or(active.width); + active.height = u32::try_from(config.height).unwrap_or(active.height); + active.buffer = None; + } + active.configured = true; + active.dirty = true; + if let Err(error) = self.draw_popup(qh) { + tracing::error!(%error, "initial popup draw failed"); + } + } + + fn done(&mut self, _connection: &Connection, _qh: &QueueHandle, popup: &Popup) { + if self + .popup + .as_ref() + .is_some_and(|active| &active.popup == popup) + { + self.close_popup(); + } + } +} + +impl SeatHandler for App { + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat_state + } + + fn new_seat( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + seat: wl_seat::WlSeat, + ) { + self.seat.get_or_insert(seat); + } + + fn new_capability( + &mut self, + _connection: &Connection, + qh: &QueueHandle, + seat: wl_seat::WlSeat, + capability: Capability, + ) { + self.seat.get_or_insert_with(|| seat.clone()); + if capability == Capability::Pointer && self.pointer.is_none() { + match self.seat_state.get_pointer(qh, &seat) { + Ok(pointer) => self.pointer = Some(pointer), + Err(error) => tracing::warn!(%error, "could not create Wayland pointer"), + } + } + if capability == Capability::Keyboard && self.keyboard.is_none() { + match self.seat_state.get_keyboard(qh, &seat, None) { + Ok(keyboard) => self.keyboard = Some(keyboard), + Err(error) => tracing::warn!(%error, "could not create Wayland keyboard"), + } + } + } + + fn remove_capability( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _seat: wl_seat::WlSeat, + capability: Capability, + ) { + if capability == Capability::Pointer + && let Some(pointer) = self.pointer.take() + { + pointer.release(); + } + if capability == Capability::Keyboard + && let Some(keyboard) = self.keyboard.take() + { + keyboard.release(); + } + } + + fn remove_seat( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + seat: wl_seat::WlSeat, + ) { + if self.seat.as_ref() == Some(&seat) { + self.seat = None; + } + } +} + +impl PointerHandler for App { + fn pointer_frame( + &mut self, + _connection: &Connection, + qh: &QueueHandle, + _pointer: &wl_pointer::WlPointer, + events: &[PointerEvent], + ) { + for event in events { + if let Some(index) = self + .bars + .iter() + .position(|bar| bar.layer.wl_surface() == &event.surface) + { + let hit = hit_test( + &self.bars[index].hit_boxes, + event.position.0, + event.position.1, + ) + .cloned(); + match event.kind { + PointerEventKind::Enter { .. } | PointerEventKind::Motion { .. } => { + let hovered = hit + .as_ref() + .map(|hit| (hit.module.clone(), hit.segment.clone())); + if self.bars[index].hovered != hovered { + self.bars[index].hovered = hovered; + self.bars[index].dirty = true; + self.invalidate(index, qh); + } + } + PointerEventKind::Leave { .. } => { + if self.bars[index].hovered.take().is_some() { + self.bars[index].dirty = true; + self.invalidate(index, qh); + } + } + PointerEventKind::Press { button, serial, .. } => { + if let (Some(hit), Some(button)) = (hit, pointer_button(button)) { + self.handle_bar_interaction( + index, + hit.clone(), + Interaction::Click { + module: hit.module.clone(), + segment: hit.segment.clone(), + button, + }, + Some(serial), + qh, + ); + } + } + PointerEventKind::Axis { vertical, .. } => { + let amount = if vertical.value120 != 0 { + f64::from(vertical.value120) + } else if vertical.discrete != 0 { + f64::from(vertical.discrete) + } else { + vertical.absolute + }; + if let Some(hit) = hit.filter(|_| amount != 0.0) { + let direction = if amount < 0.0 { + ScrollDirection::Up + } else { + ScrollDirection::Down + }; + self.handle_bar_interaction( + index, + hit.clone(), + Interaction::Scroll { + module: hit.module.clone(), + segment: hit.segment.clone(), + direction, + }, + None, + qh, + ); + } + } + PointerEventKind::Release { .. } => {} + } + } else if let Some(popup) = self + .popup + .as_ref() + .filter(|popup| popup.popup.wl_surface() == &event.surface) + { + let hit = hit_test(&popup.hit_boxes, event.position.0, event.position.1).cloned(); + match event.kind { + PointerEventKind::Press { + button: BTN_LEFT, .. + } => { + if let Some(hit) = hit { + self.handle_popup_click(hit, event.position.0, qh); + } + } + PointerEventKind::Axis { vertical, .. } + if matches!(popup.model.content, PopupContent::Audio(_)) => + { + let amount = if vertical.value120 != 0 { + f64::from(vertical.value120) + } else if vertical.discrete != 0 { + f64::from(vertical.discrete) + } else { + vertical.absolute + }; + if amount != 0.0 { + if let Some(hit) = hit.filter(|hit| { + hit.segment + .as_deref() + .is_some_and(|id| id.starts_with("audio-volume:")) + }) { + if let Some(popup) = self.popup.as_mut() + && let PopupContent::Audio(audio) = &mut popup.model.content + { + audio.focused = hit.segment; + } + self.handle_audio_key( + if amount < 0.0 { + Keysym::Right + } else { + Keysym::Left + }, + qh, + ); + } else { + let hit = HitBox { + rect: crate::render::Rect::default(), + module: popup.module.clone(), + segment: Some( + if amount < 0.0 { + "audio-scroll-up" + } else { + "audio-scroll-down" + } + .to_owned(), + ), + }; + self.handle_audio_control(&hit, 0.0, qh); + } + } + } + _ => {} + } + } + } + } +} + +impl KeyboardHandler for App { + fn enter( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _surface: &wl_surface::WlSurface, + _serial: u32, + _raw: &[u32], + _keysyms: &[Keysym], + ) { + } + + fn leave( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _surface: &wl_surface::WlSurface, + _serial: u32, + ) { + } + + fn press_key( + &mut self, + _connection: &Connection, + qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _serial: u32, + event: KeyEvent, + ) { + if event.keysym == Keysym::Escape { + self.close_popup(); + } else if self.handle_audio_key(event.keysym, qh) { + } else if event.keysym == Keysym::Left { + self.change_calendar_month(-1, qh); + } else if event.keysym == Keysym::Right { + self.change_calendar_month(1, qh); + } + } + + fn repeat_key( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _serial: u32, + _event: KeyEvent, + ) { + } + + fn release_key( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _serial: u32, + _event: KeyEvent, + ) { + } + + fn update_modifiers( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _keyboard: &wl_keyboard::WlKeyboard, + _serial: u32, + _modifiers: Modifiers, + _raw_modifiers: RawModifiers, + _layout: u32, + ) { + } +} + +impl ShmHandler for App { + fn shm_state(&mut self) -> &mut Shm { + &mut self.shm + } +} + +// XdgShell also binds the optional decoration manager. Lightbar never creates an +// xdg-toplevel, but SCTK requires the handler so popup and toplevel dispatch +// share one correctly configured shell state. +impl WindowHandler for App { + fn request_close( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _window: &Window, + ) { + } + + fn configure( + &mut self, + _connection: &Connection, + _qh: &QueueHandle, + _window: &Window, + _configure: WindowConfigure, + _serial: u32, + ) { + } +} + +fn pointer_button(button: u32) -> Option { + match button { + BTN_LEFT => Some(PointerButton::Left), + BTN_MIDDLE => Some(PointerButton::Middle), + BTN_RIGHT => Some(PointerButton::Right), + _ => None, + } +} + +delegate_registry!(App); + +impl ProvidesRegistryState for App { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry_state + } + + registry_handlers![OutputState, SeatState]; +} + +smithay_client_toolkit::delegate_dispatch2!(App); + +#[cfg(test)] +mod tests { + use super::*; + use notify::event::{ + AccessKind, AccessMode, CreateKind, DataChange, EventKind, MetadataKind, ModifyKind, + }; + + #[test] + fn quotes_sway_arguments() { + assert_eq!(quote_sway_arg("dev"), "\"dev\""); + assert_eq!(quote_sway_arg("a\"b\\c"), "\"a\\\"b\\\\c\""); + } + + #[test] + fn shifts_calendar_across_years() { + let mut calendar = CalendarModel { + year: 2025, + month: 12, + selected_day: 31, + }; + shift_calendar(&mut calendar, 2); + assert_eq!( + (calendar.year, calendar.month, calendar.selected_day), + (2026, 2, 28) + ); + } + + #[test] + fn config_watch_ignores_reads_and_unrelated_files() { + let config = PathBuf::from("/tmp/lightbar/config.toml"); + let files = BTreeSet::from([config.clone()]); + let opened = notify::Event::new(EventKind::Access(AccessKind::Open(AccessMode::Read))) + .add_path(config.clone()); + let atime = notify::Event::new(EventKind::Modify(ModifyKind::Metadata( + MetadataKind::AccessTime, + ))) + .add_path(config.clone()); + let unrelated = notify::Event::new(EventKind::Create(CreateKind::File)) + .add_path(PathBuf::from("/tmp/lightbar/bar.conf")); + + assert!(!event_requires_reload(&opened, &files)); + assert!(!event_requires_reload(&atime, &files)); + assert!(!event_requires_reload(&unrelated, &files)); + } + + #[test] + fn config_watch_accepts_writes_to_watched_files() { + let config = PathBuf::from("/tmp/lightbar/config.toml"); + let files = BTreeSet::from([config.clone()]); + let modified = notify::Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))) + .add_path(config.clone()); + let closed = notify::Event::new(EventKind::Access(AccessKind::Close(AccessMode::Write))) + .add_path(config); + + assert!(event_requires_reload(&modified, &files)); + assert!(event_requires_reload(&closed, &files)); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..aa60dea --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Clone, Parser)] +#[command(name = "lightbar", version, about)] +pub struct Cli { + /// Sway bar identifier. Sway appends this argument to `swaybar_command`. + #[arg(short = 'b', long, global = true)] + pub bar_id: Option, + + /// Override the default `$XDG_CONFIG_HOME/lightbar/config.toml` path. + #[arg(short, long, global = true, value_name = "FILE")] + pub config: Option, + + /// Logging verbosity. `LIGHTBAR_LOG` and `RUST_LOG` are also recognized. + #[arg(long, global = true, value_enum, default_value_t = LogLevel::Info)] + pub log_level: LogLevel, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum Command { + /// Parse and validate the configuration without opening a Wayland surface. + CheckConfig, + /// Check the local environment and report actionable problems. + Doctor, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl std::fmt::Display for LogLevel { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + Self::Trace => "trace", + }) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..5d6cfdb --- /dev/null +++ b/src/config.rs @@ -0,0 +1,1060 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + env, fmt, fs, + path::{Path, PathBuf}, + str::FromStr, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Deserializer}; + +use crate::APP_NAME; + +pub const CONFIG_VERSION: u32 = 1; + +#[derive(Debug, Clone)] +pub struct ConfigBundle { + pub config_path: PathBuf, + pub theme_path: PathBuf, + pub config: Config, + pub theme: Theme, +} + +impl ConfigBundle { + pub fn load(path: Option<&Path>) -> Result { + let config_path = path.map_or_else(default_config_path, Path::to_path_buf); + let source = fs::read_to_string(&config_path) + .with_context(|| format!("could not read config {}", config_path.display()))?; + let raw: RawConfig = toml::from_str(&source) + .with_context(|| format!("invalid TOML in {}", config_path.display()))?; + let config = Config::try_from(raw) + .with_context(|| format!("invalid configuration in {}", config_path.display()))?; + + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); + let theme_path = if config.theme.is_absolute() { + config.theme.clone() + } else { + config_dir.join(&config.theme) + }; + let theme_source = fs::read_to_string(&theme_path) + .with_context(|| format!("could not read theme {}", theme_path.display()))?; + let theme: Theme = toml::from_str(&theme_source) + .with_context(|| format!("invalid theme TOML in {}", theme_path.display()))?; + theme + .validate() + .with_context(|| format!("invalid theme in {}", theme_path.display()))?; + + Ok(Self { + config_path, + theme_path, + config, + theme, + }) + } +} + +pub fn default_config_path() -> PathBuf { + if let Some(path) = env::var_os("XDG_CONFIG_HOME") { + return PathBuf::from(path).join(APP_NAME).join("config.toml"); + } + env::var_os("HOME").map_or_else( + || PathBuf::from("config.toml"), + |home| { + PathBuf::from(home) + .join(".config") + .join(APP_NAME) + .join("config.toml") + }, + ) +} + +#[derive(Debug, Clone)] +pub struct Config { + pub version: u32, + pub theme: PathBuf, + pub bar: BarConfig, + pub layout: LayoutConfig, + pub modules: BTreeMap, +} + +impl TryFrom for Config { + type Error = anyhow::Error; + + fn try_from(raw: RawConfig) -> Result { + if raw.version != CONFIG_VERSION { + bail!( + "unsupported config version {}; expected {CONFIG_VERSION}", + raw.version + ); + } + raw.bar.validate()?; + + let mut modules = BTreeMap::new(); + for (name, value) in raw.modules { + validate_identifier(&name)?; + let module = parse_module(&name, value)?; + module + .validate() + .with_context(|| format!("invalid module {name:?}"))?; + modules.insert(name, module); + } + + let mut seen = BTreeSet::new(); + for name in raw.layout.iter() { + if !modules.contains_key(name) { + bail!("layout references unknown module {name:?}"); + } + if !seen.insert(name) { + bail!("module {name:?} appears more than once in the layout"); + } + } + + if modules.is_empty() { + bail!("at least one module must be configured"); + } + + Ok(Self { + version: raw.version, + theme: raw.theme, + bar: raw.bar, + layout: raw.layout, + modules, + }) + } +} + +fn validate_identifier(name: &str) -> Result<()> { + let valid = !name.is_empty() + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')); + if !valid { + bail!("invalid module name {name:?}; use ASCII letters, digits, '-' or '_'"); + } + Ok(()) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawConfig { + version: u32, + theme: PathBuf, + bar: BarConfig, + layout: LayoutConfig, + modules: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct BarConfig { + pub position: Position, + pub height: u32, + pub layer: Layer, + pub outputs: Vec, + pub namespace: String, + pub popup_width: u32, + pub popup_max_height: u32, +} + +impl Default for BarConfig { + fn default() -> Self { + Self { + position: Position::Top, + height: 24, + layer: Layer::Top, + outputs: vec!["*".to_owned()], + namespace: APP_NAME.to_owned(), + popup_width: 320, + popup_max_height: 480, + } + } +} + +impl BarConfig { + fn validate(&self) -> Result<()> { + if !(12..=256).contains(&self.height) { + bail!("bar.height must be between 12 and 256 logical pixels"); + } + if self.outputs.is_empty() { + bail!("bar.outputs must contain '*' or at least one output name"); + } + if self.outputs.iter().any(|output| output.trim().is_empty()) { + bail!("bar.outputs cannot contain an empty output name"); + } + if !(160..=1_920).contains(&self.popup_width) { + bail!("bar.popup_width must be between 160 and 1920 logical pixels"); + } + if !(80..=2_160).contains(&self.popup_max_height) { + bail!("bar.popup_max_height must be between 80 and 2160 logical pixels"); + } + validate_identifier(&self.namespace)?; + Ok(()) + } + + pub fn output_enabled(&self, output: &str) -> bool { + self.outputs + .iter() + .any(|candidate| candidate == "*" || candidate == output) + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Position { + Top, + Bottom, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Layer { + Background, + Bottom, + Top, + Overlay, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LayoutConfig { + pub left: Vec, + pub center: Vec, + pub right: Vec, +} + +impl LayoutConfig { + pub fn iter(&self) -> impl Iterator { + self.left + .iter() + .chain(&self.center) + .chain(&self.right) + .map(String::as_str) + } +} + +#[derive(Debug, Clone)] +pub enum ModuleConfig { + Workspaces(WorkspacesModule), + Mode(ModeModule), + Scratchpad(ScratchpadModule), + Network(NetworkModule), + Temperature(TemperatureModule), + Backlight(BacklightModule), + Audio(AudioModule), + Battery(BatteryModule), + Clock(ClockModule), + Command(CommandModule), + Tray(TrayModule), +} + +impl ModuleConfig { + pub fn common(&self) -> &ModuleCommon { + match self { + Self::Workspaces(module) => &module.common, + Self::Mode(module) => &module.common, + Self::Scratchpad(module) => &module.common, + Self::Network(module) => &module.common, + Self::Temperature(module) => &module.common, + Self::Backlight(module) => &module.common, + Self::Audio(module) => &module.common, + Self::Battery(module) => &module.common, + Self::Clock(module) => &module.common, + Self::Command(module) => &module.common, + Self::Tray(module) => &module.common, + } + } + + fn validate(&self) -> Result<()> { + let common = self.common(); + for action in [ + &common.actions.left, + &common.actions.middle, + &common.actions.right, + &common.actions.scroll_up, + &common.actions.scroll_down, + ] + .into_iter() + .flatten() + { + action.validate()?; + } + + match self { + Self::Temperature(module) => { + validate_interval(module.interval, "interval")?; + if module.warning_c >= module.critical_c { + bail!("warning_c must be lower than critical_c"); + } + } + Self::Backlight(module) => validate_step(module.step_percent)?, + Self::Audio(module) => validate_step(module.step_percent)?, + Self::Battery(module) => { + if module.critical_percent > module.warning_percent || module.warning_percent > 100 + { + bail!( + "battery percentages must satisfy critical_percent <= warning_percent <= 100" + ); + } + } + Self::Clock(module) => validate_interval(module.interval, "interval")?, + Self::Command(module) => { + validate_interval(module.interval, "interval")?; + validate_interval(module.timeout, "timeout")?; + match (module.argv.is_empty(), module.shell.as_deref()) { + (false, None) if !module.argv[0].trim().is_empty() => {} + (true, Some(shell)) if !shell.trim().is_empty() => {} + (false, Some(_)) => bail!("set either argv or shell, not both"), + _ => bail!("command module requires a non-empty argv or shell"), + } + if !(1..=1_048_576).contains(&module.max_output_bytes) { + bail!("max_output_bytes must be between 1 and 1048576"); + } + } + Self::Tray(module) => { + if !(8..=256).contains(&module.icon_size) { + bail!("tray icon_size must be between 8 and 256"); + } + if !(1..=32).contains(&module.menu_max_depth) { + bail!("tray menu_max_depth must be between 1 and 32"); + } + if !(1..=4096).contains(&module.menu_max_items) { + bail!("tray menu_max_items must be between 1 and 4096"); + } + if !(1024..=64 * 1024 * 1024).contains(&module.pixmap_max_bytes) { + bail!("tray pixmap_max_bytes must be between 1024 and 67108864"); + } + } + Self::Workspaces(_) | Self::Mode(_) | Self::Scratchpad(_) | Self::Network(_) => {} + } + Ok(()) + } +} + +fn validate_step(step: u8) -> Result<()> { + if !(1..=100).contains(&step) { + bail!("step_percent must be between 1 and 100"); + } + Ok(()) +} + +fn validate_interval(duration: HumanDuration, field: &str) -> Result<()> { + if duration.0 > Duration::from_secs(24 * 60 * 60) { + bail!("{field} cannot exceed 24h"); + } + Ok(()) +} + +fn parse_module(name: &str, value: toml::Value) -> Result { + let kind = value + .as_table() + .and_then(|table| table.get("kind")) + .and_then(toml::Value::as_str) + .ok_or_else(|| anyhow!("module {name:?} must contain a string 'kind'"))? + .to_owned(); + + macro_rules! decode { + ($type:ty, $variant:ident) => {{ + let decoded: $type = value + .try_into() + .with_context(|| format!("invalid {kind} module {name:?}"))?; + ModuleConfig::$variant(decoded) + }}; + } + + let module = match kind.as_str() { + "workspaces" => decode!(WorkspacesModule, Workspaces), + "mode" => decode!(ModeModule, Mode), + "scratchpad" => decode!(ScratchpadModule, Scratchpad), + "network" => decode!(NetworkModule, Network), + "temperature" => decode!(TemperatureModule, Temperature), + "backlight" => decode!(BacklightModule, Backlight), + "audio" => decode!(AudioModule, Audio), + "battery" => decode!(BatteryModule, Battery), + "clock" => decode!(ClockModule, Clock), + "command" => decode!(CommandModule, Command), + "tray" => decode!(TrayModule, Tray), + other => bail!("module {name:?} has unsupported kind {other:?}"), + }; + Ok(module) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ModuleCommon { + pub enabled: bool, + pub unavailable: UnavailableBehavior, + pub style: StyleOverride, + pub actions: ActionBindings, +} + +impl Default for ModuleCommon { + fn default() -> Self { + Self { + enabled: true, + unavailable: UnavailableBehavior::Hide, + style: StyleOverride::default(), + actions: ActionBindings::default(), + } + } +} + +#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum UnavailableBehavior { + #[default] + Hide, + Placeholder, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ActionBindings { + pub left: Option, + pub middle: Option, + pub right: Option, + pub scroll_up: Option, + pub scroll_down: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +pub enum Action { + Builtin { name: BuiltinAction }, + Sway { command: String }, + Exec { argv: Vec }, + Shell { command: String }, +} + +impl Action { + pub fn validate(&self) -> Result<()> { + match self { + Self::Builtin { .. } => Ok(()), + Self::Sway { command } | Self::Shell { command } if command.trim().is_empty() => { + bail!("action command cannot be empty") + } + Self::Exec { argv } if argv.is_empty() || argv[0].trim().is_empty() => { + bail!("exec action argv must start with a program") + } + _ => Ok(()), + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BuiltinAction { + TogglePopover, + ClosePopover, + ToggleMute, + VolumeUp, + VolumeDown, + BrightnessUp, + BrightnessDown, + PreviousMonth, + NextMonth, +} + +macro_rules! simple_module { + ($name:ident, $expected_kind:literal, $default_format:literal) => { + #[derive(Debug, Clone, Deserialize)] + #[serde(default, deny_unknown_fields)] + pub struct $name { + pub kind: String, + pub format: String, + pub common: ModuleCommon, + } + + impl Default for $name { + fn default() -> Self { + Self { + kind: $expected_kind.to_owned(), + format: $default_format.to_owned(), + common: ModuleCommon::default(), + } + } + } + }; +} + +simple_module!(WorkspacesModule, "workspaces", "{index}"); +simple_module!(ModeModule, "mode", "{mode}"); + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ScratchpadModule { + pub kind: String, + pub format: String, + pub empty_format: String, + pub common: ModuleCommon, +} + +impl Default for ScratchpadModule { + fn default() -> Self { + Self { + kind: "scratchpad".to_owned(), + format: "^{count}".to_owned(), + empty_format: "^0".to_owned(), + common: ModuleCommon::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct NetworkModule { + pub kind: String, + pub format_connected: String, + pub format_disconnected: String, + pub interface: Option, + pub common: ModuleCommon, +} + +impl Default for NetworkModule { + fn default() -> Self { + Self { + kind: "network".to_owned(), + format_connected: " {ssid}".to_owned(), + format_disconnected: "睊 Disconnected".to_owned(), + interface: None, + common: ModuleCommon::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TemperatureModule { + pub kind: String, + pub format: String, + pub sensor: Option, + pub label: Option, + pub interval: HumanDuration, + pub warning_c: f64, + pub critical_c: f64, + pub common: ModuleCommon, +} + +impl Default for TemperatureModule { + fn default() -> Self { + Self { + kind: "temperature".to_owned(), + format: " {temperature_c:.0}°C".to_owned(), + sensor: None, + label: None, + interval: HumanDuration(Duration::from_secs(30)), + warning_c: 75.0, + critical_c: 90.0, + common: ModuleCommon::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct BacklightModule { + pub kind: String, + pub format: String, + pub device: Option, + pub step_percent: u8, + pub common: ModuleCommon, +} + +impl Default for BacklightModule { + fn default() -> Self { + Self { + kind: "backlight".to_owned(), + format: " {percent}%".to_owned(), + device: None, + step_percent: 5, + common: ModuleCommon::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AudioModule { + pub kind: String, + pub format: String, + pub muted_format: String, + pub microphone_format: String, + pub microphone_muted_format: String, + pub step_percent: u8, + pub common: ModuleCommon, +} + +impl Default for AudioModule { + fn default() -> Self { + Self { + kind: "audio".to_owned(), + format: " {volume}%".to_owned(), + muted_format: "婢 Mute".to_owned(), + microphone_format: " {volume}%".to_owned(), + microphone_muted_format: " Mute".to_owned(), + step_percent: 5, + common: ModuleCommon::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct BatteryModule { + pub kind: String, + pub format: String, + pub full_format: String, + pub warning_percent: u8, + pub critical_percent: u8, + pub common: ModuleCommon, +} + +impl Default for BatteryModule { + fn default() -> Self { + Self { + kind: "battery".to_owned(), + format: "{icon} {percent}%".to_owned(), + full_format: " {percent}%".to_owned(), + warning_percent: 20, + critical_percent: 10, + common: ModuleCommon::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ClockModule { + pub kind: String, + pub format: String, + pub interval: HumanDuration, + pub common: ModuleCommon, +} + +impl Default for ClockModule { + fn default() -> Self { + Self { + kind: "clock".to_owned(), + format: "%a, %d. %b %H:%M:%S".to_owned(), + interval: HumanDuration(Duration::from_secs(1)), + common: ModuleCommon::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct CommandModule { + pub kind: String, + pub mode: CommandMode, + pub argv: Vec, + pub shell: Option, + pub interval: HumanDuration, + pub timeout: HumanDuration, + pub max_output_bytes: usize, + pub common: ModuleCommon, +} + +impl Default for CommandModule { + fn default() -> Self { + Self { + kind: "command".to_owned(), + mode: CommandMode::Interval, + argv: Vec::new(), + shell: None, + interval: HumanDuration(Duration::from_secs(30)), + timeout: HumanDuration(Duration::from_secs(2)), + max_output_bytes: 65_536, + common: ModuleCommon::default(), + } + } +} + +#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CommandMode { + #[default] + Interval, + Stream, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TrayModule { + pub kind: String, + pub icon_size: u32, + pub spacing: u32, + pub menu_max_depth: usize, + pub menu_max_items: usize, + pub pixmap_max_bytes: usize, + pub common: ModuleCommon, +} + +impl Default for TrayModule { + fn default() -> Self { + Self { + kind: "tray".to_owned(), + icon_size: 16, + spacing: 12, + menu_max_depth: 8, + menu_max_items: 512, + pixmap_max_bytes: 4 * 1024 * 1024, + common: ModuleCommon::default(), + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub struct HumanDuration(pub Duration); + +impl<'de> Deserialize<'de> for HumanDuration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + parse_duration(&value) + .map(Self) + .map_err(serde::de::Error::custom) + } +} + +fn parse_duration(value: &str) -> Result { + let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") { + (number, 1_u64) + } else if let Some(number) = value.strip_suffix('s') { + (number, 1_000) + } else if let Some(number) = value.strip_suffix('m') { + (number, 60_000) + } else if let Some(number) = value.strip_suffix('h') { + (number, 3_600_000) + } else { + bail!("duration {value:?} must end in ms, s, m, or h"); + }; + let count: u64 = number + .parse() + .with_context(|| format!("invalid duration number in {value:?}"))?; + let millis = count + .checked_mul(multiplier) + .ok_or_else(|| anyhow!("duration {value:?} is too large"))?; + if millis == 0 { + bail!("duration must be greater than zero"); + } + Ok(Duration::from_millis(millis)) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Theme { + pub font: FontTheme, + pub bar: SurfaceTheme, + pub popup: SurfaceTheme, + pub module: Style, + pub states: BTreeMap, + pub modules: BTreeMap, +} + +impl Default for Theme { + fn default() -> Self { + let mut states = BTreeMap::new(); + states.insert( + "focused".to_owned(), + StyleOverride { + foreground: Some(Rgba::BLACK), + background: Some(Rgba::rgb(0x7c, 0xaf, 0xc2)), + border: Some(Rgba::rgb(0xdd, 0xdd, 0xdd)), + ..StyleOverride::default() + }, + ); + states.insert( + "urgent".to_owned(), + StyleOverride { + foreground: Some(Rgba::rgb(0x22, 0x22, 0x22)), + background: Some(Rgba::rgb(0xab, 0x46, 0x42)), + border: Some(Rgba::rgb(0xab, 0x46, 0x42)), + ..StyleOverride::default() + }, + ); + states.insert( + "critical".to_owned(), + StyleOverride { + foreground: Some(Rgba::rgb(0xff, 0x55, 0x55)), + ..StyleOverride::default() + }, + ); + Self { + font: FontTheme::default(), + bar: SurfaceTheme::default(), + popup: SurfaceTheme { + background: Rgba::rgb(0x22, 0x22, 0x22), + border: Rgba::rgb(0x55, 0x55, 0x55), + border_width: 1, + padding: 10, + }, + module: Style::default(), + states, + modules: BTreeMap::new(), + } + } +} + +impl Theme { + fn validate(&self) -> Result<()> { + if self.font.family.trim().is_empty() { + bail!("font.family cannot be empty"); + } + if !(6.0..=96.0).contains(&self.font.size) { + bail!("font.size must be between 6 and 96 logical pixels"); + } + if self + .font + .fallbacks + .iter() + .any(|font| font.trim().is_empty()) + { + bail!("font.fallbacks cannot contain an empty family"); + } + Ok(()) + } + + pub fn style_for(&self, module: &str, state: &str, local: &StyleOverride) -> Style { + let mut style = self.module.clone(); + if let Some(state_style) = self.states.get(state) { + style.apply(state_style); + } + if let Some(module_style) = self.modules.get(module) { + style.apply(module_style); + } + style.apply(local); + style + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct FontTheme { + pub family: String, + pub fallbacks: Vec, + pub size: f32, +} + +impl Default for FontTheme { + fn default() -> Self { + Self { + family: "UbuntuMono Nerd Font".to_owned(), + fallbacks: vec!["Noto Sans".to_owned(), "Noto Color Emoji".to_owned()], + size: 12.0, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SurfaceTheme { + pub background: Rgba, + pub border: Rgba, + pub border_width: u32, + pub padding: u32, +} + +impl Default for SurfaceTheme { + fn default() -> Self { + Self { + background: Rgba::rgb(0x11, 0x11, 0x11), + border: Rgba::TRANSPARENT, + border_width: 0, + padding: 0, + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Style { + pub foreground: Rgba, + pub background: Rgba, + pub border: Rgba, + pub border_width: u32, + pub padding_x: u32, + pub padding_y: u32, + pub margin_x: u32, + pub min_width: u32, +} + +impl Default for Style { + fn default() -> Self { + Self { + foreground: Rgba::rgb(0xdd, 0xdd, 0xdd), + background: Rgba::TRANSPARENT, + border: Rgba::TRANSPARENT, + border_width: 0, + padding_x: 8, + padding_y: 0, + margin_x: 0, + min_width: 1, + } + } +} + +impl Style { + fn apply(&mut self, over: &StyleOverride) { + if let Some(value) = over.foreground { + self.foreground = value; + } + if let Some(value) = over.background { + self.background = value; + } + if let Some(value) = over.border { + self.border = value; + } + if let Some(value) = over.border_width { + self.border_width = value; + } + if let Some(value) = over.padding_x { + self.padding_x = value; + } + if let Some(value) = over.padding_y { + self.padding_y = value; + } + if let Some(value) = over.margin_x { + self.margin_x = value; + } + if let Some(value) = over.min_width { + self.min_width = value; + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct StyleOverride { + pub foreground: Option, + pub background: Option, + pub border: Option, + pub border_width: Option, + pub padding_x: Option, + pub padding_y: Option, + pub margin_x: Option, + pub min_width: Option, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub struct Rgba { + pub red: u8, + pub green: u8, + pub blue: u8, + pub alpha: u8, +} + +impl Rgba { + pub const BLACK: Self = Self::rgb(0, 0, 0); + pub const TRANSPARENT: Self = Self::rgba(0, 0, 0, 0); + + pub const fn rgb(red: u8, green: u8, blue: u8) -> Self { + Self::rgba(red, green, blue, 0xff) + } + + #[allow(clippy::self_named_constructors)] + pub const fn rgba(red: u8, green: u8, blue: u8, alpha: u8) -> Self { + Self { + red, + green, + blue, + alpha, + } + } + + pub fn as_premultiplied_bgra(self) -> [u8; 4] { + let alpha = u16::from(self.alpha); + let premultiply = |channel: u8| ((u16::from(channel) * alpha + 127) / 255) as u8; + [ + premultiply(self.blue), + premultiply(self.green), + premultiply(self.red), + self.alpha, + ] + } +} + +impl Default for Rgba { + fn default() -> Self { + Self::TRANSPARENT + } +} + +impl FromStr for Rgba { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let hex = value + .strip_prefix('#') + .ok_or_else(|| anyhow!("color {value:?} must begin with '#'"))?; + if !matches!(hex.len(), 6 | 8) { + bail!("color {value:?} must use #RRGGBB or #RRGGBBAA"); + } + let parse = |range: std::ops::Range| { + u8::from_str_radix(&hex[range], 16) + .with_context(|| format!("invalid hexadecimal color {value:?}")) + }; + Ok(Self { + red: parse(0..2)?, + green: parse(2..4)?, + blue: parse(4..6)?, + alpha: if hex.len() == 8 { parse(6..8)? } else { 0xff }, + }) + } +} + +impl<'de> Deserialize<'de> for Rgba { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for Rgba { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "#{:02x}{:02x}{:02x}{:02x}", + self.red, self.green, self.blue, self.alpha + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_rgb_and_rgba() { + assert_eq!( + "#123456".parse::().unwrap(), + Rgba::rgba(0x12, 0x34, 0x56, 0xff) + ); + assert_eq!( + "#12345678".parse::().unwrap(), + Rgba::rgba(0x12, 0x34, 0x56, 0x78) + ); + } + + #[test] + fn rejects_invalid_color() { + assert!("123456".parse::().is_err()); + assert!("#abcd".parse::().is_err()); + assert!("#xxxxxx".parse::().is_err()); + } + + #[test] + fn parses_durations() { + assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250)); + assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2)); + assert_eq!(parse_duration("3m").unwrap(), Duration::from_secs(180)); + assert!(parse_duration("0s").is_err()); + assert!(parse_duration("10").is_err()); + } +} diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..d9b269e --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,290 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, +}; + +use crate::config::ConfigBundle; + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum CheckStatus { + Pass, + Warn, + Fail, +} + +#[derive(Debug, Clone)] +pub struct Check { + pub status: CheckStatus, + pub name: &'static str, + pub detail: String, +} + +impl Check { + fn pass(name: &'static str, detail: impl Into) -> Self { + Self { + status: CheckStatus::Pass, + name, + detail: detail.into(), + } + } + + fn warn(name: &'static str, detail: impl Into) -> Self { + Self { + status: CheckStatus::Warn, + name, + detail: detail.into(), + } + } + + fn fail(name: &'static str, detail: impl Into) -> Self { + Self { + status: CheckStatus::Fail, + name, + detail: detail.into(), + } + } +} + +pub fn run(config_path: Option<&Path>) -> Vec { + let mut checks = Vec::new(); + let bundle = match ConfigBundle::load(config_path) { + Ok(bundle) => { + checks.push(Check::pass( + "configuration", + format!("{} is valid", bundle.config_path.display()), + )); + Some(bundle) + } + Err(error) => { + checks.push(Check::fail("configuration", format!("{error:#}"))); + None + } + }; + + checks.push(env_check("Wayland display", "WAYLAND_DISPLAY")); + checks.push(env_check_any("Sway IPC", &["SWAYSOCK", "I3SOCK"])); + checks.push(command_check("Sway", "sway", &["--version"], true)); + checks.push(command_check( + "Network manager UI", + "nm-connection-editor", + &[], + false, + )); + checks.push(command_check("Audio control UI", "pavucontrol", &[], false)); + checks.push(command_check("PipeWire control", "wpctl", &[], false)); + checks.push(command_check( + "PipeWire audio events", + "pw-dump", + &[], + false, + )); + checks.push(dbus_service_check( + "NetworkManager D-Bus", + "system", + "org.freedesktop.NetworkManager", + false, + )); + checks.push(dbus_service_check( + "UPower D-Bus", + "system", + "org.freedesktop.UPower", + false, + )); + checks.push(dbus_service_check( + "StatusNotifier watcher", + "user", + "org.kde.StatusNotifierWatcher", + false, + )); + + if let Some(bundle) = bundle { + checks.push(font_check(&bundle.theme.font.family)); + } + + checks.push(path_group_check( + "battery devices", + Path::new("/sys/class/power_supply"), + false, + )); + checks.push(path_group_check( + "backlight devices", + Path::new("/sys/class/backlight"), + false, + )); + checks.push(path_group_check( + "temperature sensors", + Path::new("/sys/class/hwmon"), + false, + )); + checks +} + +fn env_check(name: &'static str, variable: &'static str) -> Check { + match env::var_os(variable) { + Some(value) if !value.is_empty() => { + Check::pass(name, format!("{variable}={}", value.to_string_lossy())) + } + _ => Check::fail(name, format!("{variable} is not set")), + } +} + +fn env_check_any(name: &'static str, variables: &[&'static str]) -> Check { + variables + .iter() + .find_map(|variable| { + env::var_os(variable) + .filter(|value| !value.is_empty()) + .map(|value| Check::pass(name, format!("{variable}={}", value.to_string_lossy()))) + }) + .unwrap_or_else(|| Check::fail(name, format!("none of {} is set", variables.join(", ")))) +} + +fn command_check( + name: &'static str, + command: &'static str, + args: &[&str], + required: bool, +) -> Check { + let Some(path) = find_command(command) else { + return if required { + Check::fail(name, format!("{command} was not found in PATH")) + } else { + Check::warn( + name, + format!("optional command {command} was not found in PATH"), + ) + }; + }; + + if args.is_empty() { + return Check::pass(name, path.display().to_string()); + } + match Command::new(&path).args(args).output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + Check::pass( + name, + if version.is_empty() { + path.display().to_string() + } else { + version + }, + ) + } + Ok(output) => Check::warn( + name, + format!("{} exited with {}", path.display(), output.status), + ), + Err(error) => Check::warn(name, format!("could not run {}: {error}", path.display())), + } +} + +fn find_command(command: &str) -> Option { + let paths = env::var_os("PATH")?; + env::split_paths(&paths) + .map(|path| path.join(command)) + .find(|candidate| candidate.is_file()) +} + +fn dbus_service_check( + name: &'static str, + bus: &'static str, + service: &'static str, + required: bool, +) -> Check { + let Some(busctl) = find_command("busctl") else { + return Check::warn(name, "busctl is unavailable; service cannot be checked"); + }; + let bus_flag = if bus == "system" { + "--system" + } else { + "--user" + }; + match Command::new(busctl) + .args([bus_flag, "--no-pager", "status", service]) + .output() + { + Ok(output) if output.status.success() => Check::pass(name, format!("{service} is active")), + Ok(_) if required => Check::fail(name, format!("{service} has no owner on the {bus} bus")), + Ok(_) => Check::warn( + name, + if service == "org.kde.StatusNotifierWatcher" { + "no watcher is active; Lightbar will provide one".to_owned() + } else { + format!("{service} has no owner on the {bus} bus") + }, + ), + Err(error) => Check::warn(name, format!("could not query {service}: {error}")), + } +} + +fn font_check(family: &str) -> Check { + let Some(fc_match) = find_command("fc-match") else { + return Check::warn( + "configured font", + "fc-match is unavailable; font cannot be verified", + ); + }; + match Command::new(fc_match) + .args(["-f", "%{family}\n", family]) + .output() + { + Ok(output) if output.status.success() => { + let matched = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or_default() + .trim() + .to_owned(); + if matched.eq_ignore_ascii_case(family) { + Check::pass("configured font", format!("found {matched}")) + } else { + Check::warn( + "configured font", + format!("requested {family:?}, but fontconfig selected {matched:?}"), + ) + } + } + Ok(output) => Check::warn( + "configured font", + format!("fc-match exited with {}", output.status), + ), + Err(error) => Check::warn( + "configured font", + format!("could not run fc-match: {error}"), + ), + } +} + +fn path_group_check(name: &'static str, path: &Path, required: bool) -> Check { + match fs::read_dir(path) { + Ok(entries) => { + let count = entries.filter_map(Result::ok).count(); + if count == 0 { + Check::warn(name, format!("{} exists but is empty", path.display())) + } else { + Check::pass(name, format!("found {count} entries in {}", path.display())) + } + } + Err(error) if required => { + Check::fail(name, format!("cannot inspect {}: {error}", path.display())) + } + Err(error) => Check::warn(name, format!("cannot inspect {}: {error}", path.display())), + } +} + +pub fn print(checks: &[Check]) { + for check in checks { + let marker = match check.status { + CheckStatus::Pass => "PASS", + CheckStatus::Warn => "WARN", + CheckStatus::Fail => "FAIL", + }; + println!("{marker:4} {:20} {}", check.name, check.detail); + } +} + +pub fn has_failures(checks: &[Check]) -> bool { + checks.iter().any(|check| check.status == CheckStatus::Fail) +} diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..245dc94 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,131 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Text(String), + Integer(i64), + Number(f64), + Boolean(bool), +} + +impl From<&str> for Value { + fn from(value: &str) -> Self { + Self::Text(value.to_owned()) + } +} + +impl From for Value { + fn from(value: String) -> Self { + Self::Text(value) + } +} + +impl From for Value { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} + +impl From for Value { + fn from(value: u8) -> Self { + Self::Integer(i64::from(value)) + } +} + +impl From for Value { + fn from(value: f64) -> Self { + Self::Number(value) + } +} + +impl From for Value { + fn from(value: bool) -> Self { + Self::Boolean(value) + } +} + +pub fn expand(template: &str, values: &BTreeMap<&str, Value>) -> String { + let mut rendered = String::with_capacity(template.len()); + let mut chars = template.char_indices().peekable(); + while let Some((_, character)) = chars.next() { + match character { + '{' if chars.peek().is_some_and(|(_, next)| *next == '{') => { + chars.next(); + rendered.push('{'); + } + '}' if chars.peek().is_some_and(|(_, next)| *next == '}') => { + chars.next(); + rendered.push('}'); + } + '{' => { + let mut expression = String::new(); + let mut closed = false; + for (_, next) in chars.by_ref() { + if next == '}' { + closed = true; + break; + } + expression.push(next); + } + if !closed { + rendered.push('{'); + rendered.push_str(&expression); + break; + } + if let Some(value) = render_expression(&expression, values) { + rendered.push_str(&value); + } else { + rendered.push('{'); + rendered.push_str(&expression); + rendered.push('}'); + } + } + other => rendered.push(other), + } + } + rendered +} + +fn render_expression(expression: &str, values: &BTreeMap<&str, Value>) -> Option { + let (key, format) = expression + .split_once(':') + .map_or((expression, None), |(key, format)| (key, Some(format))); + let value = values.get(key)?; + Some(match (value, format) { + (Value::Number(number), Some(format)) => format_number(*number, format)?, + (Value::Number(number), None) => number.to_string(), + (Value::Integer(number), _) => number.to_string(), + (Value::Text(text), _) => text.clone(), + (Value::Boolean(boolean), _) => boolean.to_string(), + }) +} + +fn format_number(number: f64, format: &str) -> Option { + let precision = format.strip_prefix('.')?.parse::().ok()?; + if precision > 12 { + return None; + } + Some(format!("{number:.precision$}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expands_named_values_and_precision() { + let values = BTreeMap::from([ + ("name", Value::from("wifi")), + ("temperature", Value::from(42.67)), + ]); + assert_eq!(expand("{name} {temperature:.1}°C", &values), "wifi 42.7°C"); + } + + #[test] + fn preserves_unknown_and_unclosed_expressions() { + let values = BTreeMap::new(); + assert_eq!(expand("{missing}", &values), "{missing}"); + assert_eq!(expand("value {", &values), "value {"); + assert_eq!(expand("{{ok}}", &values), "{ok}"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..356886e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +pub mod app; +pub mod cli; +pub mod config; +pub mod doctor; +pub mod format; +pub mod model; +pub mod modules; +pub mod render; + +pub const APP_NAME: &str = "lightbar"; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4534ddb --- /dev/null +++ b/src/main.rs @@ -0,0 +1,67 @@ +use std::{env, process::ExitCode}; + +use anyhow::Result; +use clap::Parser; +use lightbar::{ + cli::{Cli, Command}, + config::ConfigBundle, + doctor, +}; +use tracing_subscriber::EnvFilter; + +fn main() -> ExitCode { + let cli = Cli::parse(); + init_logging(&cli); + match run(cli) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + tracing::error!(error = %format_args!("{error:#}"), "lightbar failed"); + eprintln!("lightbar: {error:#}"); + ExitCode::from(1) + } + } +} + +fn init_logging(cli: &Cli) { + let fallback = format!("lightbar={}", cli.log_level); + let filter = env::var("LIGHTBAR_LOG") + .or_else(|_| env::var("RUST_LOG")) + .unwrap_or(fallback); + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new(filter)) + .with_target(false) + .compact() + .init(); +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Some(Command::CheckConfig) => { + let bundle = ConfigBundle::load(cli.config.as_deref())?; + println!( + "valid: {} (theme {})", + bundle.config_path.display(), + bundle.theme_path.display() + ); + Ok(()) + } + Some(Command::Doctor) => { + let checks = doctor::run(cli.config.as_deref()); + doctor::print(&checks); + if doctor::has_failures(&checks) { + anyhow::bail!("one or more required checks failed"); + } + Ok(()) + } + None => { + let bundle = ConfigBundle::load(cli.config.as_deref())?; + tracing::info!( + config = %bundle.config_path.display(), + theme = %bundle.theme_path.display(), + bar_id = ?cli.bar_id, + "configuration loaded" + ); + lightbar::app::run(bundle, cli.bar_id) + } + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..11f0d49 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,360 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ModuleSnapshot { + pub segments: Vec, + pub tooltip: Option, + pub popup: Option, + pub visible: bool, + pub stale: bool, +} + +impl ModuleSnapshot { + pub fn text(text: impl Into) -> Self { + Self { + segments: vec![Segment::new(text)], + visible: true, + ..Self::default() + } + } + + pub fn unavailable(placeholder: Option<&str>) -> Self { + placeholder.map_or_else(Self::default, |text| { + let mut snapshot = Self::text(text); + snapshot.segments[0].state = "disabled".to_owned(); + snapshot + }) + } + + pub fn with_state(mut self, state: impl Into) -> Self { + if let Some(segment) = self.segments.first_mut() { + segment.state = state.into(); + } + self + } + + pub fn for_output(&self, output: &str) -> Self { + let mut filtered = self.clone(); + filtered.segments.retain(|segment| { + segment + .output + .as_deref() + .is_none_or(|candidate| candidate == output) + }); + filtered.visible &= !filtered.segments.is_empty(); + filtered + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Segment { + pub id: Option, + pub text: String, + pub state: String, + pub output: Option, + pub icon: Option, +} + +impl Segment { + pub fn new(text: impl Into) -> Self { + Self { + id: None, + text: text.into(), + state: "normal".to_owned(), + output: None, + icon: None, + } + } +} + +/// A small premultiplied BGRA bitmap, ready to blend into an ARGB8888 +/// shared-memory Wayland buffer. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct IconPixmap { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PopupModel { + pub title: String, + pub content: PopupContent, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PopupContent { + Rows(Vec), + Calendar(CalendarModel), + Slider(SliderModel), + Audio(AudioModel), +} + +#[derive(Debug, Copy, Clone, Default, Eq, PartialEq)] +pub enum AudioView { + #[default] + Mixer, + Outputs, + Inputs, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AudioDevice { + pub id: u32, + pub name: String, + pub description: String, + pub input: bool, + pub is_default: bool, + pub volume: Option, + pub muted: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AudioModel { + pub devices: Vec, + pub view: AudioView, + pub focused: Option, + pub offset: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AudioControl { + pub id: Option, + pub label: String, + pub slider: Option, + pub active: bool, +} + +impl AudioControl { + #[must_use] + pub fn height(&self) -> f64 { + if self.slider.is_some() { 54.0 } else { 32.0 } + } +} + +impl AudioModel { + #[must_use] + pub fn default_device(&self, input: bool) -> Option<&AudioDevice> { + self.devices + .iter() + .find(|device| device.input == input && device.is_default) + } + + #[must_use] + pub fn controls(&self) -> Vec { + let mut controls = Vec::new(); + if self.view == AudioView::Mixer { + for (input, label, selector) in [ + (false, "Output", "audio-outputs"), + (true, "Microphone", "audio-inputs"), + ] { + let device = self.default_device(input); + controls.push(AudioControl { + id: None, + label: label.to_owned(), + slider: None, + active: false, + }); + controls.push(AudioControl { + id: Some(selector.to_owned()), + label: device.map_or_else( + || "No default device · Choose…".to_owned(), + |device| format!("{} ›", device.description), + ), + slider: None, + active: false, + }); + if let Some(device) = device { + if let Some(volume) = device.volume { + controls.push(AudioControl { + id: Some(format!("audio-volume:{}", device.id)), + label: format!( + "Volume {volume:.0}%{}", + if device.muted == Some(true) { + " · Muted" + } else { + "" + } + ), + slider: Some(volume), + active: false, + }); + } + if let Some(muted) = device.muted { + controls.push(AudioControl { + id: Some(format!("audio-mute:{}", device.id)), + label: if muted { "Unmute" } else { "Mute" }.to_owned(), + slider: None, + active: muted, + }); + } + } + } + controls.push(AudioControl { + id: Some("open-settings".to_owned()), + label: "Open pavucontrol…".to_owned(), + slider: None, + active: false, + }); + } else { + controls.push(AudioControl { + id: Some("audio-back".to_owned()), + label: "‹ Back to volume controls".to_owned(), + slider: None, + active: false, + }); + let input = self.view == AudioView::Inputs; + controls.push(AudioControl { + id: None, + label: if input { + "Choose default microphone" + } else { + "Choose default output" + } + .to_owned(), + slider: None, + active: false, + }); + for device in self.devices.iter().filter(|device| device.input == input) { + controls.push(AudioControl { + id: Some(format!("audio-default:{}", device.id)), + label: format!( + "{}{}", + if device.is_default { "✓ " } else { "" }, + device.description + ), + slider: None, + active: device.is_default, + }); + } + if controls.len() == 2 { + controls.push(AudioControl { + id: None, + label: "No devices available".to_owned(), + slider: None, + active: false, + }); + } + } + controls + } + + pub fn set_view(&mut self, view: AudioView) { + self.view = view; + self.offset = 0; + self.focused = None; + } + + pub fn move_focus(&mut self, backwards: bool, available_height: f64) { + let controls = self.controls(); + let indices: Vec<_> = controls + .iter() + .enumerate() + .filter_map(|(i, c)| c.id.as_ref().map(|_| i)) + .collect(); + if indices.is_empty() { + return; + } + let current = indices.iter().position(|&i| controls[i].id == self.focused); + let next = match (current, backwards) { + (Some(i), true) => (i + indices.len() - 1) % indices.len(), + (Some(i), false) => (i + 1) % indices.len(), + (None, true) => indices.len() - 1, + (None, false) => 0, + }; + let index = indices[next]; + self.focused.clone_from(&controls[index].id); + self.offset = self.offset.min(index); + while self.offset < index + && controls[self.offset..=index] + .iter() + .map(AudioControl::height) + .sum::() + > available_height + { + self.offset += 1; + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PopupRow { + pub id: Option, + pub label: String, + pub detail: Option, + pub state: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CalendarModel { + pub year: i32, + pub month: u32, + pub selected_day: u32, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SliderModel { + pub label: String, + pub value: f64, + pub minimum: f64, + pub maximum: f64, + pub muted: bool, + pub detail_rows: Vec, +} + +#[derive(Debug, Clone)] +pub struct ModuleEvent { + pub module: String, + pub snapshot: ModuleSnapshot, +} + +#[derive(Debug, Clone, Default)] +pub struct ModuleStore { + snapshots: BTreeMap, +} + +impl ModuleStore { + pub fn get(&self, module: &str) -> Option<&ModuleSnapshot> { + self.snapshots.get(module) + } + + pub fn update(&mut self, event: ModuleEvent) -> bool { + if self.snapshots.get(&event.module) == Some(&event.snapshot) { + return false; + } + self.snapshots.insert(event.module, event.snapshot); + true + } + + pub fn insert(&mut self, module: impl Into, snapshot: ModuleSnapshot) { + self.snapshots.insert(module.into(), snapshot); + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum PointerButton { + Left, + Middle, + Right, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum ScrollDirection { + Up, + Down, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Interaction { + Click { + module: String, + segment: Option, + button: PointerButton, + }, + Scroll { + module: String, + segment: Option, + direction: ScrollDirection, + }, +} diff --git a/src/modules/audio.rs b/src/modules/audio.rs new file mode 100644 index 0000000..337c434 --- /dev/null +++ b/src/modules/audio.rs @@ -0,0 +1,812 @@ +use std::{ + collections::BTreeMap, + process::Stdio, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use anyhow::{Context, Result, bail}; +use calloop::channel::Sender; +use serde_json::Value as Json; +use tokio::{io::AsyncReadExt, process::Command, sync::mpsc}; + +use crate::{ + config::{AudioModule, UnavailableBehavior}, + format::{Value, expand}, + model::{ + AudioDevice, AudioModel, ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, Segment, + }, +}; + +const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024; +const MAX_OBJECTS: usize = 4096; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, Copy)] +pub enum AudioTarget { + DefaultOutput, + DefaultInput, + Device(u32), +} + +impl AudioTarget { + fn argument(self) -> String { + match self { + Self::DefaultOutput => "@DEFAULT_AUDIO_SINK@".to_owned(), + Self::DefaultInput => "@DEFAULT_AUDIO_SOURCE@".to_owned(), + Self::Device(id) => id.to_string(), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum AudioAction { + SetVolume(u32, f64), + ChangeVolume(AudioTarget, i16), + ToggleMute(AudioTarget), + SetDefault(u32), +} + +impl AudioAction { + fn arguments(self) -> Vec { + match self { + Self::SetVolume(id, percent) => vec![ + "set-volume".into(), + id.to_string(), + format!("{:.3}", percent.clamp(0.0, 150.0) / 100.0), + ], + Self::ChangeVolume(target, step) => vec![ + "set-volume".into(), + "-l".into(), + "1.5".into(), + target.argument(), + format!( + "{}%{}", + step.unsigned_abs(), + if step < 0 { "-" } else { "+" } + ), + ], + Self::ToggleMute(target) => vec!["set-mute".into(), target.argument(), "toggle".into()], + Self::SetDefault(id) => vec!["set-default".into(), id.to_string()], + } + } +} + +pub fn spawn( + name: String, + settings: AudioModule, + sender: Sender, + stop: Arc, +) -> (JoinHandle<()>, mpsc::Sender) { + let (actions, mut requests) = mpsc::channel(32); + let worker = thread::Builder::new().name(format!("lightbar-audio-{name}")).spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(runtime) => runtime, + Err(error) => { + tracing::error!(module = %name, %error, "could not start audio runtime"); + return; + } + }; + runtime.block_on(async { + let mut failures = 0_u32; + while !stop.load(Ordering::Acquire) { + let result = monitor(&name, &settings, &sender, &stop, &mut requests, &mut failures).await; + if stop.load(Ordering::Acquire) { break; } + if let Err(error) = result { + tracing::warn!(module = %name, %error, "audio monitor stopped; reconnecting"); + } + let placeholder = match settings.common.unavailable { + UnavailableBehavior::Hide => None, + UnavailableBehavior::Placeholder => Some("Audio unavailable"), + }; + if sender.send(ModuleEvent { module: name.clone(), snapshot: ModuleSnapshot::unavailable(placeholder) }).is_err() { break; } + // Commands queued for a disconnected generation must not affect a new device. + while requests.try_recv().is_ok() { + tracing::warn!(module = %name, "discarded audio action after disconnect"); + } + failures = failures.saturating_add(1); + let deadline = tokio::time::Instant::now() + super::process::backoff(failures); + while !stop.load(Ordering::Acquire) && tokio::time::Instant::now() < deadline { + tokio::select! { + () = tokio::time::sleep_until(deadline) => break, + action = requests.recv() => { + if action.is_none() { return; } + tracing::warn!(module = %name, "discarded audio action while disconnected"); + } + } + } + } + }); + }).expect("audio worker thread"); + (worker, actions) +} + +async fn monitor( + name: &str, + settings: &AudioModule, + sender: &Sender, + stop: &AtomicBool, + requests: &mut mpsc::Receiver, + failures: &mut u32, +) -> Result<()> { + let mut child = Command::new("pw-dump") + .args(["--monitor", "--no-colors"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .context("could not start pw-dump")?; + let mut stdout = child + .stdout + .take() + .context("pw-dump stdout was not piped")?; + let result = async { + let mut frames = JsonFrames::default(); + let mut state = AudioState::default(); + let mut last = None; + let mut buffer = [0_u8; 8192]; + while !stop.load(Ordering::Acquire) { + tokio::select! { + read = stdout.read(&mut buffer) => { + let read = read.context("could not read pw-dump events")?; + if read == 0 { bail!("pw-dump event stream closed"); } + for batch in frames.push(&buffer[..read])? { + if state.apply(batch)? { + // pw-dump's metadata deltas omit deleted keys. Re-read the + // default metadata to distinguish a removal from no change. + let defaults = read_defaults().await?; + state.replace_defaults(defaults)?; + } + let snapshot = state.snapshot(settings); + if last.as_ref() != Some(&snapshot) { + sender.send(ModuleEvent { module: name.to_owned(), snapshot: snapshot.clone() }) + .map_err(|_| anyhow::anyhow!("UI event channel closed"))?; + last = Some(snapshot); + } + *failures = 0; + } + } + action = requests.recv() => { + let Some(action) = action else { break; }; + if let Err(error) = run_action(action).await { tracing::warn!(module = name, %error, "audio control failed"); } + } + } + } + Ok(()) + }.await; + // Always reap the monitor, including malformed input and closed UI channels. + if child.try_wait()?.is_none() { + child.kill().await.context("could not stop pw-dump")?; + } + child.wait().await.context("could not reap pw-dump")?; + result +} + +async fn run_action(action: AudioAction) -> Result<()> { + let args = action.arguments(); + let mut child = Command::new("wpctl") + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("could not run wpctl {}", args[0]))?; + if let Ok(status) = tokio::time::timeout(COMMAND_TIMEOUT, child.wait()).await { + let status = status.context("could not wait for wpctl")?; + if !status.success() { + bail!("wpctl {} exited with {status}", args[0]); + } + Ok(()) + } else { + child + .kill() + .await + .context("could not stop timed-out wpctl")?; + child.wait().await.context("could not reap wpctl")?; + bail!("wpctl {} timed out", args[0]); + } +} + +async fn read_defaults() -> Result> { + let mut child = Command::new("pw-dump") + .args(["--no-colors", "default"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .context("could not read default audio metadata")?; + let stdout = child + .stdout + .take() + .context("metadata stdout was not piped")?; + let operation = async { + let mut bytes = Vec::new(); + stdout + .take(MAX_FRAME_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .await?; + if bytes.len() > MAX_FRAME_BYTES { + bail!("default audio metadata exceeded the size limit"); + } + let status = child.wait().await?; + if !status.success() { + bail!("default audio metadata command exited with {status}"); + } + serde_json::from_slice(&bytes).context("invalid default audio metadata") + }; + let result = tokio::time::timeout(COMMAND_TIMEOUT, operation).await; + if child.try_wait()?.is_none() { + child + .kill() + .await + .context("could not stop audio metadata command")?; + } + child + .wait() + .await + .context("could not reap audio metadata command")?; + result.context("default audio metadata query timed out")? +} + +#[derive(Default)] +struct JsonFrames { + bytes: Vec, + depth: usize, + quoted: bool, + escaped: bool, +} + +impl JsonFrames { + fn push(&mut self, bytes: &[u8]) -> Result>> { + let mut batches = Vec::new(); + for &byte in bytes { + if self.bytes.is_empty() { + if byte.is_ascii_whitespace() { + continue; + } + if byte != b'[' { + bail!("pw-dump event must be a JSON array"); + } + } + if self.bytes.len() >= MAX_FRAME_BYTES { + bail!("pw-dump event exceeded {MAX_FRAME_BYTES} bytes"); + } + self.bytes.push(byte); + if self.quoted { + if self.escaped { + self.escaped = false; + } else if byte == b'\\' { + self.escaped = true; + } else if byte == b'"' { + self.quoted = false; + } + } else { + match byte { + b'"' => self.quoted = true, + b'[' | b'{' => self.depth += 1, + b']' | b'}' => { + self.depth = self + .depth + .checked_sub(1) + .context("unbalanced pw-dump JSON")?; + if self.depth == 0 { + batches.push( + serde_json::from_slice(&self.bytes) + .context("invalid pw-dump JSON")?, + ); + self.bytes.clear(); + } + } + _ => {} + } + } + } + Ok(batches) + } +} + +#[derive(Default)] +struct AudioState { + objects: BTreeMap, + metadata_id: Option, + default_output: Option, + default_input: Option, +} + +impl AudioState { + fn apply(&mut self, batch: Vec) -> Result { + let mut metadata_changed = false; + for object in batch { + let id = object["id"] + .as_u64() + .and_then(|id| u32::try_from(id).ok()) + .context("pw-dump object has no valid id")?; + if object.get("info").is_some_and(Json::is_null) { + self.objects.remove(&id); + if self.metadata_id == Some(id) { + self.metadata_id = None; + self.default_output = None; + self.default_input = None; + } + continue; + } + if object["props"]["metadata.name"] == "default" || self.metadata_id == Some(id) { + metadata_changed = true; + self.metadata_id = Some(id); + if let Some(entries) = object["metadata"].as_array() { + for entry in entries { + if entry["subject"].as_u64() != Some(0) { + continue; + } + let target = match entry["key"].as_str() { + Some("default.audio.sink") => &mut self.default_output, + Some("default.audio.source") => &mut self.default_input, + _ => continue, + }; + *target = entry["value"]["name"].as_str().map(str::to_owned); + } + } + continue; + } + let class = object["info"]["props"]["media.class"].as_str(); + if matches!( + class, + Some("Audio/Sink" | "Audio/Source" | "Audio/Source/Virtual" | "Audio/Device") + ) || self.objects.contains_key(&id) + { + let stored = self + .objects + .entry(id) + .or_insert_with(|| serde_json::json!({})); + // Node properties are complete; parameter keys are incremental. + if let Some(info) = object["info"].as_object() { + if stored.get("info").is_none() { + stored["info"] = serde_json::json!({}); + } + if let Some(props) = info.get("props") { + stored["info"]["props"] = props.clone(); + } + if let Some(params) = info.get("params").and_then(Json::as_object) { + if stored["info"].get("params").is_none() { + stored["info"]["params"] = serde_json::json!({}); + } + for (key, value) in params { + stored["info"]["params"][key] = value.clone(); + } + } + } + } + if self.objects.len() > MAX_OBJECTS { + bail!("too many audio objects"); + } + } + Ok(metadata_changed) + } + + fn replace_defaults(&mut self, snapshot: Vec) -> Result<()> { + self.default_output = None; + self.default_input = None; + self.metadata_id = None; + self.apply(snapshot)?; + Ok(()) + } + + fn devices(&self) -> Vec { + let mut devices = Vec::new(); + for (&id, object) in &self.objects { + let props = &object["info"]["props"]; + let input = match props["media.class"].as_str() { + Some("Audio/Sink") => false, + Some("Audio/Source" | "Audio/Source/Virtual") => true, + _ => continue, + }; + let Some(name) = props["node.name"].as_str() else { + continue; + }; + let description = props["node.description"] + .as_str() + .or_else(|| props["node.nick"].as_str()) + .unwrap_or(name); + let route_volume = number(&props["device.id"]) + .and_then(|id| self.objects.get(&id)) + .and_then(|device| device["info"]["params"]["Route"].as_array()) + .and_then(|routes| { + routes.iter().find(|route| { + number(&route["device"]) == number(&props["card.profile.device"]) + && number(&route["device"]).is_some() + }) + }) + .and_then(|route| volume(&route["props"])); + let node_volume = || { + object["info"]["params"]["Props"] + .as_array() + .and_then(|params| params.iter().find_map(volume)) + }; + let level = route_volume.or_else(node_volume); + let default = if input { + &self.default_input + } else { + &self.default_output + }; + devices.push(AudioDevice { + id, + name: name.to_owned(), + description: description.to_owned(), + input, + is_default: default.as_deref() == Some(name), + volume: level.map(|(volume, _)| volume), + muted: level.map(|(_, muted)| muted), + }); + } + devices.sort_by(|a, b| { + a.input + .cmp(&b.input) + .then_with(|| a.description.cmp(&b.description)) + .then_with(|| a.name.cmp(&b.name)) + }); + devices + } + + fn snapshot(&self, settings: &AudioModule) -> ModuleSnapshot { + let model = AudioModel { + devices: self.devices(), + ..AudioModel::default() + }; + let mut snapshot = ModuleSnapshot { + visible: true, + ..ModuleSnapshot::default() + }; + let mut descriptions = Vec::new(); + for (input, id, label) in [(false, "output", "Output"), (true, "input", "Microphone")] { + let device = model.default_device(input); + let mut segment = Segment::new(format!("{label} N/A")); + segment.id = Some(id.to_owned()); + if let Some(device) = device { + let format = match (input, device.muted == Some(true)) { + (false, false) => &settings.format, + (false, true) => &settings.muted_format, + (true, false) => &settings.microphone_format, + (true, true) => &settings.microphone_muted_format, + }; + segment.text = expand( + format, + &BTreeMap::from([ + ( + "volume", + device + .volume + .map_or_else(|| Value::from("N/A"), Value::from), + ), + ("device", Value::from(device.description.clone())), + ("name", Value::from(device.name.clone())), + ]), + ); + descriptions.push(format!( + "{label}: {} · {}{}", + device.description, + device.volume.map_or_else( + || "Volume unavailable".to_owned(), + |volume| format!("{volume:.0}%") + ), + if device.muted == Some(true) { + " · Muted" + } else { + "" + } + )); + } else { + descriptions.push(format!("{label}: no default device")); + } + if device.is_none_or(|device| device.volume.is_none() || device.muted == Some(true)) { + "disabled".clone_into(&mut segment.state); + } + snapshot.segments.push(segment); + } + snapshot.tooltip = Some(descriptions.join("\n")); + snapshot.popup = Some(PopupModel { + title: "Audio".to_owned(), + content: PopupContent::Audio(model), + }); + snapshot + } +} + +fn number(value: &Json) -> Option { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .or_else(|| value.as_str()?.parse().ok()) +} + +fn volume(props: &Json) -> Option<(f64, bool)> { + let muted = props["mute"].as_bool()?; + let linear = props["channelVolumes"] + .as_array() + .and_then(|channels| channels.first()) + .and_then(Json::as_f64) + .or_else(|| props["volume"].as_f64())?; + // WirePlumber exposes the first channel on a cubic scale, including route volume. + (linear.is_finite() && linear >= 0.0).then(|| ((linear.cbrt() * 100.0).round(), muted)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fixture() -> AudioState { + let mut state = AudioState::default(); + state + .apply(serde_json::from_str(include_str!("../../tests/fixtures/audio.json")).unwrap()) + .unwrap(); + state + } + + #[test] + fn reports_actual_defaults_route_volume_and_independent_mute() { + let state = fixture(); + let snapshot = state.snapshot(&AudioModule::default()); + let PopupContent::Audio(audio) = snapshot.popup.unwrap().content else { + panic!("audio popup missing"); + }; + assert_eq!( + audio.devices.len(), + 3, + "application streams are not devices" + ); + let output = audio.default_device(false).unwrap(); + assert_eq!( + (output.id, output.volume, output.muted), + (10, Some(50.0), Some(false)) + ); + let input = audio.default_device(true).unwrap(); + assert_eq!( + (input.id, input.volume, input.muted), + (11, Some(80.0), Some(true)) + ); + assert_eq!(snapshot.segments[0].state, "normal"); + assert_eq!(snapshot.segments[1].state, "disabled"); + assert!( + snapshot + .tooltip + .unwrap() + .contains("USB Microphone · 80% · Muted") + ); + } + + #[test] + fn follows_external_defaults_and_keeps_other_endpoint() { + let mut state = fixture(); + state.apply(vec![json!({"id":1,"metadata":[{"subject":0,"key":"default.audio.sink","value":{"name":"headphones"}}]})]).unwrap(); + let devices = state.devices(); + assert!( + devices + .iter() + .any(|device| device.id == 12 && device.is_default) + ); + assert!( + devices + .iter() + .any(|device| device.id == 11 && device.is_default) + ); + state.apply(vec![json!({"id":11,"info":null})]).unwrap(); + let snapshot = state.snapshot(&AudioModule::default()); + assert_eq!(snapshot.segments[1].text, "Microphone N/A"); + let PopupContent::Audio(audio) = snapshot.popup.unwrap().content else { + panic!(); + }; + assert!(audio.default_device(false).is_some()); + assert!(audio.default_device(true).is_none()); + assert!( + !audio + .controls() + .iter() + .any(|control| control.id.as_deref() == Some("audio-mute:11")) + ); + state.apply(vec![json!({"id":1,"info":null})]).unwrap(); + assert!(!state.devices().iter().any(|device| device.is_default)); + } + + #[test] + fn removing_a_default_clears_only_that_endpoint() { + let mut state = fixture(); + assert!(state.apply(vec![json!({"id":1,"metadata":[]})]).unwrap()); + state + .replace_defaults(vec![ + json!({"id":1,"props":{"metadata.name":"default"},"metadata":[ + {"subject":0,"key":"default.audio.sink","value":{"name":"speakers"}} + ]}), + ]) + .unwrap(); + let devices = state.devices(); + assert!( + devices + .iter() + .any(|device| device.id == 10 && device.is_default) + ); + assert!( + !devices + .iter() + .any(|device| device.input && device.is_default) + ); + } + + #[test] + fn preserves_final_event_in_a_burst_and_handles_partial_parameters() { + let mut frames = JsonFrames::default(); + let mut state = fixture(); + let stream = concat!( + "[{\"id\":11,\"info\":{\"params\":{\"Props\":[{\"mute\":false,\"channelVolumes\":[0.001]}]}}}]\n", + "[{\"id\":11,\"info\":{\"params\":{\"Props\":[{\"mute\":false,\"channelVolumes\":[0.729]}]}}}]\n" + ); + for chunk in stream.as_bytes().chunks(7) { + for batch in frames.push(chunk).unwrap() { + state.apply(batch).unwrap(); + } + } + let microphone = state + .devices() + .into_iter() + .find(|device| device.id == 11) + .unwrap(); + assert_eq!( + (microphone.volume, microphone.muted), + (Some(90.0), Some(false)) + ); + assert_eq!(microphone.description, "USB Microphone"); + } + + #[test] + fn stream_parser_handles_escaped_names_and_limits() { + let data = json!([{"id":1,"text":"Device [USB] \"name\" \\ test"}]).to_string(); + let mut frames = JsonFrames::default(); + let mut batches = Vec::new(); + for byte in data.as_bytes() { + batches.extend(frames.push(&[*byte]).unwrap()); + } + assert_eq!(batches.len(), 1); + assert_eq!(batches[0][0]["text"], "Device [USB] \"name\" \\ test"); + assert!(JsonFrames::default().push(b"not json").is_err()); + assert!(JsonFrames::default().push(b"[{broken}]").is_err()); + let mut oversized = JsonFrames::default(); + oversized.push(b"[\"").unwrap(); + assert!(oversized.push(&vec![b'x'; MAX_FRAME_BYTES]).is_err()); + } + + #[test] + fn volume_keeps_overamplification_and_distinguishes_silence_from_mute() { + assert_eq!( + volume(&json!({"mute":false,"channelVolumes":[0.0]})), + Some((0.0, false)) + ); + assert_eq!( + volume(&json!({"mute":true,"channelVolumes":[8.0]})), + Some((200.0, true)) + ); + assert_eq!( + volume(&json!({"mute":false,"volume":0.125})), + Some((50.0, false)) + ); + assert_eq!(volume(&json!({"mute":false,"channelVolumes":[-1.0]})), None); + assert_eq!(volume(&json!({"channelVolumes":[1.0]})), None); + } + + #[test] + fn audio_commands_target_the_selected_endpoint_and_limit_volume() { + assert_eq!( + AudioAction::ToggleMute(AudioTarget::DefaultInput).arguments(), + ["set-mute", "@DEFAULT_AUDIO_SOURCE@", "toggle"] + ); + assert_eq!( + AudioAction::ChangeVolume(AudioTarget::DefaultOutput, 7).arguments(), + ["set-volume", "-l", "1.5", "@DEFAULT_AUDIO_SINK@", "7%+"] + ); + assert_eq!( + AudioAction::ChangeVolume(AudioTarget::Device(11), -7).arguments(), + ["set-volume", "-l", "1.5", "11", "7%-"] + ); + assert_eq!( + AudioAction::SetDefault(12).arguments(), + ["set-default", "12"] + ); + assert_eq!( + AudioAction::SetVolume(11, 180.0).arguments(), + ["set-volume", "11", "1.500"] + ); + assert_eq!( + AudioAction::SetVolume(11, -10.0).arguments(), + ["set-volume", "11", "0.000"] + ); + } + + #[test] + fn custom_formats_include_names_and_numeric_volume() { + let settings = AudioModule { + format: "{device}: {volume:.1}%".to_owned(), + microphone_muted_format: "{name}: muted at {volume}%".to_owned(), + ..AudioModule::default() + }; + let snapshot = fixture().snapshot(&settings); + assert_eq!(snapshot.segments[0].text, "Desktop Speakers: 50.0%"); + assert_eq!(snapshot.segments[1].text, "microphone: muted at 80%"); + } + + #[test] + #[ignore = "requires a running PipeWire/WirePlumber session; read-only"] + fn live_pipewire_matches_wpctl_and_worker_stops_when_idle() { + let output = std::process::Command::new("pw-dump") + .arg("--no-colors") + .output() + .unwrap(); + assert!(output.status.success()); + let mut state = AudioState::default(); + state + .apply(serde_json::from_slice(&output.stdout).unwrap()) + .unwrap(); + let devices = state.devices(); + for target in [AudioTarget::DefaultOutput, AudioTarget::DefaultInput] { + let output = std::process::Command::new("wpctl") + .args(["inspect", &target.argument()]) + .output() + .unwrap(); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + let id: u32 = text + .split_whitespace() + .nth(1) + .unwrap() + .trim_end_matches(',') + .parse() + .unwrap(); + let device = devices.iter().find(|device| device.id == id).unwrap(); + assert!(device.is_default); + let output = std::process::Command::new("wpctl") + .args(["get-volume", &target.argument()]) + .output() + .unwrap(); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + let percent = (text + .split_whitespace() + .nth(1) + .unwrap() + .parse::() + .unwrap() + * 100.0) + .round(); + assert_eq!(device.volume, Some(percent)); + assert_eq!(device.muted, Some(text.contains("[MUTED]"))); + } + let (sender, receiver) = calloop::channel::channel(); + let stop = Arc::new(AtomicBool::new(false)); + let (worker, actions) = spawn( + "audio-test".to_owned(), + AudioModule::default(), + sender, + Arc::clone(&stop), + ); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let first_event = loop { + if let Ok(event) = receiver.try_recv() { + break Some(event); + } + if std::time::Instant::now() >= deadline { + break None; + } + thread::sleep(Duration::from_millis(10)); + }; + stop.store(true, Ordering::Release); + drop(actions); + let start = std::time::Instant::now(); + worker.join().unwrap(); + assert!(start.elapsed() < Duration::from_secs(1)); + assert!(first_event.unwrap().snapshot.popup.is_some()); + } +} diff --git a/src/modules/clock.rs b/src/modules/clock.rs new file mode 100644 index 0000000..1aebfb2 --- /dev/null +++ b/src/modules/clock.rs @@ -0,0 +1,78 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use calloop::channel::Sender; +use chrono::{Datelike, Local}; + +use crate::{ + config::ClockModule, + model::{CalendarModel, ModuleEvent, ModuleSnapshot, PopupContent, PopupModel}, +}; + +pub fn spawn( + name: String, + settings: ClockModule, + sender: Sender, + stop: Arc, +) -> JoinHandle<()> { + thread::Builder::new() + .name(format!("lightbar-clock-{name}")) + .spawn(move || { + while !stop.load(Ordering::Acquire) { + let now = Local::now(); + let mut snapshot = ModuleSnapshot::text(now.format(&settings.format).to_string()); + snapshot.tooltip = Some(now.format("%A, %F %T %Z").to_string()); + snapshot.popup = Some(PopupModel { + title: now.format("%B %Y").to_string(), + content: PopupContent::Calendar(CalendarModel { + year: now.year(), + month: now.month(), + selected_day: now.day(), + }), + }); + if sender + .send(ModuleEvent { + module: name.clone(), + snapshot, + }) + .is_err() + { + break; + } + thread::park_timeout(until_next_boundary(settings.interval.0)); + } + }) + .expect("clock worker thread") +} + +fn until_next_boundary(interval: Duration) -> Duration { + let interval_nanos = interval.as_nanos(); + if interval_nanos == 0 { + return Duration::from_secs(1); + } + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let remaining = interval_nanos - now_nanos % interval_nanos; + Duration::from_nanos(u64::try_from(remaining.min(u128::from(u64::MAX))).unwrap_or(u64::MAX)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_boundary_is_positive_and_bounded() { + let interval = Duration::from_secs(1); + let remaining = until_next_boundary(interval); + assert!(remaining > Duration::ZERO); + assert!(remaining <= interval); + } +} diff --git a/src/modules/command.rs b/src/modules/command.rs new file mode 100644 index 0000000..d40dec0 --- /dev/null +++ b/src/modules/command.rs @@ -0,0 +1,334 @@ +use std::{ + io::{BufRead, BufReader, Read}, + process::{Child, Command, ExitStatus, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use calloop::channel::Sender; +use serde::Deserialize; + +use crate::{ + config::{CommandMode, CommandModule}, + model::{ModuleEvent, ModuleSnapshot}, +}; + +type ChildRegistry = Arc>>>>; + +pub fn spawn( + name: String, + settings: CommandModule, + sender: Sender, + stop: Arc, + children: ChildRegistry, +) -> JoinHandle<()> { + thread::Builder::new() + .name(format!("lightbar-command-{name}")) + .spawn(move || match settings.mode { + CommandMode::Interval => run_interval(name, settings, sender, stop, children), + CommandMode::Stream => run_stream(name, settings, sender, stop, children), + }) + .expect("custom command worker thread") +} + +fn run_interval( + name: String, + settings: CommandModule, + sender: Sender, + stop: Arc, + children: ChildRegistry, +) { + let mut last_good: Option = None; + let mut failures = 0_u32; + while !stop.load(Ordering::Acquire) { + let started = Instant::now(); + let event = match run_once(&settings, &children, &stop) { + Ok(output) => match parse_output(&output) { + Ok(snapshot) => { + failures = 0; + last_good = Some(snapshot.clone()); + snapshot + } + Err(error) => { + failures = failures.saturating_add(1); + tracing::warn!(module = %name, %error, "custom module output was invalid"); + stale(&last_good) + } + }, + Err(error) => { + failures = failures.saturating_add(1); + tracing::warn!(module = %name, %error, "custom module command failed"); + stale(&last_good) + } + }; + if sender + .send(ModuleEvent { + module: name.clone(), + snapshot: event, + }) + .is_err() + { + break; + } + let delay = if failures == 0 { + settings.interval.0.saturating_sub(started.elapsed()) + } else { + super::process::backoff(failures).min(Duration::from_secs(60)) + }; + thread::park_timeout(delay); + } +} + +fn run_stream( + name: String, + settings: CommandModule, + sender: Sender, + stop: Arc, + children: ChildRegistry, +) { + let mut last_good: Option = None; + let mut failures = 0_u32; + while !stop.load(Ordering::Acquire) { + match stream_once(&name, &settings, &sender, &stop, &children, &mut last_good) { + Ok(()) if stop.load(Ordering::Acquire) => break, + Ok(()) => failures = failures.saturating_add(1), + Err(error) => { + failures = failures.saturating_add(1); + tracing::warn!(module = %name, %error, "custom stream stopped"); + } + } + let _ = sender.send(ModuleEvent { + module: name.clone(), + snapshot: stale(&last_good), + }); + thread::park_timeout(super::process::backoff(failures).min(Duration::from_secs(60))); + } +} + +fn stream_once( + name: &str, + settings: &CommandModule, + sender: &Sender, + stop: &AtomicBool, + children: &ChildRegistry, + last_good: &mut Option, +) -> Result<()> { + let mut child = command(settings) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("could not spawn custom stream for {name}"))?; + let stdout = child + .stdout + .take() + .context("custom stream stdout was not piped")?; + let child = Arc::new(Mutex::new(child)); + super::process::register_child(children, &child); + if stop.load(Ordering::Acquire) + && let Ok(mut process) = child.lock() + { + let _ = process.kill(); + } + + let mut reader = BufReader::new(stdout); + let mut line = Vec::new(); + loop { + line.clear(); + let read = reader.read_until(b'\n', &mut line)?; + if read == 0 || stop.load(Ordering::Acquire) { + break; + } + if line.len() > settings.max_output_bytes { + if let Ok(mut process) = child.lock() { + let _ = process.kill(); + } + bail!("stream line exceeded {} bytes", settings.max_output_bytes); + } + while matches!(line.last(), Some(b'\n' | b'\r')) { + line.pop(); + } + let text = std::str::from_utf8(&line).context("stream emitted non-UTF-8 output")?; + match parse_output(text) { + Ok(snapshot) => { + *last_good = Some(snapshot.clone()); + if sender + .send(ModuleEvent { + module: name.to_owned(), + snapshot, + }) + .is_err() + { + break; + } + } + Err(error) => tracing::warn!(module = name, %error, "ignored invalid stream line"), + } + } + let status = super::process::wait_child(&child, stop)?; + if !status.success() && !stop.load(Ordering::Acquire) { + bail!("custom stream exited with {status}"); + } + super::process::unregister_child(children, &child); + Ok(()) +} + +fn run_once( + settings: &CommandModule, + children: &ChildRegistry, + stop: &AtomicBool, +) -> Result { + let mut child = command(settings) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("could not spawn custom command")?; + let stdout = child + .stdout + .take() + .context("custom command stdout was not piped")?; + let stderr = child + .stderr + .take() + .context("custom command stderr was not piped")?; + let child = Arc::new(Mutex::new(child)); + super::process::register_child(children, &child); + + let limit = u64::try_from(settings.max_output_bytes.saturating_add(1)).unwrap_or(u64::MAX); + let stdout_thread = thread::spawn(move || read_limited(stdout, limit)); + let stderr_thread = thread::spawn(move || read_limited(stderr, limit)); + let started = Instant::now(); + let status: ExitStatus = loop { + if (stop.load(Ordering::Acquire) || started.elapsed() >= settings.timeout.0) + && let Ok(mut process) = child.lock() + { + let _ = process.kill(); + } + let maybe_status = child + .lock() + .map_err(|_| anyhow!("custom command child lock was poisoned"))? + .try_wait()?; + if let Some(status) = maybe_status { + break status; + } + thread::sleep(Duration::from_millis(10)); + }; + let stdout = stdout_thread + .join() + .map_err(|_| anyhow!("stdout reader panicked"))??; + let stderr = stderr_thread + .join() + .map_err(|_| anyhow!("stderr reader panicked"))??; + super::process::unregister_child(children, &child); + + if started.elapsed() >= settings.timeout.0 && !status.success() { + bail!( + "custom command exceeded timeout of {:?}", + settings.timeout.0 + ); + } + if stdout.len() > settings.max_output_bytes || stderr.len() > settings.max_output_bytes { + bail!( + "custom command exceeded {} byte output limit", + settings.max_output_bytes + ); + } + if !status.success() { + let stderr = String::from_utf8_lossy(&stderr); + bail!("custom command exited with {status}: {}", stderr.trim()); + } + Ok(String::from_utf8(stdout)?.trim_end().to_owned()) +} + +fn command(settings: &CommandModule) -> Command { + if let Some(shell) = &settings.shell { + let mut command = Command::new("/bin/sh"); + command.args(["-c", shell]); + command + } else { + let mut command = Command::new(&settings.argv[0]); + command.args(&settings.argv[1..]); + command + } +} + +fn read_limited(reader: impl Read, limit: u64) -> Result> { + let mut output = Vec::new(); + reader.take(limit).read_to_end(&mut output)?; + Ok(output) +} + +fn stale(last_good: &Option) -> ModuleSnapshot { + let mut snapshot = last_good.clone().unwrap_or_default(); + snapshot.stale = last_good.is_some(); + snapshot +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsonOutput { + text: String, + #[serde(default)] + tooltip: Option, + #[serde(default = "normal_state")] + state: String, + #[serde(default = "default_visible")] + visible: bool, +} + +fn normal_state() -> String { + "normal".to_owned() +} + +const fn default_visible() -> bool { + true +} + +fn parse_output(output: &str) -> Result { + let trimmed = output.trim(); + if trimmed.is_empty() { + return Ok(ModuleSnapshot::default()); + } + if trimmed.starts_with('{') { + let parsed: JsonOutput = serde_json::from_str(trimmed)?; + if parsed.state.is_empty() + || !parsed.state.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + { + bail!("custom state must be an ASCII identifier"); + } + let mut snapshot = ModuleSnapshot::text(parsed.text).with_state(parsed.state); + snapshot.tooltip = parsed.tooltip; + snapshot.visible = parsed.visible; + Ok(snapshot) + } else { + Ok(ModuleSnapshot::text(trimmed)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_plain_and_json_output() { + assert_eq!(parse_output("hello\n").unwrap().segments[0].text, "hello"); + let parsed = + parse_output(r#"{"text":"hot","tooltip":"details","state":"critical","visible":true}"#) + .unwrap(); + assert_eq!(parsed.segments[0].state, "critical"); + assert_eq!(parsed.tooltip.as_deref(), Some("details")); + } + + #[test] + fn rejects_unknown_json_fields() { + assert!(parse_output(r#"{"text":"x","bad":true}"#).is_err()); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs new file mode 100644 index 0000000..8dabfa8 --- /dev/null +++ b/src/modules/mod.rs @@ -0,0 +1,308 @@ +mod audio; +mod clock; +mod command; +mod process; +mod sway; +mod sysfs; +mod tray; + +use std::{ + collections::BTreeMap, + process::Child, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread::JoinHandle, +}; + +use anyhow::{Context, Result, bail}; +use calloop::channel::{Channel, Sender, channel}; + +use crate::{ + config::{ + Action, AudioModule, BacklightModule, BuiltinAction, Config, ModuleConfig, + UnavailableBehavior, + }, + model::{ModuleEvent, ModuleSnapshot}, +}; + +pub use audio::{AudioAction, AudioTarget}; +pub use sway::run_sway_command; +pub use sysfs::set_backlight_percent; +pub use tray::{activate_tray_item, scroll_tray_item}; + +#[derive(Debug)] +pub struct ModuleRuntime { + stop: Arc, + threads: Vec>, + children: Arc>>>>, + module_kinds: BTreeMap, + audio_actions: BTreeMap>, +} + +impl ModuleRuntime { + pub fn start(config: &Config) -> Result<(Self, Channel)> { + let (sender, receiver) = channel(); + let runtime = Self::start_with_sender(config, sender)?; + Ok((runtime, receiver)) + } + + pub fn start_with_sender(config: &Config, sender: Sender) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let children = Arc::new(Mutex::new(Vec::new())); + let mut threads = Vec::new(); + let mut module_kinds = BTreeMap::new(); + let mut audio_actions = BTreeMap::new(); + + let sway_modules = sway::ConfiguredSwayModules::from_config(config); + if !sway_modules.is_empty() { + threads.push(sway::spawn(sway_modules, sender.clone(), Arc::clone(&stop))); + } + + for (name, module) in &config.modules { + module_kinds.insert(name.clone(), kind_name(module)); + if !module.common().enabled { + continue; + } + send_initial(name, module, &sender); + match module { + ModuleConfig::Clock(settings) => threads.push(clock::spawn( + name.clone(), + settings.clone(), + sender.clone(), + Arc::clone(&stop), + )), + ModuleConfig::Temperature(settings) => threads.push(sysfs::spawn_temperature( + name.clone(), + settings.clone(), + sender.clone(), + Arc::clone(&stop), + )), + ModuleConfig::Backlight(settings) => threads.push(sysfs::spawn_backlight( + name.clone(), + settings.clone(), + sender.clone(), + Arc::clone(&stop), + )), + ModuleConfig::Battery(settings) => threads.push(sysfs::spawn_battery( + name.clone(), + settings.clone(), + sender.clone(), + Arc::clone(&stop), + )), + ModuleConfig::Network(settings) => threads.push(process::spawn_network( + name.clone(), + settings.clone(), + sender.clone(), + Arc::clone(&stop), + Arc::clone(&children), + )), + ModuleConfig::Audio(settings) => { + let (worker, actions) = audio::spawn( + name.clone(), + settings.clone(), + sender.clone(), + Arc::clone(&stop), + ); + threads.push(worker); + audio_actions.insert(name.clone(), actions); + } + ModuleConfig::Command(settings) => threads.push(command::spawn( + name.clone(), + settings.clone(), + sender.clone(), + Arc::clone(&stop), + Arc::clone(&children), + )), + ModuleConfig::Tray(settings) => threads.push(tray::spawn( + name.clone(), + settings.clone(), + sender.clone(), + Arc::clone(&stop), + )), + ModuleConfig::Workspaces(_) + | ModuleConfig::Mode(_) + | ModuleConfig::Scratchpad(_) => {} + } + } + + Ok(Self { + stop, + threads, + children, + module_kinds, + audio_actions, + }) + } + + pub fn kind(&self, module: &str) -> Option<&'static str> { + self.module_kinds.get(module).copied() + } + + /// Queues a control without blocking the UI; the module worker owns execution. + /// + /// # Errors + /// Returns an error if the module is stopped or its bounded queue is full. + pub fn audio_action(&self, module: &str, action: AudioAction) -> Result<()> { + self.audio_actions + .get(module) + .context("audio module is not running")? + .try_send(action) + .context("could not queue audio control") + } +} + +impl Drop for ModuleRuntime { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + self.audio_actions.clear(); + for thread in &self.threads { + thread.thread().unpark(); + } + let children = self + .children + .lock() + .map(|children| children.clone()) + .unwrap_or_default(); + for child in children { + if let Ok(mut child) = child.lock() { + let _ = child.kill(); + } + } + for thread in self.threads.drain(..) { + if let Err(error) = thread.join() { + tracing::debug!(?error, "module worker did not shut down cleanly"); + } + } + } +} + +fn send_initial(name: &str, module: &ModuleConfig, sender: &Sender) { + let placeholder = match module.common().unavailable { + UnavailableBehavior::Hide => None, + UnavailableBehavior::Placeholder => Some("N/A"), + }; + let _ = sender.send(ModuleEvent { + module: name.to_owned(), + snapshot: ModuleSnapshot::unavailable(placeholder), + }); +} + +fn kind_name(module: &ModuleConfig) -> &'static str { + match module { + ModuleConfig::Workspaces(_) => "workspaces", + ModuleConfig::Mode(_) => "mode", + ModuleConfig::Scratchpad(_) => "scratchpad", + ModuleConfig::Network(_) => "network", + ModuleConfig::Temperature(_) => "temperature", + ModuleConfig::Backlight(_) => "backlight", + ModuleConfig::Audio(_) => "audio", + ModuleConfig::Battery(_) => "battery", + ModuleConfig::Clock(_) => "clock", + ModuleConfig::Command(_) => "command", + ModuleConfig::Tray(_) => "tray", + } +} + +pub fn execute_action(action: &Action) -> Result<()> { + match action { + Action::Builtin { name } => match name { + BuiltinAction::TogglePopover + | BuiltinAction::ClosePopover + | BuiltinAction::PreviousMonth + | BuiltinAction::NextMonth => { + bail!("popup action must be handled by the UI") + } + BuiltinAction::ToggleMute => { + process::wpctl(&["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]) + } + BuiltinAction::VolumeUp => { + process::wpctl(&["set-volume", "-l", "1.5", "@DEFAULT_AUDIO_SINK@", "5%+"]) + } + BuiltinAction::VolumeDown => { + process::wpctl(&["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"]) + } + BuiltinAction::BrightnessUp | BuiltinAction::BrightnessDown => { + bail!("brightness action requires module settings") + } + }, + Action::Sway { command } => sway::run_sway_command(command), + Action::Exec { argv } => spawn_detached(argv), + Action::Shell { command } => spawn_shell(command), + } +} + +pub fn execute_builtin_for_module(builtin: BuiltinAction, module: &ModuleConfig) -> Result<()> { + match (builtin, module) { + (BuiltinAction::BrightnessUp, ModuleConfig::Backlight(settings)) => { + sysfs::adjust_backlight(settings, i16::from(settings.step_percent)) + } + (BuiltinAction::BrightnessDown, ModuleConfig::Backlight(settings)) => { + sysfs::adjust_backlight(settings, -i16::from(settings.step_percent)) + } + (BuiltinAction::VolumeUp, ModuleConfig::Audio(settings)) => { + process::change_volume(settings, true) + } + (BuiltinAction::VolumeDown, ModuleConfig::Audio(settings)) => { + process::change_volume(settings, false) + } + _ => execute_action(&Action::Builtin { name: builtin }), + } +} + +fn spawn_detached(argv: &[String]) -> Result<()> { + let (program, arguments) = argv.split_first().context("exec action has no program")?; + std::process::Command::new(program) + .args(arguments) + .spawn() + .with_context(|| format!("could not spawn {program:?}"))?; + Ok(()) +} + +fn spawn_shell(command: &str) -> Result<()> { + std::process::Command::new("/bin/sh") + .args(["-c", command]) + .spawn() + .with_context(|| format!("could not execute shell action {command:?}"))?; + Ok(()) +} + +pub fn action_for_button( + module: &ModuleConfig, + button: crate::model::PointerButton, +) -> Option<&Action> { + let actions = &module.common().actions; + match button { + crate::model::PointerButton::Left => actions.left.as_ref(), + crate::model::PointerButton::Middle => actions.middle.as_ref(), + crate::model::PointerButton::Right => actions.right.as_ref(), + } +} + +pub fn action_for_scroll( + module: &ModuleConfig, + direction: crate::model::ScrollDirection, +) -> Option<&Action> { + let actions = &module.common().actions; + match direction { + crate::model::ScrollDirection::Up => actions.scroll_up.as_ref(), + crate::model::ScrollDirection::Down => actions.scroll_down.as_ref(), + } +} + +pub fn configured_audio(module: &ModuleConfig) -> Option<&AudioModule> { + if let ModuleConfig::Audio(settings) = module { + Some(settings) + } else { + None + } +} + +pub fn configured_backlight(module: &ModuleConfig) -> Option<&BacklightModule> { + if let ModuleConfig::Backlight(settings) = module { + Some(settings) + } else { + None + } +} diff --git a/src/modules/process.rs b/src/modules/process.rs new file mode 100644 index 0000000..b0623dd --- /dev/null +++ b/src/modules/process.rs @@ -0,0 +1,335 @@ +use std::{ + collections::BTreeMap, + io::{BufRead, BufReader}, + process::{Child, Command, ExitStatus, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, bail}; +use calloop::channel::Sender; + +use crate::{ + config::{AudioModule, NetworkModule}, + format::{Value, expand}, + model::{ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow}, +}; + +type ChildRegistry = Arc>>>>; + +pub fn spawn_network( + name: String, + settings: NetworkModule, + sender: Sender, + stop: Arc, + children: ChildRegistry, +) -> JoinHandle<()> { + thread::Builder::new() + .name(format!("lightbar-network-{name}")) + .spawn(move || { + let mut failures = 0_u32; + while !stop.load(Ordering::Acquire) { + if publish_network(&name, &settings, &sender).is_ok() { + failures = 0; + } else { + failures = failures.saturating_add(1); + } + if stop.load(Ordering::Acquire) { + break; + } + match monitored_lines( + "nmcli", + &["monitor"], + &stop, + &children, + Duration::from_millis(250), + || publish_network(&name, &settings, &sender), + ) { + Ok(()) if stop.load(Ordering::Acquire) => break, + Ok(()) => {} + Err(error) => { + tracing::warn!(module = %name, %error, "network monitor stopped"); + failures = failures.saturating_add(1); + } + } + thread::park_timeout(backoff(failures)); + } + }) + .expect("network worker thread") +} + +fn publish_network( + name: &str, + settings: &NetworkModule, + sender: &Sender, +) -> Result<()> { + let output = Command::new("nmcli") + .args([ + "--terse", + "--escape", + "no", + "--fields", + "DEVICE,TYPE,STATE,CONNECTION", + "device", + "status", + ]) + .output() + .context("could not run nmcli")?; + if !output.status.success() { + bail!("nmcli exited with {}", output.status); + } + let text = String::from_utf8(output.stdout)?; + let mut candidates = text.lines().filter_map(parse_network_line).filter(|entry| { + settings + .interface + .as_deref() + .is_none_or(|interface| entry.device == interface) + }); + let selected = candidates + .clone() + .find(|entry| entry.connected && entry.kind == "wifi") + .or_else(|| candidates.find(|entry| entry.connected)); + + let mut snapshot = if let Some(entry) = selected { + let values = BTreeMap::from([ + ("ssid", Value::from(entry.connection.clone())), + ("connection", Value::from(entry.connection.clone())), + ("interface", Value::from(entry.device.clone())), + ]); + let mut snapshot = ModuleSnapshot::text(expand(&settings.format_connected, &values)); + let addresses = network_addresses(&entry.device).unwrap_or_default(); + snapshot.tooltip = Some(format!( + "{} on {}{}", + entry.connection, + entry.device, + if addresses.is_empty() { + String::new() + } else { + format!(" · {}", addresses.join(", ")) + } + )); + snapshot.popup = Some(PopupModel { + title: "Network".to_owned(), + content: PopupContent::Rows(vec![ + PopupRow { + id: None, + label: "Connection".to_owned(), + detail: Some(entry.connection.clone()), + state: "normal".to_owned(), + enabled: false, + }, + PopupRow { + id: None, + label: "Interface".to_owned(), + detail: Some(entry.device.clone()), + state: "normal".to_owned(), + enabled: false, + }, + PopupRow { + id: Some("open-settings".to_owned()), + label: "Open connection editor".to_owned(), + detail: None, + state: "normal".to_owned(), + enabled: true, + }, + ]), + }); + snapshot + } else { + ModuleSnapshot::text(settings.format_disconnected.clone()).with_state("disconnected") + }; + snapshot.visible = true; + sender + .send(ModuleEvent { + module: name.to_owned(), + snapshot, + }) + .map_err(|_| anyhow::anyhow!("UI event channel closed")) +} + +#[derive(Debug, Clone)] +struct NetworkEntry { + device: String, + kind: String, + connected: bool, + connection: String, +} + +fn parse_network_line(line: &str) -> Option { + let mut fields = line.splitn(4, ':'); + let device = fields.next()?.to_owned(); + let kind = fields.next()?.to_owned(); + let state = fields.next()?.to_owned(); + let connection = fields.next().unwrap_or_default().to_owned(); + Some(NetworkEntry { + device, + kind, + connected: state.starts_with("connected"), + connection, + }) +} + +fn network_addresses(device: &str) -> Result> { + let output = Command::new("nmcli") + .args([ + "--terse", + "--get-values", + "IP4.ADDRESS", + "device", + "show", + device, + ]) + .output()?; + if !output.status.success() { + return Ok(Vec::new()); + } + Ok(String::from_utf8(output.stdout)? + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned) + .collect()) +} + +pub fn wpctl(args: &[&str]) -> Result<()> { + let status = Command::new("wpctl").args(args).status()?; + if !status.success() { + bail!("wpctl exited with {status}"); + } + Ok(()) +} + +pub fn change_volume(settings: &AudioModule, increase: bool) -> Result<()> { + let change = if increase { + format!("{}%+", settings.step_percent) + } else { + format!("{}%-", settings.step_percent) + }; + let mut args = vec!["set-volume"]; + if increase { + args.extend(["-l", "1.5"]); + } + args.extend(["@DEFAULT_AUDIO_SINK@", &change]); + wpctl(&args) +} + +fn monitored_lines( + program: &str, + args: &[&str], + stop: &AtomicBool, + children: &ChildRegistry, + debounce: Duration, + mut callback: F, +) -> Result<()> +where + F: FnMut() -> Result<()>, +{ + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("could not start {program}"))?; + let stdout = child + .stdout + .take() + .context("monitor stdout was not piped")?; + let child = Arc::new(Mutex::new(child)); + register_child(children, &child); + if stop.load(Ordering::Acquire) + && let Ok(mut process) = child.lock() + { + let _ = process.kill(); + } + + let mut last_update = Instant::now() + .checked_sub(debounce) + .unwrap_or_else(Instant::now); + for line in BufReader::new(stdout).lines() { + if stop.load(Ordering::Acquire) { + break; + } + line.with_context(|| format!("could not read {program} output"))?; + if last_update.elapsed() >= debounce { + if let Err(error) = callback() { + tracing::debug!(%error, program, "monitor refresh failed"); + } + last_update = Instant::now(); + } + } + let status = wait_child(&child, stop)?; + if !status.success() && !stop.load(Ordering::Acquire) { + bail!("{program} monitor exited with {status}"); + } + unregister_child(children, &child); + Ok(()) +} + +pub(super) fn wait_child(child: &Arc>, stop: &AtomicBool) -> Result { + loop { + let status = { + let mut process = child + .lock() + .map_err(|_| anyhow::anyhow!("module child lock was poisoned"))?; + if stop.load(Ordering::Acquire) { + let _ = process.kill(); + } + process.try_wait()? + }; + if let Some(status) = status { + return Ok(status); + } + thread::park_timeout(Duration::from_millis(10)); + } +} + +pub(super) fn register_child(children: &ChildRegistry, child: &Arc>) { + if let Ok(mut children) = children.lock() { + children.push(Arc::clone(child)); + } +} + +pub(super) fn unregister_child(children: &ChildRegistry, child: &Arc>) { + if let Ok(mut children) = children.lock() { + children.retain(|candidate| !Arc::ptr_eq(candidate, child)); + } +} + +pub(super) fn backoff(failures: u32) -> Duration { + Duration::from_secs(1_u64 << failures.min(6)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_network_status() { + let parsed = parse_network_line("wlan0:wifi:connected:home").unwrap(); + assert_eq!(parsed.device, "wlan0"); + assert!(parsed.connected); + assert_eq!(parsed.connection, "home"); + } + + #[test] + fn stopped_child_wait_is_interruptible() { + let child = Command::new("sleep").arg("30").spawn().unwrap(); + let child = Arc::new(Mutex::new(child)); + let stop = Arc::new(AtomicBool::new(false)); + let waiter_child = Arc::clone(&child); + let waiter_stop = Arc::clone(&stop); + let waiter = thread::spawn(move || wait_child(&waiter_child, &waiter_stop).unwrap()); + + thread::sleep(Duration::from_millis(20)); + stop.store(true, Ordering::Release); + waiter.thread().unpark(); + let status = waiter.join().unwrap(); + + assert!(!status.success()); + } +} diff --git a/src/modules/sway.rs b/src/modules/sway.rs new file mode 100644 index 0000000..2b460e5 --- /dev/null +++ b/src/modules/sway.rs @@ -0,0 +1,638 @@ +use std::{ + collections::BTreeMap, + env, + io::{self, Read, Write}, + os::unix::net::UnixStream, + path::PathBuf, + process::Command, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use calloop::channel::Sender; +use serde::Deserialize; +use serde_json::Value as JsonValue; + +use crate::{ + config::{Config, ModeModule, ModuleConfig, ScratchpadModule, WorkspacesModule}, + format::{Value, expand}, + model::{ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow, Segment}, +}; + +const MAGIC: &[u8; 6] = b"i3-ipc"; +const GET_WORKSPACES: u32 = 1; +const SUBSCRIBE: u32 = 2; +const RUN_COMMAND: u32 = 0; +const GET_TREE: u32 = 4; +const GET_BINDING_STATE: u32 = 12; +const EVENT_MASK: u32 = 0x8000_0000; +const EVENT_WORKSPACE: u32 = EVENT_MASK; +const EVENT_OUTPUT: u32 = EVENT_MASK | 1; +const EVENT_MODE: u32 = EVENT_MASK | 2; +const EVENT_WINDOW: u32 = EVENT_MASK | 3; +const EVENT_SHUTDOWN: u32 = EVENT_MASK | 6; + +#[derive(Debug, Clone, Default)] +pub struct ConfiguredSwayModules { + workspaces: Vec<(String, WorkspacesModule)>, + modes: Vec<(String, ModeModule)>, + scratchpads: Vec<(String, ScratchpadModule)>, +} + +impl ConfiguredSwayModules { + pub fn from_config(config: &Config) -> Self { + let mut configured = Self::default(); + for (name, module) in &config.modules { + if !module.common().enabled { + continue; + } + match module { + ModuleConfig::Workspaces(settings) => { + configured.workspaces.push((name.clone(), settings.clone())); + } + ModuleConfig::Mode(settings) => { + configured.modes.push((name.clone(), settings.clone())); + } + ModuleConfig::Scratchpad(settings) => { + configured + .scratchpads + .push((name.clone(), settings.clone())); + } + _ => {} + } + } + configured + } + + pub fn is_empty(&self) -> bool { + self.workspaces.is_empty() && self.modes.is_empty() && self.scratchpads.is_empty() + } +} + +pub fn spawn( + modules: ConfiguredSwayModules, + sender: Sender, + stop: Arc, +) -> JoinHandle<()> { + thread::Builder::new() + .name("lightbar-sway-ipc".to_owned()) + .spawn(move || { + let mut failures = 0_u32; + let mut last_good = BTreeMap::new(); + while !stop.load(Ordering::Acquire) { + match run_subscription(&modules, &sender, &stop, &mut last_good) { + Ok(()) if stop.load(Ordering::Acquire) => break, + Ok(()) => failures = 0, + Err(error) => { + failures = failures.saturating_add(1); + tracing::warn!(%error, "Sway IPC disconnected"); + publish_stale(&modules, &last_good, &sender); + } + } + let delay = Duration::from_secs(1_u64 << failures.min(5)); + thread::park_timeout(delay); + } + }) + .expect("Sway IPC worker thread") +} + +fn run_subscription( + modules: &ConfiguredSwayModules, + sender: &Sender, + stop: &AtomicBool, + last_good: &mut BTreeMap, +) -> Result<()> { + let socket_path = socket_path()?; + // Keep a separate request connection open for the lifetime of the event + // subscription. Besides avoiding connection churn, this lets an existing + // bar keep working if the filesystem name of Sway's listening socket is + // accidentally removed while the compositor is still running. + let mut control = connect(&socket_path, Duration::from_secs(3))?; + let mut events = connect(&socket_path, Duration::from_secs(2))?; + write_message( + &mut events, + SUBSCRIBE, + br#"["workspace","mode","window","output","shutdown"]"#, + )?; + let (_, reply) = read_message(&mut events)?; + let accepted = serde_json::from_slice::(&reply)? + .get("success") + .and_then(JsonValue::as_bool) + .unwrap_or(false); + if !accepted { + bail!("Sway rejected event subscription"); + } + + refresh_workspaces(modules, sender, &mut control, last_good)?; + refresh_mode(modules, sender, &mut control, last_good)?; + refresh_scratchpad(modules, sender, &mut control, last_good)?; + + while !stop.load(Ordering::Acquire) { + match read_message(&mut events) { + Ok((EVENT_WORKSPACE | EVENT_OUTPUT, _)) => { + refresh_workspaces(modules, sender, &mut control, last_good)?; + } + Ok((EVENT_MODE, payload)) => { + publish_mode_event(modules, sender, &payload, last_good)?; + } + Ok((EVENT_WINDOW, payload)) if window_may_affect_scratchpad(&payload) => { + refresh_scratchpad(modules, sender, &mut control, last_good)?; + } + Ok((EVENT_WINDOW, _)) => {} + Ok((EVENT_SHUTDOWN, _)) => bail!("Sway is shutting down"), + Ok((kind, _)) => tracing::trace!(kind, "ignored Sway IPC event"), + Err(error) + if error.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) + }) => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + +fn refresh_workspaces( + modules: &ConfiguredSwayModules, + sender: &Sender, + control: &mut UnixStream, + last_good: &mut BTreeMap, +) -> Result<()> { + if modules.workspaces.is_empty() { + return Ok(()); + } + let payload = request_on(control, GET_WORKSPACES, b"")?; + let mut workspaces: Vec = serde_json::from_slice(&payload)?; + workspaces.sort_by(|left, right| { + left.num + .cmp(&right.num) + .then_with(|| left.name.cmp(&right.name)) + }); + for (name, settings) in &modules.workspaces { + let segments = workspaces + .iter() + .map(|workspace| { + let index = if workspace.num >= 0 { + workspace.num.to_string() + } else { + workspace.name.clone() + }; + let text = expand( + &settings.format, + &std::collections::BTreeMap::from([ + ("index", Value::from(index)), + ("name", Value::from(workspace.name.clone())), + ]), + ); + Segment { + id: Some(workspace.name.clone()), + text, + state: if workspace.urgent { + "urgent" + } else if workspace.focused { + "focused" + } else if workspace.visible { + "active" + } else { + "normal" + } + .to_owned(), + output: Some(workspace.output.clone()), + icon: None, + } + }) + .collect(); + publish_snapshot( + name, + ModuleSnapshot { + segments, + visible: true, + ..ModuleSnapshot::default() + }, + sender, + last_good, + ); + } + Ok(()) +} + +fn refresh_mode( + modules: &ConfiguredSwayModules, + sender: &Sender, + control: &mut UnixStream, + last_good: &mut BTreeMap, +) -> Result<()> { + if modules.modes.is_empty() { + return Ok(()); + } + let payload = request_on(control, GET_BINDING_STATE, b"")?; + let mode: BindingState = serde_json::from_slice(&payload)?; + publish_mode(modules, sender, &mode.name, last_good); + Ok(()) +} + +fn publish_mode_event( + modules: &ConfiguredSwayModules, + sender: &Sender, + payload: &[u8], + last_good: &mut BTreeMap, +) -> Result<()> { + let mode: ModeEvent = serde_json::from_slice(payload)?; + publish_mode(modules, sender, &mode.change, last_good); + Ok(()) +} + +fn publish_mode( + modules: &ConfiguredSwayModules, + sender: &Sender, + mode: &str, + last_good: &mut BTreeMap, +) { + for (name, settings) in &modules.modes { + let snapshot = if mode == "default" { + ModuleSnapshot::default() + } else { + ModuleSnapshot::text(expand( + &settings.format, + &std::collections::BTreeMap::from([("mode", Value::from(mode))]), + )) + .with_state("urgent") + }; + publish_snapshot(name, snapshot, sender, last_good); + } +} + +fn refresh_scratchpad( + modules: &ConfiguredSwayModules, + sender: &Sender, + control: &mut UnixStream, + last_good: &mut BTreeMap, +) -> Result<()> { + if modules.scratchpads.is_empty() { + return Ok(()); + } + let tree: TreeNode = serde_json::from_slice(&request_on(control, GET_TREE, b"")?)?; + let scratch = find_scratch_node(&tree); + let windows: Vec<&TreeNode> = scratch.map_or_else(Vec::new, |node| { + node.floating_nodes.iter().chain(&node.nodes).collect() + }); + for (name, settings) in &modules.scratchpads { + let count = i64::try_from(windows.len()).unwrap_or(i64::MAX); + let text = if windows.is_empty() { + settings.empty_format.clone() + } else { + expand( + &settings.format, + &std::collections::BTreeMap::from([("count", Value::from(count))]), + ) + }; + let popup_rows = windows + .iter() + .map(|window| PopupRow { + id: Some(window.id.to_string()), + label: window + .name + .clone() + .or_else(|| window.app_id.clone()) + .unwrap_or_else(|| "Unnamed scratchpad window".to_owned()), + detail: window.app_id.clone(), + state: "normal".to_owned(), + enabled: true, + }) + .collect(); + let mut snapshot = ModuleSnapshot::text(text); + snapshot.tooltip = Some(format!("{} scratchpad window(s)", windows.len())); + snapshot.popup = Some(PopupModel { + title: "Scratchpad".to_owned(), + content: PopupContent::Rows(popup_rows), + }); + publish_snapshot(name, snapshot, sender, last_good); + } + Ok(()) +} + +fn find_scratch_node(node: &TreeNode) -> Option<&TreeNode> { + if node.name.as_deref() == Some("__i3_scratch") { + return Some(node); + } + node.nodes + .iter() + .chain(&node.floating_nodes) + .find_map(find_scratch_node) +} + +fn window_may_affect_scratchpad(payload: &[u8]) -> bool { + let Ok(event) = serde_json::from_slice::(payload) else { + // A future Sway payload shape should cost one safe refresh, not leave + // the scratchpad permanently stale. + return true; + }; + let scratchpad_state = event + .pointer("/container/scratchpad_state") + .and_then(JsonValue::as_str) + .unwrap_or("none"); + scratchpad_state != "none" || event.get("change").and_then(JsonValue::as_str) == Some("move") +} + +fn publish_snapshot( + name: &str, + snapshot: ModuleSnapshot, + sender: &Sender, + last_good: &mut BTreeMap, +) { + last_good.insert(name.to_owned(), snapshot.clone()); + let _ = sender.send(ModuleEvent { + module: name.to_owned(), + snapshot, + }); +} + +fn publish_stale( + modules: &ConfiguredSwayModules, + last_good: &BTreeMap, + sender: &Sender, +) { + // Keep the last focused/urgent workspace styling intact. The generic + // `stale` rendering state intentionally dims a module, which would make + // the selected workspace appear to vanish even though its segment is + // still present. + for (name, _) in &modules.workspaces { + let Some(snapshot) = last_good.get(name) else { + continue; + }; + let _ = sender.send(ModuleEvent { + module: name.clone(), + snapshot: disconnected_workspace_snapshot(snapshot), + }); + } + + for name in modules + .modes + .iter() + .map(|(name, _)| name) + .chain(modules.scratchpads.iter().map(|(name, _)| name)) + { + if let Some(snapshot) = last_good.get(name) { + let _ = sender.send(ModuleEvent { + module: name.clone(), + snapshot: stale_snapshot(snapshot), + }); + } + } +} + +fn disconnected_workspace_snapshot(snapshot: &ModuleSnapshot) -> ModuleSnapshot { + let mut disconnected = snapshot.clone(); + disconnected.tooltip = Some("Sway IPC disconnected; showing last known workspaces".to_owned()); + disconnected +} + +fn stale_snapshot(snapshot: &ModuleSnapshot) -> ModuleSnapshot { + let mut stale = snapshot.clone(); + stale.stale = true; + stale +} + +pub fn run_sway_command(command: &str) -> Result<()> { + let payload = request(RUN_COMMAND, command.as_bytes())?; + let replies: Vec = serde_json::from_slice(&payload)?; + if let Some(failure) = replies.iter().find(|reply| !reply.success) { + bail!( + "Sway command failed: {}", + failure.error.as_deref().unwrap_or("unknown error") + ); + } + Ok(()) +} + +fn request(kind: u32, payload: &[u8]) -> Result> { + let path = socket_path()?; + let mut stream = connect(&path, Duration::from_secs(3))?; + request_on(&mut stream, kind, payload) +} + +fn connect(path: &std::path::Path, read_timeout: Duration) -> Result { + let stream = UnixStream::connect(path) + .with_context(|| format!("could not connect to Sway IPC at {}", path.display()))?; + stream.set_read_timeout(Some(read_timeout))?; + stream.set_write_timeout(Some(Duration::from_secs(3)))?; + Ok(stream) +} + +fn request_on(stream: &mut (impl Read + Write), kind: u32, payload: &[u8]) -> Result> { + write_message(stream, kind, payload)?; + let (reply_kind, reply) = read_message(stream)?; + if reply_kind != kind { + bail!("unexpected Sway IPC reply type {reply_kind}, expected {kind}"); + } + Ok(reply) +} + +fn socket_path() -> Result { + if let Some(path) = env::var_os("SWAYSOCK").or_else(|| env::var_os("I3SOCK")) { + return Ok(PathBuf::from(path)); + } + let output = Command::new("sway") + .arg("--get-socketpath") + .output() + .context("SWAYSOCK is unset and sway --get-socketpath could not be executed")?; + if !output.status.success() { + bail!("sway --get-socketpath exited with {}", output.status); + } + let path = String::from_utf8(output.stdout)?.trim().to_owned(); + if path.is_empty() { + bail!("sway --get-socketpath returned an empty path"); + } + Ok(PathBuf::from(path)) +} + +fn write_message(stream: &mut impl Write, kind: u32, payload: &[u8]) -> Result<()> { + let length = u32::try_from(payload.len()).context("Sway IPC payload exceeds 4 GiB")?; + stream.write_all(MAGIC)?; + stream.write_all(&length.to_ne_bytes())?; + stream.write_all(&kind.to_ne_bytes())?; + stream.write_all(payload)?; + stream.flush()?; + Ok(()) +} + +fn read_message(stream: &mut impl Read) -> Result<(u32, Vec)> { + let mut header = [0_u8; 14]; + stream.read_exact(&mut header)?; + if &header[..6] != MAGIC { + return Err(anyhow!("invalid Sway IPC magic")); + } + let length = u32::from_ne_bytes(header[6..10].try_into().expect("fixed header length")); + let kind = u32::from_ne_bytes(header[10..14].try_into().expect("fixed header length")); + if length > 64 * 1024 * 1024 { + bail!("Sway IPC payload exceeds 64 MiB safety limit"); + } + let mut payload = vec![0_u8; length as usize]; + stream.read_exact(&mut payload)?; + Ok((kind, payload)) +} + +#[derive(Debug, Deserialize)] +struct Workspace { + num: i64, + name: String, + visible: bool, + focused: bool, + urgent: bool, + output: String, +} + +#[derive(Debug, Deserialize)] +struct BindingState { + name: String, +} + +#[derive(Debug, Deserialize)] +struct ModeEvent { + change: String, +} + +#[derive(Debug, Deserialize)] +struct TreeNode { + #[serde(default)] + id: i64, + #[serde(default)] + name: Option, + #[serde(default)] + app_id: Option, + #[serde(default)] + nodes: Vec, + #[serde(default)] + floating_nodes: Vec, +} + +#[derive(Debug, Deserialize)] +struct CommandReply { + success: bool, + #[serde(default)] + error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MemoryDuplex { + replies: std::io::Cursor>, + requests: Vec, + } + + impl Read for MemoryDuplex { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.replies.read(buffer) + } + } + + impl Write for MemoryDuplex { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.requests.extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn finds_nested_scratch_node() { + let root: TreeNode = serde_json::from_str( + r#"{"name":"root","nodes":[{"name":"__i3","nodes":[{"name":"__i3_scratch","floating_nodes":[{"name":"term"}]}]}]}"#, + ) + .unwrap(); + let scratch = find_scratch_node(&root).unwrap(); + assert_eq!(scratch.floating_nodes[0].name.as_deref(), Some("term")); + } + + #[test] + fn round_trips_an_ipc_frame() { + let mut frame = Vec::new(); + write_message(&mut frame, GET_WORKSPACES, br#"[{"name":"1"}]"#).unwrap(); + let (kind, payload) = read_message(&mut std::io::Cursor::new(frame)).unwrap(); + assert_eq!(kind, GET_WORKSPACES); + assert_eq!(payload, br#"[{"name":"1"}]"#); + } + + #[test] + fn reuses_a_control_connection_for_multiple_requests() { + let mut replies = Vec::new(); + write_message(&mut replies, GET_WORKSPACES, br#"{"workspaces":true}"#).unwrap(); + write_message( + &mut replies, + GET_BINDING_STATE, + br#"{"binding_state":true}"#, + ) + .unwrap(); + let mut connection = MemoryDuplex { + replies: std::io::Cursor::new(replies), + requests: Vec::new(), + }; + + assert_eq!( + request_on(&mut connection, GET_WORKSPACES, b"").unwrap(), + br#"{"workspaces":true}"# + ); + assert_eq!( + request_on(&mut connection, GET_BINDING_STATE, b"").unwrap(), + br#"{"binding_state":true}"# + ); + + let mut requests = std::io::Cursor::new(connection.requests); + for expected_kind in [GET_WORKSPACES, GET_BINDING_STATE] { + let (kind, payload) = read_message(&mut requests).unwrap(); + assert_eq!(kind, expected_kind); + assert!(payload.is_empty()); + } + } + + #[test] + fn disconnected_workspace_snapshot_preserves_focused_style() { + let snapshot = ModuleSnapshot { + segments: vec![Segment { + id: Some("2:web".to_owned()), + text: "2".to_owned(), + state: "focused".to_owned(), + output: Some("DP-1".to_owned()), + icon: None, + }], + visible: true, + ..ModuleSnapshot::default() + }; + + let disconnected = disconnected_workspace_snapshot(&snapshot); + assert!(!disconnected.stale); + assert!(disconnected.visible); + assert_eq!(disconnected.segments, snapshot.segments); + assert!( + disconnected + .tooltip + .as_deref() + .is_some_and(|tooltip| tooltip.contains("last known")) + ); + } + + #[test] + fn ignores_unrelated_window_title_churn() { + assert!(!window_may_affect_scratchpad( + br#"{"change":"title","container":{"scratchpad_state":"none"}}"# + )); + assert!(window_may_affect_scratchpad( + br#"{"change":"title","container":{"scratchpad_state":"changed"}}"# + )); + assert!(window_may_affect_scratchpad( + br#"{"change":"move","container":{"scratchpad_state":"none"}}"# + )); + } +} diff --git a/src/modules/sysfs.rs b/src/modules/sysfs.rs new file mode 100644 index 0000000..80c4cbd --- /dev/null +++ b/src/modules/sysfs.rs @@ -0,0 +1,427 @@ +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + process::Command, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use anyhow::{Context, Result, bail}; +use calloop::channel::Sender; + +use crate::{ + config::{BacklightModule, BatteryModule, TemperatureModule}, + format::{Value, expand}, + model::{ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow, SliderModel}, +}; + +pub fn spawn_temperature( + name: String, + settings: TemperatureModule, + sender: Sender, + stop: Arc, +) -> JoinHandle<()> { + spawn_polling(name, settings.interval.0, sender, stop, move || { + temperature_snapshot(&settings) + }) +} + +pub fn spawn_backlight( + name: String, + settings: BacklightModule, + sender: Sender, + stop: Arc, +) -> JoinHandle<()> { + spawn_polling(name, Duration::from_secs(5), sender, stop, move || { + backlight_snapshot(&settings) + }) +} + +pub fn spawn_battery( + name: String, + settings: BatteryModule, + sender: Sender, + stop: Arc, +) -> JoinHandle<()> { + spawn_polling(name, Duration::from_secs(30), sender, stop, move || { + battery_snapshot(&settings) + }) +} + +fn spawn_polling( + name: String, + interval: Duration, + sender: Sender, + stop: Arc, + mut read: F, +) -> JoinHandle<()> +where + F: FnMut() -> Result + Send + 'static, +{ + thread::Builder::new() + .name(format!("lightbar-sysfs-{name}")) + .spawn(move || { + let mut last_good: Option = None; + let mut failures = 0_u32; + while !stop.load(Ordering::Acquire) { + let snapshot = match read() { + Ok(snapshot) => { + failures = 0; + last_good = Some(snapshot.clone()); + snapshot + } + Err(error) => { + failures = failures.saturating_add(1); + tracing::debug!(module = %name, %error, "sysfs module update failed"); + let mut stale = last_good.clone().unwrap_or_default(); + stale.stale = last_good.is_some(); + stale + } + }; + if sender + .send(ModuleEvent { + module: name.clone(), + snapshot, + }) + .is_err() + { + break; + } + let retry = if failures == 0 { + interval + } else { + super::process::backoff(failures).min(interval) + }; + thread::park_timeout(retry); + } + }) + .expect("sysfs worker thread") +} + +fn temperature_snapshot(settings: &TemperatureModule) -> Result { + let sensor = settings + .sensor + .clone() + .map_or_else(|| discover_temperature(settings.label.as_deref()), Ok)?; + let raw = read_number(&sensor)?; + let temperature = if raw.abs() > 1_000.0 { + raw / 1_000.0 + } else { + raw + }; + let text = expand( + &settings.format, + &BTreeMap::from([("temperature_c", Value::from(temperature))]), + ); + let state = if temperature >= settings.critical_c { + "critical" + } else if temperature >= settings.warning_c { + "warning" + } else { + "normal" + }; + let mut snapshot = ModuleSnapshot::text(text).with_state(state); + snapshot.tooltip = Some(format!("{}: {temperature:.1}°C", sensor.display())); + snapshot.popup = Some(PopupModel { + title: "Temperature".to_owned(), + content: PopupContent::Rows(vec![PopupRow { + id: None, + label: sensor.display().to_string(), + detail: Some(format!("{temperature:.1}°C")), + state: state.to_owned(), + enabled: false, + }]), + }); + Ok(snapshot) +} + +fn discover_temperature(label: Option<&str>) -> Result { + let mut candidates = Vec::new(); + for hwmon in read_dirs(Path::new("/sys/class/hwmon"))? { + let chip = read_trimmed(&hwmon.join("name")).unwrap_or_default(); + for input in read_dirs(&hwmon)?.into_iter().filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("temp") && name.ends_with("_input")) + }) { + let base = input + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix("_input")) + .unwrap_or_default(); + let sensor_label = + read_trimmed(&hwmon.join(format!("{base}_label"))).unwrap_or_default(); + if label.is_some_and(|wanted| { + !sensor_label.eq_ignore_ascii_case(wanted) && !chip.eq_ignore_ascii_case(wanted) + }) { + continue; + } + let score = temperature_score(&chip, &sensor_label); + candidates.push((score, input)); + } + } + candidates + .into_iter() + .max_by_key(|(score, _)| *score) + .map(|(_, path)| path) + .context("no hwmon temperature sensor found") +} + +fn temperature_score(chip: &str, label: &str) -> u8 { + let combined = format!("{chip} {label}").to_ascii_lowercase(); + if combined.contains("package") || combined.contains("tctl") { + 3 + } else if combined.contains("coretemp") || combined.contains("k10temp") { + 2 + } else { + 1 + } +} + +fn backlight_snapshot(settings: &BacklightModule) -> Result { + let device = discover_backlight(settings.device.as_deref())?; + let current = read_number(&device.join("actual_brightness")) + .or_else(|_| read_number(&device.join("brightness")))?; + let maximum = read_number(&device.join("max_brightness"))?; + if maximum <= 0.0 { + bail!("backlight maximum is zero"); + } + let percent = (current / maximum * 100.0).round().clamp(0.0, 100.0) as i64; + let text = expand( + &settings.format, + &BTreeMap::from([("percent", Value::from(percent))]), + ); + let mut snapshot = ModuleSnapshot::text(text); + snapshot.tooltip = Some(format!("Backlight: {percent}%")); + snapshot.popup = Some(PopupModel { + title: "Backlight".to_owned(), + content: PopupContent::Slider(SliderModel { + label: device + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Backlight") + .to_owned(), + value: f64::from(percent as i32), + minimum: 0.0, + maximum: 100.0, + muted: false, + detail_rows: Vec::new(), + }), + }); + Ok(snapshot) +} + +pub fn adjust_backlight(settings: &BacklightModule, delta_percent: i16) -> Result<()> { + let device = discover_backlight(settings.device.as_deref())?; + let current = read_number(&device.join("brightness"))?; + let maximum = read_number(&device.join("max_brightness"))?; + let delta = maximum * f64::from(delta_percent) / 100.0; + let value = (current + delta).round().clamp(1.0, maximum) as u64; + set_backlight_value(&device, value) +} + +pub fn set_backlight_percent(settings: &BacklightModule, percent: f64) -> Result<()> { + let device = discover_backlight(settings.device.as_deref())?; + let maximum = read_number(&device.join("max_brightness"))?; + let value = (maximum * percent.clamp(0.0, 100.0) / 100.0) + .round() + .clamp(1.0, maximum) as u64; + set_backlight_value(&device, value) +} + +fn set_backlight_value(device: &Path, value: u64) -> Result<()> { + let brightness = device.join("brightness"); + if fs::write(&brightness, value.to_string()).is_ok() { + return Ok(()); + } + + let device_name = device + .file_name() + .and_then(|name| name.to_str()) + .context("backlight device has no UTF-8 name")?; + let status = Command::new("busctl") + .args([ + "--system", + "call", + "org.freedesktop.login1", + "/org/freedesktop/login1/session/self", + "org.freedesktop.login1.Session", + "SetBrightness", + "ssu", + "backlight", + device_name, + &value.to_string(), + ]) + .status() + .context("could not invoke logind SetBrightness")?; + if !status.success() { + bail!("logind SetBrightness exited with {status}"); + } + Ok(()) +} + +fn discover_backlight(device: Option<&str>) -> Result { + let root = Path::new("/sys/class/backlight"); + if let Some(device) = device { + let path = root.join(device); + if path.is_dir() { + return Ok(path); + } + bail!( + "configured backlight device {} does not exist", + path.display() + ); + } + read_dirs(root)? + .into_iter() + .max_by(|left, right| { + read_number(&left.join("max_brightness")) + .unwrap_or_default() + .total_cmp(&read_number(&right.join("max_brightness")).unwrap_or_default()) + }) + .context("no backlight device found") +} + +fn battery_snapshot(settings: &BatteryModule) -> Result { + let battery = discover_battery()?; + let percent = read_number(&battery.join("capacity"))? + .round() + .clamp(0.0, 100.0) as u8; + let status = read_trimmed(&battery.join("status")).unwrap_or_else(|_| "Unknown".to_owned()); + let icon = battery_icon(percent); + let values = BTreeMap::from([ + ("percent", Value::from(percent)), + ("status", Value::from(status.clone())), + ("icon", Value::from(icon)), + ]); + let format = if percent >= 99 { + &settings.full_format + } else { + &settings.format + }; + let state = if percent <= settings.critical_percent { + "critical" + } else if percent <= settings.warning_percent { + "warning" + } else { + "normal" + }; + let mut snapshot = ModuleSnapshot::text(expand(format, &values)).with_state(state); + let time = estimated_battery_time(&battery, &status); + snapshot.tooltip = Some(match time.as_deref() { + Some(time) => format!("{status}: {percent}% · {time}"), + None => format!("{status}: {percent}%"), + }); + snapshot.popup = Some(PopupModel { + title: "Battery".to_owned(), + content: PopupContent::Rows(vec![ + PopupRow { + id: None, + label: "Charge".to_owned(), + detail: Some(format!("{percent}%")), + state: state.to_owned(), + enabled: false, + }, + PopupRow { + id: None, + label: "Status".to_owned(), + detail: Some(status), + state: "normal".to_owned(), + enabled: false, + }, + PopupRow { + id: None, + label: "Estimate".to_owned(), + detail: time, + state: "normal".to_owned(), + enabled: false, + }, + ]), + }); + Ok(snapshot) +} + +fn discover_battery() -> Result { + read_dirs(Path::new("/sys/class/power_supply"))? + .into_iter() + .find(|path| { + read_trimmed(&path.join("type")).is_ok_and(|kind| kind.eq_ignore_ascii_case("battery")) + }) + .context("no battery power supply found") +} + +fn battery_icon(percent: u8) -> &'static str { + match percent { + 0..=10 => "", + 11..=35 => "", + 36..=60 => "", + 61..=85 => "", + _ => "", + } +} + +fn estimated_battery_time(battery: &Path, status: &str) -> Option { + let power = read_number(&battery.join("power_now")).ok()?; + if power <= 0.0 { + return None; + } + let energy = if status.eq_ignore_ascii_case("charging") { + read_number(&battery.join("energy_full")).ok()? + - read_number(&battery.join("energy_now")).ok()? + } else { + read_number(&battery.join("energy_now")).ok()? + }; + let hours = (energy / power).max(0.0); + let total_minutes = (hours * 60.0).round() as u64; + Some(format!( + "{}h {:02}m", + total_minutes / 60, + total_minutes % 60 + )) +} + +fn read_dirs(path: &Path) -> Result> { + let mut paths = Vec::new(); + for entry in fs::read_dir(path).with_context(|| format!("cannot read {}", path.display()))? { + let entry = entry?; + paths.push(entry.path()); + } + Ok(paths) +} + +fn read_trimmed(path: &Path) -> Result { + Ok(fs::read_to_string(path) + .with_context(|| format!("cannot read {}", path.display()))? + .trim() + .to_owned()) +} + +fn read_number(path: &Path) -> Result { + read_trimmed(path)? + .parse() + .with_context(|| format!("{} does not contain a number", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chooses_expected_battery_icons() { + assert_eq!(battery_icon(5), ""); + assert_eq!(battery_icon(50), ""); + assert_eq!(battery_icon(100), ""); + } + + #[test] + fn scores_cpu_package_sensors_first() { + assert!( + temperature_score("coretemp", "Package id 0") > temperature_score("acpitz", "temp1") + ); + } +} diff --git a/src/modules/tray.rs b/src/modules/tray.rs new file mode 100644 index 0000000..75c4059 --- /dev/null +++ b/src/modules/tray.rs @@ -0,0 +1,751 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow}; +use calloop::channel::Sender; +use futures_lite::StreamExt; +use tokio::sync::mpsc; +use zbus::{ + Connection, MatchRule, MessageStream, Proxy, + fdo::{DBusProxy, NameOwnerChanged}, + message::{Header, Type}, + names::BusName, + object_server::SignalEmitter, +}; + +use crate::{ + config::TrayModule, + model::{IconPixmap, ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow, Segment}, +}; + +const WATCHER_SERVICE: &str = "org.kde.StatusNotifierWatcher"; +const WATCHER_PATH: &str = "/StatusNotifierWatcher"; +const WATCHER_INTERFACE: &str = "org.kde.StatusNotifierWatcher"; +const ITEM_INTERFACE: &str = "org.kde.StatusNotifierItem"; + +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] +struct TrayAddress { + service: String, + path: String, +} + +impl TrayAddress { + fn parse(registered: &str) -> Option { + if registered.starts_with('/') { + return None; + } + let (service, path) = registered.find('/').map_or_else( + || (registered, "/StatusNotifierItem"), + |index| (®istered[..index], ®istered[index..]), + ); + (!service.is_empty() && path.starts_with('/')).then(|| Self { + service: service.to_owned(), + path: path.to_owned(), + }) + } + + fn id(&self) -> String { + format!("{}|{}", self.service, self.path) + } + + fn from_id(id: &str) -> Option { + let (service, path) = id.split_once('|')?; + (!service.is_empty() && path.starts_with('/')).then(|| Self { + service: service.to_owned(), + path: path.to_owned(), + }) + } + + fn registered_name(&self) -> String { + if self.path == "/StatusNotifierItem" { + self.service.clone() + } else { + format!("{}{}", self.service, self.path) + } + } +} + +#[derive(Debug, Copy, Clone)] +enum RegistryEvent { + Changed, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct ItemRegistration { + owner: String, + service: String, +} + +type ItemRegistry = Arc>>; + +#[derive(Debug)] +struct Watcher { + items: ItemRegistry, + hosts: Arc>>, + changed: mpsc::Sender, +} + +#[zbus::interface(name = "org.kde.StatusNotifierWatcher")] +impl Watcher { + async fn register_status_notifier_item( + &self, + service_or_path: String, + #[zbus(header)] header: Header<'_>, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) -> zbus::fdo::Result<()> { + let sender = header + .sender() + .map(ToString::to_string) + .ok_or_else(|| zbus::fdo::Error::Failed("registration has no sender".to_owned()))?; + let owner = sender.clone(); + let address = if service_or_path.starts_with('/') { + TrayAddress { + service: sender, + path: service_or_path, + } + } else { + TrayAddress { + service: service_or_path, + path: "/StatusNotifierItem".to_owned(), + } + }; + let registered = address.registered_name(); + let registration = ItemRegistration { + owner, + service: address.service, + }; + let previous = self + .items + .lock() + .map_err(|_| zbus::fdo::Error::Failed("watcher lock poisoned".to_owned()))? + .insert(registered.clone(), registration.clone()); + let inserted = previous.is_none(); + let changed = previous.as_ref() != Some(®istration); + if inserted { + Self::status_notifier_item_registered(&emitter, ®istered).await?; + } + if changed { + let _ = self.changed.try_send(RegistryEvent::Changed); + } + Ok(()) + } + + async fn register_status_notifier_host( + &self, + service: String, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) -> zbus::fdo::Result<()> { + let inserted = self + .hosts + .lock() + .map_err(|_| zbus::fdo::Error::Failed("watcher lock poisoned".to_owned()))? + .insert(service.clone()); + if inserted { + Self::status_notifier_host_registered(&emitter).await?; + let _ = self.changed.try_send(RegistryEvent::Changed); + } + Ok(()) + } + + #[zbus(property, name = "RegisteredStatusNotifierItems")] + fn registered_status_notifier_items(&self) -> Vec { + self.items + .lock() + .map_or_else(|_| Vec::new(), |items| items.keys().cloned().collect()) + } + + #[zbus(property, name = "IsStatusNotifierHostRegistered")] + fn is_status_notifier_host_registered(&self) -> bool { + self.hosts.lock().is_ok_and(|hosts| !hosts.is_empty()) + } + + #[zbus(property, name = "ProtocolVersion")] + fn protocol_version(&self) -> i32 { + 0 + } + + #[zbus(signal, name = "StatusNotifierItemRegistered")] + async fn status_notifier_item_registered( + emitter: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal, name = "StatusNotifierItemUnregistered")] + async fn status_notifier_item_unregistered( + emitter: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal, name = "StatusNotifierHostRegistered")] + async fn status_notifier_host_registered(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +pub fn spawn( + module: String, + settings: TrayModule, + sender: Sender, + stop: Arc, +) -> JoinHandle<()> { + thread::Builder::new() + .name(format!("lightbar-tray-{module}")) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let result = runtime + .context("could not create tray runtime") + .and_then(|runtime| runtime.block_on(run(module.clone(), settings, sender, stop))); + if let Err(error) = result { + tracing::warn!(module, error = %format_args!("{error:#}"), "tray stopped"); + } + }) + .expect("tray worker thread") +} + +async fn run( + module: String, + settings: TrayModule, + sender: Sender, + stop: Arc, +) -> Result<()> { + let connection = Connection::session() + .await + .context("could not connect to the session D-Bus")?; + let (registry_sender, mut registry_receiver) = mpsc::channel(32); + let owned_items = ensure_watcher(&connection, registry_sender).await?; + + let host_name = format!("org.kde.StatusNotifierHost-{}-1", std::process::id()); + connection + .request_name(host_name.as_str()) + .await + .context("could not own the StatusNotifierHost bus name")?; + let watcher = watcher_proxy(&connection).await?; + watcher + .call::<_, _, ()>("RegisterStatusNotifierHost", &(host_name.clone())) + .await + .context("watcher rejected StatusNotifierHost registration")?; + + let watcher_rule = MatchRule::builder() + .msg_type(Type::Signal) + .interface(WATCHER_INTERFACE)? + .build(); + let item_rule = MatchRule::builder() + .msg_type(Type::Signal) + .interface(ITEM_INTERFACE)? + .build(); + let properties_rule = MatchRule::builder() + .msg_type(Type::Signal) + .interface("org.freedesktop.DBus.Properties")? + .member("PropertiesChanged")? + .add_arg(ITEM_INTERFACE)? + .build(); + let owner_rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender("org.freedesktop.DBus")? + .interface("org.freedesktop.DBus")? + .member("NameOwnerChanged")? + .build(); + let mut watcher_signals = MessageStream::for_match_rule(watcher_rule, &connection, Some(64)) + .await + .context("could not subscribe to tray watcher changes")?; + let mut item_signals = MessageStream::for_match_rule(item_rule, &connection, Some(128)) + .await + .context("could not subscribe to tray item changes")?; + let mut property_signals = + MessageStream::for_match_rule(properties_rule, &connection, Some(128)) + .await + .context("could not subscribe to tray property changes")?; + let mut owner_signals = MessageStream::for_match_rule(owner_rule, &connection, Some(128)) + .await + .context("could not subscribe to D-Bus owner changes")?; + let mut reconcile = tokio::time::interval(Duration::from_secs(30)); + let mut stop_check = tokio::time::interval(Duration::from_millis(250)); + + publish(&connection, &module, &settings, &sender).await; + loop { + tokio::select! { + _ = stop_check.tick() => { + if stop.load(Ordering::Acquire) { + break; + } + continue; + } + _ = reconcile.tick() => {} + event = registry_receiver.recv() => { + if event.is_none() { + break; + } + } + event = watcher_signals.next() => { + if event.is_none() { + break; + } + } + event = item_signals.next() => { + if event.is_none() { + break; + } + } + event = property_signals.next() => { + if event.is_none() { + break; + } + } + event = owner_signals.next() => { + let Some(event) = event else { + break; + }; + let Some(vanished) = event.ok().and_then(vanished_bus_name) else { + continue; + }; + let Some(items) = &owned_items else { + continue; + }; + match unregister_vanished_items(&connection, items, &vanished).await { + Ok(0) => continue, + Ok(count) => { + tracing::debug!(%vanished, count, "removed vanished tray items"); + } + Err(error) => { + tracing::warn!(%vanished, %error, "could not unregister vanished tray items"); + continue; + } + } + } + } + // Collapse icon/property signal bursts into a single bounded refresh. + tokio::time::sleep(Duration::from_millis(30)).await; + publish(&connection, &module, &settings, &sender).await; + } + Ok(()) +} + +async fn ensure_watcher( + connection: &Connection, + changed: mpsc::Sender, +) -> Result> { + if watcher_proxy(connection) + .await? + .get_property::("ProtocolVersion") + .await + .is_ok() + { + tracing::debug!("using existing StatusNotifierWatcher"); + return Ok(None); + } + let items = Arc::new(Mutex::new(BTreeMap::new())); + let watcher = Watcher { + items: items.clone(), + hosts: Arc::new(Mutex::new(BTreeSet::new())), + changed, + }; + connection + .object_server() + .at(WATCHER_PATH, watcher) + .await + .context("could not export StatusNotifierWatcher")?; + connection + .request_name(WATCHER_SERVICE) + .await + .context("could not own StatusNotifierWatcher")?; + tracing::info!("providing StatusNotifierWatcher"); + Ok(Some(items)) +} + +fn vanished_bus_name(message: zbus::Message) -> Option { + let signal = NameOwnerChanged::from_message(message)?; + let args = signal.args().ok()?; + args.new_owner() + .as_ref() + .is_none() + .then(|| args.name().as_str().to_owned()) +} + +async fn unregister_vanished_items( + connection: &Connection, + items: &ItemRegistry, + vanished: &str, +) -> Result { + let removed = { + let mut items = items + .lock() + .map_err(|_| anyhow!("watcher item registry lock poisoned"))?; + remove_registrations_for_name(&mut items, vanished) + }; + if removed.is_empty() { + return Ok(0); + } + + let emitter = SignalEmitter::new(connection, WATCHER_PATH)?; + for registered in &removed { + Watcher::status_notifier_item_unregistered(&emitter, registered).await?; + } + Ok(removed.len()) +} + +fn remove_registrations_for_name( + items: &mut BTreeMap, + vanished: &str, +) -> Vec { + let mut removed = Vec::new(); + items.retain(|registered, registration| { + let keep = registration.owner != vanished && registration.service != vanished; + if !keep { + removed.push(registered.clone()); + } + keep + }); + removed +} + +async fn watcher_proxy(connection: &Connection) -> Result> { + Proxy::new(connection, WATCHER_SERVICE, WATCHER_PATH, WATCHER_INTERFACE) + .await + .map_err(Into::into) +} + +async fn publish( + connection: &Connection, + module: &str, + settings: &TrayModule, + sender: &Sender, +) { + match tokio::time::timeout( + Duration::from_secs(2), + collect_snapshot(connection, settings), + ) + .await + { + Ok(Ok(snapshot)) => { + let _ = sender.send(ModuleEvent { + module: module.to_owned(), + snapshot, + }); + } + Ok(Err(error)) => tracing::warn!(%error, "could not refresh tray"), + Err(_) => tracing::warn!("tray refresh exceeded two seconds"), + } +} + +async fn collect_snapshot( + connection: &Connection, + settings: &TrayModule, +) -> Result { + let registered = watcher_proxy(connection) + .await? + .get_property::>("RegisteredStatusNotifierItems") + .await + .context("could not read registered tray items")?; + let mut addresses: BTreeSet<_> = registered + .iter() + .filter_map(|item| TrayAddress::parse(item)) + .collect(); + let dbus = DBusProxy::new(connection).await?; + let mut segments = Vec::new(); + let mut rows = Vec::new(); + for address in &addresses { + let bus_name = match BusName::try_from(address.service.as_str()) { + Ok(name) => name, + Err(error) => { + tracing::debug!(service = %address.service, %error, "ignoring invalid tray service name"); + continue; + } + }; + match dbus.name_has_owner(bus_name).await { + Ok(false) => { + tracing::debug!(service = %address.service, "ignoring vanished tray item"); + continue; + } + Ok(true) => {} + Err(error) => { + tracing::debug!(service = %address.service, %error, "could not verify tray item owner"); + } + } + match read_item(connection, address, settings).await { + Ok(Some((segment, row))) => { + segments.push(segment); + rows.push(row); + } + Ok(None) => {} + Err(error) => { + tracing::debug!(service = %address.service, %error, "ignoring broken tray item"); + } + } + } + // Deduplicate watcher bugs and ensure the set cannot grow while iterating. + addresses.clear(); + Ok(ModuleSnapshot { + visible: !segments.is_empty(), + segments, + tooltip: (!rows.is_empty()).then(|| { + rows.iter() + .map(|row| row.label.as_str()) + .collect::>() + .join("\n") + }), + popup: (!rows.is_empty()).then(|| PopupModel { + title: "System tray".to_owned(), + content: PopupContent::Rows(rows), + }), + stale: false, + }) +} + +async fn read_item( + connection: &Connection, + address: &TrayAddress, + settings: &TrayModule, +) -> Result> { + let proxy = Proxy::new( + connection, + address.service.as_str(), + address.path.as_str(), + ITEM_INTERFACE, + ) + .await?; + let status = proxy + .get_property::("Status") + .await + .context("tray item has no readable Status property")?; + if status == "Passive" { + return Ok(None); + } + let title = proxy + .get_property::("Title") + .await + .unwrap_or_else(|_| address.service.clone()); + let icon_name = proxy + .get_property::("IconName") + .await + .unwrap_or_default(); + let pixmaps = proxy + .get_property::)>>("IconPixmap") + .await + .unwrap_or_default(); + let icon = choose_pixmap(pixmaps, settings)?; + let mut segment = Segment::new(if icon.is_some() { + String::new() + } else if icon_name.is_empty() { + "●".to_owned() + } else { + // Keep a deterministic, font-renderable fallback when an item only + // exports a theme icon name. Pixmap-exporting items use the real icon. + "●".to_owned() + }); + segment.id = Some(address.id()); + segment.state = if status == "NeedsAttention" { + "urgent".to_owned() + } else { + "normal".to_owned() + }; + segment.icon = icon; + let row = PopupRow { + id: Some(address.id()), + label: title, + detail: (!icon_name.is_empty()).then_some(icon_name), + state: segment.state.clone(), + enabled: true, + }; + Ok(Some((segment, row))) +} + +fn choose_pixmap( + pixmaps: Vec<(i32, i32, Vec)>, + settings: &TrayModule, +) -> Result> { + let target = i64::from(settings.icon_size); + let selected = pixmaps + .into_iter() + .filter(|(width, height, bytes)| { + if *width <= 0 || *height <= 0 || *width > 512 || *height > 512 { + return false; + } + let expected = i64::from(*width) + .checked_mul(i64::from(*height)) + .and_then(|pixels| pixels.checked_mul(4)); + expected.is_some_and(|expected| { + usize::try_from(expected).ok() == Some(bytes.len()) + && bytes.len() <= settings.pixmap_max_bytes + }) + }) + .min_by_key(|(width, height, _)| { + (i64::from(*width) - target).abs() + (i64::from(*height) - target).abs() + }); + let Some((width, height, argb)) = selected else { + return Ok(None); + }; + let mut pixels = Vec::with_capacity(argb.len()); + for pixel in argb.chunks_exact(4) { + let alpha = pixel[0]; + let premultiply = |channel: u8| { + u8::try_from((u16::from(channel) * u16::from(alpha) + 127) / 255).unwrap_or(u8::MAX) + }; + pixels.extend_from_slice(&[ + premultiply(pixel[3]), + premultiply(pixel[2]), + premultiply(pixel[1]), + alpha, + ]); + } + Ok(Some(IconPixmap { + width: u32::try_from(width).map_err(|_| anyhow!("negative tray pixmap width"))?, + height: u32::try_from(height).map_err(|_| anyhow!("negative tray pixmap height"))?, + pixels, + })) +} + +pub fn activate_tray_item(id: &str, method: &'static str, x: i32, y: i32) -> Result<()> { + let address = TrayAddress::from_id(id).context("invalid tray item identity")?; + thread::Builder::new() + .name("lightbar-tray-action".to_owned()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let result: Result<()> = match runtime { + Ok(runtime) => runtime + .block_on(async move { + let connection = Connection::session().await?; + let proxy = + Proxy::new(&connection, address.service, address.path, ITEM_INTERFACE) + .await?; + tokio::time::timeout( + Duration::from_secs(2), + proxy.call::<_, _, ()>(method, &(x, y)), + ) + .await + .map_err(|_| zbus::Error::Failure("tray action timed out".to_owned()))??; + Ok::<_, zbus::Error>(()) + }) + .map_err(Into::into), + Err(error) => Err(error.into()), + }; + if let Err(error) = result { + tracing::warn!(%error, method, "tray action failed"); + } + }) + .context("could not start tray action")?; + Ok(()) +} + +pub fn scroll_tray_item(id: &str, delta: i32, orientation: &'static str) -> Result<()> { + let address = TrayAddress::from_id(id).context("invalid tray item identity")?; + thread::Builder::new() + .name("lightbar-tray-scroll".to_owned()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let result: Result<()> = match runtime { + Ok(runtime) => runtime + .block_on(async move { + let connection = Connection::session().await?; + let proxy = + Proxy::new(&connection, address.service, address.path, ITEM_INTERFACE) + .await?; + tokio::time::timeout( + Duration::from_secs(2), + proxy.call::<_, _, ()>("Scroll", &(delta, orientation)), + ) + .await + .map_err(|_| zbus::Error::Failure("tray scroll timed out".to_owned()))??; + Ok::<_, zbus::Error>(()) + }) + .map_err(Into::into), + Err(error) => Err(error.into()), + }; + if let Err(error) = result { + tracing::warn!(%error, "tray scroll failed"); + } + }) + .context("could not start tray scroll action")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_registered_item_forms() { + assert_eq!( + TrayAddress::parse("org.example.Item").unwrap().path, + "/StatusNotifierItem" + ); + let item = TrayAddress::parse(":1.42/Custom/Item").unwrap(); + assert_eq!(item.service, ":1.42"); + assert_eq!(TrayAddress::from_id(&item.id()), Some(item)); + } + + #[test] + fn rejects_oversized_and_converts_argb() { + let settings = TrayModule::default(); + let icon = choose_pixmap(vec![(1, 1, vec![128, 255, 0, 0])], &settings) + .unwrap() + .unwrap(); + assert_eq!(icon.pixels, vec![0, 0, 128, 128]); + assert!( + choose_pixmap(vec![(1024, 1024, vec![0; 4])], &settings) + .unwrap() + .is_none() + ); + } + + #[test] + fn removes_all_registrations_owned_by_a_vanished_bus_connection() { + let mut items = BTreeMap::from([ + ( + ":1.10".to_owned(), + ItemRegistration { + owner: ":1.10".to_owned(), + service: ":1.10".to_owned(), + }, + ), + ( + "org.example.First".to_owned(), + ItemRegistration { + owner: ":1.10".to_owned(), + service: "org.example.First".to_owned(), + }, + ), + ( + "org.example.Other".to_owned(), + ItemRegistration { + owner: ":1.11".to_owned(), + service: "org.example.Other".to_owned(), + }, + ), + ]); + + let removed = remove_registrations_for_name(&mut items, ":1.10"); + assert_eq!(removed.len(), 2); + assert!(removed.contains(&":1.10".to_owned())); + assert!(removed.contains(&"org.example.First".to_owned())); + assert_eq!(items.len(), 1); + assert!(items.contains_key("org.example.Other")); + } + + #[test] + fn removes_registration_when_its_well_known_name_is_released() { + let mut items = BTreeMap::from([( + "org.example.Tray".to_owned(), + ItemRegistration { + owner: ":1.20".to_owned(), + service: "org.example.Tray".to_owned(), + }, + )]); + + assert_eq!( + remove_registrations_for_name(&mut items, "org.example.Tray"), + vec!["org.example.Tray".to_owned()] + ); + assert!(items.is_empty()); + } +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..30ef19c --- /dev/null +++ b/src/render.rs @@ -0,0 +1,1201 @@ +use std::collections::{BTreeMap, VecDeque}; + +use cosmic_text::{Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache}; + +use crate::{ + config::{Config, ModuleConfig, Rgba, Style, Theme}, + model::{IconPixmap, ModuleSnapshot, ModuleStore, PopupContent, PopupModel}, +}; + +#[derive(Debug, Copy, Clone, Default, PartialEq)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl Rect { + pub fn contains(self, x: f64, y: f64) -> bool { + x >= self.x && y >= self.y && x < self.x + self.width && y < self.y + self.height + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct HitBox { + pub rect: Rect, + pub module: String, + pub segment: Option, +} + +#[derive(Debug, Clone)] +struct RenderSegment { + module: String, + segment: Option, + text: String, + icon: Option, + style: Style, + rect: Rect, +} + +#[derive(Debug, Clone, Default)] +pub struct RenderResult { + pub hit_boxes: Vec, +} + +#[derive(Debug)] +pub struct Renderer { + font_system: FontSystem, + swash_cache: SwashCache, + text_width_cache: VecDeque<(TextWidthKey, f64)>, + segment_cache: VecDeque<(SegmentCacheKey, Vec)>, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct TextWidthKey { + text: String, + logical_height_bits: u64, + scale: u32, + font_family: String, + font_size_bits: u32, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct SegmentCacheKey { + width: u32, + height: u32, + scale: u32, + text: String, + icon: Option, + style: Style, + font_family: String, + font_size_bits: u32, +} + +impl Default for Renderer { + fn default() -> Self { + Self { + font_system: FontSystem::new(), + swash_cache: SwashCache::new(), + text_width_cache: VecDeque::new(), + segment_cache: VecDeque::new(), + } + } +} + +impl Renderer { + #[allow(clippy::too_many_arguments)] + pub fn render_bar( + &mut self, + canvas: &mut [u8], + physical_width: u32, + physical_height: u32, + scale: u32, + output: &str, + config: &Config, + theme: &Theme, + modules: &ModuleStore, + hovered: Option<(&str, Option<&str>)>, + ) -> RenderResult { + fill( + canvas, + physical_width, + physical_height, + theme.bar.background, + ); + let logical_width = f64::from(physical_width) / f64::from(scale); + let logical_height = f64::from(physical_height) / f64::from(scale); + let groups = [ + &config.layout.left, + &config.layout.center, + &config.layout.right, + ]; + let mut measured = Vec::with_capacity(3); + for group in groups { + measured.push(self.measure_group( + group, + output, + logical_height, + scale, + config, + theme, + modules, + hovered, + )); + } + + let left_width = group_width(&measured[0]); + let center_width = group_width(&measured[1]); + let right_width = group_width(&measured[2]); + position_group(&mut measured[0], 0.0, logical_height); + let right_start = (logical_width - right_width).max(left_width); + position_group(&mut measured[2], right_start, logical_height); + let centered = ((logical_width - center_width) / 2.0) + .max(left_width) + .min((right_start - center_width).max(left_width)); + position_group(&mut measured[1], centered, logical_height); + + let mut result = RenderResult::default(); + for segment in measured.into_iter().flatten() { + self.draw_segment( + canvas, + physical_width, + physical_height, + scale, + &segment, + theme, + ); + result.hit_boxes.push(HitBox { + rect: segment.rect, + module: segment.module, + segment: segment.segment, + }); + } + result + } + + pub fn popup_logical_size( + model: &PopupModel, + configured_width: u32, + maximum_height: u32, + ) -> (u32, u32) { + let content_height = match &model.content { + PopupContent::Rows(rows) => 42_u32.saturating_add( + u32::try_from(rows.len()) + .unwrap_or(u32::MAX) + .saturating_mul(34), + ), + PopupContent::Calendar(_) => 42 + 7 * 30, + PopupContent::Slider(slider) => 42_u32.saturating_add(56).saturating_add( + u32::try_from(slider.detail_rows.len()) + .unwrap_or(u32::MAX) + .saturating_mul(34), + ), + // Reserve the same viewport when switching between mixer and device lists. + PopupContent::Audio(_) => 420, + }; + (configured_width, content_height.min(maximum_height).max(80)) + } + + #[allow(clippy::too_many_arguments)] + pub fn render_popup( + &mut self, + canvas: &mut [u8], + physical_width: u32, + physical_height: u32, + scale: u32, + module: &str, + model: &PopupModel, + theme: &Theme, + ) -> RenderResult { + fill( + canvas, + physical_width, + physical_height, + theme.popup.background, + ); + draw_border( + canvas, + physical_width, + physical_height, + (0, 0, physical_width, physical_height), + theme.popup.border, + theme.popup.border_width.saturating_mul(scale), + ); + let logical_width = f64::from(physical_width) / f64::from(scale); + let logical_height = f64::from(physical_height) / f64::from(scale); + let padding = f64::from(theme.popup.padding); + let title_rect = Rect { + x: padding, + y: padding, + width: (logical_width - padding * 2.0).max(0.0), + height: 30.0, + }; + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + &model.title, + "focused", + title_rect, + theme, + ); + + let mut result = RenderResult::default(); + let mut y = padding + 34.0; + match &model.content { + PopupContent::Audio(audio) => { + let controls = audio.controls(); + let available = (logical_height - y - padding - 24.0).max(0.0); + let offset = audio.offset.min(controls.len().saturating_sub(1)); + let mut used = 0.0; + let mut shown = 0; + for control in controls.iter().skip(offset) { + let height = control.height(); + if used + height > available { + break; + } + let rect = Rect { + x: padding, + y: y + used, + width: (logical_width - 2.0 * padding).max(1.0), + height: height - 2.0, + }; + let focused = control.id.is_some() && control.id == audio.focused; + let state = if focused || control.active { + "focused" + } else { + "normal" + }; + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + &control.label, + state, + Rect { + height: 30.0, + ..rect + }, + theme, + ); + if let Some(value) = control.slider { + let track = Rect { + x: rect.x + 8.0, + y: rect.y + 38.0, + width: (rect.width - 16.0).max(1.0), + height: 6.0, + }; + fill_rect( + canvas, + physical_width, + physical_height, + physical_rect(track, scale), + theme.popup.border, + ); + fill_rect( + canvas, + physical_width, + physical_height, + physical_rect( + Rect { + width: track.width * (value / 150.0).clamp(0.0, 1.0), + ..track + }, + scale, + ), + theme.module.foreground, + ); + } + if control.id.is_some() { + result.hit_boxes.push(HitBox { + rect, + module: module.to_owned(), + segment: control.id.clone(), + }); + } + used += height; + shown += 1; + } + if offset > 0 || offset + shown < controls.len() { + for (id, text, x, enabled) in [ + ("audio-scroll-up", "↑ Previous", padding, offset > 0), + ( + "audio-scroll-down", + "↓ More", + logical_width / 2.0, + offset + shown < controls.len(), + ), + ] { + let rect = Rect { + x, + y: logical_height - padding - 24.0, + width: (logical_width / 2.0 - padding).max(1.0), + height: 24.0, + }; + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + text, + if enabled { "normal" } else { "disabled" }, + rect, + theme, + ); + if enabled { + result.hit_boxes.push(HitBox { + rect, + module: module.to_owned(), + segment: Some(id.to_owned()), + }); + } + } + } + } + PopupContent::Rows(rows) => { + for row in rows { + if y + 32.0 > logical_height - padding { + break; + } + let rect = Rect { + x: padding, + y, + width: (logical_width - padding * 2.0).max(0.0), + height: 32.0, + }; + let text = row.detail.as_ref().map_or_else( + || row.label.clone(), + |detail| format!("{} {}", row.label, detail), + ); + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + &text, + if row.enabled { &row.state } else { "disabled" }, + rect, + theme, + ); + if row.enabled { + result.hit_boxes.push(HitBox { + rect, + module: module.to_owned(), + segment: row.id.clone(), + }); + } + y += 34.0; + } + } + PopupContent::Calendar(calendar) => { + let button_width = 32.0; + for (id, text, x) in [ + ("previous-month", "‹", padding), + ("next-month", "›", logical_width - padding - button_width), + ] { + let rect = Rect { + x, + y, + width: button_width, + height: 28.0, + }; + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + text, + "normal", + rect, + theme, + ); + result.hit_boxes.push(HitBox { + rect, + module: module.to_owned(), + segment: Some(id.to_owned()), + }); + } + y += 30.0; + self.draw_calendar( + canvas, + physical_width, + physical_height, + scale, + module, + calendar, + Rect { + x: padding, + y, + width: logical_width - padding * 2.0, + height: (logical_height - y - padding).max(0.0), + }, + theme, + ); + } + PopupContent::Slider(slider) => { + let rect = Rect { + x: padding, + y, + width: logical_width - padding * 2.0, + height: 52.0, + }; + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + &format!("{} {:.0}%", slider.label, slider.value), + if slider.muted { "disabled" } else { "normal" }, + Rect { + height: 26.0, + ..rect + }, + theme, + ); + let track = Rect { + x: rect.x + 8.0, + y: rect.y + 34.0, + width: (rect.width - 16.0).max(1.0), + height: 6.0, + }; + fill_rect( + canvas, + physical_width, + physical_height, + physical_rect(track, scale), + theme.module.border, + ); + let ratio = ((slider.value - slider.minimum) / (slider.maximum - slider.minimum)) + .clamp(0.0, 1.0); + fill_rect( + canvas, + physical_width, + physical_height, + physical_rect( + Rect { + width: track.width * ratio, + ..track + }, + scale, + ), + theme + .states + .get("focused") + .and_then(|style| style.background) + .unwrap_or(theme.module.foreground), + ); + result.hit_boxes.push(HitBox { + rect, + module: module.to_owned(), + segment: Some("slider".to_owned()), + }); + y += 56.0; + for row in &slider.detail_rows { + if y + 32.0 > logical_height - padding { + break; + } + let rect = Rect { + x: padding, + y, + width: logical_width - padding * 2.0, + height: 32.0, + }; + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + &row.label, + if row.enabled { &row.state } else { "disabled" }, + rect, + theme, + ); + if row.enabled { + result.hit_boxes.push(HitBox { + rect, + module: module.to_owned(), + segment: row.id.clone(), + }); + } + y += 34.0; + } + } + } + result + } + + #[allow(clippy::too_many_arguments)] + fn draw_popup_text( + &mut self, + canvas: &mut [u8], + physical_width: u32, + physical_height: u32, + scale: u32, + module: &str, + text: &str, + state: &str, + rect: Rect, + theme: &Theme, + ) { + let style = theme.style_for(module, state, &crate::config::StyleOverride::default()); + self.draw_segment( + canvas, + physical_width, + physical_height, + scale, + &RenderSegment { + module: module.to_owned(), + segment: None, + text: text.to_owned(), + icon: None, + style, + rect, + }, + theme, + ); + } + + #[allow(clippy::too_many_arguments)] + fn draw_calendar( + &mut self, + canvas: &mut [u8], + physical_width: u32, + physical_height: u32, + scale: u32, + module: &str, + calendar: &crate::model::CalendarModel, + rect: Rect, + theme: &Theme, + ) { + use chrono::{Datelike, NaiveDate}; + + let cell_width = rect.width / 7.0; + let cell_height = 28.0; + for (column, label) in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + .into_iter() + .enumerate() + { + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + label, + "disabled", + Rect { + x: rect.x + column as f64 * cell_width, + y: rect.y, + width: cell_width, + height: cell_height, + }, + theme, + ); + } + let Some(first) = NaiveDate::from_ymd_opt(calendar.year, calendar.month, 1) else { + return; + }; + let next = if calendar.month == 12 { + NaiveDate::from_ymd_opt(calendar.year + 1, 1, 1) + } else { + NaiveDate::from_ymd_opt(calendar.year, calendar.month + 1, 1) + }; + let Some(next) = next else { + return; + }; + let days = (next - first).num_days() as u32; + let offset = first.weekday().num_days_from_monday(); + for day in 1..=days { + let index = offset + day - 1; + let column = index % 7; + let row = index / 7 + 1; + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + &day.to_string(), + if day == calendar.selected_day { + "focused" + } else { + "normal" + }, + Rect { + x: rect.x + f64::from(column) * cell_width, + y: rect.y + f64::from(row) * cell_height, + width: cell_width, + height: cell_height, + }, + theme, + ); + } + } + + #[allow(clippy::too_many_arguments)] + fn measure_group( + &mut self, + names: &[String], + output: &str, + logical_height: f64, + scale: u32, + config: &Config, + theme: &Theme, + modules: &ModuleStore, + hovered: Option<(&str, Option<&str>)>, + ) -> Vec { + let mut segments = Vec::new(); + for name in names { + let Some(definition) = config.modules.get(name) else { + continue; + }; + if !definition.common().enabled { + continue; + } + let snapshot = modules + .get(name) + .cloned() + .unwrap_or_default() + .for_output(output); + if !snapshot.visible { + continue; + } + let mut style = definition.common().style.clone(); + if let ModuleConfig::Tray(tray) = definition { + style.padding_x.get_or_insert(tray.spacing / 2); + } + self.measure_module( + name, + style, + snapshot, + logical_height, + scale, + theme, + hovered, + &mut segments, + ); + } + segments + } + + #[allow(clippy::too_many_arguments)] + fn measure_module( + &mut self, + name: &str, + local_style: crate::config::StyleOverride, + snapshot: ModuleSnapshot, + logical_height: f64, + scale: u32, + theme: &Theme, + hovered: Option<(&str, Option<&str>)>, + result: &mut Vec, + ) { + for segment in snapshot.segments { + let state = if snapshot.stale { + "stale" + } else if hovered + .is_some_and(|(module, id)| module == name && id == segment.id.as_deref()) + { + "hover" + } else { + &segment.state + }; + let style = theme.style_for(name, state, &local_style); + let text_width = self.measure_text(&segment.text, logical_height, scale, theme); + let icon_width = segment.icon.as_ref().map_or(0.0, |icon| { + let maximum = (logical_height - 4.0).max(1.0); + let ratio = f64::from(icon.width) / f64::from(icon.height.max(1)); + (maximum * ratio).min(maximum * 2.0) + + if segment.text.is_empty() { 0.0 } else { 4.0 } + }); + let width = (text_width + + icon_width + + f64::from(style.padding_x.saturating_mul(2)) + + f64::from(style.border_width.saturating_mul(2))) + .max(f64::from(style.min_width)); + result.push(RenderSegment { + module: name.to_owned(), + segment: segment.id, + text: segment.text, + icon: segment.icon, + style, + rect: Rect { + x: 0.0, + y: 0.0, + width, + height: logical_height, + }, + }); + } + } + + fn measure_text(&mut self, text: &str, logical_height: f64, scale: u32, theme: &Theme) -> f64 { + let key = TextWidthKey { + text: text.to_owned(), + logical_height_bits: logical_height.to_bits(), + scale, + font_family: theme.font.family.clone(), + font_size_bits: theme.font.size.to_bits(), + }; + if let Some((_, width)) = self + .text_width_cache + .iter() + .find(|(candidate, _)| candidate == &key) + { + return *width; + } + let scale = scale as f32; + let line_height = logical_height as f32 * scale; + let metrics = Metrics::new(theme.font.size * scale, line_height); + let attrs = Attrs::new().family(Family::Name(&theme.font.family)); + let mut buffer = Buffer::new(&mut self.font_system, metrics); + buffer.set_size(None, Some(line_height)); + buffer.set_text(text, &attrs, Shaping::Advanced, None); + let width = buffer + .borrow_with(&mut self.font_system) + .layout_runs() + .map(|run| run.line_w) + .fold(0.0_f32, f32::max); + let width = f64::from(width.ceil()) / f64::from(scale); + push_bounded(&mut self.text_width_cache, (key, width), 256); + width + } + + fn draw_segment( + &mut self, + canvas: &mut [u8], + physical_width: u32, + physical_height: u32, + scale: u32, + segment: &RenderSegment, + theme: &Theme, + ) { + let rect = physical_rect(segment.rect, scale); + if rect.2 == 0 || rect.3 == 0 { + return; + } + let key = SegmentCacheKey { + width: rect.2, + height: rect.3, + scale, + text: segment.text.clone(), + icon: segment.icon.clone(), + style: segment.style.clone(), + font_family: theme.font.family.clone(), + font_size_bits: theme.font.size.to_bits(), + }; + if let Some((_, pixels)) = self + .segment_cache + .iter() + .find(|(candidate, _)| candidate == &key) + { + composite_surface( + canvas, + physical_width, + physical_height, + rect.0, + rect.1, + rect.2, + rect.3, + pixels, + ); + return; + } + + let mut pixels = vec![0_u8; rect.2 as usize * rect.3 as usize * 4]; + let mut local = segment.clone(); + local.rect.x = 0.0; + local.rect.y = 0.0; + self.draw_segment_uncached(&mut pixels, rect.2, rect.3, scale, &local, theme); + composite_surface( + canvas, + physical_width, + physical_height, + rect.0, + rect.1, + rect.2, + rect.3, + &pixels, + ); + push_bounded(&mut self.segment_cache, (key, pixels), 128); + } + + fn draw_segment_uncached( + &mut self, + canvas: &mut [u8], + physical_width: u32, + physical_height: u32, + scale: u32, + segment: &RenderSegment, + theme: &Theme, + ) { + let rect = physical_rect(segment.rect, scale); + fill_rect( + canvas, + physical_width, + physical_height, + rect, + segment.style.background, + ); + draw_border( + canvas, + physical_width, + physical_height, + rect, + segment.style.border, + segment.style.border_width.saturating_mul(scale), + ); + + let inset = (segment.style.padding_x + segment.style.border_width).saturating_mul(scale); + let mut text_x = rect.0.saturating_add(inset); + if let Some(icon) = &segment.icon { + let target_height = rect.3.saturating_sub(4_u32.saturating_mul(scale)).max(1); + let target_width = ((u64::from(target_height) * u64::from(icon.width)) + / u64::from(icon.height.max(1))) + .min(u64::from(target_height) * 2) as u32; + let icon_y = rect + .1 + .saturating_add((rect.3.saturating_sub(target_height)) / 2); + draw_pixmap( + canvas, + physical_width, + physical_height, + text_x, + icon_y, + target_width.max(1), + target_height, + icon, + ); + text_x = + text_x + .saturating_add(target_width) + .saturating_add(if segment.text.is_empty() { + 0 + } else { + 4 * scale + }); + } + let line_height = segment.rect.height as f32 * scale as f32; + let metrics = Metrics::new(theme.font.size * scale as f32, line_height); + let attrs = Attrs::new().family(Family::Name(&theme.font.family)); + let mut buffer = Buffer::new(&mut self.font_system, metrics); + buffer.set_size( + Some((rect.2.saturating_sub(inset.saturating_mul(2))) as f32), + Some(line_height), + ); + buffer.set_text(&segment.text, &attrs, Shaping::Advanced, None); + let color = Color::rgba( + segment.style.foreground.red, + segment.style.foreground.green, + segment.style.foreground.blue, + segment.style.foreground.alpha, + ); + let mut borrowed = buffer.borrow_with(&mut self.font_system); + borrowed.draw( + &mut self.swash_cache, + color, + |x, y, width, height, glyph| { + let origin_x = i64::from(text_x) + i64::from(x); + let origin_y = i64::from(rect.1) + i64::from(y); + blend_glyph( + canvas, + physical_width, + physical_height, + origin_x, + origin_y, + width, + height, + glyph, + ); + }, + ); + } +} + +fn push_bounded(cache: &mut VecDeque, value: T, maximum: usize) { + if cache.len() == maximum { + cache.pop_front(); + } + cache.push_back(value); +} + +#[allow(clippy::too_many_arguments)] +fn composite_surface( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + target_x: u32, + target_y: u32, + source_width: u32, + source_height: u32, + source: &[u8], +) { + if source.len() != source_width as usize * source_height as usize * 4 { + return; + } + for row in 0..source_height { + let y = target_y.saturating_add(row); + if y >= canvas_height { + break; + } + for column in 0..source_width { + let x = target_x.saturating_add(column); + if x >= canvas_width { + break; + } + let source_index = ((row as usize * source_width as usize) + column as usize) * 4; + let destination_index = ((y as usize * canvas_width as usize) + x as usize) * 4; + if let (Some(pixel), Some(destination)) = ( + source.get(source_index..source_index + 4), + canvas.get_mut(destination_index..destination_index + 4), + ) && pixel[3] != 0 + { + blend_pixel(destination, [pixel[0], pixel[1], pixel[2], pixel[3]]); + } + } + } +} + +fn group_width(group: &[RenderSegment]) -> f64 { + group + .iter() + .map(|segment| segment.rect.width + f64::from(segment.style.margin_x.saturating_mul(2))) + .sum() +} + +fn position_group(group: &mut [RenderSegment], mut x: f64, height: f64) { + for segment in group { + x += f64::from(segment.style.margin_x); + segment.rect.x = x; + segment.rect.y = 0.0; + segment.rect.height = height; + x += segment.rect.width + f64::from(segment.style.margin_x); + } +} + +fn physical_rect(rect: Rect, scale: u32) -> (u32, u32, u32, u32) { + let scale = f64::from(scale); + ( + (rect.x * scale).max(0.0).round() as u32, + (rect.y * scale).max(0.0).round() as u32, + (rect.width * scale).max(0.0).round() as u32, + (rect.height * scale).max(0.0).round() as u32, + ) +} + +fn fill(canvas: &mut [u8], width: u32, height: u32, color: Rgba) { + fill_rect(canvas, width, height, (0, 0, width, height), color); +} + +fn fill_rect( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + rect: (u32, u32, u32, u32), + color: Rgba, +) { + let (x, y, width, height) = rect; + let x_end = x.saturating_add(width).min(canvas_width); + let y_end = y.saturating_add(height).min(canvas_height); + let source = color.as_premultiplied_bgra(); + for row in y.min(canvas_height)..y_end { + for column in x.min(canvas_width)..x_end { + let index = ((row as usize * canvas_width as usize) + column as usize) * 4; + if let Some(pixel) = canvas.get_mut(index..index + 4) { + blend_pixel(pixel, source); + } + } + } +} + +fn draw_border( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + rect: (u32, u32, u32, u32), + color: Rgba, + width: u32, +) { + if width == 0 || rect.2 == 0 || rect.3 == 0 { + return; + } + let width = width.min(rect.2 / 2).min(rect.3 / 2); + fill_rect( + canvas, + canvas_width, + canvas_height, + (rect.0, rect.1, rect.2, width), + color, + ); + fill_rect( + canvas, + canvas_width, + canvas_height, + (rect.0, rect.1 + rect.3 - width, rect.2, width), + color, + ); + fill_rect( + canvas, + canvas_width, + canvas_height, + (rect.0, rect.1, width, rect.3), + color, + ); + fill_rect( + canvas, + canvas_width, + canvas_height, + (rect.0 + rect.2 - width, rect.1, width, rect.3), + color, + ); +} + +#[allow(clippy::too_many_arguments)] +fn blend_glyph( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + origin_x: i64, + origin_y: i64, + width: u32, + height: u32, + color: Color, +) { + if origin_x >= i64::from(canvas_width) || origin_y >= i64::from(canvas_height) { + return; + } + let source = Rgba::rgba(color.r(), color.g(), color.b(), color.a()).as_premultiplied_bgra(); + for dy in 0..height { + let y = origin_y + i64::from(dy); + if !(0..i64::from(canvas_height)).contains(&y) { + continue; + } + for dx in 0..width { + let x = origin_x + i64::from(dx); + if !(0..i64::from(canvas_width)).contains(&x) { + continue; + } + let index = ((y as usize * canvas_width as usize) + x as usize) * 4; + if let Some(pixel) = canvas.get_mut(index..index + 4) { + blend_pixel(pixel, source); + } + } + } +} + +fn blend_pixel(destination: &mut [u8], source: [u8; 4]) { + let inverse = 255_u16.saturating_sub(u16::from(source[3])); + for channel in 0..3 { + destination[channel] = u8::try_from( + u16::from(source[channel]) + (u16::from(destination[channel]) * inverse + 127) / 255, + ) + .unwrap_or(u8::MAX); + } + destination[3] = + u8::try_from(u16::from(source[3]) + (u16::from(destination[3]) * inverse + 127) / 255) + .unwrap_or(u8::MAX); +} + +#[allow(clippy::too_many_arguments)] +fn draw_pixmap( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + target_x: u32, + target_y: u32, + target_width: u32, + target_height: u32, + pixmap: &IconPixmap, +) { + if pixmap.width == 0 + || pixmap.height == 0 + || pixmap.pixels.len() != pixmap.width as usize * pixmap.height as usize * 4 + { + return; + } + for dy in 0..target_height { + let y = target_y.saturating_add(dy); + if y >= canvas_height { + break; + } + let source_y = + (u64::from(dy) * u64::from(pixmap.height) / u64::from(target_height.max(1))) as u32; + for dx in 0..target_width { + let x = target_x.saturating_add(dx); + if x >= canvas_width { + break; + } + let source_x = + (u64::from(dx) * u64::from(pixmap.width) / u64::from(target_width.max(1))) as u32; + let source_index = + ((source_y as usize * pixmap.width as usize) + source_x as usize) * 4; + let destination_index = ((y as usize * canvas_width as usize) + x as usize) * 4; + if let (Some(source), Some(destination)) = ( + pixmap.pixels.get(source_index..source_index + 4), + canvas.get_mut(destination_index..destination_index + 4), + ) { + blend_pixel(destination, [source[0], source[1], source[2], source[3]]); + } + } + } +} + +pub fn hit_test(hit_boxes: &[HitBox], x: f64, y: f64) -> Option<&HitBox> { + hit_boxes + .iter() + .rev() + .find(|hit_box| hit_box.rect.contains(x, y)) +} + +pub fn snapshots_for_test(values: &[(&str, &str)]) -> ModuleStore { + let mut store = ModuleStore::default(); + for (name, text) in values { + store.insert(*name, ModuleSnapshot::text(*text)); + } + store +} + +pub fn state_counts(store: &ModuleStore, names: &[String]) -> BTreeMap { + names + .iter() + .map(|name| { + let count = store + .get(name) + .map_or(0, |snapshot| snapshot.segments.len()); + (name.clone(), count) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hit_testing_prefers_last_overlapping_box() { + let boxes = vec![ + HitBox { + rect: Rect { + x: 0.0, + y: 0.0, + width: 20.0, + height: 20.0, + }, + module: "first".to_owned(), + segment: None, + }, + HitBox { + rect: Rect { + x: 10.0, + y: 0.0, + width: 20.0, + height: 20.0, + }, + module: "second".to_owned(), + segment: None, + }, + ]; + assert_eq!(hit_test(&boxes, 15.0, 5.0).unwrap().module, "second"); + assert!(hit_test(&boxes, 50.0, 5.0).is_none()); + } + + #[test] + fn alpha_blend_is_premultiplied() { + let mut destination = [0_u8, 0, 0, 0]; + blend_pixel( + &mut destination, + Rgba::rgba(255, 0, 0, 128).as_premultiplied_bgra(), + ); + assert_eq!(destination, [0, 0, 128, 128]); + } +} diff --git a/tests/audio_popup.rs b/tests/audio_popup.rs new file mode 100644 index 0000000..accffc8 --- /dev/null +++ b/tests/audio_popup.rs @@ -0,0 +1,138 @@ +use lightbar::{ + config::ConfigBundle, + model::{AudioDevice, AudioModel, AudioView, PopupContent, PopupModel}, + render::{Renderer, hit_test}, +}; + +fn mixer() -> AudioModel { + AudioModel { + devices: vec![ + AudioDevice { + id: 10, + name: "speakers".into(), + description: "Desktop Speakers".into(), + input: false, + is_default: true, + volume: Some(50.0), + muted: Some(false), + }, + AudioDevice { + id: 11, + name: "microphone".into(), + description: "USB Microphone".into(), + input: true, + is_default: true, + volume: Some(80.0), + muted: Some(true), + }, + ], + ..AudioModel::default() + } +} + +#[test] +fn example_popup_exposes_both_endpoints_and_keyboard_focus() { + let bundle = ConfigBundle::load(Some(std::path::Path::new("examples/config.toml"))).unwrap(); + let mut renderer = Renderer::default(); + for scale in [1, 2] { + let mut audio = mixer(); + let mut visited = Vec::new(); + for _ in 0..7 { + audio.move_focus( + false, + 420.0 - 2.0 * f64::from(bundle.theme.popup.padding) - 58.0, + ); + let popup = PopupModel { + title: "Audio".into(), + content: PopupContent::Audio(audio.clone()), + }; + let mut pixels = vec![0; (320 * 420 * scale * scale * 4) as usize]; + let result = renderer.render_popup( + &mut pixels, + 320 * scale, + 420 * scale, + scale, + "audio", + &popup, + &bundle.theme, + ); + let focused = result + .hit_boxes + .iter() + .find(|hit| hit.segment == audio.focused) + .expect("keyboard focus must be visible and actionable"); + assert_eq!( + hit_test( + &result.hit_boxes, + focused.rect.x + 1.0, + focused.rect.y + 1.0 + ), + Some(focused) + ); + visited.push(audio.focused.clone().unwrap()); + } + assert_eq!( + visited, + [ + "audio-outputs", + "audio-volume:10", + "audio-mute:10", + "audio-inputs", + "audio-volume:11", + "audio-mute:11", + "open-settings" + ] + ); + audio.move_focus(false, 342.0); + assert_eq!(audio.focused.as_deref(), Some("audio-outputs")); + audio.move_focus(true, 342.0); + assert_eq!(audio.focused.as_deref(), Some("open-settings")); + } +} + +#[test] +fn keyboard_reaches_every_device_in_a_short_popup() { + let bundle = ConfigBundle::load(Some(std::path::Path::new("examples/config.toml"))).unwrap(); + let mut audio = mixer(); + for id in 20..40 { + audio.devices.push(AudioDevice { + id, + name: format!("output-{id}"), + description: format!("Output {id}"), + input: false, + is_default: false, + volume: Some(100.0), + muted: Some(false), + }); + } + audio.set_view(AudioView::Outputs); + let mut renderer = Renderer::default(); + for _ in 0..22 { + audio.move_focus( + false, + 180.0 - 2.0 * f64::from(bundle.theme.popup.padding) - 58.0, + ); + let mut pixels = vec![0; 320 * 180 * 4]; + let popup = PopupModel { + title: "Audio".into(), + content: PopupContent::Audio(audio.clone()), + }; + let result = + renderer.render_popup(&mut pixels, 320, 180, 1, "audio", &popup, &bundle.theme); + assert!( + result + .hit_boxes + .iter() + .any(|hit| hit.segment == audio.focused) + ); + } + assert_eq!(audio.focused.as_deref(), Some("audio-default:39")); + assert!(audio.offset > 0); + audio.set_view(AudioView::Inputs); + let ids: Vec<_> = audio + .controls() + .into_iter() + .filter_map(|control| control.id) + .collect(); + assert_eq!(ids, ["audio-back", "audio-default:11"]); +} diff --git a/tests/fixtures/audio.json b/tests/fixtures/audio.json new file mode 100644 index 0000000..1505d73 --- /dev/null +++ b/tests/fixtures/audio.json @@ -0,0 +1,12 @@ +[ + {"id":1,"type":"PipeWire:Interface:Metadata","props":{"metadata.name":"default"},"metadata":[ + {"subject":0,"key":"default.configured.audio.sink","value":{"name":"disconnected-headset"}}, + {"subject":0,"key":"default.audio.sink","value":{"name":"speakers"}}, + {"subject":0,"key":"default.audio.source","value":{"name":"microphone"}} + ]}, + {"id":10,"type":"PipeWire:Interface:Node","info":{"props":{"media.class":"Audio/Sink","node.name":"speakers","node.description":"Desktop Speakers","device.id":30,"card.profile.device":0},"params":{"Props":[{"mute":false,"channelVolumes":[1.0,1.0]}]}}}, + {"id":11,"type":"PipeWire:Interface:Node","info":{"props":{"media.class":"Audio/Source","node.name":"microphone","node.description":"USB Microphone"},"params":{"Props":[{"mute":true,"channelVolumes":[0.512]}]}}}, + {"id":12,"type":"PipeWire:Interface:Node","info":{"props":{"media.class":"Audio/Sink","node.name":"headphones","node.description":"USB Headphones"},"params":{"Props":[{"mute":false,"channelVolumes":[1.0,1.0]}]}}}, + {"id":30,"type":"PipeWire:Interface:Device","info":{"props":{"media.class":"Audio/Device"},"params":{"Route":[{"device":0,"props":{"mute":false,"channelVolumes":[0.125,0.125]}}]}}}, + {"id":40,"type":"PipeWire:Interface:Node","info":{"props":{"media.class":"Stream/Output/Audio","node.name":"music"},"params":{"Props":[{"mute":false,"channelVolumes":[1.0,1.0]}]}}} +]