672 lines
22 KiB
Rust
672 lines
22 KiB
Rust
use super::{
|
||
FereseShell, Instant, Length, Message, Task, button, compositor_ipc, container, row, theme,
|
||
};
|
||
use serde_json::{Value, json};
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub(super) enum Mode {
|
||
Internal,
|
||
External,
|
||
#[default]
|
||
Extend,
|
||
Mirror,
|
||
}
|
||
|
||
impl Mode {
|
||
pub const ALL: [Self; 4] = [Self::Internal, Self::External, Self::Extend, Self::Mirror];
|
||
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Self::Internal => "internal-only",
|
||
Self::External => "external-only",
|
||
Self::Extend => "extend",
|
||
Self::Mirror => "mirror",
|
||
}
|
||
}
|
||
|
||
fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Internal => "Internal only",
|
||
Self::External => "External only",
|
||
Self::Extend => "Extend",
|
||
Self::Mirror => "Mirror",
|
||
}
|
||
}
|
||
|
||
fn icon(self) -> &'static [u8] {
|
||
match self {
|
||
Self::Internal => ferese_theme::icons::DISPLAY_INTERNAL,
|
||
Self::External => ferese_theme::icons::DISPLAY_EXTERNAL,
|
||
Self::Extend => ferese_theme::icons::DISPLAY_EXTEND,
|
||
Self::Mirror => ferese_theme::icons::DISPLAY_MIRROR,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(super) struct Display {
|
||
label: String,
|
||
internal: bool,
|
||
physical: bool,
|
||
enabled: bool,
|
||
usable: bool,
|
||
mirrored: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub(super) struct Inventory {
|
||
displays: Vec<Display>,
|
||
profile: Option<String>,
|
||
pub pending: bool,
|
||
timeout: u64,
|
||
}
|
||
|
||
impl Inventory {
|
||
pub fn external_connected(&self) -> bool {
|
||
self.displays
|
||
.iter()
|
||
.any(|display| display.physical && !display.internal)
|
||
}
|
||
|
||
fn available(&self, mode: Mode) -> bool {
|
||
let usable = self.displays.iter().filter(|display| display.usable);
|
||
match mode {
|
||
Mode::Internal => usable.clone().any(|display| display.internal),
|
||
Mode::External => usable.clone().any(|display| !display.internal),
|
||
Mode::Extend | Mode::Mirror => usable.count() >= 2,
|
||
}
|
||
}
|
||
|
||
fn current(&self) -> Mode {
|
||
let enabled = self.displays.iter().filter(|display| display.enabled);
|
||
if enabled.clone().any(|display| display.mirrored) {
|
||
Mode::Mirror
|
||
} else if enabled.clone().any(|display| display.internal) {
|
||
if enabled.clone().any(|display| !display.internal) {
|
||
Mode::Extend
|
||
} else {
|
||
Mode::Internal
|
||
}
|
||
} else {
|
||
Mode::External
|
||
}
|
||
}
|
||
|
||
fn parse(outputs: Value, profiles: Value) -> Result<Self, String> {
|
||
let outputs = outputs.as_array().ok_or("Missing monitor inventory")?;
|
||
let mut profile = None;
|
||
let mut displays = Vec::new();
|
||
for output in outputs
|
||
.iter()
|
||
.filter(|output| output["connected"].as_bool().unwrap_or(true))
|
||
{
|
||
if let Some(name) = output["profile"].as_str() {
|
||
profile = Some(name.to_owned());
|
||
}
|
||
|
||
let internal = output["internal"].as_bool().unwrap_or(false);
|
||
let name = if internal {
|
||
"Built-in display"
|
||
} else {
|
||
output["connector"]
|
||
.as_str()
|
||
.or_else(|| output["name"].as_str())
|
||
.unwrap_or("External display")
|
||
};
|
||
let mode = if output["current_mode"].is_object() {
|
||
&output["current_mode"]
|
||
} else {
|
||
output["available_modes"]
|
||
.as_array()
|
||
.and_then(|modes| {
|
||
modes
|
||
.iter()
|
||
.find(|mode| mode["preferred"] == true)
|
||
.or_else(|| modes.first())
|
||
})
|
||
.unwrap_or(&Value::Null)
|
||
};
|
||
let label = match (mode["width"].as_u64(), mode["height"].as_u64()) {
|
||
(Some(width), Some(height)) => format!("{name}, {width}×{height}"),
|
||
_ => name.to_owned(),
|
||
};
|
||
displays.push(Display {
|
||
label,
|
||
internal,
|
||
physical: output["internal"].is_boolean(),
|
||
enabled: output["applied_enabled"]
|
||
.as_bool()
|
||
.or_else(|| output["enabled"].as_bool())
|
||
.unwrap_or(false),
|
||
usable: output["available_modes"]
|
||
.as_array()
|
||
.is_some_and(|modes| !modes.is_empty()),
|
||
mirrored: output["mirror_source"].is_string(),
|
||
});
|
||
}
|
||
|
||
let timeout = profiles["profiles"]
|
||
.as_array()
|
||
.and_then(|profiles| {
|
||
profiles
|
||
.iter()
|
||
.find(|entry| entry["name"].as_str() == profile.as_deref())
|
||
})
|
||
.and_then(|entry| entry["confirm_timeout"].as_u64())
|
||
.unwrap_or(15);
|
||
Ok(Self {
|
||
displays,
|
||
profile,
|
||
pending: profiles["confirmation_pending"].as_bool().unwrap_or(false),
|
||
timeout,
|
||
})
|
||
}
|
||
}
|
||
|
||
fn load() -> Result<Inventory, String> {
|
||
Inventory::parse(
|
||
compositor_ipc::call("outputs")?,
|
||
compositor_ipc::call("output-profiles")?,
|
||
)
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub(super) struct Model {
|
||
pub open: bool,
|
||
pub inventory: Option<Inventory>,
|
||
selected: Mode,
|
||
pub busy: bool,
|
||
error: Option<String>,
|
||
generation: u64,
|
||
operation: u64,
|
||
confirmation_since: Option<Instant>,
|
||
}
|
||
|
||
impl Model {
|
||
pub fn begin(&mut self) {
|
||
self.open = true;
|
||
self.error = None;
|
||
if let Some(inventory) = &self.inventory {
|
||
self.selected = inventory.current();
|
||
}
|
||
}
|
||
|
||
pub fn external_connected(&self) -> bool {
|
||
self.inventory
|
||
.as_ref()
|
||
.is_some_and(Inventory::external_connected)
|
||
}
|
||
|
||
pub fn pending(&self) -> bool {
|
||
self.inventory
|
||
.as_ref()
|
||
.is_some_and(|inventory| inventory.pending)
|
||
}
|
||
|
||
fn update(&mut self, inventory: Inventory) {
|
||
if inventory.pending && !self.pending() {
|
||
self.confirmation_since = Some(Instant::now());
|
||
}
|
||
|
||
if !inventory.pending {
|
||
self.confirmation_since = None;
|
||
}
|
||
|
||
self.selected = inventory.current();
|
||
self.inventory = Some(inventory);
|
||
}
|
||
}
|
||
|
||
impl FereseShell {
|
||
pub(super) fn load_display_inventory(&mut self) -> Task<Message> {
|
||
if self.display_mode.busy {
|
||
return Task::none();
|
||
}
|
||
|
||
self.display_mode.generation = self.display_mode.generation.wrapping_add(1);
|
||
let generation = self.display_mode.generation;
|
||
Task::perform(
|
||
async {
|
||
tokio::task::spawn_blocking(load)
|
||
.await
|
||
.map_err(|error| error.to_string())
|
||
.and_then(|result| result)
|
||
},
|
||
move |result| cosmic::Action::App(Message::DisplaysLoaded(generation, result)),
|
||
)
|
||
}
|
||
|
||
pub(super) fn finish_display_inventory(
|
||
&mut self,
|
||
generation: u64,
|
||
result: Result<Inventory, String>,
|
||
) -> Task<Message> {
|
||
if generation != self.display_mode.generation || self.display_mode.busy {
|
||
return Task::none();
|
||
}
|
||
|
||
match result {
|
||
Ok(inventory) => {
|
||
self.display_mode.update(inventory);
|
||
}
|
||
Err(error) => self.display_mode.error = Some(error),
|
||
}
|
||
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn select_display_mode(
|
||
&mut self,
|
||
mode: Mode,
|
||
preserve_focus: bool,
|
||
) -> Task<Message> {
|
||
if !self.display_mode.busy
|
||
&& !self.display_mode.pending()
|
||
&& self
|
||
.display_mode
|
||
.inventory
|
||
.as_ref()
|
||
.is_some_and(|inventory| inventory.available(mode))
|
||
{
|
||
self.display_mode.selected = mode;
|
||
self.display_mode.error = None;
|
||
// Native buttons clear focus on mouse clicks and retain it for
|
||
// keyboard activation. Do not add focus back after a click.
|
||
if preserve_focus {
|
||
return Task::none();
|
||
}
|
||
|
||
// Arrow/number selection leaves Enter for the default action,
|
||
// rather than activating a previously clicked or tabbed mode.
|
||
return cosmic::iced::advanced::widget::operate(
|
||
cosmic::iced::advanced::widget::operation::scoped(
|
||
cosmic::iced::advanced::widget::Id::new("ferese-system-modal-controls"),
|
||
cosmic::iced::advanced::widget::operation::focusable::unfocus(),
|
||
),
|
||
);
|
||
}
|
||
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn display_mode_key(&mut self, key: &cosmic::iced::keyboard::Key) -> Task<Message> {
|
||
use cosmic::iced::keyboard::Key;
|
||
use cosmic::iced::keyboard::key::Named;
|
||
let current = Mode::ALL
|
||
.iter()
|
||
.position(|mode| *mode == self.display_mode.selected)
|
||
.expect("Mode::ALL contains every selectable display mode");
|
||
let mode = match key {
|
||
Key::Character(character) => character
|
||
.parse::<usize>()
|
||
.ok()
|
||
.filter(|number| (1..=4).contains(number))
|
||
.map(|number| Mode::ALL[number - 1]),
|
||
Key::Named(
|
||
Named::ArrowLeft | Named::ArrowUp | Named::ArrowRight | Named::ArrowDown,
|
||
) => {
|
||
let backwards = matches!(key, Key::Named(Named::ArrowLeft | Named::ArrowUp));
|
||
(1..=4)
|
||
.map(|step| Mode::ALL[(current + if backwards { 4 - step } else { step }) % 4])
|
||
.find(|mode| {
|
||
self.display_mode
|
||
.inventory
|
||
.as_ref()
|
||
.is_some_and(|inventory| inventory.available(*mode))
|
||
})
|
||
}
|
||
_ => None,
|
||
};
|
||
mode.map_or_else(Task::none, |mode| self.select_display_mode(mode, false))
|
||
}
|
||
|
||
pub(super) fn apply_display_mode(&mut self) -> Task<Message> {
|
||
if self.display_mode.busy
|
||
|| self.display_mode.pending()
|
||
|| !self
|
||
.display_mode
|
||
.inventory
|
||
.as_ref()
|
||
.is_some_and(|inventory| inventory.available(self.display_mode.selected))
|
||
{
|
||
return Task::none();
|
||
}
|
||
|
||
let mode = self.display_mode.selected;
|
||
self.display_operation(false, move || {
|
||
compositor_ipc::request("output-layout", json!({"layout": mode.name()})).map(|_| ())
|
||
})
|
||
}
|
||
|
||
pub(super) fn confirm_display_mode(&mut self, keep: bool) -> Task<Message> {
|
||
if self.display_mode.busy || !self.display_mode.pending() {
|
||
return Task::none();
|
||
}
|
||
|
||
self.display_operation(true, move || {
|
||
compositor_ipc::call(if keep {
|
||
"output-confirm"
|
||
} else {
|
||
"output-revert"
|
||
})
|
||
.map(|_| ())
|
||
})
|
||
}
|
||
|
||
fn display_operation(
|
||
&mut self,
|
||
close: bool,
|
||
operation: impl FnOnce() -> Result<(), String> + Send + 'static,
|
||
) -> Task<Message> {
|
||
self.display_mode.busy = true;
|
||
self.display_mode.generation = self.display_mode.generation.wrapping_add(1);
|
||
self.display_mode.operation = self.display_mode.operation.wrapping_add(1);
|
||
let serial = self.display_mode.operation;
|
||
Task::perform(
|
||
async move {
|
||
tokio::task::spawn_blocking(move || {
|
||
operation()?;
|
||
load()
|
||
})
|
||
.await
|
||
.map_err(|error| error.to_string())
|
||
.and_then(|result| result)
|
||
},
|
||
move |result| cosmic::Action::App(Message::DisplayModeCompleted(serial, close, result)),
|
||
)
|
||
}
|
||
|
||
pub(super) fn finish_display_mode(
|
||
&mut self,
|
||
serial: u64,
|
||
close: bool,
|
||
result: Result<Inventory, String>,
|
||
) -> Task<Message> {
|
||
if serial != self.display_mode.operation {
|
||
return Task::none();
|
||
}
|
||
|
||
self.display_mode.busy = false;
|
||
match result {
|
||
Ok(inventory) => {
|
||
self.display_mode.update(inventory);
|
||
self.display_mode.error = None;
|
||
if close || !self.display_mode.pending() {
|
||
return self.close_system_modal();
|
||
}
|
||
Task::none()
|
||
}
|
||
Err(error) => {
|
||
self.display_mode.error = Some(error);
|
||
self.load_display_inventory()
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(super) fn display_mode_rows(
|
||
&self,
|
||
) -> cosmic::widget::Column<'_, cosmic::Action<Message>, cosmic::Theme> {
|
||
let palette = self.config.theme.palette();
|
||
let material_opacity = self.config.theme.material_opacity();
|
||
let model = &self.display_mode;
|
||
let focus_visible = self
|
||
.system_modal
|
||
.as_ref()
|
||
.is_some_and(|modal| modal.focus_visible);
|
||
let pending = model.pending();
|
||
let mut header = row![
|
||
self.render
|
||
.text(ferese_config::tr!("Display mode"))
|
||
.size(17),
|
||
cosmic::iced::widget::Space::new().width(Length::Fill)
|
||
]
|
||
.align_y(cosmic::iced::Alignment::Center)
|
||
.spacing(12);
|
||
if let Some(profile) = model
|
||
.inventory
|
||
.as_ref()
|
||
.and_then(|inventory| inventory.profile.as_deref())
|
||
{
|
||
header = header.push(
|
||
self.render
|
||
.text(format!("{profile} profile"))
|
||
.size(12)
|
||
.class(theme::Text::Color(palette.muted)),
|
||
);
|
||
}
|
||
|
||
let mut cards = row::with_capacity(4).spacing(8);
|
||
for (index, mode) in Mode::ALL.into_iter().enumerate() {
|
||
let available = !model.busy
|
||
&& !pending
|
||
&& model
|
||
.inventory
|
||
.as_ref()
|
||
.is_some_and(|inventory| inventory.available(mode));
|
||
let card = cosmic::widget::column![
|
||
ferese_theme::icons::tinted(mode.icon(), 40, palette.text),
|
||
self.render
|
||
.text(mode.label())
|
||
.size(13)
|
||
.wrapping(cosmic::iced::widget::text::Wrapping::None),
|
||
self.render
|
||
.text((index + 1).to_string())
|
||
.size(11)
|
||
.class(theme::Text::Color(palette.muted)),
|
||
]
|
||
.align_x(cosmic::iced::Alignment::Center)
|
||
.spacing(8)
|
||
.width(Length::Fill);
|
||
cards = cards.push(
|
||
button::custom(card)
|
||
.id(format!("ferese-display-mode-{}", mode.name()).into())
|
||
.width(Length::Fill)
|
||
.padding([14, 6])
|
||
.class(ferese_theme::controls::material_button_style_with_focus(
|
||
palette,
|
||
model.selected == mode,
|
||
material_opacity,
|
||
focus_visible,
|
||
))
|
||
.on_press_maybe(
|
||
available.then_some(cosmic::Action::App(Message::SelectDisplayMode(mode))),
|
||
),
|
||
);
|
||
}
|
||
|
||
let mut rows = cosmic::widget::column![header, cards].spacing(14);
|
||
if let Some(inventory) = &model.inventory {
|
||
let mut displays =
|
||
cosmic::widget::column::with_capacity(inventory.displays.len()).spacing(6);
|
||
for display in &inventory.displays {
|
||
displays = displays.push(
|
||
row![
|
||
ferese_theme::icons::tinted(
|
||
ferese_theme::icons::DISPLAY,
|
||
16,
|
||
palette.muted
|
||
),
|
||
self.render
|
||
.text(&display.label)
|
||
.size(12)
|
||
.class(theme::Text::Color(palette.muted))
|
||
]
|
||
.spacing(8)
|
||
.align_y(cosmic::iced::Alignment::Center),
|
||
);
|
||
}
|
||
|
||
rows = rows.push(displays);
|
||
}
|
||
|
||
if let Some(error) = &model.error {
|
||
rows = rows.push(self.render.text(error).size(12));
|
||
}
|
||
|
||
let hint = if model.busy {
|
||
"Applying display configuration…".to_owned()
|
||
} else if pending {
|
||
let remaining = model
|
||
.inventory
|
||
.as_ref()
|
||
.map_or(15, |inventory| inventory.timeout)
|
||
.saturating_sub(
|
||
model
|
||
.confirmation_since
|
||
.map_or(0, |since| since.elapsed().as_secs()),
|
||
);
|
||
format!("Keep this configuration? Reverts in {remaining}s.")
|
||
} else {
|
||
"Arrow keys or 1–4 to choose. Enter to apply.".to_owned()
|
||
};
|
||
let apply = if pending {
|
||
Message::KeepDisplayMode
|
||
} else {
|
||
Message::ApplyDisplayMode
|
||
};
|
||
let cancel = if pending {
|
||
Message::RevertDisplayMode
|
||
} else {
|
||
Message::CancelPower
|
||
};
|
||
let can_apply = !model.busy
|
||
&& (pending
|
||
|| model
|
||
.inventory
|
||
.as_ref()
|
||
.is_some_and(|inventory| inventory.available(model.selected)));
|
||
rows.push(
|
||
self.render
|
||
.text(hint)
|
||
.size(12)
|
||
.class(theme::Text::Color(palette.muted)),
|
||
)
|
||
.push(
|
||
container(
|
||
row![
|
||
cosmic::iced::widget::Space::new().width(Length::Fill),
|
||
ferese_theme::controls::text_button(
|
||
if pending { "Revert" } else { "Cancel" },
|
||
self.render.font,
|
||
palette,
|
||
false
|
||
)
|
||
.class(ferese_theme::controls::material_button_style_with_focus(
|
||
palette,
|
||
false,
|
||
material_opacity,
|
||
focus_visible
|
||
))
|
||
.id("ferese-modal-cancel".into())
|
||
.on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))),
|
||
ferese_theme::controls::text_button(
|
||
if pending { "Keep" } else { "Apply" },
|
||
self.render.font,
|
||
palette,
|
||
true
|
||
)
|
||
.class(ferese_theme::controls::button_style_with_focus(
|
||
palette,
|
||
true,
|
||
focus_visible
|
||
))
|
||
.id("ferese-display-apply".into())
|
||
.on_press_maybe(can_apply.then_some(cosmic::Action::App(apply))),
|
||
]
|
||
.spacing(10),
|
||
)
|
||
.id("ferese-display-modal-actions"),
|
||
)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn output(internal: bool, enabled: bool, connected: bool, mirror: bool) -> Value {
|
||
json!({
|
||
"connector": if internal { "eDP-1" } else { "DP-1" },
|
||
"connected": connected, "internal": internal, "applied_enabled": enabled,
|
||
"mirror_source": mirror.then_some("source"),
|
||
"available_modes": [{"width": 1920, "height": 1080, "preferred": true}],
|
||
"profile": "desk"
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn disabled_and_mirrored_external_monitors_keep_the_bar_trigger() {
|
||
for mirror in [false, true] {
|
||
let inventory = Inventory::parse(
|
||
json!([
|
||
output(true, true, true, false),
|
||
output(false, mirror, true, mirror)
|
||
]),
|
||
json!({}),
|
||
)
|
||
.unwrap();
|
||
assert!(inventory.external_connected());
|
||
assert_eq!(
|
||
inventory.current(),
|
||
if mirror { Mode::Mirror } else { Mode::Internal }
|
||
);
|
||
assert!(Mode::ALL.into_iter().all(|mode| inventory.available(mode)));
|
||
}
|
||
|
||
let removed = Inventory::parse(
|
||
json!([
|
||
output(true, true, true, false),
|
||
output(false, false, false, false)
|
||
]),
|
||
json!({}),
|
||
)
|
||
.unwrap();
|
||
assert!(!removed.external_connected());
|
||
assert!(removed.available(Mode::Internal));
|
||
assert!(!removed.available(Mode::External));
|
||
assert!(!removed.available(Mode::Extend));
|
||
assert!(!removed.available(Mode::Mirror));
|
||
}
|
||
|
||
#[test]
|
||
fn confirmation_tracks_backend_state_and_profile_timeout() {
|
||
let mut model = Model::default();
|
||
let outputs = json!([
|
||
output(true, true, true, false),
|
||
output(false, true, true, false)
|
||
]);
|
||
model.update(
|
||
Inventory::parse(
|
||
outputs.clone(),
|
||
json!({"confirmation_pending": true, "profiles": [{"name": "desk", "confirm_timeout": 7}]}),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert!(model.pending());
|
||
assert_eq!(model.selected, Mode::Extend);
|
||
assert_eq!(model.inventory.as_ref().unwrap().timeout, 7);
|
||
let since = model.confirmation_since;
|
||
model.update(
|
||
Inventory::parse(outputs.clone(), json!({"confirmation_pending": true})).unwrap(),
|
||
);
|
||
assert_eq!(
|
||
model.confirmation_since, since,
|
||
"inventory refresh must not restart the countdown"
|
||
);
|
||
model.update(Inventory::parse(outputs, json!({"confirmation_pending": false})).unwrap());
|
||
assert!(!model.pending());
|
||
assert_eq!(model.confirmation_since, None);
|
||
}
|
||
|
||
#[test]
|
||
fn unusable_monitor_does_not_enable_an_unavailable_mode() {
|
||
let inventory = Inventory::parse(
|
||
json!([{"connector":"DP-1", "connected":true, "internal":false, "available_modes":[]}]),
|
||
json!({}),
|
||
)
|
||
.unwrap();
|
||
assert!(inventory.external_connected());
|
||
assert!(Mode::ALL.into_iter().all(|mode| !inventory.available(mode)));
|
||
}
|
||
}
|