app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mcrates/ui-primitives/src/lib.rs | 7+++++++
Acrates/ui-primitives/src/roving_focus.rs | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + ); + } +}