Skip to main content

azul_layout/managers/
text_edit.rs

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