From 06130efaed7dc0e5957d10469abd2457eb2c7f9b Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Sun, 12 Apr 2026 14:26:19 +0200 Subject: [PATCH 1/4] add GPU monitoring module --- Cargo.lock | 134 +++++++++++-- Cargo.toml | 2 +- .../wayle-config/src/schemas/bar/types/mod.rs | 5 + .../src/schemas/modules/gpu/mod.rs | 157 +++++++++++++++ .../wayle-config/src/schemas/modules/mod.rs | 4 + .../src/shell/bar/modules/gpu/factory.rs | 33 ++++ .../src/shell/bar/modules/gpu/helpers.rs | 187 ++++++++++++++++++ .../src/shell/bar/modules/gpu/messages.rs | 31 +++ .../src/shell/bar/modules/gpu/mod.rs | 125 ++++++++++++ .../src/shell/bar/modules/gpu/watchers.rs | 58 ++++++ .../wayle-shell/src/shell/bar/modules/mod.rs | 2 + crates/wayle-shell/src/watchers/sysinfo.rs | 17 ++ .../scalable/actions/ld-gpu-symbolic.svg | 39 ++++ 13 files changed, 773 insertions(+), 21 deletions(-) create mode 100644 crates/wayle-config/src/schemas/modules/gpu/mod.rs create mode 100644 crates/wayle-shell/src/shell/bar/modules/gpu/factory.rs create mode 100644 crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs create mode 100644 crates/wayle-shell/src/shell/bar/modules/gpu/messages.rs create mode 100644 crates/wayle-shell/src/shell/bar/modules/gpu/mod.rs create mode 100644 crates/wayle-shell/src/shell/bar/modules/gpu/watchers.rs create mode 100644 resources/icons/hicolor/scalable/actions/ld-gpu-symbolic.svg diff --git a/Cargo.lock b/Cargo.lock index bb43d7d9..1f1c10c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -624,6 +624,41 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1810,6 +1845,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -2331,6 +2372,29 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nvml-wrapper" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9bff0aa1d48904a1385ea2a8b97576fbdcbc9a3cfccd0d31fe978e1c4038c5" +dependencies = [ + "bitflags 2.10.0", + "libloading", + "nvml-wrapper-sys", + "static_assertions", + "thiserror 1.0.69", + "wrapcenum-derive", +] + +[[package]] +name = "nvml-wrapper-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698d45156f28781a4e79652b6ebe2eaa0589057d588d3aec1333f6466f13fcb5" +dependencies = [ + "libloading", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3484,6 +3548,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strict-num" version = "0.1.1" @@ -4447,7 +4517,7 @@ dependencies = [ "wayle-bluetooth", "wayle-cava", "wayle-config", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-hyprland", "wayle-icons", "wayle-ipc", @@ -4479,7 +4549,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "zbus", ] @@ -4499,7 +4569,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "zbus", ] @@ -4518,7 +4588,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "zbus", ] @@ -4537,7 +4607,7 @@ dependencies = [ "tokio-util", "tracing", "udev", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "zbus", ] @@ -4557,7 +4627,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", ] @@ -4579,10 +4649,23 @@ dependencies = [ "tokio-stream", "toml 0.9.11+spec-1.1.0", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-derive", ] +[[package]] +name = "wayle-core" +version = "0.1.2" +dependencies = [ + "futures", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tracing", + "zbus", +] + [[package]] name = "wayle-core" version = "0.1.2" @@ -4627,7 +4710,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", ] @@ -4692,7 +4775,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "wildcard", "zbus", @@ -4714,7 +4797,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "zbus", ] @@ -4738,7 +4821,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "wildcard", "zbus", @@ -4759,7 +4842,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "zbus", ] @@ -4799,7 +4882,7 @@ dependencies = [ "wayle-brightness", "wayle-cava", "wayle-config", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-hyprland", "wayle-icons", "wayle-idle-inhibit", @@ -4836,17 +4919,16 @@ dependencies = [ [[package]] name = "wayle-sysinfo" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457d314fb55348d1f330217e158ad7892f13e6a5c3b37f4f118915615f0b6dda" dependencies = [ "derive_more", "futures", + "nvml-wrapper", "sysinfo", "thiserror 2.0.18", "tokio", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2", ] [[package]] @@ -4868,7 +4950,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "zbus", ] @@ -4896,7 +4978,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", "zbus", ] @@ -4917,7 +4999,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "wayle-traits", ] @@ -4935,7 +5017,7 @@ dependencies = [ "tokio-util", "tracing", "wayle-config", - "wayle-core", + "wayle-core 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -5422,6 +5504,18 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "wrapcenum-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 7354dbb7..216a83b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,7 +103,7 @@ wayle-media = "0.1.2" wayle-network = "0.1.2" wayle-notification = "0.1.2" wayle-power-profiles = "0.1.2" -wayle-sysinfo = "0.1.2" +wayle-sysinfo = { path = "../wayle-services/wayle-sysinfo" } wayle-systray = "0.1.2" wayle-traits = "0.1.2" wayle-wallpaper = "0.1.2" diff --git a/crates/wayle-config/src/schemas/bar/types/mod.rs b/crates/wayle-config/src/schemas/bar/types/mod.rs index 14419691..d709b9bf 100644 --- a/crates/wayle-config/src/schemas/bar/types/mod.rs +++ b/crates/wayle-config/src/schemas/bar/types/mod.rs @@ -176,6 +176,8 @@ pub enum BarModule { Cpu, /// Quick access dashboard button. Dashboard, + /// GPU usage, memory, and temperature. + Gpu, /// Compositor keybind mode indicator (submaps in Hyprland, modes in Sway/River). KeybindMode, /// Hyprland workspace switcher. @@ -251,6 +253,7 @@ impl BarModule { Self::Clock => "clock", Self::Cpu => "cpu", Self::Dashboard => "dashboard", + Self::Gpu => "gpu", Self::KeybindMode => "keybind-mode", Self::HyprlandWorkspaces => "hyprland-workspaces", Self::IdleInhibit => "idle-inhibit", @@ -283,6 +286,7 @@ impl BarModule { "clock" => Self::Clock, "cpu" => Self::Cpu, "dashboard" => Self::Dashboard, + "gpu" => Self::Gpu, "keybind-mode" => Self::KeybindMode, "hyprland-workspaces" => Self::HyprlandWorkspaces, "idle-inhibit" => Self::IdleInhibit, @@ -366,6 +370,7 @@ const BUILTIN_MODULES: &[&str] = &[ "clock", "cpu", "dashboard", + "gpu", "hyprland-workspaces", "hyprsunset", "idle-inhibit", diff --git a/crates/wayle-config/src/schemas/modules/gpu/mod.rs b/crates/wayle-config/src/schemas/modules/gpu/mod.rs new file mode 100644 index 00000000..1a85e925 --- /dev/null +++ b/crates/wayle-config/src/schemas/modules/gpu/mod.rs @@ -0,0 +1,157 @@ +use schemars::schema_for; +use wayle_derive::wayle_config; + +use crate::{ + ClickAction, ConfigProperty, + docs::{ModuleInfo, ModuleInfoProvider}, + schemas::styling::{ColorValue, CssToken, ThresholdEntry}, +}; + +/// GPU module configuration. +/// +/// Uses GPU data provided by `wayle-sysinfo` (NVML-backed on NVIDIA systems). +#[wayle_config(bar_button)] +pub struct GpuConfig { + /// Polling interval in milliseconds. + /// + /// Faster polling increases monitoring overhead. + #[serde(rename = "poll-interval-ms")] + #[default(2000)] + pub poll_interval_ms: ConfigProperty, + + /// Format string for the label. + /// + /// ## Aggregate Placeholders + /// + /// - `{{ count }}` - Number of detected GPUs + /// - `{{ active_count }}` - Number of GPUs currently reporting utilization + /// - `{{ percent }}` - Average GPU core utilization (0-100) + /// - `{{ mem_percent }}` - Average GPU memory utilization (0-100) + /// - `{{ temp_c }}` - Maximum GPU temperature in Celsius (if available) + /// + /// ## Per-GPU Placeholders (first two GPUs) + /// + /// - `{{ gpu0_percent }}`, `{{ gpu1_percent }}` + /// - `{{ gpu0_mem_percent }}`, `{{ gpu1_mem_percent }}` + /// - `{{ gpu0_temp_c }}`, `{{ gpu1_temp_c }}` + /// - `{{ gpu0_mem_used_gib }}`, `{{ gpu1_mem_used_gib }}` + /// - `{{ gpu0_mem_total_gib }}`, `{{ gpu1_mem_total_gib }}` + /// + /// ## Examples + /// + /// - `"{{ percent }}%"` - `"37%"` + /// - `"{{ gpu0_percent }}% | {{ gpu1_percent }}%"` - `"52% | 11%"` + /// - `"{{ percent }}% VRAM {{ mem_percent }}%"` - `"37% VRAM 42%"` + #[serde(rename = "format")] + #[default(String::from("{{ percent }}%"))] + pub format: ConfigProperty, + + /// Icon name. + #[serde(rename = "icon-name")] + #[default(String::from("ld-gpu-symbolic"))] + pub icon_name: ConfigProperty, + + /// Display border around button. + #[serde(rename = "border-show")] + #[default(false)] + pub border_show: ConfigProperty, + + /// Border color token. + #[serde(rename = "border-color")] + #[default(ColorValue::Token(CssToken::Blue))] + pub border_color: ConfigProperty, + + /// Display module icon. + #[serde(rename = "icon-show")] + #[default(true)] + pub icon_show: ConfigProperty, + + /// Icon foreground color. + #[serde(rename = "icon-color")] + #[default(ColorValue::Auto)] + pub icon_color: ConfigProperty, + + /// Icon container background color token. + #[serde(rename = "icon-bg-color")] + #[default(ColorValue::Token(CssToken::Blue))] + pub icon_bg_color: ConfigProperty, + + /// Display label. + #[serde(rename = "label-show")] + #[default(true)] + pub label_show: ConfigProperty, + + /// Label text color token. + #[serde(rename = "label-color")] + #[default(ColorValue::Token(CssToken::Blue))] + pub label_color: ConfigProperty, + + /// Max label characters before truncation. Set to 0 to disable. + #[serde(rename = "label-max-length")] + #[default(0)] + pub label_max_length: ConfigProperty, + + /// Button background color token. + #[serde(rename = "button-bg-color")] + #[default(ColorValue::Token(CssToken::BgSurfaceElevated))] + pub button_bg_color: ConfigProperty, + + /// Action on left click. + #[serde(rename = "left-click")] + #[default(ClickAction::None)] + pub left_click: ConfigProperty, + + /// Action on right click. + #[serde(rename = "right-click")] + #[default(ClickAction::None)] + pub right_click: ConfigProperty, + + /// Action on middle click. + #[serde(rename = "middle-click")] + #[default(ClickAction::None)] + pub middle_click: ConfigProperty, + + /// Action on scroll up. + #[serde(rename = "scroll-up")] + #[default(ClickAction::None)] + pub scroll_up: ConfigProperty, + + /// Action on scroll down. + #[serde(rename = "scroll-down")] + #[default(ClickAction::None)] + pub scroll_down: ConfigProperty, + + /// Dynamic color thresholds based on average GPU usage percentage. + /// + /// Entries are checked in order; the last matching entry wins for each + /// color slot. + /// + /// ## Example + /// + /// ```toml + /// [[modules.gpu.thresholds]] + /// above = 70 + /// icon-color = "status-warning" + /// label-color = "status-warning" + /// + /// [[modules.gpu.thresholds]] + /// above = 90 + /// icon-color = "status-error" + /// label-color = "status-error" + /// ``` + #[serde(rename = "thresholds")] + #[default(Vec::new())] + pub thresholds: ConfigProperty>, +} + +impl ModuleInfoProvider for GpuConfig { + fn module_info() -> ModuleInfo { + ModuleInfo { + name: String::from("gpu"), + icon: String::from("󰢮"), + description: String::from("GPU usage, memory, and temperature"), + behavior_configs: vec![(String::from("gpu"), || schema_for!(GpuConfig))], + styling_configs: vec![], + } + } +} diff --git a/crates/wayle-config/src/schemas/modules/mod.rs b/crates/wayle-config/src/schemas/modules/mod.rs index f8617232..4cf4ec0f 100644 --- a/crates/wayle-config/src/schemas/modules/mod.rs +++ b/crates/wayle-config/src/schemas/modules/mod.rs @@ -7,6 +7,7 @@ mod clock; mod cpu; mod custom; mod dashboard; +mod gpu; mod hyprland_workspaces; mod hyprsunset; mod idle_inhibit; @@ -38,6 +39,7 @@ pub use clock::ClockConfig; pub use cpu::CpuConfig; pub use custom::{CustomModuleDefinition, ExecutionMode, RestartDelay, RestartPolicy}; pub use dashboard::DashboardConfig; +pub use gpu::GpuConfig; pub use hyprland_workspaces::{ ActiveIndicator, DisplayMode, HyprlandWorkspacesConfig, Numbering, UrgentMode, WorkspaceStyle, }; @@ -82,6 +84,8 @@ pub struct ModulesConfig { pub cpu: CpuConfig, /// Dashboard module. pub dashboard: DashboardConfig, + /// GPU usage module. + pub gpu: GpuConfig, /// Hyprland workspace switcher module. #[serde(rename = "hyprland-workspaces")] pub hyprland_workspaces: HyprlandWorkspacesConfig, diff --git a/crates/wayle-shell/src/shell/bar/modules/gpu/factory.rs b/crates/wayle-shell/src/shell/bar/modules/gpu/factory.rs new file mode 100644 index 00000000..0de8e59f --- /dev/null +++ b/crates/wayle-shell/src/shell/bar/modules/gpu/factory.rs @@ -0,0 +1,33 @@ +use std::rc::Rc; + +use relm4::prelude::*; +use wayle_widgets::prelude::BarSettings; + +use super::{GpuInit, GpuModule}; +use crate::shell::{ + bar::{ + dropdowns::DropdownRegistry, + modules::registry::{ModuleFactory, ModuleInstance, dynamic_controller}, + }, + services::ShellServices, +}; + +pub(crate) struct Factory; + +impl ModuleFactory for Factory { + fn create( + settings: &BarSettings, + services: &ShellServices, + dropdowns: &Rc, + class: Option, + ) -> Option { + let init = GpuInit { + settings: settings.clone(), + sysinfo: services.sysinfo.clone(), + config: services.config.clone(), + dropdowns: dropdowns.clone(), + }; + let controller = dynamic_controller(GpuModule::builder().launch(init).detach()); + Some(ModuleInstance { controller, class }) + } +} diff --git a/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs b/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs new file mode 100644 index 00000000..f0b4bf65 --- /dev/null +++ b/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs @@ -0,0 +1,187 @@ +use bytesize::ByteSize; +use serde_json::json; +use wayle_sysinfo::types::{GpuData, GpuDeviceData}; + +/// Formats a GPU label using Jinja2 template syntax. +/// +/// ## Aggregate Variables +/// +/// - `{{ count }}` - Number of detected GPUs +/// - `{{ active_count }}` - Number of GPUs reporting utilization +/// - `{{ percent }}` - Average GPU core utilization (00-100, zero-padded) +/// - `{{ mem_percent }}` - Average GPU memory utilization (00-100, zero-padded) +/// - `{{ temp_c }}` - Maximum GPU temperature across devices (zero-padded) +/// +/// ## Per-device Variables (first two GPUs) +/// +/// - `{{ gpu0_percent }}`, `{{ gpu1_percent }}` +/// - `{{ gpu0_mem_percent }}`, `{{ gpu1_mem_percent }}` +/// - `{{ gpu0_temp_c }}`, `{{ gpu1_temp_c }}` +/// - `{{ gpu0_mem_used_gib }}`, `{{ gpu1_mem_used_gib }}` +/// - `{{ gpu0_mem_total_gib }}`, `{{ gpu1_mem_total_gib }}` +pub(super) fn format_label(format: &str, gpu: &GpuData) -> String { + let gpu0 = gpu.devices.iter().find(|device| device.index == 0); + let gpu1 = gpu.devices.iter().find(|device| device.index == 1); + + let max_temp_c = gpu + .devices + .iter() + .filter_map(|d| d.temperature_celsius) + .fold(0.0_f32, f32::max); + + let ctx = json!({ + "count": gpu.total_count, + "active_count": gpu.active_count, + "percent": format!("{:02.0}", gpu.average_utilization_percent), + "mem_percent": format!("{:02.0}", gpu.average_memory_utilization_percent), + "temp_c": format!("{max_temp_c:02.0}"), + + "gpu0_percent": format_percent(gpu0.and_then(|d| d.utilization_percent)), + "gpu1_percent": format_percent(gpu1.and_then(|d| d.utilization_percent)), + + "gpu0_mem_percent": format_percent(gpu0.and_then(|d| d.memory_utilization_percent)), + "gpu1_mem_percent": format_percent(gpu1.and_then(|d| d.memory_utilization_percent)), + + "gpu0_temp_c": format_percent(gpu0.and_then(|d| d.temperature_celsius)), + "gpu1_temp_c": format_percent(gpu1.and_then(|d| d.temperature_celsius)), + + "gpu0_mem_used_gib": gib(gpu0.and_then(|d| d.memory_used_bytes)), + "gpu1_mem_used_gib": gib(gpu1.and_then(|d| d.memory_used_bytes)), + "gpu0_mem_total_gib": gib(gpu0.and_then(|d| d.memory_total_bytes)), + "gpu1_mem_total_gib": gib(gpu1.and_then(|d| d.memory_total_bytes)), + }); + + crate::template::render(format, ctx).unwrap_or_default() +} + +fn format_percent(value: Option) -> String { + format!("{:02.0}", value.unwrap_or(0.0)) +} + +fn gib(bytes: Option) -> String { + format!("{:.1}", ByteSize::b(bytes.unwrap_or(0)).as_gib()) +} + +#[cfg(test)] +mod tests { + use super::*; + const GIB: u64 = 1024 * 1024 * 1024; + + fn device( + index: u32, + util: Option, + mem_used: u64, + mem_total: u64, + temp: Option, + ) -> GpuDeviceData { + GpuDeviceData { + index, + name: format!("GPU {index}"), + uuid: format!("uuid-{index}"), + utilization_percent: util, + memory_used_bytes: Some(mem_used), + memory_total_bytes: Some(mem_total), + memory_utilization_percent: if mem_total > 0 { + Some((mem_used as f32 / mem_total as f32) * 100.0) + } else { + Some(0.0) + }, + temperature_celsius: temp, + power_watts: None, + power_limit_watts: None, + fan_speed_percent: None, + graphics_clock_mhz: None, + memory_clock_mhz: None, + } + } + + fn gpu_data(devices: Vec, avg_util: f32, avg_mem_util: f32) -> GpuData { + let active_count = devices + .iter() + .filter(|d| d.utilization_percent.is_some()) + .count(); + let total_count = devices + .iter() + .map(|d| d.index as usize) + .max() + .map(|max_index| max_index + 1) + .unwrap_or(0); + GpuData { + total_count, + active_count, + average_utilization_percent: avg_util, + average_memory_utilization_percent: avg_mem_util, + devices, + } + } + + #[test] + fn format_label_replaces_aggregate_placeholders() { + let gpu = gpu_data(vec![], 37.2, 42.1); + let out = format_label("{{ percent }}% VRAM {{ mem_percent }}% ({{ count }})", &gpu); + assert_eq!(out, "37% VRAM 42% (0)"); + } + + #[test] + fn format_label_uses_max_temperature() { + let gpu = gpu_data( + vec![ + device(0, Some(10.0), 2 * GIB, 8 * GIB, Some(61.0)), + device(1, Some(20.0), 1 * GIB, 8 * GIB, Some(73.0)), + ], + 15.0, + 19.0, + ); + let out = format_label("{{ temp_c }}C", &gpu); + assert_eq!(out, "73C"); + } + + #[test] + fn format_label_replaces_per_gpu_placeholders() { + let gpu = gpu_data( + vec![ + device(0, Some(52.0), 3 * GIB, 8 * GIB, Some(65.0)), + device(1, Some(11.0), 1 * GIB, 8 * GIB, Some(49.0)), + ], + 31.5, + 25.0, + ); + let out = format_label("{{ gpu0_percent }}% | {{ gpu1_percent }}%", &gpu); + assert_eq!(out, "52% | 11%"); + } + + #[test] + fn format_label_missing_second_gpu_defaults_to_zero() { + let gpu = gpu_data( + vec![device(0, Some(40.0), 2 * GIB, 8 * GIB, Some(55.0))], + 40.0, + 25.0, + ); + let out = format_label("{{ gpu1_percent }} {{ gpu1_mem_total_gib }}", &gpu); + assert_eq!(out, "00 0.0"); + } + + #[test] + fn format_label_uses_device_index_not_vector_position() { + let gpu = gpu_data(vec![device(1, Some(11.0), 1 * GIB, 8 * GIB, Some(49.0))], 11.0, 12.5); + let out = format_label("{{ gpu0_percent }}% | {{ gpu1_percent }}%", &gpu); + assert_eq!(out, "00% | 11%"); + } + + #[test] + fn format_label_formats_memory_gib() { + let gpu = gpu_data( + vec![device( + 0, + Some(40.0), + (1.5 * GIB as f64) as u64, + 12 * GIB, + Some(55.0), + )], + 40.0, + 12.5, + ); + let out = format_label("{{ gpu0_mem_used_gib }}/{{ gpu0_mem_total_gib }}", &gpu); + assert_eq!(out, "1.5/12.0"); + } +} diff --git a/crates/wayle-shell/src/shell/bar/modules/gpu/messages.rs b/crates/wayle-shell/src/shell/bar/modules/gpu/messages.rs new file mode 100644 index 00000000..4de4051f --- /dev/null +++ b/crates/wayle-shell/src/shell/bar/modules/gpu/messages.rs @@ -0,0 +1,31 @@ +use std::{rc::Rc, sync::Arc}; + +use wayle_config::{ConfigService, schemas::styling::ThresholdColors}; +use wayle_sysinfo::SysinfoService; +use wayle_widgets::prelude::BarSettings; + +use crate::shell::bar::dropdowns::DropdownRegistry; + +pub(crate) struct GpuInit { + pub settings: BarSettings, + pub sysinfo: Arc, + pub config: Arc, + pub dropdowns: Rc, +} + +#[derive(Debug)] +pub(crate) enum GpuMsg { + LeftClick, + RightClick, + MiddleClick, + ScrollUp, + ScrollDown, +} + +#[derive(Debug)] +#[allow(clippy::enum_variant_names)] +pub(crate) enum GpuCmd { + UpdateLabel(String), + UpdateIcon(String), + UpdateThresholdColors(ThresholdColors), +} diff --git a/crates/wayle-shell/src/shell/bar/modules/gpu/mod.rs b/crates/wayle-shell/src/shell/bar/modules/gpu/mod.rs new file mode 100644 index 00000000..a2aba6c6 --- /dev/null +++ b/crates/wayle-shell/src/shell/bar/modules/gpu/mod.rs @@ -0,0 +1,125 @@ +mod factory; +mod helpers; +mod messages; +mod watchers; + +use std::{rc::Rc, sync::Arc}; + +use gtk::prelude::*; +use relm4::prelude::*; +use wayle_config::{ConfigProperty, ConfigService, schemas::styling::CssToken}; +use wayle_widgets::prelude::{ + BarButton, BarButtonBehavior, BarButtonColors, BarButtonInit, BarButtonInput, BarButtonOutput, +}; + +pub(crate) use self::{ + factory::Factory, + messages::{GpuCmd, GpuInit, GpuMsg}, +}; +use crate::shell::bar::dropdowns::{self, DropdownRegistry}; + +pub(crate) struct GpuModule { + bar_button: Controller, + config: Arc, + dropdowns: Rc, +} + +#[relm4::component(pub(crate))] +impl Component for GpuModule { + type Init = GpuInit; + type Input = GpuMsg; + type Output = (); + type CommandOutput = GpuCmd; + + view! { + gtk::Box { + add_css_class: "gpu", + + #[local_ref] + bar_button -> gtk::MenuButton {}, + } + } + + fn init( + init: Self::Init, + _root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let config = init.config.config(); + let gpu_config = &config.modules.gpu; + + let initial_label = + helpers::format_label(&gpu_config.format.get(), &init.sysinfo.gpu.get()); + + let bar_button = BarButton::builder() + .launch(BarButtonInit { + icon: gpu_config.icon_name.get().clone(), + label: initial_label, + tooltip: None, + colors: BarButtonColors { + icon_color: gpu_config.icon_color.clone(), + label_color: gpu_config.label_color.clone(), + icon_background: gpu_config.icon_bg_color.clone(), + button_background: gpu_config.button_bg_color.clone(), + border_color: gpu_config.border_color.clone(), + auto_icon_color: CssToken::Blue, + }, + behavior: BarButtonBehavior { + label_max_chars: gpu_config.label_max_length.clone(), + show_icon: gpu_config.icon_show.clone(), + show_label: gpu_config.label_show.clone(), + show_border: gpu_config.border_show.clone(), + visible: ConfigProperty::new(true), + }, + settings: init.settings, + }) + .forward(sender.input_sender(), |output| match output { + BarButtonOutput::LeftClick => GpuMsg::LeftClick, + BarButtonOutput::RightClick => GpuMsg::RightClick, + BarButtonOutput::MiddleClick => GpuMsg::MiddleClick, + BarButtonOutput::ScrollUp => GpuMsg::ScrollUp, + BarButtonOutput::ScrollDown => GpuMsg::ScrollDown, + }); + + watchers::spawn_watchers(&sender, gpu_config, &init.sysinfo); + + let model = Self { + bar_button, + config: init.config, + dropdowns: init.dropdowns, + }; + let bar_button = model.bar_button.widget(); + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender, _root: &Self::Root) { + let gpu_config = &self.config.config().modules.gpu; + + let action = match msg { + GpuMsg::LeftClick => gpu_config.left_click.get(), + GpuMsg::RightClick => gpu_config.right_click.get(), + GpuMsg::MiddleClick => gpu_config.middle_click.get(), + GpuMsg::ScrollUp => gpu_config.scroll_up.get(), + GpuMsg::ScrollDown => gpu_config.scroll_down.get(), + }; + + dropdowns::dispatch_click(&action, &self.dropdowns, &self.bar_button); + } + + fn update_cmd(&mut self, msg: GpuCmd, _sender: ComponentSender, _root: &Self::Root) { + match msg { + GpuCmd::UpdateLabel(label) => { + self.bar_button.emit(BarButtonInput::SetLabel(label)); + } + GpuCmd::UpdateIcon(icon) => { + self.bar_button.emit(BarButtonInput::SetIcon(icon)); + } + GpuCmd::UpdateThresholdColors(colors) => { + self.bar_button + .emit(BarButtonInput::SetThresholdColors(colors)); + } + } + } +} diff --git a/crates/wayle-shell/src/shell/bar/modules/gpu/watchers.rs b/crates/wayle-shell/src/shell/bar/modules/gpu/watchers.rs new file mode 100644 index 00000000..5a1f5307 --- /dev/null +++ b/crates/wayle-shell/src/shell/bar/modules/gpu/watchers.rs @@ -0,0 +1,58 @@ +use std::{sync::Arc, time::Duration}; + +use relm4::ComponentSender; +use wayle_config::schemas::{modules::GpuConfig, styling::evaluate_thresholds}; +use wayle_sysinfo::SysinfoService; +use wayle_widgets::watch; + +use super::{GpuModule, helpers::format_label, messages::GpuCmd}; + +pub(super) fn spawn_watchers( + sender: &ComponentSender, + config: &GpuConfig, + sysinfo: &Arc, +) { + let format = config.format.clone(); + let thresholds = config.thresholds.clone(); + + let sysinfo_gpu = sysinfo.clone(); + + let thresholds_watch = thresholds.clone(); + let sysinfo_thresholds = sysinfo.clone(); + watch!(sender, [thresholds_watch.watch()], |out| { + let gpu = sysinfo_thresholds.gpu.get(); + let colors = evaluate_thresholds( + gpu.average_utilization_percent as f64, + &thresholds_watch.get(), + ); + let _ = out.send(GpuCmd::UpdateThresholdColors(colors)); + }); + + watch!(sender, [sysinfo.gpu.watch()], |out| { + let gpu = sysinfo_gpu.gpu.get(); + let label = format_label(&format.get(), &gpu); + let _ = out.send(GpuCmd::UpdateLabel(label)); + + let colors = evaluate_thresholds(gpu.average_utilization_percent as f64, &thresholds.get()); + let _ = out.send(GpuCmd::UpdateThresholdColors(colors)); + }); + + let format_watch = config.format.clone(); + let sysinfo_format = sysinfo.clone(); + watch!(sender, [format_watch.watch()], |out| { + let gpu = sysinfo_format.gpu.get(); + let label = format_label(&format_watch.get(), &gpu); + let _ = out.send(GpuCmd::UpdateLabel(label)); + }); + + let icon_name = config.icon_name.clone(); + watch!(sender, [icon_name.watch()], |out| { + let _ = out.send(GpuCmd::UpdateIcon(icon_name.get().clone())); + }); + + let poll_interval = config.poll_interval_ms.clone(); + let sysinfo_interval = sysinfo.clone(); + watch!(sender, [poll_interval.watch()], |_out| { + sysinfo_interval.set_gpu_interval(Duration::from_millis(poll_interval.get())); + }); +} diff --git a/crates/wayle-shell/src/shell/bar/modules/mod.rs b/crates/wayle-shell/src/shell/bar/modules/mod.rs index f6ceb4ec..0e3f879b 100644 --- a/crates/wayle-shell/src/shell/bar/modules/mod.rs +++ b/crates/wayle-shell/src/shell/bar/modules/mod.rs @@ -6,6 +6,7 @@ mod compositor; mod cpu; mod custom; mod dashboard; +mod gpu; mod hyprland_workspaces; mod hyprsunset; mod idle_inhibit; @@ -63,6 +64,7 @@ register_modules! { Clock => clock::Factory, Cpu => cpu::Factory, Dashboard => dashboard::Factory, + Gpu => gpu::Factory, HyprlandWorkspaces => hyprland_workspaces::Factory, Hyprsunset => hyprsunset::Factory, IdleInhibit => idle_inhibit::Factory, diff --git a/crates/wayle-shell/src/watchers/sysinfo.rs b/crates/wayle-shell/src/watchers/sysinfo.rs index e3a366bd..d572bd0b 100644 --- a/crates/wayle-shell/src/watchers/sysinfo.rs +++ b/crates/wayle-shell/src/watchers/sysinfo.rs @@ -19,6 +19,7 @@ pub fn spawn(services: &ShellServices) { spawn_memory_watcher(&modules.ram, sysinfo); spawn_disk_watcher(&modules.storage, sysinfo); spawn_network_watcher(&modules.netstat, sysinfo); + spawn_gpu_watcher(&modules.gpu, sysinfo); } fn spawn_cpu_watcher( @@ -84,3 +85,19 @@ fn spawn_network_watcher( } }); } + +fn spawn_gpu_watcher( + config: &wayle_config::schemas::modules::GpuConfig, + sysinfo: &Arc, +) { + let mut stream = config.poll_interval_ms.watch(); + let sysinfo = sysinfo.clone(); + + tokio::spawn(async move { + stream.next().await; + + while let Some(interval_ms) = stream.next().await { + sysinfo.set_gpu_interval(Duration::from_millis(interval_ms)); + } + }); +} diff --git a/resources/icons/hicolor/scalable/actions/ld-gpu-symbolic.svg b/resources/icons/hicolor/scalable/actions/ld-gpu-symbolic.svg new file mode 100644 index 00000000..7478a534 --- /dev/null +++ b/resources/icons/hicolor/scalable/actions/ld-gpu-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + + From 9c5ce6046a5f610176990c34a5a4abdef4e30808 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 13 Apr 2026 11:50:44 +0200 Subject: [PATCH 2/4] remove duplicate config.poll_interval_ms watchers both the CPU and GPU module already handle this in sysinfo.rs --- crates/wayle-shell/src/shell/bar/modules/cpu/watchers.rs | 8 +------- crates/wayle-shell/src/shell/bar/modules/gpu/watchers.rs | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/crates/wayle-shell/src/shell/bar/modules/cpu/watchers.rs b/crates/wayle-shell/src/shell/bar/modules/cpu/watchers.rs index 426cee79..b780dccc 100644 --- a/crates/wayle-shell/src/shell/bar/modules/cpu/watchers.rs +++ b/crates/wayle-shell/src/shell/bar/modules/cpu/watchers.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use relm4::ComponentSender; use wayle_config::schemas::{modules::CpuConfig, styling::evaluate_thresholds}; @@ -52,10 +52,4 @@ pub(super) fn spawn_watchers( watch!(sender, [temp_sensor.watch()], |_out| { sysinfo_sensor.set_cpu_temp_sensor(&temp_sensor.get()); }); - - let poll_interval = config.poll_interval_ms.clone(); - let sysinfo_interval = sysinfo.clone(); - watch!(sender, [poll_interval.watch()], |_out| { - sysinfo_interval.set_cpu_interval(Duration::from_millis(poll_interval.get())); - }); } diff --git a/crates/wayle-shell/src/shell/bar/modules/gpu/watchers.rs b/crates/wayle-shell/src/shell/bar/modules/gpu/watchers.rs index 5a1f5307..a18e97d0 100644 --- a/crates/wayle-shell/src/shell/bar/modules/gpu/watchers.rs +++ b/crates/wayle-shell/src/shell/bar/modules/gpu/watchers.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use relm4::ComponentSender; use wayle_config::schemas::{modules::GpuConfig, styling::evaluate_thresholds}; @@ -49,10 +49,4 @@ pub(super) fn spawn_watchers( watch!(sender, [icon_name.watch()], |out| { let _ = out.send(GpuCmd::UpdateIcon(icon_name.get().clone())); }); - - let poll_interval = config.poll_interval_ms.clone(); - let sysinfo_interval = sysinfo.clone(); - watch!(sender, [poll_interval.watch()], |_out| { - sysinfo_interval.set_gpu_interval(Duration::from_millis(poll_interval.get())); - }); } From 2627f3943539a479197a902b0f1f24e04ca90a0a Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 13 Apr 2026 12:22:56 +0200 Subject: [PATCH 3/4] expose more stuff --- .../src/schemas/modules/gpu/mod.rs | 20 +++- .../src/shell/bar/modules/gpu/helpers.rs | 111 ++++++++++++++++-- 2 files changed, 112 insertions(+), 19 deletions(-) diff --git a/crates/wayle-config/src/schemas/modules/gpu/mod.rs b/crates/wayle-config/src/schemas/modules/gpu/mod.rs index 1a85e925..338fc7ca 100644 --- a/crates/wayle-config/src/schemas/modules/gpu/mod.rs +++ b/crates/wayle-config/src/schemas/modules/gpu/mod.rs @@ -25,9 +25,11 @@ pub struct GpuConfig { /// /// - `{{ count }}` - Number of detected GPUs /// - `{{ active_count }}` - Number of GPUs currently reporting utilization - /// - `{{ percent }}` - Average GPU core utilization (0-100) - /// - `{{ mem_percent }}` - Average GPU memory utilization (0-100) - /// - `{{ temp_c }}` - Maximum GPU temperature in Celsius (if available) + /// - `{{ avg_percent }}` - Average GPU core utilization (0-100) + /// - `{{ avg_mem_percent }}` - Average GPU memory utilization (0-100) + /// - `{{ max_temp_c }}` - Maximum GPU temperature in Celsius (if available) + /// - `{{ total_power_w }}` - Total GPU power draw in watts across devices + /// - `{{ hottest_gpu_name }}` - Name of the hottest GPU /// /// ## Per-GPU Placeholders (first two GPUs) /// @@ -36,14 +38,20 @@ pub struct GpuConfig { /// - `{{ gpu0_temp_c }}`, `{{ gpu1_temp_c }}` /// - `{{ gpu0_mem_used_gib }}`, `{{ gpu1_mem_used_gib }}` /// - `{{ gpu0_mem_total_gib }}`, `{{ gpu1_mem_total_gib }}` + /// - `{{ gpu0_name }}`, `{{ gpu1_name }}` + /// - `{{ gpu0_power_w }}`, `{{ gpu1_power_w }}` + /// - `{{ gpu0_power_limit_w }}`, `{{ gpu1_power_limit_w }}` + /// - `{{ gpu0_fan_percent }}`, `{{ gpu1_fan_percent }}` + /// - `{{ gpu0_graphics_mhz }}`, `{{ gpu1_graphics_mhz }}` + /// - `{{ gpu0_memory_mhz }}`, `{{ gpu1_memory_mhz }}` /// /// ## Examples /// - /// - `"{{ percent }}%"` - `"37%"` + /// - `"{{ avg_percent }}%"` - `"37%"` /// - `"{{ gpu0_percent }}% | {{ gpu1_percent }}%"` - `"52% | 11%"` - /// - `"{{ percent }}% VRAM {{ mem_percent }}%"` - `"37% VRAM 42%"` + /// - `"{{ avg_percent }}% VRAM {{ avg_mem_percent }}%"` - `"37% VRAM 42%"` #[serde(rename = "format")] - #[default(String::from("{{ percent }}%"))] + #[default(String::from("{{ avg_percent }}%"))] pub format: ConfigProperty, /// Icon name. diff --git a/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs b/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs index f0b4bf65..d8c06566 100644 --- a/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs +++ b/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs @@ -8,9 +8,11 @@ use wayle_sysinfo::types::{GpuData, GpuDeviceData}; /// /// - `{{ count }}` - Number of detected GPUs /// - `{{ active_count }}` - Number of GPUs reporting utilization -/// - `{{ percent }}` - Average GPU core utilization (00-100, zero-padded) -/// - `{{ mem_percent }}` - Average GPU memory utilization (00-100, zero-padded) -/// - `{{ temp_c }}` - Maximum GPU temperature across devices (zero-padded) +/// - `{{ avg_percent }}` - Average GPU core utilization (00-100, zero-padded) +/// - `{{ avg_mem_percent }}` - Average GPU memory utilization (00-100, zero-padded) +/// - `{{ max_temp_c }}` - Maximum GPU temperature across devices (zero-padded) +/// - `{{ total_power_w }}` - Total GPU power draw in watts across devices +/// - `{{ hottest_gpu_name }}` - Name of the hottest GPU /// /// ## Per-device Variables (first two GPUs) /// @@ -19,6 +21,12 @@ use wayle_sysinfo::types::{GpuData, GpuDeviceData}; /// - `{{ gpu0_temp_c }}`, `{{ gpu1_temp_c }}` /// - `{{ gpu0_mem_used_gib }}`, `{{ gpu1_mem_used_gib }}` /// - `{{ gpu0_mem_total_gib }}`, `{{ gpu1_mem_total_gib }}` +/// - `{{ gpu0_name }}`, `{{ gpu1_name }}` +/// - `{{ gpu0_power_w }}`, `{{ gpu1_power_w }}` +/// - `{{ gpu0_power_limit_w }}`, `{{ gpu1_power_limit_w }}` +/// - `{{ gpu0_fan_percent }}`, `{{ gpu1_fan_percent }}` +/// - `{{ gpu0_graphics_mhz }}`, `{{ gpu1_graphics_mhz }}` +/// - `{{ gpu0_memory_mhz }}`, `{{ gpu1_memory_mhz }}` pub(super) fn format_label(format: &str, gpu: &GpuData) -> String { let gpu0 = gpu.devices.iter().find(|device| device.index == 0); let gpu1 = gpu.devices.iter().find(|device| device.index == 1); @@ -29,12 +37,24 @@ pub(super) fn format_label(format: &str, gpu: &GpuData) -> String { .filter_map(|d| d.temperature_celsius) .fold(0.0_f32, f32::max); + let total_power_watts: f32 = gpu.devices.iter().filter_map(|d| d.power_watts).sum(); + + let hottest_gpu_name = gpu + .devices + .iter() + .filter_map(|d| d.temperature_celsius.map(|temp| (d.name.as_str(), temp))) + .max_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(name, _)| name) + .unwrap_or(""); + let ctx = json!({ "count": gpu.total_count, "active_count": gpu.active_count, - "percent": format!("{:02.0}", gpu.average_utilization_percent), - "mem_percent": format!("{:02.0}", gpu.average_memory_utilization_percent), - "temp_c": format!("{max_temp_c:02.0}"), + "avg_percent": format!("{:02.0}", gpu.average_utilization_percent), + "avg_mem_percent": format!("{:02.0}", gpu.average_memory_utilization_percent), + "max_temp_c": format!("{max_temp_c:02.0}"), + "total_power_w": format_float(Some(total_power_watts), 1), + "hottest_gpu_name": hottest_gpu_name, "gpu0_percent": format_percent(gpu0.and_then(|d| d.utilization_percent)), "gpu1_percent": format_percent(gpu1.and_then(|d| d.utilization_percent)), @@ -49,6 +69,24 @@ pub(super) fn format_label(format: &str, gpu: &GpuData) -> String { "gpu1_mem_used_gib": gib(gpu1.and_then(|d| d.memory_used_bytes)), "gpu0_mem_total_gib": gib(gpu0.and_then(|d| d.memory_total_bytes)), "gpu1_mem_total_gib": gib(gpu1.and_then(|d| d.memory_total_bytes)), + + "gpu0_name": text(gpu0.map(|d| d.name.as_str())), + "gpu1_name": text(gpu1.map(|d| d.name.as_str())), + + "gpu0_power_w": format_float(gpu0.and_then(|d| d.power_watts), 1), + "gpu1_power_w": format_float(gpu1.and_then(|d| d.power_watts), 1), + + "gpu0_power_limit_w": format_float(gpu0.and_then(|d| d.power_limit_watts), 1), + "gpu1_power_limit_w": format_float(gpu1.and_then(|d| d.power_limit_watts), 1), + + "gpu0_fan_percent": format_percent(gpu0.and_then(|d| d.fan_speed_percent)), + "gpu1_fan_percent": format_percent(gpu1.and_then(|d| d.fan_speed_percent)), + + "gpu0_graphics_mhz": format_u32(gpu0.and_then(|d| d.graphics_clock_mhz)), + "gpu1_graphics_mhz": format_u32(gpu1.and_then(|d| d.graphics_clock_mhz)), + + "gpu0_memory_mhz": format_u32(gpu0.and_then(|d| d.memory_clock_mhz)), + "gpu1_memory_mhz": format_u32(gpu1.and_then(|d| d.memory_clock_mhz)), }); crate::template::render(format, ctx).unwrap_or_default() @@ -58,6 +96,18 @@ fn format_percent(value: Option) -> String { format!("{:02.0}", value.unwrap_or(0.0)) } +fn format_float(value: Option, decimals: usize) -> String { + format!("{:.decimals$}", value.unwrap_or(0.0), decimals = decimals) +} + +fn format_u32(value: Option) -> String { + value.unwrap_or(0).to_string() +} + +fn text(value: Option<&str>) -> String { + value.unwrap_or("").to_string() +} + fn gib(bytes: Option) -> String { format!("{:.1}", ByteSize::b(bytes.unwrap_or(0)).as_gib()) } @@ -87,11 +137,11 @@ mod tests { Some(0.0) }, temperature_celsius: temp, - power_watts: None, - power_limit_watts: None, - fan_speed_percent: None, - graphics_clock_mhz: None, - memory_clock_mhz: None, + power_watts: Some(100.0 + index as f32), + power_limit_watts: Some(250.0), + fan_speed_percent: Some(40.0 + index as f32), + graphics_clock_mhz: Some(1800 + index), + memory_clock_mhz: Some(9000 + index), } } @@ -118,7 +168,7 @@ mod tests { #[test] fn format_label_replaces_aggregate_placeholders() { let gpu = gpu_data(vec![], 37.2, 42.1); - let out = format_label("{{ percent }}% VRAM {{ mem_percent }}% ({{ count }})", &gpu); + let out = format_label("{{ avg_percent }}% VRAM {{ avg_mem_percent }}% ({{ count }})", &gpu); assert_eq!(out, "37% VRAM 42% (0)"); } @@ -132,7 +182,7 @@ mod tests { 15.0, 19.0, ); - let out = format_label("{{ temp_c }}C", &gpu); + let out = format_label("{{ max_temp_c }}C", &gpu); assert_eq!(out, "73C"); } @@ -184,4 +234,39 @@ mod tests { let out = format_label("{{ gpu0_mem_used_gib }}/{{ gpu0_mem_total_gib }}", &gpu); assert_eq!(out, "1.5/12.0"); } + + #[test] + fn format_label_replaces_name_power_fan_clock_placeholders() { + let gpu = gpu_data( + vec![ + device(0, Some(52.0), 3 * GIB, 8 * GIB, Some(65.0)), + device(1, Some(11.0), 1 * GIB, 8 * GIB, Some(49.0)), + ], + 31.5, + 25.0, + ); + + let out = format_label( + "{{ gpu0_name }} {{ gpu0_power_w }}/{{ gpu0_power_limit_w }}W {{ gpu0_fan_percent }}% {{ gpu0_graphics_mhz }}/{{ gpu0_memory_mhz }}", + &gpu, + ); + + assert_eq!(out, "GPU 0 100.0/250.0W 40% 1800/9000"); + } + + #[test] + fn format_label_replaces_aggregate_power_and_hottest_name() { + let gpu = gpu_data( + vec![ + device(0, Some(52.0), 3 * GIB, 8 * GIB, Some(65.0)), + device(1, Some(11.0), 1 * GIB, 8 * GIB, Some(73.0)), + ], + 31.5, + 25.0, + ); + + let out = format_label("{{ max_temp_c }} {{ total_power_w }} {{ hottest_gpu_name }}", &gpu); + + assert_eq!(out, "73 201.0 GPU 1"); + } } From 166aa97f86286c45064146e8f5d61abee4dc2a45 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 13 Apr 2026 12:28:27 +0200 Subject: [PATCH 4/4] clippy --- .../src/shell/bar/modules/gpu/helpers.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs b/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs index d8c06566..f17401da 100644 --- a/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs +++ b/crates/wayle-shell/src/shell/bar/modules/gpu/helpers.rs @@ -1,6 +1,6 @@ use bytesize::ByteSize; use serde_json::json; -use wayle_sysinfo::types::{GpuData, GpuDeviceData}; +use wayle_sysinfo::types::GpuData; /// Formats a GPU label using Jinja2 template syntax. /// @@ -168,7 +168,10 @@ mod tests { #[test] fn format_label_replaces_aggregate_placeholders() { let gpu = gpu_data(vec![], 37.2, 42.1); - let out = format_label("{{ avg_percent }}% VRAM {{ avg_mem_percent }}% ({{ count }})", &gpu); + let out = format_label( + "{{ avg_percent }}% VRAM {{ avg_mem_percent }}% ({{ count }})", + &gpu, + ); assert_eq!(out, "37% VRAM 42% (0)"); } @@ -213,7 +216,11 @@ mod tests { #[test] fn format_label_uses_device_index_not_vector_position() { - let gpu = gpu_data(vec![device(1, Some(11.0), 1 * GIB, 8 * GIB, Some(49.0))], 11.0, 12.5); + let gpu = gpu_data( + vec![device(1, Some(11.0), 1 * GIB, 8 * GIB, Some(49.0))], + 11.0, + 12.5, + ); let out = format_label("{{ gpu0_percent }}% | {{ gpu1_percent }}%", &gpu); assert_eq!(out, "00% | 11%"); } @@ -265,7 +272,10 @@ mod tests { 25.0, ); - let out = format_label("{{ max_temp_c }} {{ total_power_w }} {{ hottest_gpu_name }}", &gpu); + let out = format_label( + "{{ max_temp_c }} {{ total_power_w }} {{ hottest_gpu_name }}", + &gpu, + ); assert_eq!(out, "73 201.0 GPU 1"); }