1use azul_core::{
13 dom::{DomId, DomNodeId, NodeId},
14 selection::{MultiCursorState, Selection, TextCursor},
15 styled_dom::NodeHierarchyItemId,
16 task::Instant,
17};
18
19
20pub const CURSOR_BLINK_INTERVAL_MS: u64 = 530;
22
23#[derive(Debug, Clone)]
28#[derive(Default)]
29pub struct BlinkState {
30 pub is_visible: bool,
32 pub last_input_time: Option<Instant>,
35 pub blink_timer_active: bool,
37}
38
39
40impl BlinkState {
41 #[must_use] pub fn new() -> Self { Self::default() }
42
43 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 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 #[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 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#[derive(Debug, Clone)]
91pub struct TextEditManager {
92 pub multi_cursor: Option<MultiCursorState>,
96 pub blink: BlinkState,
98 pub preedit_text: Option<String>,
101 pub preedit_cursor_begin: i32,
104 pub preedit_cursor_end: i32,
107 pub display_list_dirty: bool,
109}
110
111impl Default for TextEditManager {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117impl PartialEq for TextEditManager {
121 fn eq(&self, other: &Self) -> bool {
122 self.multi_cursor == other.multi_cursor
123 }
124}
125
126impl TextEditManager {
127 #[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 pub const fn mark_dirty(&mut self) {
143 self.display_list_dirty = true;
144 }
145
146 #[must_use] pub const fn has_active_editing(&self) -> bool {
150 self.multi_cursor.is_some()
151 }
152
153 #[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 #[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 #[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 #[must_use] pub const fn should_draw_cursor(&self) -> bool {
171 self.has_active_editing() && self.blink.is_visible
172 }
173
174 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 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 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 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 #[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 #[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 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#[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 const DOM_MAX: DomId = DomId { inner: usize::MAX };
350
351 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 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 fn plus_ms(base: &Instant, ms: u64) -> Instant {
409 base.add_optional_duration(Some(&Duration::System(SystemTimeDiff::from_millis(ms))))
410 }
411
412 #[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 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 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 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 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 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 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 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 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 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 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 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 b.clear();
602 assert!(!b.is_visible);
603 assert!(b.last_input_time.is_none());
604 assert!(!b.is_blink_timer_active());
605 assert!(b.should_blink(&Instant::now()));
607 }
608
609 #[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 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 #[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 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 #[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 #[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 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 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 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 "こんにちは", "👨👩👧👦", "e\u{0301}\u{0327}", "مرحبا", "a\u{0000}b", "\u{FEFF}bom", "🇩🇪🇯🇵", ] {
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); 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 #[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 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 #[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 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 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 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 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 #[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 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 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}