commit 2aa3ebb2b031c5c79b68e21d5f4bb925427e3717
parent b4e98d3040f673137639465a115e2fe0a53a9009
Author: triesap <triesap@radroots.dev>
Date: Wed, 21 Jan 2026 20:20:01 +0000
ui: add roving focus helpers
- add roving focus orientation and action enums
- map arrow, home, and end keys to focus actions
- compute next index with loop and bounds handling
- add unit tests for key mapping and index updates
Diffstat:
2 files changed, 155 insertions(+), 0 deletions(-)
diff --git a/crates/ui-primitives/src/lib.rs b/crates/ui-primitives/src/lib.rs
@@ -5,6 +5,7 @@ mod presence;
mod dismissable;
mod focus;
mod scroll_lock;
+mod roving_focus;
pub use portal::{RadrootsAppUiPortal, RadrootsAppUiPortalMount};
pub use presence::{
@@ -30,3 +31,9 @@ pub use scroll_lock::{
RadrootsAppUiScrollLockGuard,
RadrootsAppUiScrollLockResult,
};
+pub use roving_focus::{
+ radroots_app_ui_roving_focus_action_from_key,
+ radroots_app_ui_roving_focus_next_index,
+ RadrootsAppUiRovingFocusAction,
+ RadrootsAppUiRovingFocusOrientation,
+};
diff --git a/crates/ui-primitives/src/roving_focus.rs b/crates/ui-primitives/src/roving_focus.rs
@@ -0,0 +1,148 @@
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RadrootsAppUiRovingFocusOrientation {
+ Horizontal,
+ Vertical,
+ Both,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RadrootsAppUiRovingFocusAction {
+ Next,
+ Prev,
+ First,
+ Last,
+}
+
+pub fn radroots_app_ui_roving_focus_action_from_key(
+ key: &str,
+ orientation: RadrootsAppUiRovingFocusOrientation,
+) -> Option<RadrootsAppUiRovingFocusAction> {
+ match key {
+ "Home" => Some(RadrootsAppUiRovingFocusAction::First),
+ "End" => Some(RadrootsAppUiRovingFocusAction::Last),
+ "ArrowLeft" => matches!(
+ orientation,
+ RadrootsAppUiRovingFocusOrientation::Horizontal | RadrootsAppUiRovingFocusOrientation::Both
+ )
+ .then_some(RadrootsAppUiRovingFocusAction::Prev),
+ "ArrowRight" => matches!(
+ orientation,
+ RadrootsAppUiRovingFocusOrientation::Horizontal | RadrootsAppUiRovingFocusOrientation::Both
+ )
+ .then_some(RadrootsAppUiRovingFocusAction::Next),
+ "ArrowUp" => matches!(
+ orientation,
+ RadrootsAppUiRovingFocusOrientation::Vertical | RadrootsAppUiRovingFocusOrientation::Both
+ )
+ .then_some(RadrootsAppUiRovingFocusAction::Prev),
+ "ArrowDown" => matches!(
+ orientation,
+ RadrootsAppUiRovingFocusOrientation::Vertical | RadrootsAppUiRovingFocusOrientation::Both
+ )
+ .then_some(RadrootsAppUiRovingFocusAction::Next),
+ _ => None,
+ }
+}
+
+pub fn radroots_app_ui_roving_focus_next_index(
+ current: usize,
+ count: usize,
+ action: RadrootsAppUiRovingFocusAction,
+ looped: bool,
+) -> usize {
+ if count == 0 {
+ return 0;
+ }
+ match action {
+ RadrootsAppUiRovingFocusAction::First => 0,
+ RadrootsAppUiRovingFocusAction::Last => count.saturating_sub(1),
+ RadrootsAppUiRovingFocusAction::Next => {
+ if current + 1 >= count {
+ if looped {
+ 0
+ } else {
+ current
+ }
+ } else {
+ current + 1
+ }
+ }
+ RadrootsAppUiRovingFocusAction::Prev => {
+ if current == 0 {
+ if looped {
+ count.saturating_sub(1)
+ } else {
+ 0
+ }
+ } else {
+ current - 1
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ radroots_app_ui_roving_focus_action_from_key,
+ radroots_app_ui_roving_focus_next_index,
+ RadrootsAppUiRovingFocusAction,
+ RadrootsAppUiRovingFocusOrientation,
+ };
+
+ #[test]
+ fn roving_focus_action_maps_arrows() {
+ assert_eq!(
+ radroots_app_ui_roving_focus_action_from_key(
+ "ArrowLeft",
+ RadrootsAppUiRovingFocusOrientation::Horizontal
+ ),
+ Some(RadrootsAppUiRovingFocusAction::Prev)
+ );
+ assert_eq!(
+ radroots_app_ui_roving_focus_action_from_key(
+ "ArrowUp",
+ RadrootsAppUiRovingFocusOrientation::Horizontal
+ ),
+ None
+ );
+ assert_eq!(
+ radroots_app_ui_roving_focus_action_from_key(
+ "ArrowDown",
+ RadrootsAppUiRovingFocusOrientation::Both
+ ),
+ Some(RadrootsAppUiRovingFocusAction::Next)
+ );
+ }
+
+ #[test]
+ fn roving_focus_next_index_respects_loop() {
+ assert_eq!(
+ radroots_app_ui_roving_focus_next_index(
+ 0,
+ 3,
+ RadrootsAppUiRovingFocusAction::Prev,
+ false
+ ),
+ 0
+ );
+ assert_eq!(
+ radroots_app_ui_roving_focus_next_index(
+ 0,
+ 3,
+ RadrootsAppUiRovingFocusAction::Prev,
+ true
+ ),
+ 2
+ );
+ assert_eq!(
+ radroots_app_ui_roving_focus_next_index(
+ 2,
+ 3,
+ RadrootsAppUiRovingFocusAction::Next,
+ true
+ ),
+ 0
+ );
+ }
+}