From 61daf5290633d5e9ba5b46fff47aea6436435cb0 Mon Sep 17 00:00:00 2001 From: Jory Severijnse Date: Fri, 10 Apr 2026 17:32:52 +0200 Subject: [PATCH] feat: add media controls and fixed icon resolution - Implement Wayland pointer handling for interactive media buttons. - Add pure-Rust SVG loading via resvg. - Refactor status indicators for consistent icon and layout handling. --- Cargo.lock | 201 +++++++++++++++++++- Cargo.toml | 17 +- src/main.rs | 45 ++++- src/render.rs | 507 +++++++++++++++++++++++++++++++++++++++++--------- src/system.rs | 8 + 5 files changed, 684 insertions(+), 94 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86692ad..20aee39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,6 +72,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-broadcast" version = "0.5.1" @@ -259,6 +271,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bindgen" version = "0.69.5" @@ -606,6 +624,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbus" version = "0.9.10" @@ -762,6 +786,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -846,6 +879,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" @@ -1440,11 +1479,17 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png", + "png 0.18.1", "zune-core", "zune-jpeg", ] +[[package]] +name = "imagesize" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" + [[package]] name = "indexmap" version = "2.13.0" @@ -1538,6 +1583,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1850,6 +1906,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1879,6 +1941,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "png" version = "0.18.1" @@ -2103,7 +2178,7 @@ version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", @@ -2138,6 +2213,29 @@ dependencies = [ "winreg", ] +[[package]] +name = "resvg" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944d052815156ac8fa77eaac055220e95ba0b01fa8887108ca710c03805d9051" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -2152,6 +2250,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rpassword" version = "6.0.1" @@ -2234,12 +2338,15 @@ dependencies = [ "pangocairo", "rand", "reqwest", + "resvg", "serde", "smithay-client-toolkit", "thiserror", + "tiny-skia", "tokio", "toml 1.0.6+spec-1.1.0", "upower_dbus", + "usvg", "wayland-client", "wayland-protocols", "wayland-protocols-wlr", @@ -2267,7 +2374,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", ] [[package]] @@ -2416,6 +2523,21 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -2498,12 +2620,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "0.11.11" @@ -2636,6 +2777,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2901,6 +3068,28 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84ea542ae85c715f07b082438a4231c3760539d902e11d093847a0b22963032" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "xmlwriter", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3578,6 +3767,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 7662cff..71c4a00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,13 +30,16 @@ num-traits = { version = "0.2" } rand = { version = "0.8" } reqwest = { version = "0.11", default-features = false, features = ["blocking", "rustls-tls"], optional = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } -fastblur = "0.1" -pam-client = "0.5" -thiserror = "1.0" -bytemuck = "1.14" -calloop = "0.13" -xkbcommon = "0.7" -calloop-wayland-source = "0.3" + fastblur = "0.1" + pam-client = "0.5" + thiserror = "1.0" + bytemuck = "1.14" + calloop = "0.13" + xkbcommon = "0.7" + calloop-wayland-source = "0.3" + resvg = { version = "0.42", default-features = false } + usvg = { version = "0.42", default-features = false } + tiny-skia = "0.11" [features] default = ["networking"] diff --git a/src/main.rs b/src/main.rs index d40daef..8bb53af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,7 @@ use smithay_client_toolkit::{ registry_handlers, seat::{ keyboard::{KeyEvent, KeyboardHandler, Modifiers}, + pointer::{PointerEvent, PointerEventKind, PointerHandler}, SeatHandler, SeatState, }, session_lock::{ @@ -248,6 +249,8 @@ impl ProvidesRegistryState for WaylandLock { registry_handlers![OutputState, SeatState]; } +impl WaylandLock {} + impl CompositorHandler for WaylandLock { fn scale_factor_changed( &mut self, @@ -432,7 +435,7 @@ impl SeatHandler for WaylandLock { ) { if capability == smithay_client_toolkit::seat::Capability::Keyboard { let _ = self.seat_state.get_keyboard_with_repeat( - qh, + &qh, &seat, None, self.loop_handle.clone(), @@ -441,6 +444,9 @@ impl SeatHandler for WaylandLock { }), ); } + if capability == smithay_client_toolkit::seat::Capability::Pointer { + let _ = self.seat_state.get_pointer(&qh, &seat); + } } fn remove_capability( &mut self, @@ -571,6 +577,43 @@ impl wayland_client::Dispatch, + _pointer: &wayland_client::protocol::wl_pointer::WlPointer, + events: &[PointerEvent], + ) { + for event in events { + if let PointerEventKind::Press { button, .. } = event.kind { + if button == 0x110 { + let (x, y) = event.position; + if let Ok(lm) = self.lock_manager.lock() { + for surface in &lm.surfaces { + if surface.matches_surface(&event.surface) { + for (action, rx, ry, rw, rh) in &surface.renderer.media_rects { + if x >= *rx && x <= rx + rw && y >= *ry && y <= ry + rh { + match action.as_str() { + "play" => self.system_manager.media_play_pause(), + "stop" => self.system_manager.media_stop(), + "next" => self.system_manager.media_next(), + "prev" => self.system_manager.media_prev(), + _ => {} + } + return; + } + } + } + } + } + } + } + } + } +} + +smithay_client_toolkit::delegate_pointer!(WaylandLock); smithay_client_toolkit::delegate_compositor!(WaylandLock); smithay_client_toolkit::delegate_output!(WaylandLock); smithay_client_toolkit::delegate_shm!(WaylandLock); diff --git a/src/render.rs b/src/render.rs index ef2daaa..2d80d2d 100644 --- a/src/render.rs +++ b/src/render.rs @@ -34,6 +34,7 @@ pub struct Renderer { media_stop_icon_surface: Option, media_play_icon_surface: Option, media_next_icon_surface: Option, + pub media_rects: Vec<(String, f64, f64, f64, f64)>, } impl Renderer { @@ -73,6 +74,7 @@ impl Renderer { media_stop_icon_surface: None, media_play_icon_surface: None, media_next_icon_surface: None, + media_rects: Vec::new(), }; renderer.load_icons(); @@ -80,69 +82,374 @@ impl Renderer { } fn load_icons(&mut self) { - if let Some(ref path) = self.config.wifi_icon { - self.wifi_icon_surface = self.load_icon(path); + log::info!("Attempting to load status icons..."); + let wifi_names = [ + "network-wireless-signal-excellent-symbolic", + "network-wireless-signal-excellent", + "network-wireless-symbolic", + "network-wireless", + ]; + let wifi_path = self + .config + .wifi_icon + .clone() + .or_else(|| { + for name in &wifi_names { + if let Some(path) = self.find_system_icon(name) { + return Some(path); + } + } + None + }) + .unwrap_or_default(); + + if !wifi_path.is_empty() { + log::info!("Resolved WiFi icon path: {}", wifi_path); + self.wifi_icon_surface = self.load_icon(&wifi_path); + } else { + log::warn!("WiFi icon not found in system themes."); } - if let Some(ref path) = self.config.bluetooth_icon { - self.bluetooth_icon_surface = self.load_icon(path); + + let bt_names = [ + "bluetooth-active-symbolic", + "bluetooth-symbolic", + "bluetooth-active", + "bluetooth", + ]; + let bt_path = self + .config + .bluetooth_icon + .clone() + .or_else(|| { + for name in &bt_names { + if let Some(path) = self.find_system_icon(name) { + return Some(path); + } + } + None + }) + .unwrap_or_default(); + + if !bt_path.is_empty() { + log::info!("Resolved Bluetooth icon path: {}", bt_path); + self.bluetooth_icon_surface = self.load_icon(&bt_path); + } else { + log::warn!("Bluetooth icon not found in system themes."); } - if let Some(ref path) = self.config.battery_icon { - self.battery_icon_surface = self.load_icon(path); + + let batt_names = [ + "battery-level-100-symbolic", + "battery-full-symbolic", + "battery-full", + "battery-level-100", + "battery", + "battery-symbolic", + ]; + let batt_path = self + .config + .battery_icon + .clone() + .or_else(|| { + for name in &batt_names { + if let Some(path) = self.find_system_icon(name) { + return Some(path); + } + } + None + }) + .unwrap_or_default(); + + if !batt_path.is_empty() { + log::info!("Resolved Battery icon path: {}", batt_path); + self.battery_icon_surface = self.load_icon(&batt_path); + } else { + log::warn!("Battery icon not found in system themes."); } - if let Some(ref path) = self.config.media_prev_icon { - self.media_prev_icon_surface = self.load_icon(path); + + let prev_names = [ + "media-skip-backward-symbolic", + "media-skip-backward", + "media-playlist-repeat-symbolic", + ]; + let prev_path = self + .config + .media_prev_icon + .clone() + .or_else(|| { + for name in &prev_names { + if let Some(path) = self.find_system_icon(name) { + return Some(path); + } + } + None + }) + .unwrap_or_default(); + if !prev_path.is_empty() { + log::debug!("Resolved Media Prev icon path: {}", prev_path); + self.media_prev_icon_surface = self.load_icon(&prev_path); } - if let Some(ref path) = self.config.media_stop_icon { - self.media_stop_icon_surface = self.load_icon(path); + + let stop_names = ["media-playback-stop-symbolic", "media-playback-stop"]; + let stop_path = self + .config + .media_stop_icon + .clone() + .or_else(|| { + for name in &stop_names { + if let Some(path) = self.find_system_icon(name) { + return Some(path); + } + } + None + }) + .unwrap_or_default(); + if !stop_path.is_empty() { + log::debug!("Resolved Media Stop icon path: {}", stop_path); + self.media_stop_icon_surface = self.load_icon(&stop_path); } - if let Some(ref path) = self.config.media_play_icon { - self.media_play_icon_surface = self.load_icon(path); + + let play_names = ["media-playback-start-symbolic", "media-playback-start"]; + let play_path = self + .config + .media_play_icon + .clone() + .or_else(|| { + for name in &play_names { + if let Some(path) = self.find_system_icon(name) { + return Some(path); + } + } + None + }) + .unwrap_or_default(); + if !play_path.is_empty() { + log::debug!("Resolved Media Play icon path: {}", play_path); + self.media_play_icon_surface = self.load_icon(&play_path); } - if let Some(ref path) = self.config.media_next_icon { - self.media_next_icon_surface = self.load_icon(path); + + let next_names = ["media-skip-forward-symbolic", "media-skip-forward"]; + let next_path = self + .config + .media_next_icon + .clone() + .or_else(|| { + for name in &next_names { + if let Some(path) = self.find_system_icon(name) { + return Some(path); + } + } + None + }) + .unwrap_or_default(); + if !next_path.is_empty() { + log::debug!("Resolved Media Next icon path: {}", next_path); + self.media_next_icon_surface = self.load_icon(&next_path); } } - fn load_icon(&self, identifier: &str) -> Option { - let path = if identifier.starts_with('/') { - std::path::PathBuf::from(identifier) - } else { - return None; - }; + fn find_system_icon(&self, name: &str) -> Option { + let data_dirs = std::env::var("XDG_DATA_DIRS").unwrap_or_default(); + let mut search_paths = Vec::new(); - if let Ok(pixbuf) = gdk_pixbuf::Pixbuf::from_file(&path) { - let w = pixbuf.width(); - let h = pixbuf.height(); - let mut surface = ImageSurface::create(Format::ARgb32, w, h).ok()?; - { - let mut surface_data = surface.data().ok()?; - let pix_data = unsafe { pixbuf.pixels() }; - let n_channels = pixbuf.n_channels(); - let rowstride = pixbuf.rowstride() as usize; + for dir in data_dirs.split(':') { + let p = std::path::PathBuf::from(dir).join("icons"); + if p.exists() { + search_paths.push(p); + } + } + let sys_path = std::path::PathBuf::from("/run/current-system/sw/share/icons"); + if sys_path.exists() { + search_paths.push(sys_path); + } + let usr_path = std::path::PathBuf::from("/usr/share/icons"); + if usr_path.exists() { + search_paths.push(usr_path); + } - for y in 0..h as usize { - for x in 0..w as usize { - let pix_idx = y * rowstride + x * n_channels as usize; - let surf_idx = (y * w as usize + x) * 4; + let themes = [ + "WhiteSur", + "WhiteSur-dark", + "WhiteSur-light", + "Adwaita", + "hicolor", + "breeze", + "Papirus", + ]; + let categories = [ + "status/symbolic", + "actions/symbolic", + "devices/symbolic", + "status/24", + "status/22", + "status/16", + "status", + "actions", + "devices", + "symbolic/status", + "symbolic/actions", + "symbolic/devices", + "24x24/status", + "22x22/status", + "16x16/status", + "48x48/status", + ]; - if n_channels == 4 { - surface_data[surf_idx] = pix_data[pix_idx + 2]; - surface_data[surf_idx + 1] = pix_data[pix_idx + 1]; - surface_data[surf_idx + 2] = pix_data[pix_idx]; - surface_data[surf_idx + 3] = pix_data[pix_idx + 3]; - } else if n_channels == 3 { - surface_data[surf_idx] = pix_data[pix_idx + 2]; - surface_data[surf_idx + 1] = pix_data[pix_idx + 1]; - surface_data[surf_idx + 2] = pix_data[pix_idx]; - surface_data[surf_idx + 3] = 255; + for base in &search_paths { + for theme in &themes { + for cat in &categories { + for ext in [".svg", ".png"] { + let icon_path = base.join(theme).join(cat).join(format!("{}{}", name, ext)); + if icon_path.exists() { + log::debug!("Icon found: {:?}", icon_path); + return Some(icon_path.to_string_lossy().into_owned()); } } } } - Some(surface) - } else { - None } + + for base in &search_paths { + for theme in &themes { + let theme_root = base.join(theme); + if !theme_root.exists() { + continue; + } + + if let Ok(entries) = std::fs::read_dir(&theme_root) { + for entry in entries.flatten() { + if entry.path().is_dir() { + for ext in [".svg", ".png"] { + let icon_path = entry.path().join(format!("{}{}", name, ext)); + if icon_path.exists() { + log::debug!("Icon found (deep): {:?}", icon_path); + return Some(icon_path.to_string_lossy().into_owned()); + } + if let Ok(sub_entries) = std::fs::read_dir(entry.path()) { + for sub_entry in sub_entries.flatten() { + if sub_entry.path().is_dir() { + let icon_path = + sub_entry.path().join(format!("{}{}", name, ext)); + if icon_path.exists() { + log::debug!("Icon found (deep 2): {:?}", icon_path); + return Some( + icon_path.to_string_lossy().into_owned(), + ); + } + } + } + } + } + } + } + } + } + } + + None + } + + fn load_icon(&self, identifier: &str) -> Option { + let path = if identifier.starts_with('~') { + let home = std::env::var("HOME").unwrap_or_default(); + std::path::PathBuf::from(identifier.replacen('~', &home, 1)) + } else { + std::path::PathBuf::from(identifier) + }; + + if !path.exists() { + log::warn!("Icon path does not exist: {:?}", path); + return None; + } + + if path.extension().and_then(|s| s.to_str()) == Some("svg") { + match self.load_svg_with_resvg(&path) { + Some(surface) => return Some(surface), + None => log::warn!("resvg failed to load {:?}, falling back to pixbuf", path), + } + } + + match gdk_pixbuf::Pixbuf::from_file(&path) { + Ok(pixbuf) => { + let w = pixbuf.width(); + let h = pixbuf.height(); + let mut surface = ImageSurface::create(Format::ARgb32, w, h).ok()?; + { + let mut surface_data = surface.data().ok()?; + let pix_data = unsafe { pixbuf.pixels() }; + let n_channels = pixbuf.n_channels(); + let rowstride = pixbuf.rowstride() as usize; + + log::debug!( + "Loaded pixbuf from {:?}: {}x{}, {} channels", + path, + w, + h, + n_channels + ); + + for y in 0..h as usize { + for x in 0..w as usize { + let pix_idx = y * rowstride + x * n_channels as usize; + let surf_idx = (y * w as usize + x) * 4; + + if n_channels == 4 { + surface_data[surf_idx] = pix_data[pix_idx + 2]; + surface_data[surf_idx + 1] = pix_data[pix_idx + 1]; + surface_data[surf_idx + 2] = pix_data[pix_idx]; + surface_data[surf_idx + 3] = pix_data[pix_idx + 3]; + } else if n_channels == 3 { + surface_data[surf_idx] = pix_data[pix_idx + 2]; + surface_data[surf_idx + 1] = pix_data[pix_idx + 1]; + surface_data[surf_idx + 2] = pix_data[pix_idx]; + surface_data[surf_idx + 3] = 255; + } + } + } + } + Some(surface) + } + Err(e) => { + log::error!("Failed to load icon from {:?}: {}", path, e); + None + } + } + } + + fn load_svg_with_resvg(&self, path: &std::path::Path) -> Option { + use resvg::usvg; + + let opt = usvg::Options::default(); + let svg_data = std::fs::read(path).ok()?; + let tree = usvg::Tree::from_data(&svg_data, &opt).ok()?; + + let size = tree.size().to_int_size(); + let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height())?; + + resvg::render( + &tree, + resvg::tiny_skia::Transform::default(), + &mut pixmap.as_mut(), + ); + + let mut surface = + ImageSurface::create(Format::ARgb32, size.width() as i32, size.height() as i32).ok()?; + { + let mut surface_data = surface.data().ok()?; + let pix_data = pixmap.data(); + + for i in (0..pix_data.len()).step_by(4) { + let r = pix_data[i]; + let g = pix_data[i + 1]; + let b = pix_data[i + 2]; + let a = pix_data[i + 3]; + + surface_data[i] = b; + surface_data[i + 1] = g; + surface_data[i + 2] = r; + surface_data[i + 3] = a; + } + } + Some(surface) } /// Resize the renderer to new dimensions @@ -199,6 +506,7 @@ impl Renderer { /// Render the current frame pub fn render(&mut self) { + self.media_rects.clear(); // Clear the surface self.context.new_path(); self.context.set_source_rgba(0.0, 0.0, 0.0, 1.0); @@ -396,7 +704,7 @@ impl Renderer { let center_y = self.height as f64 / 2.0; let radius = self.config.indicator_radius as f64; let thickness = self.config.indicator_thickness as f64; - + self.context.new_path(); self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); @@ -407,12 +715,12 @@ impl Renderer { let dot_radius = radius - thickness - 10.0; let angle_step = (360.0 / 24.0_f64).to_radians(); - + for i in 0..count { let angle = (i as f64 * angle_step) - std::f64::consts::FRAC_PI_2; let x = center_x + dot_radius * angle.cos(); let y = center_y + dot_radius * angle.sin(); - + self.context.new_path(); self.context.arc(x, y, 4.0, 0.0, 2.0 * std::f64::consts::PI); self.context.fill().unwrap(); @@ -652,13 +960,17 @@ impl Renderer { let status_text = if self.system_status.media_playing { if let Some(ref icon) = self.media_play_icon_surface { - // Draw play icon instead of text let play_y = start_y + 40.0; - self.draw_icon_at( - center_x - icon.width() as f64 / 2.0, - play_y - icon.height() as f64 / 2.0, - icon, - ); + let rx = center_x - icon.width() as f64 / 2.0; + let ry = play_y - icon.height() as f64 / 2.0; + self.draw_icon_at(rx, ry, icon); + self.media_rects.push(( + "play".to_string(), + rx, + ry, + icon.width() as f64, + icon.height() as f64, + )); "" } else { "▶ Playing" @@ -685,7 +997,11 @@ impl Renderer { // Previous icon if let Some(ref icon) = self.media_prev_icon_surface { let ix = controls_center_x - icon_spacing; - self.draw_icon_at(ix - icon_size / 2.0, controls_y - icon_size / 2.0, icon); + let rx = ix - icon_size / 2.0; + let ry = controls_y - icon_size / 2.0; + self.draw_icon_at(rx, ry, icon); + self.media_rects + .push(("prev".to_string(), rx, ry, icon_size, icon_size)); } else { self.context .set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * 0.7); @@ -698,7 +1014,11 @@ impl Renderer { // Stop icon if let Some(ref icon) = self.media_stop_icon_surface { let ix = controls_center_x; - self.draw_icon_at(ix - icon_size / 2.0, controls_y - icon_size / 2.0, icon); + let rx = ix - icon_size / 2.0; + let ry = controls_y - icon_size / 2.0; + self.draw_icon_at(rx, ry, icon); + self.media_rects + .push(("stop".to_string(), rx, ry, icon_size, icon_size)); } else { self.context .set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * 0.7); @@ -710,7 +1030,11 @@ impl Renderer { // Next icon if let Some(ref icon) = self.media_next_icon_surface { let ix = controls_center_x + icon_spacing; - self.draw_icon_at(ix - icon_size / 2.0, controls_y - icon_size / 2.0, icon); + let rx = ix - icon_size / 2.0; + let ry = controls_y - icon_size / 2.0; + self.draw_icon_at(rx, ry, icon); + self.media_rects + .push(("next".to_string(), rx, ry, icon_size, icon_size)); } else { self.context .set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * 0.7); @@ -733,7 +1057,7 @@ impl Renderer { if let Some(ref ssid) = self.system_status.wifi_ssid { if let Some(ref icon) = self.wifi_icon_surface { self.draw_icon_at(x, y - 15.0, icon); - let text_x = x + icon.width() as f64 + 10.0; + let text_x = x + 24.0 + 10.0; self.context.new_path(); self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); self.context.set_font_size(16.0); @@ -766,7 +1090,7 @@ impl Renderer { if let Some(ref icon) = self.battery_icon_surface { self.draw_icon_at(x, y - 15.0, icon); - let text_x = x + icon.width() as f64 + 10.0; + let text_x = x + 24.0 + 10.0; let battery_text = format!("{:.0}%", percent); self.context.new_path(); self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); @@ -800,35 +1124,37 @@ impl Renderer { let x = margin; let y = margin + 50.0; + let status_text; + let is_off; + if self.system_status.bluetooth_connected { - if let Some(ref icon) = self.bluetooth_icon_surface { - self.draw_icon_at(x, y - 12.0, icon); - let text_x = x + icon.width() as f64 + 10.0; - let devices = self.system_status.bluetooth_devices.join(", "); - self.context.new_path(); - self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); - self.context.set_font_size(14.0); - self.context.move_to(text_x, y); - self.context.show_text(&devices).unwrap(); - } else { - let text = format!( - "🔵 {} device(s)", - self.system_status.bluetooth_devices.len() - ); - self.context.new_path(); - self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); - self.context.set_font_size(14.0); - self.context.move_to(x, y); - self.context.show_text(&text).unwrap(); - } + status_text = self.system_status.bluetooth_devices.join(", "); + is_off = false; } else { - let text = "🔴 Bluetooth off"; + status_text = "Bluetooth off".to_string(); + is_off = true; + } + + let alpha_mult = if is_off { 0.5 } else { 1.0 }; + + if let Some(ref icon) = self.bluetooth_icon_surface { + self.draw_icon_at_with_alpha(x, y - 12.0, icon, alpha_mult); + let text_x = x + 24.0 + 10.0; self.context.new_path(); self.context - .set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * 0.5); + .set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * alpha_mult); + self.context.set_font_size(14.0); + self.context.move_to(text_x, y); + self.context.show_text(&status_text).unwrap(); + } else { + let emoji = if is_off { "🔴 " } else { "🔵 " }; + let full_text = format!("{}{}", emoji, status_text); + self.context.new_path(); + self.context + .set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * alpha_mult); self.context.set_font_size(14.0); self.context.move_to(x, y); - self.context.show_text(text).unwrap(); + self.context.show_text(&full_text).unwrap(); } } @@ -862,6 +1188,21 @@ impl Renderer { self.context.restore().unwrap(); } + fn draw_icon_at_with_alpha(&self, x: f64, y: f64, surface: &ImageSurface, alpha: f64) { + self.context.save().unwrap(); + let target_size = 24.0; + let scale_x = target_size / surface.width() as f64; + let scale_y = target_size / surface.height() as f64; + let scale = scale_x.min(scale_y); + self.context.translate(x, y); + self.context.scale(scale, scale); + self.context.set_source_surface(surface, 0.0, 0.0).unwrap(); + self.context + .paint_with_alpha(self.fade_alpha * alpha) + .unwrap(); + self.context.restore().unwrap(); + } + fn draw_battery_icon_at( &self, x: f64, diff --git a/src/system.rs b/src/system.rs index 26b3bd8..e660608 100644 --- a/src/system.rs +++ b/src/system.rs @@ -259,6 +259,14 @@ impl SystemManager { } } + pub fn media_stop(&self) { + if let Ok(finder) = PlayerFinder::new() { + if let Ok(player) = finder.find_active() { + let _ = player.stop(); + } + } + } + pub fn media_next(&self) { if let Ok(finder) = PlayerFinder::new() { if let Ok(player) = finder.find_active() {