use kael::Keystroke;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FocusTrapAction {
FocusFirst,
FocusNext,
FocusPrevious,
Dismiss,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FocusTrapController {
dismiss_on_escape: bool,
autofocus: bool,
}
impl Default for FocusTrapController {
fn default() -> Self {
Self::modal()
}
}
impl FocusTrapController {
pub fn modal() -> Self {
Self {
dismiss_on_escape: true,
autofocus: true,
}
}
pub fn persistent() -> Self {
Self {
dismiss_on_escape: false,
autofocus: true,
}
}
pub fn dismiss_on_escape(mut self, dismiss_on_escape: bool) -> Self {
self.dismiss_on_escape = dismiss_on_escape;
self
}
pub fn autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
pub fn should_autofocus(&self) -> bool {
self.autofocus
}
pub fn action_for_keystroke(&self, keystroke: &Keystroke) -> Option<FocusTrapAction> {
self.action_for_key(keystroke.key.as_ref(), keystroke.modifiers.shift)
}
pub fn action_for_key(&self, key: &str, shift: bool) -> Option<FocusTrapAction> {
if key.eq_ignore_ascii_case("tab") {
if shift {
Some(FocusTrapAction::FocusPrevious)
} else {
Some(FocusTrapAction::FocusNext)
}
} else if self.dismiss_on_escape && key.eq_ignore_ascii_case("escape") {
Some(FocusTrapAction::Dismiss)
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct DisclosureController {
open: bool,
}
impl DisclosureController {
pub fn new(open: bool) -> Self {
Self { open }
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn open(&mut self) {
self.open = true;
}
pub fn close(&mut self) {
self.open = false;
}
pub fn toggle(&mut self) -> bool {
self.open = !self.open;
self.open
}
pub fn set_open(&mut self, open: bool) {
self.open = open;
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct ToggleController {
on: bool,
mixed: bool,
}
impl ToggleController {
pub fn new(on: bool) -> Self {
Self { on, mixed: false }
}
pub fn is_on(&self) -> bool {
self.on
}
pub fn is_mixed(&self) -> bool {
self.mixed
}
pub fn set_on(&mut self, on: bool) {
self.on = on;
self.mixed = false;
}
pub fn set_mixed(&mut self) {
self.mixed = true;
}
pub fn toggle(&mut self) -> bool {
self.on = !self.on;
self.mixed = false;
self.on
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SelectController {
len: usize,
open: bool,
highlighted: Option<usize>,
selected: Option<usize>,
}
impl SelectController {
pub fn new(len: usize) -> Self {
Self {
len,
open: false,
highlighted: None,
selected: None,
}
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn set_len(&mut self, len: usize) {
self.len = len;
self.highlighted = self.highlighted.filter(|&i| i < len);
self.selected = self.selected.filter(|&i| i < len);
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn open(&mut self) {
if self.len == 0 {
return;
}
self.open = true;
if self.highlighted.is_none() {
self.highlighted = Some(self.selected.unwrap_or(0));
}
}
pub fn close(&mut self) {
self.open = false;
self.highlighted = None;
}
pub fn toggle(&mut self) {
if self.open {
self.close();
} else {
self.open();
}
}
pub fn highlighted(&self) -> Option<usize> {
self.highlighted
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn highlight_next(&mut self) {
if self.len == 0 {
return;
}
self.highlighted = Some(match self.highlighted {
Some(index) => (index + 1) % self.len,
None => 0,
});
}
pub fn highlight_prev(&mut self) {
if self.len == 0 {
return;
}
self.highlighted = Some(match self.highlighted {
Some(index) => (index + self.len - 1) % self.len,
None => self.len - 1,
});
}
pub fn highlight_first(&mut self) {
if self.len > 0 {
self.highlighted = Some(0);
}
}
pub fn highlight_last(&mut self) {
if self.len > 0 {
self.highlighted = Some(self.len - 1);
}
}
pub fn select_highlighted(&mut self) -> Option<usize> {
if let Some(index) = self.highlighted {
self.selected = Some(index);
self.close();
}
self.selected
}
pub fn select(&mut self, index: usize) {
if index < self.len {
self.selected = Some(index);
self.close();
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TabsController {
len: usize,
selected: usize,
}
impl TabsController {
pub fn new(len: usize) -> Self {
Self { len, selected: 0 }
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn select(&mut self, index: usize) {
if index < self.len {
self.selected = index;
}
}
pub fn next(&mut self) {
if self.len > 0 {
self.selected = (self.selected + 1) % self.len;
}
}
pub fn prev(&mut self) {
if self.len > 0 {
self.selected = (self.selected + self.len - 1) % self.len;
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SliderController {
value: f64,
min: f64,
max: f64,
step: f64,
}
impl SliderController {
pub fn new(value: f64, min: f64, max: f64, step: f64) -> Self {
let mut controller = Self {
value: min,
min,
max,
step: step.max(0.0),
};
controller.set_value(value);
controller
}
pub fn value(&self) -> f64 {
self.value
}
pub fn min(&self) -> f64 {
self.min
}
pub fn max(&self) -> f64 {
self.max
}
pub fn step(&self) -> f64 {
self.step
}
pub fn set_value(&mut self, value: f64) {
let clamped = value.clamp(self.min, self.max);
self.value = if self.step > 0.0 {
let steps = ((clamped - self.min) / self.step).round();
(self.min + steps * self.step).clamp(self.min, self.max)
} else {
clamped
};
}
pub fn increment(&mut self) {
self.set_value(self.value + self.step.max(f64::EPSILON));
}
pub fn decrement(&mut self) {
self.set_value(self.value - self.step.max(f64::EPSILON));
}
pub fn fraction(&self) -> f64 {
if self.max <= self.min {
0.0
} else {
((self.value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
}
}
pub fn set_fraction(&mut self, fraction: f64) {
self.set_value(self.min + fraction.clamp(0.0, 1.0) * (self.max - self.min));
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ComboboxController {
query: String,
select: SelectController,
}
impl ComboboxController {
pub fn new(result_count: usize) -> Self {
Self {
query: String::new(),
select: SelectController::new(result_count),
}
}
pub fn query(&self) -> &str {
&self.query
}
pub fn set_query(&mut self, query: impl Into<String>) {
self.query = query.into();
self.select.open();
self.select.highlight_first();
}
pub fn set_result_count(&mut self, count: usize) {
self.select.set_len(count);
}
pub fn is_open(&self) -> bool {
self.select.is_open()
}
pub fn open(&mut self) {
self.select.open();
}
pub fn close(&mut self) {
self.select.close();
}
pub fn highlighted(&self) -> Option<usize> {
self.select.highlighted()
}
pub fn highlight_next(&mut self) {
self.select.highlight_next();
}
pub fn highlight_prev(&mut self) {
self.select.highlight_prev();
}
pub fn select_highlighted(&mut self) -> Option<usize> {
self.select.select_highlighted()
}
pub fn selected(&self) -> Option<usize> {
self.select.selected()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AccordionController {
open: Vec<bool>,
allow_multiple: bool,
}
impl AccordionController {
pub fn new(len: usize, allow_multiple: bool) -> Self {
Self {
open: vec![false; len],
allow_multiple,
}
}
pub fn len(&self) -> usize {
self.open.len()
}
pub fn is_empty(&self) -> bool {
self.open.is_empty()
}
pub fn is_open(&self, index: usize) -> bool {
self.open.get(index).copied().unwrap_or(false)
}
pub fn open(&mut self, index: usize) {
if index >= self.open.len() {
return;
}
if !self.allow_multiple {
self.open.iter_mut().for_each(|open| *open = false);
}
self.open[index] = true;
}
pub fn close(&mut self, index: usize) {
if let Some(open) = self.open.get_mut(index) {
*open = false;
}
}
pub fn toggle(&mut self, index: usize) {
if self.is_open(index) {
self.close(index);
} else {
self.open(index);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PaginationController {
page: usize,
page_count: usize,
}
impl PaginationController {
pub fn new(page_count: usize) -> Self {
Self {
page: 0,
page_count,
}
}
pub fn page(&self) -> usize {
self.page
}
pub fn page_count(&self) -> usize {
self.page_count
}
pub fn set_page_count(&mut self, page_count: usize) {
self.page_count = page_count;
self.page = self.page.min(page_count.saturating_sub(1));
}
pub fn set_page(&mut self, page: usize) {
self.page = page.min(self.page_count.saturating_sub(1));
}
pub fn has_next(&self) -> bool {
self.page + 1 < self.page_count
}
pub fn has_prev(&self) -> bool {
self.page > 0
}
pub fn next(&mut self) {
if self.has_next() {
self.page += 1;
}
}
pub fn prev(&mut self) {
if self.has_prev() {
self.page -= 1;
}
}
pub fn first(&mut self) {
self.page = 0;
}
pub fn last(&mut self) {
self.page = self.page_count.saturating_sub(1);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StepperController {
step: usize,
completed: Vec<bool>,
}
impl StepperController {
pub fn new(step_count: usize) -> Self {
Self {
step: 0,
completed: vec![false; step_count],
}
}
pub fn step(&self) -> usize {
self.step
}
pub fn step_count(&self) -> usize {
self.completed.len()
}
pub fn is_completed(&self, index: usize) -> bool {
self.completed.get(index).copied().unwrap_or(false)
}
pub fn is_first(&self) -> bool {
self.step == 0
}
pub fn is_last(&self) -> bool {
self.step + 1 >= self.step_count()
}
pub fn next(&mut self) {
if let Some(done) = self.completed.get_mut(self.step) {
*done = true;
}
if !self.is_last() {
self.step += 1;
}
}
pub fn prev(&mut self) {
if self.step > 0 {
self.step -= 1;
}
}
pub fn go_to(&mut self, index: usize) -> bool {
if index < self.step_count() && index <= self.step + 1 {
self.step = index;
true
} else {
false
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RadioGroupController {
len: usize,
selected: Option<usize>,
focused: usize,
}
impl RadioGroupController {
pub fn new(len: usize) -> Self {
Self {
len,
selected: None,
focused: 0,
}
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn focused(&self) -> usize {
self.focused
}
pub fn select(&mut self, index: usize) {
if index < self.len {
self.selected = Some(index);
self.focused = index;
}
}
pub fn next(&mut self) {
if self.len > 0 {
self.select((self.focused + 1) % self.len);
}
}
pub fn prev(&mut self) {
if self.len > 0 {
self.select((self.focused + self.len - 1) % self.len);
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToastQueueController {
next_id: u64,
items: Vec<u64>,
max_visible: usize,
}
impl ToastQueueController {
pub fn new(max_visible: usize) -> Self {
Self {
next_id: 0,
items: Vec::new(),
max_visible: max_visible.max(1),
}
}
pub fn push(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
self.items.push(id);
id
}
pub fn dismiss(&mut self, id: u64) -> bool {
let before = self.items.len();
self.items.retain(|&item| item != id);
self.items.len() != before
}
pub fn visible(&self) -> &[u64] {
let count = self.items.len().min(self.max_visible);
&self.items[..count]
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn clear(&mut self) {
self.items.clear();
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CarouselController {
len: usize,
current: usize,
wrap: bool,
}
impl CarouselController {
pub fn new(len: usize) -> Self {
Self {
len,
current: 0,
wrap: true,
}
}
pub fn no_wrap(mut self) -> Self {
self.wrap = false;
self
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn current(&self) -> usize {
self.current
}
pub fn go_to(&mut self, index: usize) {
if index < self.len {
self.current = index;
}
}
pub fn can_next(&self) -> bool {
self.len > 0 && (self.wrap || self.current + 1 < self.len)
}
pub fn can_prev(&self) -> bool {
self.len > 0 && (self.wrap || self.current > 0)
}
pub fn next(&mut self) {
if !self.can_next() {
return;
}
self.current = if self.current + 1 < self.len {
self.current + 1
} else {
0
};
}
pub fn prev(&mut self) {
if !self.can_prev() {
return;
}
self.current = if self.current > 0 {
self.current - 1
} else {
self.len - 1
};
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TreeController {
expanded: std::collections::HashSet<u64>,
selected: Option<u64>,
}
impl TreeController {
pub fn new() -> Self {
Self::default()
}
pub fn is_expanded(&self, id: u64) -> bool {
self.expanded.contains(&id)
}
pub fn expand(&mut self, id: u64) {
self.expanded.insert(id);
}
pub fn collapse(&mut self, id: u64) {
self.expanded.remove(&id);
}
pub fn toggle(&mut self, id: u64) -> bool {
if self.expanded.contains(&id) {
self.expanded.remove(&id);
false
} else {
self.expanded.insert(id);
true
}
}
pub fn selected(&self) -> Option<u64> {
self.selected
}
pub fn select(&mut self, id: u64) {
self.selected = Some(id);
}
pub fn is_selected(&self, id: u64) -> bool {
self.selected == Some(id)
}
}
#[cfg(test)]
mod tests {
use super::{
AccordionController, CarouselController, ComboboxController, DisclosureController,
FocusTrapAction, FocusTrapController, PaginationController, RadioGroupController,
SelectController, SliderController, StepperController, TabsController,
ToastQueueController, ToggleController, TreeController,
};
#[test]
fn focus_trap_maps_tab_escape_and_shift_tab() {
let trap = FocusTrapController::modal();
assert!(trap.should_autofocus());
assert_eq!(
trap.action_for_key("tab", false),
Some(FocusTrapAction::FocusNext)
);
assert_eq!(
trap.action_for_key("tab", true),
Some(FocusTrapAction::FocusPrevious)
);
assert_eq!(
trap.action_for_key("escape", false),
Some(FocusTrapAction::Dismiss)
);
assert_eq!(trap.action_for_key("enter", false), None);
}
#[test]
fn persistent_focus_trap_does_not_dismiss_on_escape() {
let trap = FocusTrapController::persistent().autofocus(false);
assert!(!trap.should_autofocus());
assert_eq!(
trap.action_for_key("tab", false),
Some(FocusTrapAction::FocusNext)
);
assert_eq!(trap.action_for_key("escape", false), None);
}
#[test]
fn focus_trap_reads_parsed_keystrokes() {
let trap = FocusTrapController::modal().dismiss_on_escape(false);
let tab = kael::Keystroke::parse("tab").unwrap();
let shift_tab = kael::Keystroke::parse("shift-tab").unwrap();
let escape = kael::Keystroke::parse("escape").unwrap();
assert_eq!(
trap.action_for_keystroke(&tab),
Some(FocusTrapAction::FocusNext)
);
assert_eq!(
trap.action_for_keystroke(&shift_tab),
Some(FocusTrapAction::FocusPrevious)
);
assert_eq!(trap.action_for_keystroke(&escape), None);
}
#[test]
fn disclosure_toggles() {
let mut disclosure = DisclosureController::default();
assert!(!disclosure.is_open());
assert!(disclosure.toggle());
assert!(disclosure.is_open());
disclosure.close();
assert!(!disclosure.is_open());
disclosure.open();
assert!(disclosure.is_open());
}
#[test]
fn toggle_clears_mixed_on_change() {
let mut toggle = ToggleController::new(false);
toggle.set_mixed();
assert!(toggle.is_mixed());
assert!(toggle.toggle());
assert!(toggle.is_on());
assert!(!toggle.is_mixed());
}
#[test]
fn select_keyboard_navigation_wraps() {
let mut select = SelectController::new(3);
select.open();
assert_eq!(select.highlighted(), Some(0));
select.highlight_next();
assert_eq!(select.highlighted(), Some(1));
select.highlight_next();
select.highlight_next();
assert_eq!(select.highlighted(), Some(0));
select.highlight_prev();
assert_eq!(select.highlighted(), Some(2));
select.highlight_first();
assert_eq!(select.highlighted(), Some(0));
select.highlight_last();
assert_eq!(select.highlighted(), Some(2));
}
#[test]
fn select_highlighted_commits_and_closes() {
let mut select = SelectController::new(3);
select.open();
select.highlight_next();
assert_eq!(select.select_highlighted(), Some(1));
assert_eq!(select.selected(), Some(1));
assert!(!select.is_open());
assert_eq!(select.highlighted(), None);
select.open();
assert_eq!(select.highlighted(), Some(1));
}
#[test]
fn select_set_len_clamps_indices() {
let mut select = SelectController::new(5);
select.select(4);
assert_eq!(select.selected(), Some(4));
select.set_len(3);
assert_eq!(select.selected(), None); }
#[test]
fn empty_select_is_inert() {
let mut select = SelectController::new(0);
select.open();
assert!(!select.is_open());
select.highlight_next();
assert_eq!(select.highlighted(), None);
assert_eq!(select.select_highlighted(), None);
}
#[test]
fn tabs_navigation_wraps_and_clamps() {
let mut tabs = TabsController::new(3);
assert_eq!(tabs.selected(), 0);
tabs.next();
assert_eq!(tabs.selected(), 1);
tabs.prev();
tabs.prev();
assert_eq!(tabs.selected(), 2); tabs.select(5); assert_eq!(tabs.selected(), 2);
tabs.select(0);
assert_eq!(tabs.selected(), 0);
}
#[test]
fn slider_clamps_snaps_and_steps() {
let mut slider = SliderController::new(0.0, 0.0, 10.0, 2.0);
slider.set_value(6.4);
assert_eq!(slider.value(), 6.0); slider.set_value(100.0);
assert_eq!(slider.value(), 10.0); slider.increment();
assert_eq!(slider.value(), 10.0); slider.decrement();
assert_eq!(slider.value(), 8.0);
assert_eq!(slider.fraction(), 0.8);
slider.set_fraction(0.0);
assert_eq!(slider.value(), 0.0);
}
#[test]
fn combobox_query_drives_navigation() {
let mut combobox = ComboboxController::new(5);
combobox.set_query("ab");
assert_eq!(combobox.query(), "ab");
assert!(combobox.is_open());
assert_eq!(combobox.highlighted(), Some(0));
combobox.set_result_count(2);
combobox.highlight_next();
assert_eq!(combobox.highlighted(), Some(1));
combobox.highlight_next();
assert_eq!(combobox.highlighted(), Some(0)); combobox.highlight_next();
assert_eq!(combobox.select_highlighted(), Some(1));
assert!(!combobox.is_open());
}
#[test]
fn accordion_single_vs_multi_expand() {
let mut single = AccordionController::new(3, false);
single.open(0);
single.open(1);
assert!(!single.is_open(0)); assert!(single.is_open(1));
let mut multi = AccordionController::new(3, true);
multi.open(0);
multi.open(2);
assert!(multi.is_open(0));
assert!(multi.is_open(2));
multi.toggle(0);
assert!(!multi.is_open(0));
}
#[test]
fn pagination_navigates_within_bounds() {
let mut pagination = PaginationController::new(3);
assert_eq!(pagination.page(), 0);
assert!(!pagination.has_prev());
assert!(pagination.has_next());
pagination.next();
pagination.next();
assert_eq!(pagination.page(), 2);
pagination.next(); assert_eq!(pagination.page(), 2);
assert!(!pagination.has_next());
pagination.first();
assert_eq!(pagination.page(), 0);
pagination.last();
assert_eq!(pagination.page(), 2);
pagination.set_page_count(2); assert_eq!(pagination.page(), 1);
pagination.set_page(99);
assert_eq!(pagination.page(), 1);
}
#[test]
fn stepper_advances_and_gates_jumps() {
let mut stepper = StepperController::new(3);
assert!(stepper.is_first());
assert!(!stepper.is_completed(0));
stepper.next();
assert_eq!(stepper.step(), 1);
assert!(stepper.is_completed(0));
assert!(!stepper.go_to(2 + 1)); assert!(stepper.go_to(0)); assert_eq!(stepper.step(), 0);
stepper.next();
stepper.next();
assert!(stepper.is_last());
stepper.next(); assert_eq!(stepper.step(), 2);
assert!(stepper.is_completed(2));
}
#[test]
fn radio_group_focus_follows_selection() {
let mut radio = RadioGroupController::new(3);
assert_eq!(radio.selected(), None);
radio.next();
assert_eq!(radio.selected(), Some(1));
assert_eq!(radio.focused(), 1);
radio.prev();
radio.prev();
assert_eq!(radio.selected(), Some(2)); radio.select(0);
assert_eq!(radio.selected(), Some(0));
assert_eq!(radio.focused(), 0);
}
#[test]
fn toast_queue_caps_visible_and_dismisses() {
let mut toasts = ToastQueueController::new(2);
let a = toasts.push();
let b = toasts.push();
let _c = toasts.push();
assert_eq!(toasts.len(), 3);
assert_eq!(toasts.visible(), &[a, b]);
assert!(toasts.dismiss(a));
assert!(!toasts.dismiss(a)); assert_eq!(toasts.len(), 2);
assert_eq!(toasts.visible().len(), 2);
toasts.clear();
assert!(toasts.is_empty());
}
#[test]
fn carousel_wrap_vs_no_wrap() {
let mut wrapping = CarouselController::new(3);
wrapping.prev();
assert_eq!(wrapping.current(), 2); wrapping.next();
assert_eq!(wrapping.current(), 0);
let mut bounded = CarouselController::new(3).no_wrap();
assert!(!bounded.can_prev());
bounded.prev();
assert_eq!(bounded.current(), 0); bounded.go_to(2);
assert!(!bounded.can_next());
bounded.next();
assert_eq!(bounded.current(), 2);
}
#[test]
fn tree_expand_and_select() {
let mut tree = TreeController::new();
assert!(!tree.is_expanded(7));
assert!(tree.toggle(7));
assert!(tree.is_expanded(7));
assert!(!tree.toggle(7));
assert!(!tree.is_expanded(7));
tree.expand(3);
tree.select(3);
assert!(tree.is_expanded(3));
assert!(tree.is_selected(3));
assert_eq!(tree.selected(), Some(3));
}
}