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);