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

            
12
use alloc::sync::Arc;
13
use core::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
14

            
15
use azul_core::{
16
    dom::{DomId, DomNodeId, NodeId},
17
    geom::LogicalRect,
18
    selection::{MultiCursorState, Selection, SelectionRange, TextCursor},
19
    styled_dom::NodeHierarchyItemId,
20
    task::{Duration, Instant},
21
};
22

            
23

            
24
/// Default cursor blink interval in milliseconds
25
pub const CURSOR_BLINK_INTERVAL_MS: u64 = 530;
26

            
27
/// Default cursor blink interval as a variant-agnostic [`Duration`].
28
///
29
/// The interval is a `Duration`, not a bare `u64` of milliseconds, so a
30
/// stylesheet can express it in the clockless `t` unit (`caret-animation-duration:
31
/// 5t`) and have it survive all the way to the comparison. `Duration`'s
32
/// comparisons are unit-aware, so a tick-unit interval and a wall-clock elapsed
33
/// value (or vice versa) still compare truthfully.
34
pub const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(CURSOR_BLINK_INTERVAL_MS);
35

            
36
/// Cursor blink animation state.
37
///
38
/// Extracted from the old `CursorManager` so it can live independently
39
/// on `TextEditManager` without coupling to cursor position.
40
#[derive(Debug, Clone)]
41
pub struct BlinkState {
42
    /// Whether the cursor is currently visible (toggled by blink timer)
43
    pub is_visible: bool,
44
    /// Timestamp of the last user input event (keyboard, mouse click in text).
45
    /// Used to determine whether to blink or stay solid while typing.
46
    pub last_input_time: Option<Instant>,
47
    /// Whether the cursor blink timer is currently active
48
    pub blink_timer_active: bool,
49
    /// How long the caret stays solid after input before blinking resumes, and
50
    /// the interval the blink timer is armed with.
51
    ///
52
    /// Defaults to [`CURSOR_BLINK_INTERVAL`]; `caret-animation-duration` on the
53
    /// focused node overrides it, in whichever unit the stylesheet used.
54
    pub blink_interval: Duration,
55
}
56

            
57
impl Default for BlinkState {
58
5647
    fn default() -> Self {
59
5647
        Self {
60
5647
            is_visible: false,
61
5647
            last_input_time: None,
62
5647
            blink_timer_active: false,
63
5647
            blink_interval: CURSOR_BLINK_INTERVAL,
64
5647
        }
65
5647
    }
66
}
67

            
68

            
69
impl BlinkState {
70
5647
    #[must_use] pub fn new() -> Self { Self::default() }
71

            
72
    /// Override the blink interval (from `caret-animation-duration`).
73
    ///
74
    /// Takes a [`Duration`] rather than milliseconds so a `t`-unit stylesheet
75
    /// value stays a frame count: a `5t` caret flips on the 5th frame exactly,
76
    /// on any machine, at any load.
77
    ///
78
    /// Prefer [`Self::adopt_blink_interval`] on a focus change: a RUNNING timer
79
    /// does not pick the new value up on its own.
80
4
    pub const fn set_blink_interval(&mut self, interval: Duration) {
81
4
        self.blink_interval = interval;
82
4
    }
83

            
84
    /// Adopt `interval`, reporting whether it actually CHANGED.
85
    ///
86
    /// The blink timer bakes the interval into the `Timer` once, at
87
    /// construction (`LayoutWindow::create_cursor_blink_timer`), so a timer
88
    /// that is already running keeps the PREVIOUS node's period no matter what
89
    /// this state says. Refocusing between two editables with different
90
    /// `caret-animation-duration` must therefore rebuild the timer — and only
91
    /// then, because rebuilding it on every focus change would restart the
92
    /// blink phase for nothing.
93
    ///
94
    /// This is the predicate that decides (see `HANDOFF-text-fix.md` for the
95
    /// `CursorBlinkTimerAction::Restart` half). A change of UNIT is a change:
96
    /// `5t` and `530ms` are different intervals, not one interval spelled two
97
    /// ways — the tick-unit caret must stay clockless.
98
    #[must_use]
99
39
    pub fn adopt_blink_interval(&mut self, interval: Duration) -> bool {
100
39
        let changed = self.blink_interval != interval;
101
39
        self.blink_interval = interval;
102
39
        changed
103
39
    }
104

            
105
    /// Reset blink on user input — cursor stays solid until blink interval elapses.
106
3024
    pub fn reset_blink_on_input(&mut self, now: Instant) {
107
3024
        self.is_visible = true;
108
3024
        self.last_input_time = Some(now);
109
3024
    }
110

            
111
    /// Toggle cursor visibility (called by blink timer callback).
112
1005
    pub const fn toggle_visibility(&mut self) -> bool {
113
1005
        self.is_visible = !self.is_visible;
114
1005
        self.is_visible
115
1005
    }
116

            
117
50
    pub const fn set_visibility(&mut self, visible: bool) {
118
50
        self.is_visible = visible;
119
50
    }
120

            
121
43
    pub const fn set_blink_timer_active(&mut self, active: bool) {
122
43
        self.blink_timer_active = active;
123
43
    }
124

            
125
91
    #[must_use] pub const fn is_blink_timer_active(&self) -> bool {
126
91
        self.blink_timer_active
127
91
    }
128

            
129
    /// Check if enough time has passed since last input to start blinking.
130
    ///
131
    /// The interval is [`Self::blink_interval`], compared unit-aware: a tick
132
    /// interval against wall-clock elapsed time (or the reverse) both answer
133
    /// truthfully. This used to build a `Duration::System` constant inline, which
134
    /// meant a tick-driven clock produced a `Duration::Tick` elapsed value that
135
    /// could never be "greater than" it — the caret stopped blinking, silently
136
    /// and permanently, on every clockless build.
137
33
    #[must_use] pub fn should_blink(&self, now: &Instant) -> bool {
138
33
        self.last_input_time.as_ref().is_none_or(|last_input| {
139
29
                now.duration_since(last_input).greater_than(&self.blink_interval)
140
29
            })
141
33
    }
142

            
143
    /// Clear all blink state (when editing ends).
144
    ///
145
    /// The interval goes back to the default too: it was read off the node that
146
    /// just lost focus, and leaving it behind would apply that node's
147
    /// `caret-animation-duration` to the next element focused — including one
148
    /// that never set the property.
149
74
    pub fn clear(&mut self) {
150
74
        self.is_visible = false;
151
74
        self.last_input_time = None;
152
74
        self.blink_timer_active = false;
153
74
        self.blink_interval = CURSOR_BLINK_INTERVAL;
154
74
    }
155
}
156

            
157
/// One in-flight caret tween: the caret is gliding from `from` (what the
158
/// previous frame rendered) toward wherever the current layout puts it.
159
#[derive(Debug, Clone)]
160
pub struct CaretTweenTrack {
161
    /// Rect the tween starts from (the previously RENDERED rect, so a
162
    /// mid-flight retarget stays continuous).
163
    pub from: LogicalRect,
164
    /// Rect the tween is seeking. Retargeting happens ONLY when the layout's
165
    /// current rect stops matching this — comparing against the rendered
166
    /// rect would re-arm (and restart the clock) every animation tick.
167
    pub to: LogicalRect,
168
    /// When the tween (re)started.
169
    pub start: Instant,
170
}
171

            
172
/// One in-flight selection tween (same contract as [`CaretTweenTrack`],
173
/// for the whole selection band geometry).
174
#[derive(Debug, Clone)]
175
pub struct SelectionTweenTrack {
176
    pub from: Vec<LogicalRect>,
177
    /// Geometry the tween is seeking (same retarget contract as
178
    /// [`CaretTweenTrack::to`]).
179
    pub to: Vec<LogicalRect>,
180
    pub start: Instant,
181
}
182

            
183
/// Caret / selection tween bookkeeping, updated by the display-list
184
/// post-pass (`LayoutWindow::apply_text_tweens`). `tick_flag` is shared
185
/// with the tween timer's `RefAny` data so the timer can terminate itself
186
/// the tick after both tweens finish.
187
#[derive(Debug, Default)]
188
pub struct TextTweenState {
189
    /// DOM the tracked geometry belongs to. A caret/selection appearing on a
190
    /// DIFFERENT dom resets tracking (no cross-dom tween).
191
    pub dom_id: Option<DomId>,
192
    /// Node the tracked caret/selection geometry belongs to — the editing
193
    /// session's node, maintained by [`TextEditManager`].
194
    ///
195
    /// Without it the geometry is unattributable, and a DOM reconcile that
196
    /// moves or unmounts the edited node leaves `last_caret`/`last_selection`
197
    /// describing a rectangle that belongs to nothing: the next frame then
198
    /// glides the caret across the screen from a dead rect.
199
    pub node: Option<DomNodeId>,
200
    /// In-flight caret tween, if any.
201
    pub caret: Option<CaretTweenTrack>,
202
    /// Caret rect the last display-list pass RENDERED (tween target space).
203
    pub last_caret: Option<LogicalRect>,
204
    /// In-flight selection tween, if any.
205
    pub selection: Option<SelectionTweenTrack>,
206
    /// Selection rects the last display-list pass RENDERED.
207
    pub last_selection: Vec<LogicalRect>,
208
    /// In-flight focus-ring glide (ledger #29; opt-in via
209
    /// `SystemAnimations.focus_ring_duration_ms`).
210
    pub focus_ring: Option<CaretTweenTrack>,
211
    /// Focus-ring rect the last display-list pass RENDERED.
212
    pub last_focus_ring: Option<LogicalRect>,
213
    /// Shared "a tween is in flight" flag: written by the post-pass, read
214
    /// by `caret_tween_timer_callback` (via its `RefAny`) to self-terminate.
215
    pub tick_flag: Arc<AtomicBool>,
216
}
217

            
218
/// Cloning a manager must NOT share the original's tween-timer flag: the two
219
/// copies would steer one timer, and dropping either would tell that timer the
220
/// other's tweens had finished. So the flag is the ONE field that is not
221
/// shared — the clone gets its own `Arc` holding the same value. Everything
222
/// else is copied, because a `clone()` that quietly returned
223
/// `Self::default()` reported "no tween in flight, no rendered geometry" for a
224
/// manager that had both.
225
impl Clone for TextTweenState {
226
3
    fn clone(&self) -> Self {
227
3
        Self {
228
3
            dom_id: self.dom_id,
229
3
            node: self.node,
230
3
            caret: self.caret.clone(),
231
3
            last_caret: self.last_caret,
232
3
            selection: self.selection.clone(),
233
3
            last_selection: self.last_selection.clone(),
234
3
            focus_ring: self.focus_ring.clone(),
235
3
            last_focus_ring: self.last_focus_ring,
236
3
            tick_flag: Arc::new(AtomicBool::new(
237
3
                self.tick_flag.load(AtomicOrdering::Acquire),
238
3
            )),
239
3
        }
240
3
    }
241
}
242

            
243
impl TextTweenState {
244
    /// True while any tween is mid-flight (drives the 16ms tween timer and
245
    /// forces the caret solid — blinking is suppressed during animation).
246
8394
    #[must_use] pub const fn is_active(&self) -> bool {
247
8394
        self.caret.is_some() || self.selection.is_some() || self.focus_ring.is_some()
248
8394
    }
249

            
250
    /// Publish `is_active()` to the shared flag the tween timer polls.
251
2722
    pub fn publish_active(&self) {
252
2722
        self.tick_flag.store(self.is_active(), AtomicOrdering::Release);
253
2722
    }
254

            
255
    /// Reset all tracking (focus lost / editing cleared / dom switched).
256
1
    pub fn reset(&mut self) {
257
1
        self.dom_id = None;
258
1
        self.node = None;
259
1
        self.caret = None;
260
1
        self.last_caret = None;
261
1
        self.selection = None;
262
1
        self.last_selection.clear();
263
1
        self.focus_ring = None;
264
1
        self.last_focus_ring = None;
265
1
        self.publish_active();
266
1
    }
267

            
268
    /// Reset only the TEXT tweens (caret + selection) — the focus ring has
269
    /// its own lifecycle (it runs without an editing session).
270
    ///
271
    /// `node` goes with them: it anchors the caret/selection geometry, not the
272
    /// ring.
273
6
    pub fn reset_text_tweens(&mut self) {
274
6
        self.node = None;
275
6
        self.caret = None;
276
6
        self.last_caret = None;
277
6
        self.selection = None;
278
6
        self.last_selection.clear();
279
6
        self.publish_active();
280
6
    }
281
}
282

            
283
/// The range selections of ONE editing session, as
284
/// [`TextEditManager::session_selection_ranges`] reports them.
285
///
286
/// All ranges of a session live on the same IFC root — `MultiCursorState` is
287
/// single-node by construction; a selection spanning several roots takes the
288
/// `cross_block` path instead.
289
#[derive(Debug, Clone, PartialEq)]
290
pub struct SessionSelectionRanges {
291
    /// DOM the session's node belongs to.
292
    pub dom_id: DomId,
293
    /// IFC root every range is expressed against.
294
    pub node_id: NodeId,
295
    /// Every range, in `MultiCursorState` order (position-sorted,
296
    /// non-overlapping). Never empty.
297
    pub ranges: Vec<SelectionRange>,
298
    /// The primary range: the most recently added one (Ctrl+D's newest
299
    /// occurrence), or the document-first range when the primary selection is
300
    /// a bare caret. Always an element of `ranges`.
301
    pub primary: SelectionRange,
302
}
303

            
304
/// Does `range` run FORWARD — anchor before focus in logical order?
305
///
306
/// `SelectionRange.start` is the ANCHOR and `.end` the FOCUS (the moving end:
307
/// `build_cursor_locations` reads the caret off `.end`), so a backward drag
308
/// arrives with `start > end`. This is the per-range form of the question
309
/// `LayoutWindow::set_cross_block_selection` answers for its node pair. A
310
/// degenerate range counts as forward, matching `TextSelection::new_collapsed`.
311
#[must_use]
312
89
pub fn range_is_forward(range: &SelectionRange) -> bool {
313
89
    range.start <= range.end
314
89
}
315

            
316
/// Unified text editing manager.
317
///
318
/// `multi_cursor` is the single source of truth for cursor/selection positions.
319
/// `blink` manages the caret blink animation.
320
/// `SelectionManager` (sibling module) handles non-editable text drag-select.
321
#[derive(Debug, Clone)]
322
pub struct TextEditManager {
323
    /// Multi-cursor state for contenteditable elements (Sublime Text style).
324
    /// `Some` whenever a contenteditable element has focus.
325
    /// Source of truth for `edit_text()` and display list painting.
326
    pub multi_cursor: Option<MultiCursorState>,
327
    /// Cross-block (multi-IFC-root) selection, render-ready. Precomputed by
328
    /// `LayoutWindow::set_cross_block_selection`; wins over `multi_cursor`
329
    /// in `build_text_selections_map` while set.
330
    pub cross_block: Option<azul_core::selection::TextSelection>,
331
    /// Cursor blink animation state.
332
    pub blink: BlinkState,
333
    /// IME preedit (composition) text currently being composed.
334
    /// Applies to the primary cursor only.
335
    pub preedit_text: Option<String>,
336
    /// Byte offset of cursor within preedit text (from IME), or -1 if unset.
337
    /// Uses -1 sentinel (rather than `Option`) to match platform IME C API conventions.
338
    pub preedit_cursor_begin: i32,
339
    /// Byte offset of cursor end within preedit text (from IME), or -1 if unset.
340
    /// Uses -1 sentinel (rather than `Option`) to match platform IME C API conventions.
341
    pub preedit_cursor_end: i32,
342
    /// Set to true by any mutation that changes visual output.
343
    pub display_list_dirty: bool,
344
    /// Caret / selection tween bookkeeping (see [`TextTweenState`]).
345
    pub tween: TextTweenState,
346
    /// Editing hosts whose text was mutated OUTSIDE the text-input record
347
    /// pipeline this pass (deletions, multi-cursor paste, the Enter line
348
    /// break). The host pass drains this and dispatches an `Input` event per
349
    /// host, so widget mirrors observe every committed edit, not only
350
    /// insertions. Filled by `LayoutWindow::record_text_edit_undo`.
351
    pub pending_edit_notifications: Vec<DomNodeId>,
352
}
353

            
354
impl Default for TextEditManager {
355
1
    fn default() -> Self {
356
1
        Self::new()
357
1
    }
358
}
359

            
360
/// Only compares `multi_cursor` — blink state, preedit, and dirty flag are
361
/// transient visual state that should not affect logical equality of the
362
/// editing session.
363
impl PartialEq for TextEditManager {
364
4
    fn eq(&self, other: &Self) -> bool {
365
4
        self.multi_cursor == other.multi_cursor
366
4
    }
367
}
368

            
369
impl TextEditManager {
370
    /// Create a new text edit manager with no active editing state
371
5632
    #[must_use] pub fn new() -> Self {
372
5632
        Self {
373
5632
            multi_cursor: None,
374
5632
            cross_block: None,
375
5632
            blink: BlinkState::new(),
376
5632
            preedit_text: None,
377
5632
            preedit_cursor_begin: -1,
378
5632
            preedit_cursor_end: -1,
379
5632
            display_list_dirty: false,
380
5632
            tween: TextTweenState::default(),
381
5632
            pending_edit_notifications: Vec::new(),
382
5632
        }
383
5632
    }
384

            
385
    // === Dirty flag ===
386

            
387
    /// Mark that the display list needs regeneration.
388
863
    pub const fn mark_dirty(&mut self) {
389
863
        self.display_list_dirty = true;
390
863
    }
391

            
392
    // === Editing lifecycle ===
393

            
394
    /// Whether a contenteditable element is currently being edited.
395
7383
    #[must_use] pub const fn has_active_editing(&self) -> bool {
396
7383
        self.multi_cursor.is_some()
397
7383
    }
398

            
399
    /// Get the `DomId` of the node being edited.
400
7013
    #[must_use] pub fn get_editing_dom_id(&self) -> Option<DomId> {
401
7013
        self.multi_cursor.as_ref().map(|mc| mc.node_id.dom)
402
7013
    }
403

            
404
    /// Get the `NodeId` of the node being edited.
405
9
    #[must_use] pub fn get_editing_node_id(&self) -> Option<NodeId> {
406
9
        self.multi_cursor.as_ref()
407
9
            .and_then(|mc| mc.node_id.node.into_crate_internal())
408
9
    }
409

            
410
    /// Get the primary cursor position (last-added cursor).
411
6071
    #[must_use] pub fn get_primary_cursor(&self) -> Option<TextCursor> {
412
6071
        self.multi_cursor.as_ref().and_then(MultiCursorState::get_primary_cursor)
413
6071
    }
414

            
415
    /// Whether the cursor should be drawn (editing active AND blink visible).
416
7371
    #[must_use] pub const fn should_draw_cursor(&self) -> bool {
417
7371
        self.has_active_editing() && self.blink.is_visible
418
7371
    }
419

            
420
    /// Initialize editing for a newly focused contenteditable element.
421
    ///
422
    /// Creates a `MultiCursorState` with a single cursor, starts the blink,
423
    /// and sets preedit to None.
424
    ///
425
    /// # The caret is SOLID for the first half-period, not just "visible"
426
    ///
427
    /// This used to set `is_visible = true` and, in the same breath,
428
    /// `last_input_time = None`. Those two statements contradict each other:
429
    /// `None` is the "no input has EVER been recorded" encoding, for which
430
    /// [`BlinkState::should_blink`] is true immediately — so the blink timer's
431
    /// FIRST tick (a `Timer` with an interval and no delay runs on the first
432
    /// pump) toggled the freshly-shown caret straight back OFF. Clicking or
433
    /// tabbing into a field made the caret disappear on the next frame and only
434
    /// reappear a blink-interval later, which reads as a glitch and matches no
435
    /// other toolkit.
436
    ///
437
    /// It also made every caller responsible for repairing the state this
438
    /// method had just broken. Two of the three did:
439
    /// `LayoutWindow::handle_focus_change_for_cursor_blink` calls
440
    /// `reset_blink_on_input` BEFORE the deferred `finalize_pending_focus_changes`
441
    /// gets here (so its timestamp was overwritten with `None`), and
442
    /// `process_mouse_click_for_selection` calls it immediately AFTER (so its
443
    /// caret survived). The third, `process_accessibility_action`, did not — an
444
    /// AT-driven focus got the broken phase.
445
    ///
446
    /// `reset_blink_on_input(now)` sets BOTH halves consistently: caret shown
447
    /// AND the blink phase anchored at this instant, so `should_blink` stays
448
    /// false for `CURSOR_BLINK_INTERVAL_MS` and the first toggle happens one
449
    /// half-period after focus. That is the behaviour every other toolkit ships,
450
    /// and it makes the callers' own `reset_blink_on_input` calls redundant
451
    /// rather than load-bearing.
452
    ///
453
    /// `Instant::now()` honours the thread-scoped E2E test clock, so this stays
454
    /// deterministic under `tick_ms`.
455
573
    pub fn initialize_editing(
456
573
        &mut self,
457
573
        cursor: TextCursor,
458
573
        dom_id: DomId,
459
573
        node_id: NodeId,
460
573
        contenteditable_key: u64,
461
573
    ) {
462
573
        let dom_node_id = DomNodeId {
463
573
            dom: dom_id,
464
573
            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
465
573
        };
466
573
        self.multi_cursor = Some(MultiCursorState::new_with_cursor(
467
573
            cursor,
468
573
            dom_node_id,
469
573
            contenteditable_key,
470
573
        ));
471
        // The tween now tracks THIS node's caret. The previously rendered
472
        // geometry is kept on purpose — that is what makes the caret glide
473
        // from the field it left to the one it entered.
474
573
        self.tween.node = Some(dom_node_id);
475
573
        self.blink.reset_blink_on_input(Instant::now());
476
573
        self.clear_preedit();
477
573
        self.mark_dirty();
478
573
    }
479

            
480
    /// End editing (focus left the contenteditable element).
481
62
    pub fn clear_editing(&mut self) {
482
        // Only ask for a repaint if there was something to erase.
483
        //
484
        // This used to mark dirty unconditionally, and it is reached on EVERY
485
        // focus change — including a Tab between two nodes that were never
486
        // editable. The manager then owed a repaint it had no pixels for, and
487
        // because `display_list_dirty` is a latch, one such focus move left the
488
        // window permanently "not idle": a permanent repaint request, on every
489
        // frame, for the rest of the window's life.
490
        //
491
        // Caught by the E2E non-interference gate, which saw `text_edit` move
492
        // on a focus-only step with the fingerprint otherwise identical —
493
        // cursor=none, preedit="" both before and after, and only `dirty`
494
        // flipping. Nothing changed, so nothing was owed.
495
62
        let had_cursor = self.multi_cursor.is_some();
496
62
        let had_blink = self.blink.is_visible
497
24
            || self.blink.last_input_time.is_some()
498
24
            || self.blink.blink_timer_active;
499

            
500
62
        self.multi_cursor = None;
501
62
        self.blink.clear();
502
62
        let had_preedit = self.clear_preedit_returning_changed();
503

            
504
62
        if had_cursor || had_blink || had_preedit {
505
38
            self.mark_dirty();
506
60
        }
507
62
    }
508

            
509
    // === IME preedit ===
510

            
511
    /// Set the IME preedit (composition) text.
512
129
    pub fn set_preedit(&mut self, text: String, cursor_begin: i32, cursor_end: i32) {
513
129
        self.preedit_text = if text.is_empty() { None } else { Some(text) };
514
129
        self.preedit_cursor_begin = cursor_begin;
515
129
        self.preedit_cursor_end = cursor_end;
516
129
        self.mark_dirty();
517
129
    }
518

            
519
    /// Clear the IME preedit text (composition ended or cancelled).
520
638
    pub fn clear_preedit(&mut self) {
521
638
        let _ = self.clear_preedit_returning_changed();
522
638
    }
523

            
524
    /// Clear the preedit, reporting whether anything was actually cleared.
525
    ///
526
    /// Split out so `clear_editing` can decide whether a repaint is owed
527
    /// without marking dirty twice — and so clearing an ALREADY-clear preedit
528
    /// costs nothing, which is the common case on a focus change.
529
700
    fn clear_preedit_returning_changed(&mut self) -> bool {
530
700
        let changed = self.preedit_text.is_some()
531
634
            || self.preedit_cursor_begin != -1
532
634
            || self.preedit_cursor_end != -1;
533
700
        if !changed {
534
634
            return false;
535
66
        }
536
66
        self.preedit_text = None;
537
66
        self.preedit_cursor_begin = -1;
538
66
        self.preedit_cursor_end = -1;
539
66
        self.mark_dirty();
540
66
        true
541
700
    }
542

            
543
    // === Convenience for building cursor_locations ===
544

            
545
    /// Build the Vec of cursor locations for `LayoutContext`.
546
    ///
547
    /// Returns all cursor positions from `MultiCursorState`, or empty if not editing.
548
7369
    #[must_use] pub fn build_cursor_locations(&self) -> Vec<(DomId, NodeId, TextCursor)> {
549
7369
        let Some(ref mc) = self.multi_cursor else {
550
4631
            return Vec::new();
551
        };
552
2738
        let Some(node_id) = mc.node_id.node.into_crate_internal() else {
553
1
            return Vec::new();
554
        };
555
3746
        mc.selections.iter().map(|s| {
556
3746
            let cursor = match &s.selection {
557
3655
                Selection::Cursor(c) => *c,
558
91
                Selection::Range(r) => r.end,
559
            };
560
3746
            (mc.node_id.dom, node_id, cursor)
561
3746
        }).collect()
562
7369
    }
563

            
564
    /// Cross-block selection (spans multiple IFC roots), precomputed by
565
    /// `LayoutWindow::set_cross_block_selection` — the manager stores it
566
    /// render-ready because computing the per-IFC ranges needs layout/text
567
    /// access the manager does not have. Cleared by any single-node cursor
568
    /// interaction. When set, it wins over `multi_cursor` for rendering.
569
176
    pub fn set_cross_block_selection(&mut self, sel: azul_core::selection::TextSelection) {
570
176
        self.cross_block = Some(sel);
571
176
        self.display_list_dirty = true;
572
176
    }
573

            
574
    /// Clear the cross-block selection (single-node interactions do this).
575
9
    pub fn clear_cross_block_selection(&mut self) {
576
9
        if self.cross_block.take().is_some() {
577
9
            self.display_list_dirty = true;
578
9
        }
579
9
    }
580

            
581
    /// Take the cross-block selection (delete/apply flows consume it).
582
63
    pub const fn take_cross_block_selection(&mut self) -> Option<azul_core::selection::TextSelection> {
583
63
        let s = self.cross_block.take();
584
63
        if s.is_some() {
585
63
            self.display_list_dirty = true;
586
63
        }
587
63
        s
588
63
    }
589

            
590
    /// The active cross-block selection, if any.
591
49
    #[must_use] pub const fn get_cross_block_selection(&self) -> Option<&azul_core::selection::TextSelection> {
592
49
        self.cross_block.as_ref()
593
49
    }
594

            
595
    /// Every range selection of the current editing session.
596
    ///
597
    /// `None` when there is no session, the session's node is detached, or the
598
    /// session holds only bare carets — a collapsed caret is not a selection,
599
    /// so there is nothing to highlight.
600
    ///
601
    /// This is the COMPLETE selection data. `select_next_occurrence` (Ctrl+D)
602
    /// builds sessions with several ranges on one node, and the render-facing
603
    /// [`Self::build_text_selections_map`] can carry only one of them (see
604
    /// there); anything that needs all of them reads this.
605
    #[must_use]
606
2700
    pub fn session_selection_ranges(&self) -> Option<SessionSelectionRanges> {
607
2700
        let mc = self.multi_cursor.as_ref()?;
608
2564
        let node_id = mc.node_id.node.into_crate_internal()?;
609

            
610
2561
        let mut ranges = Vec::new();
611
2561
        let mut primary = None;
612
5136
        for sel in &mc.selections {
613
2575
            if let Selection::Range(range) = &sel.selection {
614
99
                if sel.id == mc.primary_id {
615
86
                    primary = Some(*range);
616
86
                }
617
99
                ranges.push(*range);
618
2476
            }
619
        }
620

            
621
        // The primary selection can be a bare caret while other selections are
622
        // ranges (a multi-cursor click adds one; an edit can collapse the
623
        // primary range). The document-first range then stands in, so the
624
        // endpoints always describe a range that is actually painted.
625
2561
        let primary = primary.or_else(|| ranges.first().copied())?;
626

            
627
88
        Some(SessionSelectionRanges {
628
88
            dom_id: mc.node_id.dom,
629
88
            node_id,
630
88
            ranges,
631
88
            primary,
632
88
        })
633
2700
    }
634

            
635
    /// Build a `TextSelection` map for the display list's `paint_selections`.
636
    ///
637
    /// Extracts Range selections from `MultiCursorState` into the format that
638
    /// `LayoutContext.text_selections` expects: `BTreeMap<DomId, TextSelection>`.
639
    /// The `affected_nodes` map uses the editing node's `NodeId` as key.
640
    ///
641
    /// `anchor`, `focus` and `is_forward` all describe the SAME range — the
642
    /// session's primary, which is also one of the ranges in `affected_nodes`.
643
    /// They used to disagree: the endpoints came from the first range,
644
    /// `affected_nodes` kept the last (each insert overwrote the same key), and
645
    /// `is_forward` was hard-coded.
646
    ///
647
    /// `affected_nodes` carries EVERY range of the session under the one node
648
    /// key, so a multi-range (Ctrl+D) session paints all of its occurrences and
649
    /// not just the primary one.
650
2813
    #[must_use] pub fn build_text_selections_map(&self) -> std::collections::BTreeMap<DomId, azul_core::selection::TextSelection> {
651
2813
        if let Some(cb) = &self.cross_block {
652
118
            let mut map = std::collections::BTreeMap::new();
653
118
            map.insert(cb.dom_id, cb.clone());
654
118
            return map;
655
2695
        }
656
        use azul_core::selection::{TextSelection, SelectionAnchor, SelectionFocus};
657

            
658
2695
        let mut map = std::collections::BTreeMap::new();
659
2695
        let Some(session) = self.session_selection_ranges() else {
660
2609
            return map;
661
        };
662
86
        let range = session.primary;
663

            
664
86
        let mut affected_nodes = std::collections::BTreeMap::new();
665
86
        affected_nodes.insert(session.node_id, session.ranges);
666

            
667
86
        map.insert(session.dom_id, TextSelection {
668
86
            dom_id: session.dom_id,
669
86
            anchor: SelectionAnchor {
670
86
                ifc_root_node_id: session.node_id,
671
86
                cursor: range.start,
672
86
                char_bounds: LogicalRect::zero(),
673
86
                mouse_position: azul_core::geom::LogicalPosition::zero(),
674
86
            },
675
86
            focus: SelectionFocus {
676
86
                ifc_root_node_id: session.node_id,
677
86
                cursor: range.end,
678
86
                mouse_position: azul_core::geom::LogicalPosition::zero(),
679
86
            },
680
86
            affected_nodes,
681
86
            is_forward: range_is_forward(&range),
682
86
        });
683

            
684
86
        map
685
2813
    }
686
}
687

            
688
impl crate::managers::NodeIdRemap for TextEditManager {
689
    /// Remap every node-keyed piece of editing state onto the rebuilt DOM: the
690
    /// multi-cursor session, the caret/selection tween geometry, the
691
    /// cross-block selection, and the queued edit notifications.
692
    ///
693
    /// `MultiCursorState::remap_node_ids` clears the selections when the edited
694
    /// node is gone; here we additionally drop the whole editing session, since a
695
    /// cursor whose IFC root no longer exists is not an editing session.
696
41
    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
697
        // The tween's caret/selection geometry belongs to the session's node.
698
        // Resolve that anchor BEFORE the session below can be dropped — and
699
        // fall back to the session for state that was installed by writing
700
        // `multi_cursor` directly (which cannot set the anchor), so a stale
701
        // `None` heals itself here instead of leaving the geometry orphaned.
702
41
        let tween_node = self
703
41
            .tween
704
41
            .node
705
41
            .or_else(|| self.multi_cursor.as_ref().map(|mc| mc.node_id));
706

            
707
41
        if let Some(ref mut mc) = self.multi_cursor {
708
11
            if mc.node_id.dom == dom {
709
9
                let unmounted = mc
710
9
                    .node_id
711
9
                    .node
712
9
                    .into_crate_internal()
713
9
                    .is_none_or(|old| map.resolve(old).is_none());
714
9
                if unmounted {
715
6
                    self.multi_cursor = None;
716
6
                    self.preedit_text = None;
717
6
                    self.preedit_cursor_begin = -1;
718
6
                    self.preedit_cursor_end = -1;
719
6
                    self.display_list_dirty = true;
720
6
                } else {
721
3
                    mc.remap_node_ids(dom, map.as_btree_map());
722
3
                }
723
2
            }
724
30
        }
725

            
726
        // The tween follows its node, and dies with it: `last_caret` /
727
        // `last_selection` describe a rectangle that belonged to a node which
728
        // is now gone, and the next display-list pass would glide the caret
729
        // out of it across the screen.
730
41
        if let Some(old) = tween_node {
731
11
            match map.resolve_dom_node_id(dom, old) {
732
5
                Some(new_id) => self.tween.node = Some(new_id),
733
6
                None => self.tween.reset_text_tweens(),
734
            }
735
30
        }
736

            
737
        // A cross-block selection is render-ready geometry keyed by IFC-root
738
        // NodeIds. Unremapped, it paints a highlight over whichever nodes
739
        // inherited those indices.
740
41
        if self
741
41
            .cross_block
742
41
            .as_ref()
743
41
            .is_some_and(|cb| cb.dom_id == dom)
744
3
        {
745
3
            self.remap_cross_block_selection(map);
746
38
        }
747

            
748
        // Queued `Input` notifications name the host they belong to; a host
749
        // that was unmounted has no event to dispatch, and keeping the id
750
        // would dispatch it at the node that took its place.
751
41
        self.pending_edit_notifications
752
41
            .retain_mut(|node| match map.resolve_dom_node_id(dom, *node) {
753
2
                Some(new_id) => {
754
2
                    *node = new_id;
755
2
                    true
756
                }
757
1
                None => false,
758
3
            });
759
41
    }
760
}
761

            
762
impl TextEditManager {
763
    /// Rewrite the cross-block selection's IFC-root ids for the rebuilt DOM.
764
    ///
765
    /// The selection is dropped outright when either endpoint's root is gone:
766
    /// a band whose anchor or focus no longer exists has no endpoints to paint
767
    /// between. Interior roots that were unmounted are dropped individually.
768
3
    fn remap_cross_block_selection(&mut self, map: &crate::managers::NodeIdMap) {
769
3
        let Some(ref mut cb) = self.cross_block else {
770
            return;
771
        };
772
2
        let (Some(anchor), Some(focus)) = (
773
3
            map.resolve(cb.anchor.ifc_root_node_id),
774
3
            map.resolve(cb.focus.ifc_root_node_id),
775
        ) else {
776
1
            self.cross_block = None;
777
1
            self.display_list_dirty = true;
778
1
            return;
779
        };
780
2
        let mut changed =
781
2
            anchor != cb.anchor.ifc_root_node_id || focus != cb.focus.ifc_root_node_id;
782
2
        cb.anchor.ifc_root_node_id = anchor;
783
2
        cb.focus.ifc_root_node_id = focus;
784

            
785
2
        let before: Vec<NodeId> = cb.affected_nodes.keys().copied().collect();
786
2
        cb.affected_nodes = core::mem::take(&mut cb.affected_nodes)
787
2
            .into_iter()
788
4
            .filter_map(|(node, ranges)| map.resolve(node).map(|new| (new, ranges)))
789
2
            .collect();
790
2
        changed |= !cb.affected_nodes.keys().copied().eq(before);
791

            
792
        // Only owe a repaint when the painted band actually moved:
793
        // `display_list_dirty` is a latch, and a rebuild that renumbered
794
        // nothing has no pixels to redraw (see `clear_editing`).
795
2
        if changed {
796
1
            self.display_list_dirty = true;
797
1
        }
798
3
    }
799
}
800

            
801
// ============================================================================
802
// AUTOTEST: adversarial tests for `BlinkState` + `TextEditManager`
803
// ============================================================================
804
#[cfg(test)]
805
mod autotest_generated {
806
    use azul_core::{
807
        selection::{
808
            CursorAffinity, GraphemeClusterId, IdentifiedSelection, SelectionId, SelectionRange,
809
        },
810
        task::{Duration, SystemTick, SystemTimeDiff},
811
    };
812

            
813
    use super::*;
814
    use crate::managers::{NodeIdMap, NodeIdRemap};
815

            
816
    const DOM0: DomId = DomId { inner: 0 };
817
    const DOM1: DomId = DomId { inner: 1 };
818
    /// A `DomId` at the very top of the `usize` range — nothing indexes with it,
819
    /// so it must be carried through unchanged like any other id.
820
    const DOM_MAX: DomId = DomId { inner: usize::MAX };
821

            
822
    /// `NodeHierarchyItemId` stores nodes 1-based (`from_crate_internal` computes
823
    /// `index + 1`), so the largest node index that can survive a round-trip
824
    /// through a `DomNodeId` is `usize::MAX - 1`. `NodeId::new(usize::MAX)` is not
825
    /// representable and is deliberately never fed to `initialize_editing`.
826
    const MAX_ENCODABLE_NODE: usize = usize::MAX - 1;
827

            
828
    fn cursor(run: u32, byte: u32) -> TextCursor {
829
        TextCursor {
830
            cluster_id: GraphemeClusterId {
831
                source_run: run,
832
                start_byte_in_run: byte,
833
            },
834
            affinity: CursorAffinity::Leading,
835
        }
836
    }
837

            
838
    fn range(from: TextCursor, to: TextCursor) -> SelectionRange {
839
        SelectionRange {
840
            start: from,
841
            end: to,
842
        }
843
    }
844

            
845
    fn dom_node(dom: DomId, node: Option<NodeId>) -> DomNodeId {
846
        DomNodeId {
847
            dom,
848
            node: NodeHierarchyItemId::from_crate_internal(node),
849
        }
850
    }
851

            
852
    /// Build a `MultiCursorState` with an arbitrary selection list, bypassing
853
    /// `add_cursor`/`add_selection` (which sort + merge) so the exact ordering
854
    /// under test is preserved.
855
    fn multi_cursor_with(
856
        node_id: DomNodeId,
857
        selections: Vec<Selection>,
858
        key: u64,
859
    ) -> MultiCursorState {
860
        let identified: Vec<IdentifiedSelection> = selections
861
            .into_iter()
862
            .map(|selection| IdentifiedSelection {
863
                id: SelectionId::new(),
864
                selection,
865
            })
866
            .collect();
867
        let primary_id = identified
868
            .last()
869
            .map_or_else(SelectionId::new, |s| s.id);
870
        MultiCursorState {
871
            selections: identified,
872
            primary_id,
873
            node_id,
874
            contenteditable_key: key,
875
        }
876
    }
877

            
878
    /// `base + ms`, using the engine's own saturating instant arithmetic.
879
    fn plus_ms(base: &Instant, ms: u64) -> Instant {
880
        base.add_optional_duration(Some(&Duration::System(SystemTimeDiff::from_millis(ms))))
881
    }
882

            
883
    // ------------------------------------------------------------------
884
    // BlinkState
885
    // ------------------------------------------------------------------
886

            
887
    #[test]
888
    fn autotest_blink_new_invariants() {
889
        let b = BlinkState::new();
890
        assert!(!b.is_visible, "a fresh BlinkState starts hidden");
891
        assert!(b.last_input_time.is_none());
892
        assert!(!b.is_blink_timer_active());
893
        assert!(!b.blink_timer_active);
894
        // The default interval is the wall-clock one, so every existing caller
895
        // that never sets an interval keeps the 530ms behaviour it had.
896
        assert_eq!(b.blink_interval, CURSOR_BLINK_INTERVAL);
897
        assert_eq!(b.blink_interval, Duration::from_millis(CURSOR_BLINK_INTERVAL_MS));
898
        // No input has ever been recorded, so blinking is allowed immediately.
899
        assert!(b.should_blink(&Instant::now()));
900
    }
901

            
902
    #[test]
903
    fn autotest_blink_toggle_visibility_alternates_and_returns_new_state() {
904
        let mut b = BlinkState::new();
905
        assert!(b.toggle_visibility(), "first toggle turns the caret on");
906
        assert!(b.is_visible);
907
        assert!(!b.toggle_visibility(), "second toggle turns it back off");
908
        assert!(!b.is_visible);
909

            
910
        // 1000 toggles: the return value must always equal the new field value,
911
        // and parity must be exactly preserved (no drift, no panic).
912
        let mut expected = false;
913
        for _ in 0..1000 {
914
            expected = !expected;
915
            let returned = b.toggle_visibility();
916
            assert_eq!(returned, expected);
917
            assert_eq!(b.is_visible, expected);
918
        }
919
        assert!(!b.is_visible, "an even number of toggles restores the state");
920
    }
921

            
922
    #[test]
923
    fn autotest_blink_set_visibility_is_idempotent_and_orthogonal() {
924
        let mut b = BlinkState::new();
925
        b.set_blink_timer_active(true);
926

            
927
        b.set_visibility(true);
928
        b.set_visibility(true);
929
        assert!(b.is_visible);
930
        assert!(
931
            b.is_blink_timer_active(),
932
            "visibility must not disturb the timer flag"
933
        );
934

            
935
        b.set_visibility(false);
936
        b.set_visibility(false);
937
        assert!(!b.is_visible);
938
        assert!(b.is_blink_timer_active());
939
    }
940

            
941
    #[test]
942
    fn autotest_blink_timer_active_true_false_and_idempotent() {
943
        let mut b = BlinkState::new();
944
        assert!(!b.is_blink_timer_active(), "known-false: default state");
945

            
946
        b.set_blink_timer_active(true);
947
        assert!(b.is_blink_timer_active(), "known-true: after activation");
948
        b.set_blink_timer_active(true);
949
        assert!(b.is_blink_timer_active(), "re-activation is idempotent");
950

            
951
        b.set_blink_timer_active(false);
952
        assert!(!b.is_blink_timer_active());
953
        b.set_blink_timer_active(false);
954
        assert!(!b.is_blink_timer_active(), "re-deactivation is idempotent");
955

            
956
        // The timer flag never leaks into visibility.
957
        assert!(!b.is_visible);
958
    }
959

            
960
    #[test]
961
    fn autotest_blink_reset_on_input_forces_solid_caret() {
962
        let mut b = BlinkState::new();
963
        b.set_blink_timer_active(true);
964
        b.set_visibility(false);
965

            
966
        let now = Instant::now();
967
        b.reset_blink_on_input(now.clone());
968

            
969
        assert!(b.is_visible, "typing must show a solid caret");
970
        assert_eq!(b.last_input_time.as_ref(), Some(&now));
971
        assert!(
972
            b.is_blink_timer_active(),
973
            "reset_blink_on_input must not stop the timer"
974
        );
975
        // Immediately after input, the blink interval has not elapsed.
976
        assert!(!b.should_blink(&now));
977
    }
978

            
979
    #[test]
980
    fn autotest_blink_reset_on_input_repeated_keeps_latest_timestamp() {
981
        let mut b = BlinkState::new();
982
        let base = Instant::now();
983

            
984
        // Simulate a fast typist: 500 keystrokes, 1ms apart.
985
        for i in 0..500u64 {
986
            b.reset_blink_on_input(plus_ms(&base, i));
987
            assert!(b.is_visible, "the caret stays solid throughout typing");
988
        }
989

            
990
        let last = plus_ms(&base, 499);
991
        assert_eq!(b.last_input_time.as_ref(), Some(&last));
992
        // The whole burst spans 499ms < 530ms, so blinking has still not resumed.
993
        assert!(!b.should_blink(&last));
994
    }
995

            
996
    #[test]
997
    fn autotest_blink_should_blink_without_input_is_true() {
998
        let b = BlinkState::new();
999
        let now = Instant::now();
        assert!(b.should_blink(&now));
        // Also true for an instant far in the past — no input means no gate at all.
        assert!(b.should_blink(&Instant::Tick(SystemTick::new(0))));
    }
    #[test]
    fn autotest_blink_should_blink_interval_boundary_is_strict() {
        let base = Instant::now();
        let mut b = BlinkState::new();
        b.reset_blink_on_input(base.clone());
        assert!(
            !b.should_blink(&base),
            "zero elapsed time must not restart the blink"
        );
        assert!(
            !b.should_blink(&plus_ms(&base, CURSOR_BLINK_INTERVAL_MS - 1)),
            "one millisecond before the interval: still solid"
        );
        assert!(
            !b.should_blink(&plus_ms(&base, CURSOR_BLINK_INTERVAL_MS)),
            "exactly at the interval: the comparison is strictly greater-than"
        );
        assert!(
            b.should_blink(&plus_ms(&base, CURSOR_BLINK_INTERVAL_MS + 1)),
            "one millisecond past the interval: blinking resumes"
        );
        // Far past the interval (one day) — no overflow, still blinking.
        assert!(b.should_blink(&plus_ms(&base, 86_400_000)));
    }
    #[test]
    fn autotest_blink_should_blink_reversed_clock_saturates_to_false() {
        // `now` is *earlier* than the recorded input (clock skew / reordered
        // events). `Instant::duration_since` saturates to zero rather than
        // panicking, so the caret stays solid instead of the call blowing up.
        let base = Instant::now();
        let mut b = BlinkState::new();
        b.reset_blink_on_input(plus_ms(&base, 10_000));
        assert!(!b.should_blink(&base));
        assert!(b.is_visible);
    }
    #[test]
    fn autotest_blink_should_blink_mismatched_instant_kinds_are_deterministic() {
        // A Tick instant compared against a System instant has no meaningful
        // span: the two counters have no common origin, so `duration_since`
        // saturates to `Duration::Tick(0)` and nothing is ever "elapsed".
        // (This is about mismatched INSTANTS, which really are incomparable —
        // unlike mismatched DURATIONS, which are just two units of the same
        // thing and now convert.)
        let mut b = BlinkState::new();
        b.reset_blink_on_input(Instant::now());
        let tick_now = Instant::Tick(SystemTick::new(u64::MAX));
        assert_eq!(b.should_blink(&tick_now), b.should_blink(&tick_now));
        assert!(!b.should_blink(&tick_now));
    }
    /// A tick-only clock (no_std, or any clockless build) MUST resume blinking.
    ///
    /// Both endpoints are Tick, so the elapsed span is a `Duration::Tick`, which
    /// is compared against the wall-clock-typed blink interval on a canonical
    /// scale. Before that comparison was unit-aware, the answer was `false`
    /// forever and the caret on a clockless build simply never blinked again.
    #[test]
    fn autotest_blink_a_tick_only_clock_resumes_blinking_at_the_exact_frame() {
        let mut t = BlinkState::new();
        t.reset_blink_on_input(Instant::Tick(SystemTick::new(0)));
        // 530ms is 31.8 frames at 60Hz, so frame 31 is early and frame 32 blinks.
        assert!(!t.should_blink(&Instant::Tick(SystemTick::new(31))));
        assert!(t.should_blink(&Instant::Tick(SystemTick::new(32))));
        assert!(t.should_blink(&Instant::Tick(SystemTick::new(u64::MAX))));
    }
    /// The whole point of the `t` unit: a blink interval expressed in FRAMES
    /// flips on exactly the Nth frame — frame N-1 is solid, frame N blinks.
    /// There is no rounding, no clock, and nothing for a slow machine to shift.
    #[test]
    fn autotest_blink_a_tick_interval_flips_on_exactly_the_nth_frame() {
        let mut b = BlinkState::new();
        b.set_blink_interval(Duration::from_ticks(5));
        b.reset_blink_on_input(Instant::Tick(SystemTick::new(100)));
        for frame in 100..=105 {
            assert!(
                !b.should_blink(&Instant::Tick(SystemTick::new(frame))),
                "frame {frame} is within 5 frames of the input and must stay solid"
            );
        }
        assert!(
            b.should_blink(&Instant::Tick(SystemTick::new(106))),
            "frame 106 is strictly more than 5 frames past the input"
        );
    }
    /// The same tick interval, driven off a WALL-CLOCK instant: 5 frames is
    /// 83.33ms, so 83ms is solid and 84ms blinks. A `5t` stylesheet value
    /// therefore behaves identically on a desktop shell and on a clockless one.
    #[test]
    fn autotest_blink_a_tick_interval_converts_on_a_wall_clock_instant() {
        let base = Instant::now();
        let mut b = BlinkState::new();
        b.set_blink_interval(Duration::from_ticks(5));
        b.reset_blink_on_input(base.clone());
        assert!(!b.should_blink(&plus_ms(&base, 83)));
        assert!(b.should_blink(&plus_ms(&base, 84)));
    }
    /// The refocus predicate: a running blink timer holds the interval it was
    /// BUILT with, so the only safe trigger for rebuilding it is "the value
    /// actually changed". Same value ⇒ false (never restart the blink phase for
    /// nothing); different value — including a different UNIT — ⇒ true.
    #[test]
    fn autotest_blink_adopt_interval_reports_only_real_changes() {
        let mut b = BlinkState::new();
        assert!(
            !b.adopt_blink_interval(CURSOR_BLINK_INTERVAL),
            "the default adopted again is not a change"
        );
        assert!(b.adopt_blink_interval(Duration::from_millis(250)));
        assert_eq!(b.blink_interval, Duration::from_millis(250));
        assert!(
            !b.adopt_blink_interval(Duration::from_millis(250)),
            "idempotent: refocusing a node with the SAME duration must not restart the timer"
        );
        // 5 frames is 83.33ms, and 83ms is not 5 frames: the unit is part of
        // the value, so switching between them is a real change.
        assert!(b.adopt_blink_interval(Duration::from_ticks(5)));
        assert!(b.adopt_blink_interval(Duration::from_millis(83)));
        assert!(b.adopt_blink_interval(Duration::from_ticks(5)));
        assert_eq!(b.blink_interval, Duration::from_ticks(5));
        // `clear()` puts the default back, so the next focus on a node with an
        // explicit duration sees a change and rebuilds.
        b.clear();
        assert!(b.adopt_blink_interval(Duration::from_ticks(5)));
    }
    #[test]
    fn autotest_blink_clear_resets_every_field_and_is_idempotent() {
        let mut b = BlinkState::new();
        b.reset_blink_on_input(Instant::now());
        b.set_blink_timer_active(true);
        b.set_blink_interval(Duration::from_ticks(5));
        b.clear();
        assert!(!b.is_visible);
        assert!(b.last_input_time.is_none());
        assert!(!b.is_blink_timer_active());
        assert_eq!(
            b.blink_interval, CURSOR_BLINK_INTERVAL,
            "the previous node's caret-animation-duration must not leak to the next"
        );
        // Clearing an already-cleared state must not panic or resurrect anything.
        b.clear();
        assert!(!b.is_visible);
        assert!(b.last_input_time.is_none());
        assert!(!b.is_blink_timer_active());
        // With no last input, blinking is unblocked again.
        assert!(b.should_blink(&Instant::now()));
    }
    // ------------------------------------------------------------------
    // TextEditManager — construction / predicates / getters
    // ------------------------------------------------------------------
    #[test]
    fn autotest_manager_new_invariants() {
        let m = TextEditManager::new();
        assert!(m.multi_cursor.is_none());
        assert!(!m.has_active_editing());
        assert!(m.get_editing_dom_id().is_none());
        assert!(m.get_editing_node_id().is_none());
        assert!(m.get_primary_cursor().is_none());
        assert!(!m.should_draw_cursor());
        assert!(m.preedit_text.is_none());
        assert_eq!(m.preedit_cursor_begin, -1, "-1 is the 'unset' IME sentinel");
        assert_eq!(m.preedit_cursor_end, -1);
        assert!(!m.display_list_dirty, "a fresh manager owes no repaint");
        assert!(m.build_cursor_locations().is_empty());
        assert!(m.build_text_selections_map().is_empty());
        assert_eq!(m, TextEditManager::default());
    }
    #[test]
    fn autotest_manager_mark_dirty_is_sticky() {
        let mut m = TextEditManager::new();
        m.mark_dirty();
        assert!(m.display_list_dirty);
        m.mark_dirty();
        assert!(m.display_list_dirty, "marking twice must not toggle it off");
    }
    #[test]
    fn autotest_manager_partial_eq_ignores_transient_state() {
        // Documented contract: only `multi_cursor` participates in equality.
        let mut a = TextEditManager::new();
        let mut b = TextEditManager::new();
        assert_eq!(a, b);
        a.set_preedit("か".to_string(), 0, 3);
        a.blink.set_visibility(true);
        a.mark_dirty();
        assert_eq!(a, b, "preedit / blink / dirty are transient visual state");
        b.initialize_editing(cursor(0, 0), DOM0, NodeId::ZERO, 1);
        assert_ne!(a, b, "a live editing session is not equal to no session");
    }
    // ------------------------------------------------------------------
    // TextEditManager — initialize_editing (numeric edges)
    // ------------------------------------------------------------------
    #[test]
    fn autotest_initialize_editing_at_zero() {
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 0), DOM0, NodeId::ZERO, 0);
        assert!(m.has_active_editing());
        assert_eq!(m.get_editing_dom_id(), Some(DOM0));
        assert_eq!(
            m.get_editing_node_id(),
            Some(NodeId::ZERO),
            "node index 0 must not be confused with the 'no node' encoding"
        );
        assert_eq!(m.get_primary_cursor(), Some(cursor(0, 0)));
        assert_eq!(
            m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
            Some(0)
        );
        assert!(m.blink.is_visible);
        // The caret is SOLID for the first half-period after focus, so the blink
        // phase must be ANCHORED here. `None` would mean "no input ever", for
        // which `should_blink` is true immediately and the timer's first tick
        // hides the caret the user just placed. See `initialize_editing`.
        let anchored = m
            .blink
            .last_input_time
            .as_ref()
            .expect("initialize_editing must anchor the blink phase, not clear it");
        assert!(
            !m.blink.should_blink(anchored),
            "at the instant of focus, zero time has elapsed — blinking must not be allowed yet"
        );
        assert!(m.should_draw_cursor());
        assert!(m.display_list_dirty);
        assert_eq!(
            m.build_cursor_locations(),
            vec![(DOM0, NodeId::ZERO, cursor(0, 0))]
        );
    }
    #[test]
    fn autotest_initialize_editing_at_integer_extremes() {
        // Max representable node index, max DomId, max contenteditable key, and a
        // cursor at the top of the u32 grapheme-coordinate space.
        let node = NodeId::new(MAX_ENCODABLE_NODE);
        let extreme_cursor = TextCursor {
            cluster_id: GraphemeClusterId {
                source_run: u32::MAX,
                start_byte_in_run: u32::MAX,
            },
            affinity: CursorAffinity::Trailing,
        };
        let mut m = TextEditManager::new();
        m.initialize_editing(extreme_cursor, DOM_MAX, node, u64::MAX);
        assert_eq!(m.get_editing_dom_id(), Some(DOM_MAX));
        assert_eq!(
            m.get_editing_node_id(),
            Some(node),
            "usize::MAX - 1 is the largest 1-based-encodable node index"
        );
        assert_eq!(m.get_primary_cursor(), Some(extreme_cursor));
        assert_eq!(
            m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
            Some(u64::MAX),
            "the contenteditable key is opaque — u64::MAX must survive verbatim"
        );
        assert_eq!(
            m.build_cursor_locations(),
            vec![(DOM_MAX, node, extreme_cursor)]
        );
    }
    #[test]
    fn autotest_initialize_editing_overwrites_previous_session() {
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(1, 1), DOM0, NodeId::new(7), 111);
        m.initialize_editing(cursor(2, 2), DOM1, NodeId::new(9), 222);
        assert_eq!(m.get_editing_dom_id(), Some(DOM1));
        assert_eq!(m.get_editing_node_id(), Some(NodeId::new(9)));
        assert_eq!(m.get_primary_cursor(), Some(cursor(2, 2)));
        assert_eq!(
            m.build_cursor_locations().len(),
            1,
            "re-initializing replaces the cursor set, it does not accumulate"
        );
        assert_eq!(
            m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
            Some(222)
        );
    }
    #[test]
    fn autotest_initialize_editing_clears_stale_preedit() {
        let mut m = TextEditManager::new();
        m.set_preedit("漢字".to_string(), 3, 6);
        m.initialize_editing(cursor(0, 0), DOM0, NodeId::new(4), 42);
        assert!(
            m.preedit_text.is_none(),
            "focusing a new element must drop the old composition"
        );
        assert_eq!(m.preedit_cursor_begin, -1);
        assert_eq!(m.preedit_cursor_end, -1);
    }
    // ------------------------------------------------------------------
    // TextEditManager — clear_editing
    // ------------------------------------------------------------------
    #[test]
    fn autotest_clear_editing_on_fresh_manager_is_safe() {
        let mut m = TextEditManager::new();
        m.clear_editing();
        m.clear_editing();
        assert!(!m.has_active_editing());
        assert!(!m.should_draw_cursor());
        assert!(m.build_cursor_locations().is_empty());
        // A fresh manager has no cursor, no blink and no preedit, so clearing it
        // erases NOTHING and owes no repaint. This assertion used to read
        // `assert!(m.display_list_dirty, "clear_editing marks dirty
        // unconditionally")` — it documented the behaviour as found rather than
        // as intended, and what it documented was a bug: `clear_editing` runs on
        // every focus change, `display_list_dirty` is a LATCH, and so one Tab
        // between two never-editable nodes left the window owing a repaint on
        // every frame for the rest of its life.
        assert!(
            !m.display_list_dirty,
            "clearing an already-clear manager must not request a repaint"
        );
    }
    #[test]
    fn autotest_clear_editing_tears_down_everything() {
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 5), DOM0, NodeId::new(3), 77);
        m.set_preedit("ab".to_string(), 0, 2);
        m.blink.set_blink_timer_active(true);
        m.blink.reset_blink_on_input(Instant::now());
        m.clear_editing();
        assert!(m.multi_cursor.is_none());
        assert!(!m.has_active_editing());
        assert!(m.get_editing_dom_id().is_none());
        assert!(m.get_editing_node_id().is_none());
        assert!(m.get_primary_cursor().is_none());
        assert!(!m.should_draw_cursor());
        assert!(!m.blink.is_visible);
        assert!(!m.blink.is_blink_timer_active());
        assert!(m.blink.last_input_time.is_none());
        assert!(m.preedit_text.is_none());
        assert_eq!(m.preedit_cursor_begin, -1);
        assert_eq!(m.preedit_cursor_end, -1);
        assert!(m.display_list_dirty);
        assert!(m.build_cursor_locations().is_empty());
        assert!(m.build_text_selections_map().is_empty());
    }
    // ------------------------------------------------------------------
    // TextEditManager — IME preedit (numeric edges + unicode)
    // ------------------------------------------------------------------
    #[test]
    fn autotest_set_preedit_zero_offsets_are_not_the_unset_sentinel() {
        let mut m = TextEditManager::new();
        m.set_preedit("a".to_string(), 0, 0);
        assert_eq!(m.preedit_text.as_deref(), Some("a"));
        assert_eq!(
            m.preedit_cursor_begin, 0,
            "0 is a valid offset and must not be coerced to the -1 sentinel"
        );
        assert_eq!(m.preedit_cursor_end, 0);
        assert!(m.display_list_dirty);
    }
    #[test]
    fn autotest_set_preedit_stores_i32_extremes_verbatim() {
        let mut m = TextEditManager::new();
        m.set_preedit("x".to_string(), i32::MIN, i32::MAX);
        assert_eq!(m.preedit_cursor_begin, i32::MIN);
        assert_eq!(m.preedit_cursor_end, i32::MAX);
        // Negative (non-sentinel) values and an inverted begin > end range are
        // stored as-is: the manager performs no arithmetic on them, so there is
        // nothing to overflow. Consumers must clamp.
        m.set_preedit("x".to_string(), -42, -7);
        assert_eq!(m.preedit_cursor_begin, -42);
        assert_eq!(m.preedit_cursor_end, -7);
        m.set_preedit("x".to_string(), 10, 2);
        assert_eq!(m.preedit_cursor_begin, 10);
        assert_eq!(m.preedit_cursor_end, 2);
    }
    #[test]
    fn autotest_set_preedit_offsets_beyond_text_length_are_not_validated() {
        // A hostile / buggy IME can report offsets far outside the string. The
        // setter must not panic and must not silently rewrite them — it stores
        // them verbatim, which is the contract callers have to defend against.
        let mut m = TextEditManager::new();
        m.set_preedit("ab".to_string(), i32::MAX, i32::MAX);
        assert_eq!(m.preedit_text.as_deref(), Some("ab"));
        assert_eq!(m.preedit_cursor_begin, i32::MAX);
        assert_eq!(m.preedit_cursor_end, i32::MAX);
    }
    #[test]
    fn autotest_set_preedit_empty_text_becomes_none_but_keeps_offsets() {
        // Documented behaviour of `set_preedit`: an empty composition string maps
        // to `None`, yet the offsets are still overwritten with whatever the IME
        // passed. The result is a `None` text with non-sentinel offsets — callers
        // must key off `preedit_text`, not off the offsets.
        let mut m = TextEditManager::new();
        m.set_preedit(String::new(), 5, 9);
        assert!(m.preedit_text.is_none());
        assert_eq!(m.preedit_cursor_begin, 5);
        assert_eq!(m.preedit_cursor_end, 9);
        assert!(m.display_list_dirty);
    }
    #[test]
    fn autotest_set_preedit_preserves_unicode_verbatim() {
        let mut m = TextEditManager::new();
        for text in [
            "こんにちは",                 // CJK — the common IME case
            "👨‍👩‍👧‍👦",                        // ZWJ emoji family (one grapheme, many bytes)
            "e\u{0301}\u{0327}",          // combining acute + cedilla
            "مرحبا",                      // RTL
            "a\u{0000}b",                 // interior NUL
            "\u{FEFF}bom",                // byte-order mark
            "🇩🇪🇯🇵",                        // regional-indicator flags
        ] {
            m.set_preedit(text.to_string(), 0, 1);
            assert_eq!(
                m.preedit_text.as_deref(),
                Some(text),
                "preedit text must round-trip byte-for-byte"
            );
        }
    }
    #[test]
    fn autotest_set_preedit_huge_text_does_not_panic() {
        let huge = "あ".repeat(100_000); // 300_000 bytes
        let mut m = TextEditManager::new();
        m.set_preedit(huge.clone(), 0, 299_999);
        assert_eq!(m.preedit_text.as_deref(), Some(huge.as_str()));
        assert_eq!(m.preedit_text.as_ref().map(String::len), Some(300_000));
    }
    #[test]
    fn autotest_clear_preedit_is_idempotent_and_marks_dirty() {
        let mut m = TextEditManager::new();
        m.set_preedit("ば".to_string(), 0, 3);
        m.clear_preedit();
        assert!(m.preedit_text.is_none());
        assert_eq!(m.preedit_cursor_begin, -1);
        assert_eq!(m.preedit_cursor_end, -1);
        m.display_list_dirty = false;
        m.clear_preedit();
        assert!(m.preedit_text.is_none());
        assert_eq!(m.preedit_cursor_begin, -1);
        assert_eq!(m.preedit_cursor_end, -1);
        // The FIRST clear (above) really did clear a preedit and correctly marked
        // dirty. This second one has nothing left to clear, so it must not.
        // Previously asserted the opposite, in as many words: "clear_preedit
        // marks dirty even when nothing changed".
        assert!(
            !m.display_list_dirty,
            "a no-op clear_preedit must not request a repaint"
        );
    }
    #[test]
    fn autotest_preedit_does_not_create_an_editing_session() {
        let mut m = TextEditManager::new();
        m.set_preedit("compose".to_string(), 0, 7);
        assert!(
            !m.has_active_editing(),
            "IME text alone must not fake an editing session"
        );
        assert!(!m.should_draw_cursor());
        assert!(m.get_primary_cursor().is_none());
    }
    // ------------------------------------------------------------------
    // TextEditManager — build_cursor_locations
    // ------------------------------------------------------------------
    #[test]
    fn autotest_build_cursor_locations_empty_without_session() {
        assert!(TextEditManager::new().build_cursor_locations().is_empty());
    }
    #[test]
    fn autotest_build_cursor_locations_uses_range_end_and_keeps_order() {
        let node = NodeId::new(12);
        let a = cursor(0, 0);
        let b = cursor(0, 4);
        let c = cursor(1, 8);
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM1, Some(node)),
            vec![
                Selection::Cursor(a),
                Selection::Range(range(b, c)),
                Selection::Cursor(c),
            ],
            5,
        ));
        assert_eq!(
            m.build_cursor_locations(),
            vec![(DOM1, node, a), (DOM1, node, c), (DOM1, node, c)],
            "a Range contributes its `end` as the caret position"
        );
    }
    #[test]
    fn autotest_build_cursor_locations_with_detached_node_is_empty() {
        // A `MultiCursorState` whose node encodes "no node" must yield nothing
        // rather than panicking or fabricating NodeId(0).
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM0, None),
            vec![Selection::Cursor(cursor(0, 0))],
            1,
        ));
        assert!(m.has_active_editing());
        assert!(m.get_editing_node_id().is_none());
        assert!(m.build_cursor_locations().is_empty());
        assert!(m.build_text_selections_map().is_empty());
    }
    #[test]
    fn autotest_build_cursor_locations_with_no_selections_is_empty() {
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM0, Some(NodeId::ZERO)),
            Vec::new(),
            0,
        ));
        assert!(m.build_cursor_locations().is_empty());
        assert!(m.get_primary_cursor().is_none());
        assert!(m.build_text_selections_map().is_empty());
    }
    #[test]
    fn autotest_build_cursor_locations_scales_to_many_cursors() {
        let node = NodeId::new(2);
        let selections: Vec<Selection> = (0..1000u32)
            .map(|i| Selection::Cursor(cursor(0, i)))
            .collect();
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM0, Some(node)),
            selections,
            9,
        ));
        let locations = m.build_cursor_locations();
        assert_eq!(locations.len(), 1000);
        assert_eq!(locations[0], (DOM0, node, cursor(0, 0)));
        assert_eq!(locations[999], (DOM0, node, cursor(0, 999)));
    }
    // ------------------------------------------------------------------
    // TextEditManager — build_text_selections_map
    // ------------------------------------------------------------------
    #[test]
    fn autotest_build_text_selections_map_empty_without_session() {
        assert!(TextEditManager::new().build_text_selections_map().is_empty());
    }
    #[test]
    fn autotest_build_text_selections_map_ignores_pure_cursors() {
        // Collapsed carets are not selections — nothing to paint.
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 3), DOM0, NodeId::new(1), 8);
        assert!(m.build_text_selections_map().is_empty());
    }
    #[test]
    fn autotest_build_text_selections_map_single_range() {
        let node = NodeId::new(6);
        let start = cursor(0, 2);
        let end = cursor(0, 9);
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM1, Some(node)),
            vec![Selection::Range(range(start, end))],
            3,
        ));
        let map = m.build_text_selections_map();
        assert_eq!(map.len(), 1);
        let sel = map.get(&DOM1).expect("keyed by the editing DomId");
        assert_eq!(sel.dom_id, DOM1);
        assert_eq!(sel.anchor.ifc_root_node_id, node);
        assert_eq!(sel.anchor.cursor, start);
        assert_eq!(sel.focus.ifc_root_node_id, node);
        assert_eq!(sel.focus.cursor, end);
        assert!(sel.is_forward);
        assert_eq!(sel.affected_nodes.len(), 1);
        assert_eq!(sel.ranges_for_node(&node), &[range(start, end)]);
        assert_eq!(sel.get_range_for_node(&node), Some(&range(start, end)));
    }
    #[test]
    fn autotest_build_text_selections_map_backward_range_reports_is_forward_false() {
        // A backward drag anchors at 9 and puts the focus at 2, so the emitted
        // selection must say so. `is_forward` used to be hard-coded `true`.
        let node = NodeId::new(6);
        let start = cursor(0, 9);
        let end = cursor(0, 2);
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM0, Some(node)),
            vec![Selection::Range(range(start, end))],
            3,
        ));
        let map = m.build_text_selections_map();
        let sel = map.get(&DOM0).expect("keyed by the editing DomId");
        assert_eq!(sel.anchor.cursor, start);
        assert_eq!(sel.focus.cursor, end);
        assert!(!sel.is_forward, "anchor is logically AFTER the focus");
        assert!(!range_is_forward(&range(start, end)));
        assert!(range_is_forward(&range(end, start)), "the mirrored drag is forward");
        assert!(
            range_is_forward(&range(start, start)),
            "a degenerate range counts as forward, like TextSelection::new_collapsed"
        );
    }
    #[test]
    fn autotest_build_text_selections_map_multi_range_endpoints_match_the_painted_range() {
        // The two halves of the emitted `TextSelection` must agree: the
        // `anchor`/`focus` endpoints describe the PRIMARY range, and that range
        // is one of the ranges `affected_nodes` paints. They used to disagree —
        // endpoints from the FIRST range, the map from the LAST (each insert
        // overwrote the same key, so a Ctrl+D session painted ONE occurrence).
        let node = NodeId::new(4);
        let first = range(cursor(0, 0), cursor(0, 1));
        let last = range(cursor(0, 5), cursor(0, 8));
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM0, Some(node)),
            vec![
                Selection::Range(first),
                Selection::Cursor(cursor(0, 3)),
                Selection::Range(last),
            ],
            2,
        ));
        let map = m.build_text_selections_map();
        assert_eq!(map.len(), 1, "one entry per DomId, not per range");
        let sel = map.get(&DOM0).expect("keyed by the editing DomId");
        assert_eq!(sel.anchor.cursor, last.start, "endpoints from the PRIMARY range");
        assert_eq!(sel.focus.cursor, last.end);
        assert_eq!(sel.affected_nodes.len(), 1, "one node key, several ranges");
        assert_eq!(
            sel.ranges_for_node(&node),
            &[first, last],
            "BOTH occurrences reach the painter, in document order"
        );
        assert!(
            sel.ranges_for_node(&node).contains(&last),
            "the range the endpoints describe is one of the painted ones"
        );
        // …and no range is lost on the way: the session reports both.
        let session = m.session_selection_ranges().expect("a session with ranges");
        assert_eq!(session.dom_id, DOM0);
        assert_eq!(session.node_id, node);
        assert_eq!(session.ranges, vec![first, last], "carets are not ranges");
        assert_eq!(session.primary, last);
    }
    #[test]
    fn autotest_session_selection_ranges_falls_back_to_the_first_range() {
        // The primary selection is a bare CARET here (the fixture makes the
        // last element primary), so the endpoints stand in from the first
        // range — never from a caret, which would emit a collapsed selection
        // and paint nothing.
        let node = NodeId::new(2);
        let only = range(cursor(0, 4), cursor(0, 7));
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM1, Some(node)),
            vec![Selection::Range(only), Selection::Cursor(cursor(0, 12))],
            9,
        ));
        let session = m.session_selection_ranges().expect("one range is a session");
        assert_eq!(session.ranges, vec![only]);
        assert_eq!(session.primary, only);
        let map = m.build_text_selections_map();
        let sel = map.get(&DOM1).expect("keyed by the editing DomId");
        assert_eq!(sel.anchor.cursor, only.start);
        assert_eq!(sel.focus.cursor, only.end);
    }
    #[test]
    fn autotest_session_selection_ranges_is_none_without_ranges() {
        assert!(TextEditManager::new().session_selection_ranges().is_none());
        // Carets only — nothing to highlight.
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 3), DOM0, NodeId::new(1), 8);
        assert!(m.session_selection_ranges().is_none());
        // A range on a DETACHED node has no IFC root to express it against.
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM0, None),
            vec![Selection::Range(range(cursor(0, 0), cursor(0, 1)))],
            1,
        ));
        assert!(m.session_selection_ranges().is_none());
        assert!(m.build_text_selections_map().is_empty());
    }
    #[test]
    fn autotest_build_text_selections_map_degenerate_and_extreme_ranges() {
        let node = NodeId::new(MAX_ENCODABLE_NODE);
        // Zero-width range (start == end) at the top of the coordinate space.
        let point = cursor(u32::MAX, u32::MAX);
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM_MAX, Some(node)),
            vec![Selection::Range(range(point, point))],
            u64::MAX,
        ));
        let map = m.build_text_selections_map();
        let sel = map.get(&DOM_MAX).expect("keyed by the editing DomId");
        assert_eq!(sel.anchor.cursor, point);
        assert_eq!(sel.focus.cursor, point);
        assert_eq!(sel.ranges_for_node(&node), &[range(point, point)]);
    }
    // ------------------------------------------------------------------
    // TextEditManager — NodeIdRemap (DOM rebuild)
    // ------------------------------------------------------------------
    #[test]
    fn autotest_remap_without_session_is_a_noop() {
        let mut m = TextEditManager::new();
        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::ZERO, NodeId::new(1))]));
        assert!(!m.has_active_editing());
        assert!(
            !m.display_list_dirty,
            "nothing changed, so nothing to repaint"
        );
    }
    #[test]
    fn autotest_remap_rewrites_surviving_node() {
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 2), DOM0, NodeId::new(3), 55);
        m.set_preedit("ok".to_string(), 0, 2);
        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(8))]));
        assert!(m.has_active_editing());
        assert_eq!(m.get_editing_node_id(), Some(NodeId::new(8)));
        assert_eq!(m.get_editing_dom_id(), Some(DOM0));
        assert_eq!(m.get_primary_cursor(), Some(cursor(0, 2)));
        assert_eq!(
            m.preedit_text.as_deref(),
            Some("ok"),
            "a surviving node keeps its in-flight composition"
        );
        assert_eq!(
            m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
            Some(55),
            "the stable key must survive the rebuild"
        );
    }
    #[test]
    fn autotest_remap_drops_session_when_node_unmounted() {
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 1), DOM0, NodeId::new(3), 55);
        m.set_preedit("gone".to_string(), 1, 4);
        m.display_list_dirty = false;
        // The rebuilt DOM matched some *other* node — 3 is unmounted.
        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(4), NodeId::new(4))]));
        assert!(!m.has_active_editing());
        assert!(m.multi_cursor.is_none());
        assert!(m.preedit_text.is_none());
        assert_eq!(m.preedit_cursor_begin, -1);
        assert_eq!(m.preedit_cursor_end, -1);
        assert!(m.display_list_dirty);
        assert!(m.build_cursor_locations().is_empty());
    }
    #[test]
    fn autotest_remap_with_empty_map_drops_session() {
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 0), DOM0, NodeId::ZERO, 1);
        m.remap_node_ids(DOM0, &NodeIdMap::default());
        assert!(
            !m.has_active_editing(),
            "an empty map means every node was unmounted"
        );
    }
    #[test]
    fn autotest_remap_leaves_other_doms_alone() {
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 0), DOM1, NodeId::new(3), 1);
        // A reconciliation of DOM0 says nothing about a cursor living in DOM1.
        m.remap_node_ids(DOM0, &NodeIdMap::default());
        assert!(m.has_active_editing());
        assert_eq!(m.get_editing_dom_id(), Some(DOM1));
        assert_eq!(m.get_editing_node_id(), Some(NodeId::new(3)));
    }
    #[test]
    fn autotest_remap_of_detached_node_drops_session() {
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM0, None),
            vec![Selection::Cursor(cursor(0, 0))],
            1,
        ));
        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::ZERO, NodeId::ZERO)]));
        assert!(
            !m.has_active_editing(),
            "a cursor with no IFC root is not an editing session"
        );
        assert!(m.display_list_dirty);
    }
    // ------------------------------------------------------------------
    // TextTweenState — the tween must follow (and die with) its node
    // ------------------------------------------------------------------
    fn rect(x: f32, y: f32) -> LogicalRect {
        LogicalRect::new(
            azul_core::geom::LogicalPosition { x, y },
            azul_core::geom::LogicalSize {
                width: 2.0,
                height: 16.0,
            },
        )
    }
    fn instant() -> Instant {
        Instant::Tick(SystemTick::new(0))
    }
    /// A manager editing `node` in `DOM0` with a caret tween and a selection
    /// tween both mid-flight, and both "last rendered" geometries recorded.
    fn manager_with_live_tween(node: NodeId) -> TextEditManager {
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 0), DOM0, node, 7);
        m.tween.dom_id = Some(DOM0);
        m.tween.caret = Some(CaretTweenTrack {
            from: rect(10.0, 0.0),
            to: rect(40.0, 0.0),
            start: instant(),
        });
        m.tween.last_caret = Some(rect(25.0, 0.0));
        m.tween.selection = Some(SelectionTweenTrack {
            from: vec![rect(0.0, 0.0)],
            to: vec![rect(60.0, 0.0)],
            start: instant(),
        });
        m.tween.last_selection = vec![rect(30.0, 0.0)];
        m.tween.publish_active();
        m
    }
    #[test]
    fn autotest_remap_keeps_the_tween_anchored_to_a_moved_node() {
        let mut m = manager_with_live_tween(NodeId::new(3));
        assert_eq!(m.tween.node, Some(dom_node(DOM0, Some(NodeId::new(3)))));
        // A sibling was inserted ahead of it: same node, new index.
        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(4))]));
        assert_eq!(
            m.tween.node,
            Some(dom_node(DOM0, Some(NodeId::new(4)))),
            "the tween must follow the node it belongs to"
        );
        assert_eq!(m.get_editing_node_id(), Some(NodeId::new(4)));
        // The geometry is where the caret was actually RENDERED last frame, so
        // a move keeps it: that is what makes the glide continuous.
        assert_eq!(m.tween.last_caret, Some(rect(25.0, 0.0)));
        assert_eq!(m.tween.last_selection, vec![rect(30.0, 0.0)]);
        assert!(m.tween.caret.is_some());
        assert!(m.tween.selection.is_some());
        assert!(m.tween.is_active());
    }
    #[test]
    fn autotest_remap_clears_the_tween_when_the_edited_node_is_unmounted() {
        let mut m = manager_with_live_tween(NodeId::new(3));
        assert!(m.tween.tick_flag.load(AtomicOrdering::Acquire));
        // Node 3 is absent from the map => unmounted.
        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(9), NodeId::new(9))]));
        assert!(m.tween.node.is_none());
        assert!(
            m.tween.caret.is_none() && m.tween.last_caret.is_none(),
            "caret geometry belonging to a deleted node must not survive"
        );
        assert!(m.tween.selection.is_none());
        assert!(m.tween.last_selection.is_empty());
        assert!(!m.tween.is_active());
        assert!(
            !m.tween.tick_flag.load(AtomicOrdering::Acquire),
            "the timer flag must be republished, or the tween timer keeps ticking"
        );
    }
    #[test]
    fn autotest_remap_clears_the_tween_of_a_session_installed_without_the_anchor() {
        // `multi_cursor` is a public field and several call sites assign it
        // directly, which cannot set `tween.node`. The remap re-derives the
        // anchor from the session so that state is not orphaned.
        let mut m = TextEditManager::new();
        m.multi_cursor = Some(multi_cursor_with(
            dom_node(DOM0, Some(NodeId::new(2))),
            vec![Selection::Cursor(cursor(0, 0))],
            1,
        ));
        m.tween.last_caret = Some(rect(11.0, 0.0));
        assert!(m.tween.node.is_none());
        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(5))]));
        assert!(m.tween.last_caret.is_none());
        assert!(m.tween.node.is_none());
    }
    #[test]
    fn autotest_remap_leaves_the_tween_of_another_dom_alone() {
        let mut m = TextEditManager::new();
        m.initialize_editing(cursor(0, 0), DOM1, NodeId::new(3), 7);
        m.tween.last_caret = Some(rect(12.0, 0.0));
        m.remap_node_ids(DOM0, &NodeIdMap::default());
        assert_eq!(m.tween.node, Some(dom_node(DOM1, Some(NodeId::new(3)))));
        assert_eq!(m.tween.last_caret, Some(rect(12.0, 0.0)));
    }
    #[test]
    fn autotest_text_tween_state_clone_copies_every_field() {
        let m = manager_with_live_tween(NodeId::new(3));
        let clone = m.tween.clone();
        assert_eq!(clone.dom_id, m.tween.dom_id);
        assert_eq!(clone.node, m.tween.node);
        assert_eq!(clone.last_caret, m.tween.last_caret);
        assert_eq!(clone.last_selection, m.tween.last_selection);
        let (a, b) = (
            clone.caret.as_ref().expect("in-flight caret must be cloned"),
            m.tween.caret.as_ref().expect("original"),
        );
        assert_eq!(a.from, b.from);
        assert_eq!(a.to, b.to);
        let (a, b) = (
            clone
                .selection
                .as_ref()
                .expect("in-flight selection must be cloned"),
            m.tween.selection.as_ref().expect("original"),
        );
        assert_eq!(a.from, b.from);
        assert_eq!(a.to, b.to);
        assert!(clone.is_active(), "a clone of a running tween is running");
        assert!(clone.tick_flag.load(AtomicOrdering::Acquire));
    }
    #[test]
    fn autotest_text_tween_state_clone_does_not_share_the_timer_flag() {
        // Two managers sharing one flag would steer each other's tween timer.
        let m = manager_with_live_tween(NodeId::new(3));
        let mut clone = m.tween.clone();
        assert!(!Arc::ptr_eq(&clone.tick_flag, &m.tween.tick_flag));
        clone.reset();
        assert!(!clone.tick_flag.load(AtomicOrdering::Acquire));
        assert!(
            m.tween.tick_flag.load(AtomicOrdering::Acquire),
            "the original's timer must keep running"
        );
    }
    #[test]
    fn autotest_manager_clone_carries_the_tween() {
        let m = manager_with_live_tween(NodeId::new(3));
        let clone = m.clone();
        assert_eq!(clone.tween.node, m.tween.node);
        assert_eq!(clone.tween.last_caret, m.tween.last_caret);
        assert!(clone.tween.is_active());
    }
    // ------------------------------------------------------------------
    // Cross-block selection + queued edit notifications also carry NodeIds
    // ------------------------------------------------------------------
    fn cross_block_selection(anchor: NodeId, focus: NodeId) -> azul_core::selection::TextSelection {
        use azul_core::selection::{SelectionAnchor, SelectionFocus, TextSelection};
        let mut affected = alloc::collections::BTreeMap::new();
        affected.insert(anchor, vec![range(cursor(0, 0), cursor(0, 1))]);
        affected.insert(focus, vec![range(cursor(0, 0), cursor(0, 2))]);
        TextSelection {
            dom_id: DOM0,
            anchor: SelectionAnchor {
                ifc_root_node_id: anchor,
                cursor: cursor(0, 0),
                char_bounds: LogicalRect::zero(),
                mouse_position: azul_core::geom::LogicalPosition::zero(),
            },
            focus: SelectionFocus {
                ifc_root_node_id: focus,
                cursor: cursor(0, 2),
                mouse_position: azul_core::geom::LogicalPosition::zero(),
            },
            affected_nodes: affected,
            is_forward: true,
        }
    }
    #[test]
    fn autotest_remap_rewrites_the_cross_block_selection() {
        let mut m = TextEditManager::new();
        m.set_cross_block_selection(cross_block_selection(NodeId::new(2), NodeId::new(5)));
        m.remap_node_ids(
            DOM0,
            &NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(3)), (NodeId::new(5), NodeId::new(6))]),
        );
        let cb = m.get_cross_block_selection().expect("both roots survived");
        assert_eq!(cb.anchor.ifc_root_node_id, NodeId::new(3));
        assert_eq!(cb.focus.ifc_root_node_id, NodeId::new(6));
        assert_eq!(cb.ranges_for_node(&NodeId::new(3)).len(), 1);
        assert_eq!(cb.ranges_for_node(&NodeId::new(6)).len(), 1);
        assert!(
            cb.ranges_for_node(&NodeId::new(2)).is_empty(),
            "the old index must not still paint"
        );
    }
    #[test]
    fn autotest_remap_drops_the_cross_block_selection_when_an_endpoint_is_gone() {
        let mut m = TextEditManager::new();
        m.set_cross_block_selection(cross_block_selection(NodeId::new(2), NodeId::new(5)));
        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(2))]));
        assert!(
            m.get_cross_block_selection().is_none(),
            "a band with no focus root has nothing to paint between"
        );
    }
    #[test]
    fn autotest_remap_leaves_a_cross_block_selection_of_another_dom_alone() {
        let mut m = TextEditManager::new();
        let mut sel = cross_block_selection(NodeId::new(2), NodeId::new(5));
        sel.dom_id = DOM1;
        m.set_cross_block_selection(sel);
        m.remap_node_ids(DOM0, &NodeIdMap::default());
        let cb = m.get_cross_block_selection().expect("other DOM is untouched");
        assert_eq!(cb.anchor.ifc_root_node_id, NodeId::new(2));
        assert_eq!(cb.focus.ifc_root_node_id, NodeId::new(5));
    }
    #[test]
    fn autotest_remap_of_a_stable_cross_block_selection_owes_no_repaint() {
        let mut m = TextEditManager::new();
        m.set_cross_block_selection(cross_block_selection(NodeId::new(2), NodeId::new(5)));
        m.display_list_dirty = false;
        m.remap_node_ids(
            DOM0,
            &NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(2)), (NodeId::new(5), NodeId::new(5))]),
        );
        assert!(m.get_cross_block_selection().is_some());
        assert!(
            !m.display_list_dirty,
            "a rebuild that renumbered nothing has no pixels to redraw"
        );
    }
    #[test]
    fn autotest_remap_rewrites_and_prunes_pending_edit_notifications() {
        let mut m = TextEditManager::new();
        m.pending_edit_notifications = vec![
            dom_node(DOM0, Some(NodeId::new(1))),
            dom_node(DOM0, Some(NodeId::new(4))),
            dom_node(DOM1, Some(NodeId::new(4))),
        ];
        m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(0))]));
        assert_eq!(
            m.pending_edit_notifications,
            vec![
                dom_node(DOM0, Some(NodeId::new(0))),
                dom_node(DOM1, Some(NodeId::new(4))),
            ],
            "surviving hosts are rewritten, unmounted ones dropped, other DOMs untouched"
        );
    }
}