use alloc::vec::Vec;
use azul_core::{
callbacks::FocusTarget,
dom::{DomId, DomNodeId, NodeId},
events::{DefaultAction, DefaultActionResult, ScrollAmount, ScrollDirection},
window::{KeyboardState, VirtualKeyCode},
};
use crate::window::DomLayoutResult;
use std::collections::BTreeMap;
#[allow(clippy::too_many_lines)] #[must_use] pub fn determine_keyboard_default_action(
keyboard_state: &KeyboardState,
focused_node: Option<DomNodeId>,
layout_results: &BTreeMap<DomId, DomLayoutResult>,
prevented: bool,
) -> DefaultActionResult {
if prevented {
return DefaultActionResult::prevented();
}
let Some(current_key) = keyboard_state.current_virtual_keycode.into_option() else {
return DefaultActionResult::default();
};
let shift_down = keyboard_state.shift_down();
let ctrl_down = keyboard_state.ctrl_down();
let alt_down = keyboard_state.alt_down();
let action = match current_key {
VirtualKeyCode::Tab => {
if ctrl_down || alt_down {
DefaultAction::None
} else if shift_down {
DefaultAction::FocusPrevious
} else {
DefaultAction::FocusNext
}
}
VirtualKeyCode::Return | VirtualKeyCode::NumpadEnter => {
focused_node.as_ref().map_or(DefaultAction::None, |focus| if is_element_activatable(focus, layout_results) {
DefaultAction::ActivateFocusedElement {
target: *focus,
}
} else {
DefaultAction::None
})
}
VirtualKeyCode::Space => {
match focused_node.as_ref() {
Some(focus)
if is_element_activatable(focus, layout_results)
&& !is_text_input(focus, layout_results) =>
{
DefaultAction::ActivateFocusedElement { target: *focus }
}
Some(focus) if is_text_input(focus, layout_results) => DefaultAction::None,
_ => DefaultAction::ScrollFocusedContainer {
direction: if shift_down {
ScrollDirection::Up
} else {
ScrollDirection::Down
},
amount: ScrollAmount::Page,
},
}
}
VirtualKeyCode::Escape => {
if focused_node.is_some() {
DefaultAction::ClearFocus
} else {
DefaultAction::None
}
}
VirtualKeyCode::Up | VirtualKeyCode::Down | VirtualKeyCode::Left | VirtualKeyCode::Right => {
let direction = match current_key {
VirtualKeyCode::Up => ScrollDirection::Up,
VirtualKeyCode::Down => ScrollDirection::Down,
VirtualKeyCode::Left => ScrollDirection::Left,
_ => ScrollDirection::Right,
};
focused_node.as_ref().map_or(
DefaultAction::ScrollFocusedContainer {
direction,
amount: ScrollAmount::Line,
},
|focus| {
if is_text_input(focus, layout_results) {
DefaultAction::None
} else {
DefaultAction::ScrollFocusedContainer {
direction,
amount: ScrollAmount::Line,
}
}
},
)
}
VirtualKeyCode::PageUp => {
DefaultAction::ScrollFocusedContainer {
direction: ScrollDirection::Up,
amount: ScrollAmount::Page,
}
}
VirtualKeyCode::PageDown => {
DefaultAction::ScrollFocusedContainer {
direction: ScrollDirection::Down,
amount: ScrollAmount::Page,
}
}
VirtualKeyCode::Home => {
if ctrl_down {
DefaultAction::FocusFirst
} else {
DefaultAction::ScrollFocusedContainer {
direction: ScrollDirection::Up,
amount: ScrollAmount::Document,
}
}
}
VirtualKeyCode::End => {
if ctrl_down {
DefaultAction::FocusLast
} else {
DefaultAction::ScrollFocusedContainer {
direction: ScrollDirection::Down,
amount: ScrollAmount::Document,
}
}
}
_ => DefaultAction::None,
};
DefaultActionResult::new(action)
}
fn is_element_activatable(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
let Some(layout) = layout_results.get(&node_id.dom) else {
return false;
};
let Some(internal_id) = node_id.node.into_crate_internal() else {
return false;
};
layout.styled_dom.node_data.as_container()
.get(internal_id)
.is_some_and(azul_core::dom::NodeData::is_activatable)
}
fn is_text_input(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
use azul_core::events::{EventFilter, FocusEventFilter};
let Some(layout) = layout_results.get(&node_id.dom) else {
return false;
};
let Some(internal_id) = node_id.node.into_crate_internal() else {
return false;
};
let node_data = layout.styled_dom.node_data.as_container();
let Some(node) = node_data.get(internal_id) else {
return false;
};
node.get_callbacks()
.iter()
.any(|cb| matches!(cb.event, EventFilter::Focus(FocusEventFilter::TextInput)))
}
#[must_use] pub const fn default_action_to_focus_target(action: &DefaultAction) -> Option<FocusTarget> {
match action {
DefaultAction::FocusNext => Some(FocusTarget::Next),
DefaultAction::FocusPrevious => Some(FocusTarget::Previous),
DefaultAction::FocusFirst => Some(FocusTarget::First),
DefaultAction::FocusLast => Some(FocusTarget::Last),
DefaultAction::ClearFocus => Some(FocusTarget::NoFocus),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use azul_core::styled_dom::NodeHierarchyItemId;
#[test]
#[allow(clippy::field_reassign_with_default)] fn test_tab_focus_next() {
let mut keyboard_state = KeyboardState::default();
keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
let result = determine_keyboard_default_action(
&keyboard_state,
None,
&BTreeMap::new(),
false,
);
assert!(matches!(result.action, DefaultAction::FocusNext));
assert!(!result.prevented);
}
#[test]
#[allow(clippy::field_reassign_with_default)] fn test_shift_tab_focus_previous() {
let mut keyboard_state = KeyboardState::default();
keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
keyboard_state.pressed_virtual_keycodes = vec![VirtualKeyCode::LShift, VirtualKeyCode::Tab].into();
let result = determine_keyboard_default_action(
&keyboard_state,
None,
&BTreeMap::new(),
false,
);
assert!(matches!(result.action, DefaultAction::FocusPrevious));
}
#[test]
#[allow(clippy::field_reassign_with_default)] fn test_escape_clears_focus() {
let mut keyboard_state = KeyboardState::default();
keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Escape).into();
let focused = Some(DomNodeId {
dom: DomId { inner: 0 },
node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(1))),
});
let result = determine_keyboard_default_action(
&keyboard_state,
focused,
&BTreeMap::new(),
false,
);
assert!(matches!(result.action, DefaultAction::ClearFocus));
}
#[test]
#[allow(clippy::field_reassign_with_default)] fn test_prevented_returns_no_action() {
let mut keyboard_state = KeyboardState::default();
keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
let result = determine_keyboard_default_action(
&keyboard_state,
None,
&BTreeMap::new(),
true, );
assert!(result.prevented);
assert!(matches!(result.action, DefaultAction::None));
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)] mod autotest_generated {
use std::collections::HashMap;
use azul_core::{
a11y::{AccessibilityRole, AccessibilityState, SmallAriaInfo},
dom::{Dom, NodeData, NodeType},
events::{EventFilter, FocusEventFilter, HoverEventFilter},
geom::LogicalRect,
refany::RefAny,
styled_dom::{NodeHierarchyItemId, StyledDom},
};
use super::*;
use crate::solver3::{display_list::DisplayList, layout_tree::LayoutTree};
fn node_with_callback(node_type: NodeType, event: EventFilter) -> NodeData {
let mut nd = NodeData::create_node(node_type);
nd.add_callback(event, RefAny::new(0u32), 0usize);
nd
}
fn node_with_role(node_type: NodeType, role: AccessibilityRole) -> NodeData {
let mut nd = NodeData::create_node(node_type);
nd.set_accessibility_info(SmallAriaInfo::label("label").with_role(role).to_full_info());
nd
}
fn disabled_control() -> NodeData {
let mut nd = NodeData::create_node(NodeType::Input);
let mut info = SmallAriaInfo::label("Save")
.with_role(AccessibilityRole::PushButton)
.to_full_info();
info.states = vec![AccessibilityState::Unavailable].into();
nd.set_accessibility_info(info);
nd
}
fn fixture() -> BTreeMap<DomId, DomLayoutResult> {
let dom = Dom::create_body()
.with_child(Dom::create_from_data(NodeData::create_button_no_a11y()))
.with_child(Dom::create_from_data(NodeData::create_div()))
.with_child(Dom::create_from_data(node_with_callback(
NodeType::TextArea,
EventFilter::Focus(FocusEventFilter::TextInput),
)))
.with_child(Dom::create_from_data(node_with_callback(
NodeType::A,
EventFilter::Focus(FocusEventFilter::TextInput),
)))
.with_child(Dom::create_from_data(node_with_callback(
NodeType::P,
EventFilter::Hover(HoverEventFilter::LeftMouseUp),
)))
.with_child(Dom::create_from_data(disabled_control()))
.with_child(Dom::create_from_data(node_with_role(
NodeType::Select,
AccessibilityRole::CheckButton,
)));
let styled_dom = StyledDom::create_from_dom(dom);
let mut map = BTreeMap::new();
map.insert(
DomId::ROOT_ID,
DomLayoutResult {
styled_dom,
layout_tree: LayoutTree {
nodes: Vec::new(),
warm: Vec::new(),
cold: Vec::new(),
root: 0,
dom_to_layout: BTreeMap::new(),
children_arena: Vec::new(),
children_offsets: Vec::new(),
subtree_needs_intrinsic: Vec::new(),
},
calculated_positions: Vec::new(),
viewport: LogicalRect::zero(),
display_list: DisplayList::default(),
scroll_ids: HashMap::new(),
scroll_id_to_node_id: HashMap::new(),
},
);
map
}
fn dom_node(index: usize) -> DomNodeId {
DomNodeId {
dom: DomId::ROOT_ID,
node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(index))),
}
}
fn node_of(layouts: &BTreeMap<DomId, DomLayoutResult>, matcher: fn(&NodeType) -> bool) -> DomNodeId {
let layout = layouts.get(&DomId::ROOT_ID).expect("fixture dom missing");
let container = layout.styled_dom.node_data.as_container();
for i in 0..container.len() {
if container
.get(NodeId::new(i))
.is_some_and(|nd| matcher(&nd.node_type))
{
return dom_node(i);
}
}
panic!("fixture node not found");
}
fn button(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
node_of(l, |t| matches!(t, NodeType::Button))
}
fn div(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
node_of(l, |t| matches!(t, NodeType::Div))
}
fn textarea(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
node_of(l, |t| matches!(t, NodeType::TextArea))
}
fn anchor(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
node_of(l, |t| matches!(t, NodeType::A))
}
fn clickable_p(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
node_of(l, |t| matches!(t, NodeType::P))
}
fn disabled(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
node_of(l, |t| matches!(t, NodeType::Input))
}
fn role_only(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
node_of(l, |t| matches!(t, NodeType::Select))
}
fn body(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
node_of(l, |t| matches!(t, NodeType::Body))
}
fn missing_dom() -> DomNodeId {
DomNodeId {
dom: DomId { inner: 9999 },
node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
}
}
fn out_of_bounds_node() -> DomNodeId {
dom_node(9999)
}
fn null_node() -> DomNodeId {
DomNodeId {
dom: DomId::ROOT_ID,
node: NodeHierarchyItemId::NONE,
}
}
fn max_node() -> DomNodeId {
DomNodeId {
dom: DomId::ROOT_ID,
node: NodeHierarchyItemId::from_raw(usize::MAX),
}
}
fn kbd(key: VirtualKeyCode, mods: &[VirtualKeyCode]) -> KeyboardState {
let mut ks = KeyboardState::default();
ks.current_virtual_keycode = Some(key).into();
let mut pressed = mods.to_vec();
pressed.push(key);
ks.pressed_virtual_keycodes = pressed.into();
ks
}
const ALL_KEYS: &[VirtualKeyCode] = &[
VirtualKeyCode::Tab,
VirtualKeyCode::Return,
VirtualKeyCode::NumpadEnter,
VirtualKeyCode::Space,
VirtualKeyCode::Escape,
VirtualKeyCode::Up,
VirtualKeyCode::Down,
VirtualKeyCode::Left,
VirtualKeyCode::Right,
VirtualKeyCode::PageUp,
VirtualKeyCode::PageDown,
VirtualKeyCode::Home,
VirtualKeyCode::End,
VirtualKeyCode::F1,
VirtualKeyCode::Key1,
VirtualKeyCode::LShift,
VirtualKeyCode::LControl,
VirtualKeyCode::LAlt,
];
const MOD_SETS: &[&[VirtualKeyCode]] = &[
&[],
&[VirtualKeyCode::LShift],
&[VirtualKeyCode::RShift],
&[VirtualKeyCode::LControl],
&[VirtualKeyCode::RControl],
&[VirtualKeyCode::LAlt],
&[VirtualKeyCode::RAlt],
&[VirtualKeyCode::LControl, VirtualKeyCode::LShift],
&[VirtualKeyCode::LAlt, VirtualKeyCode::LShift],
&[
VirtualKeyCode::LControl,
VirtualKeyCode::LAlt,
VirtualKeyCode::LShift,
],
];
fn scroll(direction: ScrollDirection, amount: ScrollAmount) -> DefaultAction {
DefaultAction::ScrollFocusedContainer { direction, amount }
}
#[test]
fn prevented_beats_every_key_modifier_and_focus_combination() {
let layouts = fixture();
let focus_states = [
None,
Some(button(&layouts)),
Some(textarea(&layouts)),
Some(missing_dom()),
Some(null_node()),
];
for key in ALL_KEYS {
for mods in MOD_SETS {
for focus in focus_states {
let result =
determine_keyboard_default_action(&kbd(*key, mods), focus, &layouts, true);
assert!(result.prevented, "prevent_default() must be reported for {key:?}");
assert_eq!(
result.action,
DefaultAction::None,
"a prevented event must never carry an action ({key:?})"
);
}
}
}
}
#[test]
fn no_current_key_yields_the_default_result() {
let layouts = fixture();
let mut ks = KeyboardState::default();
ks.pressed_virtual_keycodes =
vec![VirtualKeyCode::LShift, VirtualKeyCode::LControl].into();
let result =
determine_keyboard_default_action(&ks, Some(button(&layouts)), &layouts, false);
assert_eq!(result.action, DefaultAction::None);
assert!(!result.prevented);
}
#[test]
fn never_reports_prevented_when_not_prevented() {
let layouts = fixture();
for key in ALL_KEYS {
for mods in MOD_SETS {
let result = determine_keyboard_default_action(
&kbd(*key, mods),
Some(button(&layouts)),
&layouts,
false,
);
assert!(!result.prevented, "{key:?} must not set `prevented`");
}
}
}
#[test]
fn tab_with_ctrl_or_alt_yields_no_action() {
let layouts = fixture();
for mods in [
&[VirtualKeyCode::LControl][..],
&[VirtualKeyCode::RControl][..],
&[VirtualKeyCode::LAlt][..],
&[VirtualKeyCode::RAlt][..],
&[VirtualKeyCode::LControl, VirtualKeyCode::LShift][..],
&[VirtualKeyCode::LAlt, VirtualKeyCode::RShift][..],
] {
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Tab, mods),
None,
&layouts,
false,
);
assert_eq!(
result.action,
DefaultAction::None,
"Ctrl/Alt+Tab belongs to the OS, not the app ({mods:?})"
);
}
}
#[test]
fn tab_uses_either_shift_key_and_ignores_focus() {
let layouts = fixture();
for shift in [VirtualKeyCode::LShift, VirtualKeyCode::RShift] {
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Tab, &[shift]),
Some(textarea(&layouts)),
&layouts,
false,
);
assert_eq!(result.action, DefaultAction::FocusPrevious);
}
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Tab, &[]),
Some(textarea(&layouts)),
&layouts,
false,
);
assert_eq!(result.action, DefaultAction::FocusNext);
}
#[test]
fn enter_activates_every_kind_of_activatable_element() {
let layouts = fixture();
for target in [
button(&layouts),
anchor(&layouts),
clickable_p(&layouts),
role_only(&layouts),
] {
for key in [VirtualKeyCode::Return, VirtualKeyCode::NumpadEnter] {
let result = determine_keyboard_default_action(
&kbd(key, &[]),
Some(target),
&layouts,
false,
);
assert_eq!(
result.action,
DefaultAction::ActivateFocusedElement { target },
"{key:?} on an activatable element must activate exactly that element"
);
}
}
}
#[test]
fn enter_on_a_disabled_control_does_not_activate() {
let layouts = fixture();
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Return, &[]),
Some(disabled(&layouts)),
&layouts,
false,
);
assert_eq!(
result.action,
DefaultAction::None,
"an Unavailable (disabled) control must never be activated"
);
}
#[test]
fn enter_on_non_activatable_or_unfocused_yields_no_action() {
let layouts = fixture();
for focus in [None, Some(div(&layouts)), Some(body(&layouts))] {
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Return, &[]),
focus,
&layouts,
false,
);
assert_eq!(result.action, DefaultAction::None);
}
}
#[test]
fn enter_on_a_dangling_focus_target_does_not_panic() {
let layouts = fixture();
let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
for focus in [
missing_dom(),
out_of_bounds_node(),
null_node(),
max_node(),
] {
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Return, &[]),
Some(focus),
&layouts,
false,
);
assert_eq!(
result.action,
DefaultAction::None,
"a focus target that cannot be resolved must not be activated"
);
}
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Return, &[]),
Some(dom_node(1)),
&empty,
false,
);
assert_eq!(result.action, DefaultAction::None);
}
#[test]
fn space_activates_a_focused_button() {
let layouts = fixture();
let target = button(&layouts);
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Space, &[]),
Some(target),
&layouts,
false,
);
assert_eq!(
result.action,
DefaultAction::ActivateFocusedElement { target }
);
}
#[test]
fn space_in_a_text_input_is_swallowed_even_when_the_element_is_activatable() {
let layouts = fixture();
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Space, &[]),
Some(textarea(&layouts)),
&layouts,
false,
);
assert_eq!(
result.action,
DefaultAction::None,
"Space in a text input must insert text, not scroll or activate"
);
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Space, &[]),
Some(anchor(&layouts)),
&layouts,
false,
);
assert_eq!(
result.action,
DefaultAction::None,
"text-input behaviour must take precedence over activation for Space"
);
}
#[test]
fn space_pages_the_scroll_container_when_nothing_activatable_has_focus() {
let layouts = fixture();
for focus in [
None,
Some(div(&layouts)),
Some(body(&layouts)),
Some(disabled(&layouts)),
Some(missing_dom()),
Some(null_node()),
Some(out_of_bounds_node()),
] {
let down = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Space, &[]),
focus,
&layouts,
false,
);
assert_eq!(down.action, scroll(ScrollDirection::Down, ScrollAmount::Page));
let up = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Space, &[VirtualKeyCode::RShift]),
focus,
&layouts,
false,
);
assert_eq!(
up.action,
scroll(ScrollDirection::Up, ScrollAmount::Page),
"Shift+Space pages up"
);
}
}
#[test]
fn escape_clears_focus_only_when_something_is_focused() {
let layouts = fixture();
for focus in [
button(&layouts),
div(&layouts),
null_node(),
missing_dom(),
max_node(),
] {
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Escape, &[]),
Some(focus),
&layouts,
false,
);
assert_eq!(result.action, DefaultAction::ClearFocus);
}
let result = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Escape, &[]),
None,
&layouts,
false,
);
assert_eq!(result.action, DefaultAction::None);
}
#[test]
fn arrow_keys_map_to_their_own_direction_and_scroll_by_line() {
let layouts = fixture();
for (key, direction) in [
(VirtualKeyCode::Up, ScrollDirection::Up),
(VirtualKeyCode::Down, ScrollDirection::Down),
(VirtualKeyCode::Left, ScrollDirection::Left),
(VirtualKeyCode::Right, ScrollDirection::Right),
] {
for focus in [
None,
Some(button(&layouts)),
Some(div(&layouts)),
Some(missing_dom()),
Some(null_node()),
] {
let result = determine_keyboard_default_action(
&kbd(key, &[]),
focus,
&layouts,
false,
);
assert_eq!(
result.action,
scroll(direction, ScrollAmount::Line),
"{key:?} must scroll one line towards {direction:?}"
);
}
let result = determine_keyboard_default_action(
&kbd(key, &[]),
Some(textarea(&layouts)),
&layouts,
false,
);
assert_eq!(
result.action,
DefaultAction::None,
"{key:?} in a text input must move the caret, not scroll"
);
}
}
#[test]
fn page_keys_scroll_a_page_regardless_of_modifiers_and_focus() {
let layouts = fixture();
for (key, direction) in [
(VirtualKeyCode::PageUp, ScrollDirection::Up),
(VirtualKeyCode::PageDown, ScrollDirection::Down),
] {
for mods in MOD_SETS {
for focus in [None, Some(textarea(&layouts)), Some(button(&layouts))] {
let result = determine_keyboard_default_action(
&kbd(key, mods),
focus,
&layouts,
false,
);
assert_eq!(result.action, scroll(direction, ScrollAmount::Page));
}
}
}
}
#[test]
fn home_and_end_switch_between_scrolling_and_focus_on_ctrl() {
let layouts = fixture();
for ctrl in [VirtualKeyCode::LControl, VirtualKeyCode::RControl] {
let home =
determine_keyboard_default_action(&kbd(VirtualKeyCode::Home, &[ctrl]), None, &layouts, false);
assert_eq!(home.action, DefaultAction::FocusFirst);
let end =
determine_keyboard_default_action(&kbd(VirtualKeyCode::End, &[ctrl]), None, &layouts, false);
assert_eq!(end.action, DefaultAction::FocusLast);
let home_shift = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Home, &[ctrl, VirtualKeyCode::LShift, VirtualKeyCode::LAlt]),
Some(textarea(&layouts)),
&layouts,
false,
);
assert_eq!(home_shift.action, DefaultAction::FocusFirst);
}
for focus in [None, Some(textarea(&layouts))] {
let home = determine_keyboard_default_action(
&kbd(VirtualKeyCode::Home, &[VirtualKeyCode::LShift]),
focus,
&layouts,
false,
);
assert_eq!(home.action, scroll(ScrollDirection::Up, ScrollAmount::Document));
let end = determine_keyboard_default_action(
&kbd(VirtualKeyCode::End, &[]),
focus,
&layouts,
false,
);
assert_eq!(end.action, scroll(ScrollDirection::Down, ScrollAmount::Document));
}
}
#[test]
fn keys_without_a_default_action_yield_none() {
let layouts = fixture();
for key in [
VirtualKeyCode::F1,
VirtualKeyCode::Key1,
VirtualKeyCode::LShift,
VirtualKeyCode::LControl,
VirtualKeyCode::LAlt,
] {
for focus in [None, Some(button(&layouts)), Some(textarea(&layouts))] {
let result =
determine_keyboard_default_action(&kbd(key, &[]), focus, &layouts, false);
assert_eq!(result.action, DefaultAction::None, "{key:?} has no default action");
}
}
}
#[test]
fn every_key_modifier_and_focus_combination_is_panic_free_and_deterministic() {
let layouts = fixture();
let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
let focus_states = [
None,
Some(button(&layouts)),
Some(div(&layouts)),
Some(textarea(&layouts)),
Some(anchor(&layouts)),
Some(disabled(&layouts)),
Some(body(&layouts)),
Some(missing_dom()),
Some(out_of_bounds_node()),
Some(null_node()),
Some(max_node()),
];
for key in ALL_KEYS {
for mods in MOD_SETS {
for focus in focus_states {
for maps in [&layouts, &empty] {
let ks = kbd(*key, mods);
let a = determine_keyboard_default_action(&ks, focus, maps, false);
let b = determine_keyboard_default_action(&ks, focus, maps, false);
assert_eq!(
a.action, b.action,
"{key:?} must be a pure function of its inputs"
);
assert_eq!(a.prevented, b.prevented);
if let DefaultAction::ActivateFocusedElement { target } = a.action {
assert_eq!(
Some(target),
focus,
"activation must target the focused node, nothing else"
);
}
}
}
}
}
}
#[test]
fn is_element_activatable_true_and_false_cases() {
let layouts = fixture();
for (node, expected, why) in [
(button(&layouts), true, "a <button> is inherently activatable"),
(anchor(&layouts), true, "an <a> is inherently activatable"),
(clickable_p(&layouts), true, "a click callback grants activation behaviour"),
(role_only(&layouts), true, "the CheckButton a11y role grants activation behaviour"),
(div(&layouts), false, "a plain <div> has no activation behaviour"),
(body(&layouts), false, "the root <body> is not activatable"),
(textarea(&layouts), false, "a text input is not activatable"),
(disabled(&layouts), false, "an Unavailable control is not activatable"),
] {
assert_eq!(is_element_activatable(&node, &layouts), expected, "{why}");
}
}
#[test]
fn is_element_activatable_rejects_every_unresolvable_node_id() {
let layouts = fixture();
let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
for node in [
missing_dom(),
out_of_bounds_node(),
null_node(),
max_node(),
] {
assert!(!is_element_activatable(&node, &layouts));
}
assert!(!is_element_activatable(&button(&layouts), &empty));
assert!(!is_element_activatable(&dom_node(0), &empty));
}
#[test]
fn is_text_input_true_and_false_cases() {
let layouts = fixture();
for (node, expected, why) in [
(textarea(&layouts), true, "a Focus(TextInput) callback marks a text input"),
(anchor(&layouts), true, "even an <a> counts if it has a TextInput callback"),
(button(&layouts), false, "a <button> is not a text input"),
(div(&layouts), false, "a plain <div> is not a text input"),
(body(&layouts), false, "the root <body> is not a text input"),
(
clickable_p(&layouts),
false,
"a non-TextInput callback must not be mistaken for a text input",
),
(disabled(&layouts), false, "a disabled control is not a text input"),
] {
assert_eq!(is_text_input(&node, &layouts), expected, "{why}");
}
}
#[test]
fn is_text_input_rejects_every_unresolvable_node_id() {
let layouts = fixture();
let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
for node in [
missing_dom(),
out_of_bounds_node(),
null_node(),
max_node(),
] {
assert!(!is_text_input(&node, &layouts));
}
assert!(!is_text_input(&textarea(&layouts), &empty));
assert!(!is_text_input(&dom_node(0), &empty));
}
#[test]
fn predicates_are_pure_and_never_both_wrong_for_the_body_root() {
let layouts = fixture();
let root = body(&layouts);
assert_eq!(
is_element_activatable(&root, &layouts),
is_element_activatable(&root, &layouts)
);
assert_eq!(is_text_input(&root, &layouts), is_text_input(&root, &layouts));
assert!(!is_element_activatable(&root, &layouts));
assert!(!is_text_input(&root, &layouts));
}
fn all_default_actions() -> Vec<DefaultAction> {
let node = dom_node(1);
let mut v = vec![
DefaultAction::FocusNext,
DefaultAction::FocusPrevious,
DefaultAction::FocusFirst,
DefaultAction::FocusLast,
DefaultAction::ClearFocus,
DefaultAction::ActivateFocusedElement { target: node },
DefaultAction::SubmitForm { form_node: node },
DefaultAction::CloseModal { modal_node: node },
DefaultAction::SelectAllText,
DefaultAction::None,
];
for direction in [
ScrollDirection::Up,
ScrollDirection::Down,
ScrollDirection::Left,
ScrollDirection::Right,
] {
for amount in [ScrollAmount::Line, ScrollAmount::Page, ScrollAmount::Document] {
v.push(scroll(direction, amount));
}
}
v
}
#[test]
fn focus_actions_map_to_their_focus_target() {
for (action, target) in [
(DefaultAction::FocusNext, FocusTarget::Next),
(DefaultAction::FocusPrevious, FocusTarget::Previous),
(DefaultAction::FocusFirst, FocusTarget::First),
(DefaultAction::FocusLast, FocusTarget::Last),
(DefaultAction::ClearFocus, FocusTarget::NoFocus),
] {
assert_eq!(default_action_to_focus_target(&action), Some(target));
}
}
#[test]
fn mapping_is_some_exactly_for_focus_actions() {
for action in all_default_actions() {
let is_focus_action = matches!(
action,
DefaultAction::FocusNext
| DefaultAction::FocusPrevious
| DefaultAction::FocusFirst
| DefaultAction::FocusLast
| DefaultAction::ClearFocus
);
let mapped = default_action_to_focus_target(&action);
assert_eq!(
mapped.is_some(),
is_focus_action,
"{action:?} must map to a FocusTarget iff it is a focus action"
);
if !is_focus_action {
assert_eq!(mapped, None);
}
}
}
#[test]
fn mapping_is_injective_over_the_focus_actions() {
let mapped: Vec<FocusTarget> = all_default_actions()
.iter()
.filter_map(default_action_to_focus_target)
.collect();
assert_eq!(mapped.len(), 5, "exactly five actions move focus");
let mut deduped = mapped.clone();
deduped.sort();
deduped.dedup();
assert_eq!(
deduped.len(),
mapped.len(),
"two different focus actions must not collapse onto the same FocusTarget"
);
}
#[test]
fn mapping_is_usable_in_a_const_context() {
const NEXT: Option<FocusTarget> = default_action_to_focus_target(&DefaultAction::FocusNext);
const NOTHING: Option<FocusTarget> =
default_action_to_focus_target(&DefaultAction::SelectAllText);
assert_eq!(NEXT, Some(FocusTarget::Next));
assert_eq!(NOTHING, None);
}
#[test]
fn key_presses_round_trip_through_to_the_focus_manager() {
let layouts = fixture();
let focus = Some(button(&layouts));
for (key, mods, expected) in [
(VirtualKeyCode::Tab, &[][..], Some(FocusTarget::Next)),
(
VirtualKeyCode::Tab,
&[VirtualKeyCode::LShift][..],
Some(FocusTarget::Previous),
),
(
VirtualKeyCode::Home,
&[VirtualKeyCode::LControl][..],
Some(FocusTarget::First),
),
(
VirtualKeyCode::End,
&[VirtualKeyCode::LControl][..],
Some(FocusTarget::Last),
),
(VirtualKeyCode::Escape, &[][..], Some(FocusTarget::NoFocus)),
(VirtualKeyCode::Return, &[][..], None),
(VirtualKeyCode::PageDown, &[][..], None),
] {
let action =
determine_keyboard_default_action(&kbd(key, mods), focus, &layouts, false).action;
assert_eq!(
default_action_to_focus_target(&action),
expected,
"{key:?} + {mods:?} round-trips to the wrong FocusTarget"
);
}
}
#[test]
fn a_prevented_key_press_never_reaches_the_focus_manager() {
let layouts = fixture();
for key in ALL_KEYS {
for mods in MOD_SETS {
let action = determine_keyboard_default_action(
&kbd(*key, mods),
Some(button(&layouts)),
&layouts,
true,
)
.action;
assert_eq!(
default_action_to_focus_target(&action),
None,
"{key:?} was prevented, so focus must not move"
);
}
}
}
}