From 96f5ee442e60a82c5eaf74e39b1b110d4d5482f1 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 2 Oct 2026 13:25:39 +0100 Subject: [PATCH] Add display mode chooser and align screen-sharing styles Open the four display modes from XF86Display or the external-monitor bar icon. Reuse output reconciliation and confirmation for manual layout changes, and notify the shell when monitor state changes. Release backdrop locks before render-element queries to prevent blurred popups from freezing the compositor. Cover popup open, update and close with regression tests. Share translucent card styles between display modes and capture sources, show focus rings during keyboard navigation, and align picker buttons, label sizing and footer spacing with system modals. --- README.md | 1 + compositor/src/backends/direct.rs | 59 +- compositor/src/backends/direct/topology.rs | 1 + compositor/src/config.rs | 31 +- compositor/src/config/bindings.rs | 7 + compositor/src/input.rs | 3 + compositor/src/ipc.rs | 11 + compositor/src/output_policy.rs | 60 +- compositor/src/render/backdrop.rs | 76 ++- compositor/src/shell_control.rs | 30 +- .../assets/icons/display-extend.svg | 1 + .../assets/icons/display-external.svg | 1 + .../assets/icons/display-internal.svg | 1 + .../assets/icons/display-mirror.svg | 1 + crates/ferese-theme/src/controls.rs | 30 +- crates/ferese-theme/src/icons.rs | 4 + docs/configuration.md | 7 + docs/shortcuts.md | 8 + feresectl/src/main.rs | 23 +- portal/src/picker.rs | 76 +-- protocols/ferese-shell-v1.xml | 14 +- scripts/tests/fixtures/blurred-popup.c | 137 ++++ scripts/tests/test_blurred_popup_isolated.py | 85 +++ shell/ferese-shell/src/bar.rs | 19 +- shell/ferese-shell/src/compositor_ipc.rs | 2 +- shell/ferese-shell/src/control.rs | 17 +- shell/ferese-shell/src/display_mode.rs | 599 ++++++++++++++++++ shell/ferese-shell/src/main.rs | 25 +- shell/ferese-shell/src/surfaces.rs | 34 + shell/ferese-shell/src/system_modal.rs | 63 +- 30 files changed, 1354 insertions(+), 72 deletions(-) create mode 100644 crates/ferese-theme/assets/icons/display-extend.svg create mode 100644 crates/ferese-theme/assets/icons/display-external.svg create mode 100644 crates/ferese-theme/assets/icons/display-internal.svg create mode 100644 crates/ferese-theme/assets/icons/display-mirror.svg create mode 100644 scripts/tests/fixtures/blurred-popup.c create mode 100644 scripts/tests/test_blurred_popup_isolated.py create mode 100644 shell/ferese-shell/src/display_mode.rs diff --git a/README.md b/README.md index 25c0c3a..bab8ecf 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Shortcuts** whenever you want to change a shortcut or gesture. | Control | Action | | --- | --- | | Super + Enter | Open a terminal | +| Display Fn key (`XF86Display`) | Choose the display mode | | Super + H / J / K / L | Focus left / down / up / right | | Super + Backspace | Return to the last focused window | | Super + Escape | Toggle between the last two visited workspaces on this monitor | diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 700418a..ceedc2b 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -551,6 +551,7 @@ pub(crate) fn set_output_profile(state: &mut Ferese, name: &str) -> Result<(), S let known_good = backend.applied_outputs.clone(); backend.manual_outputs.profile = (name != "auto").then(|| name.to_owned()); backend.manual_outputs.internal = None; + backend.manual_outputs.layout = None; let desired = crate::output_policy::select_profile( &backend.monitors, backend.lid.closed(), @@ -584,6 +585,47 @@ pub(crate) fn set_output_profile(state: &mut Ferese, name: &str) -> Result<(), S return Err("profile does not match the connected monitors/lid".into()); } arm_output_confirmation(state, known_good, previous); + state.notify_monitor_state(); + Ok(()) +} + +pub(crate) fn set_output_layout(state: &mut Ferese, layout: crate::config::OutputLayout) -> Result<(), String> { + let backend = state + .direct_backend + .as_mut() + .ok_or("display modes require the direct DRM backend")?; + if !backend.active || backend.lid.pending() { + return Err("output session is inactive or refreshing lid state".into()); + } + let usable = backend + .monitors + .iter() + .filter(|monitor| monitor.usable) + .collect::>(); + let available = match layout { + crate::config::OutputLayout::InternalOnly => usable.iter().any(|monitor| monitor.internal), + crate::config::OutputLayout::ExternalOnly => usable.iter().any(|monitor| !monitor.internal), + crate::config::OutputLayout::Extend | crate::config::OutputLayout::Mirror => usable.len() >= 2, + }; + if !available { + return Err("this display mode requires monitors that are not connected".into()); + } + let previous = backend.manual_outputs.clone(); + let known_good = backend.applied_outputs.clone(); + backend.manual_outputs.profile = None; + backend.manual_outputs.internal = None; + backend.manual_outputs.layout = Some(layout); + if let Err(error) = validate_live_outputs(state, &state.output_profiles) { + state.direct_backend.as_mut().unwrap().manual_outputs = previous; + return Err(error); + } + reconcile_outputs(state, false); + if let Some(error) = state.direct_backend.as_ref().unwrap().output_error.clone() { + state.direct_backend.as_mut().unwrap().manual_outputs = previous; + return Err(error); + } + arm_output_confirmation(state, known_good, previous); + state.notify_monitor_state(); Ok(()) } @@ -606,12 +648,14 @@ pub(crate) fn set_internal_output(state: &mut Ferese, enabled: bool) -> Result<( let previous = backend.manual_outputs.clone(); let known_good = backend.applied_outputs.clone(); backend.manual_outputs.internal = Some(enabled); + backend.manual_outputs.layout = None; reconcile_outputs(state, false); if let Some(error) = state.direct_backend.as_ref().unwrap().output_error.clone() { state.direct_backend.as_mut().unwrap().manual_outputs = previous; return Err(error); } arm_output_confirmation(state, known_good, previous); + state.notify_monitor_state(); Ok(()) } @@ -672,6 +716,7 @@ pub(crate) fn confirm_output_configuration(state: &mut Ferese, confirm: bool) -> return Err(error); } } + state.notify_monitor_state(); Ok(()) } @@ -2193,11 +2238,21 @@ fn refresh_monitor_policy(state: &mut Ferese) -> Result<(), String> { } } } - let previous_override = backend.manual_outputs.profile.clone(); + let previous_override = ( + backend.manual_outputs.profile.clone(), + backend.manual_outputs.internal, + backend.manual_outputs.layout, + ); backend .manual_outputs .observe(&backend.monitors, backend.lid.closed(), &state.output_profiles); - if previous_override != backend.manual_outputs.profile { + if previous_override + != ( + backend.manual_outputs.profile.clone(), + backend.manual_outputs.internal, + backend.manual_outputs.layout, + ) + { backend.confirmation = None; if let Some(token) = backend.confirmation_timer.take() { state.loop_handle.remove(token); diff --git a/compositor/src/backends/direct/topology.rs b/compositor/src/backends/direct/topology.rs index 45d4dd6..ee33771 100644 --- a/compositor/src/backends/direct/topology.rs +++ b/compositor/src/backends/direct/topology.rs @@ -351,6 +351,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { sync_battery_timer(state); state.restore_output_focus(); state.relayout(); + state.notify_monitor_state(); render_all(state); lid::apply_suspend_policy(state); } diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 0c4cea5..d20e02b 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -386,6 +386,31 @@ pub(crate) fn config_path() -> Option { mod tests { use super::*; + #[test] + fn display_key_has_a_default_binding_and_can_be_rebound() { + let bindings = parse("").runtime_config().unwrap().bindings; + assert!(bindings.iter().any( + |binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_XF86Display) + && binding.action == BindingAction::ToggleDisplayMode + )); + let bindings = parse( + "binding keys=\"XF86Display\" disabled=#true\nbinding keys=\"Super+P\" action=\"toggle-display-mode\"\n", + ) + .runtime_config() + .unwrap() + .bindings; + assert!( + !bindings + .iter() + .any(|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_XF86Display)) + ); + assert!( + bindings + .iter() + .any(|binding| binding.modifiers.logo && binding.action == BindingAction::ToggleDisplayMode) + ); + } + #[test] fn shortcut_hint_has_a_default_binding_and_can_be_rebound() { let bindings = parse("").runtime_config().unwrap().bindings; @@ -925,7 +950,7 @@ mod tests { let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 53); + assert_eq!(bindings.len(), 54); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -969,7 +994,7 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 53); + assert_eq!(bindings.len(), 54); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into()) @@ -977,7 +1002,7 @@ mod tests { let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 52); + assert_eq!(bindings.len(), 53); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close)); } diff --git a/compositor/src/config/bindings.rs b/compositor/src/config/bindings.rs index 3aa2389..76532ff 100644 --- a/compositor/src/config/bindings.rs +++ b/compositor/src/config/bindings.rs @@ -336,6 +336,10 @@ pub(super) fn parse_action( no_argument()?; Ok(BindingAction::ToggleOverview) } + "toggle-display-mode" => { + no_argument()?; + Ok(BindingAction::ToggleDisplayMode) + } "toggle-keybinding-guide" => { no_argument()?; Ok(BindingAction::ToggleKeybindingGuide) @@ -380,6 +384,7 @@ pub(super) fn default_bindings() -> Vec { binding("Super+]", "expel", None), binding("Super+Shift+Space", "toggle-floating", None), binding("Super+Tab", "toggle-overview", None), + binding("XF86Display", "toggle-display-mode", None), binding("Super+F1", "toggle-keybinding-guide", None), binding("Super+Escape", "workspace-back-and-forth", None), binding("Super+BackSpace", "focus-last-window", None), @@ -467,6 +472,7 @@ pub enum BindingAction { ToggleFloating, ToggleOverview, ToggleKeybindingGuide, + ToggleDisplayMode, } impl Binding { @@ -576,6 +582,7 @@ impl Binding { BindingAction::Expel => "Move window out of column".into(), BindingAction::ToggleFloating => "Toggle floating window".into(), BindingAction::ToggleOverview => "Open or close overview".into(), + BindingAction::ToggleDisplayMode => "Open display mode chooser".into(), BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(), }; let mut parts = Vec::new(); diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 9089414..da5c175 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -1072,6 +1072,9 @@ impl Ferese { BindingAction::Expel => self.expel_focused_window(), BindingAction::ToggleFloating => self.toggle_focused_floating(), BindingAction::ToggleOverview => self.toggle_overview(), + BindingAction::ToggleDisplayMode => { + self.toggle_display_mode(); + } BindingAction::ToggleKeybindingGuide => { self.toggle_keybinding_guide(); } diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index ffa0a65..4b3ba32 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -660,6 +660,9 @@ impl Ferese { "toggle-maximized" => self.toggle_focused_maximized(), "toggle-layout" => self.toggle_layout_mode(), "toggle-overview" => self.toggle_overview(), + "toggle-display-mode" => { + self.toggle_display_mode(); + } "toggle-keybinding-guide" => { if !self.toggle_keybinding_guide() { return Err(CommandError::new( @@ -699,6 +702,7 @@ impl Ferese { return Ok(json!({ "confirmation_pending": self.direct_backend.as_ref().is_some_and(|backend| backend.confirmation_pending()), "manual_profile": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.profile.as_ref()), + "manual_layout": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.layout), "manual_internal": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.internal), "profiles": self.output_profiles.iter().map(|profile| json!({ "name": profile.name, "layout": profile.layout, @@ -720,6 +724,13 @@ impl Ferese { .map_err(|error| CommandError::new("output_configuration_failed", error))?; return Ok(self.outputs_json()); } + "output-layout" => { + let layout = serde_json::from_value::(args["layout"].clone()) + .map_err(|_| CommandError::new("invalid_arguments", "unknown display layout"))?; + crate::backends::direct::set_output_layout(self, layout) + .map_err(|error| CommandError::new("output_configuration_failed", error))?; + return Ok(self.outputs_json()); + } "output-internal" => { let enabled = args["enabled"] .as_bool() diff --git a/compositor/src/output_policy.rs b/compositor/src/output_policy.rs index 2c26ed5..a3dc5f3 100644 --- a/compositor/src/output_policy.rs +++ b/compositor/src/output_policy.rs @@ -40,6 +40,7 @@ pub(crate) struct DesiredOutputConfiguration { pub(crate) struct ManualOverride { pub profile: Option, pub internal: Option, + pub layout: Option, topology: Option<(BTreeSet, bool)>, } @@ -55,6 +56,7 @@ impl ManualOverride { if self.topology.as_ref().is_some_and(|old| old != &topology) { self.profile = None; self.internal = None; + self.layout = None; } if self.profile.as_ref().is_some_and(|name| { !profiles @@ -134,7 +136,9 @@ pub(crate) fn select_profile( }) .map(|(_, profile)| profile) }); - let layout = selected.map_or(OutputLayout::Extend, |profile| profile.layout); + let layout = manual + .layout + .unwrap_or_else(|| selected.map_or(OutputLayout::Extend, |profile| profile.layout)); let manage_lid = selected.is_none_or(|profile| profile.lid_policy == LidPolicy::DockOrSuspend); let external = monitors.iter().any(|monitor| monitor.usable && !monitor.internal); let mut outputs = monitors @@ -149,6 +153,9 @@ pub(crate) fn select_profile( }) .cloned() .unwrap_or_else(|| defaults(monitor.identity.clone())); + if manual.layout.is_some() { + settings.enabled = true; + } settings.enabled &= monitor.usable; settings.enabled &= match layout { OutputLayout::InternalOnly => monitor.internal, @@ -465,6 +472,57 @@ mod tests { } } + #[test] + fn manual_layouts_reuse_profiles_and_expire_on_monitor_changes() { + let connected = [monitor("eDP-1", true), monitor("DP-1", false)]; + let mut settings = defaults("DP-1".into()); + settings.enabled = false; + settings.scale = 1.5; + let mut mobile = profile("mobile", &["eDP-1"], OutputLayout::InternalOnly); + mobile.outputs.push(settings); + let profiles = [mobile]; + for layout in [ + OutputLayout::InternalOnly, + OutputLayout::ExternalOnly, + OutputLayout::Extend, + OutputLayout::Mirror, + ] { + let mut manual = ManualOverride { + layout: Some(layout), + ..ManualOverride::default() + }; + manual.observe(&connected, false, &profiles); + let desired = select_profile(&connected, false, &profiles, &manual); + assert_eq!( + desired.outputs[0].settings.enabled, + layout != OutputLayout::ExternalOnly + ); + assert_eq!( + desired.outputs[1].settings.enabled, + layout != OutputLayout::InternalOnly + ); + assert_eq!( + desired.outputs[1].settings.scale, 1.5, + "manual layout must preserve configured scale" + ); + assert_eq!( + desired + .outputs + .iter() + .filter(|output| output.mirror_source.is_some()) + .count(), + usize::from(layout == OutputLayout::Mirror) + ); + manual.observe(&connected[..1], false, &profiles); + assert_eq!(manual.layout, None); + assert!( + select_profile(&connected[..1], false, &profiles, &manual).outputs[0] + .settings + .enabled + ); + } + } + #[test] fn specificity_and_config_order() { let monitors = [monitor("eDP-1", true), monitor("DP-1", false)]; diff --git a/compositor/src/render/backdrop.rs b/compositor/src/render/backdrop.rs index 7c5a61a..3e897e3 100644 --- a/compositor/src/render/backdrop.rs +++ b/compositor/src/render/backdrop.rs @@ -1,13 +1,13 @@ use super::*; -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub(super) struct Backdrop { pub commit: CommitCounter, sources: Vec, region: Option>, } -#[derive(Debug)] +#[derive(Clone, Debug)] struct Source { id: Id, commit: CommitCounter, @@ -19,9 +19,9 @@ struct Source { } impl Backdrop { - fn update( + fn update<'a, E: Element + 'a>( &mut self, - elements: &[E], + elements: impl IntoIterator, region: Rectangle, scale: RenderScale, ) -> bool { @@ -90,15 +90,33 @@ impl Backdrop { } } +fn update_shared( + shared: &std::sync::Mutex, + elements: &[E], + owner: &Id, + region: Rectangle, + scale: RenderScale, +) -> bool { + // Element queries can read this same backdrop's commit. Snapshot the state, + // release its lock, then inspect sources. Rendering is the sole writer. + let mut backdrop = shared.lock().unwrap().clone(); + let sources = elements.iter().filter(|element| element.id() != owner); + let changed = backdrop.update(sources, region, scale); + *shared.lock().unwrap() = backdrop; + changed +} + pub(super) fn update(elements: &[AnimatedWindowRenderElement], scale: f64) { // Resolve lower blurs first so their damage propagates to higher materials. for (index, element) in elements.iter().enumerate().rev() { if let AnimatedWindowRenderElement::Blur(blur) = element { - let changed = - blur.backdrop - .lock() - .unwrap() - .update(&elements[index + 1..], blur.geometry(scale.into()), scale.into()); + let changed = update_shared( + &blur.backdrop, + &elements[index + 1..], + blur.id(), + blur.geometry(scale.into()), + scale.into(), + ); if changed { blur.capture_dirty.store(true, Ordering::Relaxed); } @@ -115,6 +133,7 @@ mod tests { commit: CommitCounter, rect: Rectangle, damage: Vec>, + probe: Option>>, opaque: bool, alpha: f32, } @@ -126,6 +145,7 @@ mod tests { commit: CommitCounter::default(), rect, damage: vec![], + probe: None, opaque: false, alpha: 1.0, } @@ -142,6 +162,9 @@ mod tests { &self.id } fn current_commit(&self) -> CommitCounter { + if let Some(backdrop) = &self.probe { + let _guard = backdrop.try_lock().expect("backdrop lock held during an element query"); + } self.commit } fn src(&self) -> Rectangle { @@ -173,6 +196,41 @@ mod tests { Rectangle::new((x, y).into(), (w, h).into()) } + #[test] + fn blurred_popup_open_update_close_releases_backdrop_before_element_queries() { + let region = rect(0, 0, 100, 100); + let backdrop = Arc::new(std::sync::Mutex::new(Backdrop::default())); + let owner = Id::new(); + let mut panel = TestElement::new(region); + panel.probe = Some(backdrop.clone()); + let mut popup_alias = TestElement::new(region); + popup_alias.id = owner.clone(); + let mut elements = [popup_alias, panel]; + + // Open: ignore a repeated instance of the cached popup material, and + // allow dependencies to query backdrop state without a nested lock. + assert!(update_shared(&backdrop, &elements, &owner, region, 1.0.into())); + assert_eq!(backdrop.lock().unwrap().sources.len(), 1); + assert!(!update_shared(&backdrop, &elements, &owner, region, 1.0.into())); + + // Update both content and bounds while the popup remains visible. + elements[1].repaint(vec![rect(10, 10, 5, 5)]); + assert!(update_shared(&backdrop, &elements, &owner, region, 1.0.into())); + assert!(update_shared( + &backdrop, + &elements, + &owner, + rect(5, 5, 90, 90), + 1.0.into() + )); + + // Close and reopen: dependencies disappear, then are tracked again. + assert!(update_shared::(&backdrop, &[], &owner, region, 1.0.into())); + assert!(backdrop.lock().unwrap().sources.is_empty()); + assert!(update_shared(&backdrop, &elements, &owner, region, 1.0.into())); + assert!(!update_shared(&backdrop, &elements, &owner, region, 1.0.into())); + } + #[test] fn only_damage_intersecting_the_sample_invalidates_its_output() { let mut first = Backdrop::default(); diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 3c1a682..abd34c4 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities; use crate::state::ClientState; pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(5, ()); + display.create_global::(6, ()); } pub(crate) fn config_chunks(source: &str) -> Vec<&str> { @@ -206,6 +206,34 @@ impl Ferese { } } + pub(crate) fn notify_monitor_state(&self) { + for shell in self + .shell_resources + .iter() + .filter_map(|shell| shell.upgrade().ok()) + .filter(|shell| shell.version() >= 6) + { + shell.monitor_state_changed(); + } + } + + pub(crate) fn toggle_display_mode(&mut self) -> bool { + if self.session_lock.active() { + return false; + } + let Some(shell) = self + .shell_resources + .iter() + .filter_map(|shell| shell.upgrade().ok()) + .find(|shell| shell.version() >= 6) + else { + tracing::warn!("display mode chooser requires the updated Ferese shell"); + return false; + }; + shell.toggle_display_mode(self.focused_output().map_or_else(String::new, |output| output.name())); + true + } + pub(crate) fn toggle_keybinding_guide(&mut self) -> bool { if self.session_lock.active() { return false; diff --git a/crates/ferese-theme/assets/icons/display-extend.svg b/crates/ferese-theme/assets/icons/display-extend.svg new file mode 100644 index 0000000..2f96581 --- /dev/null +++ b/crates/ferese-theme/assets/icons/display-extend.svg @@ -0,0 +1 @@ + diff --git a/crates/ferese-theme/assets/icons/display-external.svg b/crates/ferese-theme/assets/icons/display-external.svg new file mode 100644 index 0000000..f47d20a --- /dev/null +++ b/crates/ferese-theme/assets/icons/display-external.svg @@ -0,0 +1 @@ + diff --git a/crates/ferese-theme/assets/icons/display-internal.svg b/crates/ferese-theme/assets/icons/display-internal.svg new file mode 100644 index 0000000..d11f539 --- /dev/null +++ b/crates/ferese-theme/assets/icons/display-internal.svg @@ -0,0 +1 @@ + diff --git a/crates/ferese-theme/assets/icons/display-mirror.svg b/crates/ferese-theme/assets/icons/display-mirror.svg new file mode 100644 index 0000000..c4781d3 --- /dev/null +++ b/crates/ferese-theme/assets/icons/display-mirror.svg @@ -0,0 +1 @@ + diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs index 26b0e76..caf3646 100644 --- a/crates/ferese-theme/src/controls.rs +++ b/crates/ferese-theme/src/controls.rs @@ -9,7 +9,24 @@ pub fn surface(background: Color, radius: f32) -> theme::Container<'static> { } pub fn button_style(p: Palette, selected: bool) -> theme::Button { - styled_button(p, selected, false) + styled_button(p, selected, false, 1.0) +} + +/// Selection cards share native button states while revealing the modal material. +pub fn material_button_style(p: Palette, selected: bool, material_opacity: f32) -> theme::Button { + let opacity = if material_opacity.is_finite() { + material_opacity.clamp(0.0, 1.0) + } else { + 1.0 + }; + + let fill = if opacity < 1.0 { + opacity * if selected { 0.65 } else { 0.25 } + } else { + 1.0 + }; + + styled_button(p, selected, false, fill) } pub fn text_button<'a, M: Clone + 'a>( @@ -65,7 +82,7 @@ pub(crate) fn switch_colors(palette: Palette, enabled: bool, hovered: bool) -> ( } pub fn navigation_style(p: Palette, selected: bool) -> theme::Button { - styled_button(p, selected, true) + styled_button(p, selected, true, 1.0) } pub fn settings_input(p: Palette) -> theme::TextInput { @@ -111,7 +128,7 @@ pub fn select<'a, M: 'a>( })) } -fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button { +fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32) -> theme::Button { let style = move |hover: bool, focused: bool| { let background = if selected { mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 }) @@ -132,6 +149,13 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button (background, p.text) }; + let mut background = background; + background.a *= if hover && opacity < 1.0 { + (opacity + 0.12).min(1.0) + } else { + opacity + }; + button::Style { background: Some(Background::Color(background)), text_color: Some(on), diff --git a/crates/ferese-theme/src/icons.rs b/crates/ferese-theme/src/icons.rs index b39c0d8..6845375 100644 --- a/crates/ferese-theme/src/icons.rs +++ b/crates/ferese-theme/src/icons.rs @@ -6,6 +6,10 @@ use cosmic::widget::icon; pub const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg"); pub const BATTERY: &[u8] = include_bytes!("../assets/icons/battery.svg"); pub const CHECK: &[u8] = include_bytes!("../assets/icons/check.svg"); +pub const DISPLAY_INTERNAL: &[u8] = include_bytes!("../assets/icons/display-internal.svg"); +pub const DISPLAY_EXTERNAL: &[u8] = include_bytes!("../assets/icons/display-external.svg"); +pub const DISPLAY_EXTEND: &[u8] = include_bytes!("../assets/icons/display-extend.svg"); +pub const DISPLAY_MIRROR: &[u8] = include_bytes!("../assets/icons/display-mirror.svg"); pub const DISPLAY: &[u8] = include_bytes!("../assets/icons/display.svg"); pub const FERESE: &[u8] = include_bytes!("../assets/icons/ferese.svg"); pub const OVERVIEW: &[u8] = include_bytes!("../assets/icons/overview.svg"); diff --git a/docs/configuration.md b/docs/configuration.md index 36a31dc..0f8f755 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -520,6 +520,8 @@ restores evacuated workspaces. ```sh feresectl outputs feresectl output-profiles +feresectl toggle-display-mode +feresectl output-layout extend feresectl output-profile presentation feresectl output-confirm feresectl output-profile auto @@ -527,6 +529,11 @@ feresectl output-internal off feresectl output-revert ``` +The display Fn key (`XF86Display`) and the bar’s display icon open the same +display-mode chooser. The icon appears while an external monitor is connected. +`output-layout` selects a layout without requiring a named profile and keeps +the matching profile’s mode, scale and transform settings. + A manual choice overrides automatic selection until the monitor set or lid state changes. Reload keeps it only if its profile still exists and matches. Manual changes revert after `confirm-timeout` seconds unless confirmed; diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 3382d2c..705ac33 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -87,11 +87,19 @@ by default. The action can also be bound as `workspace-back-and-forth` or run wi | Super + Shift + S | Select a screenshot area and open Satty | | Super + Shift + E | Ask to log out | | Super + F1 | Open or close the shortcut hint | +| Display Fn key (`XF86Display`) | Open or close the display-mode chooser | Super + F1 works even when the guide at login is disabled. It opens on the focused monitor. Bind `toggle-keybinding-guide` to change the shortcut, or run `feresectl toggle-keybinding-guide`. +The display icon appears in the bar when an external monitor is connected, +including when that monitor is disabled or mirrored. Click it to choose Internal +only, External only, Extend or Mirror. Arrow keys or 1–4 select a mode; Enter +applies it. Tab moves between controls and Escape cancels. After applying, choose +Keep before the confirmation timeout or Revert to restore the last confirmed +configuration. Bind `toggle-display-mode` if your keyboard uses another key. + In Satty, Enter saves the edited screenshot and copies it to the clipboard; Escape discards it. See [Configuration](configuration.md#commands-and-bindings) for dependencies and custom screenshot commands. diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 6fa1770..986f9a6 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -111,6 +111,13 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), let command = args.next().ok_or_else(usage)?; let positional = args.collect::>(); let payload = match command.as_str() { + "output-layout" => { + exactly_one(&command, &positional, "internal-only, external-only, extend or mirror")?; + if !["internal-only", "external-only", "extend", "mirror"].contains(&positional[0].as_str()) { + return Err("unknown display layout".into()); + } + json!({ "layout": positional[0] }) + } "output-profile" => { exactly_one(&command, &positional, "profile name or auto")?; json!({ "name": positional[0] }) @@ -170,6 +177,7 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), | "get-workspaces" | "get-outputs" | "outputs" + | "toggle-display-mode" | "output-profiles" | "output-confirm" | "output-revert" @@ -208,13 +216,26 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-layout \n feresectl toggle-display-mode\n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] mod tests { use super::*; + #[test] + fn display_mode_commands_validate_layouts() { + for layout in ["internal-only", "external-only", "extend", "mirror"] { + assert_eq!( + parse_args(["output-layout".into(), layout.into()]).unwrap().1, + json!({"layout":layout}) + ); + } + assert!(parse_args(["output-layout".into(), "docked".into()]).is_err()); + assert!(parse_args(["output-layout".into()]).is_err()); + assert!(parse_args(["toggle-display-mode".into()]).is_ok()); + } + #[test] fn shortcut_hint_command_rejects_extra_arguments() { assert_eq!( diff --git a/portal/src/picker.rs b/portal/src/picker.rs index 52ffea5..c75550d 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -86,7 +86,7 @@ pub fn run() -> Result<(), Box> { } let (theme, font, background, palette) = appearance(); - let height = 180. + prompt.source_list_height() + if prompt.persist_mode > 0 { 64. } else { 0. }; + let height = 180. + prompt.source_list_height() + if prompt.persist_mode > 0 { 48. } else { 0. }; cosmic::app::run::( Settings::default() .size(cosmic::iced::Size::new(400., height)) @@ -243,7 +243,7 @@ impl cosmic::Application for Picker { } else { &self.prompt.app }; - let mut content = column([]).width(Length::Fill).spacing(10).push( + let mut content = column([]).width(Length::Fill).height(Length::Fill).spacing(10).push( row![ glyph(ferese_theme::icons::DISPLAY, 24), column![ @@ -299,16 +299,11 @@ impl cosmic::Application for Picker { } sources = sources.push( button::custom(entry) - .class(if selected { - ferese_theme::accent_button() - } else { - ferese_theme::controls::notification_button( - self.palette.text, - self.palette.text.scale_alpha(0.06), - self.palette.radius.min(9.), - false, - ) - }) + .class(ferese_theme::controls::material_button_style( + self.palette, + selected, + self.background.a, + )) .padding(8) .width(Length::Fill) .on_press(Message::Select(index)), @@ -332,39 +327,38 @@ impl cosmic::Application for Picker { } else { "Allow this selection without asking again" }; + content = content.push( - button::custom(ferese_theme::menus::row( - label, - ferese_theme::menus::switch(self.remember, self.palette, 1.), - self.font, - )) - .class(ferese_theme::controls::navigation_style(self.palette, false)) - .width(Length::Fill) - .on_press_maybe(self.can_remember().then_some(Message::Remember)), + row![ + self.text(label).size(12).width(Length::Fill), + ferese_theme::controls::switch(self.remember, self.palette) + .on_press_maybe(self.can_remember().then_some(Message::Remember)), + ] + .align_y(Alignment::Center), ); } - let mut share = button::custom( - row![ - self.text(if self.prompt.window_capture { "Capture" } else { "Share" }), - glyph(ferese_theme::icons::ARROW, 16) - ] - .spacing(8) - .align_y(Alignment::Center), + + let share = ferese_theme::controls::text_button( + if self.prompt.window_capture { "Capture" } else { "Share" }, + self.font, + self.palette, + true, ) - .class(ferese_theme::accent_button()) - .padding([6, 16]); - if !self.selected.is_empty() { - share = share.on_press(Message::Share); - } - content = content.push( - row![ - button::custom(self.text("Cancel")) - .class(ferese_theme::controls::button_style(self.palette, false)) - .on_press(Message::Cancel), - share - ] - .spacing(8), - ); + .on_press_maybe((!self.selected.is_empty()).then_some(Message::Share)); + let cancel = ferese_theme::controls::text_button("Cancel", self.font, self.palette, false) + .class(ferese_theme::controls::material_button_style( + self.palette, + false, + self.background.a, + )) + .on_press(Message::Cancel); + content = content + .push(cosmic::iced::widget::Space::new().height(Length::Fill)) + .push( + row![cosmic::iced::widget::Space::new().width(Length::Fill), cancel, share] + .spacing(10) + .align_y(Alignment::Center), + ); container(content).padding(14).width(Length::Fill).into() } } diff --git a/protocols/ferese-shell-v1.xml b/protocols/ferese-shell-v1.xml index b93154e..a741a3f 100644 --- a/protocols/ferese-shell-v1.xml +++ b/protocols/ferese-shell-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the shell-control capability. It does not assign a Wayland surface role. @@ -35,7 +35,7 @@ - + Window, workspace, and output identifiers are unsigned 64-bit values encoded as high and low 32-bit words. Each snapshot atomically replaces @@ -171,5 +171,15 @@ + + + + + + + Query the output IPC inventory after reconciliation. This includes + disabled monitors and mirror targets without a logical wl_output. + + diff --git a/scripts/tests/fixtures/blurred-popup.c b/scripts/tests/fixtures/blurred-popup.c new file mode 100644 index 0000000..4de91df --- /dev/null +++ b/scripts/tests/fixtures/blurred-popup.c @@ -0,0 +1,137 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include "xdg-shell-client-protocol.h" +#include "ferese-effects-client-protocol.h" + +static struct wl_compositor *compositor; +static struct wl_shm *shm; +static struct xdg_wm_base *wm; +static struct ferese_effects_manager_v1 *effects; + +static void ping(void *data, struct xdg_wm_base *object, uint32_t serial) { + (void)data; + xdg_wm_base_pong(object, serial); +} +static const struct xdg_wm_base_listener wm_listener = {.ping = ping}; + +static void global(void *data, struct wl_registry *registry, uint32_t name, + const char *interface, uint32_t version) { + (void)data; + (void)version; + if (!strcmp(interface, "wl_compositor")) + compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4); + else if (!strcmp(interface, "wl_shm")) + shm = wl_registry_bind(registry, name, &wl_shm_interface, 1); + else if (!strcmp(interface, "xdg_wm_base")) { + wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1); + xdg_wm_base_add_listener(wm, &wm_listener, NULL); + } else if (!strcmp(interface, "ferese_effects_manager_v1")) + effects = wl_registry_bind(registry, name, &ferese_effects_manager_v1_interface, 3); +} +static void removed(void *data, struct wl_registry *registry, uint32_t name) { + (void)data; (void)registry; (void)name; +} +static const struct wl_registry_listener registry_listener = {global, removed}; + +static void configure(void *data, struct xdg_surface *surface, uint32_t serial) { + xdg_surface_ack_configure(surface, serial); + *(int *)data = 1; +} +static const struct xdg_surface_listener surface_listener = {.configure = configure}; + +static void frame_done(void *data, struct wl_callback *callback, uint32_t time) { + (void)time; + *(int *)data = 1; + wl_callback_destroy(callback); +} +static const struct wl_callback_listener frame_listener = {.done = frame_done}; + +static void present(struct wl_display *display, struct wl_surface *surface, + struct wl_buffer *buffer, const char *phase) { + int done = 0; + struct wl_callback *callback = wl_surface_frame(surface); + wl_callback_add_listener(callback, &frame_listener, &done); + wl_surface_attach(surface, buffer, 0, 0); + wl_surface_damage(surface, 0, 0, 320, 240); + wl_surface_commit(surface); + while (!done) + assert(wl_display_dispatch(display) >= 0); + puts(phase); + fflush(stdout); +} + +int main(void) { + struct wl_display *display = wl_display_connect(NULL); + assert(display); + struct wl_registry *registry = wl_display_get_registry(display); + wl_registry_add_listener(registry, ®istry_listener, NULL); + assert(wl_display_roundtrip(display) >= 0); + assert(compositor && shm && wm && effects); + + int fd = memfd_create("blurred-popup", MFD_CLOEXEC); + assert(fd >= 0 && ftruncate(fd, 320 * 240 * 4) == 0); + uint32_t *pixels = mmap(NULL, 320 * 240 * 4, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + assert(pixels != MAP_FAILED); + for (size_t i = 0; i < 320 * 240; i++) + pixels[i] = 0x20101010; + struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, 320 * 240 * 4); + struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0, 320, 240, 320 * 4, WL_SHM_FORMAT_ARGB8888); + wl_shm_pool_destroy(pool); + close(fd); + + int configured = 0; + struct wl_surface *parent = wl_compositor_create_surface(compositor); + struct xdg_surface *parent_xdg = xdg_wm_base_get_xdg_surface(wm, parent); + xdg_surface_add_listener(parent_xdg, &surface_listener, &configured); + struct xdg_toplevel *top = xdg_surface_get_toplevel(parent_xdg); + xdg_toplevel_set_app_id(top, "ferese.test.blurred-popup"); + struct ferese_surface_effects_v1 *parent_effects = ferese_effects_manager_v1_get_surface_effects(effects, parent); + ferese_surface_effects_v1_set_role(parent_effects, FERESE_SURFACE_EFFECTS_V1_ROLE_PANEL); + wl_surface_commit(parent); + while (!configured) + assert(wl_display_dispatch(display) >= 0); + present(display, parent, buffer, "parent"); + + for (int cycle = 0; cycle < 3; cycle++) { + configured = 0; + struct wl_surface *surface = wl_compositor_create_surface(compositor); + struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface); + xdg_surface_add_listener(xdg, &surface_listener, &configured); + struct xdg_positioner *positioner = xdg_wm_base_create_positioner(wm); + xdg_positioner_set_size(positioner, 320, 240); + xdg_positioner_set_anchor_rect(positioner, 20, 20, 10, 10); + struct xdg_popup *popup = xdg_surface_get_popup(xdg, parent_xdg, positioner); + xdg_positioner_destroy(positioner); + struct ferese_surface_effects_v1 *material = ferese_effects_manager_v1_get_surface_effects(effects, surface); + ferese_surface_effects_v1_set_role(material, FERESE_SURFACE_EFFECTS_V1_ROLE_POPOVER); + wl_surface_commit(surface); + while (!configured) + assert(wl_display_dispatch(display) >= 0); + present(display, surface, buffer, "open"); + for (int step = 0; step < 3; step++) { + ferese_surface_effects_v1_set_opacity(material, 400 + step * 200); + pixels[step] = 0x30202020; + present(display, surface, buffer, "update"); + } + ferese_surface_effects_v1_destroy(material); + xdg_popup_destroy(popup); + xdg_surface_destroy(xdg); + wl_surface_destroy(surface); + present(display, parent, buffer, "close"); + } + ferese_surface_effects_v1_destroy(parent_effects); + xdg_toplevel_destroy(top); + xdg_surface_destroy(parent_xdg); + wl_surface_destroy(parent); + wl_buffer_destroy(buffer); + munmap(pixels, 320 * 240 * 4); + wl_display_disconnect(display); + return 0; +} diff --git a/scripts/tests/test_blurred_popup_isolated.py b/scripts/tests/test_blurred_popup_isolated.py new file mode 100644 index 0000000..c92b56a --- /dev/null +++ b/scripts/tests/test_blurred_popup_isolated.py @@ -0,0 +1,85 @@ +"""Open, update and close blurred popups on a private nested compositor. + +FERESE_TEST_BLURRED_POPUP=1 FERESE_TEST_BINARY=target/release/ferese \\ + FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_blurred_popup_isolated.py +Requires a Wayland host, wayland-scanner and a C compiler. +""" +import os +from pathlib import Path +import subprocess +import tempfile +import time +import unittest + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_BLURRED_POPUP") == "1", "opt-in nested blur test") +class BlurredPopupTest(unittest.TestCase): + def test_popup_lifecycle_keeps_presenting_frames(self): + repo = Path(__file__).resolve().parents[2] + with tempfile.TemporaryDirectory(prefix="ferese-blurred-popup-") as temporary: + root = Path(temporary) + protocols = Path(subprocess.check_output( + ["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True + ).strip()) + generated = [] + for name, xml in [ + ("xdg-shell", protocols / "stable/xdg-shell/xdg-shell.xml"), + ("ferese-effects", repo / "protocols/ferese-effects-v1.xml"), + ]: + source = root / f"{name}.c" + subprocess.run(["wayland-scanner", "client-header", str(xml), + str(root / f"{name}-client-protocol.h")], check=True) + subprocess.run(["wayland-scanner", "private-code", str(xml), str(source)], check=True) + generated.append(str(source)) + flags = subprocess.check_output( + ["pkg-config", "--cflags", "--libs", "wayland-client"], text=True + ).split() + client = root / "client" + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-I", str(root), + str(repo / "scripts/tests/fixtures/blurred-popup.c"), + *generated, "-o", str(client), *flags], check=True) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese" + config.mkdir(parents=True) + (config / "config.kdl").write_text('theme { material { style "translucent"; } }\n') + host = Path(os.environ["WAYLAND_DISPLAY"]) + if not host.is_absolute(): + host = Path(os.environ["XDG_RUNTIME_DIR"]) / host + env = dict(os.environ, WAYLAND_DISPLAY=str(host), XDG_RUNTIME_DIR=str(runtime), + XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), + XDG_CACHE_HOME=str(root / "cache"), RUST_LOG="ferese=info") + env.pop("WAYLAND_SOCKET", None) + env.pop("FERESE_SHELL_CONTROL_SOCKET", None) + binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") + log_path = root / "compositor.log" + with log_path.open("w") as log: + compositor = subprocess.Popen( + [str(binary), "--backend", "nested", "--grant-effects", "--", str(client)], + env=env, stdout=log, stderr=log, + ) + try: + deadline = time.monotonic() + 30 + while log_path.read_text().splitlines().count("close") < 3: + self.assertIsNone(compositor.poll(), log_path.read_text()) + if time.monotonic() >= deadline: + self.fail("Blurred popup stopped presenting frames:\n" + log_path.read_text()) + time.sleep(.025) + control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") + subprocess.run([str(control), "get-outputs"], env=env, check=True, + stdout=subprocess.DEVNULL, timeout=5) + phases = log_path.read_text().splitlines() + for name, count in [("parent", 1), ("open", 3), ("update", 9), ("close", 3)]: + self.assertEqual(phases.count(name), count, log_path.read_text()) + finally: + if compositor.poll() is None: + compositor.terminate() + try: + compositor.wait(timeout=5) + except subprocess.TimeoutExpired: + compositor.kill() + compositor.wait() + + +if __name__ == "__main__": + unittest.main() diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index 9d49a15..db2bf90 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -136,7 +136,7 @@ impl FereseShell { .height(bar.control_height) .align_y(alignment::Vertical::Center) .class(theme::Container::custom(move |_| bar_group_style(shell_theme))); - let right = row![ + let mut right = row![ self.view_status_bar().map(move |action| match action { cosmic::Action::App(Message::OpenMenu(kind, anchor)) => cosmic::Action::App(Message::OpenMenuOn(id, kind, anchor)), @@ -146,6 +146,23 @@ impl FereseShell { ] .spacing(8) .align_y(cosmic::iced::Alignment::Center); + if self.display_mode.external_connected() { + right = right.push(motion::button( + button::custom(bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground)) + .name("Display mode") + .height(bar.control_height) + .padding([0, 7]) + .on_press(cosmic::Action::App(Message::OpenDisplays( + self.outputs + .iter() + .find(|output| output.bar == id) + .and_then(|output| output.name.clone()), + ))), + foreground, + self.display_mode.open, + 1.0, + )); + } let available = self .outputs .iter() diff --git a/shell/ferese-shell/src/compositor_ipc.rs b/shell/ferese-shell/src/compositor_ipc.rs index 6360ceb..f835d74 100644 --- a/shell/ferese-shell/src/compositor_ipc.rs +++ b/shell/ferese-shell/src/compositor_ipc.rs @@ -49,7 +49,7 @@ impl SessionConnection { } } -fn request(command: &str, args: Value) -> Result { +pub(super) fn request(command: &str, args: Value) -> Result { SessionConnection::connect()?.request(command, args) } diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 1a1db5a..208c76c 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -75,6 +75,8 @@ pub(crate) enum ControlCommand { Logout(u32, String), CancelLogout(u32), ToggleGuide(String), + ToggleDisplays(String), + MonitorsChanged, } #[derive(Clone)] @@ -116,6 +118,8 @@ enum ControlUpdate { Logout(u32, String), LogoutCancelled(u32), ToggleGuide(String), + ToggleDisplays(String), + MonitorsChanged, } impl ShellControl { @@ -123,7 +127,7 @@ impl ShellControl { let connection = control_connection()?; let (globals, mut queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 4..=5, ())?; + let manager = globals.bind::(&qh, 4..=6, ())?; let shell = manager.get_shell(&qh, ()); let (sender, updates) = mpsc::channel(); let wake = Wake(Arc::new(tokio::sync::Notify::new())); @@ -165,6 +169,8 @@ impl ShellControl { loop { match self.updates.try_recv() { Ok(ControlUpdate::Logout(serial, output)) => poll.commands.push(ControlCommand::Logout(serial, output)), + Ok(ControlUpdate::ToggleDisplays(output)) => poll.commands.push(ControlCommand::ToggleDisplays(output)), + Ok(ControlUpdate::MonitorsChanged) => poll.commands.push(ControlCommand::MonitorsChanged), Ok(ControlUpdate::ToggleGuide(output)) => poll.commands.push(ControlCommand::ToggleGuide(output)), Ok(ControlUpdate::LogoutCancelled(serial)) => poll.commands.push(ControlCommand::CancelLogout(serial)), Ok(ControlUpdate::Config(source)) => poll.config = Some(source), @@ -340,12 +346,21 @@ impl Dispatch for ControlState { fullscreen, }); } + ferese_shell_v1::Event::Capabilities { .. } => { + let _ = state.sender.send(ControlUpdate::MonitorsChanged); + } ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => { let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone())); } ferese_shell_v1::Event::LogoutRequested { serial, output_name } => { let _ = state.sender.send(ControlUpdate::Logout(serial, output_name)); } + ferese_shell_v1::Event::ToggleDisplayMode { output_name } => { + let _ = state.sender.send(ControlUpdate::ToggleDisplays(output_name)); + } + ferese_shell_v1::Event::MonitorStateChanged => { + let _ = state.sender.send(ControlUpdate::MonitorsChanged); + } ferese_shell_v1::Event::ToggleKeybindingGuide { output_name } => { let _ = state.sender.send(ControlUpdate::ToggleGuide(output_name)); } diff --git a/shell/ferese-shell/src/display_mode.rs b/shell/ferese-shell/src/display_mode.rs new file mode 100644 index 0000000..b2a03be --- /dev/null +++ b/shell/ferese-shell/src/display_mode.rs @@ -0,0 +1,599 @@ +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, + profile: Option, + 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 { + 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::parse( + compositor_ipc::call("outputs")?, + compositor_ipc::call("output-profiles")?, + ) +} + +#[derive(Default)] +pub(super) struct Model { + pub open: bool, + pub inventory: Option, + selected: Mode, + pub busy: bool, + error: Option, + generation: u64, + operation: u64, + confirmation_since: Option, +} + +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 { + 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, + ) -> Task { + 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 { + 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 { + 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::() + .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 { + 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 { + 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 { + 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, + ) -> Task { + 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, cosmic::Theme> { + let palette = self.config.theme.palette(); + let material_opacity = if self.config.theme.high_contrast { + 1.0 + } else { + f32::from(self.config.theme.surface_popover[3]) / 255.0 + }; + let model = &self.display_mode; + let pending = model.pending(); + let mut header = row![ + 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( + 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), + text(mode.label()) + .size(13) + .wrapping(cosmic::iced::widget::text::Wrapping::None), + 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( + palette, + model.selected == mode, + material_opacity, + )) + .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), + 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(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(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" }, + shell_font(), + palette, + false + ) + .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" }, + shell_font(), + palette, + true + ) + .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))); + } +} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index ebdd66c..2f13d98 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -9,6 +9,7 @@ use surfaces::*; mod compositor_ipc; mod config; mod control; +mod display_mode; mod keybinding_guide; mod motion; mod note_store; @@ -177,6 +178,7 @@ struct FereseShell { theme_error: Option, menu: Option, system_modal: Option, + display_mode: display_mode::Model, guide_shown: bool, guide_load: keybinding_guide::LoadState, guide_attempts: u8, @@ -262,6 +264,13 @@ enum Message { output: Option, result: Result, String>, }, + OpenDisplays(Option), + DisplaysLoaded(u64, Result), + SelectDisplayMode(display_mode::Mode), + ApplyDisplayMode, + DisplayModeCompleted(u64, bool, Result), + KeepDisplayMode, + RevertDisplayMode, SystemInhibitors(window::Id, Result), ConfirmPower(status::Action), CancelPower, @@ -303,6 +312,7 @@ impl cosmic::Application for FereseShell { theme_error: None, menu: None, system_modal: None, + display_mode: display_mode::Model::default(), guide_shown: false, guide_load: Default::default(), guide_attempts: 0, @@ -358,7 +368,11 @@ impl cosmic::Application for FereseShell { self.notifications .tick_subscription() .map(|_| Message::NotificationTick), - event::listen_with(|event, _status, id| match &event { + event::listen_with(|event, status, id| match &event { + Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { + key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter), + .. + }) if status == cosmic::iced::event::Status::Captured => None, Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING .load(Ordering::Relaxed) .then_some(Message::Event(event, id)), @@ -691,6 +705,13 @@ impl cosmic::Application for FereseShell { } Task::none() } + Message::OpenDisplays(output) => self.open_display_modal(output.as_deref()), + Message::DisplaysLoaded(generation, result) => self.finish_display_inventory(generation, result), + Message::SelectDisplayMode(mode) => self.select_display_mode(mode, true), + Message::ApplyDisplayMode => self.apply_display_mode(), + Message::DisplayModeCompleted(serial, close, result) => self.finish_display_mode(serial, close, result), + Message::KeepDisplayMode => self.confirm_display_mode(true), + Message::RevertDisplayMode => self.confirm_display_mode(false), Message::ExecutePower => self.execute_system_modal(), Message::AnimatePower => self.animate_system_modal(), Message::PowerCompleted(id, result) => self.finish_power_action(id, result), @@ -812,6 +833,8 @@ impl cosmic::Application for FereseShell { } for command in poll.commands { let task = match command { + control::ControlCommand::ToggleDisplays(output) => self.open_display_modal(Some(&output)), + control::ControlCommand::MonitorsChanged => self.load_display_inventory(), control::ControlCommand::ToggleGuide(output) => self.toggle_guide(output), control::ControlCommand::CancelLogout(serial) => self.cancel_logout_modal(serial), control::ControlCommand::Logout(serial, output) => { diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index 2f58bc3..31fcfc4 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -217,6 +217,40 @@ impl FereseShell { _ => {} } } + if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { + key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter), + .. + }) = &event + && self + .system_modal + .as_ref() + .is_some_and(|modal| modal.is_display_mode() && modal.contains(id)) + { + return if self.display_mode.pending() { + self.confirm_display_mode(true) + } else { + self.apply_display_mode() + }; + } + + if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key, .. }) = &event + && self + .system_modal + .as_ref() + .is_some_and(|modal| modal.is_display_mode() && modal.contains(id)) + && matches!( + key, + cosmic::iced::keyboard::Key::Character(_) + | cosmic::iced::keyboard::Key::Named( + cosmic::iced::keyboard::key::Named::ArrowLeft + | cosmic::iced::keyboard::key::Named::ArrowRight + | cosmic::iced::keyboard::key::Named::ArrowUp + | cosmic::iced::keyboard::key::Named::ArrowDown + ) + ) + { + return self.display_mode_key(key); + } if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Tab), modifiers, diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index 7a36582..63d3543 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -59,6 +59,7 @@ struct ModalSurface { enum Content { Power(PowerAction), Guide(Vec), + Displays, } pub(super) struct SystemModal { @@ -75,6 +76,10 @@ impl SystemModal { matches!(self.content, Content::Guide(_)) } + pub(super) fn is_display_mode(&self) -> bool { + matches!(self.content, Content::Displays) + } + pub(super) fn contains(&self, id: window::Id) -> bool { self.surfaces.iter().any(|surface| surface.id == id) } @@ -85,6 +90,20 @@ impl FereseShell { self.open_modal(Content::Power(action), output_name) } + pub(super) fn open_display_modal(&mut self, output: Option<&str>) -> Task { + if self + .system_modal + .as_ref() + .is_some_and(|modal| modal.is_display_mode() && !modal.motion.closing()) + { + return self.close_system_modal(); + } + self.display_mode.begin(); + let open = self.open_modal(Content::Displays, output); + let load = self.load_display_inventory(); + Task::batch([open, load]) + } + pub(super) fn rebuild_system_modal(&mut self) -> Task { let guide = self.system_modal.as_ref().and_then(|modal| match &modal.content { Content::Guide(entries) @@ -95,6 +114,10 @@ impl FereseShell { _ => None, }); let destroy = self.destroy_system_modal(true); + if self.display_mode.open { + let open = self.open_modal(Content::Displays, None); + return Task::batch([destroy, open]); + } if let Some(entries) = guide { if self.outputs.is_empty() { self.guide_shown = false; @@ -136,6 +159,9 @@ impl FereseShell { { return Task::none(); } + if content != Content::Displays { + self.display_mode.open = false; + } let primary = output_name .and_then(|name| { self.outputs @@ -187,7 +213,7 @@ impl FereseShell { Content::Power(PowerAction::Suspend) => Some(compositor_ipc::Mode::Suspend), Content::Power(PowerAction::Logout(_)) => Some(compositor_ipc::Mode::Logout), Content::Power(_) => Some(compositor_ipc::Mode::Shutdown), - Content::Guide(_) => None, + Content::Guide(_) | Content::Displays => None, }; if let Some(mode) = mode { let id = surfaces[0].id; @@ -276,6 +302,16 @@ impl FereseShell { } pub(super) fn close_system_modal(&mut self) -> Task { + if self.system_modal.as_ref().is_some_and(SystemModal::is_display_mode) { + if self.display_mode.busy { + return Task::none(); + } + if self.display_mode.pending() { + return self.confirm_display_mode(false); + } + self.display_mode.open = false; + } + if let Some(modal) = &mut self.system_modal { if let Content::Power(PowerAction::Logout(serial)) = modal.content && let Some(control) = &self.control @@ -341,7 +377,9 @@ impl FereseShell { pub(super) fn focus_system_modal(&self, id: window::Id) -> Task { if self.system_modal.as_ref().is_some_and(|modal| { - !modal.motion.closing() && modal.surfaces.iter().any(|surface| surface.id == id && surface.primary) + !modal.is_display_mode() + && !modal.motion.closing() + && modal.surfaces.iter().any(|surface| surface.id == id && surface.primary) }) { button::focus("ferese-modal-cancel".into()) } else { @@ -487,6 +525,7 @@ impl FereseShell { .id("ferese-system-modal-controls"), ) } + Content::Displays => column![container(self.display_mode_rows()).id("ferese-system-modal-controls")], Content::Guide(entries) => { let mut bindings = column::with_capacity(entries.len()).spacing(12); for entry in entries { @@ -529,7 +568,11 @@ impl FereseShell { container(rows) .id("ferese-blur-card") .width(Length::Fill) - .max_width(if modal.is_guide() { 600 } else { 360 }) + .max_width(if modal.is_display_mode() || modal.is_guide() { + 600 + } else { + 360 + }) .padding(if modal.is_guide() { 24 } else { 18 }) .class(theme::Container::custom(move |_| container::Style { background: (!material) @@ -608,9 +651,19 @@ mod tests { } let mut outside = Focus(true); - let mut cancel = Focus(true); + let mut cancel = Focus(false); let mut confirm = Focus(false); - for (backwards, expected_cancel) in [(false, false), (false, true), (true, false), (true, true)] { + assert!( + !cancel.0 && !confirm.0, + "opening the chooser leaves its buttons unfocused" + ); + for (backwards, expected_cancel) in [ + (false, true), + (false, false), + (false, true), + (true, false), + (true, true), + ] { let mut current: Box> = modal_focus_operation(backwards); loop { current.focusable(Some(&Id::new("bar")), Rectangle::default(), &mut outside);