feat: added MB4/MB5 and Esc navigation globally (#742)

This commit is contained in:
Maciek 2026-09-04 17:26:18 +02:00 • committed by LynithDev
parent b2c2289853
commit 12783fe59a
20 changed files with 229 additions and 29 deletions

View file

@ -327,7 +327,7 @@ impl Component for Button {
})
.cursor(cursor_icon)
.map(on_press.clone(), |rect, handler| {
rect.on_all_press(move |event: Event<PressEventData>| {
rect.on_press(move |event: Event<PressEventData>| {
handler.call(event);
})
});

View file

@ -1,6 +1,7 @@
use freya::prelude::*;
use crate::components::{Icon, IconType, ScrollArea};
use crate::hooks::use_overlay_claim_when;
use crate::theme::colors;
use crate::ui::border_all_color;
@ -70,6 +71,8 @@ impl Component for Dropdown {
let on_select = self.on_select.clone();
let is_open = open();
use_overlay_claim_when(is_open);
if !is_open && list_size().is_some() {
let _ = list_size.take();
}
@ -108,6 +111,11 @@ impl Component for Dropdown {
rect()
.width(self.width.clone())
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape) {
open.set_if_modified(false);
}
})
.child(
rect()
.width(Size::fill())
@ -130,7 +138,7 @@ impl Component for Dropdown {
.on_sized(move |e: Event<SizedEventData>| {
button_area.set_if_modified(Some(e.area));
})
.on_all_press(move |e: Event<PressEventData>| {
.on_press(move |e: Event<PressEventData>| {
e.prevent_default();
e.stop_propagation();
open.toggle();
@ -237,7 +245,7 @@ impl Component for DropdownOption {
})
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_all_press(self.on_press.clone())
.on_press(self.on_press.clone())
.child(
label()
.text(self.text.clone())

View file

@ -189,7 +189,7 @@ impl Component for NavLink {
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_all_press(move |e: Event<PressEventData>| {
.on_press(move |e: Event<PressEventData>| {
e.prevent_default();
match &target {
NavTarget::Route(route) => {

View file

@ -3,6 +3,8 @@ use freya::{
prelude::*,
};
use crate::hooks::use_overlay_claim;
const SCRIM_ALPHA: f32 = 90.;
#[derive(PartialEq)]
@ -61,6 +63,8 @@ impl KeyExt for OverlayPopup {
impl Component for OverlayPopup {
fn render(&self) -> impl IntoElement {
use_overlay_claim();
let a11y_id = use_a11y();
let scrim_close = self.on_close.clone();
let key_close = self.on_close.clone();

View file

@ -173,7 +173,7 @@ impl Component for ClusterCard {
.y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 0)),
)
.on_all_press(on_press)
.on_press(on_press)
.child(
rect()
.width(Size::fill())
@ -281,7 +281,7 @@ impl Component for OtherVersionsTile {
.y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 0)),
)
.on_all_press(|_| {
.on_press(|_| {
let _ = RouterContext::get().push(Route::Clusters {});
})
.child(

View file

@ -185,7 +185,7 @@ impl<T: Copy + PartialEq + 'static> Component for SegmentButton<T> {
})
.maybe(!disabled, |el| {
el.cursor(CursorIcon::Pointer)
.on_all_press(move |_| *selected.write() = value)
.on_press(move |_| *selected.write() = value)
})
.maybe_child(self.icon.map(|icon| {
let mut ic = Icon::new(icon);

View file

@ -187,7 +187,7 @@ impl Component for TabButton {
.cursor(CursorIcon::Pointer);
if let Some(handler) = self.on_press.clone() {
el = el.on_all_press(move |e| handler.call(e));
el = el.on_press(move |e| handler.call(e));
}
el.child(

View file

@ -106,7 +106,7 @@ impl Component for Switch {
.a11y_focusable(!disabled)
.a11y_role(AccessibilityRole::Button)
.maybe(!disabled, |el| {
el.cursor(CursorIcon::Pointer).on_all_press(move |_| {
el.cursor(CursorIcon::Pointer).on_press(move |_| {
on_press.call(());
})
})

View file

@ -79,7 +79,7 @@ impl Component for VersionCard {
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_all_press(move |e| on_press.call(e))
.on_press(move |e| on_press.call(e))
.on_pointer_enter(move |_| {
*hovering.write() = true;
})

View file

@ -1,10 +1,15 @@
mod active_cluster;
mod debounce;
mod actions;
mod overlay_claims;
mod queries;
mod view_state;
pub use debounce::use_debounced;
pub use overlay_claims::{
OverlayClaims, use_overlay_claim, use_overlay_claim_when, use_overlay_claims,
use_provide_overlay_claims,
};
pub use view_state::{PersistedView, use_view_state};
pub use active_cluster::{

View file

@ -0,0 +1,64 @@
use std::cell::Cell;
use std::rc::Rc;
use freya::prelude::*;
#[derive(Clone, Default)]
pub struct OverlayClaims(Rc<Cell<usize>>);
impl OverlayClaims {
/// Whether anything on screen currently owns Escape and mouse-back.
pub fn any(&self) -> bool {
self.0.get() > 0
}
fn acquire(&self) {
self.0.set(self.0.get() + 1);
}
fn release(&self) {
let held = self.0.get();
debug_assert!(held > 0, "overlay claim released without a matching acquire");
self.0.set(held.saturating_sub(1));
}
}
pub fn use_provide_overlay_claims() {
use_provide_root_context(OverlayClaims::default);
}
/// Falls back to a detached counter outside `RootLayout` so a claim raised there
/// degrades to "nothing claimed" instead of taking the app down.
pub fn use_overlay_claims() -> OverlayClaims {
try_consume_root_context::<OverlayClaims>().unwrap_or_default()
}
pub fn use_overlay_claim() {
let claims = use_hook(use_overlay_claims);
let released = claims.clone();
use_hook(move || claims.acquire());
use_drop(move || released.release());
}
pub fn use_overlay_claim_when(active: bool) {
let claims = use_hook(use_overlay_claims);
let held = use_hook(|| Rc::new(Cell::new(false)));
if held.get() != active {
held.set(active);
if active {
claims.acquire();
} else {
claims.release();
}
}
let released = claims;
let held_at_drop = held;
use_drop(move || {
if held_at_drop.get() {
released.release();
}
});
}

View file

@ -2,8 +2,9 @@ use freya::animation::*;
use freya::prelude::*;
use freya::router::*;
use super::app_shell::{appshell_overlay, back_button, hides_overlay};
use super::app_shell::{appshell_overlay, back_button, hides_overlay, navigate_back};
use crate::Route;
use crate::hooks::use_overlay_claims;
use crate::theme;
use crate::ui::entrance_motion_layer;
@ -46,6 +47,41 @@ fn is_cluster_route(route: &Route) -> bool {
)
}
/// Checks if it should go back to home or somewhere else
fn escape_exits_section(route: &Route) -> bool {
is_cluster_route(route) || is_sidebar_route(route)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum EscapeAction {
/// The root screen has nothing above it so Escape must not walk history
Ignore,
ExitSection,
Back,
}
fn escape_action(route: &Route) -> EscapeAction {
if matches!(route, Route::Home {}) {
EscapeAction::Ignore
} else if escape_exits_section(route) {
EscapeAction::ExitSection
} else {
EscapeAction::Back
}
}
/// Escape belongs to whatever the user is typing in before it belongs to the
/// router so a search field does not throw the page away mid-word
fn is_text_entry(role: AccessibilityRole) -> bool {
matches!(
role,
AccessibilityRole::TextInput
| AccessibilityRole::MultilineTextInput
| AccessibilityRole::SearchInput
| AccessibilityRole::PasswordInput
)
}
fn enter_kind(from: &Route, to: &Route) -> Enter {
if (is_sidebar_route(from) && is_sidebar_route(to))
|| (is_cluster_route(from) && is_cluster_route(to))
@ -66,6 +102,9 @@ impl Component for AnimatedAppOutlet {
let mut router = use_animated_router::<Route>();
let route = use_route::<Route>();
let at_home = matches!(&route, Route::Home {});
let escape = escape_action(&route);
let mut origin_route = use_state(|| route.clone());
let mut last_route = use_state(|| route.clone());
@ -79,6 +118,48 @@ impl Component for AnimatedAppOutlet {
let back_title = origin_route.read().title();
let platform = Platform::get();
let focused_node = platform.focused_accessibility_node;
let focused_id = platform.focused_accessibility_id;
let key_claims = use_overlay_claims();
let pointer_claims = key_claims.clone();
let on_global_key = move |e: Event<KeyboardEventData>| {
if e.key != Key::Named(NamedKey::Escape) || key_claims.any() {
return;
}
if is_text_entry(focused_node.peek().role()) {
focused_id.peek().request_unfocus();
return;
}
match escape {
EscapeAction::Ignore => {}
EscapeAction::ExitSection => {
let _ = RouterContext::get().push(Route::Home {});
}
EscapeAction::Back => navigate_back(at_home),
}
};
let on_global_pointer = move |e: Event<PointerEventData>| {
if pointer_claims.any() {
return;
}
match e.button() {
Some(MouseButton::Back) => navigate_back(at_home),
Some(MouseButton::Forward) => {
let router = RouterContext::get();
if router.can_go_forward() {
router.go_forward();
}
}
_ => {}
}
};
let anim = use_animation(|_conf| {
AnimNum::new(0., 1.)
.time(430)
@ -170,7 +251,28 @@ impl Component for AnimatedAppOutlet {
.width(Size::fill())
.height(Size::fill())
.overflow(Overflow::Clip)
.on_global_key_down(on_global_key)
.on_global_pointer_press(on_global_pointer)
.maybe_child(overlay)
.child(column)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_action_per_route() {
assert_eq!(escape_action(&Route::Home {}), EscapeAction::Ignore);
assert_eq!(
escape_action(&Route::ClusterSettings { cluster_id: 1 }),
EscapeAction::ExitSection
);
assert_eq!(
escape_action(&Route::SettingsJava {}),
EscapeAction::ExitSection
);
assert_eq!(escape_action(&Route::Clusters {}), EscapeAction::Back);
}
}

View file

@ -600,12 +600,22 @@ pub(crate) fn gradient_overlay_radial() -> impl IntoElement {
))
}
pub(crate) fn navigate_back(at_home: bool) {
let router = RouterContext::get();
if router.can_go_back() {
router.go_back();
} else if !at_home {
let _ = router.push(Route::Home {});
}
}
pub(crate) fn back_button(destination: &str) -> impl IntoElement {
Button::new()
.ghost()
.small()
.on_press(|_| {
RouterContext::get().go_back();
// Only rendered off home so the history-empty fallback is reachable
navigate_back(false);
})
.margin(Gaps::new(0., 0., 0., 32.))
.child(Icon::new(IconType::ArrowLeft).size(12.))

View file

@ -6,7 +6,7 @@ use crate::components::{
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts,
UpdatePromptOverlay,
};
use crate::hooks::{SplashState, use_provide_splash};
use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash};
use crate::layout::HomeArtPrefetch;
use crate::motion::AnimationClockDriver;
use crate::routes::Route;
@ -21,6 +21,7 @@ impl Component for RootLayout {
let active = use_state(|| false);
let home_ready = use_state(|| false);
use_provide_splash(SplashState { active, home_ready });
use_provide_overlay_claims();
// macOS rounds the window natively so Freya must not round on top of it
#[cfg(target_os = "macos")]

View file

@ -797,7 +797,7 @@ impl Component for SidebarItem {
.on_pointer_enter(move |_| *hovering.write() = true)
.on_pointer_leave(move |_| *hovering.write() = false)
.map(route, |el, route| {
el.on_all_press(move |_| {
el.on_press(move |_| {
let _ = RouterContext::get().replace(route.clone());
})
});

View file

@ -125,14 +125,13 @@ impl Component for PackageCard {
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.cursor(CursorIcon::Pointer)
.on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.on_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.child(
rect()
.margin(Gaps::new_all(1.))
.corner_radius(CornerRadius::new(10., 10., 0., 0.))
.overflow(Overflow::Clip)
.child(PackageBanner::new(icon_url, BANNER_H))
// Top-left of the banner the first thing worth knowing about a result and the opposite corner is the install button's
.maybe_child(self.installed.map(|installed| {
rect()
.position(Position::new_absolute().top(8.).left(8.))
@ -258,7 +257,7 @@ impl Component for ListRow {
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.cursor(CursorIcon::Pointer)
.on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.on_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.))
.child(
rect()
@ -303,7 +302,6 @@ impl Component for ListRow {
),
)
.child(downloads_row(item.downloads))
// Kept once installed rather than dropped a row that loses its button leaves its neighbours misaligned
.child(InstallButton::new(
&self.item,
self.cluster_id,
@ -313,7 +311,6 @@ impl Component for ListRow {
}
}
/// Same version pick and dispatch as the install button on the package page
#[derive(PartialEq)]
struct InstallButton {
provider: ProviderId,
@ -372,7 +369,7 @@ impl Component for InstallButton {
rect()
// The card behind is one big press target stop here so the package page doesn't open on top of the install
.on_all_press(|e: Event<PressEventData>| e.stop_propagation())
.on_press(|e: Event<PressEventData>| e.stop_propagation())
.child(
Button::new()
.primary()

View file

@ -100,7 +100,7 @@ impl Component for CategoryRow {
.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_all_press(move |_| on_toggle.call(()))
.on_press(move |_| on_toggle.call(()))
.child(
label()
.text(capitalize(&self.name))

View file

@ -208,7 +208,7 @@ impl Component for ProviderTag {
el.border(border_all_color(1., colors::brand()))
})
.cursor(CursorIcon::Pointer)
.on_all_press(move |_| confirm.set(Some(url.clone())))
.on_press(move |_| confirm.set(Some(url.clone())))
})
}
}
@ -302,7 +302,7 @@ impl Component for AuthorRow {
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.cursor(CursorIcon::Pointer)
.on_all_press(move |_| confirm.set(Some(url.clone())))
.on_press(move |_| confirm.set(Some(url.clone())))
})
.maybe(interactive && focused, |el| {
el.border(border_all_color(1., colors::brand()))
@ -439,7 +439,7 @@ impl Component for LinkRow {
el.border(border_all_color(1., colors::brand()))
})
.cursor(CursorIcon::Pointer)
.on_all_press(move |_| confirm.set(Some(url.clone())))
.on_press(move |_| confirm.set(Some(url.clone())))
.child(
Icon::new(IconType::Link03)
.size(14.)

View file

@ -14,7 +14,7 @@ use crate::components::{
use crate::hooks::{
ClusterLogsQuery, LogAction, UploadLogKeys, UseLogAction, UseUploadLog,
invalidate_logs_queries, try_cluster_logs, try_log_content, use_cluster_logs, use_log_action,
use_log_content, use_upload_log,
use_log_content, use_overlay_claim_when, use_upload_log,
};
use crate::layout::cluster_content;
use crate::theme::colors;
@ -377,6 +377,8 @@ impl Component for LogPicker {
// Stays usable with an empty list the category selector lives inside the panel
let is_open = open();
use_overlay_claim_when(is_open);
let query = SearchQuery::new(&filter.read());
let no_files = files.is_empty();
let rows: Vec<Element> = files
@ -402,6 +404,11 @@ impl Component for LogPicker {
rect()
.width(Size::px(260.))
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape) {
open.set_if_modified(false);
}
})
.child(
rect()
.width(Size::fill())
@ -425,7 +432,7 @@ impl Component for LogPicker {
},
))
.cursor(CursorIcon::Pointer)
.on_all_press(move |e: Event<PressEventData>| {
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
open.toggle();
})
@ -555,7 +562,7 @@ impl Component for PickerRow {
.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_all_press(self.on_press.clone())
.on_press(self.on_press.clone())
.child(
rect()
.vertical()

View file

@ -9,7 +9,7 @@ use crate::components::{
Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx,
Segment, SegmentedControl, TabBar, TabItem, TextInput,
};
use crate::hooks::use_dispatch;
use crate::hooks::{use_dispatch, use_overlay_claim};
use crate::routes::Route;
use crate::theme::colors;
use crate::{Actions, utils};
@ -311,6 +311,8 @@ struct FilterPopover {
impl Component for FilterPopover {
fn render(&self) -> impl IntoElement {
use_overlay_claim();
let mut sort = self.sort;
let current_sort = self.current_sort;
let mut enabled_filter = self.enabled_filter;
@ -473,7 +475,7 @@ impl Component for ChoiceRow {
el.border(crate::ui::border_all_color(1., colors::brand()))
})
.cursor(CursorIcon::Pointer)
.on_all_press(self.on_press.clone())
.on_press(self.on_press.clone())
.child(
label()
.text(text)