Skip to main content

azul_layout/managers/
focus_cursor.rs

1//! Focus and tab navigation management.
2//!
3//! Manages keyboard focus, tab navigation, and programmatic focus changes
4//! with a recursive event system for focus/blur callbacks (max depth: 5).
5
6use alloc::collections::BTreeMap;
7
8use azul_core::{
9    callbacks::{FocusTarget, FocusTargetPath},
10    dom::{DomId, DomNodeId, NodeId},
11    style::matches_html_element,
12    styled_dom::NodeHierarchyItemId,
13    window::UpdateFocusWarning,
14};
15
16use crate::window::DomLayoutResult;
17
18/// Information about a pending contenteditable focus that needs cursor initialization
19/// after layout is complete (W3C "flag and defer" pattern).
20///
21/// This is set during focus event handling and consumed after layout pass.
22#[derive(Copy, Debug, Clone, PartialEq, Eq)]
23pub struct PendingContentEditableFocus {
24    /// The DOM where the contenteditable element is
25    pub dom_id: DomId,
26    /// The contenteditable container node that received focus
27    pub container_node_id: NodeId,
28    /// The text node where the cursor should be placed (often a child of the container)
29    pub text_node_id: NodeId,
30}
31
32/// Manager for keyboard focus and tab navigation
33///
34/// Note: Text cursor management is now handled by the separate `CursorManager`.
35///
36/// The `FocusManager` only tracks which node has focus, while `CursorManager`
37/// tracks the cursor position within that node (if it's contenteditable).
38///
39/// ## W3C Focus/Selection Model
40///
41/// The W3C model maintains a strict separation between **keyboard focus** and **selection**:
42///
43/// 1. **Focus** lands on the contenteditable container (`document.activeElement`)
44/// 2. **Selection/Cursor** is placed in a descendant text node (`Selection.focusNode`)
45///
46/// This separation requires a "flag and defer" pattern:
47/// - During focus event: Set `cursor_needs_initialization = true`
48/// - After layout pass: Call `finalize_pending_focus_changes()` to actually initialize the cursor
49///
50/// This is necessary because cursor positioning requires text layout information,
51/// which isn't available during the focus event handling phase.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct FocusManager {
54    /// Currently focused node (if any)
55    pub focused_node: Option<DomNodeId>,
56    /// Pending focus request from callback
57    pub pending_focus_request: Option<FocusTarget>,
58    
59    // --- W3C "flag and defer" pattern fields ---
60    
61    /// Flag indicating that cursor initialization is pending (set during focus, consumed after layout)
62    pub cursor_needs_initialization: bool,
63    /// Information about the pending contenteditable focus
64    pub pending_contenteditable_focus: Option<PendingContentEditableFocus>,
65}
66
67impl Default for FocusManager {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl FocusManager {
74    /// Create a new focus manager
75    #[must_use] pub const fn new() -> Self {
76        Self {
77            focused_node: None,
78            pending_focus_request: None,
79            cursor_needs_initialization: false,
80            pending_contenteditable_focus: None,
81        }
82    }
83
84    /// Get the currently focused node
85    #[must_use] pub const fn get_focused_node(&self) -> Option<&DomNodeId> {
86        self.focused_node.as_ref()
87    }
88
89    /// Set the focused node directly (used by event system)
90    ///
91    /// Note: Cursor initialization/clearing is now handled by `CursorManager`.
92    /// The event system should check if the newly focused node is contenteditable
93    /// and call `CursorManager::initialize_cursor_at_end()` if needed.
94    pub const fn set_focused_node(&mut self, node: Option<DomNodeId>) {
95        self.focused_node = node;
96    }
97
98    /// Request a focus change (to be processed by event system)
99    pub fn request_focus_change(&mut self, target: FocusTarget) {
100        self.pending_focus_request = Some(target);
101    }
102
103    /// Take the pending focus request (one-shot)
104    pub const fn take_focus_request(&mut self) -> Option<FocusTarget> {
105        self.pending_focus_request.take()
106    }
107
108    /// Clear focus
109    pub const fn clear_focus(&mut self) {
110        self.focused_node = None;
111    }
112
113    /// Check if a specific node has focus
114    #[must_use] pub fn has_focus(&self, node: &DomNodeId) -> bool {
115        self.focused_node.as_ref() == Some(node)
116    }
117    
118    // --- W3C "flag and defer" pattern methods ---
119    
120    /// Mark that cursor initialization is needed for a contenteditable element.
121    ///
122    /// This is called during focus event handling. The actual cursor initialization
123    /// happens later in `finalize_pending_focus_changes()` after layout is complete.
124    ///
125    /// # W3C Conformance
126    ///
127    /// In the W3C model, when focus lands on a contenteditable element:
128    /// 1. The focus event fires on the container element
129    /// 2. The browser's editing engine modifies the Selection to place a caret
130    /// 3. The Selection's anchorNode/focusNode point to the child text node
131    ///
132    /// Since we need layout information to position the cursor, we defer step 2+3.
133    pub const fn set_pending_contenteditable_focus(
134        &mut self,
135        dom_id: DomId,
136        container_node_id: NodeId,
137        text_node_id: NodeId,
138    ) {
139        self.cursor_needs_initialization = true;
140        self.pending_contenteditable_focus = Some(PendingContentEditableFocus {
141            dom_id,
142            container_node_id,
143            text_node_id,
144        });
145    }
146    
147    /// Clear the pending contenteditable focus (when focus moves away or is cleared).
148    pub const fn clear_pending_contenteditable_focus(&mut self) {
149        self.cursor_needs_initialization = false;
150        self.pending_contenteditable_focus = None;
151    }
152    
153    /// Take the pending contenteditable focus (consumes the flag).
154    ///
155    /// Returns `Some(info)` if cursor initialization is pending, `None` otherwise.
156    /// After calling this, `cursor_needs_initialization` is set to `false`.
157    pub const fn take_pending_contenteditable_focus(&mut self) -> Option<PendingContentEditableFocus> {
158        if self.cursor_needs_initialization {
159            self.cursor_needs_initialization = false;
160            self.pending_contenteditable_focus.take()
161        } else {
162            None
163        }
164    }
165    
166    /// Check if cursor initialization is pending.
167    #[must_use] pub const fn needs_cursor_initialization(&self) -> bool {
168        self.cursor_needs_initialization
169    }
170
171}
172
173impl crate::managers::NodeIdRemap for FocusManager {
174    /// Remap the focused node AND the pending contenteditable focus.
175    ///
176    /// Focus on an unmounted node is CLEARED (not kept — the index now denotes a
177    /// different element).
178    fn remap_node_ids(&mut self, dom_id: DomId, map: &crate::managers::NodeIdMap) {
179        // 1. currently focused node
180        if let Some(focused) = self.focused_node {
181            if focused.dom == dom_id {
182                match focused
183                    .node
184                    .into_crate_internal()
185                    .and_then(|old| map.resolve(old))
186                {
187                    Some(new_id) => {
188                        self.focused_node = Some(DomNodeId {
189                            dom: dom_id,
190                            node: NodeHierarchyItemId::from_crate_internal(Some(new_id)),
191                        });
192                    }
193                    None => self.focused_node = None,
194                }
195            }
196        }
197
198        // 2. pending contenteditable focus (set during focus handling, consumed
199        //    after layout — a DOM rebuild can land in between).
200        if let Some(ref mut pending) = self.pending_contenteditable_focus {
201            if pending.dom_id != dom_id {
202                return;
203            }
204            match (
205                map.resolve(pending.container_node_id),
206                map.resolve(pending.text_node_id),
207            ) {
208                (Some(container), Some(text)) => {
209                    pending.container_node_id = container;
210                    pending.text_node_id = text;
211                }
212                _ => {
213                    self.pending_contenteditable_focus = None;
214                    self.cursor_needs_initialization = false;
215                }
216            }
217        }
218    }
219}
220
221/// MWA-C-focus_cursor: W3C sequential focus order over all DOMs.
222///
223/// Ordering: nodes with a positive `tabindex` (`TabIndex::OverrideInParent(n)`,
224/// n >= 1) come first, ascending by n (stable sort, so document order breaks
225/// ties); then all remaining keyboard-focusable nodes (`Auto`,
226/// `OverrideInParent(0)`, implicit focusables) in document order.
227/// `TabIndex::NoKeyboardFocus` (tabindex=-1) nodes stay focusable by click /
228/// API but are NEVER part of the Tab order. The previous linear `NodeId` walk
229/// both ignored positive-tabindex ordering and tabbed onto tabindex=-1 nodes.
230fn collect_tab_order(layout_results: &BTreeMap<DomId, DomLayoutResult>) -> Vec<DomNodeId> {
231    use azul_core::dom::TabIndex;
232    let mut positive: Vec<(u32, DomNodeId)> = Vec::new();
233    let mut auto: Vec<DomNodeId> = Vec::new();
234    for (dom_id, layout) in layout_results {
235        let node_data = layout.styled_dom.node_data.as_container();
236        for index in 0..node_data.len() {
237            let node_id = NodeId::new(index);
238            let Some(nd) = node_data.get(node_id) else {
239                continue;
240            };
241            if !nd.is_focusable() {
242                continue;
243            }
244            let dom_node = FocusSearchContext::make_dom_node_id(*dom_id, node_id);
245            match nd.get_tab_index() {
246                Some(TabIndex::NoKeyboardFocus) => {}
247                Some(TabIndex::OverrideInParent(n)) if n > 0 => positive.push((n, dom_node)),
248                _ => auto.push(dom_node),
249            }
250        }
251    }
252    order_tab_entries(positive, auto)
253}
254
255/// Pure merge of the two tab-order sections (split out for unit testing).
256fn order_tab_entries(
257    mut positive: Vec<(u32, DomNodeId)>,
258    auto: Vec<DomNodeId>,
259) -> Vec<DomNodeId> {
260    positive.sort_by_key(|(n, _)| *n); // stable: document order within equal n
261    positive.into_iter().map(|(_, id)| id).chain(auto).collect()
262}
263
264/// Document-order key for a node (DOM index, then arena index) — used to
265/// re-enter the tab order from a node that is not itself tab-focusable.
266fn doc_order_key(id: &DomNodeId) -> (usize, usize) {
267    (
268        id.dom.inner,
269        id.node.into_crate_internal().map_or(0, |n| n.index()),
270    )
271}
272
273/// Pick the next / previous entry in `order` relative to `current`.
274///
275/// If `current` is a tab stop, steps with wrap-around. If it is not (no focus
276/// yet, or focus sits on a tabindex=-1 / removed node), forward picks the
277/// first tab stop after it in document order (wrapping to the first entry),
278/// backward symmetrically.
279fn next_in_tab_order(
280    order: &[DomNodeId],
281    current: Option<DomNodeId>,
282    forward: bool,
283) -> Option<DomNodeId> {
284    if order.is_empty() {
285        return None;
286    }
287    let Some(cur) = current else {
288        return if forward {
289            order.first().copied()
290        } else {
291            order.last().copied()
292        };
293    };
294    if let Some(pos) = order.iter().position(|x| *x == cur) {
295        let len = order.len();
296        let next = if forward {
297            (pos + 1) % len
298        } else {
299            (pos + len - 1) % len
300        };
301        return Some(order[next]);
302    }
303    let cur_key = doc_order_key(&cur);
304    let candidate = if forward {
305        order
306            .iter()
307            .filter(|x| doc_order_key(x) > cur_key)
308            .min_by_key(|x| doc_order_key(x))
309    } else {
310        order
311            .iter()
312            .filter(|x| doc_order_key(x) < cur_key)
313            .max_by_key(|x| doc_order_key(x))
314    };
315    candidate.copied().or_else(|| {
316        if forward {
317            order.first().copied()
318        } else {
319            order.last().copied()
320        }
321    })
322}
323
324/// Context for focus-target resolution (`Path` / `Id` lookups).
325///
326/// MWA-C-focus_cursor: the old linear-walk machinery (`SearchDirection`,
327/// `search_focusable_node`, `get_*_start`) was replaced by the W3C tab order
328/// built in `collect_tab_order`; only the layout lookup helpers remain.
329struct FocusSearchContext<'a> {
330    /// Reference to all DOM layouts in the window
331    layout_results: &'a BTreeMap<DomId, DomLayoutResult>,
332}
333
334impl<'a> FocusSearchContext<'a> {
335    /// Create a new search context from layout results.
336    const fn new(layout_results: &'a BTreeMap<DomId, DomLayoutResult>) -> Self {
337        Self { layout_results }
338    }
339
340    /// Get the layout for a DOM ID, or return an error if invalid.
341    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
342    fn get_layout(&self, dom_id: &DomId) -> Result<&'a DomLayoutResult, UpdateFocusWarning> {
343        self.layout_results
344            .get(dom_id)
345            .ok_or_else(|| UpdateFocusWarning::FocusInvalidDomId(*dom_id))
346    }
347
348    /// Construct a `DomNodeId` from DOM and node IDs.
349    const fn make_dom_node_id(dom_id: DomId, node_id: NodeId) -> DomNodeId {
350        DomNodeId {
351            dom: dom_id,
352            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
353        }
354    }
355}
356
357/// Find the first focusable node matching a CSS path selector.
358///
359/// Iterates through all nodes in the DOM in document order (index 0..n),
360/// and returns the first node that:
361///
362/// 1. Matches the CSS path selector
363/// 2. Is focusable (has `tabindex` or is naturally focusable)
364///
365/// # Returns
366///
367/// * `Ok(Some(node))` - Found a matching focusable node
368/// * `Ok(None)` - No matching focusable node exists
369/// * `Err(_)` - CSS path could not be matched (malformed selector)
370#[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
371fn find_first_matching_focusable_node(
372    layout: &DomLayoutResult,
373    dom_id: &DomId,
374    css_path: &azul_css::css::CssPath,
375) -> Option<DomNodeId> {
376    let styled_dom = &layout.styled_dom;
377    let node_hierarchy = styled_dom.node_hierarchy.as_container();
378    let node_data = styled_dom.node_data.as_container();
379    let cascade_info = styled_dom.cascade_info.as_container();
380
381    // Iterate through all nodes in document order
382    let matching_node = (0..node_data.len())
383        .map(NodeId::new)
384        .filter(|&node_id| {
385            // Check if node matches the CSS path (no pseudo-selector requirement)
386            matches_html_element(
387                css_path,
388                node_id,
389                &node_hierarchy,
390                &node_data,
391                &cascade_info,
392                None, // No expected pseudo-selector ending like :hover/:focus
393            )
394        })
395        .find(|&node_id| {
396            // Among matching nodes, find first that is focusable
397            node_data[node_id].is_focusable()
398        });
399
400    matching_node.map(|node_id| DomNodeId {
401        dom: *dom_id,
402        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
403    })
404}
405
406/// Resolve a `FocusTarget` to an actual `DomNodeId`
407/// # Errors
408///
409/// Returns an `UpdateFocusWarning` if the focus target cannot be resolved.
410pub fn resolve_focus_target(
411    focus_target: &FocusTarget,
412    layout_results: &BTreeMap<DomId, DomLayoutResult>,
413    current_focus: Option<DomNodeId>,
414) -> Result<Option<DomNodeId>, UpdateFocusWarning> {
415    use azul_core::callbacks::FocusTarget::{Path, Id, Previous, Next, First, Last, NoFocus};
416
417    if layout_results.is_empty() {
418        return Ok(None);
419    }
420
421    let ctx = FocusSearchContext::new(layout_results);
422
423    match focus_target {
424        Path(FocusTargetPath { dom, css_path }) => {
425            let layout = ctx.get_layout(dom)?;
426            Ok(find_first_matching_focusable_node(layout, dom, css_path))
427        }
428
429        Id(dom_node_id) => {
430            let layout = ctx.get_layout(&dom_node_id.dom)?;
431            let is_valid = dom_node_id
432                .node
433                .into_crate_internal()
434                .is_some_and(|n| layout.styled_dom.node_data.as_container().get(n).is_some());
435
436            if is_valid {
437                Ok(Some(*dom_node_id))
438            } else {
439                Err(UpdateFocusWarning::FocusInvalidNodeId(
440                    dom_node_id.node,
441                ))
442            }
443        }
444
445        // MWA-C-focus_cursor: sequential navigation goes through the W3C tab
446        // order (positive tabindex ascending, then document order; -1
447        // excluded) instead of the old raw-NodeId walk.
448        Previous => Ok(next_in_tab_order(
449            &collect_tab_order(layout_results),
450            current_focus,
451            false,
452        )),
453
454        Next => Ok(next_in_tab_order(
455            &collect_tab_order(layout_results),
456            current_focus,
457            true,
458        )),
459
460        First => Ok(collect_tab_order(layout_results).first().copied()),
461
462        Last => Ok(collect_tab_order(layout_results).last().copied()),
463
464        NoFocus => Ok(None),
465    }
466}
467
468// Trait Implementations for Event Filtering
469
470impl azul_core::events::FocusManagerQuery for FocusManager {
471    fn get_focused_node_id(&self) -> Option<DomNodeId> {
472        self.focused_node
473    }
474}
475
476#[cfg(test)]
477mod tab_order_tests {
478    use super::*;
479
480    fn nid(dom: usize, node: usize) -> DomNodeId {
481        FocusSearchContext::make_dom_node_id(DomId { inner: dom }, NodeId::new(node))
482    }
483
484    #[test]
485    fn positive_tabindex_sorts_first_ascending_then_document_order() {
486        // Document order: n3 (tabindex=2), n5 (auto), n7 (tabindex=1), n9 (auto)
487        let order = order_tab_entries(
488            vec![(2, nid(0, 3)), (1, nid(0, 7))],
489            vec![nid(0, 5), nid(0, 9)],
490        );
491        assert_eq!(order, vec![nid(0, 7), nid(0, 3), nid(0, 5), nid(0, 9)]);
492    }
493
494    #[test]
495    fn equal_positive_tabindex_keeps_document_order() {
496        let order = order_tab_entries(vec![(1, nid(0, 2)), (1, nid(0, 8))], vec![]);
497        assert_eq!(order, vec![nid(0, 2), nid(0, 8)]);
498    }
499
500    #[test]
501    fn next_wraps_and_previous_wraps() {
502        let order = vec![nid(0, 1), nid(0, 4), nid(0, 6)];
503        assert_eq!(next_in_tab_order(&order, Some(nid(0, 6)), true), Some(nid(0, 1)));
504        assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), false), Some(nid(0, 6)));
505        assert_eq!(next_in_tab_order(&order, Some(nid(0, 4)), true), Some(nid(0, 6)));
506    }
507
508    #[test]
509    fn no_focus_starts_at_ends() {
510        let order = vec![nid(0, 1), nid(0, 4)];
511        assert_eq!(next_in_tab_order(&order, None, true), Some(nid(0, 1)));
512        assert_eq!(next_in_tab_order(&order, None, false), Some(nid(0, 4)));
513    }
514
515    #[test]
516    fn non_tab_stop_focus_reenters_in_document_order() {
517        // Focus sits on a tabindex=-1 node (0,5): Tab goes to the next tab
518        // stop in document order (0,6); Shift+Tab to the previous one (0,4).
519        let order = vec![nid(0, 1), nid(0, 4), nid(0, 6)];
520        assert_eq!(next_in_tab_order(&order, Some(nid(0, 5)), true), Some(nid(0, 6)));
521        assert_eq!(next_in_tab_order(&order, Some(nid(0, 5)), false), Some(nid(0, 4)));
522        // Past the last stop: wraps to first / last respectively.
523        assert_eq!(next_in_tab_order(&order, Some(nid(0, 9)), true), Some(nid(0, 1)));
524        assert_eq!(next_in_tab_order(&order, Some(nid(0, 0)), false), Some(nid(0, 6)));
525    }
526
527    #[test]
528    fn empty_order_yields_none() {
529        assert_eq!(next_in_tab_order(&[], Some(nid(0, 1)), true), None);
530        assert_eq!(next_in_tab_order(&[], None, false), None);
531    }
532}