Skip to main content

azul_layout/managers/
text_input.rs

1//! Text Input Manager
2//!
3//! Centralizes all text editing logic for contenteditable nodes.
4//!
5//! This manager handles text input from multiple sources:
6//!
7//! - Keyboard input (character insertion, backspace, etc.)
8//! - IME composition (multi-character input for Asian languages)
9//! - Accessibility actions (screen readers, voice control)
10//! - Programmatic edits (from callbacks)
11//!
12//! ## Architecture
13//!
14//! The text input system uses a two-phase approach:
15//!
16//! 1. **Record Phase**: When text input occurs, record what changed (`old_text` + `inserted_text`)
17//!
18//!    - Store in `pending_changeset`
19//!    - Do NOT modify any caches yet
20//!    - Return affected nodes so callbacks can be invoked
21//!
22//! 2. **Apply Phase**: After callbacks, if preventDefault was not set:
23//!
24//!    - Compute new text using `text3::edit`
25//!    - Update cursor position
26//!    - Update text cache
27//!    - Mark nodes dirty for re-layout
28//!
29//! This separation allows:
30//!
31//! - User callbacks to inspect the changeset before it's applied
32//! - preventDefault to cancel the edit
33//! - Consistent behavior across keyboard/IME/A11y sources
34
35use azul_core::{
36    dom::DomNodeId,
37    events::{
38        EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
39        TextInputEventData,
40    },
41    task::Instant,
42};
43use azul_css::corety::AzString;
44
45/// Information about a pending text edit that hasn't been applied yet
46#[derive(Debug, Clone)]
47#[repr(C)]
48pub struct PendingTextEdit {
49    /// The node that was edited
50    pub node: DomNodeId,
51    /// The text that was inserted
52    pub inserted_text: AzString,
53    /// The old text before the edit (plain text extracted from `InlineContent`)
54    pub old_text: AzString,
55}
56
57impl PendingTextEdit {
58    /// Preview the resulting text by appending `inserted_text` to `old_text`.
59    ///
60    /// NOTE: Actual cursor-based insertion is handled by `apply_text_changeset()`
61    /// in window.rs via `text3::edit::insert_text()`.
62    #[must_use] pub fn resulting_text(&self) -> AzString {
63        let mut result = self.old_text.as_str().to_string();
64        result.push_str(self.inserted_text.as_str());
65        result.into()
66    }
67}
68#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
69/// C-compatible Option type for `PendingTextEdit`
70#[derive(Debug, Clone)]
71#[repr(C, u8)]
72pub enum OptionPendingTextEdit {
73    None,
74    Some(PendingTextEdit),
75}
76
77impl OptionPendingTextEdit {
78    #[must_use] pub fn into_option(self) -> Option<PendingTextEdit> {
79        match self {
80            Self::None => None,
81            Self::Some(t) => Some(t),
82        }
83    }
84}
85
86impl From<Option<PendingTextEdit>> for OptionPendingTextEdit {
87    fn from(o: Option<PendingTextEdit>) -> Self {
88        o.map_or_else(|| Self::None, Self::Some)
89    }
90}
91
92impl<'a> From<Option<&'a PendingTextEdit>> for OptionPendingTextEdit {
93    fn from(o: Option<&'a PendingTextEdit>) -> Self {
94        o.map_or_else(|| Self::None, |v| Self::Some(v.clone()))
95    }
96}
97
98/// Source of a text input event
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum TextInputSource {
101    /// Regular keyboard input
102    Keyboard,
103    /// IME composition (multi-character input)
104    Ime,
105    /// Accessibility action from assistive technology
106    Accessibility,
107    /// Programmatic edit from user callback
108    Programmatic,
109}
110
111/// Text Input Manager
112///
113/// Centralizes all text editing logic. This is the single source of truth
114/// for text input state.
115#[derive(Debug)]
116pub struct TextInputManager {
117    /// The pending text changeset that hasn't been applied yet.
118    /// This is set during the "record" phase and cleared after the "apply" phase.
119    pub pending_changeset: Option<PendingTextEdit>,
120    /// Source of the current text input
121    pub input_source: Option<TextInputSource>,
122}
123
124impl TextInputManager {
125    /// Create a new `TextInputManager`
126    #[must_use] pub const fn new() -> Self {
127        Self {
128            pending_changeset: None,
129            input_source: None,
130        }
131    }
132
133    /// Record a text input event (Phase 1)
134    ///
135    /// This ONLY records what text was inserted. It does NOT apply the changes yet.
136    /// The changes are applied later in `apply_changeset()` if preventDefault is not set.
137    ///
138    /// # Arguments
139    ///
140    /// - `node` - The DOM node being edited
141    /// - `inserted_text` - The text being inserted
142    /// - `old_text` - The current text before the edit
143    /// - `source` - Where the input came from (keyboard, IME, A11y, etc.)
144    ///
145    /// Returns the affected node for event generation.
146    pub fn record_input(
147        &mut self,
148        node: DomNodeId,
149        inserted_text: String,
150        old_text: String,
151        source: TextInputSource,
152    ) -> DomNodeId {
153        self.pending_changeset = Some(PendingTextEdit {
154            node,
155            inserted_text: inserted_text.into(),
156            old_text: old_text.into(),
157        });
158
159        self.input_source = Some(source);
160
161        node
162    }
163
164    /// Get the pending changeset (if any)
165    #[must_use] pub const fn get_pending_changeset(&self) -> Option<&PendingTextEdit> {
166        self.pending_changeset.as_ref()
167    }
168
169    /// Clear the pending changeset
170    ///
171    /// This is called after applying the changeset or if preventDefault was set.
172    pub fn clear_changeset(&mut self) {
173        self.pending_changeset = None;
174        self.input_source = None;
175    }
176}
177
178impl Default for TextInputManager {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184impl EventProvider for TextInputManager {
185    /// Get pending text input events.
186    ///
187    /// If there's a pending changeset, returns an Input event for the affected node.
188    /// The event data includes the old text and inserted text so callbacks can
189    /// query the changeset.
190    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
191    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
192        let mut events = Vec::new();
193
194        if let Some(changeset) = &self.pending_changeset {
195            let event_source = match self.input_source {
196                Some(TextInputSource::Keyboard | TextInputSource::Ime) => {
197                    CoreEventSource::User
198                }
199                Some(TextInputSource::Accessibility) => CoreEventSource::User, /* A11y is still */
200                // user input
201                Some(TextInputSource::Programmatic) => CoreEventSource::Programmatic,
202                None => CoreEventSource::User,
203            };
204
205            // Generate Input event (fires on every keystroke).
206            // Carry the edit details on the event itself (inserted/old text) so
207            // callbacks read them straight off the event โ€” like other event
208            // types โ€” without having to query `get_pending_changeset()`. The
209            // edited node is available via `SyntheticEvent.target`.
210            events.push(SyntheticEvent::new(
211                EventType::Input,
212                event_source,
213                changeset.node,
214                timestamp,
215                EventData::TextInput(TextInputEventData {
216                    inserted_text: changeset.inserted_text.as_str().to_string(),
217                    old_text: changeset.old_text.as_str().to_string(),
218                }),
219            ));
220
221            // Note: We don't generate Change events here - those are generated
222            // when focus is lost or Enter is pressed (handled elsewhere)
223        }
224
225        events
226    }
227}
228
229impl crate::managers::NodeIdRemap for TextInputManager {
230    /// Remap the pending (recorded, not-yet-applied) text edit.
231    ///
232    /// If the target node was unmounted between "record" and "apply", the
233    /// changeset is dropped: applying it would insert the text into whichever
234    /// node inherited the index.
235    fn remap_node_ids(&mut self, dom: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
236        let Some(ref mut pending) = self.pending_changeset else {
237            return;
238        };
239        if let Some(new_id) = map.resolve_dom_node_id(dom, pending.node) { pending.node = new_id } else {
240            self.pending_changeset = None;
241            self.input_source = None;
242        }
243    }
244}
245
246#[cfg(test)]
247mod autotest_generated {
248    use azul_core::{
249        dom::{DomId, DomNodeId, NodeId},
250        styled_dom::NodeHierarchyItemId,
251        task::SystemTick,
252    };
253
254    use super::*;
255    use crate::managers::{NodeIdMap, NodeIdRemap};
256
257    /// A `DomNodeId` for `(dom, node_index)` using the safe 1-based encoder.
258    fn dom_node(dom: usize, index: usize) -> DomNodeId {
259        DomNodeId {
260            dom: DomId { inner: dom },
261            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(index))),
262        }
263    }
264
265    fn ts() -> Instant {
266        Instant::Tick(SystemTick::new(0))
267    }
268
269    fn edit(old: &str, inserted: &str) -> PendingTextEdit {
270        PendingTextEdit {
271            node: dom_node(0, 0),
272            inserted_text: inserted.to_string().into(),
273            old_text: old.to_string().into(),
274        }
275    }
276
277    /// Strings chosen to break naive byte/char slicing: combining marks, ZWJ
278    /// sequences, regional-indicator flags, bidi overrides, NUL and control
279    /// bytes, lone replacement chars.
280    fn adversarial_strings() -> Vec<String> {
281        vec![
282            String::new(),
283            "a".to_string(),
284            "hรฉllo".to_string(),
285            "e\u{0301}\u{0300}\u{0327}".to_string(),
286            "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".to_string(),
287            "๐Ÿ‡ฉ๐Ÿ‡ช๐Ÿ‡ซ๐Ÿ‡ท".to_string(),
288            "ู…ุฑุญุจุง ุจุงู„ุนุงู„ู…".to_string(),
289            "\u{202E}override\u{202C}".to_string(),
290            "\0nul\0inside\0".to_string(),
291            "\r\n\t\u{0b}\u{0c}".to_string(),
292            "\u{FFFD}\u{FEFF}".to_string(),
293            "๐•ฅ๐•–๐•ฉ๐•ฅ".to_string(),
294            "a".repeat(1024),
295        ]
296    }
297
298    fn text_input_data(ev: &SyntheticEvent) -> &TextInputEventData {
299        match &ev.data {
300            EventData::TextInput(d) => d,
301            other => panic!("expected EventData::TextInput, got {other:?}"),
302        }
303    }
304
305    // ---------------------------------------------------------------------
306    // PendingTextEdit::resulting_text  (getter / round-trip)
307    // ---------------------------------------------------------------------
308
309    #[test]
310    fn resulting_text_basic_append() {
311        assert_eq!(edit("Hello", " World").resulting_text().as_str(), "Hello World");
312    }
313
314    #[test]
315    fn resulting_text_empty_instance_is_empty() {
316        assert_eq!(edit("", "").resulting_text().as_str(), "");
317    }
318
319    #[test]
320    fn resulting_text_pure_deletion_keeps_old_text() {
321        // A pure deletion records an empty `inserted_text`; the preview must be
322        // the untouched old text, not an empty string.
323        assert_eq!(edit("abc", "").resulting_text().as_str(), "abc");
324    }
325
326    #[test]
327    fn resulting_text_is_exact_byte_concatenation_for_unicode() {
328        for old in adversarial_strings() {
329            for inserted in adversarial_strings() {
330                let result = edit(&old, &inserted).resulting_text();
331                let expected = format!("{old}{inserted}");
332                assert_eq!(
333                    result.as_str(),
334                    expected.as_str(),
335                    "concat mismatch for old={old:?} inserted={inserted:?}"
336                );
337                assert_eq!(
338                    result.as_str().len(),
339                    old.len() + inserted.len(),
340                    "byte length must be additive (no normalization / no truncation)"
341                );
342            }
343        }
344    }
345
346    #[test]
347    fn resulting_text_preserves_interior_nul_bytes() {
348        // AzString is length-prefixed, not NUL-terminated: an embedded NUL must
349        // survive the String -> AzString -> &str round-trip untruncated.
350        let result = edit("a\0b", "c\0d").resulting_text();
351        assert_eq!(result.as_str(), "a\0bc\0d");
352        assert_eq!(result.as_str().len(), 6);
353        assert_eq!(result.as_str().matches('\0').count(), 2);
354    }
355
356    #[test]
357    fn resulting_text_round_trips_every_adversarial_string() {
358        // encode == decode: String -> AzString -> &str must be the identity.
359        for s in adversarial_strings() {
360            assert_eq!(edit(&s, "").resulting_text().as_str(), s.as_str());
361            assert_eq!(edit("", &s).resulting_text().as_str(), s.as_str());
362        }
363    }
364
365    #[test]
366    fn resulting_text_huge_strings_do_not_panic_or_truncate() {
367        let old = "a".repeat(300_000);
368        let inserted = "b".repeat(200_000);
369        let result = edit(&old, &inserted).resulting_text();
370        assert_eq!(result.as_str().len(), 500_000);
371        assert!(result.as_str().starts_with("aaaa"));
372        assert!(result.as_str().ends_with("bbbb"));
373    }
374
375    #[test]
376    fn resulting_text_is_pure_and_repeatable() {
377        let e = edit("old", "new");
378        let first = e.resulting_text();
379        let second = e.resulting_text();
380        assert_eq!(first.as_str(), second.as_str());
381        // The receiver must be untouched by the preview.
382        assert_eq!(e.old_text.as_str(), "old");
383        assert_eq!(e.inserted_text.as_str(), "new");
384    }
385
386    // ---------------------------------------------------------------------
387    // OptionPendingTextEdit::into_option  (round-trip)
388    // ---------------------------------------------------------------------
389
390    #[test]
391    fn option_pending_text_edit_none_round_trip() {
392        assert!(OptionPendingTextEdit::None.into_option().is_none());
393        // Both `From<Option<T>>` and `From<Option<&T>>` exist โ€” pin the owned one.
394        assert!(
395            OptionPendingTextEdit::from(None::<PendingTextEdit>)
396                .into_option()
397                .is_none()
398        );
399        assert!(
400            OptionPendingTextEdit::from(None::<&PendingTextEdit>)
401                .into_option()
402                .is_none()
403        );
404    }
405
406    #[test]
407    fn option_pending_text_edit_some_round_trip_preserves_fields() {
408        for s in adversarial_strings() {
409            let original = PendingTextEdit {
410                node: dom_node(7, 13),
411                inserted_text: s.clone().into(),
412                old_text: s.clone().into(),
413            };
414            let recovered = OptionPendingTextEdit::from(Some(original.clone()))
415                .into_option()
416                .expect("Some must round-trip to Some");
417            assert_eq!(recovered.node, original.node);
418            assert_eq!(recovered.inserted_text.as_str(), s.as_str());
419            assert_eq!(recovered.old_text.as_str(), s.as_str());
420        }
421    }
422
423    #[test]
424    fn option_pending_text_edit_from_ref_deep_clones() {
425        let original = edit("old", "ins");
426        let cloned = OptionPendingTextEdit::from(Some(&original))
427            .into_option()
428            .expect("Some(&T) must map to Some");
429        // Deep clone: the borrow is over, and both sides still hold their text.
430        assert_eq!(cloned.old_text.as_str(), "old");
431        assert_eq!(cloned.inserted_text.as_str(), "ins");
432        assert_eq!(original.old_text.as_str(), "old");
433    }
434
435    // ---------------------------------------------------------------------
436    // TextInputManager::new  (constructor invariants)
437    // ---------------------------------------------------------------------
438
439    #[test]
440    fn new_starts_with_no_pending_state() {
441        let m = TextInputManager::new();
442        assert!(m.pending_changeset.is_none());
443        assert!(m.input_source.is_none());
444        assert!(m.get_pending_changeset().is_none());
445        assert!(m.get_pending_events(ts()).is_empty());
446    }
447
448    #[test]
449    fn default_matches_new() {
450        let d = TextInputManager::default();
451        assert!(d.pending_changeset.is_none());
452        assert!(d.input_source.is_none());
453    }
454
455    // ---------------------------------------------------------------------
456    // TextInputManager::record_input / get_pending_changeset / clear_changeset
457    // ---------------------------------------------------------------------
458
459    #[test]
460    fn record_input_returns_the_node_it_was_given_and_stores_it() {
461        let mut m = TextInputManager::new();
462        let node = dom_node(3, 42);
463        let returned = m.record_input(
464            node,
465            "abc".to_string(),
466            "xyz".to_string(),
467            TextInputSource::Keyboard,
468        );
469        assert_eq!(returned, node);
470
471        let pending = m.get_pending_changeset().expect("changeset must be recorded");
472        assert_eq!(pending.node, node);
473        assert_eq!(pending.inserted_text.as_str(), "abc");
474        assert_eq!(pending.old_text.as_str(), "xyz");
475        assert_eq!(m.input_source, Some(TextInputSource::Keyboard));
476    }
477
478    #[test]
479    fn record_input_survives_empty_unicode_and_huge_payloads() {
480        let mut m = TextInputManager::new();
481        for s in adversarial_strings() {
482            let returned = m.record_input(
483                dom_node(0, 0),
484                s.clone(),
485                s.clone(),
486                TextInputSource::Ime,
487            );
488            assert_eq!(returned, dom_node(0, 0));
489            let pending = m.get_pending_changeset().expect("recorded");
490            assert_eq!(pending.inserted_text.as_str(), s.as_str());
491            assert_eq!(pending.old_text.as_str(), s.as_str());
492        }
493
494        let huge = "z".repeat(1_000_000);
495        m.record_input(
496            dom_node(0, 0),
497            huge.clone(),
498            String::new(),
499            TextInputSource::Programmatic,
500        );
501        assert_eq!(
502            m.get_pending_changeset().expect("recorded").inserted_text.as_str().len(),
503            1_000_000
504        );
505    }
506
507    #[test]
508    fn record_input_accepts_extreme_node_ids() {
509        let mut m = TextInputManager::new();
510
511        // usize::MAX DomId + the sentinel "no node" hierarchy id (DomNodeId::ROOT's).
512        let none_node = DomNodeId {
513            dom: DomId { inner: usize::MAX },
514            node: NodeHierarchyItemId::NONE,
515        };
516        assert_eq!(m.record_input(none_node, "a".into(), "b".into(), TextInputSource::Keyboard), none_node);
517        assert_eq!(m.get_pending_changeset().expect("recorded").node, none_node);
518
519        // The largest representable (1-based encoded) node index.
520        let max_node = DomNodeId {
521            dom: DomId::ROOT_ID,
522            node: NodeHierarchyItemId::from_raw(usize::MAX),
523        };
524        assert_eq!(m.record_input(max_node, "a".into(), "b".into(), TextInputSource::Keyboard), max_node);
525        assert_eq!(m.get_pending_changeset().expect("recorded").node, max_node);
526
527        assert_eq!(DomNodeId::ROOT.node.into_crate_internal(), None);
528    }
529
530    #[test]
531    fn record_input_is_last_write_wins() {
532        let mut m = TextInputManager::new();
533        m.record_input(dom_node(0, 1), "first".into(), "old1".into(), TextInputSource::Keyboard);
534        m.record_input(dom_node(1, 2), "second".into(), "old2".into(), TextInputSource::Accessibility);
535
536        let pending = m.get_pending_changeset().expect("recorded");
537        assert_eq!(pending.node, dom_node(1, 2));
538        assert_eq!(pending.inserted_text.as_str(), "second");
539        assert_eq!(pending.old_text.as_str(), "old2");
540        assert_eq!(m.input_source, Some(TextInputSource::Accessibility));
541
542        // Only ever ONE pending edit -> exactly one event, for the newest node.
543        let events = m.get_pending_events(ts());
544        assert_eq!(events.len(), 1);
545        assert_eq!(events[0].target, dom_node(1, 2));
546    }
547
548    #[test]
549    fn clear_changeset_is_idempotent_on_a_fresh_manager() {
550        let mut m = TextInputManager::new();
551        m.clear_changeset();
552        m.clear_changeset();
553        m.clear_changeset();
554        assert!(m.get_pending_changeset().is_none());
555        assert!(m.input_source.is_none());
556    }
557
558    #[test]
559    fn clear_changeset_clears_both_changeset_and_source() {
560        let mut m = TextInputManager::new();
561        m.record_input(dom_node(0, 5), "x".into(), "y".into(), TextInputSource::Programmatic);
562        assert!(m.get_pending_changeset().is_some());
563
564        m.clear_changeset();
565        assert!(m.get_pending_changeset().is_none());
566        // A stale input_source would mislabel the NEXT event's EventSource.
567        assert!(m.input_source.is_none());
568        assert!(m.get_pending_events(ts()).is_empty());
569
570        // Clearing twice must stay clean, not resurrect anything.
571        m.clear_changeset();
572        assert!(m.get_pending_changeset().is_none());
573    }
574
575    // ---------------------------------------------------------------------
576    // EventProvider::get_pending_events  (invariants)
577    // ---------------------------------------------------------------------
578
579    #[test]
580    fn no_events_without_a_pending_changeset() {
581        assert!(TextInputManager::new().get_pending_events(ts()).is_empty());
582    }
583
584    #[test]
585    fn pending_event_carries_text_verbatim() {
586        for s in adversarial_strings() {
587            let mut m = TextInputManager::new();
588            m.record_input(
589                dom_node(2, 9),
590                s.clone(),
591                format!("old-{s}"),
592                TextInputSource::Keyboard,
593            );
594
595            let events = m.get_pending_events(ts());
596            assert_eq!(events.len(), 1, "exactly one Input event per changeset");
597            assert_eq!(events[0].event_type, EventType::Input);
598            assert_eq!(events[0].target, dom_node(2, 9));
599
600            let data = text_input_data(&events[0]);
601            assert_eq!(data.inserted_text, s);
602            assert_eq!(data.old_text, format!("old-{s}"));
603        }
604    }
605
606    #[test]
607    fn event_source_mapping_is_stable_for_every_input_source() {
608        let cases = [
609            (TextInputSource::Keyboard, CoreEventSource::User),
610            (TextInputSource::Ime, CoreEventSource::User),
611            (TextInputSource::Accessibility, CoreEventSource::User),
612            (TextInputSource::Programmatic, CoreEventSource::Programmatic),
613        ];
614        for (input_source, expected) in cases {
615            let mut m = TextInputManager::new();
616            m.record_input(dom_node(0, 0), "a".into(), String::new(), input_source);
617            let events = m.get_pending_events(ts());
618            assert_eq!(events.len(), 1);
619            assert_eq!(
620                events[0].source, expected,
621                "{input_source:?} must map to {expected:?}"
622            );
623        }
624    }
625
626    #[test]
627    fn pending_changeset_without_a_source_defaults_to_user() {
628        // Torn state: the fields are public, so a changeset can exist with no
629        // recorded source. It must still produce a well-formed event.
630        let m = TextInputManager {
631            pending_changeset: Some(edit("old", "ins")),
632            input_source: None,
633        };
634        let events = m.get_pending_events(ts());
635        assert_eq!(events.len(), 1);
636        assert_eq!(events[0].source, CoreEventSource::User);
637        assert_eq!(text_input_data(&events[0]).inserted_text, "ins");
638    }
639
640    #[test]
641    fn get_pending_events_does_not_consume_the_changeset() {
642        let mut m = TextInputManager::new();
643        m.record_input(dom_node(0, 4), "a".into(), "b".into(), TextInputSource::Keyboard);
644        assert_eq!(m.get_pending_events(ts()).len(), 1);
645        // Reading events is a pure query โ€” only clear_changeset() drains it.
646        assert_eq!(m.get_pending_events(ts()).len(), 1);
647        assert!(m.get_pending_changeset().is_some());
648    }
649
650    // ---------------------------------------------------------------------
651    // NodeIdRemap  (stale-NodeId invariants)
652    // ---------------------------------------------------------------------
653
654    #[test]
655    fn remap_rewrites_a_surviving_node() {
656        let mut m = TextInputManager::new();
657        m.record_input(dom_node(0, 3), "a".into(), "b".into(), TextInputSource::Keyboard);
658
659        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(1))]);
660        m.remap_node_ids(DomId::ROOT_ID, &map);
661
662        let pending = m.get_pending_changeset().expect("mapped node must survive");
663        assert_eq!(pending.node, dom_node(0, 1));
664        assert_eq!(pending.inserted_text.as_str(), "a");
665        assert_eq!(m.input_source, Some(TextInputSource::Keyboard));
666    }
667
668    #[test]
669    fn remap_drops_changeset_and_source_when_the_node_is_unmounted() {
670        let mut m = TextInputManager::new();
671        m.record_input(dom_node(0, 3), "a".into(), "b".into(), TextInputSource::Ime);
672
673        // Node 3 is absent from the map => it was unmounted. Applying the edit
674        // would write into whichever node inherited index 3.
675        let map = NodeIdMap::from_pairs([(NodeId::new(4), NodeId::new(3))]);
676        m.remap_node_ids(DomId::ROOT_ID, &map);
677
678        assert!(m.get_pending_changeset().is_none());
679        assert!(m.input_source.is_none(), "source must be cleared with the changeset");
680        assert!(m.get_pending_events(ts()).is_empty());
681    }
682
683    #[test]
684    fn remap_with_an_empty_map_drops_everything() {
685        let mut m = TextInputManager::new();
686        m.record_input(dom_node(0, 0), "a".into(), "b".into(), TextInputSource::Keyboard);
687
688        let map = NodeIdMap::from_pairs(Vec::<(NodeId, NodeId)>::new());
689        assert!(map.is_empty());
690        m.remap_node_ids(DomId::ROOT_ID, &map);
691
692        assert!(m.get_pending_changeset().is_none());
693        assert!(m.input_source.is_none());
694    }
695
696    #[test]
697    fn remap_leaves_other_doms_untouched() {
698        let mut m = TextInputManager::new();
699        m.record_input(dom_node(1, 3), "a".into(), "b".into(), TextInputSource::Keyboard);
700
701        // Reconciliation of DOM 0 says nothing about DOM 1's node ids.
702        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(9))]);
703        m.remap_node_ids(DomId::ROOT_ID, &map);
704
705        let pending = m.get_pending_changeset().expect("other-DOM state must survive");
706        assert_eq!(pending.node, dom_node(1, 3), "node id must NOT be rewritten");
707    }
708
709    #[test]
710    fn remap_drops_a_changeset_recorded_on_the_none_node_sentinel() {
711        // DomNodeId::ROOT carries NodeHierarchyItemId::NONE, which decodes to
712        // `None` โ€” it is unresolvable, so the edit must be dropped rather than
713        // silently retargeted.
714        let mut m = TextInputManager::new();
715        m.record_input(DomNodeId::ROOT, "a".into(), "b".into(), TextInputSource::Keyboard);
716
717        let map = NodeIdMap::from_pairs([(NodeId::new(0), NodeId::new(0))]);
718        m.remap_node_ids(DomId::ROOT_ID, &map);
719
720        assert!(m.get_pending_changeset().is_none());
721        assert!(m.input_source.is_none());
722    }
723
724    #[test]
725    fn remap_handles_extreme_node_indices() {
726        let mut m = TextInputManager::new();
727        let huge = DomNodeId {
728            dom: DomId::ROOT_ID,
729            node: NodeHierarchyItemId::from_raw(usize::MAX),
730        };
731        m.record_input(huge, "a".into(), "b".into(), TextInputSource::Keyboard);
732
733        // from_raw(usize::MAX) decodes to NodeId(usize::MAX - 1); remapping it
734        // down to a small index must not over/underflow the 1-based encoding.
735        let map = NodeIdMap::from_pairs([(NodeId::new(usize::MAX - 1), NodeId::new(2))]);
736        m.remap_node_ids(DomId::ROOT_ID, &map);
737
738        assert_eq!(m.get_pending_changeset().expect("mapped").node, dom_node(0, 2));
739    }
740
741    #[test]
742    fn remap_on_an_empty_manager_is_a_noop() {
743        let mut m = TextInputManager::new();
744        let map = NodeIdMap::from_pairs([(NodeId::new(0), NodeId::new(1))]);
745        m.remap_node_ids(DomId::ROOT_ID, &map);
746        assert!(m.get_pending_changeset().is_none());
747        assert!(m.input_source.is_none());
748    }
749
750    #[test]
751    fn remap_is_idempotent_when_ids_are_stable() {
752        let mut m = TextInputManager::new();
753        m.record_input(dom_node(0, 5), "a".into(), "b".into(), TextInputSource::Keyboard);
754
755        let map = NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(5))]);
756        m.remap_node_ids(DomId::ROOT_ID, &map);
757        m.remap_node_ids(DomId::ROOT_ID, &map);
758
759        assert_eq!(m.get_pending_changeset().expect("stable").node, dom_node(0, 5));
760    }
761}