Panel appearance now applies to the whole panel, with seven styles: Floating, Capsules, Notch, Edge to edge, Fused islands, Adaptive and Morph. - Share composition, geometry, and motion through the renderer-independent `ferese-panel` crate, including native materials, input regions, and Settings previews. - Add the Morph hub with media, calendar, notifications, controls, workspace previews, hover feedback, and attached mode. Keep recording and low-battery status visible and respect window clearance. - Cache workspace preview geometry, draw thumbnails directly, centralize Morph transitions, and keep rendering settings local to each shell instance. - Put Record Screen in the Morph header and show the display control only when an external monitor is connected. Update panel documentation, the desktop screenshot, and native check style names. Local validation included 660 compositor tests, 191 shell tests, isolated preview-cache checks, GPU Morph captures at 1×/1.25×/1.5×, formatting, and Clippy with warnings denied. The final header change passed all 23 Morph tests. Release workflow jobs are skipped for this PR.
615 lines
21 KiB
Rust
615 lines
21 KiB
Rust
use super::*;
|
||
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, key::Named};
|
||
let current = Mode::ALL
|
||
.iter()
|
||
.position(|mode| *mode == self.display_mode.selected)
|
||
.unwrap();
|
||
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("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)));
|
||
}
|
||
}
|