use crate::primitives::toast::{ToastFocus, ToastFocusTarget, ToastOverlay};
use crate::types::WidgetId;
use crate::{Key, NamedKey, UiEvent};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToastStackEvent {
Action(WidgetId),
Dismiss(WidgetId),
FocusReturned,
Consumed,
Ignored,
}
#[derive(Debug, Default)]
pub struct ToastStackController {
focus: Option<ToastFocus>,
}
impl ToastStackController {
pub fn new() -> Self {
Self { focus: None }
}
pub fn focus(&self) -> Option<ToastFocus> {
self.focus.clone()
}
pub fn is_focused(&self) -> bool {
self.focus.is_some()
}
pub fn give_focus(&mut self, stack: &ToastOverlay) -> bool {
match stack.toasts.last() {
Some(t) => {
self.focus = Some(ToastFocus {
toast_id: t.id,
target: ToastFocusTarget::Dismiss,
});
true
}
None => false,
}
}
pub fn take_focus(&mut self) {
self.focus = None;
}
pub fn handle(&mut self, event: &UiEvent, stack: &ToastOverlay) -> ToastStackEvent {
let UiEvent::KeyPressed { key, modifiers, .. } = event else {
return ToastStackEvent::Ignored;
};
self.resync(stack);
let Some(focus) = self.focus.clone() else {
return ToastStackEvent::Ignored;
};
let Some(toast_idx) = stack.toasts.iter().position(|t| t.id == focus.toast_id) else {
self.focus = None;
return ToastStackEvent::Ignored;
};
let control_count = 1 + stack.toasts[toast_idx].actions.len();
let cur = target_to_index(focus.target);
match key {
Key::Named(NamedKey::Escape) => {
self.focus = None;
ToastStackEvent::Dismiss(focus.toast_id)
}
Key::Named(NamedKey::Enter) => match focus.target {
ToastFocusTarget::Dismiss => ToastStackEvent::Dismiss(focus.toast_id),
ToastFocusTarget::Action(i) => match stack.toasts[toast_idx].actions.get(i) {
Some(a) => ToastStackEvent::Action(a.id),
None => ToastStackEvent::Consumed,
},
},
Key::Named(NamedKey::Left) => {
self.set_index(&focus.toast_id, (cur + control_count - 1) % control_count);
ToastStackEvent::Consumed
}
Key::Named(NamedKey::Right) => {
self.set_index(&focus.toast_id, (cur + 1) % control_count);
ToastStackEvent::Consumed
}
Key::Named(NamedKey::Tab) => {
let next = if modifiers.shift {
(cur + control_count - 1) % control_count
} else {
(cur + 1) % control_count
};
self.set_index(&focus.toast_id, next);
ToastStackEvent::Consumed
}
Key::Named(NamedKey::Up) => {
if toast_idx > 0 {
self.focus_toast_at(stack, toast_idx - 1);
}
ToastStackEvent::Consumed
}
Key::Named(NamedKey::Down) => {
if toast_idx + 1 < stack.toasts.len() {
self.focus_toast_at(stack, toast_idx + 1);
}
ToastStackEvent::Consumed
}
_ => ToastStackEvent::Ignored,
}
}
fn resync(&mut self, stack: &ToastOverlay) {
if let Some(f) = &self.focus {
if !stack.toasts.iter().any(|t| t.id == f.toast_id) {
self.focus = None;
}
}
}
fn set_index(&mut self, toast_id: &WidgetId, index: usize) {
self.focus = Some(ToastFocus {
toast_id: *toast_id,
target: index_to_target(index),
});
}
fn focus_toast_at(&mut self, stack: &ToastOverlay, idx: usize) {
if let Some(t) = stack.toasts.get(idx) {
self.focus = Some(ToastFocus {
toast_id: t.id,
target: ToastFocusTarget::Dismiss,
});
}
}
}
fn target_to_index(target: ToastFocusTarget) -> usize {
match target {
ToastFocusTarget::Dismiss => 0,
ToastFocusTarget::Action(i) => i + 1,
}
}
fn index_to_target(index: usize) -> ToastFocusTarget {
if index == 0 {
ToastFocusTarget::Dismiss
} else {
ToastFocusTarget::Action(index - 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::toast::{Toast, ToastButton, ToastCorner, ToastSeverity};
use crate::Modifiers;
fn toast(id: &str) -> Toast {
Toast {
id: WidgetId::new(id),
title: id.to_string(),
body: String::new(),
severity: ToastSeverity::Info,
actions: Vec::new(),
accent: None,
}
}
fn toast_with_actions(id: &str, action_ids: &[&str]) -> Toast {
Toast {
actions: action_ids
.iter()
.map(|a| ToastButton {
id: WidgetId::new(*a),
label: (*a).to_string(),
primary: false,
})
.collect(),
..toast(id)
}
}
fn stack(toasts: Vec<Toast>) -> ToastOverlay {
ToastOverlay {
id: WidgetId::new("toasts"),
corner: ToastCorner::BottomRight,
toasts,
focus: None,
}
}
fn key(named: NamedKey) -> UiEvent {
UiEvent::KeyPressed {
key: Key::Named(named),
modifiers: Modifiers::default(),
repeat: false,
}
}
fn shift_key(named: NamedKey) -> UiEvent {
UiEvent::KeyPressed {
key: Key::Named(named),
modifiers: Modifiers {
shift: true,
..Modifiers::default()
},
repeat: false,
}
}
#[test]
fn new_controller_has_no_focus() {
let c = ToastStackController::new();
assert!(!c.is_focused());
assert_eq!(c.focus(), None);
}
#[test]
fn unfocused_controller_ignores_every_key() {
let mut c = ToastStackController::new();
let s = stack(vec![toast("t1")]);
for k in [
NamedKey::Tab,
NamedKey::Left,
NamedKey::Right,
NamedKey::Up,
NamedKey::Down,
NamedKey::Enter,
NamedKey::Escape,
] {
assert_eq!(c.handle(&key(k), &s), ToastStackEvent::Ignored);
}
assert!(!c.is_focused());
}
#[test]
fn give_focus_seeds_newest_toast_dismiss() {
let mut c = ToastStackController::new();
let s = stack(vec![toast("t1"), toast("t2")]);
assert!(c.give_focus(&s));
assert_eq!(
c.focus(),
Some(ToastFocus {
toast_id: WidgetId::new("t2"),
target: ToastFocusTarget::Dismiss,
})
);
}
#[test]
fn give_focus_on_empty_stack_returns_false() {
let mut c = ToastStackController::new();
let s = stack(vec![]);
assert!(!c.give_focus(&s));
assert!(!c.is_focused());
}
#[test]
fn tab_cycles_dismiss_then_actions_then_wraps() {
let mut c = ToastStackController::new();
let s = stack(vec![toast_with_actions("t1", &["a", "b"])]);
c.give_focus(&s);
assert_eq!(c.focus().unwrap().target, ToastFocusTarget::Dismiss);
c.handle(&key(NamedKey::Tab), &s);
assert_eq!(c.focus().unwrap().target, ToastFocusTarget::Action(0));
c.handle(&key(NamedKey::Tab), &s);
assert_eq!(c.focus().unwrap().target, ToastFocusTarget::Action(1));
c.handle(&key(NamedKey::Tab), &s);
assert_eq!(
c.focus().unwrap().target,
ToastFocusTarget::Dismiss,
"wraps back to dismiss"
);
}
#[test]
fn shift_tab_cycles_backward() {
let mut c = ToastStackController::new();
let s = stack(vec![toast_with_actions("t1", &["a"])]);
c.give_focus(&s);
c.handle(&shift_key(NamedKey::Tab), &s);
assert_eq!(
c.focus().unwrap().target,
ToastFocusTarget::Action(0),
"shift-tab from dismiss wraps to the last control"
);
}
#[test]
fn left_right_cycle_the_same_as_tab() {
let mut c = ToastStackController::new();
let s = stack(vec![toast_with_actions("t1", &["a"])]);
c.give_focus(&s);
c.handle(&key(NamedKey::Right), &s);
assert_eq!(c.focus().unwrap().target, ToastFocusTarget::Action(0));
c.handle(&key(NamedKey::Left), &s);
assert_eq!(c.focus().unwrap().target, ToastFocusTarget::Dismiss);
}
#[test]
fn up_down_move_between_toasts_and_reset_to_dismiss() {
let mut c = ToastStackController::new();
let s = stack(vec![toast("t1"), toast_with_actions("t2", &["a"])]);
c.give_focus(&s); c.handle(&key(NamedKey::Right), &s); assert_eq!(c.focus().unwrap().toast_id, WidgetId::new("t2"));
c.handle(&key(NamedKey::Up), &s);
assert_eq!(
c.focus(),
Some(ToastFocus {
toast_id: WidgetId::new("t1"),
target: ToastFocusTarget::Dismiss,
})
);
c.handle(&key(NamedKey::Up), &s);
assert_eq!(c.focus().unwrap().toast_id, WidgetId::new("t1"));
c.handle(&key(NamedKey::Down), &s);
assert_eq!(c.focus().unwrap().toast_id, WidgetId::new("t2"));
assert_eq!(c.focus().unwrap().target, ToastFocusTarget::Dismiss);
}
#[test]
fn enter_on_dismiss_resolves_dismiss() {
let mut c = ToastStackController::new();
let s = stack(vec![toast("t1")]);
c.give_focus(&s);
assert_eq!(
c.handle(&key(NamedKey::Enter), &s),
ToastStackEvent::Dismiss(WidgetId::new("t1"))
);
}
#[test]
fn enter_on_action_resolves_that_actions_id() {
let mut c = ToastStackController::new();
let s = stack(vec![toast_with_actions("t1", &["install", "skip"])]);
c.give_focus(&s);
c.handle(&key(NamedKey::Tab), &s); assert_eq!(
c.handle(&key(NamedKey::Enter), &s),
ToastStackEvent::Action(WidgetId::new("install"))
);
}
#[test]
fn escape_dismisses_and_clears_focus() {
let mut c = ToastStackController::new();
let s = stack(vec![toast("t1")]);
c.give_focus(&s);
assert_eq!(
c.handle(&key(NamedKey::Escape), &s),
ToastStackEvent::Dismiss(WidgetId::new("t1"))
);
assert!(!c.is_focused());
}
#[test]
fn take_focus_clears_without_an_event() {
let mut c = ToastStackController::new();
let s = stack(vec![toast("t1")]);
c.give_focus(&s);
c.take_focus();
assert!(!c.is_focused());
assert_eq!(
c.handle(&key(NamedKey::Enter), &s),
ToastStackEvent::Ignored
);
}
#[test]
fn handle_resyncs_when_focused_toast_vanished() {
let mut c = ToastStackController::new();
let s1 = stack(vec![toast("t1")]);
c.give_focus(&s1);
let s2 = stack(vec![]); assert_eq!(
c.handle(&key(NamedKey::Enter), &s2),
ToastStackEvent::Ignored
);
assert!(!c.is_focused());
}
#[test]
fn non_key_events_are_ignored() {
let mut c = ToastStackController::new();
let s = stack(vec![toast("t1")]);
c.give_focus(&s);
assert_eq!(
c.handle(&UiEvent::CharTyped('x'), &s),
ToastStackEvent::Ignored
);
assert!(c.is_focused());
}
#[test]
fn unrelated_key_is_ignored_even_while_focused() {
let mut c = ToastStackController::new();
let s = stack(vec![toast("t1")]);
c.give_focus(&s);
let ev = UiEvent::KeyPressed {
key: Key::Char('a'),
modifiers: Modifiers::default(),
repeat: false,
};
assert_eq!(c.handle(&ev, &s), ToastStackEvent::Ignored);
assert!(c.is_focused());
}
}