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
@@ -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 schema;
|
||||
mod store;
|
||||
@@ -42,6 +43,9 @@ fn main() -> cosmic::iced::Result {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
RefreshDisplays,
|
||||
DisplaysLoaded(Result<Vec<displays::Display>, String>),
|
||||
RefreshRate(String, displays::Mode, bool),
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
DragWindow,
|
||||
Page(Page),
|
||||
@@ -82,6 +86,7 @@ enum Message {
|
||||
}
|
||||
|
||||
struct App {
|
||||
displays: Vec<displays::Display>,
|
||||
note_editors: HashMap<String, NoteEditor>,
|
||||
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<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) => {
|
||||
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<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> {
|
||||
match self.draft.edit(&edit) {
|
||||
Ok(()) => {
|
||||
|
||||
Reference in new issue
Block a user