Skip to main content

azul_layout/managers/
text_edit.rs

1//! Unified text editing manager
2//!
3//! Single source of truth for all text editing state. `MultiCursorState` is
4//! the primary cursor/selection system. `BlinkState` handles the caret blink
5//! animation. (Non-editable drag-select is not yet wired — the former
6//! `SelectionManager` scaffolding was dead and has been removed; a future
7//! implementation should build on `MultiCursorState`.)
8//!
9//! Every mutation that affects visual output sets `display_list_dirty = true`,
10//! ensuring the display list is always regenerated.
11
12use azul_core::{
13    dom::{DomId, DomNodeId, NodeId},
14    selection::{MultiCursorState, Selection, TextCursor},
15    styled_dom::NodeHierarchyItemId,
16    task::{Duration, Instant},
17};
18
19
20/// Default cursor blink interval in milliseconds
21pub const CURSOR_BLINK_INTERVAL_MS: u64 = 530;
22
23/// Default cursor blink interval as a variant-agnostic [`Duration`].
24///
25/// The interval is a `Duration`, not a bare `u64` of milliseconds, so a
26/// stylesheet can express it in the clockless `t` unit (`caret-animation-duration:
27/// 5t`) and have it survive all the way to the comparison. `Duration`'s
28/// comparisons are unit-aware, so a tick-unit interval and a wall-clock elapsed
29/// value (or vice versa) still compare truthfully.
30pub const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(CURSOR_BLINK_INTERVAL_MS);
31
32/// Cursor blink animation state.
33///
34/// Extracted from the old `CursorManager` so it can live independently
35/// on `TextEditManager` without coupling to cursor position.
36#[derive(Debug, Clone)]
37pub struct BlinkState {
38    /// Whether the cursor is currently visible (toggled by blink timer)
39    pub is_visible: bool,
40    /// Timestamp of the last user input event (keyboard, mouse click in text).
41    /// Used to determine whether to blink or stay solid while typing.
42    pub last_input_time: Option<Instant>,
43    /// Whether the cursor blink timer is currently active
44    pub blink_timer_active: bool,
45    /// How long the caret stays solid after input before blinking resumes, and
46    /// the interval the blink timer is armed with.
47    ///
48    /// Defaults to [`CURSOR_BLINK_INTERVAL`]; `caret-animation-duration` on the
49    /// focused node overrides it, in whichever unit the stylesheet used.
50    pub blink_interval: Duration,
51}
52
53impl Default for BlinkState {
54    fn default() -> Self {
55        Self {
56            is_visible: false,
57            last_input_time: None,
58            blink_timer_active: false,
59            blink_interval: CURSOR_BLINK_INTERVAL,
60        }
61    }
62}
63
64
65impl BlinkState {
66    #[must_use] pub fn new() -> Self { Self::default() }
67
68    /// Override the blink interval (from `caret-animation-duration`).
69    ///
70    /// Takes a [`Duration`] rather than milliseconds so a `t`-unit stylesheet
71    /// value stays a frame count: a `5t` caret flips on the 5th frame exactly,
72    /// on any machine, at any load.
73    pub const fn set_blink_interval(&mut self, interval: Duration) {
74        self.blink_interval = interval;
75    }
76
77    /// Reset blink on user input — cursor stays solid until blink interval elapses.
78    pub fn reset_blink_on_input(&mut self, now: Instant) {
79        self.is_visible = true;
80        self.last_input_time = Some(now);
81    }
82
83    /// Toggle cursor visibility (called by blink timer callback).
84    pub const fn toggle_visibility(&mut self) -> bool {
85        self.is_visible = !self.is_visible;
86        self.is_visible
87    }
88
89    pub const fn set_visibility(&mut self, visible: bool) {
90        self.is_visible = visible;
91    }
92
93    pub const fn set_blink_timer_active(&mut self, active: bool) {
94        self.blink_timer_active = active;
95    }
96
97    #[must_use] pub const fn is_blink_timer_active(&self) -> bool {
98        self.blink_timer_active
99    }
100
101    /// Check if enough time has passed since last input to start blinking.
102    ///
103    /// The interval is [`Self::blink_interval`], compared unit-aware: a tick
104    /// interval against wall-clock elapsed time (or the reverse) both answer
105    /// truthfully. This used to build a `Duration::System` constant inline, which
106    /// meant a tick-driven clock produced a `Duration::Tick` elapsed value that
107    /// could never be "greater than" it — the caret stopped blinking, silently
108    /// and permanently, on every clockless build.
109    #[must_use] pub fn should_blink(&self, now: &Instant) -> bool {
110        self.last_input_time.as_ref().is_none_or(|last_input| {
111                now.duration_since(last_input).greater_than(&self.blink_interval)
112            })
113    }
114
115    /// Clear all blink state (when editing ends).
116    ///
117    /// The interval goes back to the default too: it was read off the node that
118    /// just lost focus, and leaving it behind would apply that node's
119    /// `caret-animation-duration` to the next element focused — including one
120    /// that never set the property.
121    pub fn clear(&mut self) {
122        self.is_visible = false;
123        self.last_input_time = None;
124        self.blink_timer_active = false;
125        self.blink_interval = CURSOR_BLINK_INTERVAL;
126    }
127}
128
129/// Unified text editing manager.
130///
131/// `multi_cursor` is the single source of truth for cursor/selection positions.
132/// `blink` manages the caret blink animation.
133/// `SelectionManager` (sibling module) handles non-editable text drag-select.
134#[derive(Debug, Clone)]
135pub struct TextEditManager {
136    /// Multi-cursor state for contenteditable elements (Sublime Text style).
137    /// `Some` whenever a contenteditable element has focus.
138    /// Source of truth for `edit_text()` and display list painting.
139    pub multi_cursor: Option<MultiCursorState>,
140    /// Cursor blink animation state.
141    pub blink: BlinkState,
142    /// IME preedit (composition) text currently being composed.
143    /// Applies to the primary cursor only.
144    pub preedit_text: Option<String>,
145    /// Byte offset of cursor within preedit text (from IME), or -1 if unset.
146    /// Uses -1 sentinel (rather than `Option`) to match platform IME C API conventions.
147    pub preedit_cursor_begin: i32,
148    /// Byte offset of cursor end within preedit text (from IME), or -1 if unset.
149    /// Uses -1 sentinel (rather than `Option`) to match platform IME C API conventions.
150    pub preedit_cursor_end: i32,
151    /// Set to true by any mutation that changes visual output.
152    pub display_list_dirty: bool,
153}
154
155impl Default for TextEditManager {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161/// Only compares `multi_cursor` — blink state, preedit, and dirty flag are
162/// transient visual state that should not affect logical equality of the
163/// editing session.
164impl PartialEq for TextEditManager {
165    fn eq(&self, other: &Self) -> bool {
166        self.multi_cursor == other.multi_cursor
167    }
168}
169
170impl TextEditManager {
171    /// Create a new text edit manager with no active editing state
172    #[must_use] pub fn new() -> Self {
173        Self {
174            multi_cursor: None,
175            blink: BlinkState::new(),
176            preedit_text: None,
177            preedit_cursor_begin: -1,
178            preedit_cursor_end: -1,
179            display_list_dirty: false,
180        }
181    }
182
183    // === Dirty flag ===
184
185    /// Mark that the display list needs regeneration.
186    pub const fn mark_dirty(&mut self) {
187        self.display_list_dirty = true;
188    }
189
190    // === Editing lifecycle ===
191
192    /// Whether a contenteditable element is currently being edited.
193    #[must_use] pub const fn has_active_editing(&self) -> bool {
194        self.multi_cursor.is_some()
195    }
196
197    /// Get the `DomId` of the node being edited.
198    #[must_use] pub fn get_editing_dom_id(&self) -> Option<DomId> {
199        self.multi_cursor.as_ref().map(|mc| mc.node_id.dom)
200    }
201
202    /// Get the `NodeId` of the node being edited.
203    #[must_use] pub fn get_editing_node_id(&self) -> Option<NodeId> {
204        self.multi_cursor.as_ref()
205            .and_then(|mc| mc.node_id.node.into_crate_internal())
206    }
207
208    /// Get the primary cursor position (last-added cursor).
209    #[must_use] pub fn get_primary_cursor(&self) -> Option<TextCursor> {
210        self.multi_cursor.as_ref().and_then(MultiCursorState::get_primary_cursor)
211    }
212
213    /// Whether the cursor should be drawn (editing active AND blink visible).
214    #[must_use] pub const fn should_draw_cursor(&self) -> bool {
215        self.has_active_editing() && self.blink.is_visible
216    }
217
218    /// Initialize editing for a newly focused contenteditable element.
219    ///
220    /// Creates a `MultiCursorState` with a single cursor, starts the blink,
221    /// and sets preedit to None.
222    ///
223    /// # The caret is SOLID for the first half-period, not just "visible"
224    ///
225    /// This used to set `is_visible = true` and, in the same breath,
226    /// `last_input_time = None`. Those two statements contradict each other:
227    /// `None` is the "no input has EVER been recorded" encoding, for which
228    /// [`BlinkState::should_blink`] is true immediately — so the blink timer's
229    /// FIRST tick (a `Timer` with an interval and no delay runs on the first
230    /// pump) toggled the freshly-shown caret straight back OFF. Clicking or
231    /// tabbing into a field made the caret disappear on the next frame and only
232    /// reappear a blink-interval later, which reads as a glitch and matches no
233    /// other toolkit.
234    ///
235    /// It also made every caller responsible for repairing the state this
236    /// method had just broken. Two of the three did:
237    /// `LayoutWindow::handle_focus_change_for_cursor_blink` calls
238    /// `reset_blink_on_input` BEFORE the deferred `finalize_pending_focus_changes`
239    /// gets here (so its timestamp was overwritten with `None`), and
240    /// `process_mouse_click_for_selection` calls it immediately AFTER (so its
241    /// caret survived). The third, `process_accessibility_action`, did not — an
242    /// AT-driven focus got the broken phase.
243    ///
244    /// `reset_blink_on_input(now)` sets BOTH halves consistently: caret shown
245    /// AND the blink phase anchored at this instant, so `should_blink` stays
246    /// false for `CURSOR_BLINK_INTERVAL_MS` and the first toggle happens one
247    /// half-period after focus. That is the behaviour every other toolkit ships,
248    /// and it makes the callers' own `reset_blink_on_input` calls redundant
249    /// rather than load-bearing.
250    ///
251    /// `Instant::now()` honours the thread-scoped E2E test clock, so this stays
252    /// deterministic under `tick_ms`.
253    pub fn initialize_editing(
254        &mut self,
255        cursor: TextCursor,
256        dom_id: DomId,
257        node_id: NodeId,
258        contenteditable_key: u64,
259    ) {
260        let dom_node_id = DomNodeId {
261            dom: dom_id,
262            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
263        };
264        self.multi_cursor = Some(MultiCursorState::new_with_cursor(
265            cursor,
266            dom_node_id,
267            contenteditable_key,
268        ));
269        self.blink.reset_blink_on_input(Instant::now());
270        self.clear_preedit();
271        self.mark_dirty();
272    }
273
274    /// End editing (focus left the contenteditable element).
275    pub fn clear_editing(&mut self) {
276        // Only ask for a repaint if there was something to erase.
277        //
278        // This used to mark dirty unconditionally, and it is reached on EVERY
279        // focus change — including a Tab between two nodes that were never
280        // editable. The manager then owed a repaint it had no pixels for, and
281        // because `display_list_dirty` is a latch, one such focus move left the
282        // window permanently "not idle": a permanent repaint request, on every
283        // frame, for the rest of the window's life.
284        //
285        // Caught by the E2E non-interference gate, which saw `text_edit` move
286        // on a focus-only step with the fingerprint otherwise identical —
287        // cursor=none, preedit="" both before and after, and only `dirty`
288        // flipping. Nothing changed, so nothing was owed.
289        let had_cursor = self.multi_cursor.is_some();
290        let had_blink = self.blink.is_visible
291            || self.blink.last_input_time.is_some()
292            || self.blink.blink_timer_active;
293
294        self.multi_cursor = None;
295        self.blink.clear();
296        let had_preedit = self.clear_preedit_returning_changed();
297
298        if had_cursor || had_blink || had_preedit {
299            self.mark_dirty();
300        }
301    }
302
303    // === IME preedit ===
304
305    /// Set the IME preedit (composition) text.
306    pub fn set_preedit(&mut self, text: String, cursor_begin: i32, cursor_end: i32) {
307        self.preedit_text = if text.is_empty() { None } else { Some(text) };
308        self.preedit_cursor_begin = cursor_begin;
309        self.preedit_cursor_end = cursor_end;
310        self.mark_dirty();
311    }
312
313    /// Clear the IME preedit text (composition ended or cancelled).
314    pub fn clear_preedit(&mut self) {
315        let _ = self.clear_preedit_returning_changed();
316    }
317
318    /// Clear the preedit, reporting whether anything was actually cleared.
319    ///
320    /// Split out so `clear_editing` can decide whether a repaint is owed
321    /// without marking dirty twice — and so clearing an ALREADY-clear preedit
322    /// costs nothing, which is the common case on a focus change.
323    fn clear_preedit_returning_changed(&mut self) -> bool {
324        let changed = self.preedit_text.is_some()
325            || self.preedit_cursor_begin != -1
326            || self.preedit_cursor_end != -1;
327        if !changed {
328            return false;
329        }
330        self.preedit_text = None;
331        self.preedit_cursor_begin = -1;
332        self.preedit_cursor_end = -1;
333        self.mark_dirty();
334        true
335    }
336
337    // === Convenience for building cursor_locations ===
338
339    /// Build the Vec of cursor locations for `LayoutContext`.
340    ///
341    /// Returns all cursor positions from `MultiCursorState`, or empty if not editing.
342    #[must_use] pub fn build_cursor_locations(&self) -> Vec<(DomId, NodeId, TextCursor)> {
343        let Some(ref mc) = self.multi_cursor else {
344            return Vec::new();
345        };
346        let Some(node_id) = mc.node_id.node.into_crate_internal() else {
347            return Vec::new();
348        };
349        mc.selections.iter().map(|s| {
350            let cursor = match &s.selection {
351                Selection::Cursor(c) => *c,
352                Selection::Range(r) => r.end,
353            };
354            (mc.node_id.dom, node_id, cursor)
355        }).collect()
356    }
357
358    /// Build a `TextSelection` map for the display list's `paint_selections`.
359    ///
360    /// Extracts Range selections from `MultiCursorState` into the format that
361    /// `LayoutContext.text_selections` expects: `BTreeMap<DomId, TextSelection>`.
362    /// The `affected_nodes` map uses the editing node's `NodeId` as key.
363    /// NOTE: only one range per node is supported — if multiple cursors have
364    /// range selections on the same node, later ranges overwrite earlier ones.
365    #[must_use] pub fn build_text_selections_map(&self) -> std::collections::BTreeMap<DomId, azul_core::selection::TextSelection> {
366        use azul_core::selection::{TextSelection, SelectionAnchor, SelectionFocus};
367        use azul_core::geom::LogicalRect;
368
369        let mut map = std::collections::BTreeMap::new();
370        let Some(ref mc) = self.multi_cursor else {
371            return map;
372        };
373        let Some(node_id) = mc.node_id.node.into_crate_internal() else {
374            return map;
375        };
376
377        let mut affected_nodes = std::collections::BTreeMap::new();
378        let mut first_range: Option<azul_core::selection::SelectionRange> = None;
379        for sel in &mc.selections {
380            if let Selection::Range(range) = &sel.selection {
381                affected_nodes.insert(node_id, *range);
382                if first_range.is_none() {
383                    first_range = Some(*range);
384                }
385            }
386        }
387
388        if let Some(range) = first_range {
389            map.insert(mc.node_id.dom, TextSelection {
390                dom_id: mc.node_id.dom,
391                anchor: SelectionAnchor {
392                    ifc_root_node_id: node_id,
393                    cursor: range.start,
394                    char_bounds: LogicalRect::zero(),
395                    mouse_position: azul_core::geom::LogicalPosition::zero(),
396                },
397                focus: SelectionFocus {
398                    ifc_root_node_id: node_id,
399                    cursor: range.end,
400                    mouse_position: azul_core::geom::LogicalPosition::zero(),
401                },
402                affected_nodes,
403                is_forward: true,
404            });
405        }
406
407        map
408    }
409}
410
411impl crate::managers::NodeIdRemap for TextEditManager {
412    /// Remap the multi-cursor / selection state onto the rebuilt DOM.
413    ///
414    /// `MultiCursorState::remap_node_ids` clears the selections when the edited
415    /// node is gone; here we additionally drop the whole editing session, since a
416    /// cursor whose IFC root no longer exists is not an editing session.
417    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
418        let Some(ref mut mc) = self.multi_cursor else {
419            return;
420        };
421        if mc.node_id.dom != dom {
422            return;
423        }
424        let unmounted = mc
425            .node_id
426            .node
427            .into_crate_internal()
428            .is_none_or(|old| map.resolve(old).is_none());
429        if unmounted {
430            self.multi_cursor = None;
431            self.preedit_text = None;
432            self.preedit_cursor_begin = -1;
433            self.preedit_cursor_end = -1;
434            self.display_list_dirty = true;
435            return;
436        }
437        mc.remap_node_ids(dom, map.as_btree_map());
438    }
439}
440
441// ============================================================================
442// AUTOTEST: adversarial tests for `BlinkState` + `TextEditManager`
443// ============================================================================
444#[cfg(test)]
445mod autotest_generated {
446    use azul_core::{
447        selection::{
448            CursorAffinity, GraphemeClusterId, IdentifiedSelection, SelectionId, SelectionRange,
449        },
450        task::{Duration, SystemTick, SystemTimeDiff},
451    };
452
453    use super::*;
454    use crate::managers::{NodeIdMap, NodeIdRemap};
455
456    const DOM0: DomId = DomId { inner: 0 };
457    const DOM1: DomId = DomId { inner: 1 };
458    /// A `DomId` at the very top of the `usize` range — nothing indexes with it,
459    /// so it must be carried through unchanged like any other id.
460    const DOM_MAX: DomId = DomId { inner: usize::MAX };
461
462    /// `NodeHierarchyItemId` stores nodes 1-based (`from_crate_internal` computes
463    /// `index + 1`), so the largest node index that can survive a round-trip
464    /// through a `DomNodeId` is `usize::MAX - 1`. `NodeId::new(usize::MAX)` is not
465    /// representable and is deliberately never fed to `initialize_editing`.
466    const MAX_ENCODABLE_NODE: usize = usize::MAX - 1;
467
468    fn cursor(run: u32, byte: u32) -> TextCursor {
469        TextCursor {
470            cluster_id: GraphemeClusterId {
471                source_run: run,
472                start_byte_in_run: byte,
473            },
474            affinity: CursorAffinity::Leading,
475        }
476    }
477
478    fn range(from: TextCursor, to: TextCursor) -> SelectionRange {
479        SelectionRange {
480            start: from,
481            end: to,
482        }
483    }
484
485    fn dom_node(dom: DomId, node: Option<NodeId>) -> DomNodeId {
486        DomNodeId {
487            dom,
488            node: NodeHierarchyItemId::from_crate_internal(node),
489        }
490    }
491
492    /// Build a `MultiCursorState` with an arbitrary selection list, bypassing
493    /// `add_cursor`/`add_selection` (which sort + merge) so the exact ordering
494    /// under test is preserved.
495    fn multi_cursor_with(
496        node_id: DomNodeId,
497        selections: Vec<Selection>,
498        key: u64,
499    ) -> MultiCursorState {
500        let identified: Vec<IdentifiedSelection> = selections
501            .into_iter()
502            .map(|selection| IdentifiedSelection {
503                id: SelectionId::new(),
504                selection,
505            })
506            .collect();
507        let primary_id = identified
508            .last()
509            .map_or_else(SelectionId::new, |s| s.id);
510        MultiCursorState {
511            selections: identified,
512            primary_id,
513            node_id,
514            contenteditable_key: key,
515        }
516    }
517
518    /// `base + ms`, using the engine's own saturating instant arithmetic.
519    fn plus_ms(base: &Instant, ms: u64) -> Instant {
520        base.add_optional_duration(Some(&Duration::System(SystemTimeDiff::from_millis(ms))))
521    }
522
523    // ------------------------------------------------------------------
524    // BlinkState
525    // ------------------------------------------------------------------
526
527    #[test]
528    fn autotest_blink_new_invariants() {
529        let b = BlinkState::new();
530        assert!(!b.is_visible, "a fresh BlinkState starts hidden");
531        assert!(b.last_input_time.is_none());
532        assert!(!b.is_blink_timer_active());
533        assert!(!b.blink_timer_active);
534        // The default interval is the wall-clock one, so every existing caller
535        // that never sets an interval keeps the 530ms behaviour it had.
536        assert_eq!(b.blink_interval, CURSOR_BLINK_INTERVAL);
537        assert_eq!(b.blink_interval, Duration::from_millis(CURSOR_BLINK_INTERVAL_MS));
538        // No input has ever been recorded, so blinking is allowed immediately.
539        assert!(b.should_blink(&Instant::now()));
540    }
541
542    #[test]
543    fn autotest_blink_toggle_visibility_alternates_and_returns_new_state() {
544        let mut b = BlinkState::new();
545        assert!(b.toggle_visibility(), "first toggle turns the caret on");
546        assert!(b.is_visible);
547        assert!(!b.toggle_visibility(), "second toggle turns it back off");
548        assert!(!b.is_visible);
549
550        // 1000 toggles: the return value must always equal the new field value,
551        // and parity must be exactly preserved (no drift, no panic).
552        let mut expected = false;
553        for _ in 0..1000 {
554            expected = !expected;
555            let returned = b.toggle_visibility();
556            assert_eq!(returned, expected);
557            assert_eq!(b.is_visible, expected);
558        }
559        assert!(!b.is_visible, "an even number of toggles restores the state");
560    }
561
562    #[test]
563    fn autotest_blink_set_visibility_is_idempotent_and_orthogonal() {
564        let mut b = BlinkState::new();
565        b.set_blink_timer_active(true);
566
567        b.set_visibility(true);
568        b.set_visibility(true);
569        assert!(b.is_visible);
570        assert!(
571            b.is_blink_timer_active(),
572            "visibility must not disturb the timer flag"
573        );
574
575        b.set_visibility(false);
576        b.set_visibility(false);
577        assert!(!b.is_visible);
578        assert!(b.is_blink_timer_active());
579    }
580
581    #[test]
582    fn autotest_blink_timer_active_true_false_and_idempotent() {
583        let mut b = BlinkState::new();
584        assert!(!b.is_blink_timer_active(), "known-false: default state");
585
586        b.set_blink_timer_active(true);
587        assert!(b.is_blink_timer_active(), "known-true: after activation");
588        b.set_blink_timer_active(true);
589        assert!(b.is_blink_timer_active(), "re-activation is idempotent");
590
591        b.set_blink_timer_active(false);
592        assert!(!b.is_blink_timer_active());
593        b.set_blink_timer_active(false);
594        assert!(!b.is_blink_timer_active(), "re-deactivation is idempotent");
595
596        // The timer flag never leaks into visibility.
597        assert!(!b.is_visible);
598    }
599
600    #[test]
601    fn autotest_blink_reset_on_input_forces_solid_caret() {
602        let mut b = BlinkState::new();
603        b.set_blink_timer_active(true);
604        b.set_visibility(false);
605
606        let now = Instant::now();
607        b.reset_blink_on_input(now.clone());
608
609        assert!(b.is_visible, "typing must show a solid caret");
610        assert_eq!(b.last_input_time.as_ref(), Some(&now));
611        assert!(
612            b.is_blink_timer_active(),
613            "reset_blink_on_input must not stop the timer"
614        );
615        // Immediately after input, the blink interval has not elapsed.
616        assert!(!b.should_blink(&now));
617    }
618
619    #[test]
620    fn autotest_blink_reset_on_input_repeated_keeps_latest_timestamp() {
621        let mut b = BlinkState::new();
622        let base = Instant::now();
623
624        // Simulate a fast typist: 500 keystrokes, 1ms apart.
625        for i in 0..500u64 {
626            b.reset_blink_on_input(plus_ms(&base, i));
627            assert!(b.is_visible, "the caret stays solid throughout typing");
628        }
629
630        let last = plus_ms(&base, 499);
631        assert_eq!(b.last_input_time.as_ref(), Some(&last));
632        // The whole burst spans 499ms < 530ms, so blinking has still not resumed.
633        assert!(!b.should_blink(&last));
634    }
635
636    #[test]
637    fn autotest_blink_should_blink_without_input_is_true() {
638        let b = BlinkState::new();
639        let now = Instant::now();
640        assert!(b.should_blink(&now));
641        // Also true for an instant far in the past — no input means no gate at all.
642        assert!(b.should_blink(&Instant::Tick(SystemTick::new(0))));
643    }
644
645    #[test]
646    fn autotest_blink_should_blink_interval_boundary_is_strict() {
647        let base = Instant::now();
648        let mut b = BlinkState::new();
649        b.reset_blink_on_input(base.clone());
650
651        assert!(
652            !b.should_blink(&base),
653            "zero elapsed time must not restart the blink"
654        );
655        assert!(
656            !b.should_blink(&plus_ms(&base, CURSOR_BLINK_INTERVAL_MS - 1)),
657            "one millisecond before the interval: still solid"
658        );
659        assert!(
660            !b.should_blink(&plus_ms(&base, CURSOR_BLINK_INTERVAL_MS)),
661            "exactly at the interval: the comparison is strictly greater-than"
662        );
663        assert!(
664            b.should_blink(&plus_ms(&base, CURSOR_BLINK_INTERVAL_MS + 1)),
665            "one millisecond past the interval: blinking resumes"
666        );
667        // Far past the interval (one day) — no overflow, still blinking.
668        assert!(b.should_blink(&plus_ms(&base, 86_400_000)));
669    }
670
671    #[test]
672    fn autotest_blink_should_blink_reversed_clock_saturates_to_false() {
673        // `now` is *earlier* than the recorded input (clock skew / reordered
674        // events). `Instant::duration_since` saturates to zero rather than
675        // panicking, so the caret stays solid instead of the call blowing up.
676        let base = Instant::now();
677        let mut b = BlinkState::new();
678        b.reset_blink_on_input(plus_ms(&base, 10_000));
679
680        assert!(!b.should_blink(&base));
681        assert!(b.is_visible);
682    }
683
684    #[test]
685    fn autotest_blink_should_blink_mismatched_instant_kinds_are_deterministic() {
686        // A Tick instant compared against a System instant has no meaningful
687        // span: the two counters have no common origin, so `duration_since`
688        // saturates to `Duration::Tick(0)` and nothing is ever "elapsed".
689        // (This is about mismatched INSTANTS, which really are incomparable —
690        // unlike mismatched DURATIONS, which are just two units of the same
691        // thing and now convert.)
692        let mut b = BlinkState::new();
693        b.reset_blink_on_input(Instant::now());
694        let tick_now = Instant::Tick(SystemTick::new(u64::MAX));
695        assert_eq!(b.should_blink(&tick_now), b.should_blink(&tick_now));
696        assert!(!b.should_blink(&tick_now));
697    }
698
699    /// A tick-only clock (no_std, or any clockless build) MUST resume blinking.
700    ///
701    /// Both endpoints are Tick, so the elapsed span is a `Duration::Tick`, which
702    /// is compared against the wall-clock-typed blink interval on a canonical
703    /// scale. Before that comparison was unit-aware, the answer was `false`
704    /// forever and the caret on a clockless build simply never blinked again.
705    #[test]
706    fn autotest_blink_a_tick_only_clock_resumes_blinking_at_the_exact_frame() {
707        let mut t = BlinkState::new();
708        t.reset_blink_on_input(Instant::Tick(SystemTick::new(0)));
709
710        // 530ms is 31.8 frames at 60Hz, so frame 31 is early and frame 32 blinks.
711        assert!(!t.should_blink(&Instant::Tick(SystemTick::new(31))));
712        assert!(t.should_blink(&Instant::Tick(SystemTick::new(32))));
713        assert!(t.should_blink(&Instant::Tick(SystemTick::new(u64::MAX))));
714    }
715
716    /// The whole point of the `t` unit: a blink interval expressed in FRAMES
717    /// flips on exactly the Nth frame — frame N-1 is solid, frame N blinks.
718    /// There is no rounding, no clock, and nothing for a slow machine to shift.
719    #[test]
720    fn autotest_blink_a_tick_interval_flips_on_exactly_the_nth_frame() {
721        let mut b = BlinkState::new();
722        b.set_blink_interval(Duration::from_ticks(5));
723        b.reset_blink_on_input(Instant::Tick(SystemTick::new(100)));
724
725        for frame in 100..=105 {
726            assert!(
727                !b.should_blink(&Instant::Tick(SystemTick::new(frame))),
728                "frame {frame} is within 5 frames of the input and must stay solid"
729            );
730        }
731        assert!(
732            b.should_blink(&Instant::Tick(SystemTick::new(106))),
733            "frame 106 is strictly more than 5 frames past the input"
734        );
735    }
736
737    /// The same tick interval, driven off a WALL-CLOCK instant: 5 frames is
738    /// 83.33ms, so 83ms is solid and 84ms blinks. A `5t` stylesheet value
739    /// therefore behaves identically on a desktop shell and on a clockless one.
740    #[test]
741    fn autotest_blink_a_tick_interval_converts_on_a_wall_clock_instant() {
742        let base = Instant::now();
743        let mut b = BlinkState::new();
744        b.set_blink_interval(Duration::from_ticks(5));
745        b.reset_blink_on_input(base.clone());
746
747        assert!(!b.should_blink(&plus_ms(&base, 83)));
748        assert!(b.should_blink(&plus_ms(&base, 84)));
749    }
750
751    #[test]
752    fn autotest_blink_clear_resets_every_field_and_is_idempotent() {
753        let mut b = BlinkState::new();
754        b.reset_blink_on_input(Instant::now());
755        b.set_blink_timer_active(true);
756        b.set_blink_interval(Duration::from_ticks(5));
757
758        b.clear();
759        assert!(!b.is_visible);
760        assert!(b.last_input_time.is_none());
761        assert!(!b.is_blink_timer_active());
762        assert_eq!(
763            b.blink_interval, CURSOR_BLINK_INTERVAL,
764            "the previous node's caret-animation-duration must not leak to the next"
765        );
766
767        // Clearing an already-cleared state must not panic or resurrect anything.
768        b.clear();
769        assert!(!b.is_visible);
770        assert!(b.last_input_time.is_none());
771        assert!(!b.is_blink_timer_active());
772        // With no last input, blinking is unblocked again.
773        assert!(b.should_blink(&Instant::now()));
774    }
775
776    // ------------------------------------------------------------------
777    // TextEditManager — construction / predicates / getters
778    // ------------------------------------------------------------------
779
780    #[test]
781    fn autotest_manager_new_invariants() {
782        let m = TextEditManager::new();
783        assert!(m.multi_cursor.is_none());
784        assert!(!m.has_active_editing());
785        assert!(m.get_editing_dom_id().is_none());
786        assert!(m.get_editing_node_id().is_none());
787        assert!(m.get_primary_cursor().is_none());
788        assert!(!m.should_draw_cursor());
789        assert!(m.preedit_text.is_none());
790        assert_eq!(m.preedit_cursor_begin, -1, "-1 is the 'unset' IME sentinel");
791        assert_eq!(m.preedit_cursor_end, -1);
792        assert!(!m.display_list_dirty, "a fresh manager owes no repaint");
793        assert!(m.build_cursor_locations().is_empty());
794        assert!(m.build_text_selections_map().is_empty());
795        assert_eq!(m, TextEditManager::default());
796    }
797
798    #[test]
799    fn autotest_manager_mark_dirty_is_sticky() {
800        let mut m = TextEditManager::new();
801        m.mark_dirty();
802        assert!(m.display_list_dirty);
803        m.mark_dirty();
804        assert!(m.display_list_dirty, "marking twice must not toggle it off");
805    }
806
807    #[test]
808    fn autotest_manager_partial_eq_ignores_transient_state() {
809        // Documented contract: only `multi_cursor` participates in equality.
810        let mut a = TextEditManager::new();
811        let mut b = TextEditManager::new();
812        assert_eq!(a, b);
813
814        a.set_preedit("か".to_string(), 0, 3);
815        a.blink.set_visibility(true);
816        a.mark_dirty();
817        assert_eq!(a, b, "preedit / blink / dirty are transient visual state");
818
819        b.initialize_editing(cursor(0, 0), DOM0, NodeId::ZERO, 1);
820        assert_ne!(a, b, "a live editing session is not equal to no session");
821    }
822
823    // ------------------------------------------------------------------
824    // TextEditManager — initialize_editing (numeric edges)
825    // ------------------------------------------------------------------
826
827    #[test]
828    fn autotest_initialize_editing_at_zero() {
829        let mut m = TextEditManager::new();
830        m.initialize_editing(cursor(0, 0), DOM0, NodeId::ZERO, 0);
831
832        assert!(m.has_active_editing());
833        assert_eq!(m.get_editing_dom_id(), Some(DOM0));
834        assert_eq!(
835            m.get_editing_node_id(),
836            Some(NodeId::ZERO),
837            "node index 0 must not be confused with the 'no node' encoding"
838        );
839        assert_eq!(m.get_primary_cursor(), Some(cursor(0, 0)));
840        assert_eq!(
841            m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
842            Some(0)
843        );
844        assert!(m.blink.is_visible);
845        // The caret is SOLID for the first half-period after focus, so the blink
846        // phase must be ANCHORED here. `None` would mean "no input ever", for
847        // which `should_blink` is true immediately and the timer's first tick
848        // hides the caret the user just placed. See `initialize_editing`.
849        let anchored = m
850            .blink
851            .last_input_time
852            .as_ref()
853            .expect("initialize_editing must anchor the blink phase, not clear it");
854        assert!(
855            !m.blink.should_blink(anchored),
856            "at the instant of focus, zero time has elapsed — blinking must not be allowed yet"
857        );
858        assert!(m.should_draw_cursor());
859        assert!(m.display_list_dirty);
860        assert_eq!(
861            m.build_cursor_locations(),
862            vec![(DOM0, NodeId::ZERO, cursor(0, 0))]
863        );
864    }
865
866    #[test]
867    fn autotest_initialize_editing_at_integer_extremes() {
868        // Max representable node index, max DomId, max contenteditable key, and a
869        // cursor at the top of the u32 grapheme-coordinate space.
870        let node = NodeId::new(MAX_ENCODABLE_NODE);
871        let extreme_cursor = TextCursor {
872            cluster_id: GraphemeClusterId {
873                source_run: u32::MAX,
874                start_byte_in_run: u32::MAX,
875            },
876            affinity: CursorAffinity::Trailing,
877        };
878
879        let mut m = TextEditManager::new();
880        m.initialize_editing(extreme_cursor, DOM_MAX, node, u64::MAX);
881
882        assert_eq!(m.get_editing_dom_id(), Some(DOM_MAX));
883        assert_eq!(
884            m.get_editing_node_id(),
885            Some(node),
886            "usize::MAX - 1 is the largest 1-based-encodable node index"
887        );
888        assert_eq!(m.get_primary_cursor(), Some(extreme_cursor));
889        assert_eq!(
890            m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
891            Some(u64::MAX),
892            "the contenteditable key is opaque — u64::MAX must survive verbatim"
893        );
894        assert_eq!(
895            m.build_cursor_locations(),
896            vec![(DOM_MAX, node, extreme_cursor)]
897        );
898    }
899
900    #[test]
901    fn autotest_initialize_editing_overwrites_previous_session() {
902        let mut m = TextEditManager::new();
903        m.initialize_editing(cursor(1, 1), DOM0, NodeId::new(7), 111);
904        m.initialize_editing(cursor(2, 2), DOM1, NodeId::new(9), 222);
905
906        assert_eq!(m.get_editing_dom_id(), Some(DOM1));
907        assert_eq!(m.get_editing_node_id(), Some(NodeId::new(9)));
908        assert_eq!(m.get_primary_cursor(), Some(cursor(2, 2)));
909        assert_eq!(
910            m.build_cursor_locations().len(),
911            1,
912            "re-initializing replaces the cursor set, it does not accumulate"
913        );
914        assert_eq!(
915            m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
916            Some(222)
917        );
918    }
919
920    #[test]
921    fn autotest_initialize_editing_clears_stale_preedit() {
922        let mut m = TextEditManager::new();
923        m.set_preedit("漢字".to_string(), 3, 6);
924        m.initialize_editing(cursor(0, 0), DOM0, NodeId::new(4), 42);
925
926        assert!(
927            m.preedit_text.is_none(),
928            "focusing a new element must drop the old composition"
929        );
930        assert_eq!(m.preedit_cursor_begin, -1);
931        assert_eq!(m.preedit_cursor_end, -1);
932    }
933
934    // ------------------------------------------------------------------
935    // TextEditManager — clear_editing
936    // ------------------------------------------------------------------
937
938    #[test]
939    fn autotest_clear_editing_on_fresh_manager_is_safe() {
940        let mut m = TextEditManager::new();
941        m.clear_editing();
942        m.clear_editing();
943
944        assert!(!m.has_active_editing());
945        assert!(!m.should_draw_cursor());
946        assert!(m.build_cursor_locations().is_empty());
947        // A fresh manager has no cursor, no blink and no preedit, so clearing it
948        // erases NOTHING and owes no repaint. This assertion used to read
949        // `assert!(m.display_list_dirty, "clear_editing marks dirty
950        // unconditionally")` — it documented the behaviour as found rather than
951        // as intended, and what it documented was a bug: `clear_editing` runs on
952        // every focus change, `display_list_dirty` is a LATCH, and so one Tab
953        // between two never-editable nodes left the window owing a repaint on
954        // every frame for the rest of its life.
955        assert!(
956            !m.display_list_dirty,
957            "clearing an already-clear manager must not request a repaint"
958        );
959    }
960
961    #[test]
962    fn autotest_clear_editing_tears_down_everything() {
963        let mut m = TextEditManager::new();
964        m.initialize_editing(cursor(0, 5), DOM0, NodeId::new(3), 77);
965        m.set_preedit("ab".to_string(), 0, 2);
966        m.blink.set_blink_timer_active(true);
967        m.blink.reset_blink_on_input(Instant::now());
968
969        m.clear_editing();
970
971        assert!(m.multi_cursor.is_none());
972        assert!(!m.has_active_editing());
973        assert!(m.get_editing_dom_id().is_none());
974        assert!(m.get_editing_node_id().is_none());
975        assert!(m.get_primary_cursor().is_none());
976        assert!(!m.should_draw_cursor());
977        assert!(!m.blink.is_visible);
978        assert!(!m.blink.is_blink_timer_active());
979        assert!(m.blink.last_input_time.is_none());
980        assert!(m.preedit_text.is_none());
981        assert_eq!(m.preedit_cursor_begin, -1);
982        assert_eq!(m.preedit_cursor_end, -1);
983        assert!(m.display_list_dirty);
984        assert!(m.build_cursor_locations().is_empty());
985        assert!(m.build_text_selections_map().is_empty());
986    }
987
988    // ------------------------------------------------------------------
989    // TextEditManager — IME preedit (numeric edges + unicode)
990    // ------------------------------------------------------------------
991
992    #[test]
993    fn autotest_set_preedit_zero_offsets_are_not_the_unset_sentinel() {
994        let mut m = TextEditManager::new();
995        m.set_preedit("a".to_string(), 0, 0);
996
997        assert_eq!(m.preedit_text.as_deref(), Some("a"));
998        assert_eq!(
999            m.preedit_cursor_begin, 0,
1000            "0 is a valid offset and must not be coerced to the -1 sentinel"
1001        );
1002        assert_eq!(m.preedit_cursor_end, 0);
1003        assert!(m.display_list_dirty);
1004    }
1005
1006    #[test]
1007    fn autotest_set_preedit_stores_i32_extremes_verbatim() {
1008        let mut m = TextEditManager::new();
1009
1010        m.set_preedit("x".to_string(), i32::MIN, i32::MAX);
1011        assert_eq!(m.preedit_cursor_begin, i32::MIN);
1012        assert_eq!(m.preedit_cursor_end, i32::MAX);
1013
1014        // Negative (non-sentinel) values and an inverted begin > end range are
1015        // stored as-is: the manager performs no arithmetic on them, so there is
1016        // nothing to overflow. Consumers must clamp.
1017        m.set_preedit("x".to_string(), -42, -7);
1018        assert_eq!(m.preedit_cursor_begin, -42);
1019        assert_eq!(m.preedit_cursor_end, -7);
1020
1021        m.set_preedit("x".to_string(), 10, 2);
1022        assert_eq!(m.preedit_cursor_begin, 10);
1023        assert_eq!(m.preedit_cursor_end, 2);
1024    }
1025
1026    #[test]
1027    fn autotest_set_preedit_offsets_beyond_text_length_are_not_validated() {
1028        // A hostile / buggy IME can report offsets far outside the string. The
1029        // setter must not panic and must not silently rewrite them — it stores
1030        // them verbatim, which is the contract callers have to defend against.
1031        let mut m = TextEditManager::new();
1032        m.set_preedit("ab".to_string(), i32::MAX, i32::MAX);
1033
1034        assert_eq!(m.preedit_text.as_deref(), Some("ab"));
1035        assert_eq!(m.preedit_cursor_begin, i32::MAX);
1036        assert_eq!(m.preedit_cursor_end, i32::MAX);
1037    }
1038
1039    #[test]
1040    fn autotest_set_preedit_empty_text_becomes_none_but_keeps_offsets() {
1041        // Documented behaviour of `set_preedit`: an empty composition string maps
1042        // to `None`, yet the offsets are still overwritten with whatever the IME
1043        // passed. The result is a `None` text with non-sentinel offsets — callers
1044        // must key off `preedit_text`, not off the offsets.
1045        let mut m = TextEditManager::new();
1046        m.set_preedit(String::new(), 5, 9);
1047
1048        assert!(m.preedit_text.is_none());
1049        assert_eq!(m.preedit_cursor_begin, 5);
1050        assert_eq!(m.preedit_cursor_end, 9);
1051        assert!(m.display_list_dirty);
1052    }
1053
1054    #[test]
1055    fn autotest_set_preedit_preserves_unicode_verbatim() {
1056        let mut m = TextEditManager::new();
1057
1058        for text in [
1059            "こんにちは",                 // CJK — the common IME case
1060            "👨‍👩‍👧‍👦",                        // ZWJ emoji family (one grapheme, many bytes)
1061            "e\u{0301}\u{0327}",          // combining acute + cedilla
1062            "مرحبا",                      // RTL
1063            "a\u{0000}b",                 // interior NUL
1064            "\u{FEFF}bom",                // byte-order mark
1065            "🇩🇪🇯🇵",                        // regional-indicator flags
1066        ] {
1067            m.set_preedit(text.to_string(), 0, 1);
1068            assert_eq!(
1069                m.preedit_text.as_deref(),
1070                Some(text),
1071                "preedit text must round-trip byte-for-byte"
1072            );
1073        }
1074    }
1075
1076    #[test]
1077    fn autotest_set_preedit_huge_text_does_not_panic() {
1078        let huge = "あ".repeat(100_000); // 300_000 bytes
1079        let mut m = TextEditManager::new();
1080        m.set_preedit(huge.clone(), 0, 299_999);
1081
1082        assert_eq!(m.preedit_text.as_deref(), Some(huge.as_str()));
1083        assert_eq!(m.preedit_text.as_ref().map(String::len), Some(300_000));
1084    }
1085
1086    #[test]
1087    fn autotest_clear_preedit_is_idempotent_and_marks_dirty() {
1088        let mut m = TextEditManager::new();
1089        m.set_preedit("ば".to_string(), 0, 3);
1090
1091        m.clear_preedit();
1092        assert!(m.preedit_text.is_none());
1093        assert_eq!(m.preedit_cursor_begin, -1);
1094        assert_eq!(m.preedit_cursor_end, -1);
1095
1096        m.display_list_dirty = false;
1097        m.clear_preedit();
1098        assert!(m.preedit_text.is_none());
1099        assert_eq!(m.preedit_cursor_begin, -1);
1100        assert_eq!(m.preedit_cursor_end, -1);
1101        // The FIRST clear (above) really did clear a preedit and correctly marked
1102        // dirty. This second one has nothing left to clear, so it must not.
1103        // Previously asserted the opposite, in as many words: "clear_preedit
1104        // marks dirty even when nothing changed".
1105        assert!(
1106            !m.display_list_dirty,
1107            "a no-op clear_preedit must not request a repaint"
1108        );
1109    }
1110
1111    #[test]
1112    fn autotest_preedit_does_not_create_an_editing_session() {
1113        let mut m = TextEditManager::new();
1114        m.set_preedit("compose".to_string(), 0, 7);
1115
1116        assert!(
1117            !m.has_active_editing(),
1118            "IME text alone must not fake an editing session"
1119        );
1120        assert!(!m.should_draw_cursor());
1121        assert!(m.get_primary_cursor().is_none());
1122    }
1123
1124    // ------------------------------------------------------------------
1125    // TextEditManager — build_cursor_locations
1126    // ------------------------------------------------------------------
1127
1128    #[test]
1129    fn autotest_build_cursor_locations_empty_without_session() {
1130        assert!(TextEditManager::new().build_cursor_locations().is_empty());
1131    }
1132
1133    #[test]
1134    fn autotest_build_cursor_locations_uses_range_end_and_keeps_order() {
1135        let node = NodeId::new(12);
1136        let a = cursor(0, 0);
1137        let b = cursor(0, 4);
1138        let c = cursor(1, 8);
1139
1140        let mut m = TextEditManager::new();
1141        m.multi_cursor = Some(multi_cursor_with(
1142            dom_node(DOM1, Some(node)),
1143            vec![
1144                Selection::Cursor(a),
1145                Selection::Range(range(b, c)),
1146                Selection::Cursor(c),
1147            ],
1148            5,
1149        ));
1150
1151        assert_eq!(
1152            m.build_cursor_locations(),
1153            vec![(DOM1, node, a), (DOM1, node, c), (DOM1, node, c)],
1154            "a Range contributes its `end` as the caret position"
1155        );
1156    }
1157
1158    #[test]
1159    fn autotest_build_cursor_locations_with_detached_node_is_empty() {
1160        // A `MultiCursorState` whose node encodes "no node" must yield nothing
1161        // rather than panicking or fabricating NodeId(0).
1162        let mut m = TextEditManager::new();
1163        m.multi_cursor = Some(multi_cursor_with(
1164            dom_node(DOM0, None),
1165            vec![Selection::Cursor(cursor(0, 0))],
1166            1,
1167        ));
1168
1169        assert!(m.has_active_editing());
1170        assert!(m.get_editing_node_id().is_none());
1171        assert!(m.build_cursor_locations().is_empty());
1172        assert!(m.build_text_selections_map().is_empty());
1173    }
1174
1175    #[test]
1176    fn autotest_build_cursor_locations_with_no_selections_is_empty() {
1177        let mut m = TextEditManager::new();
1178        m.multi_cursor = Some(multi_cursor_with(
1179            dom_node(DOM0, Some(NodeId::ZERO)),
1180            Vec::new(),
1181            0,
1182        ));
1183
1184        assert!(m.build_cursor_locations().is_empty());
1185        assert!(m.get_primary_cursor().is_none());
1186        assert!(m.build_text_selections_map().is_empty());
1187    }
1188
1189    #[test]
1190    fn autotest_build_cursor_locations_scales_to_many_cursors() {
1191        let node = NodeId::new(2);
1192        let selections: Vec<Selection> = (0..1000u32)
1193            .map(|i| Selection::Cursor(cursor(0, i)))
1194            .collect();
1195
1196        let mut m = TextEditManager::new();
1197        m.multi_cursor = Some(multi_cursor_with(
1198            dom_node(DOM0, Some(node)),
1199            selections,
1200            9,
1201        ));
1202
1203        let locations = m.build_cursor_locations();
1204        assert_eq!(locations.len(), 1000);
1205        assert_eq!(locations[0], (DOM0, node, cursor(0, 0)));
1206        assert_eq!(locations[999], (DOM0, node, cursor(0, 999)));
1207    }
1208
1209    // ------------------------------------------------------------------
1210    // TextEditManager — build_text_selections_map
1211    // ------------------------------------------------------------------
1212
1213    #[test]
1214    fn autotest_build_text_selections_map_empty_without_session() {
1215        assert!(TextEditManager::new().build_text_selections_map().is_empty());
1216    }
1217
1218    #[test]
1219    fn autotest_build_text_selections_map_ignores_pure_cursors() {
1220        // Collapsed carets are not selections — nothing to paint.
1221        let mut m = TextEditManager::new();
1222        m.initialize_editing(cursor(0, 3), DOM0, NodeId::new(1), 8);
1223        assert!(m.build_text_selections_map().is_empty());
1224    }
1225
1226    #[test]
1227    fn autotest_build_text_selections_map_single_range() {
1228        let node = NodeId::new(6);
1229        let start = cursor(0, 2);
1230        let end = cursor(0, 9);
1231
1232        let mut m = TextEditManager::new();
1233        m.multi_cursor = Some(multi_cursor_with(
1234            dom_node(DOM1, Some(node)),
1235            vec![Selection::Range(range(start, end))],
1236            3,
1237        ));
1238
1239        let map = m.build_text_selections_map();
1240        assert_eq!(map.len(), 1);
1241        let sel = map.get(&DOM1).expect("keyed by the editing DomId");
1242        assert_eq!(sel.dom_id, DOM1);
1243        assert_eq!(sel.anchor.ifc_root_node_id, node);
1244        assert_eq!(sel.anchor.cursor, start);
1245        assert_eq!(sel.focus.ifc_root_node_id, node);
1246        assert_eq!(sel.focus.cursor, end);
1247        assert!(sel.is_forward);
1248        assert_eq!(sel.affected_nodes.len(), 1);
1249        assert_eq!(sel.affected_nodes.get(&node), Some(&range(start, end)));
1250    }
1251
1252    #[test]
1253    fn autotest_build_text_selections_map_backward_range_is_reported_forward() {
1254        // A backward drag (start after end) is still emitted with `is_forward:
1255        // true` — the flag is hard-coded. Pinning the current behaviour so a
1256        // future direction fix has to update this test deliberately.
1257        let node = NodeId::new(6);
1258        let start = cursor(0, 9);
1259        let end = cursor(0, 2);
1260
1261        let mut m = TextEditManager::new();
1262        m.multi_cursor = Some(multi_cursor_with(
1263            dom_node(DOM0, Some(node)),
1264            vec![Selection::Range(range(start, end))],
1265            3,
1266        ));
1267
1268        let map = m.build_text_selections_map();
1269        let sel = map.get(&DOM0).expect("keyed by the editing DomId");
1270        assert_eq!(sel.anchor.cursor, start);
1271        assert_eq!(sel.focus.cursor, end);
1272        assert!(sel.is_forward);
1273    }
1274
1275    #[test]
1276    fn autotest_build_text_selections_map_multi_range_first_wins_endpoints() {
1277        // Documented limitation: only one range per node survives. What is NOT
1278        // documented is that the two halves disagree — `affected_nodes` keeps the
1279        // LAST range (each insert overwrites the same NodeId key) while the
1280        // anchor/focus endpoints come from the FIRST. Pinned deliberately.
1281        let node = NodeId::new(4);
1282        let first = range(cursor(0, 0), cursor(0, 1));
1283        let last = range(cursor(0, 5), cursor(0, 8));
1284
1285        let mut m = TextEditManager::new();
1286        m.multi_cursor = Some(multi_cursor_with(
1287            dom_node(DOM0, Some(node)),
1288            vec![
1289                Selection::Range(first),
1290                Selection::Cursor(cursor(0, 3)),
1291                Selection::Range(last),
1292            ],
1293            2,
1294        ));
1295
1296        let map = m.build_text_selections_map();
1297        assert_eq!(map.len(), 1, "one entry per DomId, not per range");
1298        let sel = map.get(&DOM0).expect("keyed by the editing DomId");
1299        assert_eq!(sel.anchor.cursor, first.start, "endpoints from the FIRST range");
1300        assert_eq!(sel.focus.cursor, first.end);
1301        assert_eq!(
1302            sel.affected_nodes.get(&node),
1303            Some(&last),
1304            "affected_nodes keeps the LAST range — it disagrees with anchor/focus"
1305        );
1306    }
1307
1308    #[test]
1309    fn autotest_build_text_selections_map_degenerate_and_extreme_ranges() {
1310        let node = NodeId::new(MAX_ENCODABLE_NODE);
1311        // Zero-width range (start == end) at the top of the coordinate space.
1312        let point = cursor(u32::MAX, u32::MAX);
1313
1314        let mut m = TextEditManager::new();
1315        m.multi_cursor = Some(multi_cursor_with(
1316            dom_node(DOM_MAX, Some(node)),
1317            vec![Selection::Range(range(point, point))],
1318            u64::MAX,
1319        ));
1320
1321        let map = m.build_text_selections_map();
1322        let sel = map.get(&DOM_MAX).expect("keyed by the editing DomId");
1323        assert_eq!(sel.anchor.cursor, point);
1324        assert_eq!(sel.focus.cursor, point);
1325        assert_eq!(sel.affected_nodes.get(&node), Some(&range(point, point)));
1326    }
1327
1328    // ------------------------------------------------------------------
1329    // TextEditManager — NodeIdRemap (DOM rebuild)
1330    // ------------------------------------------------------------------
1331
1332    #[test]
1333    fn autotest_remap_without_session_is_a_noop() {
1334        let mut m = TextEditManager::new();
1335        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::ZERO, NodeId::new(1))]));
1336
1337        assert!(!m.has_active_editing());
1338        assert!(
1339            !m.display_list_dirty,
1340            "nothing changed, so nothing to repaint"
1341        );
1342    }
1343
1344    #[test]
1345    fn autotest_remap_rewrites_surviving_node() {
1346        let mut m = TextEditManager::new();
1347        m.initialize_editing(cursor(0, 2), DOM0, NodeId::new(3), 55);
1348        m.set_preedit("ok".to_string(), 0, 2);
1349
1350        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(8))]));
1351
1352        assert!(m.has_active_editing());
1353        assert_eq!(m.get_editing_node_id(), Some(NodeId::new(8)));
1354        assert_eq!(m.get_editing_dom_id(), Some(DOM0));
1355        assert_eq!(m.get_primary_cursor(), Some(cursor(0, 2)));
1356        assert_eq!(
1357            m.preedit_text.as_deref(),
1358            Some("ok"),
1359            "a surviving node keeps its in-flight composition"
1360        );
1361        assert_eq!(
1362            m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
1363            Some(55),
1364            "the stable key must survive the rebuild"
1365        );
1366    }
1367
1368    #[test]
1369    fn autotest_remap_drops_session_when_node_unmounted() {
1370        let mut m = TextEditManager::new();
1371        m.initialize_editing(cursor(0, 1), DOM0, NodeId::new(3), 55);
1372        m.set_preedit("gone".to_string(), 1, 4);
1373        m.display_list_dirty = false;
1374
1375        // The rebuilt DOM matched some *other* node — 3 is unmounted.
1376        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(4), NodeId::new(4))]));
1377
1378        assert!(!m.has_active_editing());
1379        assert!(m.multi_cursor.is_none());
1380        assert!(m.preedit_text.is_none());
1381        assert_eq!(m.preedit_cursor_begin, -1);
1382        assert_eq!(m.preedit_cursor_end, -1);
1383        assert!(m.display_list_dirty);
1384        assert!(m.build_cursor_locations().is_empty());
1385    }
1386
1387    #[test]
1388    fn autotest_remap_with_empty_map_drops_session() {
1389        let mut m = TextEditManager::new();
1390        m.initialize_editing(cursor(0, 0), DOM0, NodeId::ZERO, 1);
1391        m.remap_node_ids(DOM0, &NodeIdMap::default());
1392
1393        assert!(
1394            !m.has_active_editing(),
1395            "an empty map means every node was unmounted"
1396        );
1397    }
1398
1399    #[test]
1400    fn autotest_remap_leaves_other_doms_alone() {
1401        let mut m = TextEditManager::new();
1402        m.initialize_editing(cursor(0, 0), DOM1, NodeId::new(3), 1);
1403
1404        // A reconciliation of DOM0 says nothing about a cursor living in DOM1.
1405        m.remap_node_ids(DOM0, &NodeIdMap::default());
1406
1407        assert!(m.has_active_editing());
1408        assert_eq!(m.get_editing_dom_id(), Some(DOM1));
1409        assert_eq!(m.get_editing_node_id(), Some(NodeId::new(3)));
1410    }
1411
1412    #[test]
1413    fn autotest_remap_of_detached_node_drops_session() {
1414        let mut m = TextEditManager::new();
1415        m.multi_cursor = Some(multi_cursor_with(
1416            dom_node(DOM0, None),
1417            vec![Selection::Cursor(cursor(0, 0))],
1418            1,
1419        ));
1420
1421        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::ZERO, NodeId::ZERO)]));
1422
1423        assert!(
1424            !m.has_active_editing(),
1425            "a cursor with no IFC root is not an editing session"
1426        );
1427        assert!(m.display_list_dirty);
1428    }
1429}