diff --git a/CHANGELOG.md b/CHANGELOG.md index e450d0c..a2ad644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Multi monitor support +- Tray module ### Changed - Update to pop-os Iced 14.0-dev +- Dynamic menu positioning ## [0.3.1] - 2024-12-13 diff --git a/Cargo.lock b/Cargo.lock index ba54381..cb08b4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,6 +136,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -247,7 +253,7 @@ dependencies = [ "hyprland", "iced", "inotify", - "itertools 0.13.0", + "itertools 0.14.0", "libpulse-binding", "log", "pipewire", @@ -294,30 +300,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-executor" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand 2.3.0", - "futures-lite 2.5.0", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" -dependencies = [ - "async-lock 3.4.0", - "blocking", - "futures-lite 2.5.0", -] - [[package]] name = "async-io" version = "1.13.0" @@ -394,25 +376,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "async-process" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" -dependencies = [ - "async-channel", - "async-io 2.4.0", - "async-lock 3.4.0", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener 5.3.1", - "futures-lite 2.5.0", - "rustix 0.38.42", - "tracing", -] - [[package]] name = "async-recursion" version = "1.1.1" @@ -592,6 +555,12 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + [[package]] name = "bitflags" version = "1.3.2" @@ -868,6 +837,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "com" version = "0.6.0" @@ -1347,6 +1322,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -1529,10 +1519,7 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cef40d21ae2c515b51041df9ed313ed21e572df340ea58a922a0aefe7e8891a1" dependencies = [ - "fastrand 2.3.0", "futures-core", - "futures-io", - "parking", "pin-project-lite", ] @@ -1608,6 +1595,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.31.1" @@ -1761,6 +1758,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -1870,6 +1873,7 @@ dependencies = [ "iced_renderer", "iced_widget", "iced_winit", + "image", "mime", "thiserror 1.0.69", "window_clipboard", @@ -1944,6 +1948,8 @@ dependencies = [ "half", "iced_core", "iced_futures", + "image", + "kamadak-exif", "log", "once_cell", "raw-window-handle", @@ -2034,6 +2040,7 @@ dependencies = [ "iced_runtime", "num-traits", "once_cell", + "ouroboros", "rustc-hash 2.1.0", "thiserror 1.0.69", "unicode-segmentation", @@ -2067,6 +2074,24 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-traits", + "png", + "qoi", + "tiff", +] + [[package]] name = "immutable-chunkmap" version = "2.0.6" @@ -2145,9 +2170,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -2189,6 +2214,15 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.76" @@ -2199,6 +2233,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" +dependencies = [ + "mutate_once", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2238,6 +2281,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + [[package]] name = "libc" version = "0.2.168" @@ -2457,9 +2506,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" dependencies = [ "adler2", "simd-adler32", @@ -2476,6 +2525,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mutate_once" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b" + [[package]] name = "naga" version = "22.1.0" @@ -2917,6 +2972,31 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ouroboros" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944fa20996a25aded6b4795c6d63f10014a7a83f8be9828a11860b08c5fc4a67" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b0deead1528fd0e5947a8546a9642a9777c25f6e1e26f34c97b204bbb465bd" +dependencies = [ + "heck 0.4.1", + "itertools 0.12.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.90", +] + [[package]] name = "owned_ttf_parser" version = "0.25.0" @@ -3222,12 +3302,34 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "version_check", + "yansi", +] + [[package]] name = "profiling" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-xml" version = "0.36.2" @@ -3825,7 +3927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ "cfg-expr", - "heck", + "heck 0.5.0", "pkg-config", "toml", "version-compare", @@ -3899,6 +4001,17 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4107,9 +4220,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-bidi-mirroring" @@ -4432,6 +4545,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "wgpu" version = "22.1.0" @@ -5076,6 +5195,12 @@ version = "0.8.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea8b391c9a790b496184c29f7f93b9ed5b16abb306c05415b68bcc16e4d06432" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yansi-term" version = "0.1.2" @@ -5098,7 +5223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" dependencies = [ "async-broadcast 0.5.1", - "async-process 1.8.1", + "async-process", "async-recursion", "async-trait", "byteorder", @@ -5134,15 +5259,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb67eadba43784b6fb14857eba0d8fc518686d3ee537066eb6086dc318e2c8a1" dependencies = [ "async-broadcast 0.7.1", - "async-executor", - "async-fs", - "async-io 2.4.0", - "async-lock 3.4.0", - "async-process 2.3.0", "async-recursion", - "async-task", "async-trait", - "blocking", "enumflags2", "event-listener 5.3.1", "futures-core", @@ -5153,6 +5271,7 @@ dependencies = [ "serde", "serde_repr", "static_assertions", + "tokio", "tracing", "uds_windows", "windows-sys 0.59.0", @@ -5242,6 +5361,15 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zvariant" version = "3.15.2" diff --git a/Cargo.toml b/Cargo.toml index 629f572..e4e6122 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,13 +14,15 @@ iced = { git = "https://github.com/MalpenZibo/iced", features = [ "wgpu", "winit", "wayland", + "image", + "lazy", ] } chrono = "0.4" hyprland = "0.4.0-beta.2" serde = "1.0" sysinfo = "0.32" tokio = { version = "1", features = ["full"] } -zbus = "5" +zbus = { version = "5", default-features = false, features = ["tokio"] } libpulse-binding = { version = "2.28", features = ["pa_v15"] } inotify = "0.11" log = { version = "0.4", features = ["serde"] } @@ -29,6 +31,6 @@ serde_yaml = "0.9" pipewire = "0.8" wayland-client = "0.31.5" wayland-protocols = { version = "0.32.3", features = ["client", "unstable"] } -itertools = "0.13" +itertools = "0.14" hex_color = { version = "3.0", features = ["serde"] } anyhow = "1" diff --git a/src/app.rs b/src/app.rs index acb8493..63d80fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,14 +2,23 @@ use crate::{ centerbox, config::{self, Config}, get_log_spec, - menu::{menu_wrapper, MenuPosition}, + menu::{menu_wrapper, MenuSize, MenuType}, modules::{ - self, clipboard, clock::Clock, keyboard_layout::KeyboardLayout, - keyboard_submap::KeyboardSubmap, launcher, privacy::PrivacyMessage, settings::Settings, - system_info::SystemInfo, title::Title, updates::Updates, workspaces::Workspaces, + self, clipboard, + clock::Clock, + keyboard_layout::KeyboardLayout, + keyboard_submap::KeyboardSubmap, + launcher, + privacy::PrivacyMessage, + settings::Settings, + system_info::SystemInfo, + title::Title, + tray::{TrayMessage, TrayModule}, + updates::Updates, + workspaces::Workspaces, }, outputs::{HasOutput, Outputs}, - services::{privacy::PrivacyService, ReadOnlyService, ServiceEvent}, + services::{privacy::PrivacyService, tray::TrayService, ReadOnlyService, ServiceEvent}, style::ashell_theme, utils, HEIGHT, }; @@ -33,17 +42,12 @@ pub struct App { system_info: SystemInfo, keyboard_layout: KeyboardLayout, keyboard_submap: KeyboardSubmap, + tray: TrayModule, clock: Clock, privacy: Option, pub settings: Settings, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MenuType { - Updates, - Settings, -} - #[derive(Debug, Clone)] pub enum Message { None, @@ -57,6 +61,7 @@ pub enum Message { SystemInfo(modules::system_info::Message), KeyboardLayout(modules::keyboard_layout::Message), KeyboardSubmap(modules::keyboard_submap::Message), + Tray(modules::tray::TrayMessage), Clock(modules::clock::Message), Privacy(modules::privacy::PrivacyMessage), Settings(modules::settings::Message), @@ -78,6 +83,7 @@ impl App { system_info: SystemInfo::default(), keyboard_layout: KeyboardLayout::default(), keyboard_submap: KeyboardSubmap::default(), + tray: TrayModule::default(), clock: Clock::default(), privacy: None, settings: Settings::default(), @@ -167,6 +173,7 @@ impl App { self.keyboard_submap.update(message); Task::none() } + Message::Tray(msg) => self.tray.update(msg, &mut self.outputs), Message::Clock(message) => { self.clock.update(message); Task::none() @@ -271,6 +278,11 @@ impl App { .view(&self.config.keyboard.layout) .map(|l| l.map(Message::KeyboardLayout)), ) + .push_maybe( + self.tray + .view(id, &self.config.tray) + .map(|e| e.map(Message::Tray)), + ) .push( Row::new() .push( @@ -296,19 +308,28 @@ impl App { .align_items(Alignment::Center) .into() } - Some(HasOutput::Menu(menu_type)) => match menu_type { - Some(MenuType::Updates) => menu_wrapper( + Some(HasOutput::Menu(menu_info)) => match menu_info { + Some((MenuType::Updates, button_ui_ref)) => menu_wrapper( id, self.updates.menu_view(id).map(Message::Updates), - MenuPosition::Left, + MenuSize::Normal, + *button_ui_ref, + self.config.position, + ), + Some((MenuType::Tray(name), button_ui_ref)) => menu_wrapper( + id, + self.tray.menu_view(name).map(Message::Tray), + MenuSize::Normal, + *button_ui_ref, self.config.position, ), - Some(MenuType::Settings) => menu_wrapper( + Some((MenuType::Settings, button_ui_ref)) => menu_wrapper( id, self.settings .menu_view(id, &self.config.settings) .map(Message::Settings), - MenuPosition::Right, + MenuSize::Large, + *button_ui_ref, self.config.position, ), None => Row::new().into(), @@ -338,6 +359,7 @@ impl App { .subscription() .map(Message::KeyboardSubmap), ), + Some(TrayService::subscribe().map(|e| Message::Tray(TrayMessage::Event(e)))), Some(self.clock.subscription().map(Message::Clock)), Some( PrivacyService::subscribe().map(|e| Message::Privacy(PrivacyMessage::Event(e))), diff --git a/src/config.rs b/src/config.rs index 79d2a48..6008d31 100644 --- a/src/config.rs +++ b/src/config.rs @@ -118,6 +118,13 @@ impl Default for KeyboardModuleConfig { } } +#[derive(Deserialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct TrayModuleConfig { + #[serde(default)] + pub disabled: bool, +} + #[derive(Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct ClockModuleConfig { @@ -316,6 +323,8 @@ pub struct Config { #[serde(default)] pub keyboard: KeyboardModuleConfig, #[serde(default)] + pub tray: TrayModuleConfig, + #[serde(default)] pub clock: ClockModuleConfig, #[serde(default)] pub settings: SettingsModuleConfig, @@ -343,6 +352,7 @@ impl Default for Config { updates: None, system: SystemModuleConfig::default(), keyboard: KeyboardModuleConfig::default(), + tray: TrayModuleConfig::default(), clock: ClockModuleConfig::default(), settings: SettingsModuleConfig::default(), appearance: Appearance::default(), diff --git a/src/main.rs b/src/main.rs index 41b680e..3928396 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod menu; mod modules; mod outputs; mod password_dialog; +mod position_button; mod services; mod style; mod utils; diff --git a/src/menu.rs b/src/menu.rs index 247e263..e926fcf 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -1,5 +1,6 @@ -use crate::app::{self, MenuType}; +use crate::app::{self}; use crate::config::Position; +use crate::position_button::ButtonUIRef; use iced::alignment::{Horizontal, Vertical}; use iced::platform_specific::shell::commands::layer_surface::{ set_keyboard_interactivity, set_layer, KeyboardInteractivity, Layer, @@ -10,22 +11,33 @@ use iced::window::Id; use iced::{self, widget::container, Element, Task, Theme}; use iced::{Border, Length, Padding}; -#[derive(Debug, Clone)] +#[derive(Eq, PartialEq, Clone, Debug)] +pub enum MenuType { + Updates, + Settings, + Tray(String), +} + +#[derive(Clone, Debug)] pub struct Menu { pub id: Id, - pub menu_type: Option, + pub menu_info: Option<(MenuType, ButtonUIRef)>, } impl Menu { pub fn new(id: Id) -> Self { Self { id, - menu_type: None, + menu_info: None, } } - pub fn open(&mut self, menu_type: MenuType) -> Task { - self.menu_type.replace(menu_type); + pub fn open( + &mut self, + menu_type: MenuType, + button_ui_ref: ButtonUIRef, + ) -> Task { + self.menu_info.replace((menu_type, button_ui_ref)); Task::batch(vec![ set_layer(self.id, Layer::Overlay), @@ -34,8 +46,8 @@ impl Menu { } pub fn close(&mut self) -> Task { - if self.menu_type.is_some() { - self.menu_type.take(); + if self.menu_info.is_some() { + self.menu_info.take(); Task::batch(vec![ set_layer(self.id, Layer::Background), @@ -46,20 +58,25 @@ impl Menu { } } - pub fn toggle(&mut self, menu_type: MenuType) -> Task { - match self.menu_type.as_mut() { - None => self.open(menu_type), - Some(current) if *current == menu_type => self.close(), - Some(current) => { - *current = menu_type; + pub fn toggle( + &mut self, + menu_type: MenuType, + button_ui_ref: ButtonUIRef, + ) -> Task { + match self.menu_info.as_mut() { + None => self.open(menu_type, button_ui_ref), + Some((current_type, _)) if *current_type == menu_type => self.close(), + Some((current_type, current_button_ui_ref)) => { + *current_type = menu_type; + *current_button_ui_ref = button_ui_ref; Task::none() } } } pub fn close_if(&mut self, menu_type: MenuType) -> Task { - if let Some(current) = self.menu_type.as_ref() { - if *current == menu_type { + if let Some((current_type, _)) = self.menu_info.as_ref() { + if *current_type == menu_type { self.close() } else { Task::none() @@ -78,15 +95,25 @@ impl Menu { } } -pub enum MenuPosition { - Left, - Right, +pub enum MenuSize { + Normal, + Large, +} + +impl MenuSize { + fn size(&self) -> f32 { + match self { + MenuSize::Normal => 250., + MenuSize::Large => 350., + } + } } pub fn menu_wrapper( id: Id, content: Element, - position: MenuPosition, + menu_size: MenuSize, + button_ui_ref: ButtonUIRef, bar_position: Position, ) -> Element { mouse_area( @@ -95,6 +122,8 @@ pub fn menu_wrapper( container(content) .height(Length::Shrink) .width(Length::Shrink) + .max_width(menu_size.size()) + .padding(16) .style(|theme: &Theme| Style { background: Some(theme.palette().background.into()), border: Border { @@ -111,11 +140,15 @@ pub fn menu_wrapper( Position::Top => Vertical::Top, Position::Bottom => Vertical::Bottom, }) - .align_x(match position { - MenuPosition::Left => Horizontal::Left, - MenuPosition::Right => Horizontal::Right, + .align_x(Horizontal::Left) + .padding({ + let size = menu_size.size(); + + Padding::new(0.).left(f32::min( + f32::max(button_ui_ref.position.x - size / 2., 8.), + button_ui_ref.viewport.0 - size - 8., + )) }) - .padding(Padding::new(8.).top(0)) .width(Length::Fill) .height(Length::Fill), ) diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 24f1b72..7b52ec4 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -7,5 +7,6 @@ pub mod privacy; pub mod settings; pub mod system_info; pub mod title; +pub mod tray; pub mod updates; pub mod workspaces; diff --git a/src/modules/settings/mod.rs b/src/modules/settings/mod.rs index 8b5ced9..cc5ed23 100644 --- a/src/modules/settings/mod.rs +++ b/src/modules/settings/mod.rs @@ -2,12 +2,13 @@ use self::{ audio::AudioMessage, bluetooth::BluetoothMessage, network::NetworkMessage, power::PowerMessage, }; use crate::{ - app::MenuType, components::icons::{icon, Icons}, config::SettingsModuleConfig, + menu::MenuType, modules::settings::power::power_menu, outputs::Outputs, password_dialog, + position_button::{position_button, ButtonUIRef}, services::{ audio::{AudioCommand, AudioService}, bluetooth::{BluetoothCommand, BluetoothService, BluetoothState}, @@ -69,7 +70,7 @@ impl Default for Settings { #[derive(Debug, Clone)] pub enum Message { - ToggleMenu(Id), + ToggleMenu(Id, ButtonUIRef), UPower(UPowerMessage), Network(NetworkMessage), Bluetooth(BluetoothMessage), @@ -100,10 +101,10 @@ impl Settings { outputs: &mut Outputs, ) -> Task { match message { - Message::ToggleMenu(id) => { + Message::ToggleMenu(id, button_ui_ref) => { self.sub_menu = None; self.password_dialog = None; - outputs.toggle_menu(id, MenuType::Settings) + outputs.toggle_menu(id, MenuType::Settings, button_ui_ref) } Message::Audio(msg) => match msg { AudioMessage::Event(event) => match event { @@ -448,7 +449,7 @@ impl Settings { } pub fn view(&self, id: Id) -> Element { - button( + position_button( Row::new() .push_maybe( self.idle_inhibitor @@ -489,7 +490,7 @@ impl Settings { ) .style(HeaderButtonStyle::Right.into_style()) .padding([2, 8]) - .on_press(Message::ToggleMenu(id)) + .on_press(move |button_ui_ref| Message::ToggleMenu(id, button_ui_ref)) .into() } @@ -620,8 +621,6 @@ impl Settings { .push_maybe(self.brightness.as_ref().map(|b| b.brightness_slider())) .push(quick_settings) .spacing(16) - .padding(16) - .max_width(350.) .into() } } diff --git a/src/modules/tray.rs b/src/modules/tray.rs new file mode 100644 index 0000000..f2ca7eb --- /dev/null +++ b/src/modules/tray.rs @@ -0,0 +1,207 @@ +use crate::{ + components::icons::{icon, Icons}, + config::TrayModuleConfig, + menu::MenuType, + outputs::Outputs, + position_button::{position_button, ButtonUIRef}, + services::{ + tray::{ + dbus::{Layout, LayoutProps}, + TrayCommand, TrayService, + }, + ReadOnlyService, Service, ServiceEvent, + }, + style::{header_pills, GhostButtonStyle}, +}; +use iced::{ + widget::{button, container, horizontal_rule, row, text, toggler, Column, Image, Row}, + window::Id, + Alignment, Element, Length, Task, +}; +use log::debug; + +#[derive(Debug, Clone)] +pub enum TrayMessage { + Event(ServiceEvent), + OpenMenu(Id, String, ButtonUIRef), + ToggleSubmenu(i32), + MenuSelected(String, i32), +} + +#[derive(Debug, Default, Clone)] +pub struct TrayModule { + service: Option, + submenus: Vec, +} + +impl TrayModule { + pub fn update( + &mut self, + message: TrayMessage, + outputs: &mut Outputs, + ) -> Task { + match message { + TrayMessage::Event(event) => match event { + ServiceEvent::Init(service) => { + self.service = Some(service); + Task::none() + } + ServiceEvent::Update(data) => { + if let Some(service) = self.service.as_mut() { + service.update(data); + } + Task::none() + } + ServiceEvent::Error(_) => Task::none(), + }, + TrayMessage::OpenMenu(id, name, button_ui_ref) => { + if let Some(_tray) = self + .service + .as_ref() + .and_then(|t| t.iter().find(|t| t.name == name)) + { + self.submenus.clear(); + outputs.toggle_menu(id, MenuType::Tray(name), button_ui_ref) + } else { + Task::none() + } + } + TrayMessage::ToggleSubmenu(index) => { + if self.submenus.contains(&index) { + self.submenus.retain(|i| i != &index); + } else { + self.submenus.push(index); + } + Task::none() + } + TrayMessage::MenuSelected(name, id) => { + if let Some(service) = self.service.as_mut() { + debug!("Tray menu click: {}", id); + service + .command(TrayCommand::MenuSelected(name, id)) + .map(|event| crate::app::Message::Tray(TrayMessage::Event(event))) + } else { + Task::none() + } + } + } + } + + pub fn view(&self, id: Id, config: &TrayModuleConfig) -> Option> { + self.service + .as_ref() + .filter(|s| !config.disabled && s.data.len() > 0) + .map(|service| { + container( + Row::with_children( + service + .data + .iter() + .map(|item| { + position_button(if let Some(pixmap) = &item.icon_pixmap { + Into::>::into( + Image::new(pixmap.clone()).height(Length::Fixed(14.)), + ) + } else { + icon(Icons::Point).into() + }) + .on_press(move |button_ui_ref| { + TrayMessage::OpenMenu(id, item.name.to_owned(), button_ui_ref) + }) + .padding([2, 2]) + .style(GhostButtonStyle.into_style()) + .into() + }) + .collect::>(), + ) + .align_y(Alignment::Center) + .spacing(8), + ) + .padding([2, 8]) + .style(header_pills) + .into() + }) + } + + pub fn menu_view(&self, name: &'_ str) -> Element { + if let Some(item) = self + .service + .as_ref() + .and_then(|service| service.data.iter().find(|item| item.name == name)) + { + Column::with_children(item.menu.2.iter().map(|menu| self.menu_voice(name, menu))) + .spacing(8) + .into() + } else { + Row::new().into() + } + } + + fn menu_voice(&self, name: &str, layout: &Layout) -> Element { + match &layout.1 { + LayoutProps { + label: Some(label), + toggle_type: Some(toggle_type), + toggle_state: Some(state), + .. + } if toggle_type == "checkmark" => toggler(*state > 0) + .label(label.replace("_", "").to_owned()) + .on_toggle({ + let name = name.to_owned(); + let id = layout.0; + + move |_| TrayMessage::MenuSelected(name.to_owned(), id) + }) + .width(Length::Fill) + .into(), + LayoutProps { + children_display: Some(display), + label: Some(label), + .. + } if display == "submenu" => { + let is_open = self.submenus.contains(&layout.0); + Column::new() + .push( + button(row!( + text(label.to_owned()).width(Length::Fill), + icon(if is_open { + Icons::MenuOpen + } else { + Icons::MenuClosed + }) + )) + .style(GhostButtonStyle.into_style()) + .padding([8, 8]) + .on_press(TrayMessage::ToggleSubmenu(layout.0)) + .width(Length::Fill), + ) + .push_maybe(if is_open { + Some( + Column::with_children( + layout + .2 + .iter() + .map(|menu| self.menu_voice(name, menu)) + .collect::>(), + ) + .padding([0, 0, 0, 16]) + .spacing(4), + ) + } else { + None + }) + .into() + } + LayoutProps { + label: Some(label), .. + } => button(text(label.replace("_", ""))) + .style(GhostButtonStyle.into_style()) + .on_press(TrayMessage::MenuSelected(name.to_owned(), layout.0)) + .width(Length::Fill) + .padding([8, 8]) + .into(), + LayoutProps { type_: Some(t), .. } if t == "separator" => horizontal_rule(1).into(), + _ => Row::new().into(), + } + } +} diff --git a/src/modules/updates.rs b/src/modules/updates.rs index a3ceceb..7a362ef 100644 --- a/src/modules/updates.rs +++ b/src/modules/updates.rs @@ -1,8 +1,10 @@ use crate::{ - app::{self, MenuType}, + app::{self}, components::icons::{icon, Icons}, config::UpdatesModuleConfig, + menu::MenuType, outputs::Outputs, + position_button::{position_button, ButtonUIRef}, style::{GhostButtonStyle, HeaderButtonStyle}, }; use iced::{ @@ -71,7 +73,7 @@ async fn update(update_cmd: &str) { #[derive(Debug, Clone)] pub enum Message { - ToggleMenu(Id), + ToggleMenu(Id, ButtonUIRef), UpdatesCheckCompleted(Vec), UpdateFinished, ToggleUpdatesList, @@ -107,9 +109,9 @@ impl Updates { Task::none() } - Message::ToggleMenu(id) => { + Message::ToggleMenu(id, button_ui_ref) => { self.is_updates_list_open = false; - outputs.toggle_menu(id, MenuType::Updates) + outputs.toggle_menu(id, MenuType::Updates, button_ui_ref) } Message::UpdateFinished => { self.updates.clear(); @@ -164,10 +166,10 @@ impl Updates { content = content.push(text(self.updates.len())); } - button(content) + position_button(content) .padding([2, 7]) .style(HeaderButtonStyle::Full.into_style()) - .on_press(Message::ToggleMenu(id)) + .on_press(move |button_ui_ref| Message::ToggleMenu(id, button_ui_ref)) .into() } @@ -255,8 +257,6 @@ impl Updates { .width(Length::Fill), ) .spacing(4) - .padding(16) - .width(250) .into() } diff --git a/src/outputs.rs b/src/outputs.rs index 097cb83..2126597 100644 --- a/src/outputs.rs +++ b/src/outputs.rs @@ -9,7 +9,12 @@ use iced::{ use log::debug; use wayland_client::protocol::wl_output::WlOutput; -use crate::{app::MenuType, config::Position, menu::Menu, HEIGHT}; +use crate::{ + config::Position, + menu::{Menu, MenuType}, + position_button::ButtonUIRef, + HEIGHT, +}; static FALLBACK_LAYER: &str = "fallback"; @@ -23,9 +28,9 @@ struct ShellInfo { #[derive(Debug, Clone)] pub struct Outputs(Vec<(String, Option, Option)>); -pub enum HasOutput { +pub enum HasOutput<'a> { Main, - Menu(Option), + Menu(Option<&'a (MenuType, ButtonUIRef)>), } impl Outputs { @@ -92,7 +97,7 @@ impl Outputs { if info.id == id { Some(HasOutput::Main) } else if info.menu.id == id { - Some(HasOutput::Menu(info.menu.menu_type)) + Some(HasOutput::Menu(info.menu.menu_info.as_ref())) } else { None } @@ -298,12 +303,17 @@ impl Outputs { Task::batch(tasks) } - pub fn toggle_menu(&mut self, id: Id, menu_type: MenuType) -> Task { + pub fn toggle_menu( + &mut self, + id: Id, + menu_type: MenuType, + button_ui_ref: ButtonUIRef, + ) -> Task { if let Some((_, Some(shell_info), _)) = self.0.iter_mut().find(|(_, shell_info, _)| { shell_info.as_ref().map(|shell_info| shell_info.id) == Some(id) || shell_info.as_ref().map(|shell_info| shell_info.menu.id) == Some(id) }) { - let toggle_task = shell_info.menu.toggle(menu_type); + let toggle_task = shell_info.menu.toggle(menu_type, button_ui_ref); let mut tasks = self .0 .iter_mut() diff --git a/src/position_button.rs b/src/position_button.rs new file mode 100644 index 0000000..01090a2 --- /dev/null +++ b/src/position_button.rs @@ -0,0 +1,400 @@ +use iced::{ + core::{ + event::{self, Event}, + keyboard, layout, mouse, overlay, renderer, touch, + widget::{tree, Operation, Tree}, + Clipboard, Layout, Shell, Widget, + }, + id::Id, + widget::button::{Catalog, Status, Style, StyleFn}, + Background, Color, Element, Length, Padding, Point, Rectangle, Size, Vector, +}; + +#[derive(Debug, Clone, Copy)] +pub struct ButtonUIRef { + pub position: Point, + pub viewport: (f32, f32), +} + +pub struct PositionButton<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> +where + Renderer: iced::core::Renderer, + Theme: Catalog, +{ + content: Element<'a, Message, Theme, Renderer>, + on_press: Option Message + 'a>>, + id: Id, + width: Length, + height: Length, + padding: Padding, + clip: bool, + class: Theme::Class<'a>, +} + +impl<'a, Message, Theme, Renderer> PositionButton<'a, Message, Theme, Renderer> +where + Renderer: iced::core::Renderer, + Theme: Catalog, +{ + pub fn new(content: impl Into>) -> Self { + let content = content.into(); + let size = content.as_widget().size_hint(); + + PositionButton { + content, + id: Id::unique(), + on_press: None, + width: size.width.fluid(), + height: size.height.fluid(), + padding: DEFAULT_PADDING, + clip: false, + class: Theme::default(), + } + } + + /// Sets the width of the [`Button`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Button`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the [`Padding`] of the [`Button`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed. + /// + /// Unless `on_press` is called, the [`Button`] will be disabled. + pub fn on_press(mut self, on_press: impl Fn(ButtonUIRef) -> Message + 'a) -> Self { + self.on_press = Some(Box::new(on_press)); + self + } + + /// Sets whether the contents of the [`Button`] should be clipped on + /// overflow. + pub fn clip(mut self, clip: bool) -> Self { + self.clip = clip; + self + } + + /// Sets the style of the [`Button`]. + #[must_use] + pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self + where + Theme::Class<'a>: From>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + /// Sets the [`Id`] of the [`Button`]. + pub fn id(mut self, id: Id) -> Self { + self.id = id; + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct State { + is_hovered: bool, + is_pressed: bool, + is_focused: bool, +} + +impl<'a, Message, Theme, Renderer> Widget + for PositionButton<'a, Message, Theme, Renderer> +where + Message: 'a + Clone, + Renderer: 'a + iced::core::Renderer, + Theme: Catalog, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&mut self, tree: &mut Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::padded(limits, self.width, self.height, self.padding, |limits| { + self.content + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + }) + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(None, layout.bounds(), &mut |operation| { + self.content.as_widget().operate( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + operation, + ); + }); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + if let event::Status::Captured = self.content.as_widget_mut().on_event( + &mut tree.children[0], + event.clone(), + layout.children().next().unwrap(), + cursor, + renderer, + clipboard, + shell, + viewport, + ) { + return event::Status::Captured; + } + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if self.on_press.is_some() { + let bounds = layout.bounds(); + + if cursor.is_over(bounds) { + let state = tree.state.downcast_mut::(); + + state.is_pressed = true; + + return event::Status::Captured; + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) => { + if let Some(on_press) = self.on_press.as_ref() { + let state = tree.state.downcast_mut::(); + + if state.is_pressed { + state.is_pressed = false; + + let bounds = layout.bounds(); + + if cursor.is_over(bounds) { + let ui_data = ButtonUIRef { + position: Point::new( + layout.bounds().width / 2. + layout.position().x, + layout.bounds().height / 2. + layout.position().y, + ), + viewport: (viewport.width, viewport.height), + }; + shell.publish(on_press(ui_data)); + } + + return event::Status::Captured; + } + } + } + Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => { + if let Some(on_press) = self.on_press.as_ref() { + let state = tree.state.downcast_mut::(); + if state.is_focused + && matches!(key, keyboard::Key::Named(keyboard::key::Named::Enter)) + { + state.is_pressed = true; + let ui_data = ButtonUIRef { + position: Point::new( + layout.bounds().width / 2. + layout.position().x, + layout.bounds().height / 2. + layout.position().y, + ), + viewport: (viewport.width, viewport.height), + }; + shell.publish(on_press(ui_data)); + return event::Status::Captured; + } + } + } + Event::Touch(touch::Event::FingerLost { .. }) + | Event::Mouse(mouse::Event::CursorLeft) => { + let state = tree.state.downcast_mut::(); + state.is_hovered = false; + state.is_pressed = false; + } + _ => {} + } + + event::Status::Ignored + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let content_layout = layout.children().next().unwrap(); + let is_mouse_over = cursor.is_over(bounds); + + let status = if self.on_press.is_none() { + Status::Disabled + } else if is_mouse_over { + let state = tree.state.downcast_ref::(); + + if state.is_pressed { + Status::Pressed + } else { + Status::Hovered + } + } else { + Status::Active + }; + + let style = theme.style(&self.class, status); + + if style.background.is_some() || style.border.width > 0.0 || style.shadow.color.a > 0.0 { + renderer.fill_quad( + renderer::Quad { + bounds, + border: style.border, + shadow: style.shadow, + }, + style + .background + .unwrap_or(Background::Color(Color::TRANSPARENT)), + ); + } + + let viewport = if self.clip { + bounds.intersection(viewport).unwrap_or(*viewport) + } else { + *viewport + }; + + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + &renderer::Style { + text_color: style.text_color, + icon_color: style.icon_color.unwrap_or(renderer_style.icon_color), + scale_factor: renderer_style.scale_factor, + }, + content_layout, + cursor, + &viewport, + ); + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let is_mouse_over = cursor.is_over(layout.bounds()); + + if is_mouse_over && self.on_press.is_some() { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + translation, + ) + } + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn set_id(&mut self, id: Id) { + self.id = id; + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'a, + Theme: Catalog + 'a, + Renderer: iced::core::Renderer + 'a, +{ + fn from(button: PositionButton<'a, Message, Theme, Renderer>) -> Self { + Self::new(button) + } +} + +pub fn position_button<'a, Message, Theme, Renderer>( + content: impl Into>, +) -> PositionButton<'a, Message, Theme, Renderer> +where + Theme: Catalog + 'a, + Renderer: iced::core::Renderer, +{ + PositionButton::new(content) +} + +/// The default [`Padding`] of a [`Button`]. +pub(crate) const DEFAULT_PADDING: Padding = Padding { + top: 5.0, + bottom: 5.0, + right: 10.0, + left: 10.0, +}; diff --git a/src/services/mod.rs b/src/services/mod.rs index ff44778..40b3ba2 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod brightness; pub mod idle_inhibitor; pub mod network; pub mod privacy; +pub mod tray; pub mod upower; #[derive(Debug, Clone)] diff --git a/src/services/network/dbus.rs b/src/services/network/dbus.rs index 96e9d02..0ec025a 100644 --- a/src/services/network/dbus.rs +++ b/src/services/network/dbus.rs @@ -235,7 +235,6 @@ impl<'a> NetworkDbus<'a> { let mut scan_changed = wireless_device.receive_last_scan_changed().await; if let Some(t) = scan_changed.next().await { if let Ok(-1) = t.get().await { - eprintln!("scan errored"); return Ok(Default::default()); } } diff --git a/src/services/tray/dbus.rs b/src/services/tray/dbus.rs new file mode 100644 index 0000000..12b0882 --- /dev/null +++ b/src/services/tray/dbus.rs @@ -0,0 +1,213 @@ +use iced::futures::StreamExt; +use log::{info, warn}; +use zbus::{ + fdo::{DBusProxy, RequestNameFlags, RequestNameReply}, + interface, + message::Header, + names::{BusName, UniqueName, WellKnownName}, + object_server::SignalEmitter, + proxy, + zvariant::{self, OwnedObjectPath, OwnedValue, Type}, + Connection, Result, +}; + +const NAME: WellKnownName = + WellKnownName::from_static_str_unchecked("org.kde.StatusNotifierWatcher"); +const OBJECT_PATH: &str = "/StatusNotifierWatcher"; + +#[derive(Debug, Default)] +pub struct StatusNotifierWatcher { + items: Vec<(UniqueName<'static>, String)>, +} + +impl StatusNotifierWatcher { + pub async fn start_server() -> anyhow::Result { + let connection = zbus::connection::Connection::session().await?; + connection + .object_server() + .at(OBJECT_PATH, StatusNotifierWatcher::default()) + .await?; + let interface = connection + .object_server() + .interface::<_, StatusNotifierWatcher>(OBJECT_PATH) + .await?; + + let dbus_proxy = DBusProxy::new(&connection).await?; + let mut name_owner_changed_stream = dbus_proxy.receive_name_owner_changed().await?; + + let flags = RequestNameFlags::AllowReplacement.into(); + if dbus_proxy.request_name(NAME, flags).await? == RequestNameReply::InQueue { + warn!("Bus name '{}' already owned", NAME); + } + + let internal_connection = connection.clone(); + tokio::spawn(async move { + let mut have_bus_name = false; + let unique_name = internal_connection.unique_name().map(|x| x.as_ref()); + while let Some(evt) = name_owner_changed_stream.next().await { + let args = match evt.args() { + Ok(args) => args, + Err(_) => { + continue; + } + }; + if args.name.as_ref() == NAME { + if args.new_owner.as_ref() == unique_name.as_ref() { + info!("Acquired bus name: {}", NAME); + have_bus_name = true; + } else if have_bus_name { + info!("Lost bus name: {}", NAME); + have_bus_name = false; + } + } else if let BusName::Unique(name) = &args.name { + let mut interface = interface.get_mut().await; + if let Some(idx) = interface + .items + .iter() + .position(|(unique_name, _)| unique_name == name) + { + let emitter = + SignalEmitter::new(&internal_connection, OBJECT_PATH).unwrap(); + let service = interface.items.remove(idx).1; + StatusNotifierWatcher::status_notifier_item_unregistered( + &emitter, &service, + ) + .await + .unwrap(); + } + } + } + }); + + Ok(connection) + } +} + +#[interface( + name = "org.kde.StatusNotifierWatcher", + proxy( + gen_blocking = false, + default_service = "org.kde.StatusNotifierWatcher", + default_path = "/StatusNotifierWatcher", + ) +)] +impl StatusNotifierWatcher { + async fn register_status_notifier_item( + &mut self, + service: &str, + #[zbus(header)] header: Header<'_>, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) { + let sender = header.sender().unwrap(); + let service = if service.starts_with('/') { + format!("{}{}", sender, service) + } else { + service.to_string() + }; + Self::status_notifier_item_registered(&emitter, &service) + .await + .unwrap(); + + self.items.push((sender.to_owned(), service)); + } + + fn register_status_notifier_host(&mut self, _service: &str) {} + + #[zbus(property)] + fn registered_status_notifier_items(&self) -> Vec { + self.items.iter().map(|(_, x)| x.clone()).collect() + } + + #[zbus(property)] + fn is_status_notifier_host_registered(&self) -> bool { + true + } + + #[zbus(property)] + fn protocol_version(&self) -> i32 { + 0 + } + + #[zbus(signal)] + async fn status_notifier_item_registered( + emitter: &SignalEmitter<'_>, + service: &str, + ) -> Result<()>; + + #[zbus(signal)] + async fn status_notifier_item_unregistered( + emitter: &SignalEmitter<'_>, + service: &str, + ) -> Result<()>; + + #[zbus(signal)] + async fn status_notifier_host_registered(emitter: &SignalEmitter<'_>) -> Result<()>; + + #[zbus(signal)] + async fn status_notifier_host_unregistered(emitter: &SignalEmitter<'_>) -> Result<()>; +} + +#[derive(Clone, Debug, zvariant::Value)] +pub struct Icon { + pub width: i32, + pub height: i32, + pub bytes: Vec, +} + +#[proxy(interface = "org.kde.StatusNotifierItem")] +pub trait StatusNotifierItem { + #[zbus(property)] + fn icon_name(&self) -> zbus::Result; + + #[zbus(property)] + fn icon_pixmap(&self) -> zbus::Result>; + + #[zbus(property)] + fn menu(&self) -> zbus::Result; +} + +#[derive(Clone, Debug, Type)] +#[zvariant(signature = "(ia{sv}av)")] +pub struct Layout(pub i32, pub LayoutProps, pub Vec); + +impl<'a> serde::Deserialize<'a> for Layout { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + let (id, props, children) = + <(i32, LayoutProps, Vec<(zvariant::Signature, Self)>)>::deserialize(deserializer)?; + Ok(Self(id, props, children.into_iter().map(|x| x.1).collect())) + } +} + +#[derive(Clone, Debug, Type, zvariant::DeserializeDict)] +#[zvariant(signature = "dict")] +pub struct LayoutProps { + #[zvariant(rename = "children-display")] + pub children_display: Option, + pub label: Option, + #[zvariant(rename = "type")] + pub type_: Option, + #[zvariant(rename = "toggle-type")] + pub toggle_type: Option, + #[zvariant(rename = "toggle-state")] + pub toggle_state: Option, +} + +#[proxy(interface = "com.canonical.dbusmenu")] +pub trait DBusMenu { + fn get_layout( + &self, + parent_id: i32, + recursion_depth: i32, + property_names: &[&str], + ) -> zbus::Result<(u32, Layout)>; + + fn event(&self, id: i32, event_id: &str, data: &OwnedValue, timestamp: u32) + -> zbus::Result<()>; + + fn about_to_show(&self, id: i32) -> zbus::Result; + + #[zbus(signal)] + fn layout_updated(&self, revision: u32, parent: i32) -> zbus::Result<()>; +} diff --git a/src/services/tray/mod.rs b/src/services/tray/mod.rs new file mode 100644 index 0000000..2ca273b --- /dev/null +++ b/src/services/tray/mod.rs @@ -0,0 +1,421 @@ +use super::{ReadOnlyService, Service, ServiceEvent}; +use dbus::{ + DBusMenuProxy, Layout, StatusNotifierItemProxy, StatusNotifierWatcher, + StatusNotifierWatcherProxy, +}; +use iced::{ + futures::{ + channel::mpsc::Sender, + stream::{pending, select_all}, + stream_select, SinkExt, Stream, StreamExt, + }, + stream::channel, + widget::image::Handle, + Subscription, Task, +}; +use log::{debug, error, info, trace}; +use std::{any::TypeId, ops::Deref}; + +pub mod dbus; + +#[derive(Debug, Clone)] +pub enum TrayEvent { + Registered(StatusNotifierItem), + IconChanged(String, Handle), + MenuLayoutChanged(String, Layout), + Unregistered(String), + None, +} + +#[derive(Debug, Clone)] +pub struct StatusNotifierItem { + pub name: String, + pub icon_pixmap: Option, + pub menu: Layout, + item_proxy: StatusNotifierItemProxy<'static>, + menu_proxy: DBusMenuProxy<'static>, +} + +impl StatusNotifierItem { + pub async fn new(conn: &zbus::Connection, name: String) -> anyhow::Result { + let (dest, path) = if let Some(idx) = name.find('/') { + (&name[..idx], &name[idx..]) + } else { + (name.as_ref(), "/StatusNotifierItem") + }; + + let item_proxy = StatusNotifierItemProxy::builder(conn) + .destination(dest.to_owned())? + .path(path.to_owned())? + .build() + .await?; + + let icon_pixmap = item_proxy + .icon_pixmap() + .await + .unwrap_or_default() + .into_iter() + .max_by_key(|i| { + trace!("tray icon w {}, h {}", i.width, i.height); + (i.width, i.height) + }) + .map(|mut i| { + // Convert ARGB to RGBA + for pixel in i.bytes.chunks_exact_mut(4) { + pixel.rotate_left(1); + } + Handle::from_rgba(i.width as u32, i.height as u32, i.bytes) + }); + + let menu_path = item_proxy.menu().await?; + let menu_proxy = dbus::DBusMenuProxy::builder(conn) + .destination(dest.to_owned())? + .path(menu_path.to_owned())? + .build() + .await?; + + let (_, menu) = menu_proxy.get_layout(0, -1, &[]).await?; + + Ok(Self { + name, + icon_pixmap, + menu, + item_proxy, + menu_proxy, + }) + } +} + +#[derive(Debug, Default, Clone)] +pub struct TrayData(Vec); + +impl Deref for TrayData { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct TrayService { + pub data: TrayData, + _conn: zbus::Connection, +} + +impl Deref for TrayService { + type Target = TrayData; + + fn deref(&self) -> &Self::Target { + &self.data + } +} + +enum State { + Init, + Active(zbus::Connection), + Error, +} + +impl TrayService { + async fn initialize_data(conn: &zbus::Connection) -> anyhow::Result { + debug!("initializing tray data"); + let proxy = StatusNotifierWatcherProxy::new(conn).await?; + + let items = proxy.registered_status_notifier_items().await?; + + let mut status_items = Vec::with_capacity(items.len()); + for item in items { + let item = StatusNotifierItem::new(conn, item.to_string()).await?; + status_items.push(item); + } + + debug!("created items: {:?}", status_items); + + Ok(TrayData(status_items)) + } + + async fn events(conn: &zbus::Connection) -> anyhow::Result> { + let watcher = StatusNotifierWatcherProxy::new(conn).await?; + + let registered = watcher + .receive_status_notifier_item_registered() + .await? + .filter_map({ + let conn = conn.clone(); + move |e| { + let conn = conn.clone(); + async move { + debug!("registered {:?}", e); + if let Ok(args) = e.args() { + let item = + StatusNotifierItem::new(&conn, args.service.to_string()).await; + + item.map(TrayEvent::Registered).ok() + } else { + None + } + } + } + }) + .boxed(); + let unregistered = watcher + .receive_status_notifier_item_unregistered() + .await? + .filter_map(|e| async move { + debug!("unregistered {:?}", e); + + if let Ok(args) = e.args() { + Some(TrayEvent::Unregistered(args.service.to_string())) + } else { + None + } + }) + .boxed(); + + let items = watcher.registered_status_notifier_items().await?; + let mut icon_pixel_change = Vec::with_capacity(items.len()); + let mut menu_layout_change = Vec::with_capacity(items.len()); + + for name in items { + let item = StatusNotifierItem::new(conn, name.to_string()).await?; + + icon_pixel_change.push( + item.item_proxy + .receive_icon_pixmap_changed() + .await + .filter_map({ + let name = name.clone(); + move |icon| { + let name = name.clone(); + async move { + icon.get().await.ok().and_then(|icon| { + icon.into_iter() + .max_by_key(|i| { + trace!("tray icon w {}, h {}", i.width, i.height); + (i.width, i.height) + }) + .map(|mut i| { + // Convert ARGB to RGBA + for pixel in i.bytes.chunks_exact_mut(4) { + pixel.rotate_left(1); + } + TrayEvent::IconChanged( + name.to_owned(), + Handle::from_rgba( + i.width as u32, + i.height as u32, + i.bytes, + ), + ) + }) + }) + } + } + }) + .boxed(), + ); + + let layout_updated = item.menu_proxy.receive_layout_updated().await; + if let Ok(layout_updated) = layout_updated { + menu_layout_change.push(layout_updated.filter_map({ + let name = name.clone(); + let menu_proxy = item.menu_proxy.clone(); + debug!("menu layout changed"); + move |_| { + let name = name.clone(); + let menu_proxy = menu_proxy.clone(); + async move { + menu_proxy + .get_layout(0, -1, &[]) + .await + .ok() + .map(|(_, layout)| { + TrayEvent::MenuLayoutChanged(name.to_owned(), layout) + }) + } + } + })); + } + } + + Ok(stream_select!(registered, unregistered, select_all(icon_pixel_change)).boxed()) + } + + async fn start_listening(state: State, output: &mut Sender>) -> State { + match state { + State::Init => match StatusNotifierWatcher::start_server().await { + Ok(conn) => { + let data = TrayService::initialize_data(&conn).await; + + match data { + Ok(data) => { + info!("Tray service initialized"); + + let _ = output + .send(ServiceEvent::Init(TrayService { + data, + _conn: conn.clone(), + })) + .await; + + State::Active(conn) + } + Err(err) => { + error!("Failed to initialize tray service: {}", err); + + State::Error + } + } + } + Err(err) => { + error!("Failed to connect to system bus: {}", err); + + State::Error + } + }, + State::Active(conn) => { + info!("Listening for tray events"); + + match TrayService::events(&conn).await { + Ok(mut events) => { + while let Some(event) = events.next().await { + debug!("tray data {:?}", event); + + let reload_events = matches!(event, TrayEvent::Registered(_)); + + let _ = output.send(ServiceEvent::Update(event)).await; + + if reload_events { + break; + } + } + + State::Active(conn) + } + Err(err) => { + error!("Failed to listen for tray events: {}", err); + State::Error + } + } + } + State::Error => { + error!("Tray service error"); + + let _ = pending::().next().await; + State::Error + } + } + } + + async fn menu_voice_selected( + menu_proxy: &DBusMenuProxy<'_>, + id: i32, + ) -> anyhow::Result { + let value = zbus::zvariant::Value::I32(32).try_to_owned()?; + menu_proxy + .event( + id, + "clicked", + &value, + chrono::offset::Local::now().timestamp_subsec_micros(), + ) + .await?; + + let (_, layout) = menu_proxy.get_layout(0, -1, &[]).await?; + + Ok(layout) + } +} + +impl ReadOnlyService for TrayService { + type UpdateEvent = TrayEvent; + type Error = (); + + fn update(&mut self, event: Self::UpdateEvent) { + match event { + TrayEvent::Registered(new_item) => { + if let Some(existing_item) = self + .data + .0 + .iter_mut() + .find(|item| item.name == new_item.name) + { + *existing_item = new_item; + } else { + self.data.0.push(new_item); + } + } + TrayEvent::IconChanged(name, handle) => { + if let Some(item) = self.data.0.iter_mut().find(|item| item.name == name) { + item.icon_pixmap = Some(handle); + } + } + TrayEvent::MenuLayoutChanged(name, layout) => { + if let Some(item) = self.data.0.iter_mut().find(|item| item.name == name) { + item.menu = layout; + } + } + TrayEvent::Unregistered(name) => { + self.data.0.retain(|item| item.name != name); + } + TrayEvent::None => {} + } + } + + fn subscribe() -> iced::Subscription> { + let id = TypeId::of::(); + + Subscription::run_with_id( + id, + channel(100, |mut output| async move { + let mut state = State::Init; + + loop { + state = TrayService::start_listening(state, &mut output).await; + } + }), + ) + } +} + +#[derive(Debug, Clone)] +pub enum TrayCommand { + MenuSelected(String, i32), +} + +impl Service for TrayService { + type Command = TrayCommand; + + fn command(&mut self, command: Self::Command) -> Task> { + match command { + TrayCommand::MenuSelected(name, id) => { + let menu = self.data.iter().find(|item| item.name == name); + if let Some(menu) = menu { + let name_cb = name.clone(); + Task::perform( + { + let proxy = menu.menu_proxy.clone(); + + async move { + debug!("Click tray menu voice {} : {}", name, id); + TrayService::menu_voice_selected(&proxy, id).await + } + }, + move |new_layout| { + if let Ok(new_layout) = new_layout { + ServiceEvent::Update(TrayEvent::MenuLayoutChanged( + name_cb.clone(), + new_layout, + )) + } else { + ServiceEvent::Update(TrayEvent::None) + } + }, + ) + } else { + Task::none() + } + } + } + } +}