From 1d7a4728473db9480b2f8ebc0164b226a487e329 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 3 Oct 2026 04:29:06 +0100 Subject: [PATCH] make button focus ring conditional on tab press --- crates/ferese-theme/src/controls.rs | 72 ++++++++++++++++++++++++-- shell/ferese-shell/src/display_mode.rs | 14 ++++- shell/ferese-shell/src/main.rs | 1 + shell/ferese-shell/src/surfaces.rs | 7 +++ shell/ferese-shell/src/system_modal.rs | 41 ++++++++++++--- 5 files changed, 121 insertions(+), 14 deletions(-) diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs index a09f040..a08e581 100644 --- a/crates/ferese-theme/src/controls.rs +++ b/crates/ferese-theme/src/controls.rs @@ -9,11 +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, 1.0) + button_style_with_focus(p, selected, true) +} + +pub fn button_style_with_focus(p: Palette, selected: bool, focus_visible: bool) -> theme::Button { + styled_button(p, selected, false, 1.0, focus_visible) } /// 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 { + material_button_style_with_focus(p, selected, material_opacity, true) +} + +pub fn material_button_style_with_focus( + p: Palette, + selected: bool, + material_opacity: f32, + focus_visible: bool, +) -> theme::Button { let opacity = if material_opacity.is_finite() { material_opacity.clamp(0.0, 1.0) } else { @@ -26,7 +39,7 @@ pub fn material_button_style(p: Palette, selected: bool, material_opacity: f32) 1.0 }; - styled_button(p, selected, false, fill) + styled_button(p, selected, false, fill, focus_visible) } pub fn text_button<'a, M: Clone + 'a>( @@ -82,7 +95,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, 1.0) + styled_button(p, selected, true, 1.0, true) } pub fn settings_input(p: Palette) -> theme::TextInput { @@ -128,7 +141,7 @@ pub fn select<'a, M: 'a>( })) } -fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32) -> theme::Button { +fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32, focus_visible: bool) -> 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 }) @@ -169,7 +182,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32) -> } else { Color::TRANSPARENT }, - outline_width: if focused { 2. } else { 0. }, + outline_width: if focused && focus_visible { 2. } else { 0. }, outline_color: p.accent, overlay: None, shadow_offset: Vector::ZERO, @@ -350,6 +363,55 @@ pub fn filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32) mod tests { use super::*; + #[test] + fn hiding_modal_focus_preserves_button_borders_and_materials() { + use cosmic::widget::button::Catalog; + + let palette = Palette::from_resolved(&ferese_config::theme::default_theme()); + let theme = palette.native_theme(); + + for selected in [false, true] { + for opacity in [0.65, 1.0] { + let hidden = material_button_style_with_focus(palette, selected, opacity, false); + let visible = material_button_style_with_focus(palette, selected, opacity, true); + + for (hidden, visible, resting) in [ + ( + theme.active(true, selected, &hidden), + theme.active(true, selected, &visible), + theme.active(false, selected, &visible), + ), + ( + theme.hovered(true, selected, &hidden), + theme.hovered(true, selected, &visible), + theme.hovered(false, selected, &visible), + ), + ( + theme.pressed(true, selected, &hidden), + theme.pressed(true, selected, &visible), + theme.pressed(false, selected, &visible), + ), + ] { + assert_eq!(hidden.outline_width, 0.0); + assert_eq!(visible.outline_width, 2.0); + assert_eq!(resting.outline_width, 0.0); + assert_eq!(hidden.border_width, 1.0); + assert_eq!(hidden.border_width, visible.border_width); + assert_eq!(hidden.border_color, visible.border_color); + assert_eq!(hidden.border_radius, visible.border_radius); + assert_eq!(hidden.background, visible.background); + assert_eq!(hidden.text_color, visible.text_color); + } + } + + let hidden = button_style_with_focus(palette, selected, false); + let visible = button_style_with_focus(palette, selected, true); + assert_eq!(theme.active(true, selected, &hidden).outline_width, 0.0); + assert_eq!(theme.active(true, selected, &visible).outline_width, 2.0); + assert_eq!(theme.active(true, selected, &hidden).border_width, 1.0); + } + } + #[test] fn authentication_button_does_not_reverse_enabled_and_disabled_text() { use cosmic::widget::button::Catalog; diff --git a/shell/ferese-shell/src/display_mode.rs b/shell/ferese-shell/src/display_mode.rs index b2a03be..e6db908 100644 --- a/shell/ferese-shell/src/display_mode.rs +++ b/shell/ferese-shell/src/display_mode.rs @@ -391,6 +391,7 @@ impl FereseShell { f32::from(self.config.theme.surface_popover[3]) / 255.0 }; let model = &self.display_mode; + let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible); let pending = model.pending(); let mut header = row![ text("Display mode").size(17), @@ -435,10 +436,11 @@ impl FereseShell { .id(format!("ferese-display-mode-{}", mode.name()).into()) .width(Length::Fill) .padding([14, 6]) - .class(ferese_theme::controls::material_button_style( + .class(ferese_theme::controls::material_button_style_with_focus( palette, model.selected == mode, material_opacity, + focus_visible, )) .on_press_maybe(available.then_some(cosmic::Action::App(Message::SelectDisplayMode(mode)))), ); @@ -504,6 +506,11 @@ impl FereseShell { palette, false ) + .class(ferese_theme::controls::button_style_with_focus( + palette, + false, + focus_visible + )) .id("ferese-modal-cancel".into()) .on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))), ferese_theme::controls::text_button( @@ -512,6 +519,11 @@ impl FereseShell { palette, true ) + .class(ferese_theme::controls::button_style_with_focus( + palette, + true, + focus_visible + )) .id("ferese-display-apply".into()) .on_press_maybe(can_apply.then_some(cosmic::Action::App(apply))), ] diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 9a9ccea..35b2fea 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -368,6 +368,7 @@ impl cosmic::Application for FereseShell { .load(Ordering::Relaxed) .then_some(Message::Event(event, id)), Event::Keyboard(_) + | Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) | Event::Window(window::Event::Opened { .. } | window::Event::Closed) | Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..), diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index 003ec54..fca08e7 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -164,6 +164,13 @@ impl FereseShell { } pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task { + if let Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) = &event + && let Some(modal) = &mut self.system_modal + && modal.contains(id) + { + modal.focus_visible = false; + } + if let Event::Mouse(mouse) = &event { if !self .outputs diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index ba05f6c..c32ba6f 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -69,6 +69,7 @@ pub(super) struct SystemModal { error: Option, inhibitors: Option, keyboard_nav: bool, + pub(super) focus_visible: bool, } impl SystemModal { @@ -236,6 +237,7 @@ impl FereseShell { error: None, inhibitors: mode.is_none().then(compositor_ipc::Approval::default), keyboard_nav, + focus_visible: false, }); Task::batch(tasks) } @@ -387,10 +389,17 @@ impl FereseShell { } } - pub(super) fn navigate_system_modal(&self, backwards: bool) -> Task { - if self.system_modal.as_ref().is_none_or(|modal| modal.motion.closing()) { + pub(super) fn navigate_system_modal(&mut self, backwards: bool) -> Task { + let Some(modal) = &mut self.system_modal else { + return Task::none(); + }; + + if modal.motion.closing() { return Task::none(); } + + modal.focus_visible = true; + cosmic::iced::advanced::widget::operate(modal_focus_operation(backwards)) } @@ -536,14 +545,25 @@ impl FereseShell { row![ Space::new().width(Length::Fill), ferese_theme::controls::text_button("Cancel", shell_font(), palette, false) + .class(ferese_theme::controls::button_style_with_focus( + palette, + false, + modal.focus_visible, + )) .id("ferese-modal-cancel".into()) .on_press(cosmic::Action::App(Message::CancelPower)), - ferese_theme::controls::text_button(label, shell_font(), palette, true).on_press_maybe( - modal - .inhibitors - .is_some() - .then_some(cosmic::Action::App(Message::ExecutePower)) - ), + ferese_theme::controls::text_button(label, shell_font(), palette, true) + .class(ferese_theme::controls::button_style_with_focus( + palette, + true, + modal.focus_visible, + )) + .on_press_maybe( + modal + .inhibitors + .is_some() + .then_some(cosmic::Action::App(Message::ExecutePower)) + ), ] .spacing(10), ) @@ -582,6 +602,11 @@ impl FereseShell { container(row![ Space::new().width(Length::Fill), ferese_theme::controls::text_button("Got it", shell_font(), palette, true,) + .class(ferese_theme::controls::button_style_with_focus( + palette, + true, + modal.focus_visible, + )) .id("ferese-modal-cancel".into()) .on_press(cosmic::Action::App(Message::CancelPower)), ])