use alloc::collections::BTreeMap;
use azul_core::{
callbacks::{FocusTarget, FocusTargetPath},
dom::{DomId, DomNodeId, NodeId},
style::matches_html_element,
styled_dom::NodeHierarchyItemId,
window::UpdateFocusWarning,
};
use crate::window::DomLayoutResult;
#[derive(Copy, Debug, Clone, PartialEq, Eq)]
pub struct PendingContentEditableFocus {
pub dom_id: DomId,
pub container_node_id: NodeId,
pub text_node_id: NodeId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FocusManager {
pub focused_node: Option<DomNodeId>,
pub pending_focus_request: Option<FocusTarget>,
pub cursor_needs_initialization: bool,
pub pending_contenteditable_focus: Option<PendingContentEditableFocus>,
}
impl Default for FocusManager {
fn default() -> Self {
Self::new()
}
}
impl FocusManager {
#[must_use] pub const fn new() -> Self {
Self {
focused_node: None,
pending_focus_request: None,
cursor_needs_initialization: false,
pending_contenteditable_focus: None,
}
}
#[must_use] pub const fn get_focused_node(&self) -> Option<&DomNodeId> {
self.focused_node.as_ref()
}
pub const fn set_focused_node(&mut self, node: Option<DomNodeId>) {
self.focused_node = node;
}
pub fn request_focus_change(&mut self, target: FocusTarget) {
self.pending_focus_request = Some(target);
}
pub const fn take_focus_request(&mut self) -> Option<FocusTarget> {
self.pending_focus_request.take()
}
pub const fn clear_focus(&mut self) {
self.focused_node = None;
}
#[must_use] pub fn has_focus(&self, node: &DomNodeId) -> bool {
self.focused_node.as_ref() == Some(node)
}
pub const fn set_pending_contenteditable_focus(
&mut self,
dom_id: DomId,
container_node_id: NodeId,
text_node_id: NodeId,
) {
self.cursor_needs_initialization = true;
self.pending_contenteditable_focus = Some(PendingContentEditableFocus {
dom_id,
container_node_id,
text_node_id,
});
}
pub const fn clear_pending_contenteditable_focus(&mut self) {
self.cursor_needs_initialization = false;
self.pending_contenteditable_focus = None;
}
pub const fn take_pending_contenteditable_focus(&mut self) -> Option<PendingContentEditableFocus> {
if self.cursor_needs_initialization {
self.cursor_needs_initialization = false;
self.pending_contenteditable_focus.take()
} else {
None
}
}
#[must_use] pub const fn needs_cursor_initialization(&self) -> bool {
self.cursor_needs_initialization
}
}
impl crate::managers::NodeIdRemap for FocusManager {
fn remap_node_ids(&mut self, dom_id: DomId, map: &crate::managers::NodeIdMap) {
if let Some(focused) = self.focused_node {
if focused.dom == dom_id {
match focused
.node
.into_crate_internal()
.and_then(|old| map.resolve(old))
{
Some(new_id) => {
self.focused_node = Some(DomNodeId {
dom: dom_id,
node: NodeHierarchyItemId::from_crate_internal(Some(new_id)),
});
}
None => self.focused_node = None,
}
}
}
if let Some(ref mut pending) = self.pending_contenteditable_focus {
if pending.dom_id != dom_id {
return;
}
if let (Some(container), Some(text)) = (
map.resolve(pending.container_node_id),
map.resolve(pending.text_node_id),
) {
pending.container_node_id = container;
pending.text_node_id = text;
} else {
self.pending_contenteditable_focus = None;
self.cursor_needs_initialization = false;
}
}
}
}
fn collect_tab_order(layout_results: &BTreeMap<DomId, DomLayoutResult>) -> Vec<DomNodeId> {
use azul_core::dom::TabIndex;
let mut positive: Vec<(u32, DomNodeId)> = Vec::new();
let mut auto: Vec<DomNodeId> = Vec::new();
for (dom_id, layout) in layout_results {
let node_data = layout.styled_dom.node_data.as_container();
for index in 0..node_data.len() {
let node_id = NodeId::new(index);
let Some(nd) = node_data.get(node_id) else {
continue;
};
if !nd.is_focusable() {
continue;
}
let dom_node = FocusSearchContext::make_dom_node_id(*dom_id, node_id);
match nd.get_tab_index() {
Some(TabIndex::NoKeyboardFocus) => {}
Some(TabIndex::OverrideInParent(n)) if n > 0 => positive.push((n, dom_node)),
_ => auto.push(dom_node),
}
}
}
order_tab_entries(positive, auto)
}
fn order_tab_entries(
mut positive: Vec<(u32, DomNodeId)>,
auto: Vec<DomNodeId>,
) -> Vec<DomNodeId> {
positive.sort_by_key(|(n, _)| *n); positive.into_iter().map(|(_, id)| id).chain(auto).collect()
}
fn doc_order_key(id: &DomNodeId) -> (usize, usize) {
(
id.dom.inner,
id.node.into_crate_internal().map_or(0, |n| n.index()),
)
}
fn next_in_tab_order(
order: &[DomNodeId],
current: Option<DomNodeId>,
forward: bool,
) -> Option<DomNodeId> {
if order.is_empty() {
return None;
}
let Some(cur) = current else {
return if forward {
order.first().copied()
} else {
order.last().copied()
};
};
if let Some(pos) = order.iter().position(|x| *x == cur) {
let len = order.len();
let next = if forward {
(pos + 1) % len
} else {
(pos + len - 1) % len
};
return Some(order[next]);
}
let cur_key = doc_order_key(&cur);
let candidate = if forward {
order
.iter()
.filter(|x| doc_order_key(x) > cur_key)
.min_by_key(|x| doc_order_key(x))
} else {
order
.iter()
.filter(|x| doc_order_key(x) < cur_key)
.max_by_key(|x| doc_order_key(x))
};
candidate.copied().or_else(|| {
if forward {
order.first().copied()
} else {
order.last().copied()
}
})
}
struct FocusSearchContext<'a> {
layout_results: &'a BTreeMap<DomId, DomLayoutResult>,
}
impl<'a> FocusSearchContext<'a> {
const fn new(layout_results: &'a BTreeMap<DomId, DomLayoutResult>) -> Self {
Self { layout_results }
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn get_layout(&self, dom_id: &DomId) -> Result<&'a DomLayoutResult, UpdateFocusWarning> {
self.layout_results
.get(dom_id)
.ok_or_else(|| UpdateFocusWarning::FocusInvalidDomId(*dom_id))
}
const fn make_dom_node_id(dom_id: DomId, node_id: NodeId) -> DomNodeId {
DomNodeId {
dom: dom_id,
node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
}
}
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn find_first_matching_focusable_node(
layout: &DomLayoutResult,
dom_id: &DomId,
css_path: &azul_css::css::CssPath,
) -> Option<DomNodeId> {
let styled_dom = &layout.styled_dom;
let node_hierarchy = styled_dom.node_hierarchy.as_container();
let node_data = styled_dom.node_data.as_container();
let cascade_info = styled_dom.cascade_info.as_container();
let matching_node = (0..node_data.len())
.map(NodeId::new)
.filter(|&node_id| {
matches_html_element(
css_path,
node_id,
&node_hierarchy,
&node_data,
&cascade_info,
None, )
})
.find(|&node_id| {
node_data[node_id].is_focusable()
});
matching_node.map(|node_id| DomNodeId {
dom: *dom_id,
node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
})
}
pub fn resolve_focus_target(
focus_target: &FocusTarget,
layout_results: &BTreeMap<DomId, DomLayoutResult>,
current_focus: Option<DomNodeId>,
) -> Result<Option<DomNodeId>, UpdateFocusWarning> {
use azul_core::callbacks::FocusTarget::{Path, Id, Previous, Next, First, Last, NoFocus};
if layout_results.is_empty() {
return Ok(None);
}
let ctx = FocusSearchContext::new(layout_results);
match focus_target {
Path(FocusTargetPath { dom, css_path }) => {
let layout = ctx.get_layout(dom)?;
Ok(find_first_matching_focusable_node(layout, dom, css_path))
}
Id(dom_node_id) => {
let layout = ctx.get_layout(&dom_node_id.dom)?;
let is_valid = dom_node_id
.node
.into_crate_internal()
.is_some_and(|n| layout.styled_dom.node_data.as_container().get(n).is_some());
if is_valid {
Ok(Some(*dom_node_id))
} else {
Err(UpdateFocusWarning::FocusInvalidNodeId(
dom_node_id.node,
))
}
}
Previous => Ok(next_in_tab_order(
&collect_tab_order(layout_results),
current_focus,
false,
)),
Next => Ok(next_in_tab_order(
&collect_tab_order(layout_results),
current_focus,
true,
)),
First => Ok(collect_tab_order(layout_results).first().copied()),
Last => Ok(collect_tab_order(layout_results).last().copied()),
NoFocus => Ok(None),
}
}
impl azul_core::events::FocusManagerQuery for FocusManager {
fn get_focused_node_id(&self) -> Option<DomNodeId> {
self.focused_node
}
}
#[cfg(test)]
mod tab_order_tests {
use super::*;
fn nid(dom: usize, node: usize) -> DomNodeId {
FocusSearchContext::make_dom_node_id(DomId { inner: dom }, NodeId::new(node))
}
#[test]
fn positive_tabindex_sorts_first_ascending_then_document_order() {
let order = order_tab_entries(
vec![(2, nid(0, 3)), (1, nid(0, 7))],
vec![nid(0, 5), nid(0, 9)],
);
assert_eq!(order, vec![nid(0, 7), nid(0, 3), nid(0, 5), nid(0, 9)]);
}
#[test]
fn equal_positive_tabindex_keeps_document_order() {
let order = order_tab_entries(vec![(1, nid(0, 2)), (1, nid(0, 8))], vec![]);
assert_eq!(order, vec![nid(0, 2), nid(0, 8)]);
}
#[test]
fn next_wraps_and_previous_wraps() {
let order = vec![nid(0, 1), nid(0, 4), nid(0, 6)];
assert_eq!(next_in_tab_order(&order, Some(nid(0, 6)), true), Some(nid(0, 1)));
assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), false), Some(nid(0, 6)));
assert_eq!(next_in_tab_order(&order, Some(nid(0, 4)), true), Some(nid(0, 6)));
}
#[test]
fn no_focus_starts_at_ends() {
let order = vec![nid(0, 1), nid(0, 4)];
assert_eq!(next_in_tab_order(&order, None, true), Some(nid(0, 1)));
assert_eq!(next_in_tab_order(&order, None, false), Some(nid(0, 4)));
}
#[test]
fn non_tab_stop_focus_reenters_in_document_order() {
let order = vec![nid(0, 1), nid(0, 4), nid(0, 6)];
assert_eq!(next_in_tab_order(&order, Some(nid(0, 5)), true), Some(nid(0, 6)));
assert_eq!(next_in_tab_order(&order, Some(nid(0, 5)), false), Some(nid(0, 4)));
assert_eq!(next_in_tab_order(&order, Some(nid(0, 9)), true), Some(nid(0, 1)));
assert_eq!(next_in_tab_order(&order, Some(nid(0, 0)), false), Some(nid(0, 6)));
}
#[test]
fn empty_order_yields_none() {
assert_eq!(next_in_tab_order(&[], Some(nid(0, 1)), true), None);
assert_eq!(next_in_tab_order(&[], None, false), None);
}
}
#[cfg(test)]
mod autotest_generated {
use std::collections::HashMap;
use azul_core::{
dom::{Dom, NodeType, TabIndex},
geom::LogicalRect,
styled_dom::StyledDom,
};
use azul_css::css::{CssPath, CssPathSelector};
use super::*;
use crate::{
managers::{NodeIdMap, NodeIdRemap},
solver3::{display_list::DisplayList, layout_tree::LayoutTree},
};
fn dom(inner: usize) -> DomId {
DomId { inner }
}
fn nid(dom_idx: usize, node: usize) -> DomNodeId {
FocusSearchContext::make_dom_node_id(dom(dom_idx), NodeId::new(node))
}
fn null_nid(dom_idx: usize) -> DomNodeId {
DomNodeId {
dom: dom(dom_idx),
node: NodeHierarchyItemId::from_crate_internal(None),
}
}
fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
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(),
}
}
fn window(entries: Vec<(DomId, StyledDom)>) -> BTreeMap<DomId, DomLayoutResult> {
entries
.into_iter()
.map(|(id, sd)| (id, layout_result(sd)))
.collect()
}
fn tab_fixture() -> StyledDom {
StyledDom::create_from_dom(
Dom::create_body()
.with_child(Dom::create_div())
.with_child(Dom::create_node(NodeType::Button))
.with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(2)))
.with_child(Dom::create_div().with_tab_index(TabIndex::NoKeyboardFocus))
.with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(1)))
.with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(0)))
.with_child(Dom::create_node(NodeType::TextArea)),
)
}
fn tab_order_of(fixture: StyledDom) -> Vec<DomNodeId> {
collect_tab_order(&window(vec![(dom(0), fixture)]))
}
fn class_path(class: &str) -> CssPath {
CssPath {
selectors: vec![CssPathSelector::Class(class.to_string().into())].into(),
}
}
#[test]
fn focus_manager_new_matches_default_and_is_fully_empty() {
let fm = FocusManager::new();
assert_eq!(fm, FocusManager::default());
assert_eq!(fm.get_focused_node(), None);
assert!(!fm.needs_cursor_initialization());
assert_eq!(fm.pending_focus_request, None);
assert_eq!(fm.pending_contenteditable_focus, None);
assert!(!fm.has_focus(&nid(0, 0)));
assert!(!fm.has_focus(&null_nid(0)));
}
#[test]
fn focus_manager_set_get_clear_focus_roundtrip() {
let mut fm = FocusManager::new();
fm.set_focused_node(Some(nid(0, 3)));
assert_eq!(fm.get_focused_node(), Some(&nid(0, 3)));
assert!(fm.has_focus(&nid(0, 3)));
fm.set_focused_node(None);
assert_eq!(fm.get_focused_node(), None);
fm.set_focused_node(Some(nid(0, 3)));
fm.clear_focus();
assert_eq!(fm.get_focused_node(), None);
assert!(!fm.has_focus(&nid(0, 3)));
fm.clear_focus();
assert_eq!(fm.get_focused_node(), None);
}
#[test]
fn focus_manager_has_focus_discriminates_both_dom_and_node() {
let mut fm = FocusManager::new();
fm.set_focused_node(Some(nid(1, 4)));
assert!(fm.has_focus(&nid(1, 4)));
assert!(!fm.has_focus(&nid(0, 4)));
assert!(!fm.has_focus(&nid(2, 4)));
assert!(!fm.has_focus(&nid(1, 3)));
assert!(!fm.has_focus(&nid(1, 5)));
assert!(!fm.has_focus(&null_nid(1)));
}
#[test]
fn focus_manager_has_focus_on_null_node_sentinel_is_exact() {
let mut fm = FocusManager::new();
fm.set_focused_node(Some(null_nid(0)));
assert!(fm.has_focus(&null_nid(0)));
assert!(!fm.has_focus(&nid(0, 0)));
assert!(!fm.has_focus(&null_nid(1)));
}
#[test]
fn focus_manager_focus_survives_extreme_node_index() {
let extreme = nid(usize::MAX, usize::MAX - 1);
let mut fm = FocusManager::new();
fm.set_focused_node(Some(extreme));
assert!(fm.has_focus(&extreme));
assert_eq!(
fm.get_focused_node()
.and_then(|n| n.node.into_crate_internal())
.map(|n| n.index()),
Some(usize::MAX - 1)
);
}
#[test]
fn focus_manager_take_focus_request_is_one_shot() {
let mut fm = FocusManager::new();
assert_eq!(fm.take_focus_request(), None);
fm.request_focus_change(FocusTarget::Next);
assert_eq!(fm.take_focus_request(), Some(FocusTarget::Next));
assert_eq!(fm.take_focus_request(), None);
assert_eq!(fm.take_focus_request(), None);
}
#[test]
fn focus_manager_request_focus_change_overwrites_pending_request() {
let mut fm = FocusManager::new();
fm.request_focus_change(FocusTarget::First);
fm.request_focus_change(FocusTarget::Last);
fm.request_focus_change(FocusTarget::NoFocus);
assert_eq!(fm.take_focus_request(), Some(FocusTarget::NoFocus));
assert_eq!(fm.take_focus_request(), None);
}
#[test]
fn focus_manager_request_focus_change_accepts_every_variant() {
let path = FocusTarget::Path(FocusTargetPath {
dom: dom(usize::MAX),
css_path: class_path("nonexistent"),
});
let targets = vec![
FocusTarget::Id(nid(0, 0)),
FocusTarget::Id(null_nid(usize::MAX)),
path,
FocusTarget::Previous,
FocusTarget::Next,
FocusTarget::First,
FocusTarget::Last,
FocusTarget::NoFocus,
];
for t in targets {
let mut fm = FocusManager::new();
fm.request_focus_change(t.clone());
assert_eq!(fm.take_focus_request(), Some(t));
}
}
#[test]
fn focus_manager_pending_contenteditable_set_then_take_is_one_shot() {
let mut fm = FocusManager::new();
assert!(!fm.needs_cursor_initialization());
assert_eq!(fm.take_pending_contenteditable_focus(), None);
fm.set_pending_contenteditable_focus(dom(2), NodeId::new(7), NodeId::new(9));
assert!(fm.needs_cursor_initialization());
assert_eq!(
fm.take_pending_contenteditable_focus(),
Some(PendingContentEditableFocus {
dom_id: dom(2),
container_node_id: NodeId::new(7),
text_node_id: NodeId::new(9),
})
);
assert!(!fm.needs_cursor_initialization());
assert_eq!(fm.take_pending_contenteditable_focus(), None);
}
#[test]
fn focus_manager_set_pending_contenteditable_overwrites_and_accepts_extremes() {
let mut fm = FocusManager::new();
fm.set_pending_contenteditable_focus(dom(0), NodeId::new(1), NodeId::new(2));
fm.set_pending_contenteditable_focus(
dom(usize::MAX),
NodeId::new(usize::MAX),
NodeId::new(usize::MAX),
);
assert_eq!(
fm.take_pending_contenteditable_focus(),
Some(PendingContentEditableFocus {
dom_id: dom(usize::MAX),
container_node_id: NodeId::new(usize::MAX),
text_node_id: NodeId::new(usize::MAX),
})
);
}
#[test]
fn focus_manager_clear_pending_contenteditable_clears_flag_and_value() {
let mut fm = FocusManager::new();
fm.set_pending_contenteditable_focus(dom(0), NodeId::new(1), NodeId::new(2));
fm.clear_pending_contenteditable_focus();
assert!(!fm.needs_cursor_initialization());
assert_eq!(fm.pending_contenteditable_focus, None);
assert_eq!(fm.take_pending_contenteditable_focus(), None);
fm.clear_pending_contenteditable_focus();
assert!(!fm.needs_cursor_initialization());
}
#[test]
fn focus_manager_take_pending_without_flag_strands_the_value() {
let mut fm = FocusManager::new();
fm.pending_contenteditable_focus = Some(PendingContentEditableFocus {
dom_id: dom(0),
container_node_id: NodeId::new(1),
text_node_id: NodeId::new(2),
});
assert!(!fm.needs_cursor_initialization());
assert_eq!(fm.take_pending_contenteditable_focus(), None);
assert!(fm.pending_contenteditable_focus.is_some());
}
#[test]
fn focus_manager_flag_without_value_take_returns_none_and_clears_flag() {
let mut fm = FocusManager::new();
fm.cursor_needs_initialization = true;
assert!(fm.needs_cursor_initialization());
assert_eq!(fm.take_pending_contenteditable_focus(), None);
assert!(!fm.needs_cursor_initialization());
}
#[test]
fn focus_manager_clear_focus_does_not_clear_pending_cursor_state() {
let mut fm = FocusManager::new();
fm.set_focused_node(Some(nid(0, 4)));
fm.set_pending_contenteditable_focus(dom(0), NodeId::new(4), NodeId::new(5));
fm.clear_focus();
assert_eq!(fm.get_focused_node(), None);
assert!(fm.needs_cursor_initialization());
assert!(fm.pending_contenteditable_focus.is_some());
}
#[test]
fn order_tab_entries_empty_inputs_yield_empty() {
assert_eq!(order_tab_entries(Vec::new(), Vec::new()), Vec::new());
}
#[test]
fn order_tab_entries_u32_max_sorts_after_smaller_positives() {
let order = order_tab_entries(
vec![
(u32::MAX, nid(0, 1)),
(1, nid(0, 2)),
(u32::MAX - 1, nid(0, 3)),
],
vec![nid(0, 4)],
);
assert_eq!(
order,
vec![nid(0, 2), nid(0, 3), nid(0, 1), nid(0, 4)],
"u32::MAX must sort last among positives, and all positives before auto"
);
}
#[test]
fn order_tab_entries_positive_always_precedes_auto() {
let order = order_tab_entries(vec![(u32::MAX, nid(9, 99))], vec![nid(0, 0), nid(0, 1)]);
assert_eq!(order[0], nid(9, 99));
assert_eq!(order.len(), 3);
}
#[test]
fn order_tab_entries_does_not_deduplicate() {
let order = order_tab_entries(
vec![(1, nid(0, 1)), (1, nid(0, 1))],
vec![nid(0, 2), nid(0, 2)],
);
assert_eq!(order, vec![nid(0, 1), nid(0, 1), nid(0, 2), nid(0, 2)]);
}
#[test]
fn doc_order_key_null_node_collides_with_node_index_zero() {
assert_eq!(doc_order_key(&null_nid(0)), (0, 0));
assert_eq!(doc_order_key(&nid(0, 0)), (0, 0));
assert_eq!(doc_order_key(&null_nid(0)), doc_order_key(&nid(0, 0)));
}
#[test]
fn doc_order_key_is_dom_major_then_arena_index() {
assert_eq!(doc_order_key(&nid(3, 7)), (3, 7));
assert!(doc_order_key(&nid(0, usize::MAX - 1)) < doc_order_key(&nid(1, 0)));
assert_eq!(doc_order_key(&nid(usize::MAX, usize::MAX - 1)), (usize::MAX, usize::MAX - 1));
}
#[test]
fn next_in_tab_order_single_entry_wraps_onto_itself() {
let order = vec![nid(0, 1)];
assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), true), Some(nid(0, 1)));
assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), false), Some(nid(0, 1)));
}
#[test]
fn next_in_tab_order_duplicate_entries_resolve_to_first_position() {
let order = vec![nid(0, 1), nid(0, 2), nid(0, 1)];
assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), true), Some(nid(0, 2)));
assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), false), Some(nid(0, 1)));
}
#[test]
fn next_in_tab_order_unknown_current_uses_cross_dom_document_order() {
let order = vec![nid(0, 5), nid(2, 1)];
assert_eq!(next_in_tab_order(&order, Some(nid(1, 0)), true), Some(nid(2, 1)));
assert_eq!(next_in_tab_order(&order, Some(nid(1, 9)), false), Some(nid(0, 5)));
}
#[test]
fn next_in_tab_order_unknown_current_past_both_ends_wraps() {
let order = vec![nid(1, 2), nid(1, 4)];
assert_eq!(next_in_tab_order(&order, Some(nid(9, 9)), true), Some(nid(1, 2)));
assert_eq!(next_in_tab_order(&order, Some(nid(0, 0)), false), Some(nid(1, 4)));
}
#[test]
fn next_in_tab_order_null_current_node_is_deterministic() {
let order = vec![nid(0, 1), nid(0, 3)];
assert_eq!(next_in_tab_order(&order, Some(null_nid(0)), true), Some(nid(0, 1)));
assert_eq!(next_in_tab_order(&order, Some(null_nid(0)), false), Some(nid(0, 3)));
}
#[test]
fn next_in_tab_order_empty_order_is_none_for_every_input() {
assert_eq!(next_in_tab_order(&[], None, true), None);
assert_eq!(next_in_tab_order(&[], None, false), None);
assert_eq!(next_in_tab_order(&[], Some(null_nid(0)), true), None);
assert_eq!(
next_in_tab_order(&[], Some(nid(usize::MAX, usize::MAX - 1)), false),
None
);
}
#[test]
fn collect_tab_order_empty_window_is_empty() {
assert_eq!(collect_tab_order(&BTreeMap::new()), Vec::new());
}
#[test]
fn collect_tab_order_dom_without_focusables_is_empty() {
let sd = StyledDom::create_from_dom(
Dom::create_body()
.with_child(Dom::create_div())
.with_child(Dom::create_div()),
);
assert_eq!(tab_order_of(sd), Vec::new());
}
#[test]
fn collect_tab_order_positives_first_then_document_order_minus_one_excluded() {
assert_eq!(
tab_order_of(tab_fixture()),
vec![nid(0, 5), nid(0, 3), nid(0, 2), nid(0, 6), nid(0, 7)],
"tabindex=1 then tabindex=2, then auto nodes in document order"
);
}
#[test]
fn collect_tab_order_excludes_tabindex_minus_one_though_it_is_focusable() {
let order = tab_order_of(tab_fixture());
assert!(!order.contains(&nid(0, 4)), "tabindex=-1 must not be a tab stop");
assert!(!order.contains(&nid(0, 1)));
assert!(!order.contains(&nid(0, 0)));
}
#[test]
fn collect_tab_order_huge_tabindex_truncates_at_28_bits() {
let sd = StyledDom::create_from_dom(
Dom::create_body()
.with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(u32::MAX)))
.with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(1 << 28)))
.with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(1)))
.with_child(Dom::create_node(NodeType::Button)),
);
assert_eq!(
tab_order_of(sd),
vec![nid(0, 3), nid(0, 1), nid(0, 2), nid(0, 4)],
"u32::MAX stays positive (sorts after tabindex=1); 1<<28 truncates to 0 and \
falls back into the auto bucket behind every positive"
);
}
#[test]
fn collect_tab_order_tab_order_is_global_across_doms() {
let order = collect_tab_order(&window(vec![
(dom(0), tab_fixture()),
(dom(1), tab_fixture()),
]));
assert_eq!(
order,
vec![
nid(0, 5),
nid(1, 5),
nid(0, 3),
nid(1, 3),
nid(0, 2),
nid(0, 6),
nid(0, 7),
nid(1, 2),
nid(1, 6),
nid(1, 7),
]
);
}
#[test]
fn focus_search_context_get_layout_hit_and_miss() {
let results = window(vec![(dom(0), tab_fixture())]);
let ctx = FocusSearchContext::new(&results);
assert!(ctx.get_layout(&dom(0)).is_ok());
assert_eq!(
ctx.get_layout(&dom(1)).err(),
Some(UpdateFocusWarning::FocusInvalidDomId(dom(1)))
);
assert_eq!(
ctx.get_layout(&dom(usize::MAX)).err(),
Some(UpdateFocusWarning::FocusInvalidDomId(dom(usize::MAX)))
);
}
#[test]
fn focus_search_context_new_on_empty_map_never_resolves() {
let empty = BTreeMap::new();
let ctx = FocusSearchContext::new(&empty);
assert_eq!(
ctx.get_layout(&dom(0)).err(),
Some(UpdateFocusWarning::FocusInvalidDomId(dom(0)))
);
}
#[test]
fn make_dom_node_id_round_trips_including_boundary_index() {
for idx in [0usize, 1, 42, usize::MAX - 1] {
let d = FocusSearchContext::make_dom_node_id(dom(7), NodeId::new(idx));
assert_eq!(d.dom, dom(7));
assert_eq!(d.node.into_crate_internal(), Some(NodeId::new(idx)));
assert_eq!(doc_order_key(&d), (7, idx));
}
}
#[test]
fn find_first_matching_skips_matching_but_unfocusable_nodes() {
let sd = StyledDom::create_from_dom(
Dom::create_body()
.with_child(Dom::create_div().with_class("target".to_string().into()))
.with_child(
Dom::create_div()
.with_class("target".to_string().into())
.with_tab_index(TabIndex::Auto),
),
);
let results = window(vec![(dom(0), sd)]);
let layout = results.get(&dom(0)).unwrap();
assert_eq!(
find_first_matching_focusable_node(layout, &dom(0), &class_path("target")),
Some(nid(0, 2))
);
}
#[test]
fn find_first_matching_empty_css_path_matches_nothing() {
let results = window(vec![(dom(0), tab_fixture())]);
let layout = results.get(&dom(0)).unwrap();
let empty = CssPath {
selectors: Vec::<CssPathSelector>::new().into(),
};
assert_eq!(
find_first_matching_focusable_node(layout, &dom(0), &empty),
None
);
}
#[test]
fn find_first_matching_unicode_class_matches_and_misses_cleanly() {
let class = "クラス-dÃa-🎯";
let sd = StyledDom::create_from_dom(
Dom::create_body().with_child(
Dom::create_div()
.with_class(class.to_string().into())
.with_tab_index(TabIndex::Auto),
),
);
let results = window(vec![(dom(0), sd)]);
let layout = results.get(&dom(0)).unwrap();
assert_eq!(
find_first_matching_focusable_node(layout, &dom(0), &class_path(class)),
Some(nid(0, 1))
);
assert_eq!(
find_first_matching_focusable_node(layout, &dom(0), &class_path("クラス-dÃa-🎲")),
None
);
let huge = "x".repeat(10_000);
assert_eq!(
find_first_matching_focusable_node(layout, &dom(0), &class_path(&huge)),
None
);
}
#[test]
fn resolve_focus_target_empty_window_short_circuits_every_variant() {
let empty = BTreeMap::new();
let targets = vec![
FocusTarget::Id(nid(usize::MAX, 0)),
FocusTarget::Id(null_nid(0)),
FocusTarget::Path(FocusTargetPath {
dom: dom(usize::MAX),
css_path: class_path("nope"),
}),
FocusTarget::Previous,
FocusTarget::Next,
FocusTarget::First,
FocusTarget::Last,
FocusTarget::NoFocus,
];
for t in targets {
assert_eq!(
resolve_focus_target(&t, &empty, Some(nid(0, 1))),
Ok(None),
"empty window must short-circuit {t:?}"
);
}
}
#[test]
fn resolve_focus_target_id_rejects_unknown_dom() {
let results = window(vec![(dom(0), tab_fixture())]);
assert_eq!(
resolve_focus_target(&FocusTarget::Id(nid(1, 2)), &results, None),
Err(UpdateFocusWarning::FocusInvalidDomId(dom(1)))
);
}
#[test]
fn resolve_focus_target_id_rejects_out_of_range_node() {
let results = window(vec![(dom(0), tab_fixture())]);
for idx in [8usize, 9, 1_000_000, usize::MAX - 1] {
let target = nid(0, idx);
assert_eq!(
resolve_focus_target(&FocusTarget::Id(target), &results, None),
Err(UpdateFocusWarning::FocusInvalidNodeId(target.node)),
"node {idx} is out of range and must be rejected"
);
}
}
#[test]
fn resolve_focus_target_id_rejects_null_node_sentinel() {
let results = window(vec![(dom(0), tab_fixture())]);
let target = null_nid(0);
assert_eq!(
resolve_focus_target(&FocusTarget::Id(target), &results, None),
Err(UpdateFocusWarning::FocusInvalidNodeId(target.node))
);
}
#[test]
fn resolve_focus_target_id_accepts_valid_but_unfocusable_node() {
let results = window(vec![(dom(0), tab_fixture())]);
assert_eq!(
resolve_focus_target(&FocusTarget::Id(nid(0, 0)), &results, None),
Ok(Some(nid(0, 0)))
);
assert_eq!(
resolve_focus_target(&FocusTarget::Id(nid(0, 4)), &results, None),
Ok(Some(nid(0, 4)))
);
}
#[test]
fn resolve_focus_target_path_rejects_unknown_dom() {
let results = window(vec![(dom(0), tab_fixture())]);
let target = FocusTarget::Path(FocusTargetPath {
dom: dom(3),
css_path: class_path("target"),
});
assert_eq!(
resolve_focus_target(&target, &results, None),
Err(UpdateFocusWarning::FocusInvalidDomId(dom(3)))
);
}
#[test]
fn resolve_focus_target_path_with_no_match_is_ok_none_not_err() {
let results = window(vec![(dom(0), tab_fixture())]);
let target = FocusTarget::Path(FocusTargetPath {
dom: dom(0),
css_path: class_path("no-such-class"),
});
assert_eq!(resolve_focus_target(&target, &results, None), Ok(None));
}
#[test]
fn resolve_focus_target_no_focus_is_always_none() {
let results = window(vec![(dom(0), tab_fixture())]);
assert_eq!(
resolve_focus_target(&FocusTarget::NoFocus, &results, Some(nid(0, 5))),
Ok(None)
);
}
#[test]
fn resolve_focus_target_first_and_last_are_the_tab_order_ends() {
let results = window(vec![(dom(0), tab_fixture())]);
assert_eq!(
resolve_focus_target(&FocusTarget::First, &results, None),
Ok(Some(nid(0, 5))),
"First must be the lowest positive tabindex, not document node 0"
);
assert_eq!(
resolve_focus_target(&FocusTarget::Last, &results, None),
Ok(Some(nid(0, 7)))
);
assert_eq!(
resolve_focus_target(&FocusTarget::First, &results, Some(nid(0, 7))),
Ok(Some(nid(0, 5)))
);
}
#[test]
fn resolve_focus_target_first_and_last_on_unfocusable_dom_are_none() {
let sd = StyledDom::create_from_dom(Dom::create_body().with_child(Dom::create_div()));
let results = window(vec![(dom(0), sd)]);
assert_eq!(resolve_focus_target(&FocusTarget::First, &results, None), Ok(None));
assert_eq!(resolve_focus_target(&FocusTarget::Last, &results, None), Ok(None));
assert_eq!(resolve_focus_target(&FocusTarget::Next, &results, None), Ok(None));
assert_eq!(
resolve_focus_target(&FocusTarget::Previous, &results, Some(nid(0, 0))),
Ok(None)
);
}
#[test]
fn resolve_focus_target_next_and_previous_wrap_around_the_tab_order() {
let results = window(vec![(dom(0), tab_fixture())]);
assert_eq!(
resolve_focus_target(&FocusTarget::Next, &results, Some(nid(0, 7))),
Ok(Some(nid(0, 5)))
);
assert_eq!(
resolve_focus_target(&FocusTarget::Previous, &results, Some(nid(0, 5))),
Ok(Some(nid(0, 7)))
);
assert_eq!(
resolve_focus_target(&FocusTarget::Next, &results, Some(nid(0, 3))),
Ok(Some(nid(0, 2)))
);
assert_eq!(
resolve_focus_target(&FocusTarget::Previous, &results, Some(nid(0, 2))),
Ok(Some(nid(0, 3)))
);
}
#[test]
fn resolve_focus_target_next_from_a_non_tab_stop_reenters_in_document_order() {
let results = window(vec![(dom(0), tab_fixture())]);
assert_eq!(
resolve_focus_target(&FocusTarget::Previous, &results, Some(nid(0, 4))),
Ok(Some(nid(0, 3)))
);
assert_eq!(
resolve_focus_target(&FocusTarget::Next, &results, Some(nid(0, 1))),
Ok(Some(nid(0, 2)))
);
}
#[test]
fn resolve_focus_target_next_from_a_stale_removed_node_never_panics() {
let results = window(vec![(dom(0), tab_fixture())]);
let stale = nid(0, 9_999);
assert_eq!(
resolve_focus_target(&FocusTarget::Next, &results, Some(stale)),
Ok(Some(nid(0, 5))),
"no tab stop past node 9999 -> wrap to the first"
);
let alien = nid(5, 1);
assert_eq!(
resolve_focus_target(&FocusTarget::Next, &results, Some(alien)),
Ok(Some(nid(0, 5)))
);
assert_eq!(
resolve_focus_target(&FocusTarget::Previous, &results, Some(alien)),
Ok(Some(nid(0, 7)))
);
}
#[test]
fn remap_rewrites_the_focused_node() {
let mut fm = FocusManager::new();
fm.set_focused_node(Some(nid(0, 5)));
fm.remap_node_ids(
dom(0),
&NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(2))]),
);
assert_eq!(fm.get_focused_node(), Some(&nid(0, 2)));
}
#[test]
fn remap_clears_focus_on_an_unmounted_node() {
let mut fm = FocusManager::new();
fm.set_focused_node(Some(nid(0, 5)));
fm.remap_node_ids(
dom(0),
&NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(1))]),
);
assert_eq!(fm.get_focused_node(), None);
}
#[test]
fn remap_leaves_other_doms_untouched() {
let mut fm = FocusManager::new();
fm.set_focused_node(Some(nid(1, 5)));
fm.remap_node_ids(
dom(0),
&NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(2))]),
);
assert_eq!(fm.get_focused_node(), Some(&nid(1, 5)));
}
#[test]
fn remap_rewrites_pending_contenteditable_focus() {
let mut fm = FocusManager::new();
fm.set_pending_contenteditable_focus(dom(0), NodeId::new(3), NodeId::new(4));
fm.remap_node_ids(
dom(0),
&NodeIdMap::from_pairs([
(NodeId::new(3), NodeId::new(10)),
(NodeId::new(4), NodeId::new(11)),
]),
);
assert!(fm.needs_cursor_initialization());
assert_eq!(
fm.take_pending_contenteditable_focus(),
Some(PendingContentEditableFocus {
dom_id: dom(0),
container_node_id: NodeId::new(10),
text_node_id: NodeId::new(11),
})
);
}
#[test]
fn remap_partially_resolvable_pending_focus_drops_it_entirely() {
for pairs in [
vec![(NodeId::new(3), NodeId::new(10))], vec![(NodeId::new(4), NodeId::new(11))], vec![], ] {
let mut fm = FocusManager::new();
fm.set_pending_contenteditable_focus(dom(0), NodeId::new(3), NodeId::new(4));
fm.remap_node_ids(dom(0), &NodeIdMap::from_pairs(pairs));
assert!(!fm.needs_cursor_initialization());
assert_eq!(fm.pending_contenteditable_focus, None);
}
}
}