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
This commit is contained in:
Prouk committed 2026-10-15 12:25:14 +02:00
1 parent d060ecfaa8
commit 75b552f26b
23 files changed
+3977 -1

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.devenv
.github
# Added by cargo
/target
Generated
+1000
View File
@@ -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"
+14
View File
@@ -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"] }
+35 -1
View File
@@ -1,3 +1,37 @@
# Pell
Lightweight Wayland Shell
> 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.
+65
View File
@@ -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
}
+35
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
+234
View File
@@ -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<Self>, _: &LayerSurface) {
self.exit = true;
}
fn configure(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
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<Self>,
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<Self>, 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<Self>,
_: &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<Self>, _: WlSeat) {}
fn new_capability(
&mut self,
_: &Connection,
qh: &QueueHandle<Self>,
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<Self>,
_: 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<Self>, 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<Self>,
_: &wl_surface::WlSurface,
_: i32,
) {
}
fn transform_changed(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: wl_output::Transform,
) {
}
fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}
fn surface_enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: &wl_output::WlOutput,
) {
}
fn surface_leave(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &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<Self>, _: wl_output::WlOutput) {}
fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: 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);
+288
View File
@@ -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<CursorShapeManager>,
config: Option<ConfigFile>,
font: Rc<Font>,
bars: Vec<Bar>,
tray: Option<Rc<RefCell<Tray>>>,
/// The open tray menu, if any.
menu: Option<MenuPopup>,
/// The seat and pointer used to click, with its cursor-shape device.
seat: Option<WlSeat>,
pointer: Option<(WlPointer, Option<WpCursorShapeDeviceV1>)>,
exit: bool,
}
/// Connects to the compositor, shows the bars and runs until they are closed.
pub fn run(
config: Option<ConfigFile>,
theme: Theme,
bars: Vec<(Placement, Widgets)>,
tray: Option<Rc<RefCell<Tray>>>,
) -> Result<(), Box<dyn Error>> {
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::<Result<_, _>>()?;
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::<App>::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<Self>, 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<Self>,
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<dyn Error>>, 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
}
+383
View File
@@ -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<Box<dyn Widget>>,
pub center: Vec<Box<dyn Widget>>,
pub right: Vec<Box<dyn Widget>>,
}
impl Widgets {
fn sections(&self) -> [&[Box<dyn Widget>]; 3] {
[&self.left, &self.center, &self.right]
}
/// Every widget, left to right.
fn iter(&self) -> impl Iterator<Item = &Box<dyn Widget>> {
self.left.iter().chain(&self.center).chain(&self.right)
}
fn iter_mut(&mut self) -> impl Iterator<Item = &mut Box<dyn Widget>> {
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<Pixmap>,
/// The surface width last asked from the compositor. 0 while hidden.
requested_width: u32,
font: Rc<Font>,
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<App>,
theme: Theme,
font: Rc<Font>,
widgets: Widgets,
) -> Result<Self, Box<dyn Error>> {
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<dyn Error>> {
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<Action> {
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<Area> {
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<f32> = 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::<f32>() + gaps
}
/// Total width of `widgets` placed side by side.
fn row_width(&self, widgets: &[Box<dyn Widget>]) -> f32 {
let widths: Vec<f32> = 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::<f32>() + 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<Path> {
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()
}
+167
View File
@@ -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<SystemTime>,
}
impl ConfigFile {
/// The config file location, or `None` if neither `XDG_CONFIG_HOME` nor `HOME` is set.
pub fn locate() -> Option<Self> {
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<Theme> {
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<SystemTime> {
fs::metadata(&self.path)
.and_then(|meta| meta.modified())
.ok()
}
fn read_theme(&self) -> Result<Theme, Box<dyn Error>> {
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<HexColor>,
foreground: Option<HexColor>,
}
/// A color written as `"#rrggbb"` or `"#rrggbbaa"`.
#[derive(Deserialize)]
#[serde(try_from = "String")]
struct HexColor(Color);
impl TryFrom<String> for HexColor {
type Error = String;
fn try_from(text: String) -> Result<Self, Self::Error> {
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<Color, String> {
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());
}
}
+65
View File
@@ -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
}
}
}
+274
View File
@@ -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<App>) -> Result<Self, BindError> {
globals.bind(qh, 1..=6, GlobalData).map(Self)
}
}
impl<const VERSION: u32> ProvidesBoundGlobal<XdgWmBase, VERSION> for PopupShell {
fn bound_global(&self) -> Result<XdgWmBase, GlobalError> {
Ok(self.0.clone())
}
}
pub struct MenuPopup {
popup: Popup,
pool: SlotPool,
pixmap: Option<Pixmap>,
font: Rc<Font>,
theme: Theme,
pub menu: MenuRef,
entries: Vec<MenuEntry>,
/// Where each entry is drawn, in the same order.
rows: Vec<Area>,
hovered: Option<usize>,
}
/// 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<MenuEntry>,
font: Rc<Font>,
compositor: &CompositorState,
xdg_shell: &PopupShell,
shm: &Shm,
qh: &QueueHandle<App>,
) -> Result<Self, Box<dyn Error>> {
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<f32>) -> 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<dyn Error>> {
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<usize> {
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<Area>, 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
}
+236
View File
@@ -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<Self, Box<dyn Error>> {
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<dyn Error>> {
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<Path> {
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);
}
+42
View File
@@ -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),
}
}
}
+236
View File
@@ -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 `<theme>/<size>/<category>/<name>.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<Pixmap> {
if name.starts_with('/') {
return load_png(Path::new(name), size);
}
let file_name = format!("{name}.png");
let mut roots: Vec<PathBuf> = 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<u8>)], size: u32) -> Option<Pixmap> {
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<T>(
candidates: impl IntoIterator<Item = T>,
size: u32,
size_of: impl Fn(&T) -> Option<u32>,
) -> Option<T> {
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 `<root>/**/<file_name>`, looking `depth` directories deep.
fn find_files(root: &Path, file_name: &str, depth: usize, found: &mut Vec<PathBuf>) {
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<u32> {
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<PathBuf> {
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<String> {
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<Pixmap> {
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<u8> {
bytes.chunks_exact(channels).flat_map(to_rgba).collect()
}
/// Builds a pixmap from straight (non-premultiplied) RGBA bytes.
fn premultiplied_pixmap(mut rgba: Vec<u8>, width: u32, height: u32) -> Option<Pixmap> {
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]);
}
}
+136
View File
@@ -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<u8>)>;
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<Pixmap>,
/// The item's D-Bus menu, if it exports one.
pub menu: Option<MenuRef>,
/// 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<Message> {
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<PropMap> {
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<IconPixmaps> {
let call = self
.properties_call("Get")?
.append2(INTERFACE, "IconPixmap");
let reply = channel.send_with_reply_and_block(call, CALL_TIMEOUT).ok()?;
reply
.read1::<Variant<IconPixmaps>>()
.ok()
.map(|variant| variant.0)
}
fn properties_call(&self, method: &str) -> Option<Message> {
Message::new_method_call(
&self.bus_name,
&self.path,
"org.freedesktop.DBus.Properties",
method,
)
.ok()
}
}
+206
View File
@@ -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> {
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<MenuEntry> {
// 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::<String>::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<MenuEntry>) -> 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<MenuEntry>) -> 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<MenuEntry> {
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<dyn RefArg>)>) -> 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);
}
}
+161
View File
@@ -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<TrayItem>,
/// 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<Self, Box<dyn Error>> {
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<Item = &TrayItem> {
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<Message>) {
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<Message, Box<dyn Error>> {
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)?)
}
}
+175
View File
@@ -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#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="org.kde.StatusNotifierWatcher">
<method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
<method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
<property name="ProtocolVersion" type="i" access="read"/>
<signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
<signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
<signal name="StatusNotifierHostRegistered"/>
</interface>
<interface name="org.freedesktop.DBus.Properties">
<method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method>
<method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method>
</interface>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect"><arg type="s" direction="out"/></method>
</interface>
</node>"#;
impl Tray {
/// Subscribes to the signals we need and takes the watcher name.
pub(super) fn start_watcher(&self) -> Result<(), Box<dyn Error>> {
// 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::<u32>()? != 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<Box<dyn RefArg>>> = [
"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<Variant<Box<dyn RefArg>>> {
let value: Box<dyn RefArg> = match name {
"RegisteredStatusNotifierItems" => {
Box::new(self.items.iter().map(TrayItem::id).collect::<Vec<_>>())
}
"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)
}
+40
View File
@@ -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);
}
}
+57
View File
@@ -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<Action> {
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<Self> {
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 },
}
+120
View File
@@ -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<RefCell<Tray>>,
/// The tray revision last drawn.
drawn_revision: Option<u64>,
}
impl TrayWidget {
pub fn new(tray: Rc<RefCell<Tray>>) -> 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<Action> {
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<usize> {
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));
}
}