Add manual and battery-aware automatic display refresh switching
This commit is contained in:
1 parent
a203b45a4b
commit
a02925b2ee
8 files changed
+515
-5
No files matched your search
@@ -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,4 +1,5 @@
|
|||||||
pub(crate) mod direct;
|
pub(crate) mod direct;
|
||||||
|
mod power;
|
||||||
|
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(()) => {
|
||||||
|
|||||||
Reference in new issue
Block a user