Fix modal keyboard navigation and title switches; refresh documentation
This commit is contained in:
1 parent
1b665f4157
commit
67d2d95a01
9 files changed
+312
-192
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user