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

            
6
use alloc::collections::BTreeMap;
7

            
8
use 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

            
16
use 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)]
23
pub 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)]
53
pub 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
    // --- focus-before-first-layout retry queue ---
67

            
68
    /// A [`FocusTarget`] that could not be resolved because no layout existed
69
    /// yet, kept so it can be re-resolved as soon as one does.
70
    ///
71
    /// `resolve_focus_target` answers `Ok(None)` with empty `layout_results`,
72
    /// and every caller reads `Ok(None)` as "clear focus" — so a programmatic
73
    /// `set_focus` issued from a `create` callback (which runs BEFORE the first
74
    /// layout) vanished without a trace, and apps papered over it with a
75
    /// short timer. Drained by
76
    /// `LayoutWindow::finalize_pending_focus_changes`, which runs after the
77
    /// layout pass.
78
    pub deferred_focus_target: Option<FocusTarget>,
79
    /// How many times the pending contenteditable focus has been re-armed for
80
    /// want of a text layout. Bounded so a node that will never have an inline
81
    /// layout cannot re-arm forever.
82
    pub pending_focus_retries: u8,
83
}
84

            
85
/// How many times [`FocusManager::pending_contenteditable_focus`] may be put
86
/// back because the text layout was not available yet.
87
pub const MAX_PENDING_FOCUS_RETRIES: u8 = 2;
88

            
89
/// Outcome of resolving a [`FocusTarget`] through
90
/// [`resolve_focus_target_or_defer`].
91
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92
pub enum FocusResolution {
93
    /// The target resolved. `None` means "clear focus" (`FocusTarget::NoFocus`,
94
    /// or a selector that matched nothing focusable) and MUST be applied.
95
    Resolved(Option<DomNodeId>),
96
    /// No layout existed yet, so the target was retained on the
97
    /// [`FocusManager`] and will be re-resolved after the first layout pass.
98
    /// Callers must leave the current focus alone.
99
    Deferred,
100
}
101

            
102
impl Default for FocusManager {
103
1
    fn default() -> Self {
104
1
        Self::new()
105
1
    }
106
}
107

            
108
impl FocusManager {
109
    /// Create a new focus manager
110
5753
    #[must_use] pub const fn new() -> Self {
111
5753
        Self {
112
5753
            focused_node: None,
113
5753
            pending_focus_request: None,
114
5753
            cursor_needs_initialization: false,
115
5753
            pending_contenteditable_focus: None,
116
5753
            deferred_focus_target: None,
117
5753
            pending_focus_retries: 0,
118
5753
        }
119
5753
    }
120

            
121
    /// Get the currently focused node
122
26463
    #[must_use] pub const fn get_focused_node(&self) -> Option<&DomNodeId> {
123
26463
        self.focused_node.as_ref()
124
26463
    }
125

            
126
    /// Set the focused node directly (used by event system)
127
    ///
128
    /// Note: Cursor initialization/clearing is now handled by `CursorManager`.
129
    /// The event system should check if the newly focused node is contenteditable
130
    /// and call `CursorManager::initialize_cursor_at_end()` if needed.
131
632
    pub const fn set_focused_node(&mut self, node: Option<DomNodeId>) {
132
632
        self.focused_node = node;
133
632
    }
134

            
135
    /// Request a focus change (to be processed by event system)
136
12
    pub fn request_focus_change(&mut self, target: FocusTarget) {
137
12
        self.pending_focus_request = Some(target);
138
12
    }
139

            
140
    /// Take the pending focus request (one-shot)
141
14
    pub const fn take_focus_request(&mut self) -> Option<FocusTarget> {
142
14
        self.pending_focus_request.take()
143
14
    }
144

            
145
    /// Clear focus
146
4
    pub const fn clear_focus(&mut self) {
147
4
        self.focused_node = None;
148
4
    }
149

            
150
    /// Check if a specific node has focus
151
73
    #[must_use] pub fn has_focus(&self, node: &DomNodeId) -> bool {
152
73
        self.focused_node.as_ref() == Some(node)
153
73
    }
154
    
155
    // --- W3C "flag and defer" pattern methods ---
156
    
157
    /// Mark that cursor initialization is needed for a contenteditable element.
158
    ///
159
    /// This is called during focus event handling. The actual cursor initialization
160
    /// happens later in `finalize_pending_focus_changes()` after layout is complete.
161
    ///
162
    /// # W3C Conformance
163
    ///
164
    /// In the W3C model, when focus lands on a contenteditable element:
165
    /// 1. The focus event fires on the container element
166
    /// 2. The browser's editing engine modifies the Selection to place a caret
167
    /// 3. The Selection's anchorNode/focusNode point to the child text node
168
    ///
169
    /// Since we need layout information to position the cursor, we defer step 2+3.
170
68
    pub const fn set_pending_contenteditable_focus(
171
68
        &mut self,
172
68
        dom_id: DomId,
173
68
        container_node_id: NodeId,
174
68
        text_node_id: NodeId,
175
68
    ) {
176
68
        self.cursor_needs_initialization = true;
177
68
        self.pending_focus_retries = 0;
178
68
        self.pending_contenteditable_focus = Some(PendingContentEditableFocus {
179
68
            dom_id,
180
68
            container_node_id,
181
68
            text_node_id,
182
68
        });
183
68
    }
184

            
185
    /// Put a just-taken pending contenteditable focus BACK because the text
186
    /// layout it needs did not exist yet. Returns `false` once
187
    /// [`MAX_PENDING_FOCUS_RETRIES`] is exhausted, in which case the caller
188
    /// must seed the cursor with whatever it has.
189
27
    pub const fn rearm_pending_contenteditable_focus(
190
27
        &mut self,
191
27
        pending: PendingContentEditableFocus,
192
27
    ) -> bool {
193
27
        if self.pending_focus_retries >= MAX_PENDING_FOCUS_RETRIES {
194
9
            return false;
195
18
        }
196
18
        self.pending_focus_retries += 1;
197
18
        self.cursor_needs_initialization = true;
198
18
        self.pending_contenteditable_focus = Some(pending);
199
18
        true
200
27
    }
201

            
202
    /// Clear the pending contenteditable focus (when focus moves away or is cleared).
203
31
    pub const fn clear_pending_contenteditable_focus(&mut self) {
204
31
        self.cursor_needs_initialization = false;
205
31
        self.pending_focus_retries = 0;
206
31
        self.pending_contenteditable_focus = None;
207
31
    }
208
    
209
    /// Take the pending contenteditable focus (consumes the flag).
210
    ///
211
    /// Returns `Some(info)` if cursor initialization is pending, `None` otherwise.
212
    /// After calling this, `cursor_needs_initialization` is set to `false`.
213
132
    pub const fn take_pending_contenteditable_focus(&mut self) -> Option<PendingContentEditableFocus> {
214
132
        if self.cursor_needs_initialization {
215
69
            self.cursor_needs_initialization = false;
216
69
            self.pending_contenteditable_focus.take()
217
        } else {
218
63
            None
219
        }
220
132
    }
221
    
222
    /// Check if cursor initialization is pending.
223
17
    #[must_use] pub const fn needs_cursor_initialization(&self) -> bool {
224
17
        self.cursor_needs_initialization
225
17
    }
226

            
227
    // --- focus-before-first-layout retry queue ---
228

            
229
    /// Retain a focus target that had no layout to resolve against.
230
    ///
231
    /// A later request replaces an earlier one: only the most recent
232
    /// programmatic focus can win once layout arrives, exactly as it would if
233
    /// both had been resolvable immediately.
234
9
    pub fn defer_focus_target(&mut self, target: FocusTarget) {
235
9
        self.deferred_focus_target = Some(target);
236
9
    }
237

            
238
    /// Whether a focus target is waiting for the first layout.
239
243
    #[must_use] pub const fn has_deferred_focus_target(&self) -> bool {
240
243
        self.deferred_focus_target.is_some()
241
243
    }
242

            
243
    /// Take the deferred focus target (one-shot).
244
9
    pub const fn take_deferred_focus_target(&mut self) -> Option<FocusTarget> {
245
9
        self.deferred_focus_target.take()
246
9
    }
247
}
248

            
249
impl crate::managers::NodeIdRemap for FocusManager {
250
    /// Remap the focused node AND the pending contenteditable focus.
251
    ///
252
    /// Focus on an unmounted node is CLEARED (not kept — the index now denotes a
253
    /// different element).
254
33
    fn remap_node_ids(&mut self, dom_id: DomId, map: &crate::managers::NodeIdMap) {
255
        // 1. currently focused node
256
33
        if let Some(focused) = self.focused_node {
257
5
            if focused.dom == dom_id {
258
4
                match focused
259
4
                    .node
260
4
                    .into_crate_internal()
261
4
                    .and_then(|old| map.resolve(old))
262
                {
263
2
                    Some(new_id) => {
264
2
                        self.focused_node = Some(DomNodeId {
265
2
                            dom: dom_id,
266
2
                            node: NodeHierarchyItemId::from_crate_internal(Some(new_id)),
267
2
                        });
268
2
                    }
269
2
                    None => self.focused_node = None,
270
                }
271
1
            }
272
28
        }
273

            
274
        // 2. pending contenteditable focus (set during focus handling, consumed
275
        //    after layout — a DOM rebuild can land in between).
276
33
        if let Some(ref mut pending) = self.pending_contenteditable_focus {
277
4
            if pending.dom_id != dom_id {
278
                return;
279
4
            }
280
1
            if let (Some(container), Some(text)) = (
281
4
                map.resolve(pending.container_node_id),
282
4
                map.resolve(pending.text_node_id),
283
1
            ) {
284
1
                pending.container_node_id = container;
285
1
                pending.text_node_id = text;
286
3
            } else {
287
3
                self.pending_contenteditable_focus = None;
288
3
                self.cursor_needs_initialization = false;
289
3
            }
290
29
        }
291
33
    }
292
}
293

            
294
/// MWA-C-focus_cursor: W3C sequential focus order over all DOMs.
295
///
296
/// Ordering: nodes with a positive `tabindex` (`TabIndex::OverrideInParent(n)`,
297
/// n >= 1) come first, ascending by n (stable sort, so document order breaks
298
/// ties); then all remaining keyboard-focusable nodes (`Auto`,
299
/// `OverrideInParent(0)`, implicit focusables) in document order.
300
/// `TabIndex::NoKeyboardFocus` (tabindex=-1) nodes stay focusable by click /
301
/// API but are NEVER part of the Tab order. The previous linear `NodeId` walk
302
/// both ignored positive-tabindex ordering and tabbed onto tabindex=-1 nodes.
303
35
fn collect_tab_order(layout_results: &BTreeMap<DomId, DomLayoutResult>) -> Vec<DomNodeId> {
304
    use azul_core::dom::TabIndex;
305
35
    let mut positive: Vec<(u32, DomNodeId)> = Vec::new();
306
35
    let mut auto: Vec<DomNodeId> = Vec::new();
307
70
    for (dom_id, layout) in layout_results {
308
35
        let node_data = layout.styled_dom.node_data.as_container();
309
374
        for index in 0..node_data.len() {
310
374
            let node_id = NodeId::new(index);
311
374
            let Some(nd) = node_data.get(node_id) else {
312
                continue;
313
            };
314
374
            if !nd.is_focusable() {
315
239
                continue;
316
135
            }
317
135
            let dom_node = FocusSearchContext::make_dom_node_id(*dom_id, node_id);
318
135
            match nd.get_tab_index() {
319
16
                Some(TabIndex::NoKeyboardFocus) => {}
320
51
                Some(TabIndex::OverrideInParent(n)) if n > 0 => positive.push((n, dom_node)),
321
85
                _ => auto.push(dom_node),
322
            }
323
        }
324
    }
325
35
    order_tab_entries(positive, auto)
326
35
}
327

            
328
/// Pure merge of the two tab-order sections (split out for unit testing).
329
41
fn order_tab_entries(
330
41
    mut positive: Vec<(u32, DomNodeId)>,
331
41
    auto: Vec<DomNodeId>,
332
41
) -> Vec<DomNodeId> {
333
41
    positive.sort_by_key(|(n, _)| *n); // stable: document order within equal n
334
41
    positive.into_iter().map(|(_, id)| id).chain(auto).collect()
335
41
}
336

            
337
/// Document-order key for a node (DOM index, then arena index) — used to
338
/// re-enter the tab order from a node that is not itself tab-focusable.
339
95
fn doc_order_key(id: &DomNodeId) -> (usize, usize) {
340
    (
341
95
        id.dom.inner,
342
95
        id.node.into_crate_internal().map_or(0, |n| n.index()),
343
    )
344
95
}
345

            
346
/// Pick the next / previous entry in `order` relative to `current`.
347
///
348
/// If `current` is a tab stop, steps with wrap-around. If it is not (no focus
349
/// yet, or focus sits on a tabindex=-1 / removed node), forward picks the
350
/// first tab stop after it in document order (wrapping to the first entry),
351
/// backward symmetrically.
352
49
fn next_in_tab_order(
353
49
    order: &[DomNodeId],
354
49
    current: Option<DomNodeId>,
355
49
    forward: bool,
356
49
) -> Option<DomNodeId> {
357
49
    if order.is_empty() {
358
8
        return None;
359
41
    }
360
41
    let Some(cur) = current else {
361
8
        return if forward {
362
7
            order.first().copied()
363
        } else {
364
1
            order.last().copied()
365
        };
366
    };
367
82
    if let Some(pos) = order.iter().position(|x| *x == cur) {
368
18
        let len = order.len();
369
18
        let next = if forward {
370
12
            (pos + 1) % len
371
        } else {
372
6
            (pos + len - 1) % len
373
        };
374
18
        return Some(order[next]);
375
15
    }
376
15
    let cur_key = doc_order_key(&cur);
377
15
    let candidate = if forward {
378
8
        order
379
8
            .iter()
380
27
            .filter(|x| doc_order_key(x) > cur_key)
381
9
            .min_by_key(|x| doc_order_key(x))
382
    } else {
383
7
        order
384
7
            .iter()
385
22
            .filter(|x| doc_order_key(x) < cur_key)
386
10
            .max_by_key(|x| doc_order_key(x))
387
    };
388
15
    candidate.copied().or_else(|| {
389
7
        if forward {
390
4
            order.first().copied()
391
        } else {
392
3
            order.last().copied()
393
        }
394
7
    })
395
49
}
396

            
397
/// Context for focus-target resolution (`Path` / `Id` lookups).
398
///
399
/// MWA-C-focus_cursor: the old linear-walk machinery (`SearchDirection`,
400
/// `search_focusable_node`, `get_*_start`) was replaced by the W3C tab order
401
/// built in `collect_tab_order`; only the layout lookup helpers remain.
402
struct FocusSearchContext<'a> {
403
    /// Reference to all DOM layouts in the window
404
    layout_results: &'a BTreeMap<DomId, DomLayoutResult>,
405
}
406

            
407
impl<'a> FocusSearchContext<'a> {
408
    /// Create a new search context from layout results.
409
55
    const fn new(layout_results: &'a BTreeMap<DomId, DomLayoutResult>) -> Self {
410
55
        Self { layout_results }
411
55
    }
412

            
413
    /// Get the layout for a DOM ID, or return an error if invalid.
414
    #[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)
415
27
    fn get_layout(&self, dom_id: &DomId) -> Result<&'a DomLayoutResult, UpdateFocusWarning> {
416
27
        self.layout_results
417
27
            .get(dom_id)
418
27
            .ok_or_else(|| UpdateFocusWarning::FocusInvalidDomId(*dom_id))
419
27
    }
420

            
421
    /// Construct a `DomNodeId` from DOM and node IDs.
422
317
    const fn make_dom_node_id(dom_id: DomId, node_id: NodeId) -> DomNodeId {
423
317
        DomNodeId {
424
317
            dom: dom_id,
425
317
            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
426
317
        }
427
317
    }
428
}
429

            
430
/// Find the first focusable node matching a CSS path selector.
431
///
432
/// Iterates through all nodes in the DOM in document order (index 0..n),
433
/// and returns the first node that:
434
///
435
/// 1. Matches the CSS path selector
436
/// 2. Is focusable (has `tabindex` or is naturally focusable)
437
///
438
/// # Returns
439
///
440
/// * `Ok(Some(node))` - Found a matching focusable node
441
/// * `Ok(None)` - No matching focusable node exists
442
/// * `Err(_)` - CSS path could not be matched (malformed selector)
443
#[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)
444
6
fn find_first_matching_focusable_node(
445
6
    layout: &DomLayoutResult,
446
6
    dom_id: &DomId,
447
6
    css_path: &azul_css::css::CssPath,
448
6
) -> Option<DomNodeId> {
449
6
    let styled_dom = &layout.styled_dom;
450
6
    let node_hierarchy = styled_dom.node_hierarchy.as_container();
451
6
    let node_data = styled_dom.node_data.as_container();
452
6
    let cascade_info = styled_dom.cascade_info.as_container();
453

            
454
    // Iterate through all nodes in document order
455
6
    let matching_node = (0..node_data.len())
456
6
        .map(NodeId::new)
457
25
        .filter(|&node_id| {
458
            // Check if node matches the CSS path (no pseudo-selector requirement)
459
25
            matches_html_element(
460
25
                css_path,
461
25
                node_id,
462
25
                &node_hierarchy,
463
25
                &node_data,
464
25
                &cascade_info,
465
25
                None, // No expected pseudo-selector ending like :hover/:focus
466
            )
467
25
        })
468
6
        .find(|&node_id| {
469
            // Among matching nodes, find first that is focusable
470
3
            node_data[node_id].is_focusable()
471
3
        });
472

            
473
6
    matching_node.map(|node_id| DomNodeId {
474
2
        dom: *dom_id,
475
2
        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
476
2
    })
477
6
}
478

            
479
/// Resolve a `FocusTarget`, or QUEUE it when there is no layout to resolve
480
/// against yet.
481
///
482
/// This is the entry point every focus-changing caller should use.
483
/// [`resolve_focus_target`] cannot tell "nothing matched" from "nothing exists
484
/// yet": both are `Ok(None)`, and callers apply that as "clear focus". A
485
/// `set_focus` issued from a `create` callback — which runs before the first
486
/// layout — was therefore dropped on the floor.
487
///
488
/// # Errors
489
///
490
/// Returns an `UpdateFocusWarning` if the focus target cannot be resolved.
491
13
pub fn resolve_focus_target_or_defer(
492
13
    focus_manager: &mut FocusManager,
493
13
    focus_target: &FocusTarget,
494
13
    layout_results: &BTreeMap<DomId, DomLayoutResult>,
495
13
) -> Result<FocusResolution, UpdateFocusWarning> {
496
    // `NoFocus` means the app WANTS focus cleared; that is answerable without
497
    // any layout and must not be queued (it would then fire later, clearing a
498
    // focus the app had meanwhile set).
499
13
    if layout_results.is_empty() && !matches!(focus_target, FocusTarget::NoFocus) {
500
9
        focus_manager.defer_focus_target(focus_target.clone());
501
9
        return Ok(FocusResolution::Deferred);
502
4
    }
503

            
504
4
    let current_focus = focus_manager.get_focused_node().copied();
505
4
    resolve_focus_target(focus_target, layout_results, current_focus).map(FocusResolution::Resolved)
506
13
}
507

            
508
/// Resolve a `FocusTarget` to an actual `DomNodeId`
509
///
510
/// Prefer [`resolve_focus_target_or_defer`]: with empty `layout_results` this
511
/// answers `Ok(None)`, which is indistinguishable from "clear focus".
512
///
513
/// # Errors
514
///
515
/// Returns an `UpdateFocusWarning` if the focus target cannot be resolved.
516
61
pub fn resolve_focus_target(
517
61
    focus_target: &FocusTarget,
518
61
    layout_results: &BTreeMap<DomId, DomLayoutResult>,
519
61
    current_focus: Option<DomNodeId>,
520
61
) -> Result<Option<DomNodeId>, UpdateFocusWarning> {
521
    use azul_core::callbacks::FocusTarget::{Path, Id, Previous, Next, First, Last, NoFocus};
522

            
523
61
    if layout_results.is_empty() {
524
8
        return Ok(None);
525
53
    }
526

            
527
53
    let ctx = FocusSearchContext::new(layout_results);
528

            
529
53
    match focus_target {
530
2
        Path(FocusTargetPath { dom, css_path }) => {
531
2
            let layout = ctx.get_layout(dom)?;
532
1
            Ok(find_first_matching_focusable_node(layout, dom, css_path))
533
        }
534

            
535
21
        Id(dom_node_id) => {
536
21
            let layout = ctx.get_layout(&dom_node_id.dom)?;
537
20
            let is_valid = dom_node_id
538
20
                .node
539
20
                .into_crate_internal()
540
20
                .is_some_and(|n| layout.styled_dom.node_data.as_container().get(n).is_some());
541

            
542
20
            if is_valid {
543
15
                Ok(Some(*dom_node_id))
544
            } else {
545
5
                Err(UpdateFocusWarning::FocusInvalidNodeId(
546
5
                    dom_node_id.node,
547
5
                ))
548
            }
549
        }
550

            
551
        // MWA-C-focus_cursor: sequential navigation goes through the W3C tab
552
        // order (positive tabindex ascending, then document order; -1
553
        // excluded) instead of the old raw-NodeId walk.
554
6
        Previous => Ok(next_in_tab_order(
555
6
            &collect_tab_order(layout_results),
556
6
            current_focus,
557
6
            false,
558
6
        )),
559

            
560
18
        Next => Ok(next_in_tab_order(
561
18
            &collect_tab_order(layout_results),
562
18
            current_focus,
563
18
            true,
564
18
        )),
565

            
566
3
        First => Ok(collect_tab_order(layout_results).first().copied()),
567

            
568
2
        Last => Ok(collect_tab_order(layout_results).last().copied()),
569

            
570
1
        NoFocus => Ok(None),
571
    }
572
61
}
573

            
574
// Trait Implementations for Event Filtering
575

            
576
impl azul_core::events::FocusManagerQuery for FocusManager {
577
    fn get_focused_node_id(&self) -> Option<DomNodeId> {
578
        self.focused_node
579
    }
580
}
581

            
582
#[cfg(test)]
583
mod tab_order_tests {
584
    use super::*;
585

            
586
37
    fn nid(dom: usize, node: usize) -> DomNodeId {
587
37
        FocusSearchContext::make_dom_node_id(DomId { inner: dom }, NodeId::new(node))
588
37
    }
589

            
590
    #[test]
591
1
    fn positive_tabindex_sorts_first_ascending_then_document_order() {
592
        // Document order: n3 (tabindex=2), n5 (auto), n7 (tabindex=1), n9 (auto)
593
1
        let order = order_tab_entries(
594
1
            vec![(2, nid(0, 3)), (1, nid(0, 7))],
595
1
            vec![nid(0, 5), nid(0, 9)],
596
        );
597
1
        assert_eq!(order, vec![nid(0, 7), nid(0, 3), nid(0, 5), nid(0, 9)]);
598
1
    }
599

            
600
    #[test]
601
1
    fn equal_positive_tabindex_keeps_document_order() {
602
1
        let order = order_tab_entries(vec![(1, nid(0, 2)), (1, nid(0, 8))], vec![]);
603
1
        assert_eq!(order, vec![nid(0, 2), nid(0, 8)]);
604
1
    }
605

            
606
    #[test]
607
1
    fn next_wraps_and_previous_wraps() {
608
1
        let order = vec![nid(0, 1), nid(0, 4), nid(0, 6)];
609
1
        assert_eq!(next_in_tab_order(&order, Some(nid(0, 6)), true), Some(nid(0, 1)));
610
1
        assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), false), Some(nid(0, 6)));
611
1
        assert_eq!(next_in_tab_order(&order, Some(nid(0, 4)), true), Some(nid(0, 6)));
612
1
    }
613

            
614
    #[test]
615
1
    fn no_focus_starts_at_ends() {
616
1
        let order = vec![nid(0, 1), nid(0, 4)];
617
1
        assert_eq!(next_in_tab_order(&order, None, true), Some(nid(0, 1)));
618
1
        assert_eq!(next_in_tab_order(&order, None, false), Some(nid(0, 4)));
619
1
    }
620

            
621
    #[test]
622
1
    fn non_tab_stop_focus_reenters_in_document_order() {
623
        // Focus sits on a tabindex=-1 node (0,5): Tab goes to the next tab
624
        // stop in document order (0,6); Shift+Tab to the previous one (0,4).
625
1
        let order = vec![nid(0, 1), nid(0, 4), nid(0, 6)];
626
1
        assert_eq!(next_in_tab_order(&order, Some(nid(0, 5)), true), Some(nid(0, 6)));
627
1
        assert_eq!(next_in_tab_order(&order, Some(nid(0, 5)), false), Some(nid(0, 4)));
628
        // Past the last stop: wraps to first / last respectively.
629
1
        assert_eq!(next_in_tab_order(&order, Some(nid(0, 9)), true), Some(nid(0, 1)));
630
1
        assert_eq!(next_in_tab_order(&order, Some(nid(0, 0)), false), Some(nid(0, 6)));
631
1
    }
632

            
633
    #[test]
634
1
    fn empty_order_yields_none() {
635
1
        assert_eq!(next_in_tab_order(&[], Some(nid(0, 1)), true), None);
636
1
        assert_eq!(next_in_tab_order(&[], None, false), None);
637
1
    }
638
}
639

            
640
#[cfg(test)]
641
mod autotest_generated {
642
    use std::collections::HashMap;
643

            
644
    use azul_core::{
645
        dom::{Dom, NodeType, TabIndex},
646
        geom::LogicalRect,
647
        styled_dom::StyledDom,
648
    };
649
    use azul_css::css::{CssPath, CssPathSelector};
650

            
651
    use super::*;
652
    use crate::{
653
        managers::{NodeIdMap, NodeIdRemap},
654
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
655
    };
656

            
657
    // ------------------------------------------------------------------
658
    // Fixtures
659
    // ------------------------------------------------------------------
660

            
661
    fn dom(inner: usize) -> DomId {
662
        DomId { inner }
663
    }
664

            
665
    fn nid(dom_idx: usize, node: usize) -> DomNodeId {
666
        FocusSearchContext::make_dom_node_id(dom(dom_idx), NodeId::new(node))
667
    }
668

            
669
    /// A `DomNodeId` whose node slot is the "no node" sentinel (`inner == 0`).
670
    fn null_nid(dom_idx: usize) -> DomNodeId {
671
        DomNodeId {
672
            dom: dom(dom_idx),
673
            node: NodeHierarchyItemId::from_crate_internal(None),
674
        }
675
    }
676

            
677
    /// `DomLayoutResult` with an empty layout tree — every function under test
678
    /// here reads only `styled_dom`, so no real layout (and no font) is needed.
679
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
680
        DomLayoutResult {
681
            styled_dom,
682
            layout_tree: LayoutTree {
683
                nodes: Vec::new(),
684
                warm: Vec::new(),
685
                cold: Vec::new(),
686
                root: 0,
687
                dom_to_layout: BTreeMap::new(),
688
                children_arena: Vec::new(),
689
                children_offsets: Vec::new(),
690
                subtree_needs_intrinsic: Vec::new(),
691
            },
692
            calculated_positions: Vec::new(),
693
            viewport: LogicalRect::zero(),
694
            display_list: std::sync::Arc::new(DisplayList::default()),
695
            scroll_ids: HashMap::new(),
696
            scroll_id_to_node_id: HashMap::new(),
697
        }
698
    }
699

            
700
    fn window(entries: Vec<(DomId, StyledDom)>) -> BTreeMap<DomId, DomLayoutResult> {
701
        entries
702
            .into_iter()
703
            .map(|(id, sd)| (id, layout_result(sd)))
704
            .collect()
705
    }
706

            
707
    /// Flat (pre-order) indices of [`tab_fixture`]:
708
    ///
709
    /// | idx | node                    | focusable | tab bucket        |
710
    /// |-----|-------------------------|-----------|-------------------|
711
    /// | 0   | body                    | no        | —                 |
712
    /// | 1   | div (plain)             | no        | —                 |
713
    /// | 2   | button                  | yes       | auto              |
714
    /// | 3   | div `tabindex=2`        | yes       | positive (n=2)    |
715
    /// | 4   | div `tabindex=-1`       | yes       | EXCLUDED          |
716
    /// | 5   | div `tabindex=1`        | yes       | positive (n=1)    |
717
    /// | 6   | div `tabindex=0`        | yes       | auto              |
718
    /// | 7   | textarea                | yes       | auto              |
719
    ///
720
    /// => tab order `[5, 3, 2, 6, 7]`.
721
    fn tab_fixture() -> StyledDom {
722
        StyledDom::create_from_dom(
723
            Dom::create_body()
724
                .with_child(Dom::create_div())
725
                .with_child(Dom::create_node(NodeType::Button))
726
                .with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(2)))
727
                .with_child(Dom::create_div().with_tab_index(TabIndex::NoKeyboardFocus))
728
                .with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(1)))
729
                .with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(0)))
730
                .with_child(Dom::create_node(NodeType::TextArea)),
731
        )
732
    }
733

            
734
    fn tab_order_of(fixture: StyledDom) -> Vec<DomNodeId> {
735
        collect_tab_order(&window(vec![(dom(0), fixture)]))
736
    }
737

            
738
    fn class_path(class: &str) -> CssPath {
739
        CssPath {
740
            selectors: vec![CssPathSelector::Class(class.to_string().into())].into(),
741
        }
742
    }
743

            
744
    // ==================================================================
745
    // FocusManager — constructor / getters / predicates
746
    // ==================================================================
747

            
748
    #[test]
749
    fn focus_manager_new_matches_default_and_is_fully_empty() {
750
        let fm = FocusManager::new();
751
        assert_eq!(fm, FocusManager::default());
752
        assert_eq!(fm.get_focused_node(), None);
753
        assert!(!fm.needs_cursor_initialization());
754
        assert_eq!(fm.pending_focus_request, None);
755
        assert_eq!(fm.pending_contenteditable_focus, None);
756
        // A default instance must answer every query without panicking.
757
        assert!(!fm.has_focus(&nid(0, 0)));
758
        assert!(!fm.has_focus(&null_nid(0)));
759
    }
760

            
761
    #[test]
762
    fn focus_manager_set_get_clear_focus_roundtrip() {
763
        let mut fm = FocusManager::new();
764
        fm.set_focused_node(Some(nid(0, 3)));
765
        assert_eq!(fm.get_focused_node(), Some(&nid(0, 3)));
766
        assert!(fm.has_focus(&nid(0, 3)));
767

            
768
        // Explicitly setting `None` is equivalent to clearing.
769
        fm.set_focused_node(None);
770
        assert_eq!(fm.get_focused_node(), None);
771

            
772
        fm.set_focused_node(Some(nid(0, 3)));
773
        fm.clear_focus();
774
        assert_eq!(fm.get_focused_node(), None);
775
        assert!(!fm.has_focus(&nid(0, 3)));
776
        // Clearing twice is idempotent, not a panic.
777
        fm.clear_focus();
778
        assert_eq!(fm.get_focused_node(), None);
779
    }
780

            
781
    #[test]
782
    fn focus_manager_has_focus_discriminates_both_dom_and_node() {
783
        let mut fm = FocusManager::new();
784
        fm.set_focused_node(Some(nid(1, 4)));
785

            
786
        assert!(fm.has_focus(&nid(1, 4)));
787
        // Same node index, different DOM — must NOT be treated as focused.
788
        assert!(!fm.has_focus(&nid(0, 4)));
789
        assert!(!fm.has_focus(&nid(2, 4)));
790
        // Same DOM, different node index.
791
        assert!(!fm.has_focus(&nid(1, 3)));
792
        assert!(!fm.has_focus(&nid(1, 5)));
793
        // The "no node" sentinel must not alias node 0.
794
        assert!(!fm.has_focus(&null_nid(1)));
795
    }
796

            
797
    #[test]
798
    fn focus_manager_has_focus_on_null_node_sentinel_is_exact() {
799
        // Focusing the sentinel itself: it matches only the sentinel, and in
800
        // particular is NOT confused with real node index 0 (whose encoded
801
        // `NodeHierarchyItemId` is 1, not 0).
802
        let mut fm = FocusManager::new();
803
        fm.set_focused_node(Some(null_nid(0)));
804
        assert!(fm.has_focus(&null_nid(0)));
805
        assert!(!fm.has_focus(&nid(0, 0)));
806
        assert!(!fm.has_focus(&null_nid(1)));
807
    }
808

            
809
    #[test]
810
    fn focus_manager_focus_survives_extreme_node_index() {
811
        // `NodeHierarchyItemId` encodes `Some(n)` as `n + 1`, so `usize::MAX`
812
        // itself would overflow the encoding. `usize::MAX - 1` is the largest
813
        // representable node and must round-trip cleanly.
814
        let extreme = nid(usize::MAX, usize::MAX - 1);
815
        let mut fm = FocusManager::new();
816
        fm.set_focused_node(Some(extreme));
817
        assert!(fm.has_focus(&extreme));
818
        assert_eq!(
819
            fm.get_focused_node()
820
                .and_then(|n| n.node.into_crate_internal())
821
                .map(|n| n.index()),
822
            Some(usize::MAX - 1)
823
        );
824
    }
825

            
826
    // ==================================================================
827
    // FocusManager — pending focus request (one-shot)
828
    // ==================================================================
829

            
830
    #[test]
831
    fn focus_manager_take_focus_request_is_one_shot() {
832
        let mut fm = FocusManager::new();
833
        // Taking from a fresh manager yields None rather than panicking.
834
        assert_eq!(fm.take_focus_request(), None);
835

            
836
        fm.request_focus_change(FocusTarget::Next);
837
        assert_eq!(fm.take_focus_request(), Some(FocusTarget::Next));
838
        // Consumed — a second take must not replay the request.
839
        assert_eq!(fm.take_focus_request(), None);
840
        assert_eq!(fm.take_focus_request(), None);
841
    }
842

            
843
    #[test]
844
    fn focus_manager_request_focus_change_overwrites_pending_request() {
845
        // The field holds a single slot: a second request silently REPLACES the
846
        // first (requests are not queued). Pin that, so a change to queueing
847
        // semantics is a deliberate, visible break.
848
        let mut fm = FocusManager::new();
849
        fm.request_focus_change(FocusTarget::First);
850
        fm.request_focus_change(FocusTarget::Last);
851
        fm.request_focus_change(FocusTarget::NoFocus);
852
        assert_eq!(fm.take_focus_request(), Some(FocusTarget::NoFocus));
853
        assert_eq!(fm.take_focus_request(), None);
854
    }
855

            
856
    #[test]
857
    fn focus_manager_request_focus_change_accepts_every_variant() {
858
        let path = FocusTarget::Path(FocusTargetPath {
859
            dom: dom(usize::MAX),
860
            css_path: class_path("nonexistent"),
861
        });
862
        let targets = vec![
863
            FocusTarget::Id(nid(0, 0)),
864
            FocusTarget::Id(null_nid(usize::MAX)),
865
            path,
866
            FocusTarget::Previous,
867
            FocusTarget::Next,
868
            FocusTarget::First,
869
            FocusTarget::Last,
870
            FocusTarget::NoFocus,
871
        ];
872
        for t in targets {
873
            let mut fm = FocusManager::new();
874
            fm.request_focus_change(t.clone());
875
            assert_eq!(fm.take_focus_request(), Some(t));
876
        }
877
    }
878

            
879
    // ==================================================================
880
    // FocusManager — W3C "flag and defer" contenteditable state
881
    // ==================================================================
882

            
883
    #[test]
884
    fn focus_manager_pending_contenteditable_set_then_take_is_one_shot() {
885
        let mut fm = FocusManager::new();
886
        assert!(!fm.needs_cursor_initialization());
887
        assert_eq!(fm.take_pending_contenteditable_focus(), None);
888

            
889
        fm.set_pending_contenteditable_focus(dom(2), NodeId::new(7), NodeId::new(9));
890
        assert!(fm.needs_cursor_initialization());
891

            
892
        assert_eq!(
893
            fm.take_pending_contenteditable_focus(),
894
            Some(PendingContentEditableFocus {
895
                dom_id: dom(2),
896
                container_node_id: NodeId::new(7),
897
                text_node_id: NodeId::new(9),
898
            })
899
        );
900
        // Flag consumed; a second take must not replay the pending focus.
901
        assert!(!fm.needs_cursor_initialization());
902
        assert_eq!(fm.take_pending_contenteditable_focus(), None);
903
    }
904

            
905
    #[test]
906
    fn focus_manager_set_pending_contenteditable_overwrites_and_accepts_extremes() {
907
        let mut fm = FocusManager::new();
908
        fm.set_pending_contenteditable_focus(dom(0), NodeId::new(1), NodeId::new(2));
909
        // Extreme ids (and container == text, i.e. a degenerate self-reference)
910
        // must be stored verbatim without panicking.
911
        fm.set_pending_contenteditable_focus(
912
            dom(usize::MAX),
913
            NodeId::new(usize::MAX),
914
            NodeId::new(usize::MAX),
915
        );
916
        assert_eq!(
917
            fm.take_pending_contenteditable_focus(),
918
            Some(PendingContentEditableFocus {
919
                dom_id: dom(usize::MAX),
920
                container_node_id: NodeId::new(usize::MAX),
921
                text_node_id: NodeId::new(usize::MAX),
922
            })
923
        );
924
    }
925

            
926
    #[test]
927
    fn focus_manager_clear_pending_contenteditable_clears_flag_and_value() {
928
        let mut fm = FocusManager::new();
929
        fm.set_pending_contenteditable_focus(dom(0), NodeId::new(1), NodeId::new(2));
930
        fm.clear_pending_contenteditable_focus();
931

            
932
        assert!(!fm.needs_cursor_initialization());
933
        assert_eq!(fm.pending_contenteditable_focus, None);
934
        assert_eq!(fm.take_pending_contenteditable_focus(), None);
935
        // Clearing an already-clear manager is idempotent.
936
        fm.clear_pending_contenteditable_focus();
937
        assert!(!fm.needs_cursor_initialization());
938
    }
939

            
940
    #[test]
941
    fn focus_manager_take_pending_without_flag_strands_the_value() {
942
        // Both fields are `pub`, so the flag and the value can be desynced by a
943
        // direct field write. `take_pending_contenteditable_focus` gates purely
944
        // on the FLAG, so a value written without the flag is never handed out
945
        // and is left stranded in the manager. Pin the (safe, non-panicking)
946
        // behaviour: no cursor is initialised, and the stale value survives.
947
        let mut fm = FocusManager::new();
948
        fm.pending_contenteditable_focus = Some(PendingContentEditableFocus {
949
            dom_id: dom(0),
950
            container_node_id: NodeId::new(1),
951
            text_node_id: NodeId::new(2),
952
        });
953

            
954
        assert!(!fm.needs_cursor_initialization());
955
        assert_eq!(fm.take_pending_contenteditable_focus(), None);
956
        assert!(fm.pending_contenteditable_focus.is_some());
957
    }
958

            
959
    #[test]
960
    fn focus_manager_flag_without_value_take_returns_none_and_clears_flag() {
961
        // The mirror-image desync: flag set, value absent. `take` must report
962
        // "nothing to do" AND drop the flag, so the caller cannot spin on a
963
        // permanently-pending initialisation.
964
        let mut fm = FocusManager::new();
965
        fm.cursor_needs_initialization = true;
966

            
967
        assert!(fm.needs_cursor_initialization());
968
        assert_eq!(fm.take_pending_contenteditable_focus(), None);
969
        assert!(!fm.needs_cursor_initialization());
970
    }
971

            
972
    #[test]
973
    fn focus_manager_clear_focus_does_not_clear_pending_cursor_state() {
974
        // `clear_focus` touches ONLY `focused_node`: the deferred contenteditable
975
        // cursor request deliberately survives it (callers must call
976
        // `clear_pending_contenteditable_focus` themselves). Pin this, since a
977
        // silent change would either leak a cursor into an unfocused node or
978
        // drop a legitimate deferred cursor.
979
        let mut fm = FocusManager::new();
980
        fm.set_focused_node(Some(nid(0, 4)));
981
        fm.set_pending_contenteditable_focus(dom(0), NodeId::new(4), NodeId::new(5));
982

            
983
        fm.clear_focus();
984

            
985
        assert_eq!(fm.get_focused_node(), None);
986
        assert!(fm.needs_cursor_initialization());
987
        assert!(fm.pending_contenteditable_focus.is_some());
988
    }
989

            
990
    // ==================================================================
991
    // order_tab_entries / doc_order_key
992
    // ==================================================================
993

            
994
    #[test]
995
    fn order_tab_entries_empty_inputs_yield_empty() {
996
        assert_eq!(order_tab_entries(Vec::new(), Vec::new()), Vec::new());
997
    }
998

            
999
    #[test]
    fn order_tab_entries_u32_max_sorts_after_smaller_positives() {
        // No overflow / wrap: u32::MAX is just a very large key and must land
        // last among the positives, never first.
        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() {
        // Even the largest possible positive tabindex outranks every auto entry.
        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() {
        // Duplicates are preserved verbatim (the function is a pure merge, not a
        // set builder) — a duplicated entry must not silently vanish.
        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() {
        // `doc_order_key` maps the "no node" sentinel to arena index 0, so it is
        // indistinguishable from real node 0 within the same DOM. Pin the
        // collision: `next_in_tab_order`'s re-entry search relies on this key,
        // and a focus sitting on the sentinel therefore re-enters as if it sat
        // on node 0.
        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));
        // DOM index dominates: a huge node index in DOM 0 still precedes node 0
        // of DOM 1.
        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));
    }
    // ==================================================================
    // next_in_tab_order
    // ==================================================================
    #[test]
    fn next_in_tab_order_single_entry_wraps_onto_itself() {
        // `(0 + 1) % 1 == 0` — must terminate on itself, not loop or panic.
        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() {
        // `position()` finds the FIRST occurrence, so a duplicated tab stop makes
        // the trailing copy unreachable by stepping. Pin it (a dedup in
        // `collect_tab_order` would change this).
        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)));
        // Backward from index 0 wraps to the last element.
        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() {
        // Current node lives in DOM 1; the tab order is split across DOM 0 and 2.
        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)];
        // Nothing greater -> wrap to first.
        assert_eq!(next_in_tab_order(&order, Some(nid(9, 9)), true), Some(nid(1, 2)));
        // Nothing smaller -> wrap to last.
        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() {
        // The sentinel keys as (dom, 0); it is not in the order, so the re-entry
        // path runs. It must produce a stable answer, not panic.
        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
        );
    }
    // ==================================================================
    // collect_tab_order
    // ==================================================================
    #[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() {
        // See `tab_fixture` doc comment for the expected layout.
        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() {
        // The tabindex=-1 node (index 4) is click/API focusable but must NEVER be
        // a tab stop.
        let order = tab_order_of(tab_fixture());
        assert!(!order.contains(&nid(0, 4)), "tabindex=-1 must not be a tab stop");
        // ...and the plain, non-focusable div is absent too.
        assert!(!order.contains(&nid(0, 1)));
        // ...while the body itself is never a tab stop.
        assert!(!order.contains(&nid(0, 0)));
    }
    #[test]
    fn collect_tab_order_huge_tabindex_truncates_at_28_bits() {
        // `NodeFlags` packs the tabindex into 28 bits, so:
        //   * tabindex = u32::MAX  -> stored as 2^28-1  -> still POSITIVE
        //   * tabindex = 1 << 28   -> stored as 0       -> demoted to the AUTO
        //                                                  bucket (0 is not > 0)
        // The truncation is silent, so pin the observable ordering consequence.
        //
        // Document order: 1 = u32::MAX, 2 = 1<<28, 3 = tabindex 1, 4 = button.
        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() {
        // A positive-tabindex node in DOM 1 must outrank an auto node in DOM 0:
        // the tab order is a single sequence over all DOMs, not per-DOM chunks.
        let order = collect_tab_order(&window(vec![
            (dom(0), tab_fixture()),
            (dom(1), tab_fixture()),
        ]));
        assert_eq!(
            order,
            vec![
                // positives, ascending; ties broken by DOM then document order
                nid(0, 5),
                nid(1, 5),
                nid(0, 3),
                nid(1, 3),
                // autos, in DOM order then document order
                nid(0, 2),
                nid(0, 6),
                nid(0, 7),
                nid(1, 2),
                nid(1, 6),
                nid(1, 7),
            ]
        );
    }
    // ==================================================================
    // FocusSearchContext
    // ==================================================================
    #[test]
    fn focus_search_context_get_layout_hit_and_miss() {
        let results = window(vec![(dom(0), tab_fixture())]);
        let ctx = FocusSearchContext::new(&results);
        // `DomLayoutResult` is not `PartialEq`, so compare on the error side only.
        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() {
        // encode == decode for 0, a mid value, and the largest encodable index
        // (`usize::MAX` itself would overflow `NodeHierarchyItemId`'s n+1 encoding).
        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));
        }
    }
    // ==================================================================
    // find_first_matching_focusable_node
    // ==================================================================
    #[test]
    fn find_first_matching_skips_matching_but_unfocusable_nodes() {
        // Node 1 matches `.target` but is NOT focusable; node 2 matches AND is
        // focusable. The first *focusable* match must win.
        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() {
        // A `CssPath` with zero selectors must not vacuously match every node.
        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() {
        // Non-ASCII / astral-plane class names must compare by exact string, with
        // no panic and no byte-index slicing surprises.
        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))
        );
        // A near-miss (same prefix, different suffix) must NOT match.
        assert_eq!(
            find_first_matching_focusable_node(layout, &dom(0), &class_path("クラス-día-🎲")),
            None
        );
        // A huge class name that cannot exist in the DOM also just misses.
        let huge = "x".repeat(10_000);
        assert_eq!(
            find_first_matching_focusable_node(layout, &dom(0), &class_path(&huge)),
            None
        );
    }
    // ==================================================================
    // resolve_focus_target
    // ==================================================================
    #[test]
    fn resolve_focus_target_empty_window_short_circuits_every_variant() {
        // The `layout_results.is_empty()` guard runs BEFORE any validation, so
        // even a structurally invalid target resolves to `Ok(None)` — never Err,
        // never a panic.
        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() {
        // The fixture has 8 nodes (0..=7); anything past the end must be a
        // `FocusInvalidNodeId` error, not a panic and not a silent focus.
        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() {
        // `Id` checks only that the node EXISTS — programmatic focus deliberately
        // bypasses the focusability check (unlike `Path` and the tab order).
        // Node 0 is the body and node 4 is tabindex=-1: both resolve.
        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() {
        // NOTE: the doc comment on `find_first_matching_focusable_node` advertises
        // `Err(_)` for an unmatchable path, but the implementation returns
        // `Ok(None)`. Pin the IMPLEMENTED behaviour (a miss is not an error);
        // the doc comment is what is wrong here.
        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())]);
        // Tab order is [5, 3, 2, 6, 7].
        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)))
        );
        // `current_focus` must not influence First/Last.
        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())]);
        // Tab order [5, 3, 2, 6, 7]: stepping off either end wraps.
        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)))
        );
        // ...and step normally in the middle.
        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())]);
        // Focus sits on the tabindex=-1 node (index 4), which is NOT in the tab
        // order. Shift+Tab must fall back to document order and land on node 3
        // (the nearest preceding tab stop by DOCUMENT position), NOT on the tab
        // order's neighbour of any element.
        assert_eq!(
            resolve_focus_target(&FocusTarget::Previous, &results, Some(nid(0, 4))),
            Ok(Some(nid(0, 3)))
        );
        // Focus on the plain, non-focusable div (index 1): Tab goes to the next
        // tab stop in DOCUMENT order (node 2, the button) — not to the tab
        // order's first entry (node 5).
        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() {
        // Focus left over from a previous DOM whose node index no longer exists:
        // resolution must still yield a valid tab stop rather than panic.
        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"
        );
        // A stale node in a DOM that isn't even mounted.
        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)))
        );
    }
    // ==================================================================
    // NodeIdRemap
    // ==================================================================
    #[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() {
        // The node vanished from the rebuilt DOM: keeping the stale index would
        // silently focus a DIFFERENT element, so focus must be dropped.
        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)));
        // Remapping DOM 0 must not disturb focus that lives in DOM 1.
        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() {
        // Container survives the rebuild but the text node does not (or vice
        // versa): keeping half of the pair would place a cursor in the wrong
        // node, so BOTH the value and the flag must be dropped.
        for pairs in [
            vec![(NodeId::new(3), NodeId::new(10))],  // text node unmapped
            vec![(NodeId::new(4), NodeId::new(11))],  // container unmapped
            vec![],                                   // neither survives
        ] {
            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);
        }
    }
}