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
//!    - Append to `pending_changesets`, a FIFO queue — several edits can be
19
//!      recorded before the pass that applies them runs, and they may target
20
//!      different nodes and come from different sources
21
//!    - Do NOT modify any caches yet
22
//!    - Return affected nodes so callbacks can be invoked
23
//!
24
//! 2. **Apply Phase**: After callbacks, if preventDefault was not set:
25
//!
26
//!    - Compute new text using `text3::edit`
27
//!    - Update cursor position
28
//!    - Update text cache
29
//!    - Mark nodes dirty for re-layout
30
//!
31
//! This separation allows:
32
//!
33
//! - User callbacks to inspect the changeset before it's applied
34
//! - preventDefault to cancel the edit
35
//! - Consistent behavior across keyboard/IME/A11y sources
36

            
37
use azul_core::{
38
    dom::DomNodeId,
39
    events::{
40
        EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
41
        TextInputEventData,
42
    },
43
    task::Instant,
44
};
45
use azul_css::corety::AzString;
46

            
47
/// Information about a pending text edit that hasn't been applied yet
48
#[derive(Debug, Clone)]
49
#[repr(C)]
50
pub struct PendingTextEdit {
51
    /// The node that was edited
52
    pub node: DomNodeId,
53
    /// The text that was inserted
54
    pub inserted_text: AzString,
55
    /// The old text before the edit (plain text extracted from `InlineContent`)
56
    pub old_text: AzString,
57
}
58

            
59
impl PendingTextEdit {
60
    /// Preview the resulting text by appending `inserted_text` to `old_text`.
61
    ///
62
    /// NOTE: Actual cursor-based insertion is handled by `apply_text_changeset()`
63
    /// in window.rs via `text3::edit::insert_text()`.
64
202
    #[must_use] pub fn resulting_text(&self) -> AzString {
65
202
        let mut result = self.old_text.as_str().to_string();
66
202
        result.push_str(self.inserted_text.as_str());
67
202
        result.into()
68
202
    }
69
}
70
#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
71
/// C-compatible Option type for `PendingTextEdit`
72
#[derive(Debug, Clone)]
73
#[repr(C, u8)]
74
pub enum OptionPendingTextEdit {
75
    None,
76
    Some(PendingTextEdit),
77
}
78

            
79
impl OptionPendingTextEdit {
80
17
    #[must_use] pub fn into_option(self) -> Option<PendingTextEdit> {
81
17
        match self {
82
3
            Self::None => None,
83
14
            Self::Some(t) => Some(t),
84
        }
85
17
    }
86
}
87

            
88
impl From<Option<PendingTextEdit>> for OptionPendingTextEdit {
89
14
    fn from(o: Option<PendingTextEdit>) -> Self {
90
14
        o.map_or_else(|| Self::None, Self::Some)
91
14
    }
92
}
93

            
94
impl<'a> From<Option<&'a PendingTextEdit>> for OptionPendingTextEdit {
95
2
    fn from(o: Option<&'a PendingTextEdit>) -> Self {
96
2
        o.map_or_else(|| Self::None, |v| Self::Some(v.clone()))
97
2
    }
98
}
99

            
100
/// Source of a text input event
101
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102
pub enum TextInputSource {
103
    /// Regular keyboard input
104
    Keyboard,
105
    /// IME composition (multi-character input)
106
    Ime,
107
    /// Accessibility action from assistive technology
108
    Accessibility,
109
    /// Programmatic edit from user callback
110
    Programmatic,
111
}
112

            
113
/// One recorded-but-not-yet-applied edit, together with where it came from.
114
///
115
/// The source is stored PER ENTRY: a queue can hold a keyboard edit and an
116
/// accessibility edit at the same time, so one manager-wide "current source"
117
/// cannot describe it (it would relabel the older entries' `EventSource` with
118
/// whatever arrived last).
119
#[derive(Debug, Clone)]
120
pub struct QueuedTextEdit {
121
    /// What was recorded.
122
    pub edit: PendingTextEdit,
123
    /// Where this particular edit came from.
124
    pub source: TextInputSource,
125
}
126

            
127
/// FIFO of recorded-but-not-yet-applied text edits, oldest first.
128
///
129
/// The head is stored out of line from the tail so [`Self::front`] can be a
130
/// `const fn`: a `Vec` cannot be dereferenced in const context, and
131
/// `LayoutWindow::get_last_text_changeset` (which reaches the front through
132
/// [`TextInputManager::get_pending_changeset`]) is const. The split is an
133
/// implementation detail — `head.is_none()` always means the whole queue is
134
/// empty, which is why both fields are private and every mutation goes through
135
/// a method here.
136
#[derive(Debug, Clone, Default)]
137
pub struct PendingTextEditQueue {
138
    head: Option<QueuedTextEdit>,
139
    tail: Vec<QueuedTextEdit>,
140
}
141

            
142
impl PendingTextEditQueue {
143
    /// An empty queue.
144
5624
    #[must_use] pub const fn new() -> Self {
145
5624
        Self {
146
5624
            head: None,
147
5624
            tail: Vec::new(),
148
5624
        }
149
5624
    }
150

            
151
    /// Append an edit; the oldest entry stays at the front.
152
2023
    pub fn push(&mut self, entry: QueuedTextEdit) {
153
2023
        if self.head.is_none() {
154
1946
            self.head = Some(entry);
155
1946
        } else {
156
77
            self.tail.push(entry);
157
77
        }
158
2023
    }
159

            
160
    /// The oldest queued edit — the one the next apply pass consumes.
161
1863
    #[must_use] pub const fn front(&self) -> Option<&QueuedTextEdit> {
162
1863
        match &self.head {
163
1839
            Some(q) => Some(q),
164
24
            None => None,
165
        }
166
1863
    }
167

            
168
    /// Mutable access to the oldest queued edit.
169
70
    pub const fn front_mut(&mut self) -> Option<&mut QueuedTextEdit> {
170
70
        self.head.as_mut()
171
70
    }
172

            
173
    /// The most recently recorded edit.
174
21
    #[must_use] pub fn back(&self) -> Option<&QueuedTextEdit> {
175
21
        self.tail.last().or(self.head.as_ref())
176
21
    }
177

            
178
    /// Remove and return the oldest queued edit.
179
3777
    pub fn pop_front(&mut self) -> Option<QueuedTextEdit> {
180
3777
        let popped = self.head.take()?;
181
1884
        if !self.tail.is_empty() {
182
47
            self.head = Some(self.tail.remove(0));
183
1838
        }
184
1884
        Some(popped)
185
3777
    }
186

            
187
    /// Drop the whole queue.
188
5
    pub fn clear(&mut self) {
189
5
        self.head = None;
190
5
        self.tail.clear();
191
5
    }
192

            
193
    /// Every queued edit, oldest first.
194
132
    pub fn iter(&self) -> impl Iterator<Item = &QueuedTextEdit> {
195
132
        self.head.iter().chain(self.tail.iter())
196
132
    }
197

            
198
    /// Number of queued edits.
199
11
    #[must_use] pub fn len(&self) -> usize {
200
11
        usize::from(self.head.is_some()) + self.tail.len()
201
11
    }
202

            
203
    /// Whether nothing is queued.
204
5
    #[must_use] pub const fn is_empty(&self) -> bool {
205
5
        self.head.is_none()
206
5
    }
207

            
208
    /// Rewrite every entry with `f`, dropping the entries for which it returns
209
    /// `false`. Order is preserved and the head/tail invariant is restored.
210
37
    pub fn retain_mut<F: FnMut(&mut QueuedTextEdit) -> bool>(&mut self, mut f: F) {
211
37
        let mut kept: Vec<QueuedTextEdit> = self
212
37
            .head
213
37
            .take()
214
37
            .into_iter()
215
37
            .chain(core::mem::take(&mut self.tail))
216
40
            .filter_map(|mut e| f(&mut e).then_some(e))
217
37
            .collect();
218
37
        if kept.is_empty() {
219
29
            return;
220
8
        }
221
8
        self.tail = kept.split_off(1);
222
8
        self.head = kept.pop();
223
37
    }
224
}
225

            
226
impl<'a> IntoIterator for &'a PendingTextEditQueue {
227
    type Item = &'a QueuedTextEdit;
228
    type IntoIter = core::iter::Chain<
229
        core::option::Iter<'a, QueuedTextEdit>,
230
        core::slice::Iter<'a, QueuedTextEdit>,
231
    >;
232

            
233
    fn into_iter(self) -> Self::IntoIter {
234
        self.head.iter().chain(self.tail.iter())
235
    }
236
}
237

            
238
/// Text Input Manager
239
///
240
/// Centralizes all text editing logic. This is the single source of truth
241
/// for text input state.
242
#[derive(Debug)]
243
pub struct TextInputManager {
244
    /// Text changesets that have been recorded but not applied yet, oldest
245
    /// first.
246
    ///
247
    /// This is a QUEUE, not a slot: the record phase runs per OS text event
248
    /// while the apply phase runs per event pass, so two keystrokes arriving
249
    /// inside one pass both have to survive. A single slot silently dropped
250
    /// the older one.
251
    pub pending_changesets: PendingTextEditQueue,
252
}
253

            
254
impl TextInputManager {
255
    /// Create a new `TextInputManager`
256
5624
    #[must_use] pub const fn new() -> Self {
257
5624
        Self {
258
5624
            pending_changesets: PendingTextEditQueue::new(),
259
5624
        }
260
5624
    }
261

            
262
    /// Record a text input event (Phase 1)
263
    ///
264
    /// This ONLY records what text was inserted. It does NOT apply the changes yet.
265
    /// The changes are applied later in `apply_changeset()` if preventDefault is not set.
266
    ///
267
    /// Appends to the queue — recording twice before one apply keeps BOTH
268
    /// edits, in the order they arrived, each with its own source.
269
    ///
270
    /// # Arguments
271
    ///
272
    /// - `node` - The DOM node being edited
273
    /// - `inserted_text` - The text being inserted
274
    /// - `old_text` - The current text before the edit
275
    /// - `source` - Where the input came from (keyboard, IME, A11y, etc.)
276
    ///
277
    /// Returns the affected node for event generation.
278
1953
    pub fn record_input(
279
1953
        &mut self,
280
1953
        node: DomNodeId,
281
1953
        inserted_text: String,
282
1953
        old_text: String,
283
1953
        source: TextInputSource,
284
1953
    ) -> DomNodeId {
285
1953
        self.pending_changesets.push(QueuedTextEdit {
286
1953
            edit: PendingTextEdit {
287
1953
                node,
288
1953
                inserted_text: inserted_text.into(),
289
1953
                old_text: old_text.into(),
290
1953
            },
291
1953
            source,
292
1953
        });
293

            
294
1953
        node
295
1953
    }
296

            
297
    /// Get the changeset the next apply pass will consume — the OLDEST queued
298
    /// one, so applying in this order matches the order the edits were typed.
299
    ///
300
    /// Callers that want the most recent edit instead want
301
    /// [`Self::get_newest_changeset`]; callers that want all of them iterate
302
    /// `pending_changesets`.
303
1848
    #[must_use] pub const fn get_pending_changeset(&self) -> Option<&PendingTextEdit> {
304
1848
        match self.pending_changesets.front() {
305
1832
            Some(q) => Some(&q.edit),
306
16
            None => None,
307
        }
308
1848
    }
309

            
310
    /// The source of the changeset [`Self::get_pending_changeset`] returns.
311
15
    #[must_use] pub const fn get_pending_source(&self) -> Option<TextInputSource> {
312
15
        match self.pending_changesets.front() {
313
7
            Some(q) => Some(q.source),
314
8
            None => None,
315
        }
316
15
    }
317

            
318
    /// The MOST RECENTLY recorded changeset — for callers that report "what was
319
    /// just typed" rather than "what is applied next".
320
20
    #[must_use] pub fn get_newest_changeset(&self) -> Option<&PendingTextEdit> {
321
20
        self.pending_changesets.back().map(|q| &q.edit)
322
20
    }
323

            
324
    /// The source of the changeset [`Self::get_newest_changeset`] returns.
325
1
    #[must_use] pub fn get_newest_source(&self) -> Option<TextInputSource> {
326
1
        self.pending_changesets.back().map(|q| q.source)
327
1
    }
328

            
329
    /// Remove and return the oldest queued edit — the apply phase's cursor
330
    /// through the queue. Applying one edit must NOT discard the rest.
331
3777
    pub fn take_next_changeset(&mut self) -> Option<QueuedTextEdit> {
332
3777
        self.pending_changesets.pop_front()
333
3777
    }
334

            
335
    /// Replace the changeset currently at the front of the queue (the one about
336
    /// to be applied), keeping its source. With an empty queue this records a
337
    /// new `Programmatic` entry.
338
    ///
339
    /// This is what `CallbackInfo::set_text_changeset` rewrites: a callback
340
    /// overriding "the text that is about to be inserted" means the in-flight
341
    /// edit, not the whole queue.
342
70
    pub fn set_changeset(&mut self, changeset: PendingTextEdit) {
343
70
        if let Some(front) = self.pending_changesets.front_mut() {
344
1
            front.edit = changeset;
345
69
        } else {
346
69
            self.pending_changesets.push(QueuedTextEdit {
347
69
                edit: changeset,
348
69
                source: TextInputSource::Programmatic,
349
69
            });
350
69
        }
351
70
    }
352

            
353
    /// Drop EVERY pending changeset.
354
    ///
355
    /// This is called when preventDefault vetoes the input, when focus moves
356
    /// away, and after the apply phase has drained the queue. A veto kills the
357
    /// whole batch — leaving later entries queued would land them a pass later,
358
    /// which is exactly what the veto forbade.
359
5
    pub fn clear_changeset(&mut self) {
360
5
        self.pending_changesets.clear();
361
5
    }
362
}
363

            
364
impl Default for TextInputManager {
365
1
    fn default() -> Self {
366
1
        Self::new()
367
1
    }
368
}
369

            
370
/// The `EventSource` an edit recorded from `source` is dispatched with.
371
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
372
33
const fn event_source_of(source: TextInputSource) -> CoreEventSource {
373
33
    match source {
374
28
        TextInputSource::Keyboard | TextInputSource::Ime => CoreEventSource::User,
375
        // A11y is still user input
376
2
        TextInputSource::Accessibility => CoreEventSource::User,
377
3
        TextInputSource::Programmatic => CoreEventSource::Programmatic,
378
    }
379
33
}
380

            
381
impl EventProvider for TextInputManager {
382
    /// Get pending text input events: one Input event per QUEUED changeset, in
383
    /// the order they were recorded.
384
    ///
385
    /// The event data includes the old text and inserted text so callbacks can
386
    /// query the changeset. Each event is labelled with the source of ITS OWN
387
    /// edit, so a keyboard edit and an accessibility edit queued in the same
388
    /// pass keep their own `EventSource`.
389
103
    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
390
103
        self.pending_changesets
391
103
            .iter()
392
107
            .map(|queued| {
393
                // Generate Input event (fires on every keystroke).
394
                // Carry the edit details on the event itself (inserted/old text) so
395
                // callbacks read them straight off the event — like other event
396
                // types — without having to query `get_pending_changeset()`. The
397
                // edited node is available via `SyntheticEvent.target`.
398
                //
399
                // Note: We don't generate Change events here - those are generated
400
                // when focus is lost or Enter is pressed (handled elsewhere)
401
33
                SyntheticEvent::new(
402
33
                    EventType::Input,
403
33
                    event_source_of(queued.source),
404
33
                    queued.edit.node,
405
33
                    timestamp.clone(),
406
33
                    EventData::TextInput(TextInputEventData {
407
33
                        inserted_text: queued.edit.inserted_text.as_str().to_string(),
408
33
                        old_text: queued.edit.old_text.as_str().to_string(),
409
33
                    }),
410
                )
411
33
            })
412
103
            .collect()
413
103
    }
414
}
415

            
416
impl crate::managers::NodeIdRemap for TextInputManager {
417
    /// Remap every pending (recorded, not-yet-applied) text edit.
418
    ///
419
    /// An entry whose target node was unmounted between "record" and "apply" is
420
    /// dropped — applying it would insert the text into whichever node inherited
421
    /// the index — while the entries around it keep their place in the queue.
422
37
    fn remap_node_ids(&mut self, dom: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
423
40
        self.pending_changesets.retain_mut(|queued| {
424
16
            match map.resolve_dom_node_id(dom, queued.edit.node) {
425
9
                Some(new_id) => {
426
9
                    queued.edit.node = new_id;
427
9
                    true
428
                }
429
7
                None => false,
430
            }
431
16
        });
432
37
    }
433
}
434

            
435
#[cfg(test)]
436
mod autotest_generated {
437
    use azul_core::{
438
        dom::{DomId, DomNodeId, NodeId},
439
        styled_dom::NodeHierarchyItemId,
440
        task::SystemTick,
441
    };
442

            
443
    use super::*;
444
    use crate::managers::{NodeIdMap, NodeIdRemap};
445

            
446
    /// A `DomNodeId` for `(dom, node_index)` using the safe 1-based encoder.
447
    fn dom_node(dom: usize, index: usize) -> DomNodeId {
448
        DomNodeId {
449
            dom: DomId { inner: dom },
450
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(index))),
451
        }
452
    }
453

            
454
    fn ts() -> Instant {
455
        Instant::Tick(SystemTick::new(0))
456
    }
457

            
458
    fn edit(old: &str, inserted: &str) -> PendingTextEdit {
459
        PendingTextEdit {
460
            node: dom_node(0, 0),
461
            inserted_text: inserted.to_string().into(),
462
            old_text: old.to_string().into(),
463
        }
464
    }
465

            
466
    /// Strings chosen to break naive byte/char slicing: combining marks, ZWJ
467
    /// sequences, regional-indicator flags, bidi overrides, NUL and control
468
    /// bytes, lone replacement chars.
469
    fn adversarial_strings() -> Vec<String> {
470
        vec![
471
            String::new(),
472
            "a".to_string(),
473
            "héllo".to_string(),
474
            "e\u{0301}\u{0300}\u{0327}".to_string(),
475
            "👨‍👩‍👧‍👦".to_string(),
476
            "🇩🇪🇫🇷".to_string(),
477
            "مرحبا بالعالم".to_string(),
478
            "\u{202E}override\u{202C}".to_string(),
479
            "\0nul\0inside\0".to_string(),
480
            "\r\n\t\u{0b}\u{0c}".to_string(),
481
            "\u{FFFD}\u{FEFF}".to_string(),
482
            "𝕥𝕖𝕩𝕥".to_string(),
483
            "a".repeat(1024),
484
        ]
485
    }
486

            
487
    fn text_input_data(ev: &SyntheticEvent) -> &TextInputEventData {
488
        match &ev.data {
489
            EventData::TextInput(d) => d,
490
            other => panic!("expected EventData::TextInput, got {other:?}"),
491
        }
492
    }
493

            
494
    // ---------------------------------------------------------------------
495
    // PendingTextEdit::resulting_text  (getter / round-trip)
496
    // ---------------------------------------------------------------------
497

            
498
    #[test]
499
    fn resulting_text_basic_append() {
500
        assert_eq!(edit("Hello", " World").resulting_text().as_str(), "Hello World");
501
    }
502

            
503
    #[test]
504
    fn resulting_text_empty_instance_is_empty() {
505
        assert_eq!(edit("", "").resulting_text().as_str(), "");
506
    }
507

            
508
    #[test]
509
    fn resulting_text_pure_deletion_keeps_old_text() {
510
        // A pure deletion records an empty `inserted_text`; the preview must be
511
        // the untouched old text, not an empty string.
512
        assert_eq!(edit("abc", "").resulting_text().as_str(), "abc");
513
    }
514

            
515
    #[test]
516
    fn resulting_text_is_exact_byte_concatenation_for_unicode() {
517
        for old in adversarial_strings() {
518
            for inserted in adversarial_strings() {
519
                let result = edit(&old, &inserted).resulting_text();
520
                let expected = format!("{old}{inserted}");
521
                assert_eq!(
522
                    result.as_str(),
523
                    expected.as_str(),
524
                    "concat mismatch for old={old:?} inserted={inserted:?}"
525
                );
526
                assert_eq!(
527
                    result.as_str().len(),
528
                    old.len() + inserted.len(),
529
                    "byte length must be additive (no normalization / no truncation)"
530
                );
531
            }
532
        }
533
    }
534

            
535
    #[test]
536
    fn resulting_text_preserves_interior_nul_bytes() {
537
        // AzString is length-prefixed, not NUL-terminated: an embedded NUL must
538
        // survive the String -> AzString -> &str round-trip untruncated.
539
        let result = edit("a\0b", "c\0d").resulting_text();
540
        assert_eq!(result.as_str(), "a\0bc\0d");
541
        assert_eq!(result.as_str().len(), 6);
542
        assert_eq!(result.as_str().matches('\0').count(), 2);
543
    }
544

            
545
    #[test]
546
    fn resulting_text_round_trips_every_adversarial_string() {
547
        // encode == decode: String -> AzString -> &str must be the identity.
548
        for s in adversarial_strings() {
549
            assert_eq!(edit(&s, "").resulting_text().as_str(), s.as_str());
550
            assert_eq!(edit("", &s).resulting_text().as_str(), s.as_str());
551
        }
552
    }
553

            
554
    #[test]
555
    fn resulting_text_huge_strings_do_not_panic_or_truncate() {
556
        let old = "a".repeat(300_000);
557
        let inserted = "b".repeat(200_000);
558
        let result = edit(&old, &inserted).resulting_text();
559
        assert_eq!(result.as_str().len(), 500_000);
560
        assert!(result.as_str().starts_with("aaaa"));
561
        assert!(result.as_str().ends_with("bbbb"));
562
    }
563

            
564
    #[test]
565
    fn resulting_text_is_pure_and_repeatable() {
566
        let e = edit("old", "new");
567
        let first = e.resulting_text();
568
        let second = e.resulting_text();
569
        assert_eq!(first.as_str(), second.as_str());
570
        // The receiver must be untouched by the preview.
571
        assert_eq!(e.old_text.as_str(), "old");
572
        assert_eq!(e.inserted_text.as_str(), "new");
573
    }
574

            
575
    // ---------------------------------------------------------------------
576
    // OptionPendingTextEdit::into_option  (round-trip)
577
    // ---------------------------------------------------------------------
578

            
579
    #[test]
580
    fn option_pending_text_edit_none_round_trip() {
581
        assert!(OptionPendingTextEdit::None.into_option().is_none());
582
        // Both `From<Option<T>>` and `From<Option<&T>>` exist — pin the owned one.
583
        assert!(
584
            OptionPendingTextEdit::from(None::<PendingTextEdit>)
585
                .into_option()
586
                .is_none()
587
        );
588
        assert!(
589
            OptionPendingTextEdit::from(None::<&PendingTextEdit>)
590
                .into_option()
591
                .is_none()
592
        );
593
    }
594

            
595
    #[test]
596
    fn option_pending_text_edit_some_round_trip_preserves_fields() {
597
        for s in adversarial_strings() {
598
            let original = PendingTextEdit {
599
                node: dom_node(7, 13),
600
                inserted_text: s.clone().into(),
601
                old_text: s.clone().into(),
602
            };
603
            let recovered = OptionPendingTextEdit::from(Some(original.clone()))
604
                .into_option()
605
                .expect("Some must round-trip to Some");
606
            assert_eq!(recovered.node, original.node);
607
            assert_eq!(recovered.inserted_text.as_str(), s.as_str());
608
            assert_eq!(recovered.old_text.as_str(), s.as_str());
609
        }
610
    }
611

            
612
    #[test]
613
    fn option_pending_text_edit_from_ref_deep_clones() {
614
        let original = edit("old", "ins");
615
        let cloned = OptionPendingTextEdit::from(Some(&original))
616
            .into_option()
617
            .expect("Some(&T) must map to Some");
618
        // Deep clone: the borrow is over, and both sides still hold their text.
619
        assert_eq!(cloned.old_text.as_str(), "old");
620
        assert_eq!(cloned.inserted_text.as_str(), "ins");
621
        assert_eq!(original.old_text.as_str(), "old");
622
    }
623

            
624
    // ---------------------------------------------------------------------
625
    // TextInputManager::new  (constructor invariants)
626
    // ---------------------------------------------------------------------
627

            
628
    #[test]
629
    fn new_starts_with_no_pending_state() {
630
        let m = TextInputManager::new();
631
        assert!(m.pending_changesets.is_empty());
632
        assert_eq!(m.pending_changesets.len(), 0);
633
        assert!(m.get_pending_changeset().is_none());
634
        assert!(m.get_pending_source().is_none());
635
        assert!(m.get_newest_changeset().is_none());
636
        assert!(m.get_pending_events(ts()).is_empty());
637
    }
638

            
639
    #[test]
640
    fn default_matches_new() {
641
        let d = TextInputManager::default();
642
        assert!(d.pending_changesets.is_empty());
643
        assert!(d.get_pending_source().is_none());
644
    }
645

            
646
    // ---------------------------------------------------------------------
647
    // TextInputManager::record_input / get_pending_changeset / clear_changeset
648
    // ---------------------------------------------------------------------
649

            
650
    #[test]
651
    fn record_input_returns_the_node_it_was_given_and_stores_it() {
652
        let mut m = TextInputManager::new();
653
        let node = dom_node(3, 42);
654
        let returned = m.record_input(
655
            node,
656
            "abc".to_string(),
657
            "xyz".to_string(),
658
            TextInputSource::Keyboard,
659
        );
660
        assert_eq!(returned, node);
661

            
662
        let pending = m.get_pending_changeset().expect("changeset must be recorded");
663
        assert_eq!(pending.node, node);
664
        assert_eq!(pending.inserted_text.as_str(), "abc");
665
        assert_eq!(pending.old_text.as_str(), "xyz");
666
        assert_eq!(m.get_pending_source(), Some(TextInputSource::Keyboard));
667
    }
668

            
669
    #[test]
670
    fn record_input_survives_empty_unicode_and_huge_payloads() {
671
        let mut m = TextInputManager::new();
672
        for s in adversarial_strings() {
673
            let returned = m.record_input(
674
                dom_node(0, 0),
675
                s.clone(),
676
                s.clone(),
677
                TextInputSource::Ime,
678
            );
679
            assert_eq!(returned, dom_node(0, 0));
680
            let pending = m.get_newest_changeset().expect("recorded");
681
            assert_eq!(pending.inserted_text.as_str(), s.as_str());
682
            assert_eq!(pending.old_text.as_str(), s.as_str());
683
        }
684
        assert_eq!(m.pending_changesets.len(), adversarial_strings().len());
685

            
686
        let huge = "z".repeat(1_000_000);
687
        m.record_input(
688
            dom_node(0, 0),
689
            huge.clone(),
690
            String::new(),
691
            TextInputSource::Programmatic,
692
        );
693
        assert_eq!(
694
            m.get_newest_changeset().expect("recorded").inserted_text.as_str().len(),
695
            1_000_000
696
        );
697
    }
698

            
699
    #[test]
700
    fn record_input_accepts_extreme_node_ids() {
701
        let mut m = TextInputManager::new();
702

            
703
        // usize::MAX DomId + the sentinel "no node" hierarchy id (DomNodeId::ROOT's).
704
        let none_node = DomNodeId {
705
            dom: DomId { inner: usize::MAX },
706
            node: NodeHierarchyItemId::NONE,
707
        };
708
        assert_eq!(m.record_input(none_node, "a".into(), "b".into(), TextInputSource::Keyboard), none_node);
709
        assert_eq!(m.get_pending_changeset().expect("recorded").node, none_node);
710

            
711
        // The largest representable (1-based encoded) node index.
712
        let max_node = DomNodeId {
713
            dom: DomId::ROOT_ID,
714
            node: NodeHierarchyItemId::from_raw(usize::MAX),
715
        };
716
        assert_eq!(m.record_input(max_node, "a".into(), "b".into(), TextInputSource::Keyboard), max_node);
717
        assert_eq!(m.get_newest_changeset().expect("recorded").node, max_node);
718

            
719
        assert_eq!(DomNodeId::ROOT.node.into_crate_internal(), None);
720
    }
721

            
722
    // ---------------------------------------------------------------------
723
    // The QUEUE contract (replaces the deleted last-write-wins debt marker)
724
    // ---------------------------------------------------------------------
725

            
726
    #[test]
727
    fn record_input_queues_and_keeps_every_edit_in_order() {
728
        let mut m = TextInputManager::new();
729
        m.record_input(dom_node(0, 1), "first".into(), "old1".into(), TextInputSource::Keyboard);
730
        m.record_input(dom_node(0, 1), "second".into(), "old2".into(), TextInputSource::Keyboard);
731
        m.record_input(dom_node(0, 1), "third".into(), "old3".into(), TextInputSource::Keyboard);
732

            
733
        assert_eq!(m.pending_changesets.len(), 3);
734
        let inserted: Vec<&str> = m
735
            .pending_changesets
736
            .iter()
737
            .map(|q| q.edit.inserted_text.as_str())
738
            .collect();
739
        assert_eq!(inserted, ["first", "second", "third"]);
740

            
741
        // The front is the OLDEST — applying front-first replays the typing order.
742
        assert_eq!(m.get_pending_changeset().expect("front").inserted_text.as_str(), "first");
743
        assert_eq!(m.get_newest_changeset().expect("back").inserted_text.as_str(), "third");
744
    }
745

            
746
    #[test]
747
    fn take_next_changeset_drains_oldest_first_without_dropping_the_rest() {
748
        let mut m = TextInputManager::new();
749
        for s in ["a", "b", "c"] {
750
            m.record_input(dom_node(0, 1), s.into(), String::new(), TextInputSource::Keyboard);
751
        }
752

            
753
        let mut drained = Vec::new();
754
        while let Some(q) = m.take_next_changeset() {
755
            drained.push(q.edit.inserted_text.as_str().to_string());
756
        }
757
        assert_eq!(drained, ["a", "b", "c"]);
758
        assert!(m.pending_changesets.is_empty());
759
        assert!(m.get_pending_changeset().is_none());
760
    }
761

            
762
    #[test]
763
    fn get_pending_events_emits_one_event_per_queued_edit_in_order() {
764
        let mut m = TextInputManager::new();
765
        m.record_input(dom_node(0, 1), "a".into(), "old-a".into(), TextInputSource::Keyboard);
766
        m.record_input(dom_node(0, 1), "b".into(), "old-b".into(), TextInputSource::Keyboard);
767
        m.record_input(dom_node(0, 1), "c".into(), "old-c".into(), TextInputSource::Keyboard);
768

            
769
        let events = m.get_pending_events(ts());
770
        assert_eq!(events.len(), 3, "one Input event per queued edit");
771
        let inserted: Vec<&str> = events
772
            .iter()
773
            .map(|e| text_input_data(e).inserted_text.as_str())
774
            .collect();
775
        assert_eq!(inserted, ["a", "b", "c"]);
776
        let old: Vec<&str> = events
777
            .iter()
778
            .map(|e| text_input_data(e).old_text.as_str())
779
            .collect();
780
        assert_eq!(old, ["old-a", "old-b", "old-c"]);
781
    }
782

            
783
    #[test]
784
    fn a_batch_can_hold_edits_for_several_different_nodes() {
785
        let mut m = TextInputManager::new();
786
        m.record_input(dom_node(0, 1), "x".into(), String::new(), TextInputSource::Keyboard);
787
        m.record_input(dom_node(1, 2), "y".into(), String::new(), TextInputSource::Keyboard);
788
        m.record_input(dom_node(0, 3), "z".into(), String::new(), TextInputSource::Keyboard);
789

            
790
        let targets: Vec<DomNodeId> = m.get_pending_events(ts()).iter().map(|e| e.target).collect();
791
        assert_eq!(targets, [dom_node(0, 1), dom_node(1, 2), dom_node(0, 3)]);
792
    }
793

            
794
    #[test]
795
    fn each_queued_edit_carries_its_own_source() {
796
        // The whole reason the source is per entry: a keyboard edit and an
797
        // accessibility edit can be queued together, and a manager-wide
798
        // "current source" would relabel the older one.
799
        let mut m = TextInputManager::new();
800
        m.record_input(dom_node(0, 1), "k".into(), String::new(), TextInputSource::Keyboard);
801
        m.record_input(dom_node(0, 2), "p".into(), String::new(), TextInputSource::Programmatic);
802
        m.record_input(dom_node(0, 3), "a".into(), String::new(), TextInputSource::Accessibility);
803

            
804
        let sources: Vec<TextInputSource> =
805
            m.pending_changesets.iter().map(|q| q.source).collect();
806
        assert_eq!(
807
            sources,
808
            [
809
                TextInputSource::Keyboard,
810
                TextInputSource::Programmatic,
811
                TextInputSource::Accessibility
812
            ]
813
        );
814

            
815
        let event_sources: Vec<CoreEventSource> =
816
            m.get_pending_events(ts()).iter().map(|e| e.source).collect();
817
        assert_eq!(
818
            event_sources,
819
            [
820
                CoreEventSource::User,
821
                CoreEventSource::Programmatic,
822
                CoreEventSource::User
823
            ],
824
            "the Programmatic edit must not drag the keyboard edit's label with it"
825
        );
826

            
827
        assert_eq!(m.get_pending_source(), Some(TextInputSource::Keyboard));
828
        assert_eq!(m.get_newest_source(), Some(TextInputSource::Accessibility));
829
    }
830

            
831
    #[test]
832
    fn set_changeset_rewrites_only_the_in_flight_edit() {
833
        let mut m = TextInputManager::new();
834
        m.record_input(dom_node(0, 1), "a".into(), "old-a".into(), TextInputSource::Keyboard);
835
        m.record_input(dom_node(0, 2), "b".into(), "old-b".into(), TextInputSource::Ime);
836

            
837
        m.set_changeset(PendingTextEdit {
838
            node: dom_node(0, 1),
839
            inserted_text: "A".to_string().into(),
840
            old_text: "old-a".to_string().into(),
841
        });
842

            
843
        assert_eq!(m.pending_changesets.len(), 2, "the queued sibling survives");
844
        assert_eq!(m.get_pending_changeset().expect("front").inserted_text.as_str(), "A");
845
        assert_eq!(
846
            m.get_pending_source(),
847
            Some(TextInputSource::Keyboard),
848
            "overriding the text must not relabel where it came from"
849
        );
850
        assert_eq!(m.get_newest_changeset().expect("back").inserted_text.as_str(), "b");
851
    }
852

            
853
    #[test]
854
    fn set_changeset_on_an_empty_queue_records_a_programmatic_edit() {
855
        let mut m = TextInputManager::new();
856
        m.set_changeset(PendingTextEdit {
857
            node: dom_node(0, 4),
858
            inserted_text: "p".to_string().into(),
859
            old_text: String::new().into(),
860
        });
861
        assert_eq!(m.pending_changesets.len(), 1);
862
        assert_eq!(m.get_pending_source(), Some(TextInputSource::Programmatic));
863
        assert_eq!(m.get_pending_events(ts())[0].source, CoreEventSource::Programmatic);
864
    }
865

            
866
    #[test]
867
    fn clear_changeset_is_idempotent_on_a_fresh_manager() {
868
        let mut m = TextInputManager::new();
869
        m.clear_changeset();
870
        m.clear_changeset();
871
        m.clear_changeset();
872
        assert!(m.get_pending_changeset().is_none());
873
        assert!(m.get_pending_source().is_none());
874
    }
875

            
876
    #[test]
877
    fn clear_changeset_empties_the_whole_queue() {
878
        let mut m = TextInputManager::new();
879
        m.record_input(dom_node(0, 5), "x".into(), "y".into(), TextInputSource::Programmatic);
880
        m.record_input(dom_node(0, 6), "x2".into(), "y2".into(), TextInputSource::Keyboard);
881
        m.record_input(dom_node(1, 7), "x3".into(), "y3".into(), TextInputSource::Ime);
882
        assert_eq!(m.pending_changesets.len(), 3);
883

            
884
        // A preventDefault veto kills the whole batch: an entry left behind
885
        // would land a pass later, which is what the veto forbade.
886
        m.clear_changeset();
887
        assert!(m.pending_changesets.is_empty());
888
        assert_eq!(m.pending_changesets.len(), 0);
889
        assert!(m.get_pending_changeset().is_none());
890
        // A stale source would mislabel the NEXT event's EventSource.
891
        assert!(m.get_pending_source().is_none());
892
        assert!(m.get_newest_changeset().is_none());
893
        assert!(m.get_pending_events(ts()).is_empty());
894

            
895
        // Clearing twice must stay clean, not resurrect anything.
896
        m.clear_changeset();
897
        assert!(m.get_pending_changeset().is_none());
898

            
899
        // ...and the queue is still usable afterwards.
900
        m.record_input(dom_node(0, 8), "after".into(), String::new(), TextInputSource::Keyboard);
901
        assert_eq!(m.pending_changesets.len(), 1);
902
        assert_eq!(m.get_pending_changeset().expect("front").node, dom_node(0, 8));
903
    }
904

            
905
    // ---------------------------------------------------------------------
906
    // EventProvider::get_pending_events  (invariants)
907
    // ---------------------------------------------------------------------
908

            
909
    #[test]
910
    fn no_events_without_a_pending_changeset() {
911
        assert!(TextInputManager::new().get_pending_events(ts()).is_empty());
912
    }
913

            
914
    #[test]
915
    fn pending_event_carries_text_verbatim() {
916
        for s in adversarial_strings() {
917
            let mut m = TextInputManager::new();
918
            m.record_input(
919
                dom_node(2, 9),
920
                s.clone(),
921
                format!("old-{s}"),
922
                TextInputSource::Keyboard,
923
            );
924

            
925
            let events = m.get_pending_events(ts());
926
            assert_eq!(events.len(), 1, "exactly one Input event per changeset");
927
            assert_eq!(events[0].event_type, EventType::Input);
928
            assert_eq!(events[0].target, dom_node(2, 9));
929

            
930
            let data = text_input_data(&events[0]);
931
            assert_eq!(data.inserted_text, s);
932
            assert_eq!(data.old_text, format!("old-{s}"));
933
        }
934
    }
935

            
936
    #[test]
937
    fn event_source_mapping_is_stable_for_every_input_source() {
938
        let cases = [
939
            (TextInputSource::Keyboard, CoreEventSource::User),
940
            (TextInputSource::Ime, CoreEventSource::User),
941
            (TextInputSource::Accessibility, CoreEventSource::User),
942
            (TextInputSource::Programmatic, CoreEventSource::Programmatic),
943
        ];
944
        for (input_source, expected) in cases {
945
            let mut m = TextInputManager::new();
946
            m.record_input(dom_node(0, 0), "a".into(), String::new(), input_source);
947
            let events = m.get_pending_events(ts());
948
            assert_eq!(events.len(), 1);
949
            assert_eq!(
950
                events[0].source, expected,
951
                "{input_source:?} must map to {expected:?}"
952
            );
953
        }
954
    }
955

            
956
    #[test]
957
    fn a_queued_edit_always_has_a_source() {
958
        // A changeset without a source used to be representable (two public
959
        // fields that could disagree) and had to fall back to `User`. The
960
        // source now travels WITH the edit, so the torn state is gone: even
961
        // hand-building the queue entry demands one.
962
        let mut m = TextInputManager::new();
963
        m.pending_changesets.push(QueuedTextEdit {
964
            edit: edit("old", "ins"),
965
            source: TextInputSource::Keyboard,
966
        });
967
        let events = m.get_pending_events(ts());
968
        assert_eq!(events.len(), 1);
969
        assert_eq!(events[0].source, CoreEventSource::User);
970
        assert_eq!(text_input_data(&events[0]).inserted_text, "ins");
971
        assert_eq!(m.get_pending_source(), Some(TextInputSource::Keyboard));
972
    }
973

            
974
    #[test]
975
    fn get_pending_events_does_not_consume_the_changeset() {
976
        let mut m = TextInputManager::new();
977
        m.record_input(dom_node(0, 4), "a".into(), "b".into(), TextInputSource::Keyboard);
978
        m.record_input(dom_node(0, 4), "c".into(), "d".into(), TextInputSource::Keyboard);
979
        assert_eq!(m.get_pending_events(ts()).len(), 2);
980
        // Reading events is a pure query — only clear_changeset()/
981
        // take_next_changeset() drain it.
982
        assert_eq!(m.get_pending_events(ts()).len(), 2);
983
        assert_eq!(m.pending_changesets.len(), 2);
984
    }
985

            
986
    // ---------------------------------------------------------------------
987
    // NodeIdRemap  (stale-NodeId invariants)
988
    // ---------------------------------------------------------------------
989

            
990
    #[test]
991
    fn remap_rewrites_a_surviving_node() {
992
        let mut m = TextInputManager::new();
993
        m.record_input(dom_node(0, 3), "a".into(), "b".into(), TextInputSource::Keyboard);
994

            
995
        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(1))]);
996
        m.remap_node_ids(DomId::ROOT_ID, &map);
997

            
998
        let pending = m.get_pending_changeset().expect("mapped node must survive");
999
        assert_eq!(pending.node, dom_node(0, 1));
        assert_eq!(pending.inserted_text.as_str(), "a");
        assert_eq!(m.get_pending_source(), Some(TextInputSource::Keyboard));
    }
    #[test]
    fn remap_drops_changeset_and_source_when_the_node_is_unmounted() {
        let mut m = TextInputManager::new();
        m.record_input(dom_node(0, 3), "a".into(), "b".into(), TextInputSource::Ime);
        // Node 3 is absent from the map => it was unmounted. Applying the edit
        // would write into whichever node inherited index 3.
        let map = NodeIdMap::from_pairs([(NodeId::new(4), NodeId::new(3))]);
        m.remap_node_ids(DomId::ROOT_ID, &map);
        assert!(m.get_pending_changeset().is_none());
        assert!(m.get_pending_source().is_none(), "source must be dropped with the changeset");
        assert!(m.get_pending_events(ts()).is_empty());
    }
    #[test]
    fn remap_drops_only_the_unmounted_entries_of_a_queue() {
        let mut m = TextInputManager::new();
        m.record_input(dom_node(0, 1), "keep1".into(), String::new(), TextInputSource::Keyboard);
        m.record_input(dom_node(0, 2), "gone".into(), String::new(), TextInputSource::Ime);
        m.record_input(dom_node(0, 3), "keep2".into(), String::new(), TextInputSource::Accessibility);
        // Node 2 is unmounted; 1 and 3 shift down by one.
        let map = NodeIdMap::from_pairs([
            (NodeId::new(1), NodeId::new(1)),
            (NodeId::new(3), NodeId::new(2)),
        ]);
        m.remap_node_ids(DomId::ROOT_ID, &map);
        assert_eq!(m.pending_changesets.len(), 2);
        let survivors: Vec<(&str, DomNodeId, TextInputSource)> = m
            .pending_changesets
            .iter()
            .map(|q| (q.edit.inserted_text.as_str(), q.edit.node, q.source))
            .collect();
        assert_eq!(
            survivors,
            [
                ("keep1", dom_node(0, 1), TextInputSource::Keyboard),
                ("keep2", dom_node(0, 2), TextInputSource::Accessibility),
            ],
            "order, rewritten ids and per-entry sources all survive the drop"
        );
        // The head/tail split must be intact: the front is still the oldest.
        assert_eq!(m.get_pending_changeset().expect("front").inserted_text.as_str(), "keep1");
        assert_eq!(m.get_newest_changeset().expect("back").inserted_text.as_str(), "keep2");
    }
    #[test]
    fn remap_dropping_the_head_promotes_the_next_entry() {
        let mut m = TextInputManager::new();
        m.record_input(dom_node(0, 1), "gone".into(), String::new(), TextInputSource::Keyboard);
        m.record_input(dom_node(0, 2), "survivor".into(), String::new(), TextInputSource::Ime);
        let map = NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(0))]);
        m.remap_node_ids(DomId::ROOT_ID, &map);
        assert_eq!(m.pending_changesets.len(), 1);
        let front = m.get_pending_changeset().expect("the survivor becomes the head");
        assert_eq!(front.inserted_text.as_str(), "survivor");
        assert_eq!(front.node, dom_node(0, 0));
        assert_eq!(m.get_pending_source(), Some(TextInputSource::Ime));
        assert_eq!(m.get_pending_events(ts()).len(), 1);
    }
    #[test]
    fn remap_with_an_empty_map_drops_everything() {
        let mut m = TextInputManager::new();
        m.record_input(dom_node(0, 0), "a".into(), "b".into(), TextInputSource::Keyboard);
        m.record_input(dom_node(0, 1), "c".into(), "d".into(), TextInputSource::Keyboard);
        let map = NodeIdMap::from_pairs(Vec::<(NodeId, NodeId)>::new());
        assert!(map.is_empty());
        m.remap_node_ids(DomId::ROOT_ID, &map);
        assert!(m.pending_changesets.is_empty());
        assert!(m.get_pending_changeset().is_none());
        assert!(m.get_pending_source().is_none());
    }
    #[test]
    fn remap_leaves_other_doms_untouched() {
        let mut m = TextInputManager::new();
        m.record_input(dom_node(1, 3), "a".into(), "b".into(), TextInputSource::Keyboard);
        // Reconciliation of DOM 0 says nothing about DOM 1's node ids.
        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(9))]);
        m.remap_node_ids(DomId::ROOT_ID, &map);
        let pending = m.get_pending_changeset().expect("other-DOM state must survive");
        assert_eq!(pending.node, dom_node(1, 3), "node id must NOT be rewritten");
    }
    #[test]
    fn remap_drops_a_changeset_recorded_on_the_none_node_sentinel() {
        // DomNodeId::ROOT carries NodeHierarchyItemId::NONE, which decodes to
        // `None` — it is unresolvable, so the edit must be dropped rather than
        // silently retargeted.
        let mut m = TextInputManager::new();
        m.record_input(DomNodeId::ROOT, "a".into(), "b".into(), TextInputSource::Keyboard);
        let map = NodeIdMap::from_pairs([(NodeId::new(0), NodeId::new(0))]);
        m.remap_node_ids(DomId::ROOT_ID, &map);
        assert!(m.get_pending_changeset().is_none());
        assert!(m.get_pending_source().is_none());
    }
    #[test]
    fn remap_handles_extreme_node_indices() {
        let mut m = TextInputManager::new();
        let huge = DomNodeId {
            dom: DomId::ROOT_ID,
            node: NodeHierarchyItemId::from_raw(usize::MAX),
        };
        m.record_input(huge, "a".into(), "b".into(), TextInputSource::Keyboard);
        // from_raw(usize::MAX) decodes to NodeId(usize::MAX - 1); remapping it
        // down to a small index must not over/underflow the 1-based encoding.
        let map = NodeIdMap::from_pairs([(NodeId::new(usize::MAX - 1), NodeId::new(2))]);
        m.remap_node_ids(DomId::ROOT_ID, &map);
        assert_eq!(m.get_pending_changeset().expect("mapped").node, dom_node(0, 2));
    }
    #[test]
    fn remap_on_an_empty_manager_is_a_noop() {
        let mut m = TextInputManager::new();
        let map = NodeIdMap::from_pairs([(NodeId::new(0), NodeId::new(1))]);
        m.remap_node_ids(DomId::ROOT_ID, &map);
        assert!(m.get_pending_changeset().is_none());
        assert!(m.get_pending_source().is_none());
    }
    #[test]
    fn remap_is_idempotent_when_ids_are_stable() {
        let mut m = TextInputManager::new();
        m.record_input(dom_node(0, 5), "a".into(), "b".into(), TextInputSource::Keyboard);
        let map = NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(5))]);
        m.remap_node_ids(DomId::ROOT_ID, &map);
        m.remap_node_ids(DomId::ROOT_ID, &map);
        assert_eq!(m.get_pending_changeset().expect("stable").node, dom_node(0, 5));
    }
}