Files
feresaria/shell/ferese-settings/src/displays.rs
T

418 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.is_multiple_of(1000) {
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),
]
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Target {
pub key: String,
pub matcher: String,
pub title: String,
pub profile: Option<String>,
pub prefix: Option<String>,
pub active: bool,
pub connected: bool,
}
impl std::fmt::Display for Target {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} · {}{}",
self.title,
self.profile.as_deref().unwrap_or("Automatic"),
if self.connected { "" } else { " (offline)" }
)
}
}
pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -> Vec<Target> {
let mut targets = Vec::new();
for profile in 0..snapshot.records("output_profiles") {
let prefix = format!("output_profiles.{profile}");
let name = snapshot.string(&format!("{prefix}.name"), "Display profile");
for output in 0..snapshot.records(&format!("{prefix}.outputs")) {
let prefix = format!("{prefix}.outputs.{output}");
let matcher = snapshot.string(&format!("{prefix}.match"), "");
let live = displays
.iter()
.find(|d| d.connector == matcher || d.identity == matcher);
targets.push(Target {
key: format!("{name}\0{matcher}"),
title: live.map(|d| d.connector.clone()).unwrap_or_else(|| matcher.clone()),
matcher,
profile: Some(name.clone()),
prefix: Some(prefix),
active: live.is_some_and(|d| d.profile.as_deref() == Some(&name)),
connected: live.is_some(),
});
}
}
for display in displays {
if !targets
.iter()
.any(|t| t.matcher == display.connector || t.matcher == display.identity)
{
targets.push(Target {
key: format!("automatic\0{}", display.connector),
matcher: display.connector.clone(),
title: display.connector.clone(),
profile: None,
prefix: None,
active: true,
connected: true,
});
}
}
targets
}
pub(super) fn selected<'a>(targets: &'a [Target], key: Option<&str>) -> Option<&'a Target> {
targets
.iter()
.find(|t| Some(t.key.as_str()) == key)
.or_else(|| targets.iter().find(|t| t.active))
.or_else(|| targets.iter().find(|t| t.connected))
.or_else(|| targets.first())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Resolution {
pub width: u32,
pub height: u32,
}
impl std::fmt::Display for Resolution {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} × {}", self.width, self.height)
}
}
pub(super) fn resolutions(display: &Display) -> Vec<Resolution> {
let mut values: Vec<_> = display
.modes
.iter()
.filter(|m| m.width > 0 && m.height > 0)
.map(|m| Resolution {
width: m.width,
height: m.height,
})
.collect();
values.sort_by_key(|r| {
(
std::cmp::Reverse(u64::from(r.width) * u64::from(r.height)),
r.width,
r.height,
)
});
values.dedup();
values
}
pub(super) fn resolution(configured: &str, display: &Display) -> Option<Resolution> {
configured
.split('@')
.next()
.and_then(|v| v.split_once('x'))
.and_then(|(w, h)| {
Some(Resolution {
width: w.parse().ok()?,
height: h.parse().ok()?,
})
})
.or_else(|| {
display.current.map(|m| Resolution {
width: m.width,
height: m.height,
})
})
}
pub(super) fn change_resolution(
display: &Display,
configured: &str,
resolution: Resolution,
automatic: bool,
) -> Option<(Mode, bool)> {
let modes = choices(display, &format!("{}x{}", resolution.width, resolution.height));
let auto = automatic && modes.iter().any(|m| (59_000..=61_000).contains(&m.refresh));
let previous = configured
.split('@')
.nth(1)
.and_then(|r| r.parse::<f64>().ok())
.map(|r| r * 1000.)
.or_else(|| display.current.map(|m| f64::from(m.refresh)));
let mode = if auto {
modes.last()
} else {
modes
.iter()
.find(|m| previous.is_some_and(|r| (r - f64::from(m.refresh)).abs() < 1.))
.or_else(|| modes.last())
}?;
Some((*mode, auto))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Refresh {
Manual(Mode),
Automatic(Mode),
}
impl std::fmt::Display for Refresh {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Manual(mode) => f.write_str(&mode.label()),
Self::Automatic(_) => f.write_str("Auto (battery)"),
}
}
}
#[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]");
}
#[test]
fn selected_display_prefers_active_profile_and_survives_profile_reordering() {
let snapshot =
Snapshot::parse("output-profile dock { output HDMI-A-1; }; output-profile laptop { output eDP-1; }".into())
.unwrap();
let live = vec![Display {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: Some("laptop".into()),
current: None,
modes: vec![],
}];
let values = targets(&snapshot, &live);
assert_eq!(selected(&values, None).unwrap().profile.as_deref(), Some("laptop"));
let dock = selected(&values, Some("dock\0HDMI-A-1")).unwrap();
assert!(!dock.connected);
let key = dock.key.clone();
let reordered =
Snapshot::parse("output-profile laptop { output eDP-1; }; output-profile dock { output HDMI-A-1; }".into())
.unwrap();
let values = targets(&reordered, &live);
assert_eq!(
selected(&values, Some(&key)).unwrap().prefix.as_deref(),
Some("output_profiles.1.outputs.0")
);
}
#[test]
fn automatically_managed_outputs_are_still_visible() {
let snapshot = Snapshot::parse(String::new()).unwrap();
let live = vec![Display {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: None,
current: None,
modes: vec![],
}];
let values = targets(&snapshot, &live);
assert_eq!(values.len(), 1);
assert!(selected(&values, None).unwrap().prefix.is_none());
}
#[test]
fn resolution_changes_preserve_refresh_and_only_keep_valid_auto_policy() {
let mode = |width, height, refresh| Mode { width, height, refresh };
let display = Display {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: None,
current: Some(mode(2880, 1800, 60000)),
modes: vec![
mode(2880, 1800, 60000),
mode(2880, 1800, 120000),
mode(2560, 1440, 90000),
],
};
assert_eq!(resolutions(&display).len(), 2);
let high = Resolution {
width: 2880,
height: 1800,
};
assert_eq!(
change_resolution(&display, "2880x1800@120", high, false),
Some((mode(2880, 1800, 120000), false))
);
assert_eq!(
change_resolution(&display, "2880x1800@120", high, true),
Some((mode(2880, 1800, 120000), true))
);
let other = Resolution {
width: 2560,
height: 1440,
};
assert_eq!(
change_resolution(&display, "2880x1800@120", other, true),
Some((mode(2560, 1440, 90000), false))
);
assert!(change_resolution(&display, "", Resolution { width: 1, height: 1 }, true).is_none());
}
}