1use azul_core::{
13 dom::{DomId, DomNodeId, NodeId},
14 selection::{MultiCursorState, Selection, TextCursor},
15 styled_dom::NodeHierarchyItemId,
16 task::{Duration, Instant},
17};
18
19
20pub const CURSOR_BLINK_INTERVAL_MS: u64 = 530;
22
23pub const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(CURSOR_BLINK_INTERVAL_MS);
31
32#[derive(Debug, Clone)]
37pub struct BlinkState {
38 pub is_visible: bool,
40 pub last_input_time: Option<Instant>,
43 pub blink_timer_active: bool,
45 pub blink_interval: Duration,
51}
52
53impl Default for BlinkState {
54 fn default() -> Self {
55 Self {
56 is_visible: false,
57 last_input_time: None,
58 blink_timer_active: false,
59 blink_interval: CURSOR_BLINK_INTERVAL,
60 }
61 }
62}
63
64
65impl BlinkState {
66 #[must_use] pub fn new() -> Self { Self::default() }
67
68 pub const fn set_blink_interval(&mut self, interval: Duration) {
74 self.blink_interval = interval;
75 }
76
77 pub fn reset_blink_on_input(&mut self, now: Instant) {
79 self.is_visible = true;
80 self.last_input_time = Some(now);
81 }
82
83 pub const fn toggle_visibility(&mut self) -> bool {
85 self.is_visible = !self.is_visible;
86 self.is_visible
87 }
88
89 pub const fn set_visibility(&mut self, visible: bool) {
90 self.is_visible = visible;
91 }
92
93 pub const fn set_blink_timer_active(&mut self, active: bool) {
94 self.blink_timer_active = active;
95 }
96
97 #[must_use] pub const fn is_blink_timer_active(&self) -> bool {
98 self.blink_timer_active
99 }
100
101 #[must_use] pub fn should_blink(&self, now: &Instant) -> bool {
110 self.last_input_time.as_ref().is_none_or(|last_input| {
111 now.duration_since(last_input).greater_than(&self.blink_interval)
112 })
113 }
114
115 pub fn clear(&mut self) {
122 self.is_visible = false;
123 self.last_input_time = None;
124 self.blink_timer_active = false;
125 self.blink_interval = CURSOR_BLINK_INTERVAL;
126 }
127}
128
129#[derive(Debug, Clone)]
135pub struct TextEditManager {
136 pub multi_cursor: Option<MultiCursorState>,
140 pub blink: BlinkState,
142 pub preedit_text: Option<String>,
145 pub preedit_cursor_begin: i32,
148 pub preedit_cursor_end: i32,
151 pub display_list_dirty: bool,
153}
154
155impl Default for TextEditManager {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161impl PartialEq for TextEditManager {
165 fn eq(&self, other: &Self) -> bool {
166 self.multi_cursor == other.multi_cursor
167 }
168}
169
170impl TextEditManager {
171 #[must_use] pub fn new() -> Self {
173 Self {
174 multi_cursor: None,
175 blink: BlinkState::new(),
176 preedit_text: None,
177 preedit_cursor_begin: -1,
178 preedit_cursor_end: -1,
179 display_list_dirty: false,
180 }
181 }
182
183 pub const fn mark_dirty(&mut self) {
187 self.display_list_dirty = true;
188 }
189
190 #[must_use] pub const fn has_active_editing(&self) -> bool {
194 self.multi_cursor.is_some()
195 }
196
197 #[must_use] pub fn get_editing_dom_id(&self) -> Option<DomId> {
199 self.multi_cursor.as_ref().map(|mc| mc.node_id.dom)
200 }
201
202 #[must_use] pub fn get_editing_node_id(&self) -> Option<NodeId> {
204 self.multi_cursor.as_ref()
205 .and_then(|mc| mc.node_id.node.into_crate_internal())
206 }
207
208 #[must_use] pub fn get_primary_cursor(&self) -> Option<TextCursor> {
210 self.multi_cursor.as_ref().and_then(MultiCursorState::get_primary_cursor)
211 }
212
213 #[must_use] pub const fn should_draw_cursor(&self) -> bool {
215 self.has_active_editing() && self.blink.is_visible
216 }
217
218 pub fn initialize_editing(
254 &mut self,
255 cursor: TextCursor,
256 dom_id: DomId,
257 node_id: NodeId,
258 contenteditable_key: u64,
259 ) {
260 let dom_node_id = DomNodeId {
261 dom: dom_id,
262 node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
263 };
264 self.multi_cursor = Some(MultiCursorState::new_with_cursor(
265 cursor,
266 dom_node_id,
267 contenteditable_key,
268 ));
269 self.blink.reset_blink_on_input(Instant::now());
270 self.clear_preedit();
271 self.mark_dirty();
272 }
273
274 pub fn clear_editing(&mut self) {
276 let had_cursor = self.multi_cursor.is_some();
290 let had_blink = self.blink.is_visible
291 || self.blink.last_input_time.is_some()
292 || self.blink.blink_timer_active;
293
294 self.multi_cursor = None;
295 self.blink.clear();
296 let had_preedit = self.clear_preedit_returning_changed();
297
298 if had_cursor || had_blink || had_preedit {
299 self.mark_dirty();
300 }
301 }
302
303 pub fn set_preedit(&mut self, text: String, cursor_begin: i32, cursor_end: i32) {
307 self.preedit_text = if text.is_empty() { None } else { Some(text) };
308 self.preedit_cursor_begin = cursor_begin;
309 self.preedit_cursor_end = cursor_end;
310 self.mark_dirty();
311 }
312
313 pub fn clear_preedit(&mut self) {
315 let _ = self.clear_preedit_returning_changed();
316 }
317
318 fn clear_preedit_returning_changed(&mut self) -> bool {
324 let changed = self.preedit_text.is_some()
325 || self.preedit_cursor_begin != -1
326 || self.preedit_cursor_end != -1;
327 if !changed {
328 return false;
329 }
330 self.preedit_text = None;
331 self.preedit_cursor_begin = -1;
332 self.preedit_cursor_end = -1;
333 self.mark_dirty();
334 true
335 }
336
337 #[must_use] pub fn build_cursor_locations(&self) -> Vec<(DomId, NodeId, TextCursor)> {
343 let Some(ref mc) = self.multi_cursor else {
344 return Vec::new();
345 };
346 let Some(node_id) = mc.node_id.node.into_crate_internal() else {
347 return Vec::new();
348 };
349 mc.selections.iter().map(|s| {
350 let cursor = match &s.selection {
351 Selection::Cursor(c) => *c,
352 Selection::Range(r) => r.end,
353 };
354 (mc.node_id.dom, node_id, cursor)
355 }).collect()
356 }
357
358 #[must_use] pub fn build_text_selections_map(&self) -> std::collections::BTreeMap<DomId, azul_core::selection::TextSelection> {
366 use azul_core::selection::{TextSelection, SelectionAnchor, SelectionFocus};
367 use azul_core::geom::LogicalRect;
368
369 let mut map = std::collections::BTreeMap::new();
370 let Some(ref mc) = self.multi_cursor else {
371 return map;
372 };
373 let Some(node_id) = mc.node_id.node.into_crate_internal() else {
374 return map;
375 };
376
377 let mut affected_nodes = std::collections::BTreeMap::new();
378 let mut first_range: Option<azul_core::selection::SelectionRange> = None;
379 for sel in &mc.selections {
380 if let Selection::Range(range) = &sel.selection {
381 affected_nodes.insert(node_id, *range);
382 if first_range.is_none() {
383 first_range = Some(*range);
384 }
385 }
386 }
387
388 if let Some(range) = first_range {
389 map.insert(mc.node_id.dom, TextSelection {
390 dom_id: mc.node_id.dom,
391 anchor: SelectionAnchor {
392 ifc_root_node_id: node_id,
393 cursor: range.start,
394 char_bounds: LogicalRect::zero(),
395 mouse_position: azul_core::geom::LogicalPosition::zero(),
396 },
397 focus: SelectionFocus {
398 ifc_root_node_id: node_id,
399 cursor: range.end,
400 mouse_position: azul_core::geom::LogicalPosition::zero(),
401 },
402 affected_nodes,
403 is_forward: true,
404 });
405 }
406
407 map
408 }
409}
410
411impl crate::managers::NodeIdRemap for TextEditManager {
412 fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
418 let Some(ref mut mc) = self.multi_cursor else {
419 return;
420 };
421 if mc.node_id.dom != dom {
422 return;
423 }
424 let unmounted = mc
425 .node_id
426 .node
427 .into_crate_internal()
428 .is_none_or(|old| map.resolve(old).is_none());
429 if unmounted {
430 self.multi_cursor = None;
431 self.preedit_text = None;
432 self.preedit_cursor_begin = -1;
433 self.preedit_cursor_end = -1;
434 self.display_list_dirty = true;
435 return;
436 }
437 mc.remap_node_ids(dom, map.as_btree_map());
438 }
439}
440
441#[cfg(test)]
445mod autotest_generated {
446 use azul_core::{
447 selection::{
448 CursorAffinity, GraphemeClusterId, IdentifiedSelection, SelectionId, SelectionRange,
449 },
450 task::{Duration, SystemTick, SystemTimeDiff},
451 };
452
453 use super::*;
454 use crate::managers::{NodeIdMap, NodeIdRemap};
455
456 const DOM0: DomId = DomId { inner: 0 };
457 const DOM1: DomId = DomId { inner: 1 };
458 const DOM_MAX: DomId = DomId { inner: usize::MAX };
461
462 const MAX_ENCODABLE_NODE: usize = usize::MAX - 1;
467
468 fn cursor(run: u32, byte: u32) -> TextCursor {
469 TextCursor {
470 cluster_id: GraphemeClusterId {
471 source_run: run,
472 start_byte_in_run: byte,
473 },
474 affinity: CursorAffinity::Leading,
475 }
476 }
477
478 fn range(from: TextCursor, to: TextCursor) -> SelectionRange {
479 SelectionRange {
480 start: from,
481 end: to,
482 }
483 }
484
485 fn dom_node(dom: DomId, node: Option<NodeId>) -> DomNodeId {
486 DomNodeId {
487 dom,
488 node: NodeHierarchyItemId::from_crate_internal(node),
489 }
490 }
491
492 fn multi_cursor_with(
496 node_id: DomNodeId,
497 selections: Vec<Selection>,
498 key: u64,
499 ) -> MultiCursorState {
500 let identified: Vec<IdentifiedSelection> = selections
501 .into_iter()
502 .map(|selection| IdentifiedSelection {
503 id: SelectionId::new(),
504 selection,
505 })
506 .collect();
507 let primary_id = identified
508 .last()
509 .map_or_else(SelectionId::new, |s| s.id);
510 MultiCursorState {
511 selections: identified,
512 primary_id,
513 node_id,
514 contenteditable_key: key,
515 }
516 }
517
518 fn plus_ms(base: &Instant, ms: u64) -> Instant {
520 base.add_optional_duration(Some(&Duration::System(SystemTimeDiff::from_millis(ms))))
521 }
522
523 #[test]
528 fn autotest_blink_new_invariants() {
529 let b = BlinkState::new();
530 assert!(!b.is_visible, "a fresh BlinkState starts hidden");
531 assert!(b.last_input_time.is_none());
532 assert!(!b.is_blink_timer_active());
533 assert!(!b.blink_timer_active);
534 assert_eq!(b.blink_interval, CURSOR_BLINK_INTERVAL);
537 assert_eq!(b.blink_interval, Duration::from_millis(CURSOR_BLINK_INTERVAL_MS));
538 assert!(b.should_blink(&Instant::now()));
540 }
541
542 #[test]
543 fn autotest_blink_toggle_visibility_alternates_and_returns_new_state() {
544 let mut b = BlinkState::new();
545 assert!(b.toggle_visibility(), "first toggle turns the caret on");
546 assert!(b.is_visible);
547 assert!(!b.toggle_visibility(), "second toggle turns it back off");
548 assert!(!b.is_visible);
549
550 let mut expected = false;
553 for _ in 0..1000 {
554 expected = !expected;
555 let returned = b.toggle_visibility();
556 assert_eq!(returned, expected);
557 assert_eq!(b.is_visible, expected);
558 }
559 assert!(!b.is_visible, "an even number of toggles restores the state");
560 }
561
562 #[test]
563 fn autotest_blink_set_visibility_is_idempotent_and_orthogonal() {
564 let mut b = BlinkState::new();
565 b.set_blink_timer_active(true);
566
567 b.set_visibility(true);
568 b.set_visibility(true);
569 assert!(b.is_visible);
570 assert!(
571 b.is_blink_timer_active(),
572 "visibility must not disturb the timer flag"
573 );
574
575 b.set_visibility(false);
576 b.set_visibility(false);
577 assert!(!b.is_visible);
578 assert!(b.is_blink_timer_active());
579 }
580
581 #[test]
582 fn autotest_blink_timer_active_true_false_and_idempotent() {
583 let mut b = BlinkState::new();
584 assert!(!b.is_blink_timer_active(), "known-false: default state");
585
586 b.set_blink_timer_active(true);
587 assert!(b.is_blink_timer_active(), "known-true: after activation");
588 b.set_blink_timer_active(true);
589 assert!(b.is_blink_timer_active(), "re-activation is idempotent");
590
591 b.set_blink_timer_active(false);
592 assert!(!b.is_blink_timer_active());
593 b.set_blink_timer_active(false);
594 assert!(!b.is_blink_timer_active(), "re-deactivation is idempotent");
595
596 assert!(!b.is_visible);
598 }
599
600 #[test]
601 fn autotest_blink_reset_on_input_forces_solid_caret() {
602 let mut b = BlinkState::new();
603 b.set_blink_timer_active(true);
604 b.set_visibility(false);
605
606 let now = Instant::now();
607 b.reset_blink_on_input(now.clone());
608
609 assert!(b.is_visible, "typing must show a solid caret");
610 assert_eq!(b.last_input_time.as_ref(), Some(&now));
611 assert!(
612 b.is_blink_timer_active(),
613 "reset_blink_on_input must not stop the timer"
614 );
615 assert!(!b.should_blink(&now));
617 }
618
619 #[test]
620 fn autotest_blink_reset_on_input_repeated_keeps_latest_timestamp() {
621 let mut b = BlinkState::new();
622 let base = Instant::now();
623
624 for i in 0..500u64 {
626 b.reset_blink_on_input(plus_ms(&base, i));
627 assert!(b.is_visible, "the caret stays solid throughout typing");
628 }
629
630 let last = plus_ms(&base, 499);
631 assert_eq!(b.last_input_time.as_ref(), Some(&last));
632 assert!(!b.should_blink(&last));
634 }
635
636 #[test]
637 fn autotest_blink_should_blink_without_input_is_true() {
638 let b = BlinkState::new();
639 let now = Instant::now();
640 assert!(b.should_blink(&now));
641 assert!(b.should_blink(&Instant::Tick(SystemTick::new(0))));
643 }
644
645 #[test]
646 fn autotest_blink_should_blink_interval_boundary_is_strict() {
647 let base = Instant::now();
648 let mut b = BlinkState::new();
649 b.reset_blink_on_input(base.clone());
650
651 assert!(
652 !b.should_blink(&base),
653 "zero elapsed time must not restart the blink"
654 );
655 assert!(
656 !b.should_blink(&plus_ms(&base, CURSOR_BLINK_INTERVAL_MS - 1)),
657 "one millisecond before the interval: still solid"
658 );
659 assert!(
660 !b.should_blink(&plus_ms(&base, CURSOR_BLINK_INTERVAL_MS)),
661 "exactly at the interval: the comparison is strictly greater-than"
662 );
663 assert!(
664 b.should_blink(&plus_ms(&base, CURSOR_BLINK_INTERVAL_MS + 1)),
665 "one millisecond past the interval: blinking resumes"
666 );
667 assert!(b.should_blink(&plus_ms(&base, 86_400_000)));
669 }
670
671 #[test]
672 fn autotest_blink_should_blink_reversed_clock_saturates_to_false() {
673 let base = Instant::now();
677 let mut b = BlinkState::new();
678 b.reset_blink_on_input(plus_ms(&base, 10_000));
679
680 assert!(!b.should_blink(&base));
681 assert!(b.is_visible);
682 }
683
684 #[test]
685 fn autotest_blink_should_blink_mismatched_instant_kinds_are_deterministic() {
686 let mut b = BlinkState::new();
693 b.reset_blink_on_input(Instant::now());
694 let tick_now = Instant::Tick(SystemTick::new(u64::MAX));
695 assert_eq!(b.should_blink(&tick_now), b.should_blink(&tick_now));
696 assert!(!b.should_blink(&tick_now));
697 }
698
699 #[test]
706 fn autotest_blink_a_tick_only_clock_resumes_blinking_at_the_exact_frame() {
707 let mut t = BlinkState::new();
708 t.reset_blink_on_input(Instant::Tick(SystemTick::new(0)));
709
710 assert!(!t.should_blink(&Instant::Tick(SystemTick::new(31))));
712 assert!(t.should_blink(&Instant::Tick(SystemTick::new(32))));
713 assert!(t.should_blink(&Instant::Tick(SystemTick::new(u64::MAX))));
714 }
715
716 #[test]
720 fn autotest_blink_a_tick_interval_flips_on_exactly_the_nth_frame() {
721 let mut b = BlinkState::new();
722 b.set_blink_interval(Duration::from_ticks(5));
723 b.reset_blink_on_input(Instant::Tick(SystemTick::new(100)));
724
725 for frame in 100..=105 {
726 assert!(
727 !b.should_blink(&Instant::Tick(SystemTick::new(frame))),
728 "frame {frame} is within 5 frames of the input and must stay solid"
729 );
730 }
731 assert!(
732 b.should_blink(&Instant::Tick(SystemTick::new(106))),
733 "frame 106 is strictly more than 5 frames past the input"
734 );
735 }
736
737 #[test]
741 fn autotest_blink_a_tick_interval_converts_on_a_wall_clock_instant() {
742 let base = Instant::now();
743 let mut b = BlinkState::new();
744 b.set_blink_interval(Duration::from_ticks(5));
745 b.reset_blink_on_input(base.clone());
746
747 assert!(!b.should_blink(&plus_ms(&base, 83)));
748 assert!(b.should_blink(&plus_ms(&base, 84)));
749 }
750
751 #[test]
752 fn autotest_blink_clear_resets_every_field_and_is_idempotent() {
753 let mut b = BlinkState::new();
754 b.reset_blink_on_input(Instant::now());
755 b.set_blink_timer_active(true);
756 b.set_blink_interval(Duration::from_ticks(5));
757
758 b.clear();
759 assert!(!b.is_visible);
760 assert!(b.last_input_time.is_none());
761 assert!(!b.is_blink_timer_active());
762 assert_eq!(
763 b.blink_interval, CURSOR_BLINK_INTERVAL,
764 "the previous node's caret-animation-duration must not leak to the next"
765 );
766
767 b.clear();
769 assert!(!b.is_visible);
770 assert!(b.last_input_time.is_none());
771 assert!(!b.is_blink_timer_active());
772 assert!(b.should_blink(&Instant::now()));
774 }
775
776 #[test]
781 fn autotest_manager_new_invariants() {
782 let m = TextEditManager::new();
783 assert!(m.multi_cursor.is_none());
784 assert!(!m.has_active_editing());
785 assert!(m.get_editing_dom_id().is_none());
786 assert!(m.get_editing_node_id().is_none());
787 assert!(m.get_primary_cursor().is_none());
788 assert!(!m.should_draw_cursor());
789 assert!(m.preedit_text.is_none());
790 assert_eq!(m.preedit_cursor_begin, -1, "-1 is the 'unset' IME sentinel");
791 assert_eq!(m.preedit_cursor_end, -1);
792 assert!(!m.display_list_dirty, "a fresh manager owes no repaint");
793 assert!(m.build_cursor_locations().is_empty());
794 assert!(m.build_text_selections_map().is_empty());
795 assert_eq!(m, TextEditManager::default());
796 }
797
798 #[test]
799 fn autotest_manager_mark_dirty_is_sticky() {
800 let mut m = TextEditManager::new();
801 m.mark_dirty();
802 assert!(m.display_list_dirty);
803 m.mark_dirty();
804 assert!(m.display_list_dirty, "marking twice must not toggle it off");
805 }
806
807 #[test]
808 fn autotest_manager_partial_eq_ignores_transient_state() {
809 let mut a = TextEditManager::new();
811 let mut b = TextEditManager::new();
812 assert_eq!(a, b);
813
814 a.set_preedit("か".to_string(), 0, 3);
815 a.blink.set_visibility(true);
816 a.mark_dirty();
817 assert_eq!(a, b, "preedit / blink / dirty are transient visual state");
818
819 b.initialize_editing(cursor(0, 0), DOM0, NodeId::ZERO, 1);
820 assert_ne!(a, b, "a live editing session is not equal to no session");
821 }
822
823 #[test]
828 fn autotest_initialize_editing_at_zero() {
829 let mut m = TextEditManager::new();
830 m.initialize_editing(cursor(0, 0), DOM0, NodeId::ZERO, 0);
831
832 assert!(m.has_active_editing());
833 assert_eq!(m.get_editing_dom_id(), Some(DOM0));
834 assert_eq!(
835 m.get_editing_node_id(),
836 Some(NodeId::ZERO),
837 "node index 0 must not be confused with the 'no node' encoding"
838 );
839 assert_eq!(m.get_primary_cursor(), Some(cursor(0, 0)));
840 assert_eq!(
841 m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
842 Some(0)
843 );
844 assert!(m.blink.is_visible);
845 let anchored = m
850 .blink
851 .last_input_time
852 .as_ref()
853 .expect("initialize_editing must anchor the blink phase, not clear it");
854 assert!(
855 !m.blink.should_blink(anchored),
856 "at the instant of focus, zero time has elapsed — blinking must not be allowed yet"
857 );
858 assert!(m.should_draw_cursor());
859 assert!(m.display_list_dirty);
860 assert_eq!(
861 m.build_cursor_locations(),
862 vec![(DOM0, NodeId::ZERO, cursor(0, 0))]
863 );
864 }
865
866 #[test]
867 fn autotest_initialize_editing_at_integer_extremes() {
868 let node = NodeId::new(MAX_ENCODABLE_NODE);
871 let extreme_cursor = TextCursor {
872 cluster_id: GraphemeClusterId {
873 source_run: u32::MAX,
874 start_byte_in_run: u32::MAX,
875 },
876 affinity: CursorAffinity::Trailing,
877 };
878
879 let mut m = TextEditManager::new();
880 m.initialize_editing(extreme_cursor, DOM_MAX, node, u64::MAX);
881
882 assert_eq!(m.get_editing_dom_id(), Some(DOM_MAX));
883 assert_eq!(
884 m.get_editing_node_id(),
885 Some(node),
886 "usize::MAX - 1 is the largest 1-based-encodable node index"
887 );
888 assert_eq!(m.get_primary_cursor(), Some(extreme_cursor));
889 assert_eq!(
890 m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
891 Some(u64::MAX),
892 "the contenteditable key is opaque — u64::MAX must survive verbatim"
893 );
894 assert_eq!(
895 m.build_cursor_locations(),
896 vec![(DOM_MAX, node, extreme_cursor)]
897 );
898 }
899
900 #[test]
901 fn autotest_initialize_editing_overwrites_previous_session() {
902 let mut m = TextEditManager::new();
903 m.initialize_editing(cursor(1, 1), DOM0, NodeId::new(7), 111);
904 m.initialize_editing(cursor(2, 2), DOM1, NodeId::new(9), 222);
905
906 assert_eq!(m.get_editing_dom_id(), Some(DOM1));
907 assert_eq!(m.get_editing_node_id(), Some(NodeId::new(9)));
908 assert_eq!(m.get_primary_cursor(), Some(cursor(2, 2)));
909 assert_eq!(
910 m.build_cursor_locations().len(),
911 1,
912 "re-initializing replaces the cursor set, it does not accumulate"
913 );
914 assert_eq!(
915 m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
916 Some(222)
917 );
918 }
919
920 #[test]
921 fn autotest_initialize_editing_clears_stale_preedit() {
922 let mut m = TextEditManager::new();
923 m.set_preedit("漢字".to_string(), 3, 6);
924 m.initialize_editing(cursor(0, 0), DOM0, NodeId::new(4), 42);
925
926 assert!(
927 m.preedit_text.is_none(),
928 "focusing a new element must drop the old composition"
929 );
930 assert_eq!(m.preedit_cursor_begin, -1);
931 assert_eq!(m.preedit_cursor_end, -1);
932 }
933
934 #[test]
939 fn autotest_clear_editing_on_fresh_manager_is_safe() {
940 let mut m = TextEditManager::new();
941 m.clear_editing();
942 m.clear_editing();
943
944 assert!(!m.has_active_editing());
945 assert!(!m.should_draw_cursor());
946 assert!(m.build_cursor_locations().is_empty());
947 assert!(
956 !m.display_list_dirty,
957 "clearing an already-clear manager must not request a repaint"
958 );
959 }
960
961 #[test]
962 fn autotest_clear_editing_tears_down_everything() {
963 let mut m = TextEditManager::new();
964 m.initialize_editing(cursor(0, 5), DOM0, NodeId::new(3), 77);
965 m.set_preedit("ab".to_string(), 0, 2);
966 m.blink.set_blink_timer_active(true);
967 m.blink.reset_blink_on_input(Instant::now());
968
969 m.clear_editing();
970
971 assert!(m.multi_cursor.is_none());
972 assert!(!m.has_active_editing());
973 assert!(m.get_editing_dom_id().is_none());
974 assert!(m.get_editing_node_id().is_none());
975 assert!(m.get_primary_cursor().is_none());
976 assert!(!m.should_draw_cursor());
977 assert!(!m.blink.is_visible);
978 assert!(!m.blink.is_blink_timer_active());
979 assert!(m.blink.last_input_time.is_none());
980 assert!(m.preedit_text.is_none());
981 assert_eq!(m.preedit_cursor_begin, -1);
982 assert_eq!(m.preedit_cursor_end, -1);
983 assert!(m.display_list_dirty);
984 assert!(m.build_cursor_locations().is_empty());
985 assert!(m.build_text_selections_map().is_empty());
986 }
987
988 #[test]
993 fn autotest_set_preedit_zero_offsets_are_not_the_unset_sentinel() {
994 let mut m = TextEditManager::new();
995 m.set_preedit("a".to_string(), 0, 0);
996
997 assert_eq!(m.preedit_text.as_deref(), Some("a"));
998 assert_eq!(
999 m.preedit_cursor_begin, 0,
1000 "0 is a valid offset and must not be coerced to the -1 sentinel"
1001 );
1002 assert_eq!(m.preedit_cursor_end, 0);
1003 assert!(m.display_list_dirty);
1004 }
1005
1006 #[test]
1007 fn autotest_set_preedit_stores_i32_extremes_verbatim() {
1008 let mut m = TextEditManager::new();
1009
1010 m.set_preedit("x".to_string(), i32::MIN, i32::MAX);
1011 assert_eq!(m.preedit_cursor_begin, i32::MIN);
1012 assert_eq!(m.preedit_cursor_end, i32::MAX);
1013
1014 m.set_preedit("x".to_string(), -42, -7);
1018 assert_eq!(m.preedit_cursor_begin, -42);
1019 assert_eq!(m.preedit_cursor_end, -7);
1020
1021 m.set_preedit("x".to_string(), 10, 2);
1022 assert_eq!(m.preedit_cursor_begin, 10);
1023 assert_eq!(m.preedit_cursor_end, 2);
1024 }
1025
1026 #[test]
1027 fn autotest_set_preedit_offsets_beyond_text_length_are_not_validated() {
1028 let mut m = TextEditManager::new();
1032 m.set_preedit("ab".to_string(), i32::MAX, i32::MAX);
1033
1034 assert_eq!(m.preedit_text.as_deref(), Some("ab"));
1035 assert_eq!(m.preedit_cursor_begin, i32::MAX);
1036 assert_eq!(m.preedit_cursor_end, i32::MAX);
1037 }
1038
1039 #[test]
1040 fn autotest_set_preedit_empty_text_becomes_none_but_keeps_offsets() {
1041 let mut m = TextEditManager::new();
1046 m.set_preedit(String::new(), 5, 9);
1047
1048 assert!(m.preedit_text.is_none());
1049 assert_eq!(m.preedit_cursor_begin, 5);
1050 assert_eq!(m.preedit_cursor_end, 9);
1051 assert!(m.display_list_dirty);
1052 }
1053
1054 #[test]
1055 fn autotest_set_preedit_preserves_unicode_verbatim() {
1056 let mut m = TextEditManager::new();
1057
1058 for text in [
1059 "こんにちは", "👨👩👧👦", "e\u{0301}\u{0327}", "مرحبا", "a\u{0000}b", "\u{FEFF}bom", "🇩🇪🇯🇵", ] {
1067 m.set_preedit(text.to_string(), 0, 1);
1068 assert_eq!(
1069 m.preedit_text.as_deref(),
1070 Some(text),
1071 "preedit text must round-trip byte-for-byte"
1072 );
1073 }
1074 }
1075
1076 #[test]
1077 fn autotest_set_preedit_huge_text_does_not_panic() {
1078 let huge = "あ".repeat(100_000); let mut m = TextEditManager::new();
1080 m.set_preedit(huge.clone(), 0, 299_999);
1081
1082 assert_eq!(m.preedit_text.as_deref(), Some(huge.as_str()));
1083 assert_eq!(m.preedit_text.as_ref().map(String::len), Some(300_000));
1084 }
1085
1086 #[test]
1087 fn autotest_clear_preedit_is_idempotent_and_marks_dirty() {
1088 let mut m = TextEditManager::new();
1089 m.set_preedit("ば".to_string(), 0, 3);
1090
1091 m.clear_preedit();
1092 assert!(m.preedit_text.is_none());
1093 assert_eq!(m.preedit_cursor_begin, -1);
1094 assert_eq!(m.preedit_cursor_end, -1);
1095
1096 m.display_list_dirty = false;
1097 m.clear_preedit();
1098 assert!(m.preedit_text.is_none());
1099 assert_eq!(m.preedit_cursor_begin, -1);
1100 assert_eq!(m.preedit_cursor_end, -1);
1101 assert!(
1106 !m.display_list_dirty,
1107 "a no-op clear_preedit must not request a repaint"
1108 );
1109 }
1110
1111 #[test]
1112 fn autotest_preedit_does_not_create_an_editing_session() {
1113 let mut m = TextEditManager::new();
1114 m.set_preedit("compose".to_string(), 0, 7);
1115
1116 assert!(
1117 !m.has_active_editing(),
1118 "IME text alone must not fake an editing session"
1119 );
1120 assert!(!m.should_draw_cursor());
1121 assert!(m.get_primary_cursor().is_none());
1122 }
1123
1124 #[test]
1129 fn autotest_build_cursor_locations_empty_without_session() {
1130 assert!(TextEditManager::new().build_cursor_locations().is_empty());
1131 }
1132
1133 #[test]
1134 fn autotest_build_cursor_locations_uses_range_end_and_keeps_order() {
1135 let node = NodeId::new(12);
1136 let a = cursor(0, 0);
1137 let b = cursor(0, 4);
1138 let c = cursor(1, 8);
1139
1140 let mut m = TextEditManager::new();
1141 m.multi_cursor = Some(multi_cursor_with(
1142 dom_node(DOM1, Some(node)),
1143 vec![
1144 Selection::Cursor(a),
1145 Selection::Range(range(b, c)),
1146 Selection::Cursor(c),
1147 ],
1148 5,
1149 ));
1150
1151 assert_eq!(
1152 m.build_cursor_locations(),
1153 vec![(DOM1, node, a), (DOM1, node, c), (DOM1, node, c)],
1154 "a Range contributes its `end` as the caret position"
1155 );
1156 }
1157
1158 #[test]
1159 fn autotest_build_cursor_locations_with_detached_node_is_empty() {
1160 let mut m = TextEditManager::new();
1163 m.multi_cursor = Some(multi_cursor_with(
1164 dom_node(DOM0, None),
1165 vec![Selection::Cursor(cursor(0, 0))],
1166 1,
1167 ));
1168
1169 assert!(m.has_active_editing());
1170 assert!(m.get_editing_node_id().is_none());
1171 assert!(m.build_cursor_locations().is_empty());
1172 assert!(m.build_text_selections_map().is_empty());
1173 }
1174
1175 #[test]
1176 fn autotest_build_cursor_locations_with_no_selections_is_empty() {
1177 let mut m = TextEditManager::new();
1178 m.multi_cursor = Some(multi_cursor_with(
1179 dom_node(DOM0, Some(NodeId::ZERO)),
1180 Vec::new(),
1181 0,
1182 ));
1183
1184 assert!(m.build_cursor_locations().is_empty());
1185 assert!(m.get_primary_cursor().is_none());
1186 assert!(m.build_text_selections_map().is_empty());
1187 }
1188
1189 #[test]
1190 fn autotest_build_cursor_locations_scales_to_many_cursors() {
1191 let node = NodeId::new(2);
1192 let selections: Vec<Selection> = (0..1000u32)
1193 .map(|i| Selection::Cursor(cursor(0, i)))
1194 .collect();
1195
1196 let mut m = TextEditManager::new();
1197 m.multi_cursor = Some(multi_cursor_with(
1198 dom_node(DOM0, Some(node)),
1199 selections,
1200 9,
1201 ));
1202
1203 let locations = m.build_cursor_locations();
1204 assert_eq!(locations.len(), 1000);
1205 assert_eq!(locations[0], (DOM0, node, cursor(0, 0)));
1206 assert_eq!(locations[999], (DOM0, node, cursor(0, 999)));
1207 }
1208
1209 #[test]
1214 fn autotest_build_text_selections_map_empty_without_session() {
1215 assert!(TextEditManager::new().build_text_selections_map().is_empty());
1216 }
1217
1218 #[test]
1219 fn autotest_build_text_selections_map_ignores_pure_cursors() {
1220 let mut m = TextEditManager::new();
1222 m.initialize_editing(cursor(0, 3), DOM0, NodeId::new(1), 8);
1223 assert!(m.build_text_selections_map().is_empty());
1224 }
1225
1226 #[test]
1227 fn autotest_build_text_selections_map_single_range() {
1228 let node = NodeId::new(6);
1229 let start = cursor(0, 2);
1230 let end = cursor(0, 9);
1231
1232 let mut m = TextEditManager::new();
1233 m.multi_cursor = Some(multi_cursor_with(
1234 dom_node(DOM1, Some(node)),
1235 vec![Selection::Range(range(start, end))],
1236 3,
1237 ));
1238
1239 let map = m.build_text_selections_map();
1240 assert_eq!(map.len(), 1);
1241 let sel = map.get(&DOM1).expect("keyed by the editing DomId");
1242 assert_eq!(sel.dom_id, DOM1);
1243 assert_eq!(sel.anchor.ifc_root_node_id, node);
1244 assert_eq!(sel.anchor.cursor, start);
1245 assert_eq!(sel.focus.ifc_root_node_id, node);
1246 assert_eq!(sel.focus.cursor, end);
1247 assert!(sel.is_forward);
1248 assert_eq!(sel.affected_nodes.len(), 1);
1249 assert_eq!(sel.affected_nodes.get(&node), Some(&range(start, end)));
1250 }
1251
1252 #[test]
1253 fn autotest_build_text_selections_map_backward_range_is_reported_forward() {
1254 let node = NodeId::new(6);
1258 let start = cursor(0, 9);
1259 let end = cursor(0, 2);
1260
1261 let mut m = TextEditManager::new();
1262 m.multi_cursor = Some(multi_cursor_with(
1263 dom_node(DOM0, Some(node)),
1264 vec![Selection::Range(range(start, end))],
1265 3,
1266 ));
1267
1268 let map = m.build_text_selections_map();
1269 let sel = map.get(&DOM0).expect("keyed by the editing DomId");
1270 assert_eq!(sel.anchor.cursor, start);
1271 assert_eq!(sel.focus.cursor, end);
1272 assert!(sel.is_forward);
1273 }
1274
1275 #[test]
1276 fn autotest_build_text_selections_map_multi_range_first_wins_endpoints() {
1277 let node = NodeId::new(4);
1282 let first = range(cursor(0, 0), cursor(0, 1));
1283 let last = range(cursor(0, 5), cursor(0, 8));
1284
1285 let mut m = TextEditManager::new();
1286 m.multi_cursor = Some(multi_cursor_with(
1287 dom_node(DOM0, Some(node)),
1288 vec![
1289 Selection::Range(first),
1290 Selection::Cursor(cursor(0, 3)),
1291 Selection::Range(last),
1292 ],
1293 2,
1294 ));
1295
1296 let map = m.build_text_selections_map();
1297 assert_eq!(map.len(), 1, "one entry per DomId, not per range");
1298 let sel = map.get(&DOM0).expect("keyed by the editing DomId");
1299 assert_eq!(sel.anchor.cursor, first.start, "endpoints from the FIRST range");
1300 assert_eq!(sel.focus.cursor, first.end);
1301 assert_eq!(
1302 sel.affected_nodes.get(&node),
1303 Some(&last),
1304 "affected_nodes keeps the LAST range — it disagrees with anchor/focus"
1305 );
1306 }
1307
1308 #[test]
1309 fn autotest_build_text_selections_map_degenerate_and_extreme_ranges() {
1310 let node = NodeId::new(MAX_ENCODABLE_NODE);
1311 let point = cursor(u32::MAX, u32::MAX);
1313
1314 let mut m = TextEditManager::new();
1315 m.multi_cursor = Some(multi_cursor_with(
1316 dom_node(DOM_MAX, Some(node)),
1317 vec![Selection::Range(range(point, point))],
1318 u64::MAX,
1319 ));
1320
1321 let map = m.build_text_selections_map();
1322 let sel = map.get(&DOM_MAX).expect("keyed by the editing DomId");
1323 assert_eq!(sel.anchor.cursor, point);
1324 assert_eq!(sel.focus.cursor, point);
1325 assert_eq!(sel.affected_nodes.get(&node), Some(&range(point, point)));
1326 }
1327
1328 #[test]
1333 fn autotest_remap_without_session_is_a_noop() {
1334 let mut m = TextEditManager::new();
1335 m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::ZERO, NodeId::new(1))]));
1336
1337 assert!(!m.has_active_editing());
1338 assert!(
1339 !m.display_list_dirty,
1340 "nothing changed, so nothing to repaint"
1341 );
1342 }
1343
1344 #[test]
1345 fn autotest_remap_rewrites_surviving_node() {
1346 let mut m = TextEditManager::new();
1347 m.initialize_editing(cursor(0, 2), DOM0, NodeId::new(3), 55);
1348 m.set_preedit("ok".to_string(), 0, 2);
1349
1350 m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(8))]));
1351
1352 assert!(m.has_active_editing());
1353 assert_eq!(m.get_editing_node_id(), Some(NodeId::new(8)));
1354 assert_eq!(m.get_editing_dom_id(), Some(DOM0));
1355 assert_eq!(m.get_primary_cursor(), Some(cursor(0, 2)));
1356 assert_eq!(
1357 m.preedit_text.as_deref(),
1358 Some("ok"),
1359 "a surviving node keeps its in-flight composition"
1360 );
1361 assert_eq!(
1362 m.multi_cursor.as_ref().map(|mc| mc.contenteditable_key),
1363 Some(55),
1364 "the stable key must survive the rebuild"
1365 );
1366 }
1367
1368 #[test]
1369 fn autotest_remap_drops_session_when_node_unmounted() {
1370 let mut m = TextEditManager::new();
1371 m.initialize_editing(cursor(0, 1), DOM0, NodeId::new(3), 55);
1372 m.set_preedit("gone".to_string(), 1, 4);
1373 m.display_list_dirty = false;
1374
1375 m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::new(4), NodeId::new(4))]));
1377
1378 assert!(!m.has_active_editing());
1379 assert!(m.multi_cursor.is_none());
1380 assert!(m.preedit_text.is_none());
1381 assert_eq!(m.preedit_cursor_begin, -1);
1382 assert_eq!(m.preedit_cursor_end, -1);
1383 assert!(m.display_list_dirty);
1384 assert!(m.build_cursor_locations().is_empty());
1385 }
1386
1387 #[test]
1388 fn autotest_remap_with_empty_map_drops_session() {
1389 let mut m = TextEditManager::new();
1390 m.initialize_editing(cursor(0, 0), DOM0, NodeId::ZERO, 1);
1391 m.remap_node_ids(DOM0, &NodeIdMap::default());
1392
1393 assert!(
1394 !m.has_active_editing(),
1395 "an empty map means every node was unmounted"
1396 );
1397 }
1398
1399 #[test]
1400 fn autotest_remap_leaves_other_doms_alone() {
1401 let mut m = TextEditManager::new();
1402 m.initialize_editing(cursor(0, 0), DOM1, NodeId::new(3), 1);
1403
1404 m.remap_node_ids(DOM0, &NodeIdMap::default());
1406
1407 assert!(m.has_active_editing());
1408 assert_eq!(m.get_editing_dom_id(), Some(DOM1));
1409 assert_eq!(m.get_editing_node_id(), Some(NodeId::new(3)));
1410 }
1411
1412 #[test]
1413 fn autotest_remap_of_detached_node_drops_session() {
1414 let mut m = TextEditManager::new();
1415 m.multi_cursor = Some(multi_cursor_with(
1416 dom_node(DOM0, None),
1417 vec![Selection::Cursor(cursor(0, 0))],
1418 1,
1419 ));
1420
1421 m.remap_node_ids(DOM0, &NodeIdMap::from_pairs([(NodeId::ZERO, NodeId::ZERO)]));
1422
1423 assert!(
1424 !m.has_active_editing(),
1425 "a cursor with no IFC root is not an editing session"
1426 );
1427 assert!(m.display_list_dirty);
1428 }
1429}