From 75b552f26b623b757c99583039b43239aae5bb8f Mon Sep 17 00:00:00 2001 From: Prouk Date: Thu, 15 Oct 2026 12:25:14 +0200 Subject: [PATCH] Add initial Wayland shell with clock and system tray - Layer-shell bars drawn in software with tiny-skia and fontdue - Clock widget centered at the top of the screen - System tray in a top-right notch, acting as the StatusNotifierWatcher, with icons and right-click menus - Live-reloaded TOML config for colors (~/.config/pell/config.toml) - devenv setup for the Rust toolchain and Wayland libraries - README: config, tray behaviour and AI disclosure --- .gitignore | 7 + Cargo.lock | 1000 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 14 + README.md | 36 +- devenv.lock | 65 +++ devenv.nix | 35 ++ devenv.yaml | 1 + src/app/handlers.rs | 234 ++++++++++ src/app/mod.rs | 288 +++++++++++++ src/bar.rs | 383 +++++++++++++++++ src/config.rs | 167 ++++++++ src/main.rs | 65 +++ src/menu_popup.rs | 274 ++++++++++++ src/render.rs | 236 ++++++++++ src/theme.rs | 42 ++ src/tray/icon.rs | 236 ++++++++++ src/tray/item.rs | 136 ++++++ src/tray/menu.rs | 206 +++++++++ src/tray/mod.rs | 161 +++++++ src/tray/watcher.rs | 175 ++++++++ src/widget/clock.rs | 40 ++ src/widget/mod.rs | 57 +++ src/widget/tray.rs | 120 ++++++ 23 files changed, 3977 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 devenv.lock create mode 100644 devenv.nix create mode 100644 devenv.yaml create mode 100644 src/app/handlers.rs create mode 100644 src/app/mod.rs create mode 100644 src/bar.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/menu_popup.rs create mode 100644 src/render.rs create mode 100644 src/theme.rs create mode 100644 src/tray/icon.rs create mode 100644 src/tray/item.rs create mode 100644 src/tray/menu.rs create mode 100644 src/tray/mod.rs create mode 100644 src/tray/watcher.rs create mode 100644 src/widget/clock.rs create mode 100644 src/widget/mod.rs create mode 100644 src/widget/tray.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47a6c15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.devenv +.github + + +# Added by cargo + +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f118a93 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1000 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Pell" +version = "0.1.0" +dependencies = [ + "chrono", + "dbus", + "fontdue", + "png", + "serde", + "smithay-client-toolkit", + "tiny-skia", + "toml", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[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 = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags", + "polling", + "rustix", + "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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[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 = "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 = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fontdue" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7894823fa221401399e2598f8b63f81ac77ff5c63248b7656779bff1632d7d3d" +dependencies = [ + "hashbrown", + "ttf-parser", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[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 = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[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 = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[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 = "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 = "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 = "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 = "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.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "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", +] + +[[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 = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "smithay-client-toolkit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dc9ee14b0fdcb535f9556141bacac070c994a977d2240ee455d7438a617f00" +dependencies = [ + "bitflags", + "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 = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[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-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[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 = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +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", + "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", + "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", + "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", + "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", + "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", + "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 = "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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[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 = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0804722 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "Pell" +version = "0.1.0" +edition = "2024" + +[dependencies] +chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +dbus = "0.9.12" +fontdue = "0.9.4" +png = "0.18.1" +serde = { version = "1.0.229", features = ["derive"] } +smithay-client-toolkit = "0.21.1" +tiny-skia = "0.12.0" +toml = { version = "1.1.8", default-features = false, features = ["parse", "serde"] } diff --git a/README.md b/README.md index 8be94af..575416d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,37 @@ # Pell -Lightweight Wayland Shell \ No newline at end of file +> A minimal widget based shell written in Rust, cause I do what I want + +## Configuration + +Pell reads `$XDG_CONFIG_HOME/pell/config.toml` (or `~/.config/pell/config.toml`). +Every key is optional, and changes are applied live, within a second of saving. + +```toml +[colors] +background = "#1e1e2e" # "#rrggbb" or "#rrggbbaa" +foreground = "#cdd6f4" +``` + +## System tray + +Tray icons show in a notch at the top-right of the screen. Pell acts as the +`org.kde.StatusNotifierWatcher` itself, so no other tray (like waybar's) can run +alongside it; if one is already running, Pell starts without its tray. + +- Left click activates the app (or opens its menu), middle click is the + secondary action, right click opens the menu. +- Icons must be PNG (pixel data from the app, or a PNG in the icon theme). + Others show as a placeholder dot. +- Submenus are shown inline, indented, rather than as separate popups. +- Apps started before Pell may need a restart to show up. + +## AI Disclosure + +This project makes use of AI assistance. Specifically: + +- **Optimisation**: AI tools are used to suggest performance and readability improvements to existing code. +- **Code verification**: AI tools are used to review code, spot bugs, and cross-check behaviour against intended design. + +All AI-generated or AI-assisted suggestions are reviewed by a human before being merged. The author remains responsible for the content of this repository. + diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..20cf7d3 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,65 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1790701375, + "narHash": "sha256-0FpLHIRDYiyo3EMTnMQSobZQknZaa3JuWo6vmMCK76o=", + "owner": "cachix", + "repo": "devenv", + "rev": "fe20b5cba7ab5e93ae73f956a8d3efc50e1753f4", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1789506231, + "narHash": "sha256-vNkJbtmqyBfFhn/HnzQo4ROuoyYWuy/allqTfJb4kMY=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "c2f38fe7f9e04d9aadd354d380f2bd40531d9737", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1789370336, + "narHash": "sha256-6RSEDHIWQtesQKWSu5qRai8L2h4KgCgMEfJHstW99G4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c7def046b9a883d46974757852106483d741586f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..b5d8d3f --- /dev/null +++ b/devenv.nix @@ -0,0 +1,35 @@ +{ + pkgs, + lib, + ... +}: + +let + # Loaded at runtime via dlopen by wayland-client / xkbcommon / EGL crates. + runtimeLibs = with pkgs; [ + wayland + libxkbcommon + libGL + ]; +in +{ + languages = { + rust = { + enable = true; + components = [ "rustc" "cargo" "clippy" "rustfmt" "rust-analyzer" ]; + }; + }; + + packages = with pkgs; [ + marksman + pkg-config + wayland-protocols + wayland-scanner + fontconfig + freetype + # Linked by the `dbus` crate (system tray). + dbus + ] ++ runtimeLibs; + + env.LD_LIBRARY_PATH = lib.makeLibraryPath runtimeLibs; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..9037a0d --- /dev/null +++ b/devenv.yaml @@ -0,0 +1 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json \ No newline at end of file diff --git a/src/app/handlers.rs b/src/app/handlers.rs new file mode 100644 index 0000000..57e6d0f --- /dev/null +++ b/src/app/handlers.rs @@ -0,0 +1,234 @@ +//! Wayland protocol handlers: they route each event to the bar, menu or +//! widget it concerns. + +use smithay_client_toolkit::{ + compositor::CompositorHandler, + delegate_registry, + output::{OutputHandler, OutputState}, + reexports::{ + client::{ + Connection, QueueHandle, + protocol::{wl_output, wl_pointer::WlPointer, wl_seat::WlSeat, wl_surface}, + }, + protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape, + }, + registry::{ProvidesRegistryState, RegistryState}, + registry_handlers, + seat::{ + Capability, SeatHandler, SeatState, + pointer::{PointerEvent, PointerEventKind, PointerHandler}, + }, + shell::{ + wlr_layer::{LayerShellHandler, LayerSurface, LayerSurfaceConfigure}, + xdg::popup::{Popup, PopupConfigure, PopupHandler}, + }, + shm::{Shm, ShmHandler}, +}; + +use super::{App, report}; + +impl LayerShellHandler for App { + fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) { + self.exit = true; + } + + fn configure( + &mut self, + _: &Connection, + _: &QueueHandle, + layer: &LayerSurface, + configure: LayerSurfaceConfigure, + _: u32, + ) { + let Some(bar) = self.bars.iter_mut().find(|bar| bar.layer() == layer) else { + return; + }; + let (width, height) = configure.new_size; + bar.resize(width, height); + report(bar.draw(), "draw the bar"); + } +} + +impl PopupHandler for App { + fn configure( + &mut self, + _: &Connection, + _: &QueueHandle, + popup: &Popup, + configure: PopupConfigure, + ) { + let Some(menu) = self.menu.as_mut().filter(|menu| menu.is_popup(popup)) else { + return; + }; + menu.resize(configure.width as u32, configure.height as u32); + report(menu.draw(), "draw the menu"); + } + + /// The compositor closed the menu (e.g. a click outside it). + fn done(&mut self, _: &Connection, _: &QueueHandle, popup: &Popup) { + if self.menu.as_ref().is_some_and(|menu| menu.is_popup(popup)) { + self.menu = None; + } + } +} + +impl PointerHandler for App { + fn pointer_frame( + &mut self, + _: &Connection, + qh: &QueueHandle, + _: &WlPointer, + events: &[PointerEvent], + ) { + for event in events { + match event.kind { + PointerEventKind::Enter { serial } => { + if let Some((_, Some(device))) = &self.pointer { + device.set_shape(serial, Shape::Default); + } + self.hover_menu(event, true); + } + PointerEventKind::Motion { .. } => self.hover_menu(event, true), + PointerEventKind::Leave { .. } => self.hover_menu(event, false), + PointerEventKind::Press { button, serial, .. } => { + self.press(qh, event, button, serial) + } + _ => {} + } + } + } +} + +impl App { + /// Highlights the menu entry under the pointer. + fn hover_menu(&mut self, event: &PointerEvent, inside: bool) { + let Some(menu) = self + .menu + .as_mut() + .filter(|menu| menu.is_surface(&event.surface)) + else { + return; + }; + let y = inside.then_some(event.position.1 as f32); + if menu.hover(y) { + report(menu.draw(), "draw the menu"); + } + } +} + +impl SeatHandler for App { + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat_state + } + + fn new_seat(&mut self, _: &Connection, _: &QueueHandle, _: WlSeat) {} + + fn new_capability( + &mut self, + _: &Connection, + qh: &QueueHandle, + seat: WlSeat, + capability: Capability, + ) { + if capability != Capability::Pointer || self.pointer.is_some() { + return; + } + match self.seat_state.get_pointer(qh, &seat) { + Ok(pointer) => { + let device = self + .cursor_shapes + .as_ref() + .map(|shapes| shapes.get_shape_device(&pointer, qh)); + self.pointer = Some((pointer, device)); + self.seat = Some(seat); + } + Err(err) => eprintln!("pell: could not use the pointer: {err}"), + } + } + + fn remove_capability( + &mut self, + _: &Connection, + _: &QueueHandle, + _: WlSeat, + capability: Capability, + ) { + if capability == Capability::Pointer + && let Some((pointer, device)) = self.pointer.take() + { + if let Some(device) = device { + device.destroy(); + } + pointer.release(); + } + } + + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle, seat: WlSeat) { + if self.seat.as_ref() == Some(&seat) { + self.seat = None; + } + } +} + +// We draw only when a widget changes, so these events need no handling yet. +impl CompositorHandler for App { + fn scale_factor_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: i32, + ) { + } + fn transform_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: wl_output::Transform, + ) { + } + fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: u32) {} + fn surface_enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } + fn surface_leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } +} + +// The compositor picks the bars' output, so output changes need no handling yet. +impl OutputHandler for App { + fn output_state(&mut self) -> &mut OutputState { + &mut self.output_state + } + fn new_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} +} + +impl ShmHandler for App { + fn shm_state(&mut self) -> &mut Shm { + &mut self.shm + } +} + +impl ProvidesRegistryState for App { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry_state + } + registry_handlers![OutputState, SeatState]; +} + +delegate_registry!(App); +smithay_client_toolkit::delegate_dispatch2!(App); diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..1882e44 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,288 @@ +//! Wayland connection, event loop, and what to do with input. +//! The protocol handlers routing events here live in `handlers`. + +mod handlers; + +use std::{ + cell::RefCell, + error::Error, + os::fd::{AsFd, BorrowedFd, RawFd}, + rc::Rc, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use smithay_client_toolkit::{ + compositor::CompositorState, + output::OutputState, + reexports::{ + calloop::{ + EventLoop, Interest, Mode, PostAction, + generic::Generic, + timer::{TimeoutAction, Timer}, + }, + calloop_wayland_source::WaylandSource, + client::{ + Connection, QueueHandle, + globals::registry_queue_init, + protocol::{wl_pointer::WlPointer, wl_seat::WlSeat}, + }, + protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1, + }, + registry::RegistryState, + seat::{ + SeatState, + pointer::{PointerEvent, cursor_shape::CursorShapeManager}, + }, + shell::wlr_layer::LayerShell, + shm::Shm, +}; + +use crate::{ + bar::{Bar, Placement, Widgets}, + config::ConfigFile, + menu_popup::{Anchoring, MenuPopup, PopupShell}, + render::Font, + theme::Theme, + tray::{MenuRef, Tray}, + widget::{Action, MouseButton}, +}; + +pub struct App { + registry_state: RegistryState, + output_state: OutputState, + seat_state: SeatState, + compositor: CompositorState, + xdg_shell: PopupShell, + shm: Shm, + cursor_shapes: Option, + config: Option, + font: Rc, + bars: Vec, + tray: Option>>, + /// The open tray menu, if any. + menu: Option, + /// The seat and pointer used to click, with its cursor-shape device. + seat: Option, + pointer: Option<(WlPointer, Option)>, + exit: bool, +} + +/// Connects to the compositor, shows the bars and runs until they are closed. +pub fn run( + config: Option, + theme: Theme, + bars: Vec<(Placement, Widgets)>, + tray: Option>>, +) -> Result<(), Box> { + let conn = Connection::connect_to_env()?; + let (globals, event_queue) = registry_queue_init(&conn)?; + let qh = event_queue.handle(); + + let compositor = CompositorState::bind(&globals, &qh)?; + let layer_shell = LayerShell::bind(&globals, &qh)?; + let shm = Shm::bind(&globals, &qh)?; + + let font = Rc::new(Font::load(&theme.font_family)?); + let bars = bars + .into_iter() + .map(|(placement, widgets)| { + let (theme, font) = (theme.clone(), font.clone()); + Bar::new( + placement, + &compositor, + &layer_shell, + &shm, + &qh, + theme, + font, + widgets, + ) + }) + .collect::>()?; + + let mut app = App { + registry_state: RegistryState::new(&globals), + output_state: OutputState::new(&globals, &qh), + seat_state: SeatState::new(&globals, &qh), + xdg_shell: PopupShell::bind(&globals, &qh)?, + // Optional: without it, the cursor keeps whatever shape it had. + cursor_shapes: CursorShapeManager::bind(&globals, &qh).ok(), + compositor, + shm, + config, + font, + bars, + tray, + menu: None, + seat: None, + pointer: None, + exit: false, + }; + + let mut event_loop = EventLoop::::try_new()?; + WaylandSource::new(conn, event_queue) + .insert(event_loop.handle()) + .map_err(|e| e.error)?; + event_loop + .handle() + .insert_source(Timer::immediate(), |_, _, app| { + app.tick(); + TimeoutAction::ToInstant(next_second()) + }) + .map_err(|e| e.error)?; + if let Some(tray) = &app.tray { + let socket = DbusSocket(tray.borrow().fd()); + event_loop + .handle() + .insert_source( + Generic::new(socket, Interest::READ, Mode::Level), + |_, _, app| { + app.process_tray(); + Ok(PostAction::Continue) + }, + ) + .map_err(|e| e.error)?; + } + + while !app.exit { + event_loop.dispatch(None, &mut app)?; + } + Ok(()) +} + +/// The tray's D-Bus socket, for the event loop to poll. +struct DbusSocket(RawFd); + +impl AsFd for DbusSocket { + fn as_fd(&self) -> BorrowedFd<'_> { + // SAFETY: the socket belongs to the tray's D-Bus connection, which + // lives in `App` for as long as the event loop runs. + unsafe { BorrowedFd::borrow_raw(self.0) } + } +} + +impl App { + /// Runs once per second: reload the config and refresh widgets. + fn tick(&mut self) { + let theme_changed = self.reload_config(); + self.refresh(theme_changed); + } + + /// Handles D-Bus messages, then shows what changed in the tray. + fn process_tray(&mut self) { + if let Some(tray) = &self.tray { + tray.borrow_mut().dispatch(); + } + self.refresh(false); + } + + /// Updates every bar's widgets and redraws the bars that changed + /// (or all of them when `force` is set). + fn refresh(&mut self, force: bool) { + for bar in &mut self.bars { + let changed = bar.update() | force; + // A resized bar is redrawn when the compositor confirms the new size. + if changed && !bar.fit_to_content(&self.compositor) { + report(bar.draw(), "draw the bar"); + } + } + } + + /// Applies the config file if it changed. Returns `true` if it did. + fn reload_config(&mut self) -> bool { + let Some(theme) = self.config.as_mut().and_then(ConfigFile::reload_if_changed) else { + return false; + }; + for bar in &mut self.bars { + bar.set_theme(theme.clone()); + } + true + } + + fn press(&mut self, qh: &QueueHandle, event: &PointerEvent, button: u32, serial: u32) { + let Some(button) = MouseButton::from_code(button) else { + return; + }; + let (x, y) = (event.position.0 as f32, event.position.1 as f32); + + if let Some(menu) = &self.menu + && menu.is_surface(&event.surface) + { + if let Some(entry) = menu.entry_at(y) + && let Some(tray) = &self.tray + { + tray.borrow().menu_clicked(&menu.menu, entry.id); + self.menu = None; + } + return; + } + // A click elsewhere closes the menu. + self.menu = None; + + let Some(index) = self + .bars + .iter() + .position(|bar| bar.is_surface(&event.surface)) + else { + return; + }; + if let Some(Action::ShowMenu { menu, x }) = self.bars[index].click(x, button) { + self.open_menu(qh, index, menu, x, serial); + } + // Clicks may have sent D-Bus calls, and their replies queued messages. + self.process_tray(); + } + + fn open_menu( + &mut self, + qh: &QueueHandle, + bar: usize, + menu: MenuRef, + x: f32, + serial: u32, + ) { + let (Some(tray), Some(seat)) = (&self.tray, &self.seat) else { + return; + }; + let entries = tray.borrow_mut().menu_entries(&menu); + if entries.is_empty() { + return; + } + let anchoring = Anchoring { + bar: &self.bars[bar], + x, + seat, + serial, + }; + let popup = MenuPopup::open( + anchoring, + menu, + entries, + self.font.clone(), + &self.compositor, + &self.xdg_shell, + &self.shm, + qh, + ); + match popup { + Ok(popup) => self.menu = Some(popup), + Err(err) => eprintln!("pell: could not open the menu: {err}"), + } + } +} + +fn report(result: Result<(), Box>, action: &str) { + if let Err(err) = result { + eprintln!("pell: failed to {action}: {err}"); + } +} + +/// The start of the next wall-clock second, so the clock flips on time. +fn next_second() -> Instant { + let since_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let until_next = + Duration::from_secs(1) - Duration::from_nanos(since_epoch.subsec_nanos().into()); + Instant::now() + until_next +} diff --git a/src/bar.rs b/src/bar.rs new file mode 100644 index 0000000..396cd46 --- /dev/null +++ b/src/bar.rs @@ -0,0 +1,383 @@ +//! A bar: a small layer-shell surface hanging from the top of the screen. +//! It sizes itself to its widgets, lays them out and draws them. +//! +//! Its shape, with `r` the corner radius, when centered: +//! +//! ```text +//! ____________________________ <- screen edge +//! \_| |_/ <- inverted corners (r wide), transparent outside +//! | widgets | +//! \____________________/ <- rounded corners +//! ``` +//! +//! In a screen corner, the side against the screen edge has no corners. + +use std::{error::Error, rc::Rc}; + +use smithay_client_toolkit::{ + compositor::{CompositorState, Region}, + reexports::client::{QueueHandle, protocol::wl_surface::WlSurface}, + shell::{ + WaylandSurface, + wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerShell, LayerSurface}, + }, + shm::{Shm, slot::SlotPool}, +}; +use tiny_skia::{Path, PathBuilder, Pixmap}; + +use crate::{ + app::App, + render::{self, Area, Font, Painter, quarter_arc}, + theme::Theme, + widget::{Action, MouseButton, Widget}, +}; + +/// Where a bar sits on the screen. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Placement { + TopCenter, + TopRight, +} + +impl Placement { + fn anchor(self) -> Anchor { + match self { + Self::TopCenter => Anchor::TOP, + Self::TopRight => Anchor::TOP | Anchor::RIGHT, + } + } + + /// Which sides get the inverted and rounded corners: `(left, right)`. + fn corners(self) -> (bool, bool) { + match self { + Self::TopCenter => (true, true), + Self::TopRight => (true, false), + } + } +} + +/// Widgets grouped by where they sit on the bar. +#[derive(Default)] +pub struct Widgets { + pub left: Vec>, + pub center: Vec>, + pub right: Vec>, +} + +impl Widgets { + fn sections(&self) -> [&[Box]; 3] { + [&self.left, &self.center, &self.right] + } + + /// Every widget, left to right. + fn iter(&self) -> impl Iterator> { + self.left.iter().chain(&self.center).chain(&self.right) + } + + fn iter_mut(&mut self) -> impl Iterator> { + self.left + .iter_mut() + .chain(&mut self.center) + .chain(&mut self.right) + } +} + +pub struct Bar { + placement: Placement, + layer: LayerSurface, + pool: SlotPool, + /// What we draw into. `None` until the compositor has told us our size. + pixmap: Option, + /// The surface width last asked from the compositor. 0 while hidden. + requested_width: u32, + font: Rc, + theme: Theme, + widgets: Widgets, +} + +impl Bar { + #[allow(clippy::too_many_arguments)] + pub fn new( + placement: Placement, + compositor: &CompositorState, + layer_shell: &LayerShell, + shm: &Shm, + qh: &QueueHandle, + theme: Theme, + font: Rc, + widgets: Widgets, + ) -> Result> { + let surface = compositor.create_surface(qh); + let layer = + layer_shell.create_layer_surface(qh, surface, Layer::Top, Some("pell-bar"), None); + layer.set_anchor(placement.anchor()); + layer.set_keyboard_interactivity(KeyboardInteractivity::None); + + let pool = SlotPool::new(theme.bar_height as usize * 256 * 4, shm)?; + let mut bar = Self { + placement, + layer, + pool, + pixmap: None, + requested_width: 0, + font, + theme, + widgets, + }; + bar.set_exclusive_zone(); + bar.update(); + bar.fit_to_content(compositor); + Ok(bar) + } + + pub fn layer(&self) -> &LayerSurface { + &self.layer + } + + pub fn is_surface(&self, surface: &WlSurface) -> bool { + self.layer.wl_surface() == surface + } + + pub fn theme(&self) -> &Theme { + &self.theme + } + + /// Replaces the theme. Call [`Bar::fit_to_content`] afterwards: sizes may have changed. + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + self.set_exclusive_zone(); + } + + /// Called when the compositor assigns us a size. + pub fn resize(&mut self, width: u32, height: u32) { + if self.requested_width == 0 { + return; // Hidden since this configure was sent. + } + let width = if width == 0 { + self.requested_width + } else { + width + }; + let height = if height == 0 { + self.theme.bar_height + } else { + height + }; + self.pixmap = Pixmap::new(width, height); + } + + /// Updates every widget. Returns `true` if any of them needs a redraw. + pub fn update(&mut self) -> bool { + // No short-circuit: every widget must get its update. + self.widgets + .iter_mut() + .fold(false, |changed, widget| widget.update() | changed) + } + + /// Asks the compositor for a surface just wide enough for the widgets, + /// or hides the bar if they have nothing to show. + /// Returns `true` if the size changed: the bar then gets redrawn once + /// the compositor confirms it with a configure event. + pub fn fit_to_content(&mut self, compositor: &CompositorState) -> bool { + let content_width = self.content_width(); + if content_width <= 0.0 { + self.hide(); + return false; + } + + let (left_corner, right_corner) = self.corner_widths(); + let body_width = (content_width + 2.0 * self.theme.padding).ceil(); + let width = (left_corner + body_width + right_corner).ceil() as u32; + if width == self.requested_width { + return false; + } + + // From hidden, this commit (without a buffer) maps the surface again. + self.requested_width = width; + self.layer.set_size(width, self.theme.bar_height); + self.set_input_region(compositor, left_corner as i32, body_width as i32); + self.layer.commit(); + true + } + + pub fn draw(&mut self) -> Result<(), Box> { + let areas = self.widget_areas(); + let Some(pixmap) = self.pixmap.as_mut() else { + return Ok(()); + }; + let (width, height) = (pixmap.width() as f32, pixmap.height() as f32); + let (left_corner, right_corner) = corner_widths(self.placement, &self.theme); + + let mut painter = Painter::new(pixmap, &self.font, &self.theme); + painter.clear(); + if let Some(shape) = bar_shape(width, height, left_corner, right_corner) { + painter.fill_path(&shape, self.theme.background); + } + for (widget, area) in self.widgets.iter().zip(areas) { + widget.draw(&mut painter, area); + } + + render::present(&mut self.pool, self.layer.wl_surface(), pixmap) + } + + /// Passes a click at surface position `x` to the widget under it. + pub fn click(&mut self, x: f32, button: MouseButton) -> Option { + let areas = self.widget_areas(); + let (widget, area) = self + .widgets + .iter_mut() + .zip(areas) + .find(|(_, area)| (area.x..area.x + area.width).contains(&x))?; + + match widget.click(x - area.x, button, &self.theme)? { + Action::ShowMenu { menu, x } => Some(Action::ShowMenu { + menu, + x: x + area.x, + }), + } + } + + fn hide(&mut self) { + if self.requested_width == 0 { + return; + } + self.requested_width = 0; + self.pixmap = None; + self.layer.wl_surface().attach(None, 0, 0); + self.layer.commit(); + } + + fn set_exclusive_zone(&self) { + let zone = match self.placement { + // Windows are placed below the bar. + Placement::TopCenter => self.theme.bar_height as i32, + // Sits in the strip the centered bar already reserves, rather than + // being pushed below it, and reserves nothing of its own. + Placement::TopRight => -1, + }; + self.layer.set_exclusive_zone(zone); + } + + fn corner_widths(&self) -> (f32, f32) { + corner_widths(self.placement, &self.theme) + } + + /// Where each widget goes, in the order of [`Widgets::iter`]: + /// sections left-aligned, centered and right-aligned in the body. + fn widget_areas(&self) -> Vec { + let (left_corner, right_corner) = self.corner_widths(); + let padding = self.theme.padding; + let content = Area { + x: left_corner + padding, + y: 0.0, + width: self.requested_width as f32 - left_corner - right_corner - 2.0 * padding, + height: self.theme.bar_height as f32, + }; + + let [left, center, right] = self.widgets.sections(); + let center_width = self.row_width(center); + let right_width = self.row_width(right); + let starts = [ + content.x, + content.x + (content.width - center_width) / 2.0, + content.x + content.width - right_width, + ]; + + let mut areas = Vec::new(); + for (row, start) in [left, center, right].into_iter().zip(starts) { + let mut x = start; + for widget in row { + let width = widget.width(&self.font, &self.theme); + areas.push(Area { + x, + width, + ..content + }); + x += width + self.theme.spacing; + } + } + areas + } + + /// Width of all widgets side by side, sections separated by `spacing`. + fn content_width(&self) -> f32 { + let rows: Vec = self + .widgets + .sections() + .into_iter() + .map(|row| self.row_width(row)) + .filter(|width| *width > 0.0) + .collect(); + let gaps = rows.len().saturating_sub(1) as f32 * self.theme.spacing; + rows.iter().sum::() + gaps + } + + /// Total width of `widgets` placed side by side. + fn row_width(&self, widgets: &[Box]) -> f32 { + let widths: Vec = widgets + .iter() + .map(|widget| widget.width(&self.font, &self.theme)) + .filter(|width| *width > 0.0) + .collect(); + let gaps = widths.len().saturating_sub(1) as f32 * self.theme.spacing; + widths.iter().sum::() + gaps + } + + /// Only the body takes pointer input: clicks on the transparent + /// inverted corners go to whatever is below. + fn set_input_region(&self, compositor: &CompositorState, body_x: i32, body_width: i32) { + match Region::new(compositor) { + Ok(region) => { + region.add(body_x, 0, body_width, self.theme.bar_height as i32); + self.layer + .wl_surface() + .set_input_region(Some(region.wl_region())); + } + Err(err) => eprintln!("pell: could not set the bar's input region: {err}"), + } + } +} + +/// The corner radius, never more than half the bar's height. +fn corner_radius(theme: &Theme) -> f32 { + theme.corner_radius.min(theme.bar_height as f32 / 2.0) +} + +/// How wide the inverted corner is on each side: `(left, right)`, 0 for no corner. +fn corner_widths(placement: Placement, theme: &Theme) -> (f32, f32) { + let radius = corner_radius(theme); + let (left, right) = placement.corners(); + ( + if left { radius } else { 0.0 }, + if right { radius } else { 0.0 }, + ) +} + +/// The bar outline (see the module docs), as a `width` x `height` path. +/// A side with a corner width of 0 is a straight edge. +fn bar_shape(width: f32, height: f32, left_corner: f32, right_corner: f32) -> Option { + let (left, right) = (left_corner, width - right_corner); // body edges + + let mut path = PathBuilder::new(); + path.move_to(0.0, 0.0); + path.line_to(width, 0.0); + if right_corner > 0.0 { + let r = right_corner; + quarter_arc(&mut path, (right, r), (width, r)); // inverted top-right + path.line_to(right, height - r); + quarter_arc(&mut path, (right - r, height), (right - r, height - r)); // rounded bottom-right + } else { + path.line_to(width, height); + } + if left_corner > 0.0 { + let r = left_corner; + path.line_to(left + r, height); + quarter_arc(&mut path, (left, height - r), (left + r, height - r)); // rounded bottom-left + path.line_to(left, r); + quarter_arc(&mut path, (0.0, 0.0), (0.0, r)); // inverted top-left + } else { + path.line_to(0.0, height); + } + path.close(); + path.finish() +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4756ed9 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,167 @@ +//! The user's config file, `$XDG_CONFIG_HOME/pell/config.toml`. +//! +//! Every key is optional; missing ones keep the [`Theme`] default: +//! +//! ```toml +//! [colors] +//! background = "#1e1e2e" # "#rrggbb" or "#rrggbbaa" +//! foreground = "#cdd6f4" +//! ``` +//! +//! The file is watched by checking its modification time, which is cheap +//! enough to do on every tick and also catches editors that save by +//! replacing the file. + +use std::{env, error::Error, fs, path::PathBuf, time::SystemTime}; + +use serde::Deserialize; +use tiny_skia::Color; + +use crate::theme::Theme; + +pub struct ConfigFile { + path: PathBuf, + /// Modification time when last read; `None` if the file didn't exist. + last_modified: Option, +} + +impl ConfigFile { + /// The config file location, or `None` if neither `XDG_CONFIG_HOME` nor `HOME` is set. + pub fn locate() -> Option { + let config_home = env::var_os("XDG_CONFIG_HOME") + .filter(|dir| !dir.is_empty()) + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; + + Some(Self { + path: config_home.join("pell").join("config.toml"), + last_modified: None, + }) + } + + /// Reads the theme. Falls back to the default theme if the file is + /// missing or invalid. + pub fn load(&mut self) -> Theme { + self.last_modified = self.modified(); + self.read_theme().unwrap_or_else(|err| { + eprintln!("pell: {}: {err}", self.path.display()); + Theme::default() + }) + } + + /// Returns the new theme if the file changed since it was last read. + /// An invalid file is reported and ignored, keeping the current theme. + pub fn reload_if_changed(&mut self) -> Option { + let modified = self.modified(); + if modified == self.last_modified { + return None; + } + self.last_modified = modified; + + match self.read_theme() { + Ok(theme) => Some(theme), + Err(err) => { + eprintln!("pell: {}: {err}", self.path.display()); + None + } + } + } + + fn modified(&self) -> Option { + fs::metadata(&self.path) + .and_then(|meta| meta.modified()) + .ok() + } + + fn read_theme(&self) -> Result> { + let mut theme = Theme::default(); + if !self.path.exists() { + return Ok(theme); + } + + let config: Config = toml::from_str(&fs::read_to_string(&self.path)?)?; + if let Some(HexColor(color)) = config.colors.background { + theme.background = color; + } + if let Some(HexColor(color)) = config.colors.foreground { + theme.foreground = color; + } + Ok(theme) + } +} + +/// The file's layout. Unknown keys are rejected to catch typos. +#[derive(Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +struct Config { + colors: Colors, +} + +#[derive(Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +struct Colors { + background: Option, + foreground: Option, +} + +/// A color written as `"#rrggbb"` or `"#rrggbbaa"`. +#[derive(Deserialize)] +#[serde(try_from = "String")] +struct HexColor(Color); + +impl TryFrom for HexColor { + type Error = String; + + fn try_from(text: String) -> Result { + let invalid = || format!("invalid color {text:?}, expected \"#rrggbb\" or \"#rrggbbaa\""); + + let hex = text.strip_prefix('#').ok_or_else(invalid)?; + if !matches!(hex.len(), 6 | 8) || !hex.is_ascii() { + return Err(invalid()); + } + let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| invalid()); + + let alpha = if hex.len() == 8 { channel(6)? } else { 0xff }; + Ok(Self(Color::from_rgba8( + channel(0)?, + channel(2)?, + channel(4)?, + alpha, + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(text: &str) -> Result { + HexColor::try_from(text.to_string()).map(|HexColor(color)| color) + } + + #[test] + fn parses_rgb_and_rgba() { + assert_eq!( + parse("#ff8000"), + Ok(Color::from_rgba8(0xff, 0x80, 0x00, 0xff)) + ); + assert_eq!( + parse("#ff800040"), + Ok(Color::from_rgba8(0xff, 0x80, 0x00, 0x40)) + ); + } + + #[test] + fn rejects_malformed_colors() { + for text in ["ff8000", "#ff80", "#ff80000", "#gg8000", "#ff80é00"] { + assert!(parse(text).is_err(), "{text} should be rejected"); + } + } + + #[test] + fn missing_keys_keep_defaults() { + let config: Config = toml::from_str("[colors]\nforeground = \"#000000\"").unwrap(); + assert!(config.colors.background.is_none()); + assert!(config.colors.foreground.is_some()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..57f8a03 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,65 @@ +//! Pell — a minimal widget based shell for Wayland. +//! +//! - `app`: Wayland connection, event loop and protocol handlers. +//! - `bar`: the layer surfaces at the top of the screen and their layout. +//! - `widget`: the `Widget` trait and the widgets themselves. +//! - `tray`: the system tray's D-Bus side (StatusNotifierItem). +//! - `menu_popup`: the popup showing a tray item's menu. +//! - `render`: font loading and the `Painter` widgets draw with. +//! - `theme`: colors, font and sizes. +//! - `config`: the user's config file, which can override the theme. + +mod app; +mod bar; +mod config; +mod menu_popup; +mod render; +mod theme; +mod tray; +mod widget; + +use std::{cell::RefCell, process::ExitCode, rc::Rc}; + +use bar::{Placement, Widgets}; +use config::ConfigFile; +use tray::Tray; +use widget::{Clock, TrayWidget}; + +fn main() -> ExitCode { + let mut config = ConfigFile::locate(); + let theme = config.as_mut().map(ConfigFile::load).unwrap_or_default(); + + // The shell still runs without a tray, e.g. when another one is active. + let tray = match Tray::connect(theme.icon_size.round() as u32) { + Ok(tray) => Some(Rc::new(RefCell::new(tray))), + Err(err) => { + eprintln!("pell: system tray disabled: {err}"); + None + } + }; + + let mut bars = vec![( + Placement::TopCenter, + Widgets { + center: vec![Box::new(Clock::new("%H:%M"))], + ..Default::default() + }, + )]; + if let Some(tray) = &tray { + bars.push(( + Placement::TopRight, + Widgets { + right: vec![Box::new(TrayWidget::new(tray.clone()))], + ..Default::default() + }, + )); + } + + match app::run(config, theme, bars, tray) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("pell: {err}"); + ExitCode::FAILURE + } + } +} diff --git a/src/menu_popup.rs b/src/menu_popup.rs new file mode 100644 index 0000000..dcc1005 --- /dev/null +++ b/src/menu_popup.rs @@ -0,0 +1,274 @@ +//! A tray item's menu, shown in a popup below the bar. +//! +//! Submenus are not opened separately: their entries are listed inline, +//! indented under the submenu's title. + +use std::{error::Error, rc::Rc}; + +use smithay_client_toolkit::{ + compositor::CompositorState, + error::GlobalError, + globals::{GlobalData, ProvidesBoundGlobal}, + reexports::{ + client::{ + QueueHandle, + globals::{BindError, GlobalList}, + protocol::{wl_seat::WlSeat, wl_surface::WlSurface}, + }, + protocols::xdg::shell::client::{ + xdg_positioner::{Anchor, ConstraintAdjustment, Gravity}, + xdg_wm_base::XdgWmBase, + }, + }, + shell::xdg::{XdgPositioner, popup::Popup}, + shm::{Shm, slot::SlotPool}, +}; +use tiny_skia::{Color, Pixmap}; + +use crate::{ + app::App, + bar::Bar, + render::{self, Area, Font, Painter}, + theme::Theme, + tray::{EntryKind, MenuEntry, MenuRef}, +}; + +/// Space between the menu's border and its rows. +const MARGIN: f32 = 6.0; +const SEPARATOR_HEIGHT: f32 = 9.0; + +/// The `xdg_wm_base` global, which popups are created from. +/// Bound directly: sctk's `XdgShell` also needs handlers for windows, which we don't have. +pub struct PopupShell(XdgWmBase); + +impl PopupShell { + pub fn bind(globals: &GlobalList, qh: &QueueHandle) -> Result { + globals.bind(qh, 1..=6, GlobalData).map(Self) + } +} + +impl ProvidesBoundGlobal for PopupShell { + fn bound_global(&self) -> Result { + Ok(self.0.clone()) + } +} + +pub struct MenuPopup { + popup: Popup, + pool: SlotPool, + pixmap: Option, + font: Rc, + theme: Theme, + pub menu: MenuRef, + entries: Vec, + /// Where each entry is drawn, in the same order. + rows: Vec, + hovered: Option, +} + +/// Where to open the menu. +pub struct Anchoring<'a> { + pub bar: &'a Bar, + /// Horizontal position on the bar the menu is centered on. + pub x: f32, + /// The click that opened it: the popup grabs the pointer from it. + pub seat: &'a WlSeat, + pub serial: u32, +} + +impl MenuPopup { + #[allow(clippy::too_many_arguments)] + pub fn open( + anchoring: Anchoring, + menu: MenuRef, + entries: Vec, + font: Rc, + compositor: &CompositorState, + xdg_shell: &PopupShell, + shm: &Shm, + qh: &QueueHandle, + ) -> Result> { + let theme = anchoring.bar.theme().clone(); + let (rows, width, height) = layout(&entries, &font, &theme); + + let positioner = XdgPositioner::new(xdg_shell)?; + positioner.set_size(width as i32, height as i32); + // Hang below the bar, centered on `x`, moved or flipped to stay on screen. + positioner.set_anchor_rect(anchoring.x as i32, 0, 1, theme.bar_height as i32); + positioner.set_anchor(Anchor::Bottom); + positioner.set_gravity(Gravity::Bottom); + positioner.set_constraint_adjustment( + ConstraintAdjustment::SlideX + | ConstraintAdjustment::SlideY + | ConstraintAdjustment::FlipY, + ); + + let surface = compositor.create_surface(qh); + let popup = Popup::from_surface(None, &positioner, qh, surface, xdg_shell)?; + anchoring.bar.layer().get_popup(popup.xdg_popup()); + // Grabbing lets the compositor close the menu on a click elsewhere. + popup.xdg_popup().grab(anchoring.seat, anchoring.serial); + popup.wl_surface().commit(); + + let pool = SlotPool::new((width * height * 4) as usize, shm)?; + Ok(Self { + popup, + pool, + pixmap: None, + font, + theme, + menu, + entries, + rows, + hovered: None, + }) + } + + pub fn is_surface(&self, surface: &WlSurface) -> bool { + self.popup.wl_surface() == surface + } + + pub fn is_popup(&self, popup: &Popup) -> bool { + self.popup.wl_surface() == popup.wl_surface() + } + + /// Called when the compositor assigns us a size. + pub fn resize(&mut self, width: u32, height: u32) { + self.pixmap = Pixmap::new(width, height); + } + + /// Highlights the entry under `y` (`None` when the pointer left). + /// Returns `true` if the highlight moved. + pub fn hover(&mut self, y: Option) -> bool { + let hovered = y.and_then(|y| self.clickable_at(y)); + let changed = hovered != self.hovered; + self.hovered = hovered; + changed + } + + /// The entry under `y`, if it can be clicked. + pub fn entry_at(&self, y: f32) -> Option<&MenuEntry> { + self.clickable_at(y).map(|index| &self.entries[index]) + } + + pub fn draw(&mut self) -> Result<(), Box> { + let Some(pixmap) = self.pixmap.as_mut() else { + return Ok(()); + }; + let bounds = Area { + x: 0.0, + y: 0.0, + width: pixmap.width() as f32, + height: pixmap.height() as f32, + }; + let theme = &self.theme; + let mut painter = Painter::new(pixmap, &self.font, theme); + painter.clear(); + painter.fill_rounded_rect(bounds, theme.corner_radius, theme.background); + + for (index, (entry, row)) in self.entries.iter().zip(&self.rows).enumerate() { + if self.hovered == Some(index) { + let highlight = faded(theme.foreground, 0.15); + painter.fill_rounded_rect(*row, theme.corner_radius / 2.0, highlight); + } + draw_entry(&mut painter, entry, *row, theme); + } + + render::present(&mut self.pool, self.popup.wl_surface(), pixmap) + } + + fn clickable_at(&self, y: f32) -> Option { + let index = self + .rows + .iter() + .position(|row| (row.y..row.y + row.height).contains(&y))?; + let entry = &self.entries[index]; + let clickable = + entry.enabled && matches!(entry.kind, EntryKind::Item | EntryKind::Toggle(_)); + clickable.then_some(index) + } +} + +/// Places every entry under the previous one. +/// Returns the rows and the size of the whole menu. +fn layout(entries: &[MenuEntry], font: &Font, theme: &Theme) -> (Vec, u32, u32) { + let row_height = (theme.font_size * 1.8).round(); + let content_width = entries + .iter() + .map(|entry| label_x(entry, theme) + font.stable_text_width(&entry.label, theme.font_size)) + .fold(0.0, f32::max); + let width = (content_width + 2.0 * (MARGIN + theme.padding)).ceil(); + + let mut y = MARGIN; + let rows = entries + .iter() + .map(|entry| { + let height = match entry.kind { + EntryKind::Separator => SEPARATOR_HEIGHT, + _ => row_height, + }; + let row = Area { + x: MARGIN, + y, + width: width - 2.0 * MARGIN, + height, + }; + y += height; + row + }) + .collect(); + (rows, width as u32, (y + MARGIN).ceil() as u32) +} + +/// Where the label starts in its row, past the indent and the check mark. +fn label_x(entry: &MenuEntry, theme: &Theme) -> f32 { + let check_column = theme.font_size * 1.2; + entry.depth as f32 * theme.font_size + check_column +} + +fn draw_entry(painter: &mut Painter, entry: &MenuEntry, row: Area, theme: &Theme) { + let inner = Area { + x: row.x + theme.padding, + width: row.width - 2.0 * theme.padding, + ..row + }; + if entry.kind == EntryKind::Separator { + let line = Area { + y: row.y + row.height / 2.0, + height: 1.0, + ..inner + }; + painter.fill_rounded_rect(line, 0.0, faded(theme.foreground, 0.2)); + return; + } + + if entry.kind == EntryKind::Toggle(true) { + let size = theme.font_size * 0.4; + let dot = Area { + x: inner.x + entry.depth as f32 * theme.font_size, + y: row.y + (row.height - size) / 2.0, + width: size, + height: size, + }; + painter.fill_rounded_rect(dot, size / 2.0, theme.foreground); + } + + let color = match entry.kind { + _ if !entry.enabled => faded(theme.foreground, 0.4), + // Titles of inline submenus. + EntryKind::Submenu => faded(theme.foreground, 0.6), + _ => theme.foreground, + }; + let x = inner.x + label_x(entry, theme); + let label = Area { + x, + width: inner.x + inner.width - x, + ..row + }; + painter.draw_text(&entry.label, label, color); +} + +fn faded(mut color: Color, opacity: f32) -> Color { + color.apply_opacity(opacity); + color +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..81ceb7a --- /dev/null +++ b/src/render.rs @@ -0,0 +1,236 @@ +//! Software rendering: a font, an area type and a `Painter` widgets draw with. + +use std::{error::Error, fs, process::Command}; + +use smithay_client_toolkit::{ + reexports::client::protocol::{wl_shm, wl_surface::WlSurface}, + shm::slot::SlotPool, +}; +use tiny_skia::{ + Color, FillRule, FilterQuality, Paint, Path, PathBuilder, Pixmap, PixmapPaint, Point, Transform, +}; + +use crate::theme::Theme; + +/// A rectangle in surface pixels. +#[derive(Clone, Copy, Debug)] +pub struct Area { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +pub struct Font { + inner: fontdue::Font, +} + +impl Font { + /// Resolves `family` with `fc-match` and loads the matching font file. + pub fn load(family: &str) -> Result> { + let output = Command::new("fc-match") + .arg("--format=%{file}") + .arg(family) + .output()?; + let path = String::from_utf8(output.stdout)?; + if path.is_empty() { + return Err(format!("no font found for family {family:?}").into()); + } + + let bytes = fs::read(&path)?; + let inner = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default())?; + Ok(Self { inner }) + } + + /// Measures `text` with every digit counted as the widest one, + /// so text with changing numbers (like a clock) keeps the same width. + pub fn stable_text_width(&self, text: &str, size: f32) -> f32 { + let widest_digit = ('0'..='9') + .map(|digit| self.inner.metrics(digit, size).advance_width) + .fold(0.0, f32::max); + + text.chars() + .map(|c| match c.is_ascii_digit() { + true => widest_digit, + false => self.inner.metrics(c, size).advance_width, + }) + .sum() + } +} + +/// Everything a widget needs to draw itself. +pub struct Painter<'a> { + pixmap: &'a mut Pixmap, + font: &'a Font, + pub theme: &'a Theme, +} + +impl<'a> Painter<'a> { + pub fn new(pixmap: &'a mut Pixmap, font: &'a Font, theme: &'a Theme) -> Self { + Self { + pixmap, + font, + theme, + } + } + + /// Makes the whole surface fully transparent. + pub fn clear(&mut self) { + self.pixmap.fill(Color::TRANSPARENT); + } + + pub fn fill_path(&mut self, path: &Path, color: Color) { + let mut paint = Paint::default(); + paint.set_color(color); + paint.anti_alias = true; + self.pixmap + .fill_path(path, &paint, FillRule::Winding, Transform::identity(), None); + } + + pub fn fill_rounded_rect(&mut self, area: Area, radius: f32, color: Color) { + if let Some(path) = rounded_rect(area, radius) { + self.fill_path(&path, color); + } + } + + /// Draws `image` scaled to fit `area`, centered, keeping its aspect ratio. + pub fn draw_image(&mut self, image: &Pixmap, area: Area) { + let scale = (area.width / image.width() as f32).min(area.height / image.height() as f32); + let x = area.x + (area.width - image.width() as f32 * scale) / 2.0; + let y = area.y + (area.height - image.height() as f32 * scale) / 2.0; + + let paint = PixmapPaint { + quality: FilterQuality::Bicubic, + ..PixmapPaint::default() + }; + let transform = Transform::from_row(scale, 0.0, 0.0, scale, x, y); + self.pixmap + .draw_pixmap(0, 0, image.as_ref(), &paint, transform, None); + } + + /// Draws `text` left-aligned in `area` and vertically centered. + /// Anything outside `area` is clipped. + pub fn draw_text(&mut self, text: &str, area: Area, color: Color) { + let size = self.theme.font_size; + let baseline = self.centered_baseline(area); + let right_edge = (area.x + area.width).ceil() as i32; + let mut pen_x = area.x; + + for c in text.chars() { + let (metrics, coverage) = self.font.inner.rasterize(c, size); + let left = pen_x.round() as i32 + metrics.xmin; + let top = baseline - metrics.height as i32 - metrics.ymin; + + for (i, &alpha) in coverage.iter().enumerate() { + let x = left + (i % metrics.width) as i32; + let y = top + (i / metrics.width) as i32; + if x < right_edge { + self.blend_pixel(x, y, color, alpha); + } + } + + pen_x += metrics.advance_width; + } + } + + fn centered_baseline(&self, area: Area) -> i32 { + let Some(line) = self + .font + .inner + .horizontal_line_metrics(self.theme.font_size) + else { + return (area.y + area.height) as i32; + }; + let text_height = line.ascent - line.descent; + (area.y + (area.height - text_height) / 2.0 + line.ascent).round() as i32 + } + + /// Source-over blends `color`, scaled by `coverage`, onto one pixel. + fn blend_pixel(&mut self, x: i32, y: i32, color: Color, coverage: u8) { + let (width, height) = (self.pixmap.width() as i32, self.pixmap.height() as i32); + if coverage == 0 || x < 0 || y < 0 || x >= width || y >= height { + return; + } + + let alpha = color.alpha() * coverage as f32 / 255.0; + let source = [color.red(), color.green(), color.blue()].map(|c| c * alpha); + + let offset = (y * width + x) as usize * 4; + let pixel = &mut self.pixmap.data_mut()[offset..offset + 4]; + let out_alpha = alpha + pixel[3] as f32 / 255.0 * (1.0 - alpha); + + for (channel, source) in pixel[..3].iter_mut().zip(source) { + let blended = source + *channel as f32 / 255.0 * (1.0 - alpha); + // Keep the premultiplied invariant (channel <= alpha) despite rounding. + *channel = (blended.min(out_alpha) * 255.0).round() as u8; + } + pixel[3] = (out_alpha * 255.0).round() as u8; + } +} + +/// Shows `pixmap` on `surface`: copies it into a fresh shared-memory buffer, +/// attaches it and commits. +pub fn present( + pool: &mut SlotPool, + surface: &WlSurface, + pixmap: &Pixmap, +) -> Result<(), Box> { + let (width, height) = (pixmap.width() as i32, pixmap.height() as i32); + let (buffer, canvas) = + pool.create_buffer(width, height, width * 4, wl_shm::Format::Argb8888)?; + copy_to_argb8888(pixmap, canvas); + + surface.damage_buffer(0, 0, width, height); + buffer.attach_to(surface)?; + surface.commit(); + Ok(()) +} + +/// Copies a premultiplied RGBA pixmap into a Wayland `Argb8888` buffer +/// (stored as little-endian BGRA bytes). +fn copy_to_argb8888(pixmap: &Pixmap, buffer: &mut [u8]) { + let (src_pixels, _) = pixmap.data().as_chunks::<4>(); + let (dst_pixels, _) = buffer.as_chunks_mut::<4>(); + for (&[r, g, b, a], dst) in src_pixels.iter().zip(dst_pixels) { + *dst = [b, g, r, a]; + } +} + +pub fn rounded_rect(area: Area, radius: f32) -> Option { + let r = radius.min(area.width / 2.0).min(area.height / 2.0); + let (left, top) = (area.x, area.y); + let (right, bottom) = (area.x + area.width, area.y + area.height); + + let mut path = PathBuilder::new(); + path.move_to(left + r, top); + path.line_to(right - r, top); + quarter_arc(&mut path, (right, top + r), (right - r, top + r)); + path.line_to(right, bottom - r); + quarter_arc(&mut path, (right - r, bottom), (right - r, bottom - r)); + path.line_to(left + r, bottom); + quarter_arc(&mut path, (left, bottom - r), (left + r, bottom - r)); + path.line_to(left, top + r); + quarter_arc(&mut path, (left + r, top), (left + r, top + r)); + path.close(); + path.finish() +} + +/// Continues `path` with a quarter circle around `center`, ending at `to`. +pub fn quarter_arc(path: &mut PathBuilder, to: (f32, f32), center: (f32, f32)) { + // Control point distance that best approximates a circle with a cubic Bézier. + const K: f32 = 0.552_284_8; + + let from = path.last_point().unwrap_or(Point::zero()); + let (to_x, to_y) = to; + let (center_x, center_y) = center; + // Each control point leaves its end point along the circle's tangent. + let control1 = ( + from.x + (to_x - center_x) * K, + from.y + (to_y - center_y) * K, + ); + let control2 = ( + to_x + (from.x - center_x) * K, + to_y + (from.y - center_y) * K, + ); + path.cubic_to(control1.0, control1.1, control2.0, control2.1, to_x, to_y); +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..8668b8a --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,42 @@ +//! Visual settings shared by the bar and every widget. + +use tiny_skia::Color; + +#[derive(Clone)] +pub struct Theme { + /// Font family name, resolved through fontconfig (e.g. "monospace", "Iosevka"). + pub font_family: String, + pub font_size: f32, + /// Size of tray icons, in pixels. + pub icon_size: f32, + + pub bar_height: u32, + /// Radius of the bar's rounded bottom corners and of the inverted + /// corners joining it to the screen edge. At most half the bar height. + pub corner_radius: f32, + /// Space between the bar edges and the first/last widget. + pub padding: f32, + /// Space between two widgets in the same section. + pub spacing: f32, + + pub background: Color, + pub foreground: Color, +} + +impl Default for Theme { + fn default() -> Self { + Self { + font_family: "monospace".into(), + font_size: 16.0, + icon_size: 18.0, + + bar_height: 28, + corner_radius: 10.0, + padding: 12.0, + spacing: 12.0, + + background: Color::from_rgba8(0x1e, 0x1e, 0x2e, 0xff), + foreground: Color::from_rgba8(0xcd, 0xd6, 0xf4, 0xff), + } + } +} diff --git a/src/tray/icon.rs b/src/tray/icon.rs new file mode 100644 index 0000000..c920267 --- /dev/null +++ b/src/tray/icon.rs @@ -0,0 +1,236 @@ +//! Loads tray icons, either from the pixels an app sends or from an icon +//! theme by name. Only PNG files are supported (no SVG). +//! +//! Icons are scaled down to the size they are drawn at once, when loaded, +//! so only small pixmaps are kept in memory. + +use std::{ + env, fs, + io::BufReader, + path::{Path, PathBuf}, +}; + +use tiny_skia::{FilterQuality, IntSize, Pixmap, PixmapPaint, Transform}; + +/// Theme directories are `///.png`, +/// so icon files sit two directories below the theme root. +const THEME_DEPTH: usize = 2; + +/// Looks up `name` in `theme_path` (an app-provided directory), the user's +/// icon theme, `hicolor`, then the `pixmaps` directories. +/// An absolute `name` is used as the file path. +pub fn from_theme(name: &str, theme_path: Option<&str>, size: u32) -> Option { + if name.starts_with('/') { + return load_png(Path::new(name), size); + } + let file_name = format!("{name}.png"); + + let mut roots: Vec = theme_path + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .into_iter() + .collect(); + let data_dirs = data_dirs(); + for theme in user_icon_theme() + .iter() + .map(String::as_str) + .chain(["hicolor"]) + { + roots.extend(data_dirs.iter().map(|dir| dir.join("icons").join(theme))); + } + + let themed = roots.iter().find_map(|root| { + let mut found = Vec::new(); + find_files(root, &file_name, THEME_DEPTH, &mut found); + pick_closest(found, size, |path| size_of_dir(path)) + }); + let path = themed.or_else(|| { + data_dirs + .iter() + .map(|dir| dir.join("pixmaps").join(&file_name)) + .find(|path| path.is_file()) + })?; + load_png(&path, size) +} + +/// Converts the `IconPixmap` property: images in several sizes, with pixels +/// as ARGB32 in network byte order (bytes `[a, r, g, b]`). +pub fn from_argb32(images: &[(i32, i32, Vec)], size: u32) -> Option { + let valid = images.iter().filter(|(width, height, pixels)| { + *width > 0 && *height > 0 && pixels.len() == (*width as usize) * (*height as usize) * 4 + }); + let (width, height, pixels) = pick_closest(valid, size, |(width, _, _)| Some(*width as u32))?; + + let rgba = pixels + .as_chunks::<4>() + .0 + .iter() + .flat_map(|&[a, r, g, b]| [r, g, b, a]) + .collect(); + let image = premultiplied_pixmap(rgba, *width as u32, *height as u32)?; + Some(scale_to(image, size)) +} + +/// The smallest candidate at least `size` big, or else the biggest one. +/// Candidates of unknown size come last. +fn pick_closest( + candidates: impl IntoIterator, + size: u32, + size_of: impl Fn(&T) -> Option, +) -> Option { + candidates + .into_iter() + .min_by_key(|candidate| match size_of(candidate) { + Some(found) if found >= size => (0, found), + Some(found) => (1, u32::MAX - found), + None => (2, 0), + }) +} + +/// Collects `/**/`, looking `depth` directories deep. +fn find_files(root: &Path, file_name: &str, depth: usize, found: &mut Vec) { + let candidate = root.join(file_name); + if candidate.is_file() { + found.push(candidate); + } + if depth == 0 { + return; + } + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|kind| kind.is_dir()) { + find_files(&entry.path(), file_name, depth - 1, found); + } + } +} + +/// The icon size of a theme file, read from a directory name like `22x22` +/// or `22x22@2x`. `None` for `scalable` or unsized directories. +fn size_of_dir(path: &Path) -> Option { + path.ancestors().skip(1).take(THEME_DEPTH).find_map(|dir| { + let name = dir.file_name()?.to_str()?; + let (width, _) = name.split_once('x')?; + width.parse().ok() + }) +} + +/// `$XDG_DATA_HOME` then `$XDG_DATA_DIRS`, with the spec's defaults. +fn data_dirs() -> Vec { + let home = env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))); + let system = env::var("XDG_DATA_DIRS") + .ok() + .filter(|dirs| !dirs.is_empty()) + .unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + home.into_iter() + .chain(system.split(':').map(PathBuf::from)) + .collect() +} + +/// The icon theme set in GTK's settings, if any. +fn user_icon_theme() -> Option { + let config = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; + let settings = fs::read_to_string(config.join("gtk-3.0/settings.ini")).ok()?; + settings.lines().find_map(|line| { + let (key, value) = line.split_once('=')?; + (key.trim() == "gtk-icon-theme-name").then(|| value.trim().to_string()) + }) +} + +fn load_png(path: &Path, size: u32) -> Option { + let file = fs::File::open(path).ok()?; + let mut decoder = png::Decoder::new(BufReader::new(file)); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().ok()?; + let mut buffer = vec![0; reader.output_buffer_size()?]; + let info = reader.next_frame(&mut buffer).ok()?; + buffer.truncate(info.buffer_size()); + + let rgba = match info.color_type { + png::ColorType::Rgba => buffer, + png::ColorType::Rgb => expand(&buffer, 3, |p| [p[0], p[1], p[2], 255]), + png::ColorType::GrayscaleAlpha => expand(&buffer, 2, |p| [p[0], p[0], p[0], p[1]]), + png::ColorType::Grayscale => expand(&buffer, 1, |p| [p[0], p[0], p[0], 255]), + // Expanded to RGB(A) by `normalize_to_color8`. + png::ColorType::Indexed => return None, + }; + let image = premultiplied_pixmap(rgba, info.width, info.height)?; + Some(scale_to(image, size)) +} + +fn expand(bytes: &[u8], channels: usize, to_rgba: impl Fn(&[u8]) -> [u8; 4]) -> Vec { + bytes.chunks_exact(channels).flat_map(to_rgba).collect() +} + +/// Builds a pixmap from straight (non-premultiplied) RGBA bytes. +fn premultiplied_pixmap(mut rgba: Vec, width: u32, height: u32) -> Option { + for pixel in rgba.as_chunks_mut::<4>().0 { + let alpha = u16::from(pixel[3]); + for channel in &mut pixel[..3] { + *channel = ((u16::from(*channel) * alpha + 127) / 255) as u8; + } + } + Pixmap::from_vec(rgba, IntSize::from_wh(width, height)?) +} + +/// Shrinks `image` so its larger side is `size`, keeping the aspect ratio. +/// Smaller images are kept as they are. +fn scale_to(image: Pixmap, size: u32) -> Pixmap { + let largest = image.width().max(image.height()); + if largest <= size { + return image; + } + let scale = size as f32 / largest as f32; + let width = ((image.width() as f32 * scale).round() as u32).max(1); + let height = ((image.height() as f32 * scale).round() as u32).max(1); + let Some(mut scaled) = Pixmap::new(width, height) else { + return image; + }; + let paint = PixmapPaint { + quality: FilterQuality::Bicubic, + ..PixmapPaint::default() + }; + scaled.draw_pixmap( + 0, + 0, + image.as_ref(), + &paint, + Transform::from_scale(scale, scale), + None, + ); + scaled +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picks_smallest_icon_at_least_the_target_size() { + let sizes = [Some(16), Some(48), Some(24), None]; + assert_eq!(pick_closest(sizes, 22, |size| *size), Some(Some(24))); + assert_eq!(pick_closest(sizes, 64, |size| *size), Some(Some(48))); + } + + #[test] + fn reads_size_from_theme_directories() { + let path = Path::new("/icons/hicolor/22x22@2x/apps/steam.png"); + assert_eq!(size_of_dir(path), Some(22)); + assert_eq!( + size_of_dir(Path::new("/icons/hicolor/scalable/apps/a.png")), + None + ); + } + + #[test] + fn converts_argb_to_premultiplied_rgba() { + // One half-transparent red pixel. + let icon = from_argb32(&[(1, 1, vec![128, 255, 0, 0])], 16).unwrap(); + assert_eq!(icon.data(), &[128, 0, 0, 128]); + } +} diff --git a/src/tray/item.rs b/src/tray/item.rs new file mode 100644 index 0000000..db1a3ff --- /dev/null +++ b/src/tray/item.rs @@ -0,0 +1,136 @@ +//! One tray item: an app's `org.kde.StatusNotifierItem` D-Bus object. + +use dbus::{ + Message, + arg::{PropMap, RefArg, Variant}, + channel::Channel, +}; +use tiny_skia::Pixmap; + +use super::{CALL_TIMEOUT, icon, menu::MenuRef}; + +pub const INTERFACE: &str = "org.kde.StatusNotifierItem"; + +/// An item's icon in every size the app provides: `(width, height, ARGB32 big-endian pixels)`. +type IconPixmaps = Vec<(i32, i32, Vec)>; + +pub struct TrayItem { + /// Where the item lives: bus name + object path. + bus_name: String, + path: String, + /// Unique bus name (`:1.42`) of the app; signals come from it. + owner: String, + + pub title: String, + /// `None` if no usable icon was found. + pub icon: Option, + /// The item's D-Bus menu, if it exports one. + pub menu: Option, + /// The item only supports showing its menu, not being activated. + pub item_is_menu: bool, + /// The app asks for the item to be hidden. + pub(super) passive: bool, +} + +impl TrayItem { + pub(super) fn load( + channel: &Channel, + bus_name: &str, + path: &str, + owner: &str, + icon_size: u32, + ) -> Self { + let mut item = Self { + bus_name: bus_name.into(), + path: path.into(), + owner: owner.into(), + title: String::new(), + icon: None, + menu: None, + item_is_menu: false, + passive: false, + }; + item.refresh(channel, icon_size); + item + } + + /// Re-reads every property from the app. + pub(super) fn refresh(&mut self, channel: &Channel, icon_size: u32) { + let Some(properties) = self.all_properties(channel) else { + return; + }; + let string = |name: &str| properties.get(name).and_then(|value| value.0.as_str()); + + self.title = string("Title").unwrap_or_default().to_string(); + self.passive = string("Status") == Some("Passive"); + self.item_is_menu = properties + .get("ItemIsMenu") + .and_then(|value| value.0.as_i64()) + .is_some_and(|value| value != 0); + self.menu = string("Menu") + .filter(|path| *path != "/") + .map(|path| MenuRef::new(&self.bus_name, path)); + + let theme_icon = string("IconName") + .filter(|name| !name.is_empty()) + .and_then(|name| icon::from_theme(name, string("IconThemePath"), icon_size)); + self.icon = theme_icon.or_else(|| { + let pixmaps = self.icon_pixmaps(channel)?; + icon::from_argb32(&pixmaps, icon_size) + }); + } + + /// The id other tray clients know this item by. + pub(super) fn id(&self) -> String { + format!("{}{}", self.bus_name, self.path) + } + + pub(super) fn belongs_to(&self, bus_name: &str) -> bool { + self.owner == bus_name || self.bus_name == bus_name + } + + pub(super) fn is_registered_as(&self, bus_name: &str, path: &str) -> bool { + self.bus_name == bus_name && self.path == path + } + + /// Whether a signal from `sender` at `path` is about this item. + pub(super) fn is_object(&self, sender: &str, path: &str) -> bool { + self.owner == sender && self.path == path + } + + /// A call to one of the item's methods taking a screen position + /// (`Activate`, `SecondaryActivate`, `ContextMenu`). Wayland clients + /// don't know where they are on screen, so the position is always 0, 0. + pub(super) fn call_at_origin(&self, method: &str) -> Option { + let call = Message::new_method_call(&self.bus_name, &self.path, INTERFACE, method).ok()?; + Some(call.append2(0i32, 0i32)) + } + + fn all_properties(&self, channel: &Channel) -> Option { + let call = self.properties_call("GetAll")?.append1(INTERFACE); + let reply = channel.send_with_reply_and_block(call, CALL_TIMEOUT).ok()?; + reply.read1().ok() + } + + /// Read on its own: as a generic property it would be parsed byte by byte. + fn icon_pixmaps(&self, channel: &Channel) -> Option { + let call = self + .properties_call("Get")? + .append2(INTERFACE, "IconPixmap"); + let reply = channel.send_with_reply_and_block(call, CALL_TIMEOUT).ok()?; + reply + .read1::>() + .ok() + .map(|variant| variant.0) + } + + fn properties_call(&self, method: &str) -> Option { + Message::new_method_call( + &self.bus_name, + &self.path, + "org.freedesktop.DBus.Properties", + method, + ) + .ok() + } +} diff --git a/src/tray/menu.rs b/src/tray/menu.rs new file mode 100644 index 0000000..b5a0e24 --- /dev/null +++ b/src/tray/menu.rs @@ -0,0 +1,206 @@ +//! Tray item menus, through the `com.canonical.dbusmenu` interface. +//! +//! The app sends its menu as a tree. Pell shows it as one flat list: +//! submenu entries become indented sections instead of opening new popups. + +use dbus::{ + Message, + arg::{ArgType, Iter, PropMap, RefArg, Variant}, +}; + +use super::{CALL_TIMEOUT, Tray}; + +const INTERFACE: &str = "com.canonical.dbusmenu"; + +/// Where an item's menu lives on the bus. +#[derive(Clone, Debug, PartialEq)] +pub struct MenuRef { + bus_name: String, + path: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MenuEntry { + /// The app's id for the entry, sent back when it is clicked. + pub id: i32, + pub label: String, + pub enabled: bool, + pub kind: EntryKind, + /// How many submenus deep the entry is; 0 for top-level entries. + pub depth: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EntryKind { + Item, + Separator, + /// A check box or radio button, and whether it is checked. + Toggle(bool), + /// A submenu's title; its entries follow, one level deeper. + Submenu, +} + +impl MenuRef { + pub(super) fn new(bus_name: &str, path: &str) -> Self { + Self { + bus_name: bus_name.into(), + path: path.into(), + } + } + + fn call(&self, method: &str) -> Option { + Message::new_method_call(&self.bus_name, &self.path, INTERFACE, method).ok() + } +} + +impl Tray { + /// Fetches the current entries of a menu, about to be shown. + /// Empty if the app doesn't answer. + pub fn menu_entries(&mut self, menu: &MenuRef) -> Vec { + // Lets the app update the menu first. Its answer doesn't matter. + if let Some(call) = menu.call("AboutToShow") { + let _ = self + .channel + .send_with_reply_and_block(call.append1(0i32), CALL_TIMEOUT); + } + + // Root entry 0, all levels (-1), all properties (empty list). + let Some(call) = menu.call("GetLayout") else { + return Vec::new(); + }; + let call = call.append3(0i32, -1i32, Vec::::new()); + let reply = self.channel.send_with_reply_and_block(call, CALL_TIMEOUT); + + let mut entries = Vec::new(); + if let Ok(reply) = reply { + // Reply: (revision: u, layout: (ia{sv}av)). + let mut args = reply.iter_init(); + args.next(); + if let Some(mut root) = args.recurse(ArgType::Struct) { + read_children(&mut root, 0, &mut entries); + } + } + entries + } + + pub fn menu_clicked(&self, menu: &MenuRef, entry_id: i32) { + if let Some(call) = menu.call("Event") { + let timestamp = 0u32; + let data = Variant(0i32); + self.send(call.append3(entry_id, "clicked", data).append1(timestamp)); + } + } +} + +/// Reads the children of the layout node `node` points at (`(ia{sv}av)`). +fn read_children(node: &mut Iter, depth: usize, entries: &mut Vec) -> Option<()> { + node.next(); // id + node.next(); // properties + let mut children = node.recurse(ArgType::Array)?; + while children.arg_type() == ArgType::Variant { + if let Some(mut child) = children.recurse(ArgType::Variant) + && let Some(mut child) = child.recurse(ArgType::Struct) + { + read_entry(&mut child, depth, entries); + } + children.next(); + } + Some(()) +} + +/// Reads one layout node, then its children if it is a submenu. +fn read_entry(node: &mut Iter, depth: usize, entries: &mut Vec) -> Option<()> { + let id: i32 = node.get()?; + let mut fields = *node; + fields.next(); + let properties: PropMap = fields.get()?; + + let entry = entry_from(id, &properties, depth)?; + let is_submenu = entry.kind == EntryKind::Submenu; + entries.push(entry); + if is_submenu { + read_children(node, depth + 1, entries); + } + Some(()) +} + +/// Builds an entry from its dbusmenu properties. `None` for hidden entries. +fn entry_from(id: i32, properties: &PropMap, depth: usize) -> Option { + let string = |name: &str| properties.get(name).and_then(|value| value.0.as_str()); + let number = |name: &str| properties.get(name).and_then(|value| value.0.as_i64()); + // Booleans default to true when missing. + let flag = |name: &str| number(name).is_none_or(|value| value != 0); + + if !flag("visible") { + return None; + } + let kind = if string("type") == Some("separator") { + EntryKind::Separator + } else if string("children-display") == Some("submenu") { + EntryKind::Submenu + } else if matches!(string("toggle-type"), Some("checkmark" | "radio")) { + EntryKind::Toggle(number("toggle-state") == Some(1)) + } else { + EntryKind::Item + }; + + Some(MenuEntry { + id, + label: strip_mnemonics(string("label").unwrap_or_default()), + enabled: flag("enabled"), + kind, + depth, + }) +} + +/// Removes keyboard-shortcut markers: `_File` → `File`, `__` → `_`. +fn strip_mnemonics(label: &str) -> String { + let mut text = String::with_capacity(label.len()); + let mut chars = label.chars(); + while let Some(char) = chars.next() { + match char { + '_' => text.extend(chars.next()), + other => text.push(other), + } + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + fn properties(pairs: Vec<(&str, Box)>) -> PropMap { + pairs + .into_iter() + .map(|(name, value)| (name.to_string(), Variant(value))) + .collect() + } + + #[test] + fn strips_mnemonics() { + assert_eq!(strip_mnemonics("_Quit"), "Quit"); + assert_eq!(strip_mnemonics("snake__case"), "snake_case"); + } + + #[test] + fn reads_entry_kinds() { + let separator = properties(vec![("type", Box::new("separator".to_string()))]); + assert_eq!( + entry_from(1, &separator, 0).unwrap().kind, + EntryKind::Separator + ); + + let checked = properties(vec![ + ("toggle-type", Box::new("checkmark".to_string())), + ("toggle-state", Box::new(1i32)), + ]); + assert_eq!( + entry_from(2, &checked, 0).unwrap().kind, + EntryKind::Toggle(true) + ); + + let hidden = properties(vec![("visible", Box::new(false))]); + assert_eq!(entry_from(3, &hidden, 0), None); + } +} diff --git a/src/tray/mod.rs b/src/tray/mod.rs new file mode 100644 index 0000000..19340e1 --- /dev/null +++ b/src/tray/mod.rs @@ -0,0 +1,161 @@ +//! System tray, following the StatusNotifierItem spec. +//! +//! Apps publish their tray icon as a D-Bus object (an "item") and register it +//! with the `org.kde.StatusNotifierWatcher` service. Pell *is* that watcher, +//! and also the "host" that shows the items. Everything runs on the main +//! thread: the D-Bus socket is polled by the event loop, which calls +//! [`Tray::dispatch`] when it is readable. +//! +//! - `watcher`: answers the registration calls apps make to us. +//! - `item`: one tray icon and the properties we read from it. +//! - `icon`: turns an item's icon (pixels or a theme icon name) into a pixmap. +//! - `menu`: an item's context menu, through the `com.canonical.dbusmenu` interface. + +mod icon; +mod item; +mod menu; +mod watcher; + +use std::{error::Error, os::fd::RawFd, time::Duration}; + +use dbus::{ + Message, MessageType, + channel::{BusType, Channel}, +}; + +pub use item::TrayItem; +pub use menu::{EntryKind, MenuEntry, MenuRef}; + +/// How long a blocking call waits for an app to answer. +/// Kept short: the whole shell waits while an app is slow to reply. +const CALL_TIMEOUT: Duration = Duration::from_millis(300); + +pub struct Tray { + channel: Channel, + items: Vec, + /// Size icons are loaded at, in pixels. + icon_size: u32, + /// Incremented on every change, so widgets know when to redraw. + revision: u64, +} + +impl Tray { + /// Connects to the session bus and takes the watcher name. + /// Fails if another tray (watcher) is already running. + pub fn connect(icon_size: u32) -> Result> { + let mut channel = Channel::get_private(BusType::Session)?; + channel.set_watch_enabled(true); + + let tray = Self { + channel, + items: Vec::new(), + icon_size, + revision: 0, + }; + tray.start_watcher()?; + Ok(tray) + } + + /// The D-Bus socket, for the event loop to poll. + pub fn fd(&self) -> RawFd { + self.channel.watch().fd + } + + pub fn revision(&self) -> u64 { + self.revision + } + + /// Items to show. "Passive" items ask to be hidden. + pub fn visible_items(&self) -> impl Iterator { + self.items.iter().filter(|item| !item.passive) + } + + /// Handles every message waiting on the bus. + /// Call it when the socket is readable, and after any other tray call: + /// blocking calls may have queued incoming messages without waking the event loop. + pub fn dispatch(&mut self) { + // Non-blocking: moves whatever the socket has into the incoming queue. + let _ = self.channel.read_write(Some(Duration::ZERO)); + while let Some(message) = self.channel.pop_message() { + match message.msg_type() { + MessageType::MethodCall => self.handle_method_call(&message), + MessageType::Signal => self.handle_signal(&message), + MessageType::MethodReturn | MessageType::Error => {} + } + } + self.channel.flush(); + } + + /// Left click. + pub fn activate(&self, item: &TrayItem) { + self.send_opt(item.call_at_origin("Activate")); + } + + /// Middle click. + pub fn secondary_activate(&self, item: &TrayItem) { + self.send_opt(item.call_at_origin("SecondaryActivate")); + } + + /// Right click on an item without a D-Bus menu: let the app show its own. + pub fn context_menu(&self, item: &TrayItem) { + self.send_opt(item.call_at_origin("ContextMenu")); + } + + fn handle_signal(&mut self, message: &Message) { + let (Some(interface), Some(member)) = (message.interface(), message.member()) else { + return; + }; + match (&*interface, &*member) { + ("org.freedesktop.DBus", "NameOwnerChanged") => { + if let Ok((name, _old_owner, new_owner)) = message.read3::<&str, &str, &str>() + && new_owner.is_empty() + { + self.remove_items_of(name); + } + } + // NewIcon, NewStatus, NewTitle, NewMenu...: re-read the item. + (item::INTERFACE, _) => { + let (Some(sender), Some(path)) = (message.sender(), message.path()) else { + return; + }; + let icon_size = self.icon_size; + for item in &mut self.items { + if item.is_object(&sender, &path) { + item.refresh(&self.channel, icon_size); + self.revision += 1; + } + } + } + _ => {} + } + } + + /// Sends a message without waiting for a reply. + fn send(&self, message: Message) { + let _ = self.channel.send(message); + self.channel.flush(); + } + + fn send_opt(&self, message: Option) { + if let Some(message) = message { + self.send(message); + } + } + + /// Calls a method on the bus itself (`org.freedesktop.DBus`) and waits for the reply. + fn call_bus( + &self, + method: &str, + build: impl FnOnce(Message) -> Message, + ) -> Result> { + let call = Message::new_method_call( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + method, + )?; + Ok(self + .channel + .send_with_reply_and_block(build(call), CALL_TIMEOUT)?) + } +} diff --git a/src/tray/watcher.rs b/src/tray/watcher.rs new file mode 100644 index 0000000..50e904c --- /dev/null +++ b/src/tray/watcher.rs @@ -0,0 +1,175 @@ +//! The `org.kde.StatusNotifierWatcher` service: apps call it to register +//! their tray items. + +use std::{collections::HashMap, error::Error, ffi::CString}; + +use dbus::{ + Message, + arg::{RefArg, Variant}, +}; + +use super::{Tray, item::TrayItem}; + +const NAME: &str = "org.kde.StatusNotifierWatcher"; +const PATH: &str = "/StatusNotifierWatcher"; +const INTERFACE: &str = "org.kde.StatusNotifierWatcher"; + +/// Asks the bus not to queue us for the name if someone else owns it. +const DO_NOT_QUEUE: u32 = 4; +const PRIMARY_OWNER: u32 = 1; + +/// Some clients (e.g. Qt) introspect the watcher before using it. +const INTROSPECTION: &str = r#" + + + + + + + + + + + + + + + + + + +"#; + +impl Tray { + /// Subscribes to the signals we need and takes the watcher name. + pub(super) fn start_watcher(&self) -> Result<(), Box> { + // Apps disconnecting, so their items can be removed. + self.call_bus("AddMatch", |call| { + call.append1("type='signal',sender='org.freedesktop.DBus',member='NameOwnerChanged'") + })?; + // Items announcing changes (new icon, status...). + self.call_bus("AddMatch", |call| { + call.append1(format!( + "type='signal',interface='{}'", + super::item::INTERFACE + )) + })?; + + let reply = self.call_bus("RequestName", |call| call.append2(NAME, DO_NOT_QUEUE))?; + if reply.read1::()? != PRIMARY_OWNER { + return Err("another system tray is already running".into()); + } + + // We are also the host: tell apps waiting for one that it's there. + self.send(Message::new_signal( + PATH, + INTERFACE, + "StatusNotifierHostRegistered", + )?); + Ok(()) + } + + pub(super) fn handle_method_call(&mut self, call: &Message) { + let interface = call + .interface() + .map(|name| name.to_string()) + .unwrap_or_default(); + let member = call + .member() + .map(|name| name.to_string()) + .unwrap_or_default(); + + let reply = match (interface.as_str(), member.as_str()) { + (INTERFACE, "RegisterStatusNotifierItem") => { + if let (Ok(service), Some(sender)) = (call.read1::<&str>(), call.sender()) { + self.register_item(&sender, service); + } + call.method_return() + } + // Other hosts are fine, but we have nothing to do with them. + (INTERFACE, "RegisterStatusNotifierHost") => call.method_return(), + ("org.freedesktop.DBus.Properties", "Get") => match call.read2::<&str, &str>() { + Ok((_, name)) => match self.watcher_property(name) { + Some(value) => call.method_return().append1(value), + None => error(call, "org.freedesktop.DBus.Error.UnknownProperty"), + }, + Err(_) => error(call, "org.freedesktop.DBus.Error.InvalidArgs"), + }, + ("org.freedesktop.DBus.Properties", "GetAll") => { + let properties: HashMap<&str, Variant>> = [ + "RegisteredStatusNotifierItems", + "IsStatusNotifierHostRegistered", + "ProtocolVersion", + ] + .into_iter() + .filter_map(|name| Some((name, self.watcher_property(name)?))) + .collect(); + call.method_return().append1(properties) + } + ("org.freedesktop.DBus.Introspectable", "Introspect") => { + call.method_return().append1(INTROSPECTION) + } + ("org.freedesktop.DBus.Peer", "Ping") => call.method_return(), + _ => error(call, "org.freedesktop.DBus.Error.UnknownMethod"), + }; + self.send(reply); + } + + /// Removes the items of an app that left the bus. + pub(super) fn remove_items_of(&mut self, bus_name: &str) { + let (gone, kept) = std::mem::take(&mut self.items) + .into_iter() + .partition(|item| item.belongs_to(bus_name)); + self.items = kept; + + for item in gone { + self.revision += 1; + self.emit_item_signal("StatusNotifierItemUnregistered", &item); + } + } + + /// `service` is either a bus name (the item lives at the default path) + /// or an object path (the item belongs to the caller). + fn register_item(&mut self, sender: &str, service: &str) { + let (bus_name, path) = match service.starts_with('/') { + true => (sender, service), + false => (service, "/StatusNotifierItem"), + }; + if self + .items + .iter() + .any(|item| item.is_registered_as(bus_name, path)) + { + return; + } + + let item = TrayItem::load(&self.channel, bus_name, path, sender, self.icon_size); + self.emit_item_signal("StatusNotifierItemRegistered", &item); + self.items.push(item); + self.revision += 1; + } + + fn watcher_property(&self, name: &str) -> Option>> { + let value: Box = match name { + "RegisteredStatusNotifierItems" => { + Box::new(self.items.iter().map(TrayItem::id).collect::>()) + } + "IsStatusNotifierHostRegistered" => Box::new(true), + "ProtocolVersion" => Box::new(0i32), + _ => return None, + }; + Some(Variant(value)) + } + + fn emit_item_signal(&self, signal: &str, item: &TrayItem) { + if let Ok(message) = Message::new_signal(PATH, INTERFACE, signal) { + self.send(message.append1(item.id())); + } + } +} + +fn error(call: &Message, name: &'static str) -> Message { + let text = CString::new(name).unwrap_or_default(); + call.error(&name.into(), &text) +} diff --git a/src/widget/clock.rs b/src/widget/clock.rs new file mode 100644 index 0000000..fe86815 --- /dev/null +++ b/src/widget/clock.rs @@ -0,0 +1,40 @@ +use chrono::Local; + +use super::Widget; +use crate::{ + render::{Area, Font, Painter}, + theme::Theme, +}; + +/// Shows the local time. +pub struct Clock { + /// A `chrono` strftime format, e.g. `"%H:%M"`. + format: String, + text: String, +} + +impl Clock { + pub fn new(format: &str) -> Self { + Self { + format: format.into(), + text: String::new(), + } + } +} + +impl Widget for Clock { + fn update(&mut self) -> bool { + let text = Local::now().format(&self.format).to_string(); + let changed = text != self.text; + self.text = text; + changed + } + + fn width(&self, font: &Font, theme: &Theme) -> f32 { + font.stable_text_width(&self.text, theme.font_size) + } + + fn draw(&self, painter: &mut Painter, area: Area) { + painter.draw_text(&self.text, area, painter.theme.foreground); + } +} diff --git a/src/widget/mod.rs b/src/widget/mod.rs new file mode 100644 index 0000000..8cccd39 --- /dev/null +++ b/src/widget/mod.rs @@ -0,0 +1,57 @@ +//! Widgets are the building blocks placed on a bar. + +mod clock; +mod tray; + +pub use clock::Clock; +pub use tray::TrayWidget; + +use crate::{ + render::{Area, Font, Painter}, + theme::Theme, + tray::MenuRef, +}; + +pub trait Widget { + /// Refreshes the widget's state. Returns `true` if it needs a redraw. + fn update(&mut self) -> bool; + + /// Width in pixels the widget wants to occupy. The bar is sized from this, + /// so return a width that fits every state the widget can show. + /// A bar whose widgets all have a width of 0 is hidden. + fn width(&self, font: &Font, theme: &Theme) -> f32; + + /// Draws the widget inside `area`, whose width is the one from [`Widget::width`]. + fn draw(&self, painter: &mut Painter, area: Area); + + /// Handles a click at `x`, relative to the widget's left edge. + fn click(&mut self, _x: f32, _button: MouseButton, _theme: &Theme) -> Option { + None + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MouseButton { + Left, + Right, + Middle, +} + +impl MouseButton { + /// From a Linux input event code, as sent by Wayland. + pub fn from_code(code: u32) -> Option { + match code { + 0x110 => Some(Self::Left), + 0x111 => Some(Self::Right), + 0x112 => Some(Self::Middle), + _ => None, + } + } +} + +/// Something a widget asks the shell to do after a click. +pub enum Action { + /// Open a tray item's menu below the bar, at `x`. + /// The widget gives `x` relative to itself; the bar turns it into a surface position. + ShowMenu { menu: MenuRef, x: f32 }, +} diff --git a/src/widget/tray.rs b/src/widget/tray.rs new file mode 100644 index 0000000..bbc6362 --- /dev/null +++ b/src/widget/tray.rs @@ -0,0 +1,120 @@ +//! Tray icons, side by side. + +use std::{cell::RefCell, rc::Rc}; + +use super::{Action, MouseButton, Widget}; +use crate::{ + render::{Area, Font, Painter}, + theme::Theme, + tray::{Tray, TrayItem}, +}; + +pub struct TrayWidget { + /// Shared with the app, which feeds it D-Bus messages. + tray: Rc>, + /// The tray revision last drawn. + drawn_revision: Option, +} + +impl TrayWidget { + pub fn new(tray: Rc>) -> Self { + Self { + tray, + drawn_revision: None, + } + } +} + +impl Widget for TrayWidget { + fn update(&mut self) -> bool { + let revision = self.tray.borrow().revision(); + let changed = self.drawn_revision != Some(revision); + self.drawn_revision = Some(revision); + changed + } + + fn width(&self, _: &Font, theme: &Theme) -> f32 { + let count = self.tray.borrow().visible_items().count(); + if count == 0 { + return 0.0; + } + count as f32 * theme.icon_size + (count - 1) as f32 * theme.spacing + } + + fn draw(&self, painter: &mut Painter, area: Area) { + let size = painter.theme.icon_size; + let tray = self.tray.borrow(); + + for (index, item) in tray.visible_items().enumerate() { + let icon_area = Area { + x: area.x + icon_offset(index, painter.theme), + y: area.y + (area.height - size) / 2.0, + width: size, + height: size, + }; + match &item.icon { + Some(icon) => painter.draw_image(icon, icon_area), + None => draw_placeholder(painter, icon_area), + } + } + } + + fn click(&mut self, x: f32, button: MouseButton, theme: &Theme) -> Option { + let tray = self.tray.borrow(); + let index = item_at(x, theme)?; + let item = tray.visible_items().nth(index)?; + let show_menu = |item: &TrayItem| { + Some(Action::ShowMenu { + menu: item.menu.clone()?, + x: icon_offset(index, theme) + theme.icon_size / 2.0, + }) + }; + + match button { + MouseButton::Left if item.item_is_menu => return show_menu(item), + MouseButton::Left => tray.activate(item), + MouseButton::Middle => tray.secondary_activate(item), + MouseButton::Right if item.menu.is_some() => return show_menu(item), + MouseButton::Right => tray.context_menu(item), + } + None + } +} + +/// Where the icon at `index` starts, from the widget's left edge. +fn icon_offset(index: usize, theme: &Theme) -> f32 { + index as f32 * (theme.icon_size + theme.spacing) +} + +/// The index of the icon under `x`. `None` in the gaps between icons. +fn item_at(x: f32, theme: &Theme) -> Option { + let stride = theme.icon_size + theme.spacing; + if x < 0.0 || x % stride > theme.icon_size { + return None; + } + Some((x / stride) as usize) +} + +/// Stands in for an icon that could not be loaded (e.g. SVG only). +fn draw_placeholder(painter: &mut Painter, area: Area) { + let mut color = painter.theme.foreground; + color.apply_opacity(0.4); + painter.fill_rounded_rect(area, area.width / 2.0, color); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_clicks_to_icons() { + let theme = Theme { + icon_size: 18.0, + spacing: 10.0, + ..Theme::default() + }; + assert_eq!(item_at(5.0, &theme), Some(0)); + assert_eq!(item_at(22.0, &theme), None); // gap + assert_eq!(item_at(30.0, &theme), Some(1)); + } +}