1use azul_core::{
26 dom::DomNodeId,
27 selection::{OptionSelectionRange, SelectionRange},
28 task::Instant,
29 window::CursorPosition,
30};
31use azul_css::AzString;
32
33use crate::managers::selection::ClipboardContent;
34
35pub type ChangesetId = usize;
37
38#[derive(Debug, Clone)]
40#[repr(C)]
41pub struct TextChangeset {
42 pub id: ChangesetId,
44 pub target: DomNodeId,
46 pub operation: TextOperation,
48 pub timestamp: Instant,
50}
51
52#[derive(Debug, Clone)]
54#[repr(C)]
55pub struct TextOpInsertText {
56 pub text: AzString,
57 pub position: CursorPosition,
58 pub new_cursor: CursorPosition,
59}
60
61#[derive(Debug, Clone)]
63#[repr(C)]
64pub struct TextOpDeleteText {
65 pub range: SelectionRange,
66 pub deleted_text: AzString,
67 pub new_cursor: CursorPosition,
68}
69
70#[derive(Debug, Clone)]
72#[repr(C)]
73pub struct TextOpReplaceText {
74 pub range: SelectionRange,
75 pub old_text: AzString,
76 pub new_text: AzString,
77 pub new_cursor: CursorPosition,
78}
79
80#[derive(Copy, Debug, Clone)]
82#[repr(C)]
83pub struct TextOpSetSelection {
84 pub old_range: OptionSelectionRange,
85 pub new_range: SelectionRange,
86}
87
88#[derive(Copy, Debug, Clone)]
90#[repr(C)]
91pub struct TextOpExtendSelection {
92 pub old_range: SelectionRange,
93 pub new_range: SelectionRange,
94 pub direction: SelectionDirection,
95}
96
97#[derive(Copy, Debug, Clone)]
99#[repr(C)]
100pub struct TextOpClearSelection {
101 pub old_range: SelectionRange,
102}
103
104#[derive(Copy, Debug, Clone)]
106#[repr(C)]
107pub struct TextOpMoveCursor {
108 pub old_position: CursorPosition,
109 pub new_position: CursorPosition,
110 pub movement: CursorMovement,
111}
112
113#[derive(Debug, Clone)]
115#[repr(C)]
116pub struct TextOpCopy {
117 pub range: SelectionRange,
118 pub content: ClipboardContent,
119}
120
121#[derive(Debug, Clone)]
123#[repr(C)]
124pub struct TextOpCut {
125 pub range: SelectionRange,
126 pub content: ClipboardContent,
127 pub new_cursor: CursorPosition,
128}
129
130#[derive(Debug, Clone)]
132#[repr(C)]
133pub struct TextOpPaste {
134 pub content: ClipboardContent,
135 pub position: CursorPosition,
136 pub new_cursor: CursorPosition,
137}
138
139#[derive(Copy, Debug, Clone)]
141#[repr(C)]
142pub struct TextOpSelectAll {
143 pub old_range: OptionSelectionRange,
144 pub new_range: SelectionRange,
145}
146
147#[derive(Debug, Clone)]
149#[repr(C, u8)]
150pub enum TextOperation {
151 InsertText(TextOpInsertText),
153 DeleteText(TextOpDeleteText),
155 ReplaceText(TextOpReplaceText),
157 SetSelection(TextOpSetSelection),
159 ExtendSelection(TextOpExtendSelection),
161 ClearSelection(TextOpClearSelection),
163 MoveCursor(TextOpMoveCursor),
165 Copy(TextOpCopy),
167 Cut(TextOpCut),
169 Paste(TextOpPaste),
171 SelectAll(TextOpSelectAll),
173}
174
175pub use azul_core::events::SelectionDirection;
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180#[repr(C)]
181pub enum CursorMovement {
182 Left,
184 Right,
186 Up,
188 Down,
190 WordLeft,
192 WordRight,
194 LineStart,
196 LineEnd,
198 DocumentStart,
200 DocumentEnd,
202 Absolute,
204}
205
206impl TextChangeset {
207 pub fn new(target: DomNodeId, operation: TextOperation, timestamp: Instant) -> Self {
209 use std::sync::atomic::{AtomicUsize, Ordering};
210 static CHANGESET_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
211
212 Self {
213 id: CHANGESET_ID_COUNTER.fetch_add(1, Ordering::Relaxed),
214 target,
215 operation,
216 timestamp,
217 }
218 }
219
220 #[must_use] pub const fn mutates_text(&self) -> bool {
222 matches!(
223 self.operation,
224 TextOperation::InsertText { .. }
225 | TextOperation::DeleteText { .. }
226 | TextOperation::ReplaceText { .. }
227 | TextOperation::Cut { .. }
228 | TextOperation::Paste { .. }
229 )
230 }
231
232 #[must_use] pub const fn changes_selection(&self) -> bool {
234 matches!(
235 self.operation,
236 TextOperation::SetSelection { .. }
237 | TextOperation::ExtendSelection { .. }
238 | TextOperation::ClearSelection { .. }
239 | TextOperation::MoveCursor { .. }
240 | TextOperation::SelectAll { .. }
241 )
242 }
243
244 #[must_use] pub const fn uses_clipboard(&self) -> bool {
246 matches!(
247 self.operation,
248 TextOperation::Copy { .. } | TextOperation::Cut { .. } | TextOperation::Paste { .. }
249 )
250 }
251
252 #[must_use] pub const fn resulting_cursor_position(&self) -> Option<CursorPosition> {
254 match &self.operation {
255 TextOperation::InsertText(op) => Some(op.new_cursor),
256 TextOperation::DeleteText(op) => Some(op.new_cursor),
257 TextOperation::ReplaceText(op) => Some(op.new_cursor),
258 TextOperation::Cut(op) => Some(op.new_cursor),
259 TextOperation::Paste(op) => Some(op.new_cursor),
260 TextOperation::MoveCursor(op) => Some(op.new_position),
261 _ => None,
262 }
263 }
264
265 #[must_use] pub const fn resulting_selection_range(&self) -> Option<SelectionRange> {
267 match &self.operation {
268 TextOperation::SetSelection(op) => Some(op.new_range),
269 TextOperation::ExtendSelection(op) => Some(op.new_range),
270 TextOperation::SelectAll(op) => Some(op.new_range),
271 _ => None,
272 }
273 }
274}
275
276#[cfg(test)]
277mod autotest_generated {
278 use std::{collections::HashSet, thread};
279
280 use azul_core::{
281 dom::DomId,
282 geom::LogicalPosition,
283 selection::{CursorAffinity, GraphemeClusterId, TextCursor},
284 styled_dom::NodeHierarchyItemId,
285 task::SystemTick,
286 };
287
288 use super::*;
289 use crate::managers::selection::StyledTextRun;
290
291 fn node(dom: usize, raw: usize) -> DomNodeId {
304 DomNodeId {
305 dom: DomId { inner: dom },
306 node: NodeHierarchyItemId::from_raw(raw),
307 }
308 }
309
310 fn ts(tick: u64) -> Instant {
311 Instant::Tick(SystemTick::new(tick))
312 }
313
314 fn cur(x: f32, y: f32) -> CursorPosition {
315 CursorPosition::InWindow(LogicalPosition::new(x, y))
316 }
317
318 fn tc(run: u32, byte: u32, affinity: CursorAffinity) -> TextCursor {
319 TextCursor {
320 cluster_id: GraphemeClusterId {
321 source_run: run,
322 start_byte_in_run: byte,
323 },
324 affinity,
325 }
326 }
327
328 fn range(start: TextCursor, end: TextCursor) -> SelectionRange {
329 SelectionRange { start, end }
330 }
331
332 fn simple_range() -> SelectionRange {
334 range(
335 tc(0, 0, CursorAffinity::Leading),
336 tc(0, 1, CursorAffinity::Trailing),
337 )
338 }
339
340 fn extreme_range() -> SelectionRange {
342 range(
343 tc(u32::MAX, u32::MAX, CursorAffinity::Trailing),
344 tc(0, 0, CursorAffinity::Leading),
345 )
346 }
347
348 fn clip(text: &str) -> ClipboardContent {
349 ClipboardContent {
350 plain_text: AzString::from(text),
351 styled_runs: Vec::<StyledTextRun>::new().into(),
352 }
353 }
354
355 fn all_ops() -> Vec<(&'static str, TextOperation)> {
360 vec![
361 (
362 "InsertText",
363 TextOperation::InsertText(TextOpInsertText {
364 text: AzString::from("a\u{0301}\u{1F600}\u{202E}\0"),
365 position: cur(f32::NAN, f32::NEG_INFINITY),
366 new_cursor: cur(f32::MAX, f32::MIN),
367 }),
368 ),
369 (
370 "DeleteText",
371 TextOperation::DeleteText(TextOpDeleteText {
372 range: extreme_range(),
373 deleted_text: AzString::from(""),
374 new_cursor: CursorPosition::Uninitialized,
375 }),
376 ),
377 (
378 "ReplaceText",
379 TextOperation::ReplaceText(TextOpReplaceText {
380 range: simple_range(),
381 old_text: AzString::from("\u{FFFD}"),
382 new_text: AzString::from("\u{10FFFF}"),
383 new_cursor: CursorPosition::OutOfWindow(LogicalPosition::new(-0.0, 0.0)),
384 }),
385 ),
386 (
387 "SetSelection",
388 TextOperation::SetSelection(TextOpSetSelection {
389 old_range: OptionSelectionRange::None,
390 new_range: extreme_range(),
391 }),
392 ),
393 (
394 "ExtendSelection",
395 TextOperation::ExtendSelection(TextOpExtendSelection {
396 old_range: simple_range(),
397 new_range: extreme_range(),
398 direction: SelectionDirection::Backward,
399 }),
400 ),
401 (
402 "ClearSelection",
403 TextOperation::ClearSelection(TextOpClearSelection {
404 old_range: extreme_range(),
405 }),
406 ),
407 (
408 "MoveCursor",
409 TextOperation::MoveCursor(TextOpMoveCursor {
410 old_position: CursorPosition::Uninitialized,
411 new_position: cur(f32::INFINITY, f32::NAN),
412 movement: CursorMovement::DocumentEnd,
413 }),
414 ),
415 (
416 "Copy",
417 TextOperation::Copy(TextOpCopy {
418 range: extreme_range(),
419 content: clip(""),
420 }),
421 ),
422 (
423 "Cut",
424 TextOperation::Cut(TextOpCut {
425 range: extreme_range(),
426 content: clip("\u{1F600}"),
427 new_cursor: cur(0.0, 0.0),
428 }),
429 ),
430 (
431 "Paste",
432 TextOperation::Paste(TextOpPaste {
433 content: clip("\r\n\t"),
434 position: cur(-1.0e30, 1.0e30),
435 new_cursor: cur(f32::EPSILON, -f32::EPSILON),
436 }),
437 ),
438 (
439 "SelectAll",
440 TextOperation::SelectAll(TextOpSelectAll {
441 old_range: OptionSelectionRange::Some(simple_range()),
442 new_range: extreme_range(),
443 }),
444 ),
445 ]
446 }
447
448 fn expected_predicates(name: &str) -> (bool, bool, bool) {
453 match name {
454 "InsertText" | "DeleteText" | "ReplaceText" => (true, false, false),
455 "SetSelection" | "ExtendSelection" | "ClearSelection" | "MoveCursor" | "SelectAll" => {
456 (false, true, false)
457 }
458 "Copy" => (false, false, true),
459 "Cut" | "Paste" => (true, false, true),
460 other => panic!("unclassified TextOperation variant: {other}"),
461 }
462 }
463
464 fn changeset_for(op: TextOperation) -> TextChangeset {
465 TextChangeset::new(node(0, 1), op, ts(0))
466 }
467
468 #[test]
473 fn new_preserves_every_argument_verbatim() {
474 let target = node(usize::MAX, usize::MAX);
475 let timestamp = ts(u64::MAX);
476 let op = TextOperation::InsertText(TextOpInsertText {
477 text: AzString::from("hello"),
478 position: cur(1.0, 2.0),
479 new_cursor: cur(3.0, 4.0),
480 });
481
482 let cs = TextChangeset::new(target, op, timestamp.clone());
483
484 assert_eq!(cs.target, target, "target must round-trip unchanged");
485 assert_eq!(
486 cs.timestamp, timestamp,
487 "timestamp must round-trip unchanged"
488 );
489 match &cs.operation {
490 TextOperation::InsertText(op) => assert_eq!(op.text.as_str(), "hello"),
491 other => panic!("constructor swapped the operation variant: {other:?}"),
492 }
493 }
494
495 #[test]
496 fn new_does_not_panic_on_extreme_arguments() {
497 let huge_text = "\u{1F600}".repeat(64 * 1024); let cs = TextChangeset::new(
501 node(usize::MAX, usize::MAX),
502 TextOperation::ReplaceText(TextOpReplaceText {
503 range: extreme_range(),
504 old_text: AzString::from(huge_text.as_str()),
505 new_text: AzString::from(""),
506 new_cursor: cur(f32::NAN, f32::NAN),
507 }),
508 ts(u64::MAX),
509 );
510
511 assert_eq!(cs.target.dom.inner, usize::MAX);
512 assert_eq!(cs.target.node.into_raw(), usize::MAX);
513 assert!(cs.mutates_text());
514 assert!(cs.resulting_cursor_position().is_some());
515 match &cs.operation {
516 TextOperation::ReplaceText(op) => {
517 assert_eq!(op.old_text.as_str().len(), 256 * 1024);
518 assert!(op.new_text.as_str().is_empty());
519 }
520 other => panic!("unexpected variant: {other:?}"),
521 }
522 }
523
524 #[test]
525 fn new_assigns_strictly_increasing_unique_ids() {
526 let mut ids = Vec::new();
527 for i in 0..256_u64 {
528 let cs = TextChangeset::new(
529 node(0, 1),
530 TextOperation::ClearSelection(TextOpClearSelection {
531 old_range: simple_range(),
532 }),
533 ts(i),
534 );
535 ids.push(cs.id);
536 }
537
538 for w in ids.windows(2) {
541 assert!(
542 w[1] > w[0],
543 "changeset ids must strictly increase: {} then {}",
544 w[0],
545 w[1]
546 );
547 }
548 let unique: HashSet<ChangesetId> = ids.iter().copied().collect();
549 assert_eq!(unique.len(), ids.len(), "changeset ids must be unique");
550 }
551
552 #[test]
553 fn new_ids_stay_unique_across_threads() {
554 const THREADS: usize = 8;
558 const PER_THREAD: usize = 250;
559
560 let handles: Vec<_> = (0..THREADS)
561 .map(|_| {
562 thread::spawn(|| {
563 (0..PER_THREAD)
564 .map(|_| {
565 TextChangeset::new(
566 node(0, 1),
567 TextOperation::Copy(TextOpCopy {
568 range: simple_range(),
569 content: clip("x"),
570 }),
571 ts(0),
572 )
573 .id
574 })
575 .collect::<Vec<ChangesetId>>()
576 })
577 })
578 .collect();
579
580 let mut all = Vec::new();
581 for h in handles {
582 all.extend(h.join().expect("worker thread panicked"));
583 }
584
585 let unique: HashSet<ChangesetId> = all.iter().copied().collect();
586 assert_eq!(
587 unique.len(),
588 THREADS * PER_THREAD,
589 "concurrent TextChangeset::new handed out duplicate ids"
590 );
591 }
592
593 #[test]
594 fn clone_keeps_the_id_but_new_mints_a_fresh_one() {
595 let cs = changeset_for(TextOperation::ClearSelection(TextOpClearSelection {
596 old_range: simple_range(),
597 }));
598 let cloned = cs.clone();
599 assert_eq!(cloned.id, cs.id, "Clone must not re-mint the id");
600
601 let fresh = changeset_for(TextOperation::ClearSelection(TextOpClearSelection {
602 old_range: simple_range(),
603 }));
604 assert!(fresh.id > cs.id, "new() must mint a fresh id");
605 }
606
607 #[test]
612 fn predicates_match_the_documented_truth_table() {
613 for (name, op) in all_ops() {
614 let cs = changeset_for(op);
615 let got = (
616 cs.mutates_text(),
617 cs.changes_selection(),
618 cs.uses_clipboard(),
619 );
620 assert_eq!(
621 got,
622 expected_predicates(name),
623 "{name}: (mutates_text, changes_selection, uses_clipboard) mismatch"
624 );
625 }
626 }
627
628 #[test]
629 fn all_eleven_variants_are_covered_and_none_is_both_text_and_selection() {
630 let ops = all_ops();
631 assert_eq!(
632 ops.len(),
633 11,
634 "all_ops() must cover every TextOperation variant"
635 );
636
637 for (name, op) in ops {
638 let cs = changeset_for(op);
639
640 assert!(
644 !(cs.mutates_text() && cs.changes_selection()),
645 "{name} classifies as both a text mutation and a selection change"
646 );
647
648 assert!(
652 cs.mutates_text() || cs.changes_selection() || cs.uses_clipboard(),
653 "{name} is invisible to all three predicates"
654 );
655 }
656 }
657
658 #[test]
659 fn predicates_are_pure_and_ignore_target_and_timestamp() {
660 for (name, op) in all_ops() {
661 let a = TextChangeset::new(node(0, 0), op.clone(), ts(0));
662 let b = TextChangeset::new(node(usize::MAX, usize::MAX), op, ts(u64::MAX));
663
664 assert_eq!(a.mutates_text(), b.mutates_text(), "{name}: mutates_text");
665 assert_eq!(
666 a.changes_selection(),
667 b.changes_selection(),
668 "{name}: changes_selection"
669 );
670 assert_eq!(
671 a.uses_clipboard(),
672 b.uses_clipboard(),
673 "{name}: uses_clipboard"
674 );
675
676 assert_eq!(a.mutates_text(), a.mutates_text());
678 assert_eq!(a.changes_selection(), a.changes_selection());
679 assert_eq!(a.uses_clipboard(), a.uses_clipboard());
680 }
681 }
682
683 #[test]
688 fn resulting_cursor_position_is_some_exactly_for_cursor_moving_ops() {
689 for (name, op) in all_ops() {
690 let cs = changeset_for(op);
691 let expected = matches!(
692 name,
693 "InsertText" | "DeleteText" | "ReplaceText" | "Cut" | "Paste" | "MoveCursor"
694 );
695 assert_eq!(
696 cs.resulting_cursor_position().is_some(),
697 expected,
698 "{name}: resulting_cursor_position() presence"
699 );
700
701 if cs.mutates_text() {
704 assert!(
705 cs.resulting_cursor_position().is_some(),
706 "{name} mutates text but reports no resulting cursor"
707 );
708 }
709 }
710 }
711
712 #[test]
713 fn resulting_cursor_position_returns_the_new_cursor_not_the_old_one() {
714 let cs = changeset_for(TextOperation::MoveCursor(TextOpMoveCursor {
715 old_position: cur(1.0, 1.0),
716 new_position: cur(9.0, 9.0),
717 movement: CursorMovement::Absolute,
718 }));
719 assert_eq!(cs.resulting_cursor_position(), Some(cur(9.0, 9.0)));
720
721 let cs = changeset_for(TextOperation::Paste(TextOpPaste {
722 content: clip("abc"),
723 position: cur(1.0, 1.0),
724 new_cursor: cur(4.0, 1.0),
725 }));
726 assert_eq!(cs.resulting_cursor_position(), Some(cur(4.0, 1.0)));
727 }
728
729 #[test]
730 fn resulting_cursor_position_preserves_nan_and_infinity_bit_for_bit() {
731 let payloads = [
736 (f32::NAN, f32::NEG_INFINITY),
737 (f32::INFINITY, -0.0),
738 (f32::MAX, f32::MIN),
739 (f32::MIN_POSITIVE, -f32::MIN_POSITIVE),
740 ];
741
742 for (x, y) in payloads {
743 let cs = changeset_for(TextOperation::InsertText(TextOpInsertText {
744 text: AzString::from("t"),
745 position: CursorPosition::Uninitialized,
746 new_cursor: cur(x, y),
747 }));
748
749 match cs.resulting_cursor_position() {
750 Some(CursorPosition::InWindow(p)) => {
751 assert_eq!(p.x.to_bits(), x.to_bits(), "x mangled for ({x}, {y})");
752 assert_eq!(p.y.to_bits(), y.to_bits(), "y mangled for ({x}, {y})");
753 }
754 other => panic!("expected InWindow cursor, got {other:?}"),
755 }
756 }
757 }
758
759 #[test]
760 fn resulting_cursor_position_preserves_the_cursor_variant() {
761 for expected in [
765 CursorPosition::Uninitialized,
766 CursorPosition::OutOfWindow(LogicalPosition::new(-5.0, -5.0)),
767 CursorPosition::InWindow(LogicalPosition::new(0.0, 0.0)),
768 ] {
769 let cs = changeset_for(TextOperation::DeleteText(TextOpDeleteText {
770 range: simple_range(),
771 deleted_text: AzString::from("x"),
772 new_cursor: expected,
773 }));
774 assert_eq!(cs.resulting_cursor_position(), Some(expected));
775 }
776 }
777
778 #[test]
783 fn resulting_selection_range_is_some_exactly_for_range_setting_ops() {
784 for (name, op) in all_ops() {
785 let cs = changeset_for(op);
786 let expected = matches!(name, "SetSelection" | "ExtendSelection" | "SelectAll");
787 assert_eq!(
788 cs.resulting_selection_range().is_some(),
789 expected,
790 "{name}: resulting_selection_range() presence"
791 );
792
793 if cs.resulting_selection_range().is_some() {
798 assert!(
799 cs.changes_selection(),
800 "{name} yields a selection range but denies changing the selection"
801 );
802 }
803 }
804 }
805
806 #[test]
807 fn clear_and_move_change_selection_but_yield_no_range() {
808 let cleared = changeset_for(TextOperation::ClearSelection(TextOpClearSelection {
809 old_range: simple_range(),
810 }));
811 assert!(cleared.changes_selection());
812 assert_eq!(cleared.resulting_selection_range(), None);
813 assert_eq!(cleared.resulting_cursor_position(), None);
814
815 let moved = changeset_for(TextOperation::MoveCursor(TextOpMoveCursor {
816 old_position: cur(0.0, 0.0),
817 new_position: cur(1.0, 0.0),
818 movement: CursorMovement::WordRight,
819 }));
820 assert!(moved.changes_selection());
821 assert_eq!(moved.resulting_selection_range(), None);
822 assert_eq!(moved.resulting_cursor_position(), Some(cur(1.0, 0.0)));
823 }
824
825 #[test]
826 fn resulting_selection_range_does_not_normalize_a_backwards_range() {
827 let backwards = extreme_range();
830 assert!(backwards.end < backwards.start);
831
832 let cs = changeset_for(TextOperation::SetSelection(TextOpSetSelection {
833 old_range: OptionSelectionRange::None,
834 new_range: backwards,
835 }));
836
837 let got = cs
838 .resulting_selection_range()
839 .expect("SetSelection must yield a range");
840 assert_eq!(got, backwards, "endpoints were reordered or clamped");
841 assert_eq!(got.start.cluster_id.source_run, u32::MAX);
842 assert_eq!(got.start.cluster_id.start_byte_in_run, u32::MAX);
843 assert_eq!(got.start.affinity, CursorAffinity::Trailing);
844 assert_eq!(got.end, tc(0, 0, CursorAffinity::Leading));
845 }
846
847 #[test]
848 fn resulting_selection_range_returns_new_range_and_preserves_empty_ranges() {
849 let caret = range(
852 tc(7, 3, CursorAffinity::Leading),
853 tc(7, 3, CursorAffinity::Leading),
854 );
855 let cs = changeset_for(TextOperation::ExtendSelection(TextOpExtendSelection {
856 old_range: extreme_range(),
857 new_range: caret,
858 direction: SelectionDirection::Forward,
859 }));
860 assert_eq!(cs.resulting_selection_range(), Some(caret));
861
862 let cs = changeset_for(TextOperation::SelectAll(TextOpSelectAll {
864 old_range: OptionSelectionRange::Some(simple_range()),
865 new_range: caret,
866 }));
867 assert_eq!(cs.resulting_selection_range(), Some(caret));
868 }
869
870 #[test]
875 fn text_payloads_round_trip_through_the_changeset_unchanged() {
876 let cases = [
877 "", "\0", "a\u{0301}", "\u{1F1E9}\u{1F1EA}", "\u{202E}txet desrever\u{202C}", "\u{10FFFF}", "line1\r\nline2\u{2028}line3", ];
885
886 for s in cases {
887 let cs = changeset_for(TextOperation::ReplaceText(TextOpReplaceText {
888 range: simple_range(),
889 old_text: AzString::from(s),
890 new_text: AzString::from(s),
891 new_cursor: CursorPosition::Uninitialized,
892 }));
893
894 match &cs.operation {
895 TextOperation::ReplaceText(op) => {
896 assert_eq!(op.old_text.as_str(), s, "old_text mangled for {s:?}");
897 assert_eq!(op.new_text.as_str(), s, "new_text mangled for {s:?}");
898 assert_eq!(op.old_text.as_str().len(), s.len(), "byte length changed");
899 }
900 other => panic!("unexpected variant: {other:?}"),
901 }
902 }
903 }
904
905 #[test]
906 fn clipboard_payloads_survive_and_stay_classified_as_clipboard_ops() {
907 let big = "\u{00E9}".repeat(128 * 1024); let cs = changeset_for(TextOperation::Cut(TextOpCut {
910 range: extreme_range(),
911 content: clip(&big),
912 new_cursor: cur(0.0, 0.0),
913 }));
914
915 assert!(cs.uses_clipboard());
916 assert!(
917 cs.mutates_text(),
918 "Cut deletes text, so it must count as a mutation"
919 );
920 assert!(!cs.changes_selection());
921 match &cs.operation {
922 TextOperation::Cut(op) => {
923 assert_eq!(op.content.plain_text.as_str().len(), 256 * 1024);
924 assert!(op.content.styled_runs.as_slice().is_empty());
925 assert_eq!(op.content.to_html(), "<div></div>");
927 }
928 other => panic!("unexpected variant: {other:?}"),
929 }
930
931 let empty = changeset_for(TextOperation::Copy(TextOpCopy {
933 range: simple_range(),
934 content: clip(""),
935 }));
936 assert!(empty.uses_clipboard());
937 assert!(!empty.mutates_text());
938 assert_eq!(empty.resulting_cursor_position(), None);
939 assert_eq!(empty.resulting_selection_range(), None);
940 }
941
942 #[test]
943 fn timestamps_round_trip_and_stay_ordered() {
944 let zero = changeset_for_ts(ts(0));
945 let max = changeset_for_ts(ts(u64::MAX));
946
947 assert_eq!(zero.timestamp, ts(0));
948 assert_eq!(max.timestamp, ts(u64::MAX));
949 assert!(
950 zero.timestamp < max.timestamp,
951 "tick ordering must survive being stored in a changeset"
952 );
953 }
954
955 fn changeset_for_ts(timestamp: Instant) -> TextChangeset {
956 TextChangeset::new(
957 node(0, 1),
958 TextOperation::ClearSelection(TextOpClearSelection {
959 old_range: simple_range(),
960 }),
961 timestamp,
962 )
963 }
964}