From a02925b2eeb3fd6af3cee5915c44bf1fe4c20006 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 08:08:08 +0100 Subject: [PATCH] Add manual and battery-aware automatic display refresh switching --- compositor/src/backends/direct.rs | 79 +++++++++++- compositor/src/backends/mod.rs | 1 + compositor/src/backends/power.rs | 137 ++++++++++++++++++++ compositor/src/config.rs | 15 +++ compositor/src/ipc.rs | 2 + docs/configuration.md | 25 ++++ shell/ferese-settings/src/displays.rs | 172 ++++++++++++++++++++++++++ shell/ferese-settings/src/main.rs | 89 +++++++++++++ 8 files changed, 515 insertions(+), 5 deletions(-) create mode 100644 compositor/src/backends/power.rs create mode 100644 shell/ferese-settings/src/displays.rs diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 64774ad..e24617a 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -41,6 +41,7 @@ pub struct DirectBackendState { pub session: LibSeatSession, pub active: bool, lid_closed: bool, + pub(crate) low_power: bool, devices: HashMap, input_devices: Vec, presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>, @@ -119,6 +120,7 @@ pub(crate) struct ConnectedOutputInfo { pub identity: String, pub enabled: bool, pub profile: Option, + pub auto_refresh: bool, pub physical_size: Option<(u32, u32)>, pub current_mode: Option, pub available_modes: Vec, @@ -201,6 +203,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() session, active: session_active, lid_closed: initial_lid_closed(), + low_power: super::power::low_power(false, super::power::battery_percent()), devices: HashMap::new(), input_devices: Vec::new(), presentation: HashMap::new(), @@ -209,6 +212,12 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() animation_active: false, }); open_primary_device(event_loop, state, &primary_path)?; + event_loop + .handle() + .insert_source(Timer::from_duration(Duration::from_secs(5)), |_, _, state| { + update_power_policy(state); + TimeoutAction::ToDuration(Duration::from_secs(5)) + })?; event_loop .handle() @@ -261,6 +270,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() } } } + update_power_policy(state); state.reset_animation_clock(); tracing::info!("direct session activated"); state.lock_input_activity(); @@ -328,7 +338,38 @@ pub(crate) fn reload_input_devices(state: &mut Ferese) { } } +fn update_power_policy(state: &mut Ferese) { + if !state + .output_profiles + .iter() + .any(|profile| profile.outputs.iter().any(|output| output.auto_refresh)) + { + return; + } + let Some(backend) = state.direct_backend.as_mut() else { + return; + }; + // Keep low_power as the applied policy while paused. A transition sampled + // during inactivity must still be detected and applied on activation. + let battery = if backend.active { + super::power::battery_percent() + } else { + None + }; + if let Some(next) = super::power::policy_change(backend.active, backend.low_power, battery) { + backend.low_power = next; + reload_outputs(state); + } +} + pub(crate) fn reload_outputs(state: &mut Ferese) { + if let Some(backend) = state.direct_backend.as_mut() { + let enabled = state + .output_profiles + .iter() + .any(|profile| profile.outputs.iter().any(|output| output.auto_refresh)); + backend.low_power = enabled && super::power::low_power(backend.low_power, super::power::battery_percent()); + } let nodes = state .direct_backend .as_ref() @@ -345,7 +386,7 @@ pub(crate) fn validate_live_outputs(state: &Ferese, profiles: &[OutputProfile]) }; let mut usable = 0; for device in backend.devices.values() { - let mut scan = select_outputs(&device.drm, profiles).map_err(|e| e.to_string())?; + let mut scan = select_outputs(&device.drm, profiles, backend.low_power).map_err(|e| e.to_string())?; apply_lid_policy(&mut scan, backend.lid_closed); usable += scan.selections.len(); } @@ -390,7 +431,11 @@ fn open_primary_device( state .dmabuf_state .create_global_with_default_feedback::(&display_handle, &feedback); - let mut scan = select_outputs(&drm, &state.output_profiles)?; + let mut scan = select_outputs( + &drm, + &state.output_profiles, + state.direct_backend.as_ref().is_some_and(|backend| backend.low_power), + )?; apply_lid_policy( &mut scan, state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed), @@ -787,7 +832,11 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { return; }; - let mut scan = match select_outputs(&device.drm, &state.output_profiles) { + let mut scan = match select_outputs( + &device.drm, + &state.output_profiles, + state.direct_backend.as_ref().is_some_and(|backend| backend.low_power), + ) { Ok(scan) => scan, Err(error) => { tracing::error!(?node, %error, "failed to scan DRM connectors"); @@ -984,6 +1033,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { { info.enabled = true; info.current_mode = Some(connected_mode_info(output.mode)); + info.auto_refresh = output.settings.auto_refresh; info.scale = output.settings.scale; info.transform = output.settings.transform; info.configured_position = output.settings.position; @@ -1188,7 +1238,7 @@ fn send_frame_callbacks(state: &mut Ferese, output: &Output) { } } -fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result { +fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile], low_power: bool) -> io::Result { let resources = drm.resource_handles()?; let connected = resources .connectors() @@ -1230,13 +1280,31 @@ fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result>(); + super::power::low_refresh_mode(normal.size(), &modes) + .map(|index| connector.modes()[index]) + .or(Some(normal)) + } else { + Some(normal) + } + }) .flatten(); connected_outputs.push(ConnectedOutputInfo { connector: connector.to_string(), identity: identity.clone(), enabled: settings.enabled, profile: active_profile.map(|profile| profile.name.clone()), + auto_refresh: settings.auto_refresh, physical_size: connector.size(), current_mode: selected_mode.map(connected_mode_info), available_modes: connector.modes().iter().copied().map(connected_mode_info).collect(), @@ -1298,6 +1366,7 @@ fn output_matches(settings: &OutputSettings, connector: &connector::Info, identi fn default_output_settings(matcher: String) -> OutputSettings { OutputSettings { + auto_refresh: false, matcher, enabled: true, mode: None, diff --git a/compositor/src/backends/mod.rs b/compositor/src/backends/mod.rs index 4316926..85c98dc 100644 --- a/compositor/src/backends/mod.rs +++ b/compositor/src/backends/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod direct; +mod power; use std::ffi::OsString; diff --git a/compositor/src/backends/power.rs b/compositor/src/backends/power.rs new file mode 100644 index 0000000..b586ae3 --- /dev/null +++ b/compositor/src/backends/power.rs @@ -0,0 +1,137 @@ +//! Power state for automatic display refresh rates. Device batteries are excluded. +use std::path::Path; + +pub(super) fn battery_percent() -> Option { + read_battery_percent(Path::new("/sys/class/power_supply")) +} + +fn read_battery_percent(root: &Path) -> Option { + let mut entries = std::fs::read_dir(root) + .ok()? + .flatten() + .map(|entry| entry.path()) + .collect::>(); + entries.sort(); + let read = |path: &Path, name: &str| { + std::fs::read_to_string(path.join(name)) + .ok() + .map(|text| text.trim().to_owned()) + }; + if entries + .iter() + .any(|path| read(path, "online").as_deref() == Some("1") && read(path, "type").as_deref() != Some("Battery")) + { + return None; + } + entries.iter().find_map(|path| { + if read(path, "type").as_deref() != Some("Battery") + || read(path, "scope").as_deref() == Some("Device") + || read(path, "status").as_deref() != Some("Discharging") + { + return None; + } + read(path, "capacity")? + .parse::() + .ok() + .filter(|percent| *percent <= 100) + }) +} + +pub(super) fn low_power(previous: bool, battery: Option) -> bool { + battery.is_some_and(|percent| if previous { percent < 35 } else { percent < 30 }) +} + +/// Return a new policy only when an active session needs a mode transition. +/// Inactive samples leave the applied policy intact until activation. +pub(super) fn policy_change(active: bool, applied: bool, battery: Option) -> Option { + if !active { + return None; + } + let next = low_power(applied, battery); + (next != applied).then_some(next) +} + +/// Keep resolution unchanged and use a supported mode within 1 Hz of 60 Hz. +/// Panels without such a mode retain their normal refresh rate. +pub(super) fn low_refresh_mode(size: (u16, u16), modes: &[(u16, u16, i32)]) -> Option { + modes + .iter() + .enumerate() + .filter(|(_, (width, height, refresh))| (*width, *height) == size && (59_000..=61_000).contains(refresh)) + .min_by_key(|(_, (_, _, refresh))| refresh.abs_diff(60_000)) + .map(|(index, _)| index) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn activation_rescans_only_for_a_changed_policy() { + assert_eq!(policy_change(true, false, Some(50)), None); + assert_eq!(policy_change(true, true, Some(32)), None); + assert_eq!(policy_change(true, false, Some(29)), Some(true)); + assert_eq!(policy_change(true, true, None), Some(false)); + } + + #[test] + fn paused_samples_defer_changes_until_activation() { + assert_eq!(policy_change(false, false, Some(20)), None); + assert_eq!(policy_change(true, false, Some(20)), Some(true)); + assert_eq!(policy_change(false, true, None), None); + assert_eq!(policy_change(true, true, None), Some(false)); + // A round trip across the threshold while paused needs no rescan. + assert_eq!(policy_change(false, false, Some(20)), None); + assert_eq!(policy_change(true, false, Some(50)), None); + } + + #[test] + fn low_refresh_selection_rejects_unsupported_rates_and_preserves_resolution() { + let modes = [ + (2880, 1800, 120_000), + (1920, 1080, 60_000), + (2880, 1800, 59_940), + (2880, 1800, 60_000), + ]; + assert_eq!(low_refresh_mode((2880, 1800), &modes), Some(3)); + assert_eq!(low_refresh_mode((2880, 1800), &modes[..3]), Some(2)); + assert_eq!(low_refresh_mode((2880, 1800), &modes[..2]), None); + assert_eq!(low_refresh_mode((2880, 1800), &[(2880, 1800, -60_000)]), None); + } + + #[test] + fn thresholds_have_hysteresis_and_ac_or_unknown_restore_normal() { + assert!(low_power(false, Some(29))); + assert!(!low_power(false, Some(30))); + assert!(low_power(true, Some(34))); + assert!(!low_power(true, Some(35))); + assert!(!low_power(true, None)); + } + + #[test] + fn only_discharging_system_batteries_count_and_ac_has_priority() { + let root = tempfile::tempdir().unwrap(); + let battery = root.path().join("BAT0"); + std::fs::create_dir(&battery).unwrap(); + for (name, value) in [ + ("type", "Battery"), + ("scope", "System"), + ("status", "Discharging"), + ("capacity", "20"), + ] { + std::fs::write(battery.join(name), value).unwrap(); + } + assert_eq!(read_battery_percent(root.path()), Some(20)); + std::fs::write(battery.join("scope"), "Device").unwrap(); + assert_eq!(read_battery_percent(root.path()), None); + std::fs::write(battery.join("scope"), "System").unwrap(); + std::fs::write(battery.join("capacity"), "bad").unwrap(); + assert_eq!(read_battery_percent(root.path()), None); + std::fs::write(battery.join("capacity"), "20").unwrap(); + let ac = root.path().join("AC"); + std::fs::create_dir(&ac).unwrap(); + std::fs::write(ac.join("type"), "Mains").unwrap(); + std::fs::write(ac.join("online"), "1").unwrap(); + assert_eq!(read_battery_percent(root.path()), None); + } +} diff --git a/compositor/src/config.rs b/compositor/src/config.rs index f04e1df..53abc23 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -74,6 +74,7 @@ pub struct OutputProfile { #[derive(Clone, Debug, PartialEq)] pub struct OutputSettings { + pub auto_refresh: bool, pub matcher: String, pub enabled: bool, pub mode: Option, @@ -118,6 +119,8 @@ struct OutputProfileConfig { #[derive(Clone, Debug, Deserialize)] struct OutputConfig { + #[serde(default)] + auto_refresh: bool, #[serde(rename = "match")] matcher: String, #[serde(default = "default_true")] @@ -1139,6 +1142,7 @@ impl Config { } Ok(OutputSettings { + auto_refresh: output.auto_refresh, matcher: matcher.to_owned(), enabled: output.enabled, mode: output @@ -2448,6 +2452,7 @@ mod tests { name: "docked".to_owned(), outputs: vec![ OutputSettings { + auto_refresh: false, matcher: "HDMI-A-1".to_owned(), enabled: true, mode: Some(OutputModeRequest { @@ -2460,6 +2465,7 @@ mod tests { position: Some([0, 0]), }, OutputSettings { + auto_refresh: false, matcher: "eDP-1".to_owned(), enabled: false, mode: None, @@ -2472,6 +2478,15 @@ mod tests { ); } + #[test] + fn automatic_refresh_is_opt_in_and_rejects_wrong_types() { + let config = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }"); + assert!(config.output_profiles().unwrap()[0].outputs[0].auto_refresh); + let manual = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@60\"; }"); + assert!(!manual.output_profiles().unwrap()[0].outputs[0].auto_refresh); + assert!(Config::parse_source("output-profile laptop { output eDP-1 auto-refresh=\"yes\"; }").is_err()); + } + #[test] fn rejects_invalid_output_profiles() { let invalid_scale = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n"); diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index f777f7b..7ddbc57 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -1045,6 +1045,8 @@ impl Ferese { "connected": true, "enabled": info.enabled && mapped.is_some(), "profile": info.profile, + "auto_refresh": info.auto_refresh, + "low_power": backend.low_power, "focused": id.is_some() && id == focused, "workspace": workspace.map(|workspace| workspace.0), "x": position.map(|position| position[0]), diff --git a/docs/configuration.md b/docs/configuration.md index accd1f4..482183c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -367,6 +367,7 @@ are connected wins. Find connector names and persistent identities using | `match` | nonempty connector or persistent identity string | required; unique within profile | | `enabled` | boolean | `true` | | `mode` | `"WIDTHxHEIGHT"` or `"WIDTHxHEIGHT@HZ"` | Preferred mode | +| `auto-refresh` | boolean | `false` | | `scale` | number > 0 | `1` | | `transform` | enum below | `"normal"` | | `position` | `[integer, integer]` | Automatic horizontal placement | @@ -383,6 +384,30 @@ output-profile name="docked" { } ``` +Settings → Displays offers the connected display's supported refresh rates at +its configured resolution. Choosing a rate saves it and disables Auto. Choosing +Auto saves the highest supported rate as the normal mode and enables automatic +switching. These choices apply immediately for the active profile and persist +across restarts. + +With `auto-refresh=#true`, a discharging system battery below 30% switches the +display to a supported mode within 1 Hz of 60 Hz at the same resolution. The +configured normal mode returns on external power or when the battery reaches +35%. Power is checked every five seconds; mode changes only run when the policy +changes. Unavailable battery data restores normal refresh. If no matching 60 Hz +mode exists, normal refresh is retained. Rejected DRM mode changes retain the +current output. A refresh-rate change can briefly blank the display (about one +second on some panels), including automatic changes below 30%, recovery at 35%, +and restoration on AC. Session activation only re-evaluates the power policy; +an unchanged policy does not trigger an additional rescan. Connector changes +continue to use the existing hotplug handling. + +```kdl +output-profile laptop { + output eDP-1 mode="2880x1800@120" scale=1.75 auto-refresh=#true +} +``` + With a usable external display, closing the lid disables internal panels. Workspaces move to a remaining display and return when their original output returns. Ferese does not change the system's suspend policy. diff --git a/shell/ferese-settings/src/displays.rs b/shell/ferese-settings/src/displays.rs new file mode 100644 index 0000000..4932a40 --- /dev/null +++ b/shell/ferese-settings/src/displays.rs @@ -0,0 +1,172 @@ +use serde_json::{Value, json}; + +use crate::store::{Edit, set}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Mode { + pub width: u32, + pub height: u32, + pub refresh: u32, +} + +impl Mode { + fn from_value(value: &Value) -> Option { + Some(Self { + width: value["width"].as_u64()?.try_into().ok()?, + height: value["height"].as_u64()?.try_into().ok()?, + refresh: value["refresh_millihertz"].as_u64()?.try_into().ok()?, + }) + } + + pub fn config(self) -> String { + format!("{}x{}@{:.3}", self.width, self.height, f64::from(self.refresh) / 1000.) + } + + pub fn label(self) -> String { + if self.refresh % 1000 == 0 { + format!("{} Hz", self.refresh / 1000) + } else { + format!("{:.3} Hz", f64::from(self.refresh) / 1000.) + } + } +} + +#[derive(Clone, Debug)] +pub struct Display { + pub connector: String, + pub identity: String, + pub profile: Option, + pub current: Option, + pub modes: Vec, +} + +pub fn load() -> Result, String> { + let mut connection = ferese_ipc::theme::Connection::connect().map_err(|error| error.to_string())?; + let value = connection.call("get-outputs", json!({}))?; + let values = value.as_array().ok_or("Invalid display response")?; + Ok(values + .iter() + .filter_map(|value| { + Some(Display { + connector: value["connector"].as_str()?.to_owned(), + identity: value["identity"].as_str().unwrap_or("").to_owned(), + profile: value["profile"].as_str().map(str::to_owned), + current: Mode::from_value(&value["current_mode"]), + modes: value["available_modes"] + .as_array() + .into_iter() + .flatten() + .filter_map(Mode::from_value) + .collect(), + }) + }) + .collect()) +} + +pub fn choices(display: &Display, configured: &str) -> Vec { + let size = configured + .split('@') + .next() + .and_then(|size| size.split_once('x')) + .and_then(|(width, height)| Some((width.parse::().ok()?, height.parse::().ok()?))) + .or_else(|| display.current.map(|mode| (mode.width, mode.height))); + let mut modes = display + .modes + .iter() + .copied() + .filter(|mode| Some((mode.width, mode.height)) == size && mode.refresh > 0) + .collect::>(); + modes.sort_by_key(|mode| mode.refresh); + modes.dedup(); + modes +} + +pub fn edits(prefix: &str, mode: Mode, automatic: bool) -> Vec { + vec![ + set(&format!("{prefix}.mode"), mode.config()), + set(&format!("{prefix}.auto_refresh"), automatic), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::Snapshot; + + #[test] + fn refresh_choices_preserve_resolution_and_fractional_rates() { + let display = Display { + connector: "eDP-1".into(), + identity: "panel".into(), + profile: None, + current: Some(Mode { + width: 2880, + height: 1800, + refresh: 120000, + }), + modes: vec![ + Mode { + width: 2880, + height: 1800, + refresh: 120000, + }, + Mode { + width: 2880, + height: 1800, + refresh: 59940, + }, + Mode { + width: 1920, + height: 1080, + refresh: 60000, + }, + Mode { + width: 2880, + height: 1800, + refresh: 59940, + }, + ], + }; + let choices = choices(&display, "2880x1800@120"); + assert_eq!(choices.len(), 2); + assert_eq!(choices[0].label(), "59.940 Hz"); + assert_eq!(choices[0].config(), "2880x1800@59.940"); + assert_eq!(choices[1].label(), "120 Hz"); + } + + #[test] + fn manual_choice_disables_auto_and_auto_restores_high_rate() { + let mut snapshot = Snapshot::parse( + "output-profile laptop { output eDP-1 mode=\"2880x1800@120\" scale=1.75 auto-refresh=#true; }".into(), + ) + .unwrap(); + let prefix = "output_profiles.0.outputs.0"; + for edit in edits( + prefix, + Mode { + width: 2880, + height: 1800, + refresh: 60000, + }, + false, + ) { + snapshot.edit(&edit).unwrap(); + } + assert!(!snapshot.boolean(&format!("{prefix}.auto_refresh"), true)); + assert_eq!(snapshot.string(&format!("{prefix}.mode"), ""), "2880x1800@60.000"); + assert_eq!(snapshot.number(&format!("{prefix}.scale"), 1.), 1.75); + for edit in edits( + prefix, + Mode { + width: 2880, + height: 1800, + refresh: 120000, + }, + true, + ) { + snapshot.edit(&edit).unwrap(); + } + assert!(snapshot.boolean(&format!("{prefix}.auto_refresh"), false)); + assert_eq!(snapshot.string(&format!("{prefix}.mode"), ""), "2880x1800@120.000"); + } +} diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index f1f4e3d..451dc60 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -1,3 +1,4 @@ +mod displays; mod fonts; mod schema; mod store; @@ -42,6 +43,9 @@ fn main() -> cosmic::iced::Result { #[derive(Clone, Debug)] enum Message { + RefreshDisplays, + DisplaysLoaded(Result, String>), + RefreshRate(String, displays::Mode, bool), ThemeChanged(ferese_config::theme::Snapshot), DragWindow, Page(Page), @@ -82,6 +86,7 @@ enum Message { } struct App { + displays: Vec, note_editors: HashMap, core: Core, path: PathBuf, @@ -146,6 +151,7 @@ impl cosmic::Application for App { let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family)); let native_palette = visuals::Palette::from_resolved(&resolved.presented); let mut app = Self { + displays: vec![], note_editors: HashMap::new(), core, path, @@ -186,6 +192,14 @@ impl cosmic::Application for App { fn update(&mut self, message: Message) -> Task { match message { + Message::RefreshDisplays => return self.load_displays(), + Message::DisplaysLoaded(result) => { + self.displays = result.unwrap_or_default(); + } + Message::RefreshRate(prefix, mode, automatic) => { + self.inputs.remove(&format!("{prefix}.mode")); + return self.edit_many(displays::edits(&prefix, mode, automatic)); + } Message::ThemeChanged(snapshot) => { self.resolved = snapshot; self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); @@ -300,6 +314,9 @@ impl cosmic::Application for App { Message::Page(page) => { self.page = page; self.search.clear(); + if page == Page::Displays { + return self.load_displays(); + } if page == Page::Wallpaper { return self.load_thumbnail(); } @@ -575,6 +592,11 @@ impl cosmic::Application for App { cosmic::iced::Subscription::batch([ cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes), ferese_theme::service::subscription().map(Message::ThemeChanged), + if self.page == Page::Displays { + cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays) + } else { + cosmic::iced::Subscription::none() + }, ]) } @@ -971,6 +993,7 @@ impl cosmic::Application for App { let prefix = format!("{prefix}.outputs.{output}"); let group = column([]) .spacing(1) + .push(self.refresh_controls(&prefix)) .push(self.field(schema::text( format!("{prefix}.match"), "Display", @@ -1607,6 +1630,72 @@ impl App { .into() } + fn load_displays(&self) -> Task { + cosmic::task::future(async { + Message::DisplaysLoaded( + tokio::task::spawn_blocking(displays::load) + .await + .unwrap_or_else(|error| Err(error.to_string())), + ) + }) + } + + fn refresh_controls(&self, prefix: &str) -> Element<'_, Message> { + let matcher = self.draft.string(&format!("{prefix}.match"), ""); + let configured = self.draft.string(&format!("{prefix}.mode"), ""); + let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false); + let Some(display) = self + .displays + .iter() + .find(|display| display.connector == matcher || display.identity == matcher) + else { + return self.note("Connect this display to choose a supported refresh rate."); + }; + let modes = displays::choices(display, &configured); + let mut controls = row([]).spacing(8).align_y(Alignment::Center); + for mode in &modes { + let selected = !automatic + && configured + .split('@') + .nth(1) + .and_then(|rate| rate.parse::().ok()) + .is_some_and(|rate| (rate * 1000. - f64::from(mode.refresh)).abs() < 1.); + controls = controls.push( + ferese_theme::controls::text_button( + mode.label(), + self.font, + visuals::Palette::from_resolved(&self.resolved.presented), + selected, + ) + .on_press(Message::RefreshRate(prefix.to_owned(), *mode, false)), + ); + } + let supports_auto = modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh)); + if supports_auto && let Some(mode) = modes.last() { + controls = controls.push( + ferese_theme::controls::text_button( + "Auto", + self.font, + visuals::Palette::from_resolved(&self.resolved.presented), + automatic, + ) + .on_press(Message::RefreshRate(prefix.to_owned(), *mode, true)), + ); + } + let current = display.current.map(|mode| mode.label()).unwrap_or_else(|| "Off".into()); + let profile = display.profile.as_deref().unwrap_or("automatic configuration"); + column![ + self.label("Refresh rate", 14.), + self.note(&format!("Current: {current} · active profile: {profile}")), + controls, + self.note(if supports_auto { + "Auto uses 60 Hz below 30% on battery. Normal refresh returns on AC or at 35%. Switching may briefly blank the display. Choosing a rate turns Auto off." + } else { + "This resolution has no supported 60 Hz mode for automatic switching." + }), + ].spacing(8).into() + } + fn change(&mut self, edit: Edit) -> Task { match self.draft.edit(&edit) { Ok(()) => {