diff --git a/i18n/en/cachyos_hello.ftl b/i18n/en/cachyos_hello.ftl index 95d341e5..f2f2c0c5 100644 --- a/i18n/en/cachyos_hello.ftl +++ b/i18n/en/cachyos_hello.ftl @@ -43,6 +43,7 @@ tweak-bpftune-tooltip = Automatically tune system network tweak-bluetooth-tooltip = Enable support for Bluetooth wireless devices (mice, audio, etc.) tweak-ananicycpp-tooltip = Auto-adjust process priorities for better system responsiveness tweak-cachyupdate-tooltip = Update notifier in tray +tweak-refreshswitch-tooltip = Auto-switch display refresh rate on battery (low Hz) and AC (high Hz) for laptops # Tweaks page (fixes) remove-lock-title = Remove db lock diff --git a/refresh-switch/Cargo.lock b/refresh-switch/Cargo.lock new file mode 100644 index 00000000..aa9e0161 --- /dev/null +++ b/refresh-switch/Cargo.lock @@ -0,0 +1,306 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "ctrlc" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "refresh-switch" +version = "0.1.0" +dependencies = [ + "ctrlc", + "dbus", + "serde", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/refresh-switch/Cargo.toml b/refresh-switch/Cargo.toml new file mode 100644 index 00000000..efecb6a4 --- /dev/null +++ b/refresh-switch/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "refresh-switch" +version = "0.1.0" +edition = "2024" + +[dependencies] +dbus = "0.9" +ctrlc = "3.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/refresh-switch/README.md b/refresh-switch/README.md new file mode 100644 index 00000000..c33c46a1 --- /dev/null +++ b/refresh-switch/README.md @@ -0,0 +1,60 @@ +# refresh-switch + +Auto-switch display refresh rate on laptops based on power source (AC/battery). + +## How it works + +- Listens for UPower D-Bus signals (zero polling) +- **AC power**: switches built-in display to the highest available refresh rate +- **Battery**: switches to the lowest available refresh rate +- External monitors are never touched (they have their own power supply) +- Auto-detects desktop environment, monitor, and available refresh rates + +## Supported desktops + +| Desktop | Backend | Tool | +|---------|---------|------| +| Hyprland | `hyprctl monitors -j` / `hyprctl keyword monitor` | hyprctl | +| KDE Plasma | `kscreen-doctor -j` / `kscreen-doctor output...` | libkscreen | +| GNOME | `gnome-randr` or `gnome-monitor-config` | gnome-randr / gnome-monitor-config | + +Desktop is auto-detected from `XDG_CURRENT_DESKTOP`. + +## Installation + +```bash +cargo build --release +cp target/release/refresh-switch ~/.local/bin/ +cp refresh-switch.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now refresh-switch.service +``` + +## Usage + +```bash +# Check status +systemctl --user status refresh-switch.service + +# View logs +journalctl --user -u refresh-switch.service -f + +# Run manually (for testing) +./target/release/refresh-switch +``` + +## Dependencies + +- UPower (D-Bus) -- power state monitoring +- systemd -- service management +- One of the following (auto-detected): + - **Hyprland**: `hyprctl` + - **KDE Plasma**: `kscreen-doctor` (from `libkscreen`) + - **GNOME**: `gnome-randr` or `gnome-monitor-config` + +## Design + +- Only built-in displays (eDP connectors) are switched +- Refresh rates are auto-detected from the monitor's supported modes +- Nothing is hardcoded -- works on any laptop with any resolution/refresh rates +- Lightweight: ~1MB memory, near-zero CPU usage diff --git a/refresh-switch/refresh-switch.service b/refresh-switch/refresh-switch.service new file mode 100644 index 00000000..fdd7b2ac --- /dev/null +++ b/refresh-switch/refresh-switch.service @@ -0,0 +1,14 @@ +[Unit] +Description=Refresh Rate Switcher - Auto-switch display refresh rate based on power source +Wants=graphical-session.target +After=graphical-session.target + +[Service] +Type=simple +ExecStartPre=-/usr/bin/systemctl --user import-environment DISPLAY WAYLAND_DISPLAY XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS HYPRLAND_INSTANCE_SIGNATURE +ExecStart=/usr/bin/refresh-switch +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/refresh-switch/src/backend/gnome.rs b/refresh-switch/src/backend/gnome.rs new file mode 100644 index 00000000..dad5c338 --- /dev/null +++ b/refresh-switch/src/backend/gnome.rs @@ -0,0 +1,210 @@ +use super::{BackendError, DisplayBackend}; +use crate::monitor::{Mode, Monitor}; + +pub struct GnomeBackend; + +impl GnomeBackend { + /// Query monitors via gnome-randr or gnome-monitor-config CLI tools. + fn query_monitors() -> Result, BackendError> { + // Try gnome-randr first (simpler output) + if let Ok(out) = std::process::Command::new("gnome-randr") + .arg("list") + .output() + { + if out.status.success() { + return parse_monitor_list(&String::from_utf8_lossy(&out.stdout)); + } + } + + // Fall back to gnome-monitor-config + let output = std::process::Command::new("gnome-monitor-config") + .arg("list") + .output() + .map_err(|e| { + BackendError::NotAvailable(format!( + "GNOME: need gnome-randr or gnome-monitor-config: {e}" + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BackendError::CommandFailed(stderr.trim().to_string())); + } + + parse_monitor_list(&String::from_utf8_lossy(&output.stdout)) + } +} + +/// Intermediate structs for GNOME monitor data. +#[derive(Debug, Clone)] +struct GnomeMonitor { + connector: String, + modes: Vec, +} + +#[derive(Debug, Clone)] +struct GnomeMode { + width: u32, + height: u32, + rate: f64, + is_current: bool, +} + +/// Parse output of `gnome-monitor-config list` or `gnome-randr list`. +/// +/// Example output: +/// ```text +/// Monitor [ eDP-1 ] ON +/// 2560x1600@240.000 [current] [preferred] +/// 2560x1600@60.000 +/// Monitor [ HDMI-1 ] ON +/// 1920x1080@60.000 [current] [preferred] +/// ``` +fn parse_monitor_list(output: &str) -> Result, BackendError> { + let mut monitors = Vec::new(); + let mut current_monitor: Option = None; + + for line in output.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("Monitor") { + if let Some(mon) = current_monitor.take() { + monitors.push(mon); + } + + // Parse "Monitor [ eDP-1 ] ON" + if let Some(name) = trimmed + .split('[') + .nth(1) + .and_then(|s| s.split(']').next()) + .map(|s| s.trim().to_string()) + { + current_monitor = Some(GnomeMonitor { + connector: name, + modes: Vec::new(), + }); + } + } else if let Some(ref mut mon) = current_monitor { + if let Some(mode) = parse_mode_line(trimmed) { + mon.modes.push(mode); + } + } + } + + if let Some(mon) = current_monitor { + monitors.push(mon); + } + + Ok(monitors) +} + +/// Parse a mode line like "2560x1600@240.000 [current] [preferred]". +fn parse_mode_line(line: &str) -> Option { + let line = line.trim(); + if line.is_empty() || line.starts_with("Monitor") { + return None; + } + + let is_current = line.contains("[current]"); + + // Extract the mode spec (everything before '[') + let spec = line.split('[').next()?.trim(); + + // Parse "WIDTHxHEIGHT@RATE" + let (res, rate_str) = spec.split_once('@')?; + let (w, h) = res.split_once('x')?; + + Some(GnomeMode { + width: w.parse().ok()?, + height: h.parse().ok()?, + rate: rate_str.parse().ok()?, + is_current, + }) +} + +impl DisplayBackend for GnomeBackend { + fn name(&self) -> &str { + "GNOME" + } + + fn get_monitors(&self) -> Result, BackendError> { + let gnome_monitors = Self::query_monitors()?; + + let monitors = gnome_monitors + .into_iter() + .filter_map(|gm| { + let current = gm.modes.iter().find(|m| m.is_current)?; + + Some(Monitor { + name: gm.connector, + width: current.width, + height: current.height, + rate: current.rate, + scale: 1.0, // gnome-randr/gnome-monitor-config don't expose this easily + pos_x: 0, + pos_y: 0, + modes: gm + .modes + .iter() + .map(|m| Mode { + width: m.width, + height: m.height, + rate: m.rate, + }) + .collect(), + }) + }) + .collect(); + + Ok(monitors) + } + + fn set_rate(&self, monitor: &Monitor, rate: f64) -> Result<(), BackendError> { + // Find the exact rate from the monitor's mode list to avoid floating-point + // formatting mismatches. GNOME tools do exact string matching on mode specs, + // so "165.002" won't match a mode reported as "165.00195312". + let exact_rate = monitor + .modes + .iter() + .filter(|m| m.width == monitor.width && m.height == monitor.height) + .min_by(|a, b| { + let da = (a.rate - rate).abs(); + let db = (b.rate - rate).abs(); + da.partial_cmp(&db).unwrap() + }) + .map(|m| m.rate) + .unwrap_or(rate); + + // Format as integer Hz — both gnome-randr and gnome-monitor-config accept + // integer rates and match to the closest available mode, same as Hyprland/KDE. + let mode_str = format!("{}x{}@{:.0}", monitor.width, monitor.height, exact_rate); + + // Try gnome-randr first + if let Ok(out) = std::process::Command::new("gnome-randr") + .args(["modify", &monitor.name, "-m", &mode_str]) + .output() + { + if out.status.success() { + return Ok(()); + } + } + + // Fall back to gnome-monitor-config + // Use -LM flags only (-p is not supported on all versions) + let output = std::process::Command::new("gnome-monitor-config") + .args(["set", "-LM", &monitor.name, "-m", &mode_str]) + .output() + .map_err(|e| { + BackendError::NotAvailable(format!( + "GNOME: need gnome-randr or gnome-monitor-config: {e}" + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BackendError::CommandFailed(stderr.trim().to_string())); + } + + Ok(()) + } +} diff --git a/refresh-switch/src/backend/hyprland.rs b/refresh-switch/src/backend/hyprland.rs new file mode 100644 index 00000000..de0d8578 --- /dev/null +++ b/refresh-switch/src/backend/hyprland.rs @@ -0,0 +1,101 @@ +use super::{BackendError, DisplayBackend}; +use crate::monitor::{Mode, Monitor}; +use serde::Deserialize; +use std::process::Command; + +pub struct HyprlandBackend; + +/// JSON structure from `hyprctl monitors -j`. +#[derive(Deserialize)] +struct HyprMonitor { + name: String, + width: u32, + height: u32, + #[serde(rename = "refreshRate")] + refresh_rate: f64, + scale: f64, + x: i32, + y: i32, + #[serde(rename = "availableModes")] + available_modes: Vec, +} + +/// Parse a mode string like "2560x1600@240.00Hz" into a Mode. +fn parse_mode(s: &str) -> Option { + // format: "WIDTHxHEIGHT@RATE.RATEHz" + let s = s.strip_suffix("Hz").unwrap_or(s); + let (res, rate_str) = s.split_once('@')?; + let (w, h) = res.split_once('x')?; + Some(Mode { + width: w.parse().ok()?, + height: h.parse().ok()?, + rate: rate_str.parse().ok()?, + }) +} + +impl DisplayBackend for HyprlandBackend { + fn name(&self) -> &str { + "Hyprland" + } + + fn get_monitors(&self) -> Result, BackendError> { + let output = Command::new("hyprctl") + .args(["monitors", "-j"]) + .output() + .map_err(|e| BackendError::NotAvailable(format!("hyprctl: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BackendError::CommandFailed(stderr.trim().to_string())); + } + + let hypr_monitors: Vec = serde_json::from_slice(&output.stdout) + .map_err(|e| BackendError::ParseError(format!("hyprctl JSON: {e}")))?; + + let monitors = hypr_monitors + .into_iter() + .map(|hm| Monitor { + name: hm.name, + width: hm.width, + height: hm.height, + rate: hm.refresh_rate, + scale: hm.scale, + pos_x: hm.x, + pos_y: hm.y, + modes: hm + .available_modes + .iter() + .filter_map(|s| parse_mode(s)) + .collect(), + }) + .collect(); + + Ok(monitors) + } + + fn set_rate(&self, monitor: &Monitor, rate: f64) -> Result<(), BackendError> { + // hyprctl keyword monitor NAME,WIDTHxHEIGHT@RATE,POSXxPOSY,SCALE + let arg = format!( + "{},{}x{}@{:.0},{}x{},{:.1}", + monitor.name, + monitor.width, + monitor.height, + rate, + monitor.pos_x, + monitor.pos_y, + monitor.scale, + ); + + let output = Command::new("hyprctl") + .args(["keyword", "monitor", &arg]) + .output() + .map_err(|e| BackendError::CommandFailed(format!("hyprctl: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BackendError::CommandFailed(stderr.trim().to_string())); + } + + Ok(()) + } +} diff --git a/refresh-switch/src/backend/kde.rs b/refresh-switch/src/backend/kde.rs new file mode 100644 index 00000000..966a3116 --- /dev/null +++ b/refresh-switch/src/backend/kde.rs @@ -0,0 +1,119 @@ +use super::{BackendError, DisplayBackend}; +use crate::monitor::{Mode, Monitor}; +use serde::Deserialize; +use std::process::Command; + +pub struct KdeBackend; + +/// JSON structure from `kscreen-doctor -j`. +#[derive(Deserialize)] +struct KscreenOutput { + outputs: Vec, +} + +#[derive(Deserialize)] +struct KscreenMonitor { + name: String, + enabled: bool, + #[serde(rename = "currentModeId")] + current_mode_id: Option, + pos: Option, + scale: Option, + modes: Vec, +} + +#[derive(Deserialize)] +struct KscreenPos { + x: i32, + y: i32, +} + +#[derive(Deserialize)] +struct KscreenMode { + id: String, + #[serde(rename = "refreshRate")] + refresh_rate: f64, + size: KscreenSize, +} + +#[derive(Deserialize)] +struct KscreenSize { + width: u32, + height: u32, +} + +impl DisplayBackend for KdeBackend { + fn name(&self) -> &str { + "KDE Plasma" + } + + fn get_monitors(&self) -> Result, BackendError> { + let output = Command::new("kscreen-doctor") + .arg("-j") + .output() + .map_err(|e| BackendError::NotAvailable(format!("kscreen-doctor: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BackendError::CommandFailed(stderr.trim().to_string())); + } + + let kscreen: KscreenOutput = serde_json::from_slice(&output.stdout) + .map_err(|e| BackendError::ParseError(format!("kscreen-doctor JSON: {e}")))?; + + let monitors = kscreen + .outputs + .into_iter() + .filter(|o| o.enabled) + .filter_map(|o| { + let current_mode = o + .current_mode_id + .as_ref() + .and_then(|id| o.modes.iter().find(|m| &m.id == id))?; + + let pos = o.pos.as_ref(); + + Some(Monitor { + name: o.name, + width: current_mode.size.width, + height: current_mode.size.height, + rate: current_mode.refresh_rate, + scale: o.scale.unwrap_or(1.0), + pos_x: pos.map_or(0, |p| p.x), + pos_y: pos.map_or(0, |p| p.y), + modes: o + .modes + .iter() + .map(|m| Mode { + width: m.size.width, + height: m.size.height, + rate: m.refresh_rate, + }) + .collect(), + }) + }) + .collect(); + + Ok(monitors) + } + + fn set_rate(&self, monitor: &Monitor, rate: f64) -> Result<(), BackendError> { + // kscreen-doctor output.NAME.mode.WIDTHxHEIGHT@RATE + let arg = format!( + "output.{}.mode.{}x{}@{:.0}", + monitor.name, monitor.width, monitor.height, rate, + ); + + let output = Command::new("kscreen-doctor") + .arg(&arg) + .output() + .map_err(|e| BackendError::CommandFailed(format!("kscreen-doctor: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BackendError::CommandFailed(stderr.trim().to_string())); + } + + Ok(()) + } +} diff --git a/refresh-switch/src/backend/mod.rs b/refresh-switch/src/backend/mod.rs new file mode 100644 index 00000000..17d39d1d --- /dev/null +++ b/refresh-switch/src/backend/mod.rs @@ -0,0 +1,74 @@ +pub mod gnome; +pub mod hyprland; +pub mod kde; + +use crate::monitor::Monitor; +use std::fmt; + +/// Errors from display backends. +#[derive(Debug)] +pub enum BackendError { + /// The required tool is not available (e.g. hyprctl, kscreen-doctor). + NotAvailable(String), + /// Command execution failed. + CommandFailed(String), + /// Failed to parse output. + ParseError(String), +} + +impl fmt::Display for BackendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotAvailable(msg) => write!(f, "backend not available: {msg}"), + Self::CommandFailed(msg) => write!(f, "command failed: {msg}"), + Self::ParseError(msg) => write!(f, "parse error: {msg}"), + } + } +} + +/// Trait implemented by each compositor/DE backend. +pub trait DisplayBackend { + /// Human-readable name of this backend. + fn name(&self) -> &str; + + /// Query all connected monitors. + fn get_monitors(&self) -> Result, BackendError>; + + /// Set refresh rate on a specific monitor, preserving resolution/scale/position. + fn set_rate(&self, monitor: &Monitor, rate: f64) -> Result<(), BackendError>; +} + +/// Supported desktop environments / compositors. +#[derive(Debug, Clone, Copy)] +pub enum Desktop { + Hyprland, + Kde, + Gnome, +} + +/// Auto-detect the running desktop environment from XDG_CURRENT_DESKTOP. +pub fn detect_desktop() -> Option { + let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + let desktop = desktop.to_lowercase(); + + // XDG_CURRENT_DESKTOP can contain multiple values separated by ':' + for entry in desktop.split(':') { + match entry.trim() { + "hyprland" => return Some(Desktop::Hyprland), + "kde" => return Some(Desktop::Kde), + "gnome" => return Some(Desktop::Gnome), + _ => {} + } + } + + None +} + +/// Create the appropriate backend for the detected (or specified) desktop. +pub fn create_backend(desktop: Desktop) -> Box { + match desktop { + Desktop::Hyprland => Box::new(hyprland::HyprlandBackend), + Desktop::Kde => Box::new(kde::KdeBackend), + Desktop::Gnome => Box::new(gnome::GnomeBackend), + } +} diff --git a/refresh-switch/src/main.rs b/refresh-switch/src/main.rs new file mode 100644 index 00000000..bd43008e --- /dev/null +++ b/refresh-switch/src/main.rs @@ -0,0 +1,119 @@ +mod backend; +mod monitor; +mod power; + +use backend::{create_backend, detect_desktop, DisplayBackend}; +use dbus::blocking::Connection; +use power::PowerSource; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{channel, Sender}; +use std::time::Duration; + +static RUNNING: AtomicBool = AtomicBool::new(true); + +/// Apply the correct refresh rate to all built-in monitors. +fn apply(conn: &Connection, backend: &dyn DisplayBackend, last_state: &mut Option) { + let Some(source) = power::get_power_source(conn) else { + return; + }; + + // Skip if power source hasn't changed + if *last_state == Some(source) { + return; + } + + let source_name = match source { + PowerSource::Ac => "AC", + PowerSource::Battery => "battery", + }; + + eprintln!("power: switched to {source_name}"); + + let monitors = match backend.get_monitors() { + Ok(m) => m, + Err(e) => { + eprintln!("failed to query monitors: {e}"); + return; + } + }; + + // Only switch built-in (eDP) displays + let builtin: Vec<_> = monitors.iter().filter(|m| m.is_builtin()).collect(); + + if builtin.is_empty() { + eprintln!("no built-in displays found, skipping"); + *last_state = Some(source); + return; + } + + for mon in &builtin { + let Some(target) = mon.target_rate(source) else { + eprintln!(" {}: no suitable rate found", mon.name); + continue; + }; + + if !mon.needs_switch(source) { + eprintln!(" {}: already at {:.0} Hz", mon.name, mon.rate); + continue; + } + + eprintln!(" {}: {:.0} Hz -> {:.0} Hz", mon.name, mon.rate, target); + if let Err(e) = backend.set_rate(mon, target) { + eprintln!(" {}: failed to set rate: {e}", mon.name); + } + } + + *last_state = Some(source); +} + +fn main() { + // Detect desktop environment + let desktop = match detect_desktop() { + Some(d) => d, + None => { + let xdg = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + eprintln!("unsupported desktop: {xdg}"); + eprintln!("supported: Hyprland, KDE Plasma, GNOME"); + std::process::exit(1); + } + }; + + let backend = create_backend(desktop); + eprintln!("refresh-switch: backend={}", backend.name()); + + // Connect to system bus for UPower + let conn = Connection::new_system().expect("failed to connect to D-Bus"); + + // Apply initial state + let mut last_state: Option = None; + apply(&conn, backend.as_ref(), &mut last_state); + + // Setup signal handling + let (tx, rx): (Sender<()>, _) = channel(); + let tx_ctrlc = tx.clone(); + + ctrlc::set_handler(move || { + RUNNING.store(false, Ordering::SeqCst); + let _ = tx_ctrlc.send(()); + }) + .expect("failed to set signal handler"); + + // Subscribe to UPower power state changes + let tx_signal = tx.clone(); + power::subscribe_power_changes(&conn, tx_signal).expect("failed to subscribe to UPower"); + + eprintln!("listening for power changes..."); + + while RUNNING.load(Ordering::SeqCst) { + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(()) => { + apply(&conn, backend.as_ref(), &mut last_state); + } + Err(_) => { + conn.process(Duration::from_millis(10)).ok(); + } + } + } + + eprintln!("shutting down"); +} diff --git a/refresh-switch/src/monitor.rs b/refresh-switch/src/monitor.rs new file mode 100644 index 00000000..b718407d --- /dev/null +++ b/refresh-switch/src/monitor.rs @@ -0,0 +1,58 @@ +use crate::power::PowerSource; + +/// A display mode (resolution + refresh rate). +#[derive(Debug, Clone)] +pub struct Mode { + pub width: u32, + pub height: u32, + pub rate: f64, +} + +/// A connected monitor with its properties and available modes. +#[derive(Debug, Clone)] +pub struct Monitor { + pub name: String, + pub width: u32, + pub height: u32, + pub rate: f64, + pub scale: f64, + pub pos_x: i32, + pub pos_y: i32, + pub modes: Vec, +} + +impl Monitor { + /// Check if this is a built-in laptop panel (eDP connector). + pub fn is_builtin(&self) -> bool { + self.name.starts_with("eDP") + } + + /// Get available refresh rates at the current resolution. + pub fn rates_at_current_res(&self) -> Vec { + self.modes + .iter() + .filter(|m| m.width == self.width && m.height == self.height) + .map(|m| m.rate) + .collect() + } + + /// Pick the target refresh rate for the given power source. + /// AC -> highest available rate at current resolution. + /// Battery -> lowest available rate at current resolution. + pub fn target_rate(&self, source: PowerSource) -> Option { + let rates = self.rates_at_current_res(); + match source { + PowerSource::Ac => rates.into_iter().max_by(|a, b| a.partial_cmp(b).unwrap()), + PowerSource::Battery => rates.into_iter().min_by(|a, b| a.partial_cmp(b).unwrap()), + } + } + + /// Check if switching is needed (current rate differs from target). + pub fn needs_switch(&self, source: PowerSource) -> bool { + if let Some(target) = self.target_rate(source) { + (self.rate - target).abs() > 1.0 + } else { + false + } + } +} diff --git a/refresh-switch/src/power.rs b/refresh-switch/src/power.rs new file mode 100644 index 00000000..9b58d5f1 --- /dev/null +++ b/refresh-switch/src/power.rs @@ -0,0 +1,56 @@ +use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; +use dbus::blocking::Connection; +use std::sync::mpsc::Sender; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PowerSource { + Battery, + Ac, +} + +/// Query UPower for current power source. +pub fn get_power_source(conn: &Connection) -> Option { + let proxy = conn.with_proxy( + "org.freedesktop.UPower", + "/org/freedesktop/UPower/devices/DisplayDevice", + Duration::from_secs(5), + ); + + // State: 1=charging, 2=discharging, 3=empty, 4=fully charged, + // 5=pending charge, 6=pending discharge + match proxy.get::("org.freedesktop.UPower.Device", "State") { + Ok(state) => { + let source = if state == 1 || state == 4 || state == 5 { + PowerSource::Ac + } else { + PowerSource::Battery + }; + Some(source) + } + Err(e) => { + eprintln!("failed to get power state: {e}"); + None + } + } +} + +/// Subscribe to UPower property changes on the DisplayDevice. +/// Sends a `()` on `tx` whenever a change is detected. +pub fn subscribe_power_changes(conn: &Connection, tx: Sender<()>) -> Result<(), dbus::Error> { + let match_rule = dbus::message::MatchRule::new_signal( + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + ) + .with_path("/org/freedesktop/UPower/devices/DisplayDevice"); + + conn.add_match( + match_rule, + move |_: (), _conn: &Connection, _msg: &dbus::message::Message| { + let _ = tx.send(()); + true + }, + )?; + + Ok(()) +} diff --git a/src/cli_handler.rs b/src/cli_handler.rs index 70e9fad0..02d30ef6 100644 --- a/src/cli_handler.rs +++ b/src/cli_handler.rs @@ -187,6 +187,7 @@ fn list_tweaks() -> Result<()> { TweakName::Bluetooth, TweakName::Ananicy, TweakName::CachyUpdate, + TweakName::RefreshSwitch, ] { let (_, service_names, _) = tweak::get_details(*tweak); let is_enabled = systemd_units::check_any_units(service_names); diff --git a/src/pages/tweaks.rs b/src/pages/tweaks.rs index 9c7a635a..09b39a27 100644 --- a/src/pages/tweaks.rs +++ b/src/pages/tweaks.rs @@ -51,6 +51,8 @@ pub(crate) fn create_options_section() -> gtk::Box { let bluetooth_btn = create_tweak_checkbox!("Bluetooth", TweakName::Bluetooth); let ananicy_cpp_btn = create_tweak_checkbox!("Ananicy Cpp", TweakName::Ananicy); let cachy_update_btn = create_tweak_checkbox!("Cachy Update", TweakName::CachyUpdate); + let refresh_switch_btn = + create_tweak_checkbox!("Refresh Rate Switch", TweakName::RefreshSwitch); // set tooltips psd_btn.set_tooltip_text(Some(&fl!("tweak-psd-tooltip"))); @@ -59,6 +61,7 @@ pub(crate) fn create_options_section() -> gtk::Box { bluetooth_btn.set_tooltip_text(Some(&fl!("tweak-bluetooth-tooltip"))); ananicy_cpp_btn.set_tooltip_text(Some(&fl!("tweak-ananicycpp-tooltip"))); cachy_update_btn.set_tooltip_text(Some(&fl!("tweak-cachyupdate-tooltip"))); + refresh_switch_btn.set_tooltip_text(Some(&fl!("tweak-refreshswitch-tooltip"))); topbox.pack_start(&label, true, false, 1); box_collection.pack_start(&psd_btn, true, false, 2); @@ -67,8 +70,12 @@ pub(crate) fn create_options_section() -> gtk::Box { box_collection.pack_start(&ananicy_cpp_btn, true, false, 2); box_collection.pack_start(&cachy_update_btn, true, false, 2); box_collection_s.pack_start(&bluetooth_btn, true, false, 2); + let box_collection_t = gtk::Box::new(gtk::Orientation::Horizontal, 10); + box_collection_t.pack_start(&refresh_switch_btn, true, false, 2); + box_collection_t.set_halign(gtk::Align::Fill); box_collection.set_halign(gtk::Align::Fill); box_collection_s.set_halign(gtk::Align::Fill); + topbox.pack_end(&box_collection_t, true, false, 1); topbox.pack_end(&box_collection_s, true, false, 1); topbox.pack_end(&box_collection, true, false, 1); diff --git a/src/tweak.rs b/src/tweak.rs index 7e23aa5f..044523b5 100644 --- a/src/tweak.rs +++ b/src/tweak.rs @@ -15,6 +15,9 @@ pub enum TweakName { /// `CachyOS` update notifier #[clap(name = "cachy-update")] CachyUpdate, + /// Auto-switch display refresh rate based on power source (AC/battery) + #[clap(name = "refresh-switch")] + RefreshSwitch, } pub fn get_details(tweak: TweakName) -> (&'static str, &'static str, &'static str) { @@ -27,5 +30,6 @@ pub fn get_details(tweak: TweakName) -> (&'static str, &'static str, &'static st TweakName::CachyUpdate => { ("user_service", "arch-update.timer arch-update-tray.service", "cachy-update") }, + TweakName::RefreshSwitch => ("user_service", "refresh-switch.service", ""), } }