Fix modal keyboard navigation and title switches; refresh documentation

This commit is contained in:
Samuel Onoja committed 2026-10-01 07:38:43 +01:00
1 parent 1b665f4157
commit 67d2d95a01
9 files changed
+312 -192

No files matched your search

+15 -2
View File
@@ -1794,6 +1794,16 @@ impl FereseShell {
_ => {}
}
}
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Tab),
modifiers,
..
}) = &event
&& !modifiers.control()
&& self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
{
return self.navigate_system_modal(modifiers.shift());
}
if matches!(
&event,
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
@@ -1841,8 +1851,11 @@ impl FereseShell {
}
}
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
Task::batch([
self.focus_system_modal(id),
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))),
])
}
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
+26 -12
View File
@@ -435,6 +435,22 @@ impl FereseShell {
p,
));
}
if kind == Menu::Network
&& let Some(network) = &self.status.network
{
heading = heading.push(shell_switch(
"Wi-Fi",
network.enabled,
Action::Wifi(!network.enabled),
theme,
p,
));
}
if kind == Menu::Audio
&& let Some(audio) = &self.status.audio
{
heading = heading.push(shell_switch("Mute", audio.muted, Action::Mute(!audio.muted), theme, p));
}
let mut rows = column::with_capacity(12).spacing(12).width(Length::Fill);
if kind != Menu::Calendar {
rows = rows.push(heading);
@@ -546,7 +562,8 @@ impl FereseShell {
muted,
color_with_opacity(theme.accent, p),
p,
Some((n.enabled, Action::Wifi(!n.enabled))),
n.enabled,
None,
));
if connected {
let strength = match n.signal {
@@ -589,6 +606,7 @@ impl FereseShell {
muted,
color_with_opacity(theme.accent, p),
p,
b.enabled,
Some((b.enabled, Action::Bluetooth(!b.enabled))),
));
@@ -620,17 +638,13 @@ impl FereseShell {
if (combined || menu.kind == Menu::Audio)
&& let Some(a) = &self.status.audio
{
let mut volume_heading = row![text("Volume").size(13).width(Length::Fill)].align_y(Alignment::Center);
if combined {
volume_heading =
volume_heading.push(shell_switch("Mute", a.muted, Action::Mute(!a.muted), theme, p));
}
let audio = column![
row![
text("Volume").size(13).width(Length::Fill),
menu_button(
if a.muted { "Unmute" } else { "Mute" },
Message::Control(Action::Mute(!a.muted)),
primary,
p
)
]
.align_y(Alignment::Center),
volume_heading,
text(&a.output).size(12).class(theme::Text::Color(muted)),
slider_row(
audio_icon(a.volume, a.muted),
@@ -856,9 +870,9 @@ fn status_summary<'a>(
muted: Color,
accent: Color,
opacity: f32,
enabled: bool,
toggle: Option<(bool, Action)>,
) -> Element<'a, cosmic::Action<Message>> {
let enabled = toggle.as_ref().is_some_and(|(enabled, _)| *enabled);
let badge_color = if enabled { accent } else { primary };
let badge = container(accented_icon(source, 22, primary, accent))
.width(32)
+97 -14
View File
@@ -67,6 +67,7 @@ pub(super) struct SystemModal {
pub(super) motion: motion::PopupMotion,
error: Option<String>,
inhibitors: Option<compositor_ipc::Approval>,
keyboard_nav: bool,
}
impl SystemModal {
@@ -190,12 +191,15 @@ impl FereseShell {
move |result| cosmic::Action::App(Message::SystemInhibitors(id, result)),
));
}
let keyboard_nav = self.core.keyboard_nav();
self.core.set_keyboard_nav(false);
self.system_modal = Some(SystemModal {
content,
surfaces,
motion: motion::PopupMotion::new(self.config.animations),
error: None,
inhibitors: mode.is_none().then(compositor_ipc::Approval::default),
keyboard_nav,
});
Task::batch(tasks)
}
@@ -293,6 +297,7 @@ impl FereseShell {
let Some(modal) = self.system_modal.take() else {
return Task::none();
};
self.core.set_keyboard_nav(modal.keyboard_nav);
if cancel
&& let Content::Power(PowerAction::Logout(serial)) = modal.content
&& let Some(control) = &self.control
@@ -322,6 +327,23 @@ impl FereseShell {
}
}
pub(super) fn focus_system_modal(&self, id: window::Id) -> Task<Message> {
if self.system_modal.as_ref().is_some_and(|modal| {
!modal.motion.closing() && modal.surfaces.iter().any(|surface| surface.id == id && surface.primary)
}) {
button::focus("ferese-modal-cancel".into())
} else {
Task::none()
}
}
pub(super) fn navigate_system_modal(&self, backwards: bool) -> Task<Message> {
if self.system_modal.as_ref().is_none_or(|modal| modal.motion.closing()) {
return Task::none();
}
cosmic::iced::advanced::widget::operate(modal_focus_operation(backwards))
}
pub(super) fn execute_system_modal(&mut self) -> Task<Message> {
let Some(modal) = &mut self.system_modal else {
return Task::none();
@@ -440,18 +462,22 @@ impl FereseShell {
rows = rows.push(text(error).size(13));
}
rows.push(
row![
Space::new().width(Length::Fill),
ferese_theme::controls::text_button("Cancel", shell_font(), palette, false)
.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))
),
]
.spacing(10),
container(
row![
Space::new().width(Length::Fill),
ferese_theme::controls::text_button("Cancel", shell_font(), palette, false)
.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))
),
]
.spacing(10),
)
.id("ferese-system-modal-controls"),
)
}
Content::Guide(entries) => {
@@ -482,11 +508,13 @@ impl FereseShell {
text("Disable this guide in Settings → Shortcuts. It appears at each login until disabled.")
.size(12)
.class(theme::Text::Color(palette.muted)),
row![
container(row![
Space::new().width(Length::Fill),
ferese_theme::controls::text_button("Got it", shell_font(), palette, true,)
.id("ferese-modal-cancel".into())
.on_press(cosmic::Action::App(Message::CancelPower)),
],
])
.id("ferese-system-modal-controls"),
]
.spacing(14)
}
@@ -537,10 +565,65 @@ impl FereseShell {
}
}
// Restrict Tab traversal to the modal actions, excluding the bar and desktop widgets.
fn modal_focus_operation<T: Send + 'static>(backwards: bool) -> Box<dyn cosmic::iced::advanced::widget::Operation<T>> {
use cosmic::iced::advanced::widget::{Id, operation};
let target = Id::new("ferese-system-modal-controls");
if backwards {
Box::new(operation::scoped(target, operation::focusable::focus_previous()))
} else {
Box::new(operation::scoped(target, operation::focusable::focus_next()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_stays_within_modal_actions_in_both_directions() {
use cosmic::iced::Rectangle;
use cosmic::iced::advanced::widget::{Id, Operation, operation};
#[derive(Default)]
struct Focus(bool);
impl operation::Focusable for Focus {
fn is_focused(&self) -> bool {
self.0
}
fn focus(&mut self) {
self.0 = true;
}
fn unfocus(&mut self) {
self.0 = false;
}
}
let mut outside = Focus(true);
let mut cancel = Focus(true);
let mut confirm = Focus(false);
for (backwards, expected_cancel) in [(false, false), (false, true), (true, false), (true, true)] {
let mut current: Box<dyn Operation<()>> = modal_focus_operation(backwards);
loop {
current.focusable(Some(&Id::new("bar")), Rectangle::default(), &mut outside);
current.container(Some(&Id::new("ferese-system-modal-controls")), Rectangle::default());
current.traverse(&mut |operation| {
operation.focusable(Some(&Id::new("cancel")), Rectangle::default(), &mut cancel);
operation.focusable(Some(&Id::new("confirm")), Rectangle::default(), &mut confirm);
});
match current.finish() {
operation::Outcome::Chain(next) => current = next,
_ => break,
}
}
assert!(outside.0, "modal navigation changed focus outside the modal");
assert_ne!(cancel.0, confirm.0);
assert_eq!(cancel.0, expected_cancel);
}
assert!(cancel.0);
assert!(!confirm.0);
}
#[test]
fn only_power_actions_require_confirmation() {
assert_eq!(