Add manual and battery-aware automatic display refresh switching

This commit is contained in:
Samuel Onoja committed 2026-10-01 08:08:08 +01:00
1 parent a203b45a4b
commit a02925b2ee
8 files changed
+515 -5

No files matched your search

+74 -5
View File
@@ -41,6 +41,7 @@ pub struct DirectBackendState {
pub session: LibSeatSession, pub session: LibSeatSession,
pub active: bool, pub active: bool,
lid_closed: bool, lid_closed: bool,
pub(crate) low_power: bool,
devices: HashMap<DrmNode, DirectDevice>, devices: HashMap<DrmNode, DirectDevice>,
input_devices: Vec<LibinputDevice>, input_devices: Vec<LibinputDevice>,
presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>, presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>,
@@ -119,6 +120,7 @@ pub(crate) struct ConnectedOutputInfo {
pub identity: String, pub identity: String,
pub enabled: bool, pub enabled: bool,
pub profile: Option<String>, pub profile: Option<String>,
pub auto_refresh: bool,
pub physical_size: Option<(u32, u32)>, pub physical_size: Option<(u32, u32)>,
pub current_mode: Option<ConnectedModeInfo>, pub current_mode: Option<ConnectedModeInfo>,
pub available_modes: Vec<ConnectedModeInfo>, pub available_modes: Vec<ConnectedModeInfo>,
@@ -201,6 +203,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
session, session,
active: session_active, active: session_active,
lid_closed: initial_lid_closed(), lid_closed: initial_lid_closed(),
low_power: super::power::low_power(false, super::power::battery_percent()),
devices: HashMap::new(), devices: HashMap::new(),
input_devices: Vec::new(), input_devices: Vec::new(),
presentation: HashMap::new(), presentation: HashMap::new(),
@@ -209,6 +212,12 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
animation_active: false, animation_active: false,
}); });
open_primary_device(event_loop, state, &primary_path)?; 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 event_loop
.handle() .handle()
@@ -261,6 +270,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
} }
} }
} }
update_power_policy(state);
state.reset_animation_clock(); state.reset_animation_clock();
tracing::info!("direct session activated"); tracing::info!("direct session activated");
state.lock_input_activity(); 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) { 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 let nodes = state
.direct_backend .direct_backend
.as_ref() .as_ref()
@@ -345,7 +386,7 @@ pub(crate) fn validate_live_outputs(state: &Ferese, profiles: &[OutputProfile])
}; };
let mut usable = 0; let mut usable = 0;
for device in backend.devices.values() { 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); apply_lid_policy(&mut scan, backend.lid_closed);
usable += scan.selections.len(); usable += scan.selections.len();
} }
@@ -390,7 +431,11 @@ fn open_primary_device(
state state
.dmabuf_state .dmabuf_state
.create_global_with_default_feedback::<Ferese>(&display_handle, &feedback); .create_global_with_default_feedback::<Ferese>(&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( apply_lid_policy(
&mut scan, &mut scan,
state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed), 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; 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, Ok(scan) => scan,
Err(error) => { Err(error) => {
tracing::error!(?node, %error, "failed to scan DRM connectors"); 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.enabled = true;
info.current_mode = Some(connected_mode_info(output.mode)); info.current_mode = Some(connected_mode_info(output.mode));
info.auto_refresh = output.settings.auto_refresh;
info.scale = output.settings.scale; info.scale = output.settings.scale;
info.transform = output.settings.transform; info.transform = output.settings.transform;
info.configured_position = output.settings.position; 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<OutputScan> { fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile], low_power: bool) -> io::Result<OutputScan> {
let resources = drm.resource_handles()?; let resources = drm.resource_handles()?;
let connected = resources let connected = resources
.connectors() .connectors()
@@ -1230,13 +1280,31 @@ fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result<Out
.unwrap_or_else(|| default_output_settings(connector.to_string())); .unwrap_or_else(|| default_output_settings(connector.to_string()));
let selected_mode = settings let selected_mode = settings
.enabled .enabled
.then(|| select_mode(&connector, settings.mode)) .then(|| {
let normal = select_mode(&connector, settings.mode)?;
if settings.auto_refresh && low_power {
let modes = connector
.modes()
.iter()
.map(|mode| {
let (width, height) = mode.size();
(width, height, OutputMode::from(*mode).refresh)
})
.collect::<Vec<_>>();
super::power::low_refresh_mode(normal.size(), &modes)
.map(|index| connector.modes()[index])
.or(Some(normal))
} else {
Some(normal)
}
})
.flatten(); .flatten();
connected_outputs.push(ConnectedOutputInfo { connected_outputs.push(ConnectedOutputInfo {
connector: connector.to_string(), connector: connector.to_string(),
identity: identity.clone(), identity: identity.clone(),
enabled: settings.enabled, enabled: settings.enabled,
profile: active_profile.map(|profile| profile.name.clone()), profile: active_profile.map(|profile| profile.name.clone()),
auto_refresh: settings.auto_refresh,
physical_size: connector.size(), physical_size: connector.size(),
current_mode: selected_mode.map(connected_mode_info), current_mode: selected_mode.map(connected_mode_info),
available_modes: connector.modes().iter().copied().map(connected_mode_info).collect(), 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 { fn default_output_settings(matcher: String) -> OutputSettings {
OutputSettings { OutputSettings {
auto_refresh: false,
matcher, matcher,
enabled: true, enabled: true,
mode: None, mode: None,
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod direct; pub(crate) mod direct;
mod power;
use std::ffi::OsString; use std::ffi::OsString;
+137
View File
@@ -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<u8> {
read_battery_percent(Path::new("/sys/class/power_supply"))
}
fn read_battery_percent(root: &Path) -> Option<u8> {
let mut entries = std::fs::read_dir(root)
.ok()?
.flatten()
.map(|entry| entry.path())
.collect::<Vec<_>>();
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::<u8>()
.ok()
.filter(|percent| *percent <= 100)
})
}
pub(super) fn low_power(previous: bool, battery: Option<u8>) -> 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<u8>) -> Option<bool> {
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<usize> {
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);
}
}
+15
View File
@@ -74,6 +74,7 @@ pub struct OutputProfile {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct OutputSettings { pub struct OutputSettings {
pub auto_refresh: bool,
pub matcher: String, pub matcher: String,
pub enabled: bool, pub enabled: bool,
pub mode: Option<OutputModeRequest>, pub mode: Option<OutputModeRequest>,
@@ -118,6 +119,8 @@ struct OutputProfileConfig {
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
struct OutputConfig { struct OutputConfig {
#[serde(default)]
auto_refresh: bool,
#[serde(rename = "match")] #[serde(rename = "match")]
matcher: String, matcher: String,
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -1139,6 +1142,7 @@ impl Config {
} }
Ok(OutputSettings { Ok(OutputSettings {
auto_refresh: output.auto_refresh,
matcher: matcher.to_owned(), matcher: matcher.to_owned(),
enabled: output.enabled, enabled: output.enabled,
mode: output mode: output
@@ -2448,6 +2452,7 @@ mod tests {
name: "docked".to_owned(), name: "docked".to_owned(),
outputs: vec![ outputs: vec![
OutputSettings { OutputSettings {
auto_refresh: false,
matcher: "HDMI-A-1".to_owned(), matcher: "HDMI-A-1".to_owned(),
enabled: true, enabled: true,
mode: Some(OutputModeRequest { mode: Some(OutputModeRequest {
@@ -2460,6 +2465,7 @@ mod tests {
position: Some([0, 0]), position: Some([0, 0]),
}, },
OutputSettings { OutputSettings {
auto_refresh: false,
matcher: "eDP-1".to_owned(), matcher: "eDP-1".to_owned(),
enabled: false, enabled: false,
mode: None, 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] #[test]
fn rejects_invalid_output_profiles() { fn rejects_invalid_output_profiles() {
let invalid_scale = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n"); let invalid_scale = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n");
+2
View File
@@ -1045,6 +1045,8 @@ impl Ferese {
"connected": true, "connected": true,
"enabled": info.enabled && mapped.is_some(), "enabled": info.enabled && mapped.is_some(),
"profile": info.profile, "profile": info.profile,
"auto_refresh": info.auto_refresh,
"low_power": backend.low_power,
"focused": id.is_some() && id == focused, "focused": id.is_some() && id == focused,
"workspace": workspace.map(|workspace| workspace.0), "workspace": workspace.map(|workspace| workspace.0),
"x": position.map(|position| position[0]), "x": position.map(|position| position[0]),
+25
View File
@@ -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 | | `match` | nonempty connector or persistent identity string | required; unique within profile |
| `enabled` | boolean | `true` | | `enabled` | boolean | `true` |
| `mode` | `"WIDTHxHEIGHT"` or `"WIDTHxHEIGHT@HZ"` | Preferred mode | | `mode` | `"WIDTHxHEIGHT"` or `"WIDTHxHEIGHT@HZ"` | Preferred mode |
| `auto-refresh` | boolean | `false` |
| `scale` | number > 0 | `1` | | `scale` | number > 0 | `1` |
| `transform` | enum below | `"normal"` | | `transform` | enum below | `"normal"` |
| `position` | `[integer, integer]` | Automatic horizontal placement | | `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. With a usable external display, closing the lid disables internal panels.
Workspaces move to a remaining display and return when their original output Workspaces move to a remaining display and return when their original output
returns. Ferese does not change the system's suspend policy. returns. Ferese does not change the system's suspend policy.
+172
View File
@@ -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<Self> {
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<String>,
pub current: Option<Mode>,
pub modes: Vec<Mode>,
}
pub fn load() -> Result<Vec<Display>, 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<Mode> {
let size = configured
.split('@')
.next()
.and_then(|size| size.split_once('x'))
.and_then(|(width, height)| Some((width.parse::<u32>().ok()?, height.parse::<u32>().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::<Vec<_>>();
modes.sort_by_key(|mode| mode.refresh);
modes.dedup();
modes
}
pub fn edits(prefix: &str, mode: Mode, automatic: bool) -> Vec<Edit> {
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(), "[email protected]");
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"), ""), "[email protected]");
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"), ""), "[email protected]");
}
}
+89
View File
@@ -1,3 +1,4 @@
mod displays;
mod fonts; mod fonts;
mod schema; mod schema;
mod store; mod store;
@@ -42,6 +43,9 @@ fn main() -> cosmic::iced::Result {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
RefreshDisplays,
DisplaysLoaded(Result<Vec<displays::Display>, String>),
RefreshRate(String, displays::Mode, bool),
ThemeChanged(ferese_config::theme::Snapshot), ThemeChanged(ferese_config::theme::Snapshot),
DragWindow, DragWindow,
Page(Page), Page(Page),
@@ -82,6 +86,7 @@ enum Message {
} }
struct App { struct App {
displays: Vec<displays::Display>,
note_editors: HashMap<String, NoteEditor>, note_editors: HashMap<String, NoteEditor>,
core: Core, core: Core,
path: PathBuf, path: PathBuf,
@@ -146,6 +151,7 @@ impl cosmic::Application for App {
let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family)); let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family));
let native_palette = visuals::Palette::from_resolved(&resolved.presented); let native_palette = visuals::Palette::from_resolved(&resolved.presented);
let mut app = Self { let mut app = Self {
displays: vec![],
note_editors: HashMap::new(), note_editors: HashMap::new(),
core, core,
path, path,
@@ -186,6 +192,14 @@ impl cosmic::Application for App {
fn update(&mut self, message: Message) -> Task<Message> { fn update(&mut self, message: Message) -> Task<Message> {
match message { 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) => { Message::ThemeChanged(snapshot) => {
self.resolved = snapshot; self.resolved = snapshot;
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); 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) => { Message::Page(page) => {
self.page = page; self.page = page;
self.search.clear(); self.search.clear();
if page == Page::Displays {
return self.load_displays();
}
if page == Page::Wallpaper { if page == Page::Wallpaper {
return self.load_thumbnail(); return self.load_thumbnail();
} }
@@ -575,6 +592,11 @@ impl cosmic::Application for App {
cosmic::iced::Subscription::batch([ cosmic::iced::Subscription::batch([
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes), cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes),
ferese_theme::service::subscription().map(Message::ThemeChanged), 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 prefix = format!("{prefix}.outputs.{output}");
let group = column([]) let group = column([])
.spacing(1) .spacing(1)
.push(self.refresh_controls(&prefix))
.push(self.field(schema::text( .push(self.field(schema::text(
format!("{prefix}.match"), format!("{prefix}.match"),
"Display", "Display",
@@ -1607,6 +1630,72 @@ impl App {
.into() .into()
} }
fn load_displays(&self) -> Task<Message> {
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::<f64>().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<Message> { fn change(&mut self, edit: Edit) -> Task<Message> {
match self.draft.edit(&edit) { match self.draft.edit(&edit) {
Ok(()) => { Ok(()) => {