From ea35d818a179837d179235d885b4ae0c2d71a55a Mon Sep 17 00:00:00 2001 From: Csaba Date: Thu, 12 Mar 2026 23:58:07 +0100 Subject: [PATCH] feat(bar): windows systray widget A new System Tray widget has been added to komorebi-bar, bringing native Windows system tray functionality directly into the bar. Special thanks to the Zebar project and its contributors for developing the systray-util crate library that makes Windows system tray integration possible. The widget intercepts system tray icon data by creating a hidden "spy" window that mimics the Windows taskbar. When applications use the Shell_NotifyIcon API to manage their tray icons, the widget receives the same broadcast messages, allowing it to monitor all system tray activity while forwarding messages to the real taskbar to avoid disruption. Users can configure which icons to hide using flexible rules. A plain string matches by exe name (case-insensitive). A structured object can match on exe, tooltip, and/or GUID fields using AND logic. Each field supports matching strategies from komorebi's window rules (Equals, StartsWith, EndsWith, Contains, Regex, and their negated variants), allowing precise filtering even when multiple icons share the same exe and GUID. An info button opens a floating panel listing all icons with their properties and copy buttons, making it easy to identify which values to use in filter rules. The widget fully supports mouse interactions including left-click, right-click, middle-click, and double-click actions on tray icons. Double-click support uses the LeftDoubleClick action from systray-util 0.2.0, which sends WM_LBUTTONDBLCLK and NIN_SELECT messages. It handles right-aligned placement correctly by adjusting the rendering order and toggle button arrow directions to maintain consistent visual appearance regardless of which panel the widget is placed in. Some system tray icons register a click callback but never actually respond to click messages, effectively becoming "zombie" icons from an interaction standpoint. The widget includes fallback commands for known problematic icons that override the native click action with a direct shell command (e.g. opening Windows Security or volume settings). The implementation uses a background thread with its own tokio runtime to handle the async systray events, communicating with the UI thread through crossbeam channels. Icon images are cached efficiently using hash-based keys that update when icons change. Rapid icon updates are deduplicated to prevent UI freezing, and image conversion (RgbaImage to ColorImage) is performed on the background thread to keep the UI responsive. The widget automatically detects and removes stale icons whose owning process has exited, using the Win32 IsWindow API on a configurable interval. A manual refresh button is also available for immediate cleanup. A shortcuts button can be configured to toggle komorebi-shortcuts by killing the process if running or starting it otherwise. The refresh, info, and shortcuts buttons can each be placed in the main visible area or in the overflow section. --- Cargo.lock | 204 +-- dependencies.json | 60 +- docs/common-workflows/bar-widgets/systray.md | 218 +++ docs/common-workflows/bar.md | 40 + komorebi-bar/Cargo.toml | 4 + komorebi-bar/src/bar.rs | 18 +- komorebi-bar/src/main.rs | 16 + komorebi-bar/src/widgets/mod.rs | 12 +- komorebi-bar/src/widgets/systray.rs | 1301 ++++++++++++++++++ komorebi-bar/src/widgets/widget.rs | 12 + komorebi/src/core/animation.rs | 2 +- mkdocs.yml | 2 + schema.bar.json | 254 +++- schema.json | 4 +- 14 files changed, 2026 insertions(+), 121 deletions(-) create mode 100644 docs/common-workflows/bar-widgets/systray.md create mode 100644 docs/common-workflows/bar.md create mode 100644 komorebi-bar/src/widgets/systray.rs diff --git a/Cargo.lock b/Cargo.lock index e1fdf102..4a48e8dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -284,9 +284,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "approx" @@ -340,7 +340,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -472,7 +472,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -507,7 +507,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -800,9 +800,9 @@ checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" @@ -821,7 +821,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1001,9 +1001,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", "clap_derive", @@ -1011,9 +1011,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ "anstream", "anstyle", @@ -1031,7 +1031,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1366,7 +1366,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1390,7 +1390,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1401,7 +1401,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1520,7 +1520,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1791,7 +1791,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1812,7 +1812,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1862,7 +1862,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1966,7 +1966,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -2106,7 +2106,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -2228,7 +2228,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -2920,6 +2920,7 @@ dependencies = [ "ravif", "rayon", "rgb", + "serde", "tiff 0.10.3", "zune-core 0.5.1", "zune-jpeg 0.5.12", @@ -2998,7 +2999,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -3112,9 +3113,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.87" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -3201,6 +3202,7 @@ dependencies = [ "dunce", "eframe", "egui-phosphor", + "egui_extras", "font-loader", "hotwatch", "image 0.25.9", @@ -3213,12 +3215,15 @@ dependencies = [ "num-traits", "parking_lot", "random_word", + "regex", "reqwest", "schemars 1.2.1", "serde", "serde_json_lenient", "starship-battery", "sysinfo 0.38.2", + "systray-util", + "tokio", "tracing", "tracing-subscriber", "which", @@ -3500,7 +3505,7 @@ dependencies = [ "proc-macro2", "quote", "regex-syntax", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -3630,7 +3635,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -3765,9 +3770,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.18" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" dependencies = [ "libc", "log", @@ -4021,7 +4026,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4095,7 +4100,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4564,7 +4569,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4808,7 +4813,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "unicase", ] @@ -4857,7 +4862,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5009,7 +5014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5046,7 +5051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5391,7 +5396,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5650,7 +5655,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5686,9 +5691,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.7.0" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", @@ -5699,9 +5704,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.17.0" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" dependencies = [ "core-foundation-sys", "libc", @@ -5740,7 +5745,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5751,7 +5756,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5788,7 +5793,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5840,7 +5845,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -6184,7 +6189,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -6227,9 +6232,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" dependencies = [ "proc-macro2", "quote", @@ -6253,7 +6258,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -6305,6 +6310,23 @@ dependencies = [ "libc", ] +[[package]] +name = "systray-util" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "988063a25f37b77024a34a00047c4377a32d861626192c77c6fff0ae377b31a4" +dependencies = [ + "image 0.25.9", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "windows 0.58.0", + "windows-core 0.58.0", +] + [[package]] name = "tempfile" version = "3.25.0" @@ -6373,7 +6395,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -6384,7 +6406,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -6498,11 +6520,25 @@ dependencies = [ "bytes", "libc", "mio 1.1.1", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -6652,7 +6688,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -6948,9 +6984,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.110" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if 1.0.4", "once_cell", @@ -6961,9 +6997,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.60" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42e96ea38f49b191e08a1bab66c7ffdba24b06f9995b39a9dd60222e5b6f1da" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if 1.0.4", "futures-util", @@ -6975,9 +7011,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.110" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6985,22 +7021,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.110" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.110" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -7176,9 +7212,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.87" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c7c5718134e770ee62af3b6b4a84518ec10101aad610c024b64d6ff29bb1ff" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -7660,7 +7696,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7671,7 +7707,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7682,7 +7718,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7693,7 +7729,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7704,7 +7740,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7715,7 +7751,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7726,7 +7762,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -8286,7 +8322,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn 2.0.117", + "syn 2.0.116", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -8302,7 +8338,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -8462,7 +8498,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "synstructure", ] @@ -8519,7 +8555,7 @@ checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "zbus-lockstep", "zbus_xml", "zvariant", @@ -8534,7 +8570,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "zbus_names", "zvariant", "zvariant_utils", @@ -8580,7 +8616,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -8600,7 +8636,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "synstructure", ] @@ -8640,7 +8676,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -8711,7 +8747,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "zvariant_utils", ] @@ -8724,6 +8760,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 2.0.116", "winnow", ] diff --git a/dependencies.json b/dependencies.json index d43856fe..81744061 100644 --- a/dependencies.json +++ b/dependencies.json @@ -51,8 +51,8 @@ "cfg-if 1.0.4 registry+https://github.com/rust-lang/crates.io-index", "chrono 0.4.43 registry+https://github.com/rust-lang/crates.io-index", "chrono-tz 0.10.4 registry+https://github.com/rust-lang/crates.io-index", - "clap 4.5.58 registry+https://github.com/rust-lang/crates.io-index", - "clap_builder 4.5.58 registry+https://github.com/rust-lang/crates.io-index", + "clap 4.5.59 registry+https://github.com/rust-lang/crates.io-index", + "clap_builder 4.5.59 registry+https://github.com/rust-lang/crates.io-index", "clap_derive 4.5.55 registry+https://github.com/rust-lang/crates.io-index", "clap_lex 1.0.0 registry+https://github.com/rust-lang/crates.io-index", "color-eyre 0.6.5 registry+https://github.com/rust-lang/crates.io-index", @@ -107,15 +107,15 @@ "flate2 1.1.9 registry+https://github.com/rust-lang/crates.io-index", "fnv 1.0.7 registry+https://github.com/rust-lang/crates.io-index", "form_urlencoded 1.2.2 registry+https://github.com/rust-lang/crates.io-index", - "futures 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-channel 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-core 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-executor 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-io 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-macro 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-sink 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-task 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-util 0.3.31 registry+https://github.com/rust-lang/crates.io-index", + "futures 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-channel 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-core 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-executor 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-io 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-macro 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-sink 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-task 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-util 0.3.32 registry+https://github.com/rust-lang/crates.io-index", "getrandom 0.1.16 registry+https://github.com/rust-lang/crates.io-index", "getrandom 0.2.17 registry+https://github.com/rust-lang/crates.io-index", "getrandom 0.3.4 registry+https://github.com/rust-lang/crates.io-index", @@ -275,7 +275,7 @@ "supports-hyperlinks 3.2.0 registry+https://github.com/rust-lang/crates.io-index", "supports-unicode 3.0.0 registry+https://github.com/rust-lang/crates.io-index", "syn 1.0.109 registry+https://github.com/rust-lang/crates.io-index", - "syn 2.0.115 registry+https://github.com/rust-lang/crates.io-index", + "syn 2.0.116 registry+https://github.com/rust-lang/crates.io-index", "sync_wrapper 1.0.2 registry+https://github.com/rust-lang/crates.io-index", "tempfile 3.25.0 registry+https://github.com/rust-lang/crates.io-index", "terminal_size 0.4.3 registry+https://github.com/rust-lang/crates.io-index", @@ -291,7 +291,7 @@ "tzdb 0.7.3 registry+https://github.com/rust-lang/crates.io-index", "tzdb_data 0.2.3 registry+https://github.com/rust-lang/crates.io-index", "unicase 2.9.0 registry+https://github.com/rust-lang/crates.io-index", - "unicode-ident 1.0.23 registry+https://github.com/rust-lang/crates.io-index", + "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index", "unicode-linebreak 0.1.5 registry+https://github.com/rust-lang/crates.io-index", "unicode-segmentation 1.12.0 registry+https://github.com/rust-lang/crates.io-index", "unicode-width 0.1.14 registry+https://github.com/rust-lang/crates.io-index", @@ -301,6 +301,7 @@ "url 2.5.8 registry+https://github.com/rust-lang/crates.io-index", "utf8_iter 1.0.4 registry+https://github.com/rust-lang/crates.io-index", "utf8parse 0.2.2 registry+https://github.com/rust-lang/crates.io-index", + "uuid 1.21.0 registry+https://github.com/rust-lang/crates.io-index", "vcpkg 0.2.15 registry+https://github.com/rust-lang/crates.io-index", "version_check 0.9.5 registry+https://github.com/rust-lang/crates.io-index", "web-time 1.1.0 registry+https://github.com/rust-lang/crates.io-index", @@ -499,8 +500,8 @@ "chrono 0.4.43 registry+https://github.com/rust-lang/crates.io-index", "chrono-tz 0.10.4 registry+https://github.com/rust-lang/crates.io-index", "chumsky 0.9.3 registry+https://github.com/rust-lang/crates.io-index", - "clap 4.5.58 registry+https://github.com/rust-lang/crates.io-index", - "clap_builder 4.5.58 registry+https://github.com/rust-lang/crates.io-index", + "clap 4.5.59 registry+https://github.com/rust-lang/crates.io-index", + "clap_builder 4.5.59 registry+https://github.com/rust-lang/crates.io-index", "clap_derive 4.5.55 registry+https://github.com/rust-lang/crates.io-index", "clap_lex 1.0.0 registry+https://github.com/rust-lang/crates.io-index", "color-eyre 0.6.5 registry+https://github.com/rust-lang/crates.io-index", @@ -566,15 +567,15 @@ "font-loader 0.11.0 registry+https://github.com/rust-lang/crates.io-index", "form_urlencoded 1.2.2 registry+https://github.com/rust-lang/crates.io-index", "fs-tail 0.1.4 registry+https://github.com/rust-lang/crates.io-index", - "futures 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-channel 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-core 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-executor 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-io 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-macro 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-sink 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-task 0.3.31 registry+https://github.com/rust-lang/crates.io-index", - "futures-util 0.3.31 registry+https://github.com/rust-lang/crates.io-index", + "futures 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-channel 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-core 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-executor 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-io 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-macro 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-sink 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-task 0.3.32 registry+https://github.com/rust-lang/crates.io-index", + "futures-util 0.3.32 registry+https://github.com/rust-lang/crates.io-index", "generic-array 0.14.7 registry+https://github.com/rust-lang/crates.io-index", "getrandom 0.1.16 registry+https://github.com/rust-lang/crates.io-index", "getrandom 0.2.17 registry+https://github.com/rust-lang/crates.io-index", @@ -769,10 +770,11 @@ "strum 0.27.2 registry+https://github.com/rust-lang/crates.io-index", "strum_macros 0.27.2 registry+https://github.com/rust-lang/crates.io-index", "syn 1.0.109 registry+https://github.com/rust-lang/crates.io-index", - "syn 2.0.115 registry+https://github.com/rust-lang/crates.io-index", + "syn 2.0.116 registry+https://github.com/rust-lang/crates.io-index", "synstructure 0.13.2 registry+https://github.com/rust-lang/crates.io-index", "sysinfo 0.33.1 registry+https://github.com/rust-lang/crates.io-index", - "sysinfo 0.38.1 registry+https://github.com/rust-lang/crates.io-index", + "sysinfo 0.38.2 registry+https://github.com/rust-lang/crates.io-index", + "systray-util 0.2.0 registry+https://github.com/rust-lang/crates.io-index", "tempfile 3.25.0 registry+https://github.com/rust-lang/crates.io-index", "terminal_size 0.4.3 registry+https://github.com/rust-lang/crates.io-index", "textwrap 0.16.2 registry+https://github.com/rust-lang/crates.io-index", @@ -784,6 +786,7 @@ "time 0.3.47 registry+https://github.com/rust-lang/crates.io-index", "time-core 0.1.8 registry+https://github.com/rust-lang/crates.io-index", "tokio 1.49.0 registry+https://github.com/rust-lang/crates.io-index", + "tokio-macros 2.6.0 registry+https://github.com/rust-lang/crates.io-index", "tokio-native-tls 0.3.1 registry+https://github.com/rust-lang/crates.io-index", "tokio-util 0.7.18 registry+https://github.com/rust-lang/crates.io-index", "toml 0.5.11 registry+https://github.com/rust-lang/crates.io-index", @@ -805,7 +808,7 @@ "tzdb_data 0.2.3 registry+https://github.com/rust-lang/crates.io-index", "uds_windows 1.1.0 registry+https://github.com/rust-lang/crates.io-index", "unicase 2.9.0 registry+https://github.com/rust-lang/crates.io-index", - "unicode-ident 1.0.23 registry+https://github.com/rust-lang/crates.io-index", + "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index", "unicode-segmentation 1.12.0 registry+https://github.com/rust-lang/crates.io-index", "unicode-width 0.1.14 registry+https://github.com/rust-lang/crates.io-index", "unicode-width 0.2.2 registry+https://github.com/rust-lang/crates.io-index", @@ -815,6 +818,7 @@ "url 2.5.8 registry+https://github.com/rust-lang/crates.io-index", "utf8_iter 1.0.4 registry+https://github.com/rust-lang/crates.io-index", "utf8parse 0.2.2 registry+https://github.com/rust-lang/crates.io-index", + "uuid 1.21.0 registry+https://github.com/rust-lang/crates.io-index", "vcpkg 0.2.15 registry+https://github.com/rust-lang/crates.io-index", "version_check 0.9.5 registry+https://github.com/rust-lang/crates.io-index", "walkdir 2.5.0 registry+https://github.com/rust-lang/crates.io-index", @@ -941,7 +945,7 @@ "litemap 0.8.1 registry+https://github.com/rust-lang/crates.io-index", "potential_utf 0.1.4 registry+https://github.com/rust-lang/crates.io-index", "tinystr 0.8.2 registry+https://github.com/rust-lang/crates.io-index", - "unicode-ident 1.0.23 registry+https://github.com/rust-lang/crates.io-index", + "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index", "writeable 0.6.2 registry+https://github.com/rust-lang/crates.io-index", "yoke 0.8.1 registry+https://github.com/rust-lang/crates.io-index", "yoke-derive 0.8.1 registry+https://github.com/rust-lang/crates.io-index", diff --git a/docs/common-workflows/bar-widgets/systray.md b/docs/common-workflows/bar-widgets/systray.md new file mode 100644 index 00000000..b610fc3f --- /dev/null +++ b/docs/common-workflows/bar-widgets/systray.md @@ -0,0 +1,218 @@ +# System Tray + +The System Tray widget brings native Windows system tray icons into +`komorebi-bar`. It intercepts tray icon data by creating a hidden window that +mimics the Windows taskbar, receiving the same broadcast messages that +applications send via `Shell_NotifyIcon`. + +## Basic configuration + +```json +{ + "right_widgets": [ + { + "Systray": { + "enable": true + } + } + ] +} +``` + +## Hiding icons + +The `hidden_icons` config field accepts a list of rules. Each rule can be either +a plain string or a structured object. + +A **plain string** matches the exe name (case-insensitive). This is the original +format, so existing configs continue to work without changes: + +```json +"hidden_icons": [ + "SecurityHealthSystray.exe", + "PhoneExperienceHost.exe" +] +``` + +A **structured object** matches one or more icon properties. All specified fields +must match (AND logic). By default matching is exact and case-insensitive. + +```json +"hidden_icons": [ + { "exe": "svchost.exe", "tooltip": "Some Specific App" }, + { "guid": "{7820AE73-23E3-4229-82C1-E41CB67D5B9C}" }, + { "tooltip": "App I want hidden" } +] +``` + +The two forms can be mixed freely: + +```json +"hidden_icons": [ + "PhoneExperienceHost.exe", + { "exe": "svchost.exe", "tooltip": "Specific Notification" }, + { "guid": "{7820AE73-23E3-4229-82C1-E41CB67D5B9C}" } +] +``` + +Available fields for structured rules: + +| Field | Description | +|-----------|----------------------------------------------------------| +| `exe` | Executable name (e.g. `"SecurityHealthSystray.exe"`) | +| `tooltip` | Tooltip text shown on hover | +| `guid` | Icon GUID — most stable identifier across app restarts | + +### Matching strategies + +Each field can be a plain string (exact case-insensitive match) or an object +with `value` and `matching_strategy` for advanced matching. This uses the same +`MatchingStrategy` as komorebi's window rules. + +```json +"hidden_icons": [ + { + "exe": "explorer.exe", + "tooltip": { "value": "Network", "matching_strategy": "StartsWith" } + } +] +``` + +The above hides explorer.exe icons whose tooltip starts with "Network", while +leaving other explorer.exe icons visible. + +Available strategies: + +| Strategy | Description | +|---------------------|---------------------------------------------------| +| `Equals` | Exact match (default when using a plain string) | +| `StartsWith` | Value starts with the given text | +| `EndsWith` | Value ends with the given text | +| `Contains` | Value contains the given text | +| `Regex` | Value matches a regular expression | +| `DoesNotEqual` | Value does not exactly equal the given text | +| `DoesNotStartWith` | Value does not start with the given text | +| `DoesNotEndWith` | Value does not end with the given text | +| `DoesNotContain` | Value does not contain the given text | + +All strategies except `Regex` are case-insensitive. For case-insensitive regex, +include `(?i)` in the pattern. + +Plain strings and strategy objects can be mixed across fields: + +```json +{ + "exe": "explorer.exe", + "tooltip": { "value": "notification", "matching_strategy": "Contains" } +} +``` + +Run komorebi-bar with `RUST_LOG=info` to see the exe, tooltip, and GUID of every +systray icon in the log output. + +## Stale icon cleanup + +Some applications (e.g. Docker Desktop) may exit without properly removing their +tray icon. The widget detects these stale icons by checking whether the owning +window still exists via the Win32 `IsWindow` API. + +### Automatic cleanup + +By default, the widget checks for stale icons every 60 seconds. The interval +can be configured with `stale_icons_check_interval` (in seconds). The value is +clamped between 30 and 600. Set to 0 to disable automatic cleanup. + +```json +"stale_icons_check_interval": 120 +``` + +### Refresh button + +A manual refresh button can be shown by setting `refresh_button`. Clicking it +immediately removes any stale icons. + +- `"Visible"` — shows the button in the main icon area +- `"Overflow"` — shows the button in the hidden/overflow section (appears when + the overflow toggle is expanded) + +```json +"refresh_button": "Overflow" +``` + +When set to `"Overflow"`, the overflow toggle arrow will appear even if there are +no hidden icons, so the refresh button remains accessible. + +## Info button + +An info button can be shown to open a floating panel that lists all systray icons +with their exe name, tooltip, GUID, and visibility status. This is useful for +identifying which icons to filter with `hidden_icons` rules. + +- `"Visible"` — shows the button in the main icon area +- `"Overflow"` — shows the button in the hidden/overflow section + +```json +"info_button": "Visible" +``` + +The info panel shows **all** icons, including those hidden by rules or the OS. +Each row shows the icon image, exe name, tooltip, GUID, and whether it is visible. +Copy buttons are provided on the exe, tooltip, and GUID cells for easy copying +(e.g. to paste a GUID into a filter rule). + +Like the refresh button, setting `info_button` to `"Overflow"` will make the +overflow toggle arrow appear even if there are no hidden icons. + +## Shortcuts button + +A button that toggles komorebi-shortcuts. If the shortcuts process is running +it will be killed; otherwise it will be started. + +- `"Visible"` — shows the button in the main icon area +- `"Overflow"` — shows the button in the hidden/overflow section + +```json +"shortcuts_button": "Visible" +``` + +Like the other buttons, setting `shortcuts_button` to `"Overflow"` will make the +overflow toggle arrow appear even if there are no hidden icons. + +## Mouse interactions + +The widget supports left-click, right-click, middle-click, and double-click on +tray icons. Double-click sends the `LeftDoubleClick` action (via systray-util +0.2.0), which delivers `WM_LBUTTONDBLCLK` and `NIN_SELECT` messages to the icon. + +## Click fallbacks + +Some systray icons register a click callback but never actually respond to click +messages, effectively becoming "zombie" icons from an interaction standpoint. For +known problematic icons, the widget overrides the native click action with a +direct shell command. Fallback commands take priority — if a fallback is defined +for an icon, it always runs regardless of whether the icon reports itself as +clickable. + +| Exe | Tooltip condition | Fallback command | +|--------------------------------|-------------------|---------------------------------| +| `SecurityHealthSystray.exe` | any | `start windowsdefender://` | +| `explorer.exe` | ends with `%` | `start ms-settings:apps-volume` | +| `explorer.exe` | empty | `start ms-settings:batterysaver`| + +## Full example + +```json +{ + "Systray": { + "enable": true, + "hidden_icons": [ + "SecurityHealthSystray.exe", + { "exe": "explorer.exe", "tooltip": { "value": "Network", "matching_strategy": "StartsWith" } } + ], + "stale_icons_check_interval": 60, + "refresh_button": "Overflow", + "info_button": "Visible", + "shortcuts_button": "Overflow" + } +} +``` diff --git a/docs/common-workflows/bar.md b/docs/common-workflows/bar.md new file mode 100644 index 00000000..14e8abab --- /dev/null +++ b/docs/common-workflows/bar.md @@ -0,0 +1,40 @@ +# Komorebi Bar + +`komorebi-bar` is a status bar for komorebi that renders on top of the tiling +window manager. It is configured through a `komorebi.bar.json` file, either +alongside your `komorebi.json` or at the path specified in the +`bar_configurations` array. + +## Widgets + +Widgets are placed in the `left_widgets`, `center_widgets`, or `right_widgets` +arrays. Each widget is an object with the widget type as key and its +configuration as value. + +| Widget | Description | +|--------------|--------------------------------------------------------| +| `Komorebi` | Workspaces, layout, focused window, and more | +| `Battery` | Battery level and charging status | +| `Date` | Current date in configurable format | +| `Time` | Current time in configurable format | +| `Media` | Currently playing media information | +| `Memory` | System memory usage | +| `Network` | Network activity and connection status | +| `Storage` | Disk usage information | +| `Update` | Komorebi update notification | +| `Systray` | Windows system tray icons | + +Widgets with dedicated documentation pages: + +- [System Tray](bar-widgets/systray.md) + +> Dedicated pages for the remaining widgets will be added in the future. + +## Schema + +The full configuration schema is available at +[komorebi-bar.lgug2z.com/schema](https://komorebi-bar.lgug2z.com/schema). + +For running a bar on each monitor, see +[Multiple Bar Instances](multiple-bar-instances.md) and +[Multi-Monitor Setup](multi-monitor-setup.md). diff --git a/komorebi-bar/Cargo.toml b/komorebi-bar/Cargo.toml index be2b1443..77d56bf2 100644 --- a/komorebi-bar/Cargo.toml +++ b/komorebi-bar/Cargo.toml @@ -18,6 +18,7 @@ dirs = { workspace = true } dunce = { workspace = true } eframe = { workspace = true } egui-phosphor = { git = "https://github.com/amPerl/egui-phosphor", rev = "d13688738478ecd12b426e3e74c59d6577a85b59" } +egui_extras = { workspace = true } font-loader = "0.11" hotwatch = { workspace = true } image = "0.25" @@ -27,6 +28,7 @@ num = "0.4" num-derive = "0.4" num-traits = "0.2" parking_lot = { workspace = true } +regex = "1" random_word = { version = "0.5", features = ["en"] } reqwest = { version = "0.12", features = ["blocking"] } schemars = { workspace = true, optional = true } @@ -34,6 +36,8 @@ serde = { workspace = true } serde_json = { workspace = true } starship-battery = "0.10" sysinfo = { workspace = true } +systray-util = "0.2.0" +tokio = { version = "1", features = ["rt", "sync", "time"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } which = { workspace = true } diff --git a/komorebi-bar/src/bar.rs b/komorebi-bar/src/bar.rs index 9b418e13..7b797ff8 100644 --- a/komorebi-bar/src/bar.rs +++ b/komorebi-bar/src/bar.rs @@ -18,6 +18,7 @@ use crate::render::Color32Ext; use crate::render::Grouping; use crate::render::RenderConfig; use crate::render::RenderExt; +use crate::take_widget_clicked; use crate::widgets::komorebi::Komorebi; use crate::widgets::komorebi::MonitorInfo; use crate::widgets::widget::BarWidget; @@ -1082,6 +1083,10 @@ impl eframe::App for Komobar { let frame = render_config.change_frame_on_bar(frame, &ctx.style()); CentralPanel::default().frame(frame).show(ctx, |ui| { + // Variable to store command to execute after widgets are rendered + // This allows widgets to mark clicks as consumed before bar processes them + let mut pending_command: Option = None; + if let Some(mouse_config) = &self.config.mouse { let command = if ui .input(|i| i.pointer.button_double_clicked(PointerButton::Primary)) @@ -1182,9 +1187,9 @@ impl eframe::App for Komobar { &None }; - if let Some(command) = command { - command.execute(self.mouse_follows_focus); - } + // Store the command to execute after widgets are rendered + // This allows widgets to mark clicks as consumed + pending_command = command.clone(); } // Apply grouping logic for the bar as a whole @@ -1316,6 +1321,13 @@ impl eframe::App for Komobar { }); }); } + + // Execute the deferred mouse command only if no widget consumed the click + if let Some(command) = pending_command + && !take_widget_clicked() + { + command.execute(self.mouse_follows_focus); + } }); } } diff --git a/komorebi-bar/src/main.rs b/komorebi-bar/src/main.rs index c3b26f63..40671e4e 100644 --- a/komorebi-bar/src/main.rs +++ b/komorebi-bar/src/main.rs @@ -38,6 +38,8 @@ use windows::Win32::UI::WindowsAndMessaging::EnumThreadWindows; use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; use windows_core::BOOL; +use std::sync::atomic::AtomicBool; + pub static MAX_LABEL_WIDTH: AtomicI32 = AtomicI32::new(400); pub static MONITOR_LEFT: AtomicI32 = AtomicI32::new(0); pub static MONITOR_TOP: AtomicI32 = AtomicI32::new(0); @@ -46,6 +48,20 @@ pub static MONITOR_INDEX: AtomicUsize = AtomicUsize::new(0); pub static BAR_HEIGHT: f32 = 50.0; pub static DEFAULT_PADDING: f32 = 10.0; +/// Flag to indicate that a widget has consumed a click event this frame. +/// This prevents the bar's global mouse handler from also processing the click. +pub static WIDGET_CLICKED: AtomicBool = AtomicBool::new(false); + +/// Mark that a widget has consumed a click event this frame. +pub fn mark_widget_clicked() { + WIDGET_CLICKED.store(true, Ordering::SeqCst); +} + +/// Check if a widget has consumed a click event this frame and reset the flag. +pub fn take_widget_clicked() -> bool { + WIDGET_CLICKED.swap(false, Ordering::SeqCst) +} + pub static AUTO_SELECT_FILL_COLOUR: AtomicU32 = AtomicU32::new(0); pub static AUTO_SELECT_TEXT_COLOUR: AtomicU32 = AtomicU32::new(0); diff --git a/komorebi-bar/src/widgets/mod.rs b/komorebi-bar/src/widgets/mod.rs index e75b76d2..9614e759 100644 --- a/komorebi-bar/src/widgets/mod.rs +++ b/komorebi-bar/src/widgets/mod.rs @@ -20,6 +20,8 @@ pub mod media; pub mod memory; pub mod network; pub mod storage; +#[cfg(target_os = "windows")] +pub mod systray; pub mod time; pub mod update; pub mod widget; @@ -92,10 +94,16 @@ impl IconsCache { pub fn insert_image(&self, id: ImageIconId, image: Arc) { self.images.write().unwrap().insert(id, image); } + + /// Removes the cached image and texture for the given icon ID. + pub fn remove(&self, id: &ImageIconId) { + self.images.write().unwrap().remove(id); + self.textures.write().unwrap().1.remove(id); + } } #[inline] -fn rgba_to_color_image(rgba_image: &RgbaImage) -> ColorImage { +pub(crate) fn rgba_to_color_image(rgba_image: &RgbaImage) -> ColorImage { let size = [rgba_image.width() as usize, rgba_image.height() as usize]; let pixels = rgba_image.as_flat_samples(); ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()) @@ -156,6 +164,8 @@ pub enum ImageIconId { Path(Arc), /// Windows HWND handle. Hwnd(isize), + /// System tray icon identifier. + SystrayIcon(String), } impl From<&Path> for ImageIconId { diff --git a/komorebi-bar/src/widgets/systray.rs b/komorebi-bar/src/widgets/systray.rs new file mode 100644 index 00000000..f3f8d878 --- /dev/null +++ b/komorebi-bar/src/widgets/systray.rs @@ -0,0 +1,1301 @@ +use super::ICONS_CACHE; +use super::ImageIcon; +use super::ImageIconId; +use super::rgba_to_color_image; +use crate::bar::Alignment; +use crate::mark_widget_clicked; +use crate::render::RenderConfig; +use crate::selected_frame::SelectableFrame; +use crate::widgets::widget::BarWidget; +use crossbeam_channel::Receiver; +use crossbeam_channel::Sender; +use crossbeam_channel::unbounded; +use eframe::egui::CentralPanel; +use eframe::egui::ColorImage; +use eframe::egui::Context; +use eframe::egui::Frame; +use eframe::egui::Image; +use eframe::egui::Label; +use eframe::egui::Margin; +use eframe::egui::ScrollArea; +use eframe::egui::Ui; +use eframe::egui::Vec2; +use eframe::egui::ViewportBuilder; +use eframe::egui::ViewportClass; +use eframe::egui::ViewportId; +use eframe::egui::Window as EguiWindow; +use egui_extras::Column; +use egui_extras::TableBuilder; +use komorebi_client::MatchingStrategy; +use parking_lot::Mutex; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; +use std::time::Instant; +use systray_util::StableId; +use systray_util::Systray as SystrayClient; +use systray_util::SystrayEvent; +use systray_util::SystrayIconAction; +use windows::Win32::Foundation::CloseHandle; +use windows::Win32::Foundation::HWND; +use windows::Win32::System::Threading::OpenProcess; +use windows::Win32::System::Threading::PROCESS_NAME_WIN32; +use windows::Win32::System::Threading::PROCESS_QUERY_INFORMATION; +use windows::Win32::System::Threading::QueryFullProcessImageNameW; +use windows::Win32::UI::WindowsAndMessaging::GetSystemMetrics; +use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; +use windows::Win32::UI::WindowsAndMessaging::IsWindow; +use windows::Win32::UI::WindowsAndMessaging::SM_CXSCREEN; +use windows::Win32::UI::WindowsAndMessaging::SM_CYSCREEN; +use windows::core::PWSTR; + +/// Whether hidden icons are currently shown +static SHOW_HIDDEN_ICONS: AtomicBool = AtomicBool::new(false); + +/// Whether the systray info panel is currently shown +static SHOW_SYSTRAY_INFO: AtomicBool = AtomicBool::new(false); + +#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Position of the overflow toggle button +pub enum OverflowTogglePosition { + /// Toggle button appears on the left side (before visible icons) + Left, + /// Toggle button appears on the right side (after visible icons) + #[default] + Right, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Where to place a systray button (refresh, info, shortcuts, etc.) +pub enum ButtonPosition { + /// Show in the main visible area + Visible, + /// Show in the overflow/hidden section + Overflow, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +/// A field value with an optional matching strategy. +/// +/// A plain string uses exact (case-insensitive) matching. +/// An object with `value` and `matching_strategy` uses the specified strategy. +pub enum FieldMatch { + /// Exact case-insensitive match + Exact(String), + /// Match using a specific strategy + WithStrategy { + /// The value to match against + value: String, + /// How to match (Equals, StartsWith, EndsWith, Contains, Regex, etc.) + matching_strategy: MatchingStrategy, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +/// Rule for matching a systray icon to hide +/// +/// A plain string matches the exe name (backward compatible). +/// An object with optional `exe`, `tooltip`, and/or `guid` fields +/// uses AND logic: all specified fields must match. +/// Each field can be a plain string (exact match) or an object with +/// `value` and `matching_strategy` for advanced matching. +pub enum HiddenIconRule { + /// Match by exe name (case-insensitive, exact) + Exe(String), + /// Match by one or more properties (all specified fields must match) + Match { + /// Exe name to match + exe: Option, + /// Tooltip text to match + tooltip: Option, + /// Icon GUID to match (most stable identifier across restarts) + guid: Option, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// System tray widget configuration +pub struct SystrayConfig { + /// Enable the System Tray widget + pub enable: bool, + /// A list of rules for icons to hide from the system tray + /// + /// Each entry can be a plain string (matches exe name, case-insensitive) + /// or an object with optional `exe`, `tooltip`, and/or `guid` fields + /// (all specified fields must match, AND logic, case-insensitive). + /// + /// Run komorebi-bar with RUST_LOG=info to see the properties of all + /// systray icons in the log output. + pub hidden_icons: Option>, + /// Position of the overflow toggle button (default: Right) + pub overflow_toggle_position: Option, + /// Show an info button that opens a floating panel listing all systray icons + /// with their exe, tooltip, and GUID. Set to "Visible" to show it in the main + /// area, or "Overflow" to show it in the hidden/overflow section. + pub info_button: Option, + /// Interval in seconds to automatically check for and remove stale icons + /// whose owning process has exited. Clamped between 30 and 600 seconds. + /// Defaults to 60. Set to 0 to disable. + pub stale_icons_check_interval: Option, + /// Show a refresh button that manually triggers stale icon cleanup. + /// Set to "Visible" to show it in the main area, or "Overflow" to + /// show it in the hidden/overflow section. + pub refresh_button: Option, + /// Show a button that toggles komorebi-shortcuts (kills the process if + /// running, starts it otherwise). Set to "Visible" to show it in the main + /// area, or "Overflow" to show it in the hidden/overflow section. + pub shortcuts_button: Option, +} + +/// Command sent from UI to background thread +#[derive(Debug)] +enum SystrayCommand { + SendAction(StableId, SystrayIconAction), +} + +/// A single field condition: pre-lowercased value (except for Regex), +/// the matching strategy, and an optional pre-compiled regex. +#[derive(Clone)] +struct NormalizedField { + value: String, + strategy: MatchingStrategy, + compiled_regex: Option, +} + +impl NormalizedField { + /// Build from a raw value and strategy. Pre-lowercases the value for + /// non-regex strategies and pre-compiles the regex pattern if applicable. + fn new(value: String, strategy: MatchingStrategy) -> Self { + let compiled_regex = if strategy == MatchingStrategy::Regex { + match regex::Regex::new(&value) { + Ok(re) => Some(re), + Err(err) => { + tracing::warn!("invalid regex in hidden_icons rule: {err}"); + None + } + } + } else { + None + }; + + let normalized_value = if strategy == MatchingStrategy::Regex { + value + } else { + value.to_lowercase() + }; + + Self { + value: normalized_value, + strategy, + compiled_regex, + } + } + + /// Build from a `FieldMatch` config value. + fn from_field_match(field: FieldMatch) -> Self { + match field { + FieldMatch::Exact(s) => Self::new(s, MatchingStrategy::Equals), + FieldMatch::WithStrategy { + value, + matching_strategy, + } => Self::new(value, matching_strategy), + } + } + + /// Returns true if this field condition matches the given input string. + /// Non-regex comparisons are case-insensitive (value is pre-lowercased, + /// input is lowercased at match time). + fn matches(&self, input: &str) -> bool { + if self.strategy == MatchingStrategy::Regex { + return self + .compiled_regex + .as_ref() + .is_some_and(|re| re.is_match(input)); + } + + let input_lower = input.to_lowercase(); + match self.strategy { + MatchingStrategy::Legacy | MatchingStrategy::Equals => self.value == input_lower, + MatchingStrategy::StartsWith => input_lower.starts_with(&self.value), + MatchingStrategy::EndsWith => input_lower.ends_with(&self.value), + MatchingStrategy::Contains => input_lower.contains(&self.value), + MatchingStrategy::DoesNotEqual => self.value != input_lower, + MatchingStrategy::DoesNotStartWith => !input_lower.starts_with(&self.value), + MatchingStrategy::DoesNotEndWith => !input_lower.ends_with(&self.value), + MatchingStrategy::DoesNotContain => !input_lower.contains(&self.value), + MatchingStrategy::Regex => unreachable!(), + } + } +} + +/// Pre-normalized hidden icon matching rule with per-field matching strategies. +#[derive(Clone)] +struct NormalizedHiddenRule { + exe: Option, + tooltip: Option, + guid: Option, +} + +impl NormalizedHiddenRule { + /// Returns true if this rule matches the given icon properties (AND logic). + /// All specified (non-None) fields must match. + fn matches(&self, exe_name: &str, tooltip: &str, guid: Option<&str>) -> bool { + let exe_ok = self.exe.as_ref().is_none_or(|f| f.matches(exe_name)); + let tooltip_ok = self.tooltip.as_ref().is_none_or(|f| f.matches(tooltip)); + let guid_ok = self + .guid + .as_ref() + .is_none_or(|f| guid.is_some_and(|actual| f.matches(actual))); + exe_ok && tooltip_ok && guid_ok + } +} + +impl From for NormalizedHiddenRule { + fn from(rule: HiddenIconRule) -> Self { + match rule { + HiddenIconRule::Exe(exe) => Self { + exe: Some(NormalizedField::new(exe, MatchingStrategy::Equals)), + tooltip: None, + guid: None, + }, + HiddenIconRule::Match { exe, tooltip, guid } => Self { + exe: exe.map(NormalizedField::from_field_match), + tooltip: tooltip.map(NormalizedField::from_field_match), + guid: guid.map(NormalizedField::from_field_match), + }, + } + } +} + +/// Cached icon data for display +#[derive(Clone, Debug)] +struct CachedIcon { + stable_id: StableId, + tooltip: String, + exe_name: String, + guid_str: Option, + window_handle: Option, + image_icon: Option, + is_visible: bool, + /// Whether the icon has a callback message and can receive click events + is_clickable: bool, +} + +/// Pre-processed icon data from the background thread. +/// Image conversion (RgbaImage -> ColorImage) is done off the UI thread. +struct PreprocessedIcon { + stable_id: StableId, + tooltip: String, + window_handle: Option, + is_visible: bool, + /// Whether the icon has a callback message and can receive click events + is_clickable: bool, + /// Stable ID as string (pre-computed to avoid repeated allocations) + stable_id_str: String, + /// Cache key for the icon image (stable_id + hash) + image_cache_key: String, + /// Pre-converted ColorImage (conversion done off UI thread) + color_image: Option>, + /// GUID string for logging only + guid_str: Option, +} + +/// Pre-processed event sent from background thread to UI +enum PreprocessedEvent { + IconAddOrUpdate(PreprocessedIcon), + IconRemove(StableId), +} + +/// Global shared state for the systray (UI-side only, no SystrayClient here) +#[derive(Default)] +struct GlobalSystrayState { + /// Cached icons for rendering (shared across all widget instances) + icons: HashMap, + /// Receiver for pre-processed events from the background thread + event_rx: Option>, + /// Sender for commands to the background thread + command_tx: Option>, + /// Whether the background thread has been started + initialized: bool, + /// Last time stale icon cleanup was performed + last_cleanup: Option, +} + +/// Global singleton for the systray state +static SYSTRAY_STATE: LazyLock>> = + LazyLock::new(|| Arc::new(Mutex::new(GlobalSystrayState::default()))); + +/// Pre-process a SystrayEvent in the background thread. +/// Converts RgbaImage -> ColorImage so the UI thread only does the GPU upload. +fn preprocess_event(event: SystrayEvent) -> PreprocessedEvent { + match event { + SystrayEvent::IconAdd(ref icon) | SystrayEvent::IconUpdate(ref icon) => { + let stable_id_str = icon.stable_id.to_string(); + + let image_cache_key = match &icon.icon_image_hash { + Some(hash) => format!("{}_{}", stable_id_str, hash), + None => stable_id_str.clone(), + }; + + let guid_str = icon.guid.map(|g| g.to_string()); + + // Convert RgbaImage -> ColorImage here (background thread) instead of the UI thread + let color_image = icon + .icon_image + .as_ref() + .map(|img| Arc::new(rgba_to_color_image(&img.clone()))); + + let is_clickable = icon.window_handle.is_some() + && icon.uid.is_some() + && icon.callback_message.is_some(); + + PreprocessedEvent::IconAddOrUpdate(PreprocessedIcon { + stable_id: icon.stable_id.clone(), + tooltip: icon.tooltip.clone(), + window_handle: icon.window_handle, + is_visible: icon.is_visible, + is_clickable, + stable_id_str, + image_cache_key, + color_image, + guid_str, + }) + } + SystrayEvent::IconRemove(stable_id) => PreprocessedEvent::IconRemove(stable_id), + } +} + +/// Initialize the global systray background thread (only runs once) +fn ensure_systray_initialized() { + let mut state = SYSTRAY_STATE.lock(); + + if state.initialized { + return; + } + + state.initialized = true; + + let (event_tx, event_rx) = unbounded::(); + let (command_tx, command_rx) = unbounded::(); + + state.event_rx = Some(event_rx); + state.command_tx = Some(command_tx); + + // Drop the lock before spawning the thread + drop(state); + + // Spawn background thread with its own tokio runtime + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime for systray"); + + rt.block_on(async move { + match SystrayClient::new() { + Ok(mut systray) => { + tracing::info!("Systray client initialized successfully"); + + // Send initial icons (pre-processed in background thread) + for icon in systray.icons() { + let event = SystrayEvent::IconAdd(icon.clone()); + let _ = event_tx.send(preprocess_event(event)); + } + + // Create an async channel receiver for commands + let (async_cmd_tx, mut async_cmd_rx) = + tokio::sync::mpsc::unbounded_channel::(); + + // Spawn a task to bridge crossbeam -> tokio channel + let bridge_tx = async_cmd_tx.clone(); + std::thread::spawn(move || { + while let Ok(cmd) = command_rx.recv() { + if bridge_tx.send(cmd).is_err() { + break; + } + } + }); + + loop { + tokio::select! { + // Handle systray events (pre-process before sending) + event = systray.events() => { + match event { + Some(event) => { + if event_tx.send(preprocess_event(event)).is_err() { + tracing::error!("Failed to send systray event to UI"); + break; + } + } + None => { + tracing::info!("Systray events channel closed"); + break; + } + } + } + // Handle commands from UI + Some(cmd) = async_cmd_rx.recv() => { + match cmd { + SystrayCommand::SendAction(stable_id, action) => { + tracing::debug!( + "Processing systray action for {}: {:?}", + stable_id, + action + ); + if let Err(e) = systray.send_action(&stable_id, &action) { + tracing::error!( + "Failed to send systray action to {}: {:?}", + stable_id, + e + ); + } + } + } + } + } + } + } + Err(e) => { + tracing::error!("Failed to initialize systray client: {:?}", e); + } + } + }); + }); +} + +pub struct Systray { + pub enable: bool, + /// Pre-normalized rules for hiding icons + hidden_icons: Vec, + /// Position of the overflow toggle button + overflow_toggle_position: OverflowTogglePosition, + /// Where to show the info button (None = no button) + info_button: Option, + /// Interval for automatic stale icon cleanup (None = disabled) + stale_icons_check_interval: Option, + /// Where to show the refresh button (None = no button) + refresh_button: Option, + /// Where to show the shortcuts toggle button (None = no button) + shortcuts_button: Option, +} + +impl From<&SystrayConfig> for Systray { + fn from(value: &SystrayConfig) -> Self { + // Initialize the global systray on first widget creation + if value.enable { + ensure_systray_initialized(); + } + + // Normalize rules (lowercase all fields) for case-insensitive matching + let hidden_icons = value + .hidden_icons + .clone() + .unwrap_or_default() + .into_iter() + .map(NormalizedHiddenRule::from) + .collect(); + + // 0 = disabled, None = default 60s, otherwise clamp to [30, 600] + let stale_icons_check_interval = match value.stale_icons_check_interval { + Some(0) => None, + Some(secs) => Some(Duration::from_secs(secs.clamp(30, 600))), + None => Some(Duration::from_secs(60)), + }; + + Self { + enable: value.enable, + hidden_icons, + overflow_toggle_position: value.overflow_toggle_position.unwrap_or_default(), + info_button: value.info_button, + stale_icons_check_interval, + refresh_button: value.refresh_button, + shortcuts_button: value.shortcuts_button, + } + } +} + +impl Systray { + /// Process pending events from the background thread. + /// Returns true if any events were processed. + fn process_events() -> bool { + // 1. Drain events from channel (brief lock) + let events: Vec = { + let state = SYSTRAY_STATE.lock(); + match &state.event_rx { + Some(rx) => rx.try_iter().collect(), + None => return false, + } + }; + + if events.is_empty() { + return false; + } + + // 2. Deduplicate — keep only the last event per stable_id. + // If an icon fires many updates between frames, only the last matters. + let deduped = Self::deduplicate_events(events); + + // 3. Resolve exe names outside the lock. + // Exe names don't change for a given window handle, so we reuse + // any already-known name and only call the Win32 API for new icons. + let known_exe_names: HashMap = { + let state = SYSTRAY_STATE.lock(); + state + .icons + .iter() + .filter(|(_, icon)| !icon.exe_name.is_empty()) + .map(|(id, icon)| (id.clone(), icon.exe_name.clone())) + .collect() + }; + + let mut resolved_exe_names: HashMap = HashMap::new(); + for event in &deduped { + if let PreprocessedEvent::IconAddOrUpdate(picon) = event + && !known_exe_names.contains_key(&picon.stable_id_str) + && !resolved_exe_names.contains_key(&picon.stable_id_str) + { + let exe_name = picon + .window_handle + .and_then(Self::get_exe_from_hwnd) + .unwrap_or_default(); + resolved_exe_names.insert(picon.stable_id_str.clone(), exe_name); + } + } + + // 4. Process deduplicated events with the lock held (fast path only — + // no Win32 calls or image conversion happen here). + let mut state = SYSTRAY_STATE.lock(); + for event in deduped { + match event { + PreprocessedEvent::IconAddOrUpdate(picon) => { + let exe_name = known_exe_names + .get(&picon.stable_id_str) + .or_else(|| resolved_exe_names.get(&picon.stable_id_str)) + .cloned() + .unwrap_or_default(); + + // Evict stale cache entry if the image hash changed + if let Some(old_cached) = state.icons.get(&picon.stable_id_str) + && let Some(old_icon) = &old_cached.image_icon + { + let new_key = ImageIconId::SystrayIcon(picon.image_cache_key.clone()); + if old_icon.id != new_key { + ICONS_CACHE.remove(&old_icon.id); + } + } + + let stable_id_str = picon.stable_id_str.clone(); + let cached = Self::create_cached_icon(picon, exe_name); + state.icons.insert(stable_id_str, cached); + } + PreprocessedEvent::IconRemove(stable_id) => { + let key = stable_id.to_string(); + // Evict cache for removed icons + if let Some(old_cached) = state.icons.get(&key) + && let Some(old_icon) = &old_cached.image_icon + { + ICONS_CACHE.remove(&old_icon.id); + } + state.icons.remove(&key); + } + } + } + + true + } + + /// Deduplicate events, keeping only the last event per stable_id. + /// For rapid-fire icon updates, this collapses N events into 1. + fn deduplicate_events(events: Vec) -> Vec { + let mut last_event: HashMap = HashMap::new(); + + for event in events { + let key = match &event { + PreprocessedEvent::IconAddOrUpdate(picon) => picon.stable_id_str.clone(), + PreprocessedEvent::IconRemove(stable_id) => stable_id.to_string(), + }; + last_event.insert(key, event); + } + + last_event.into_values().collect() + } + + /// Create a CachedIcon from pre-processed data. + /// Image conversion and exe name resolution are already done by the time + /// this is called. + fn create_cached_icon(picon: PreprocessedIcon, exe_name: String) -> CachedIcon { + tracing::info!( + "Systray icon: tooltip={:?}, exe={:?}, guid={:?}, stable_id={}, is_visible={}", + picon.tooltip, + exe_name, + picon.guid_str, + picon.stable_id_str, + picon.is_visible + ); + + // Image already converted by the background thread — + // just insert the ColorImage into the shared cache. + let image_icon = picon.color_image.map(|color_image| { + let id = ImageIconId::SystrayIcon(picon.image_cache_key.clone()); + ICONS_CACHE.insert_image(id.clone(), color_image.clone()); + ImageIcon::new(id, color_image) + }); + + CachedIcon { + stable_id: picon.stable_id, + tooltip: picon.tooltip, + exe_name, + guid_str: picon.guid_str, + window_handle: picon.window_handle, + image_icon, + is_visible: picon.is_visible, + is_clickable: picon.is_clickable, + } + } + + /// Get the executable name from a window handle + fn get_exe_from_hwnd(hwnd: isize) -> Option { + unsafe { + let mut process_id: u32 = 0; + GetWindowThreadProcessId( + HWND(hwnd as *mut _), + Some(std::ptr::addr_of_mut!(process_id)), + ); + + if process_id == 0 { + return None; + } + + let handle = OpenProcess(PROCESS_QUERY_INFORMATION, false, process_id).ok()?; + + let mut len = 260_u32; + let mut path: Vec = vec![0; len as usize]; + let text_ptr = path.as_mut_ptr(); + + let result = + QueryFullProcessImageNameW(handle, PROCESS_NAME_WIN32, PWSTR(text_ptr), &mut len); + + let _ = CloseHandle(handle); + + if result.is_err() { + return None; + } + + let exe_path = String::from_utf16(&path[..len as usize]).ok()?; + + // Extract just the filename from the path + exe_path.rsplit('\\').next().map(|s| s.to_string()) + } + } + + /// Send a click action to an icon + fn send_action(stable_id: &StableId, action: SystrayIconAction) { + let state = SYSTRAY_STATE.lock(); + + if let Some(command_tx) = &state.command_tx + && command_tx + .send(SystrayCommand::SendAction(stable_id.clone(), action)) + .is_err() + { + tracing::error!("Failed to send command to systray thread"); + } + } + + /// Get a snapshot of current icons + fn get_visible_icons() -> Vec { + let state = SYSTRAY_STATE.lock(); + let mut icons: Vec<_> = state + .icons + .values() + .filter(|icon| icon.is_visible) + .cloned() + .collect(); + icons.sort_by(|a, b| a.stable_id.to_string().cmp(&b.stable_id.to_string())); + icons + } + + /// Get a snapshot of all icons (including those not marked as visible by the OS) + fn get_all_icons() -> Vec { + let state = SYSTRAY_STATE.lock(); + let mut icons: Vec<_> = state.icons.values().cloned().collect(); + icons.sort_by(|a, b| a.stable_id.to_string().cmp(&b.stable_id.to_string())); + icons + } + + /// Check if an icon should be hidden based on the configured rules + fn is_icon_hidden(&self, icon: &CachedIcon) -> bool { + self.hidden_icons + .iter() + .any(|rule| rule.matches(&icon.exe_name, &icon.tooltip, icon.guid_str.as_deref())) + } + + /// Remove icons whose owning window no longer exists. + /// Returns true if any stale icons were removed. + fn cleanup_stale_icons() -> bool { + let mut state = SYSTRAY_STATE.lock(); + + let stale_ids: Vec = state + .icons + .iter() + .filter(|(_, icon)| { + icon.window_handle + .is_some_and(|hwnd| unsafe { !IsWindow(Some(HWND(hwnd as *mut _))).as_bool() }) + }) + .map(|(id, _)| id.clone()) + .collect(); + + if stale_ids.is_empty() { + return false; + } + + for id in &stale_ids { + if let Some(old_cached) = state.icons.get(id) + && let Some(old_icon) = &old_cached.image_icon + { + ICONS_CACHE.remove(&old_icon.id); + } + state.icons.remove(id); + } + + tracing::info!("Removed {} stale systray icon(s)", stale_ids.len()); + true + } +} + +impl BarWidget for Systray { + fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { + if !self.enable { + return; + } + + // Process any pending events and request repaint if there were any + if Self::process_events() { + ctx.request_repaint(); + } + + // Periodic stale icon cleanup + if let Some(interval) = self.stale_icons_check_interval { + let should_cleanup = { + let mut state = SYSTRAY_STATE.lock(); + let now = Instant::now(); + let due = state + .last_cleanup + .is_none_or(|last| now.duration_since(last) >= interval); + if due { + state.last_cleanup = Some(now); + } + due + }; + + if should_cleanup && Self::cleanup_stale_icons() { + ctx.request_repaint(); + } + } + + // Render the floating info window before the bar layout so it is not + // clipped by the widget's allocated area. + self.render_info_window(ctx); + + let icon_size = config.icon_font_id.size; + let all_icons = Self::get_visible_icons(); + + // Separate visible and hidden icons + let (visible_icons, hidden_icons): (Vec<_>, Vec<_>) = all_icons + .into_iter() + .partition(|icon| !self.is_icon_hidden(icon)); + + let show_hidden = SHOW_HIDDEN_ICONS.load(Ordering::SeqCst); + let refresh_in_overflow = self.refresh_button == Some(ButtonPosition::Overflow); + let info_in_overflow = self.info_button == Some(ButtonPosition::Overflow); + let shortcuts_in_overflow = self.shortcuts_button == Some(ButtonPosition::Overflow); + let has_overflow_content = !hidden_icons.is_empty() + || refresh_in_overflow + || info_in_overflow + || shortcuts_in_overflow; + + // Check if we're in a right-aligned context (rendering is reversed) + let is_reversed = matches!(config.alignment, Some(Alignment::Right)); + + // Determine effective toggle position (flip if reversed) + let effective_toggle_left = match (self.overflow_toggle_position, is_reversed) { + (OverflowTogglePosition::Left, false) => true, + (OverflowTogglePosition::Right, false) => false, + (OverflowTogglePosition::Left, true) => false, // Flip when reversed + (OverflowTogglePosition::Right, true) => true, // Flip when reversed + }; + + config.apply_on_widget(false, ui, |ui| { + // Render toggle button on the left if configured + if has_overflow_content && effective_toggle_left { + self.render_toggle_button( + ui, + show_hidden, + &hidden_icons, + icon_size, + ctx, + is_reversed, + ); + } + + // Render visible icons + for cached_icon in &visible_icons { + self.render_icon(ctx, ui, cached_icon, icon_size); + } + + // Render visible-area buttons (after icons) + if self.refresh_button == Some(ButtonPosition::Visible) { + Self::render_refresh_button(ui, ctx); + } + if self.info_button == Some(ButtonPosition::Visible) { + Self::render_info_button(ui); + } + if self.shortcuts_button == Some(ButtonPosition::Visible) { + Self::render_shortcuts_button(ui); + } + + // Render toggle button on the right if configured (default) + if has_overflow_content && !effective_toggle_left { + self.render_toggle_button( + ui, + show_hidden, + &hidden_icons, + icon_size, + ctx, + is_reversed, + ); + } + }); + } +} + +impl Systray { + /// Render the toggle button for showing/hiding overflow icons + fn render_toggle_button( + &self, + ui: &mut Ui, + show_hidden: bool, + hidden_icons: &[CachedIcon], + icon_size: f32, + ctx: &Context, + is_reversed: bool, + ) { + // Determine arrow direction: + // - When collapsed: arrow points toward where hidden icons will appear + // - When expanded: arrow points toward visible icons (away from hidden icons) + // + // In left_widgets (not reversed) with toggle on Left: + // Collapsed: ? [visible...] (arrow points left, where hidden will appear) + // Expanded: ? [hidden...] [visible...] (arrow points right, toward visible) + // + // In right_widgets (reversed) with toggle on Left: + // Collapsed: [visible...] ? (arrow points left, where hidden will appear) + // Expanded: [visible...] ? [hidden...] (arrow points right, toward visible) + let toggle_icon = match (self.overflow_toggle_position, is_reversed, show_hidden) { + // Left position, normal rendering + (OverflowTogglePosition::Left, false, false) => egui_phosphor::regular::CARET_LEFT, + (OverflowTogglePosition::Left, false, true) => egui_phosphor::regular::CARET_RIGHT, + // Right position, normal rendering + (OverflowTogglePosition::Right, false, false) => egui_phosphor::regular::CARET_RIGHT, + (OverflowTogglePosition::Right, false, true) => egui_phosphor::regular::CARET_LEFT, + // Left position, reversed rendering (right_widgets) - arrows flipped + (OverflowTogglePosition::Left, true, false) => egui_phosphor::regular::CARET_LEFT, + (OverflowTogglePosition::Left, true, true) => egui_phosphor::regular::CARET_RIGHT, + // Right position, reversed rendering (right_widgets) - arrows flipped + (OverflowTogglePosition::Right, true, false) => egui_phosphor::regular::CARET_RIGHT, + (OverflowTogglePosition::Right, true, true) => egui_phosphor::regular::CARET_LEFT, + }; + + let toggle_response = SelectableFrame::new(show_hidden) + .show(ui, |ui| { + ui.add(Label::new(toggle_icon).selectable(false)); + }) + .on_hover_text_at_pointer(if show_hidden { + "Hide overflow icons" + } else { + "Show overflow icons" + }); + + if toggle_response.clicked() { + mark_widget_clicked(); + SHOW_HIDDEN_ICONS.store(!show_hidden, Ordering::SeqCst); + } + + // If expanded, show the hidden icons and overflow buttons + if show_hidden { + for cached_icon in hidden_icons { + self.render_icon(ctx, ui, cached_icon, icon_size); + } + + if self.refresh_button == Some(ButtonPosition::Overflow) { + Self::render_refresh_button(ui, ctx); + } + if self.info_button == Some(ButtonPosition::Overflow) { + Self::render_info_button(ui); + } + if self.shortcuts_button == Some(ButtonPosition::Overflow) { + Self::render_shortcuts_button(ui); + } + } + } + + /// Render the refresh button that triggers stale icon cleanup + fn render_refresh_button(ui: &mut Ui, ctx: &Context) { + let response = SelectableFrame::new(false) + .show(ui, |ui| { + ui.add(Label::new(egui_phosphor::regular::ARROWS_CLOCKWISE).selectable(false)); + }) + .on_hover_text_at_pointer("Refresh systray icons"); + + if response.clicked() { + mark_widget_clicked(); + if Self::cleanup_stale_icons() { + ctx.request_repaint(); + } + } + } + + /// Render the info button that toggles the info panel + fn render_info_button(ui: &mut Ui) { + let show_info = SHOW_SYSTRAY_INFO.load(Ordering::SeqCst); + + let response = SelectableFrame::new(show_info) + .show(ui, |ui| { + ui.add(Label::new(egui_phosphor::regular::INFO).selectable(false)); + }) + .on_hover_text_at_pointer("Show systray icon details"); + + if response.clicked() { + mark_widget_clicked(); + SHOW_SYSTRAY_INFO.store(!show_info, Ordering::SeqCst); + } + } + + /// Render the shortcuts toggle button. + /// Toggles `komorebi-shortcuts.exe`: kills it if running, starts it otherwise. + fn render_shortcuts_button(ui: &mut Ui) { + let response = SelectableFrame::new(false) + .show(ui, |ui| { + ui.add(Label::new(egui_phosphor::regular::KEYBOARD).selectable(false)); + }) + .on_hover_text_at_pointer("Toggle shortcuts"); + + if response.clicked() { + mark_widget_clicked(); + // Run on a background thread so `taskkill` output() doesn't block the UI + thread::spawn(|| { + let killed = std::process::Command::new("taskkill") + .args(["/F", "/IM", "komorebi-shortcuts.exe"]) + .output() + .is_ok_and(|o| o.status.success()); + + if !killed { + let _ = std::process::Command::new("komorebi-shortcuts.exe").spawn(); + } + }); + } + } + + /// Render the info panel as a separate OS window via a deferred viewport. + /// This avoids being clipped by the bar's thin OS window. + fn render_info_window(&self, ctx: &Context) { + if !SHOW_SYSTRAY_INFO.load(Ordering::SeqCst) { + return; + } + + // Clone the rules into an Arc so the `Send + Sync + 'static` callback + // can use them for the "Hidden (rule)" column. + let rules: Arc> = Arc::new(self.hidden_icons.clone()); + + let window_size = [500.0f32, 300.0]; + // GetSystemMetrics returns physical pixels; ViewportBuilder expects + // logical points, so divide by the current DPI scale factor. + let scale = ctx.pixels_per_point(); + let center = unsafe { + let sw = GetSystemMetrics(SM_CXSCREEN) as f32 / scale; + let sh = GetSystemMetrics(SM_CYSCREEN) as f32 / scale; + [(sw - window_size[0]) / 2.0, (sh - window_size[1]) / 2.0] + }; + + ctx.show_viewport_deferred( + ViewportId::from_hash_of("systray_info"), + ViewportBuilder::default() + .with_title("Systray Icons") + .with_inner_size(window_size) + .with_position(center), + move |ctx, class| { + // Handle the OS window's close button + if ctx.input(|i| i.viewport().close_requested()) { + SHOW_SYSTRAY_INFO.store(false, Ordering::SeqCst); + return; + } + + match class { + ViewportClass::Embedded => { + // Fallback when the backend doesn't support multiple + // OS windows — render inside an egui::Window (clipped + // to the bar, but still functional). + let mut open = true; + EguiWindow::new("Systray Icons") + .open(&mut open) + .resizable(true) + .default_size([500.0, 300.0]) + .show(ctx, |ui| { + Self::render_info_content(ui, ctx, &rules); + }); + if !open { + SHOW_SYSTRAY_INFO.store(false, Ordering::SeqCst); + } + } + ViewportClass::Deferred | ViewportClass::Immediate | ViewportClass::Root => { + CentralPanel::default().show(ctx, |ui| { + Self::render_info_content(ui, ctx, &rules); + }); + } + } + }, + ); + } + + /// Render a small copy-to-clipboard button + fn copy_button(ui: &mut Ui, text: &str) { + if ui + .small_button(egui_phosphor::regular::COPY) + .on_hover_text("Copy to clipboard") + .clicked() + { + ui.ctx().copy_text(text.to_string()); + } + } + + /// Render the info table content (shared between deferred and embedded viewport paths). + /// Uses `egui_extras::TableBuilder` for striped rows with overlines. + fn render_info_content(ui: &mut Ui, ctx: &Context, rules: &[NormalizedHiddenRule]) { + let all_icons = Self::get_all_icons(); + + let line_height = eframe::egui::TextStyle::Body + .resolve(ui.style()) + .size + .max(ui.spacing().interact_size.y); + + ScrollArea::horizontal().show(ui, |ui| { + let available_height = ui.available_height(); + + let table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(eframe::egui::Layout::left_to_right( + eframe::egui::Align::Center, + )) + .column(Column::auto()) // Icon + .column(Column::auto().resizable(true)) // Exe + .column(Column::auto().resizable(true)) // Tooltip + .column(Column::auto().resizable(true)) // GUID + .column(Column::auto()) // Visible + .column(Column::auto().resizable(true)) // Clickable + .min_scrolled_height(0.0) + .max_scroll_height(available_height); + + table + .header(20.0, |mut header| { + header.col(|ui| { + ui.strong("Icon"); + }); + header.col(|ui| { + ui.strong("Exe"); + }); + header.col(|ui| { + ui.strong("Tooltip"); + }); + header.col(|ui| { + ui.strong("GUID"); + }); + header.col(|ui| { + ui.strong("Visible"); + }); + header.col(|ui| { + ui.strong("Clickable"); + }); + }) + .body(|mut body| { + for icon in &all_icons { + // Size the row to fit multi-line tooltips + let tooltip_lines = icon.tooltip.lines().count().max(1); + let row_height = line_height * tooltip_lines as f32; + + body.row(row_height, |mut row| { + row.set_overline(true); + + row.col(|ui| { + if let Some(image_icon) = &icon.image_icon { + ui.add( + Image::from_texture(&image_icon.texture(ctx)) + .maintain_aspect_ratio(true) + .fit_to_exact_size(Vec2::splat(16.0)), + ); + } else { + ui.allocate_space(Vec2::splat(16.0)); + } + }); + row.col(|ui| { + ui.label(&icon.exe_name); + if !icon.exe_name.is_empty() { + Self::copy_button(ui, &icon.exe_name); + } + }); + row.col(|ui| { + ui.label(&icon.tooltip); + if !icon.tooltip.is_empty() { + Self::copy_button(ui, &icon.tooltip); + } + }); + row.col(|ui| { + let guid = icon.guid_str.as_deref().unwrap_or("—"); + ui.label(guid); + if icon.guid_str.is_some() { + Self::copy_button(ui, guid); + } + }); + row.col(|ui| { + let hidden_by_rule = rules.iter().any(|rule| { + rule.matches( + &icon.exe_name, + &icon.tooltip, + icon.guid_str.as_deref(), + ) + }); + let visibility_text = if !icon.is_visible { + "Hidden (OS)" + } else if hidden_by_rule { + "Hidden (rule)" + } else { + "Yes" + }; + ui.label(visibility_text); + }); + row.col(|ui| { + if let Some(cmd) = + Self::fallback_command(&icon.exe_name, &icon.tooltip) + { + ui.label(format!("Fallback: {cmd}")); + } else if icon.is_clickable { + ui.label("Yes"); + } else { + ui.label("No"); + } + }); + }); + } + }); + }); + } + + /// Render a single systray icon + fn render_icon(&self, ctx: &Context, ui: &mut Ui, cached_icon: &CachedIcon, icon_size: f32) { + let stable_id = cached_icon.stable_id.clone(); + let tooltip = &cached_icon.tooltip; + + let response = SelectableFrame::new(false).show(ui, |ui| { + if let Some(image_icon) = &cached_icon.image_icon { + Frame::NONE + .inner_margin(Margin::same(ui.style().spacing.button_padding.y as i8)) + .show(ui, |ui| { + ui.add( + Image::from_texture(&image_icon.texture(ctx)) + .maintain_aspect_ratio(true) + .fit_to_exact_size(Vec2::splat(icon_size)), + ); + }); + } else { + // Fallback: allocate space with a placeholder + ui.allocate_space(Vec2::splat(icon_size)); + } + }); + + let response = if tooltip.is_empty() { + response + } else { + response.on_hover_text_at_pointer(tooltip) + }; + + // Handle mouse clicks - mark as consumed to prevent bar from also handling. + // Fallback commands take priority: if we've defined a fallback for an icon, + // we know its native click is broken/useless (some icons register a callback + // but don't actually respond to click messages). + let has_fallback = + Self::fallback_command(&cached_icon.exe_name, &cached_icon.tooltip).is_some(); + + // Check double_clicked() before clicked() because egui fires both on + // the second click of a double-click — we want to send only the + // double-click action in that case. + if response.double_clicked() { + mark_widget_clicked(); + if has_fallback { + Self::fallback_click(cached_icon); + } else { + Self::send_action(&stable_id, SystrayIconAction::LeftDoubleClick); + } + } else if response.clicked() { + mark_widget_clicked(); + if has_fallback { + Self::fallback_click(cached_icon); + } else { + Self::send_action(&stable_id, SystrayIconAction::LeftClick); + } + } else if response.secondary_clicked() { + mark_widget_clicked(); + if has_fallback { + Self::fallback_click(cached_icon); + } else { + Self::send_action(&stable_id, SystrayIconAction::RightClick); + } + } else if response.middle_clicked() { + mark_widget_clicked(); + if has_fallback { + Self::fallback_click(cached_icon); + } else { + Self::send_action(&stable_id, SystrayIconAction::MiddleClick); + } + } + } + + /// Returns the fallback command for an icon with known broken/missing click + /// handling, if one is defined. Fallbacks take priority over native clicks. + fn fallback_command(exe_name: &str, tooltip: &str) -> Option<&'static str> { + match exe_name.to_lowercase().as_str() { + "securityhealthsystray.exe" => Some("start windowsdefender://"), + "explorer.exe" if tooltip.ends_with('%') => Some("start ms-settings:apps-volume"), + "explorer.exe" if tooltip.is_empty() => Some("start ms-settings:batterysaver"), + _ => None, + } + } + + /// Execute the fallback command for an icon with broken/missing click handling. + fn fallback_click(icon: &CachedIcon) { + if let Some(cmd) = Self::fallback_command(&icon.exe_name, &icon.tooltip) { + tracing::debug!( + "Fallback click for non-clickable icon: exe={}, cmd={}", + icon.exe_name, + cmd + ); + std::process::Command::new("cmd.exe") + .args(["/C", cmd]) + .spawn() + .ok(); + } else { + tracing::debug!("No fallback for non-clickable icon: exe={}", icon.exe_name); + } + } +} diff --git a/komorebi-bar/src/widgets/widget.rs b/komorebi-bar/src/widgets/widget.rs index 773cba2a..6da0806b 100644 --- a/komorebi-bar/src/widgets/widget.rs +++ b/komorebi-bar/src/widgets/widget.rs @@ -19,6 +19,10 @@ use crate::widgets::network::Network; use crate::widgets::network::NetworkConfig; use crate::widgets::storage::Storage; use crate::widgets::storage::StorageConfig; +#[cfg(target_os = "windows")] +use crate::widgets::systray::Systray; +#[cfg(target_os = "windows")] +use crate::widgets::systray::SystrayConfig; use crate::widgets::time::Time; use crate::widgets::time::TimeConfig; use crate::widgets::update::Update; @@ -66,6 +70,10 @@ pub enum WidgetConfig { /// Storage widget configuration #[cfg_attr(feature = "schemars", schemars(title = "Storage"))] Storage(StorageConfig), + /// System Tray widget configuration (Windows only) + #[cfg(target_os = "windows")] + #[cfg_attr(feature = "schemars", schemars(title = "Systray"))] + Systray(SystrayConfig), /// Time widget configuration #[cfg_attr(feature = "schemars", schemars(title = "Time"))] Time(TimeConfig), @@ -87,6 +95,8 @@ impl WidgetConfig { WidgetConfig::Memory(config) => Box::new(Memory::from(*config)), WidgetConfig::Network(config) => Box::new(Network::from(*config)), WidgetConfig::Storage(config) => Box::new(Storage::from(*config)), + #[cfg(target_os = "windows")] + WidgetConfig::Systray(config) => Box::new(Systray::from(config)), WidgetConfig::Time(config) => Box::new(Time::from(config.clone())), WidgetConfig::Update(config) => Box::new(Update::from(*config)), } @@ -112,6 +122,8 @@ impl WidgetConfig { WidgetConfig::Memory(config) => config.enable, WidgetConfig::Network(config) => config.enable, WidgetConfig::Storage(config) => config.enable, + #[cfg(target_os = "windows")] + WidgetConfig::Systray(config) => config.enable, WidgetConfig::Time(config) => config.enable, WidgetConfig::Update(config) => config.enable, } diff --git a/komorebi/src/core/animation.rs b/komorebi/src/core/animation.rs index 659685a7..83be03cc 100644 --- a/komorebi/src/core/animation.rs +++ b/komorebi/src/core/animation.rs @@ -74,7 +74,7 @@ pub enum AnimationStyle { EaseInOutBounce, #[cfg_attr(feature = "schemars", schemars(title = "CubicBezier"))] #[value(skip)] - /// Custom Cubic Bézier function + /// Custom Cubic Bezier function CubicBezier(f64, f64, f64, f64), } diff --git a/mkdocs.yml b/mkdocs.yml index 35fe0052..dd464c34 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,8 @@ nav: - common-workflows/mouse-follows-focus.md - common-workflows/dynamic-layout-switching.md - common-workflows/multiple-bar-instances.md + - common-workflows/bar.md + - common-workflows/bar-widgets/systray.md - common-workflows/multi-monitor-setup.md - CLI reference: - cli/quickstart.md diff --git a/schema.bar.json b/schema.bar.json index 6bc7d36f..13e024c5 100644 --- a/schema.bar.json +++ b/schema.bar.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "KomobarConfig", - "description": "The `komorebi.bar.json` configuration file reference for `v0.1.40`", + "description": "The `komorebi.bar.json` configuration file reference for `v0.1.41`", "type": "object", "properties": { "center_widgets": { @@ -353,7 +353,7 @@ }, { "title": "CubicBezier", - "description": "Custom Cubic Bézier function", + "description": "Custom Cubic Bezier function", "type": "object", "properties": { "CubicBezier": { @@ -2158,6 +2158,21 @@ } ] }, + "ButtonPosition": { + "description": "Where to place a systray button (refresh, info, shortcuts, etc.)", + "oneOf": [ + { + "description": "Show in the main visible area", + "type": "string", + "const": "Visible" + }, + { + "description": "Show in the overflow/hidden section", + "type": "string", + "const": "Overflow" + } + ] + }, "Catppuccin": { "description": "Catppuccin palette", "oneOf": [ @@ -2573,6 +2588,33 @@ } ] }, + "FieldMatch": { + "description": "A field value with an optional matching strategy.\n\nA plain string uses exact (case-insensitive) matching.\nAn object with `value` and `matching_strategy` uses the specified strategy.", + "anyOf": [ + { + "description": "Exact case-insensitive match", + "type": "string" + }, + { + "description": "Match using a specific strategy", + "type": "object", + "properties": { + "matching_strategy": { + "description": "How to match (Equals, StartsWith, EndsWith, Contains, Regex, etc.)", + "$ref": "#/$defs/MatchingStrategy" + }, + "value": { + "description": "The value to match against", + "type": "string" + } + }, + "required": [ + "value", + "matching_strategy" + ] + } + ] + }, "FocusFollowsMouseImplementation": { "description": "Focus follows mouse implementation", "oneOf": [ @@ -2802,6 +2844,54 @@ "type": "string", "format": "color-hex" }, + "HiddenIconRule": { + "description": "Rule for matching a systray icon to hide\n\nA plain string matches the exe name (backward compatible).\nAn object with optional `exe`, `tooltip`, and/or `guid` fields\nuses AND logic: all specified fields must match.\nEach field can be a plain string (exact match) or an object with\n`value` and `matching_strategy` for advanced matching.", + "anyOf": [ + { + "description": "Match by exe name (case-insensitive, exact)", + "type": "string" + }, + { + "description": "Match by one or more properties (all specified fields must match)", + "type": "object", + "properties": { + "exe": { + "description": "Exe name to match", + "anyOf": [ + { + "$ref": "#/$defs/FieldMatch" + }, + { + "type": "null" + } + ] + }, + "guid": { + "description": "Icon GUID to match (most stable identifier across restarts)", + "anyOf": [ + { + "$ref": "#/$defs/FieldMatch" + }, + { + "type": "null" + } + ] + }, + "tooltip": { + "description": "Tooltip text to match", + "anyOf": [ + { + "$ref": "#/$defs/FieldMatch" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, "HidingBehaviour": { "description": "Window hiding behaviour", "oneOf": [ @@ -3859,6 +3949,61 @@ } ] }, + "MatchingStrategy": { + "description": "Strategy for matching identifiers", + "oneOf": [ + { + "description": "Should not be used, only kept for backward compatibility", + "type": "string", + "const": "Legacy" + }, + { + "description": "Equals", + "type": "string", + "const": "Equals" + }, + { + "description": "Starts With", + "type": "string", + "const": "StartsWith" + }, + { + "description": "Ends With", + "type": "string", + "const": "EndsWith" + }, + { + "description": "Contains", + "type": "string", + "const": "Contains" + }, + { + "description": "Regex", + "type": "string", + "const": "Regex" + }, + { + "description": "Does not end with", + "type": "string", + "const": "DoesNotEndWith" + }, + { + "description": "Does not start with", + "type": "string", + "const": "DoesNotStartWith" + }, + { + "description": "Does not equal", + "type": "string", + "const": "DoesNotEqual" + }, + { + "description": "Does not contain", + "type": "string", + "const": "DoesNotContain" + } + ] + }, "MediaConfig": { "description": "Media widget configuration", "type": "object", @@ -4301,6 +4446,21 @@ "Down" ] }, + "OverflowTogglePosition": { + "description": "Position of the overflow toggle button", + "oneOf": [ + { + "description": "Toggle button appears on the left side (before visible icons)", + "type": "string", + "const": "Left" + }, + { + "description": "Toggle button appears on the right side (after visible icons)", + "type": "string", + "const": "Right" + } + ] + }, "PathBuf": { "description": "A file system path. Environment variables like %VAR%, $Env:VAR, or $VAR are automatically resolved.", "type": "string" @@ -8147,6 +8307,82 @@ "filter_state_changes" ] }, + "SystrayConfig": { + "description": "System tray widget configuration", + "type": "object", + "properties": { + "enable": { + "description": "Enable the System Tray widget", + "type": "boolean" + }, + "hidden_icons": { + "description": "A list of rules for icons to hide from the system tray\n\nEach entry can be a plain string (matches exe name, case-insensitive)\nor an object with optional `exe`, `tooltip`, and/or `guid` fields\n(all specified fields must match, AND logic, case-insensitive).\n\nRun komorebi-bar with RUST_LOG=info to see the properties of all\nsystray icons in the log output.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/HiddenIconRule" + } + }, + "info_button": { + "description": "Show an info button that opens a floating panel listing all systray icons\nwith their exe, tooltip, and GUID. Set to \"Visible\" to show it in the main\narea, or \"Overflow\" to show it in the hidden/overflow section.", + "anyOf": [ + { + "$ref": "#/$defs/ButtonPosition" + }, + { + "type": "null" + } + ] + }, + "overflow_toggle_position": { + "description": "Position of the overflow toggle button (default: Right)", + "anyOf": [ + { + "$ref": "#/$defs/OverflowTogglePosition" + }, + { + "type": "null" + } + ] + }, + "refresh_button": { + "description": "Show a refresh button that manually triggers stale icon cleanup.\nSet to \"Visible\" to show it in the main area, or \"Overflow\" to\nshow it in the hidden/overflow section.", + "anyOf": [ + { + "$ref": "#/$defs/ButtonPosition" + }, + { + "type": "null" + } + ] + }, + "shortcuts_button": { + "description": "Show a button that toggles komorebi-shortcuts (kills the process if\nrunning, starts it otherwise). Set to \"Visible\" to show it in the main\narea, or \"Overflow\" to show it in the hidden/overflow section.", + "anyOf": [ + { + "$ref": "#/$defs/ButtonPosition" + }, + { + "type": "null" + } + ] + }, + "stale_icons_check_interval": { + "description": "Interval in seconds to automatically check for and remove stale icons\nwhose owning process has exited. Clamped between 30 and 600 seconds.\nDefaults to 60. Set to 0 to disable.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "enable" + ] + }, "TimeConfig": { "description": "Time widget configuration", "type": "object", @@ -8416,6 +8652,20 @@ "Storage" ] }, + { + "title": "Systray", + "description": "System Tray widget configuration (Windows only)", + "type": "object", + "properties": { + "Systray": { + "$ref": "#/$defs/SystrayConfig" + } + }, + "additionalProperties": false, + "required": [ + "Systray" + ] + }, { "title": "Time", "description": "Time widget configuration", diff --git a/schema.json b/schema.json index 45a5fae2..a400c378 100644 --- a/schema.json +++ b/schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "StaticConfig", - "description": "The `komorebi.json` static configuration file reference for `v0.1.40`", + "description": "The `komorebi.json` static configuration file reference for `v0.1.41`", "type": "object", "properties": { "animation": { @@ -703,7 +703,7 @@ }, { "title": "CubicBezier", - "description": "Custom Cubic Bézier function", + "description": "Custom Cubic Bezier function", "type": "object", "properties": { "CubicBezier": {