From b8c07b47ab19ee5096252eb5bdb89bcaed4a0071 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:45:17 -0500 Subject: [PATCH] Add system controls dashboard widget --- src/actions/system.rs | 240 +++++++++++++++++ src/dashboard/widgets/mod.rs | 7 + src/dashboard/widgets/system_controls.rs | 328 +++++++++++++++++++++++ src/launcher.rs | 66 +++++ 4 files changed, 641 insertions(+) create mode 100644 src/dashboard/widgets/system_controls.rs diff --git a/src/actions/system.rs b/src/actions/system.rs index 90b1d7c5..defae6ce 100644 --- a/src/actions/system.rs +++ b/src/actions/system.rs @@ -1,5 +1,12 @@ use sysinfo::System; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PowerPlan { + pub guid: String, + pub name: String, + pub active: bool, +} + pub fn run_system(cmd: &str) -> anyhow::Result<()> { if let Some(mut command) = super::super::launcher::system_command(cmd) { command.spawn().map(|_| ()).map_err(|e| e.into()) @@ -40,6 +47,10 @@ pub fn set_volume(v: u32) { super::super::launcher::set_system_volume(v); } +pub fn toggle_system_mute() { + super::super::launcher::toggle_system_mute(); +} + pub fn mute_active_window() { super::super::launcher::mute_active_window(); } @@ -52,6 +63,235 @@ pub fn toggle_process_mute(pid: u32) { super::super::launcher::toggle_process_mute(pid); } +pub fn get_system_volume() -> Option { + #[cfg(target_os = "windows")] + { + use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; + use windows::Win32::Media::Audio::{ + eMultimedia, eRender, IMMDeviceEnumerator, MMDeviceEnumerator, + }; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, + }; + + unsafe { + let mut percent = None; + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + if let Ok(enm) = + CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL) + { + if let Ok(device) = enm.GetDefaultAudioEndpoint(eRender, eMultimedia) { + if let Ok(vol) = device.Activate::(CLSCTX_ALL, None) { + if let Ok(val) = vol.GetMasterVolumeLevelScalar() { + percent = Some((val * 100.0).round() as u8); + } + } + } + } + CoUninitialize(); + percent + } + } + + #[cfg(not(target_os = "windows"))] + { + None + } +} + +pub fn get_system_mute() -> Option { + #[cfg(target_os = "windows")] + { + use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; + use windows::Win32::Media::Audio::{ + eMultimedia, eRender, IMMDeviceEnumerator, MMDeviceEnumerator, + }; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, + }; + + unsafe { + let mut muted = None; + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + if let Ok(enm) = + CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL) + { + if let Ok(device) = enm.GetDefaultAudioEndpoint(eRender, eMultimedia) { + if let Ok(vol) = device.Activate::(CLSCTX_ALL, None) { + if let Ok(val) = vol.GetMute() { + muted = Some(val.as_bool()); + } + } + } + } + CoUninitialize(); + muted + } + } + + #[cfg(not(target_os = "windows"))] + { + None + } +} + +pub fn get_main_display_brightness() -> Option { + #[cfg(target_os = "windows")] + { + use windows::Win32::Devices::Display::{ + DestroyPhysicalMonitors, GetMonitorBrightness, GetNumberOfPhysicalMonitorsFromHMONITOR, + GetPhysicalMonitorsFromHMONITOR, PHYSICAL_MONITOR, + }; + use windows::Win32::Foundation::{BOOL, LPARAM, RECT}; + use windows::Win32::Graphics::Gdi::{EnumDisplayMonitors, HDC, HMONITOR}; + + unsafe extern "system" fn enum_monitors( + hmonitor: HMONITOR, + _hdc: HDC, + _rect: *mut RECT, + lparam: LPARAM, + ) -> BOOL { + let percent_ptr = lparam.0 as *mut u32; + let mut count: u32 = 0; + if GetNumberOfPhysicalMonitorsFromHMONITOR(hmonitor, &mut count).is_ok() { + let mut monitors = vec![PHYSICAL_MONITOR::default(); count as usize]; + if GetPhysicalMonitorsFromHMONITOR(hmonitor, &mut monitors).is_ok() { + if let Some(m) = monitors.first() { + let mut min = 0u32; + let mut cur = 0u32; + let mut max = 0u32; + if GetMonitorBrightness(m.hPhysicalMonitor, &mut min, &mut cur, &mut max) + != 0 + { + if max > min { + *percent_ptr = ((cur - min) * 100 / (max - min)) as u32; + } else { + *percent_ptr = 0; + } + } + } + let _ = DestroyPhysicalMonitors(&monitors); + } + } + false.into() + } + + let mut percent: u32 = 50; + unsafe { + let _ = EnumDisplayMonitors( + HDC(std::ptr::null_mut()), + None, + Some(enum_monitors), + LPARAM(&mut percent as *mut u32 as isize), + ); + } + Some(percent as u8) + } + + #[cfg(not(target_os = "windows"))] + { + None + } +} + +pub fn get_power_plans() -> Result, String> { + #[cfg(target_os = "windows")] + { + use std::process::Command; + + let output = Command::new("powercfg") + .arg("/L") + .output() + .map_err(|err| format!("Failed to query power plans: {err}"))?; + if !output.status.success() { + return Err("Failed to query power plans.".into()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let plans = parse_powercfg_list(&stdout); + if plans.is_empty() { + Err("No power plans detected.".into()) + } else { + Ok(plans) + } + } + + #[cfg(not(target_os = "windows"))] + { + Err("Power plans are not supported on this OS.".into()) + } +} + +pub fn set_power_plan(guid: &str) -> anyhow::Result<()> { + #[cfg(target_os = "windows")] + { + use std::process::Command; + + Command::new("powercfg").arg("/S").arg(guid).status()?; + return Ok(()); + } + + #[cfg(not(target_os = "windows"))] + { + let _ = guid; + Ok(()) + } +} + +#[cfg(target_os = "windows")] +fn parse_powercfg_list(output: &str) -> Vec { + let mut plans = Vec::new(); + for line in output.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("Power Scheme GUID:") else { + continue; + }; + let rest = rest.trim(); + let mut parts = rest.splitn(2, ' '); + let guid = parts.next().unwrap_or("").trim(); + let details = parts.next().unwrap_or("").trim(); + if guid.is_empty() { + continue; + } + let name = if let Some(start) = details.find('(') { + if let Some(end) = details[start + 1..].find(')') { + details[start + 1..start + 1 + end].trim() + } else { + details + } + } else { + details + }; + let active = details.contains('*'); + plans.push(PowerPlan { + guid: guid.to_string(), + name: name.to_string(), + active, + }); + } + plans +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(target_os = "windows")] + fn parse_powercfg_output() { + let sample = r#" +Power Scheme GUID: 381b4222-f694-41f0-9685-ff5bb260df2e (Balanced) * +Power Scheme GUID: 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c (High performance) +Power Scheme GUID: a1841308-3541-4fab-bc81-f71556f20b4a (Power saver) +"#; + let plans = parse_powercfg_list(sample); + assert_eq!(plans.len(), 3); + assert_eq!(plans[0].name, "Balanced"); + assert!(plans[0].active); + assert_eq!(plans[1].guid, "8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c"); + assert!(!plans[1].active); + } +} + pub fn recycle_clean() { // Emptying the recycle bin can take a noticeable amount of time on // Windows. Running it on the current thread would block the UI and diff --git a/src/dashboard/widgets/mod.rs b/src/dashboard/widgets/mod.rs index e85c998a..3da8afa5 100644 --- a/src/dashboard/widgets/mod.rs +++ b/src/dashboard/widgets/mod.rs @@ -30,6 +30,7 @@ mod recycle_bin; mod snippets_favorites; mod stopwatch; mod system_actions; +mod system_controls; mod system_status; mod tempfiles; mod todo; @@ -62,6 +63,7 @@ pub use recycle_bin::RecycleBinWidget; pub use snippets_favorites::SnippetsFavoritesWidget; pub use stopwatch::StopwatchWidget; pub use system_actions::SystemWidget; +pub use system_controls::SystemControlsWidget; pub use system_status::SystemStatusWidget; pub use tempfiles::TempfilesWidget; pub use todo::TodoWidget; @@ -296,6 +298,11 @@ impl WidgetRegistry { "system", WidgetFactory::new(SystemWidget::new).with_settings_ui(SystemWidget::settings_ui), ); + reg.register( + "system_controls", + WidgetFactory::new(SystemControlsWidget::new) + .with_settings_ui(SystemControlsWidget::settings_ui), + ); reg.register( "system_status", WidgetFactory::new(SystemStatusWidget::new) diff --git a/src/dashboard/widgets/system_controls.rs b/src/dashboard/widgets/system_controls.rs new file mode 100644 index 00000000..259ee4a5 --- /dev/null +++ b/src/dashboard/widgets/system_controls.rs @@ -0,0 +1,328 @@ +use super::{ + default_refresh_throttle_secs, edit_typed_settings, refresh_schedule, refresh_settings_ui, + run_refresh_schedule, RefreshMode, TimedCache, Widget, WidgetAction, WidgetSettingsContext, + WidgetSettingsUiResult, +}; +use crate::actions::system::{ + get_main_display_brightness, get_power_plans, get_system_mute, get_system_volume, PowerPlan, +}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use eframe::egui; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +fn default_refresh_interval() -> f32 { + 5.0 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemControlsConfig { + #[serde(default = "default_refresh_interval")] + pub refresh_interval_secs: f32, + #[serde(default)] + pub refresh_mode: RefreshMode, + #[serde(default = "default_refresh_throttle_secs")] + pub refresh_throttle_secs: f32, + #[serde(default)] + pub manual_refresh_only: bool, +} + +impl Default for SystemControlsConfig { + fn default() -> Self { + Self { + refresh_interval_secs: default_refresh_interval(), + refresh_mode: RefreshMode::Auto, + refresh_throttle_secs: default_refresh_throttle_secs(), + manual_refresh_only: false, + } + } +} + +#[derive(Clone, Default)] +struct SystemControlsSnapshot { + brightness_percent: Option, + brightness_error: Option, + volume_percent: Option, + volume_error: Option, + muted: Option, + mute_error: Option, + power_plans: Vec, + power_plan_error: Option, + active_power_plan: Option, +} + +pub struct SystemControlsWidget { + cfg: SystemControlsConfig, + cache: TimedCache, + refresh_pending: bool, +} + +impl SystemControlsWidget { + pub fn new(cfg: SystemControlsConfig) -> Self { + let interval = Duration::from_secs_f32(cfg.refresh_interval_secs.max(1.0)); + Self { + cfg, + cache: TimedCache::new(SystemControlsSnapshot::default(), interval), + refresh_pending: true, + } + } + + pub fn settings_ui( + ui: &mut egui::Ui, + value: &mut serde_json::Value, + ctx: &WidgetSettingsContext<'_>, + ) -> WidgetSettingsUiResult { + edit_typed_settings(ui, value, ctx, |ui, cfg: &mut SystemControlsConfig, _ctx| { + refresh_settings_ui( + ui, + &mut cfg.refresh_interval_secs, + &mut cfg.refresh_mode, + &mut cfg.refresh_throttle_secs, + Some(&mut cfg.manual_refresh_only), + "System control data is cached. The widget will skip refreshing until this many seconds have passed. Use Refresh to update immediately.", + ) + }) + } + + fn refresh_interval(&self) -> Duration { + Duration::from_secs_f32(self.cfg.refresh_interval_secs.max(1.0)) + } + + fn update_interval(&mut self) { + self.cache.set_interval(self.refresh_interval()); + } + + fn refresh(&mut self) { + self.update_interval(); + let mut snapshot = SystemControlsSnapshot::default(); + + if cfg!(target_os = "windows") { + snapshot.brightness_percent = get_main_display_brightness(); + if snapshot.brightness_percent.is_none() { + snapshot.brightness_error = Some("Brightness unavailable.".into()); + } + } else { + snapshot.brightness_error = + Some("Brightness controls are not supported on this OS.".into()); + } + + if cfg!(target_os = "windows") { + snapshot.volume_percent = get_system_volume(); + snapshot.muted = get_system_mute(); + if snapshot.volume_percent.is_none() { + snapshot.volume_error = Some("System volume unavailable.".into()); + } + if snapshot.muted.is_none() { + snapshot.mute_error = Some("Mute status unavailable.".into()); + } + } else { + snapshot.volume_error = Some("Volume controls are not supported on this OS.".into()); + snapshot.mute_error = Some("Mute controls are not supported on this OS.".into()); + } + + if cfg!(target_os = "windows") { + match get_power_plans() { + Ok(plans) => { + snapshot.active_power_plan = + plans.iter().find(|p| p.active).map(|p| p.guid.clone()); + if snapshot.active_power_plan.is_none() { + snapshot.active_power_plan = plans.first().map(|p| p.guid.clone()); + } + snapshot.power_plans = plans; + } + Err(err) => snapshot.power_plan_error = Some(err), + } + } else { + snapshot.power_plan_error = + Some("Power plans are not supported on this OS.".into()); + } + + self.cache.refresh(|data| *data = snapshot); + } + + fn maybe_refresh(&mut self, ctx: &DashboardContext<'_>) { + self.update_interval(); + let schedule = refresh_schedule( + self.refresh_interval(), + self.cfg.refresh_mode, + self.cfg.manual_refresh_only, + self.cfg.refresh_throttle_secs, + ); + if run_refresh_schedule( + ctx, + schedule, + &mut self.refresh_pending, + &mut self.cache.last_refresh, + ) { + self.refresh(); + } + } + + fn action(label: String, action: String) -> WidgetAction { + WidgetAction { + action: Action { + label, + desc: "System controls".into(), + action, + args: None, + }, + query_override: None, + } + } + + fn power_plan_label<'a>(plans: &'a [PowerPlan], guid: &str) -> &'a str { + plans + .iter() + .find(|plan| plan.guid == guid) + .map(|plan| plan.name.as_str()) + .unwrap_or("Select power plan") + } +} + +impl Default for SystemControlsWidget { + fn default() -> Self { + Self::new(SystemControlsConfig::default()) + } +} + +impl Widget for SystemControlsWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + self.maybe_refresh(ctx); + let mut clicked = None; + + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("Brightness"); + if let Some(level) = self.cache.data.brightness_percent.as_mut() { + let resp = ui.add(egui::Slider::new(level, 0..=100).text("Level")); + if resp.changed() { + let label = format!("Set brightness to {level}%"); + let action = format!("brightness:set:{level}"); + clicked.get_or_insert_with(|| Self::action(label, action)); + } + ui.label(format!("{level}%")); + } else if let Some(err) = &self.cache.data.brightness_error { + ui.colored_label(egui::Color32::YELLOW, err); + } + }); + + ui.horizontal(|ui| { + ui.label("Volume"); + if let Some(level) = self.cache.data.volume_percent.as_mut() { + let resp = ui.add(egui::Slider::new(level, 0..=100).text("Level")); + if resp.changed() { + let label = format!("Set volume to {level}%"); + let action = format!("volume:set:{level}"); + clicked.get_or_insert_with(|| Self::action(label, action)); + } + ui.label(format!("{level}%")); + } else if let Some(err) = &self.cache.data.volume_error { + ui.colored_label(egui::Color32::YELLOW, err); + } + + if let Some(muted) = self.cache.data.muted.as_mut() { + if ui.checkbox(muted, "Mute").changed() { + clicked.get_or_insert_with(|| { + Self::action("Toggle system mute".into(), "volume:toggle_mute".into()) + }); + } + } else if let Some(err) = &self.cache.data.mute_error { + ui.colored_label(egui::Color32::YELLOW, err); + } + }); + + ui.horizontal(|ui| { + ui.label("Power plan"); + if self.cache.data.power_plans.is_empty() { + if let Some(err) = &self.cache.data.power_plan_error { + ui.colored_label(egui::Color32::YELLOW, err); + } else { + ui.colored_label(egui::Color32::YELLOW, "No power plans available."); + } + } else { + let mut selection = self + .cache + .data + .active_power_plan + .clone() + .unwrap_or_else(|| self.cache.data.power_plans[0].guid.clone()); + let mut selection_changed = None; + let selected_label = + Self::power_plan_label(&self.cache.data.power_plans, &selection); + egui::ComboBox::from_id_source("system_controls_power_plan") + .selected_text(selected_label) + .show_ui(ui, |ui| { + for plan in &self.cache.data.power_plans { + if ui + .selectable_value( + &mut selection, + plan.guid.clone(), + plan.name.clone(), + ) + .changed() + { + selection_changed = Some((plan.name.clone(), plan.guid.clone())); + } + } + }); + if let Some((name, guid)) = selection_changed { + let label = format!("Set power plan to {}", name); + let action = format!("power:plan:set:{}", guid); + clicked.get_or_insert_with(|| Self::action(label, action)); + self.cache.data.active_power_plan = Some(selection.clone()); + for plan in &mut self.cache.data.power_plans { + plan.active = plan.guid == selection; + } + } + } + }); + }); + + clicked + } + + fn on_config_updated(&mut self, settings: &serde_json::Value) { + if let Ok(cfg) = serde_json::from_value::(settings.clone()) { + self.cfg = cfg; + self.update_interval(); + self.cache.invalidate(); + self.refresh_pending = true; + } + } + + fn header_ui( + &mut self, + ui: &mut egui::Ui, + _ctx: &DashboardContext<'_>, + ) -> Option { + let schedule = refresh_schedule( + self.refresh_interval(), + self.cfg.refresh_mode, + self.cfg.manual_refresh_only, + self.cfg.refresh_throttle_secs, + ); + let tooltip = match schedule.mode { + RefreshMode::Manual => "Manual refresh only.".to_string(), + RefreshMode::Throttled => { + format!( + "Minimum refresh interval {:.0}s.", + schedule.throttle.as_secs_f32() + ) + } + RefreshMode::Auto => format!( + "Cached for {:.0}s. Refresh to update system controls immediately.", + self.cfg.refresh_interval_secs + ), + }; + if ui.small_button("Refresh").on_hover_text(tooltip).clicked() { + self.refresh_pending = true; + } + None + } +} diff --git a/src/launcher.rs b/src/launcher.rs index 0904e564..8235c968 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -26,6 +26,32 @@ pub(crate) fn set_system_volume(percent: u32) { } } +pub(crate) fn toggle_system_mute() { + use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; + use windows::Win32::Media::Audio::{ + eMultimedia, eRender, IMMDeviceEnumerator, MMDeviceEnumerator, + }; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, + }; + + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + if let Ok(enm) = + CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL) + { + if let Ok(device) = enm.GetDefaultAudioEndpoint(eRender, eMultimedia) { + if let Ok(vol) = device.Activate::(CLSCTX_ALL, None) { + if let Ok(val) = vol.GetMute() { + let _ = vol.SetMute(!val.as_bool(), std::ptr::null()); + } + } + } + } + CoUninitialize(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -43,6 +69,31 @@ mod tests { ActionKind::VolumeToggleMuteProcess { pid: 42 } ); } + + #[test] + fn parse_volume_toggle_mute() { + let action = Action { + label: String::new(), + desc: String::new(), + action: "volume:toggle_mute".into(), + args: None, + }; + assert_eq!(parse_action_kind(&action), ActionKind::VolumeToggleMute); + } + + #[test] + fn parse_power_plan_set() { + let action = Action { + label: String::new(), + desc: String::new(), + action: "power:plan:set:balanced".into(), + args: None, + }; + assert_eq!( + parse_action_kind(&action), + ActionKind::PowerPlanSet { guid: "balanced" } + ); + } } pub(crate) fn mute_active_window() { @@ -370,6 +421,10 @@ enum ActionKind<'a> { pid: u32, }, VolumeMuteActive, + VolumeToggleMute, + PowerPlanSet { + guid: &'a str, + }, Screenshot { mode: crate::actions::screenshot::Mode, clip: bool, @@ -677,6 +732,12 @@ fn parse_action_kind(action: &Action) -> ActionKind<'_> { if s == "volume:mute_active" { return ActionKind::VolumeMuteActive; } + if s == "volume:toggle_mute" { + return ActionKind::VolumeToggleMute; + } + if let Some(guid) = s.strip_prefix("power:plan:set:") { + return ActionKind::PowerPlanSet { guid }; + } if let Some(mode) = s.strip_prefix("screenshot:") { use crate::actions::screenshot::Mode as ScreenshotMode; return match mode { @@ -944,6 +1005,10 @@ pub fn launch_action(action: &Action) -> anyhow::Result<()> { system::mute_active_window(); Ok(()) } + ActionKind::VolumeToggleMute => { + system::toggle_system_mute(); + Ok(()) + } ActionKind::Screenshot { mode, clip } => { crate::actions::screenshot::capture(mode, clip)?; Ok(()) @@ -989,6 +1054,7 @@ pub fn launch_action(action: &Action) -> anyhow::Result<()> { crate::plugins::macros::run_macro(name)?; Ok(()) } + ActionKind::PowerPlanSet { guid } => system::set_power_plan(guid), ActionKind::ExecPath { path, args } => exec::launch(path, args), } }