1use fission_ir::semantics::{InputFormatter, MaxLengthEnforcement};
8use serde::{Deserialize, Serialize};
9use std::{error::Error, fmt, sync::Arc};
10use unicode_segmentation::UnicodeSegmentation;
11
12#[derive(
14 Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
15)]
16#[serde(transparent)]
17pub struct TextPosition(usize);
18
19impl TextPosition {
20 pub const START: Self = Self(0);
21
22 pub fn from_utf8(text: &str, offset: usize) -> Result<Self, TextOffsetError> {
24 if offset > text.len() {
25 return Err(TextOffsetError::out_of_bounds(
26 offset,
27 text.len(),
28 OffsetUnit::Utf8,
29 ));
30 }
31 if !text.is_char_boundary(offset) {
32 return Err(TextOffsetError::not_boundary(
33 offset,
34 text.len(),
35 OffsetUnit::Utf8,
36 ));
37 }
38 Ok(Self(offset))
39 }
40
41 pub fn from_utf16(text: &str, offset: usize) -> Result<Self, TextOffsetError> {
43 let utf16_len = text.encode_utf16().count();
44 if offset > utf16_len {
45 return Err(TextOffsetError::out_of_bounds(
46 offset,
47 utf16_len,
48 OffsetUnit::Utf16,
49 ));
50 }
51 if offset == utf16_len {
52 return Ok(Self(text.len()));
53 }
54 let mut utf16 = 0;
55 for (byte, ch) in text.char_indices() {
56 if utf16 == offset {
57 return Ok(Self(byte));
58 }
59 utf16 += ch.len_utf16();
60 if utf16 > offset {
61 return Err(TextOffsetError::not_boundary(
62 offset,
63 utf16_len,
64 OffsetUnit::Utf16,
65 ));
66 }
67 }
68 Ok(Self(text.len()))
69 }
70
71 pub fn from_scalar_offset(text: &str, offset: usize) -> Result<Self, TextOffsetError> {
73 let scalar_len = text.chars().count();
74 if offset > scalar_len {
75 return Err(TextOffsetError::out_of_bounds(
76 offset,
77 scalar_len,
78 OffsetUnit::Scalar,
79 ));
80 }
81 Ok(Self(
82 text.char_indices()
83 .nth(offset)
84 .map(|(byte, _)| byte)
85 .unwrap_or(text.len()),
86 ))
87 }
88
89 pub fn at_end(text: &str) -> Self {
90 Self(text.len())
91 }
92
93 pub const fn utf8_offset(self) -> usize {
94 self.0
95 }
96
97 pub fn utf16_offset(self, text: &str) -> Result<usize, TextOffsetError> {
98 Self::from_utf8(text, self.0)?;
99 Ok(text[..self.0].encode_utf16().count())
100 }
101
102 pub fn scalar_offset(self, text: &str) -> Result<usize, TextOffsetError> {
103 Self::from_utf8(text, self.0)?;
104 Ok(text[..self.0].chars().count())
105 }
106
107 pub fn is_grapheme_boundary(self, text: &str) -> bool {
108 Self::from_utf8(text, self.0).is_ok()
109 && (self.0 == text.len() || text.grapheme_indices(true).any(|(i, _)| i == self.0))
110 }
111
112 pub fn floor(text: &str, offset: usize) -> Self {
113 let mut offset = offset.min(text.len());
114 while offset > 0 && !text.is_char_boundary(offset) {
115 offset -= 1;
116 }
117 Self(offset)
118 }
119}
120
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122enum OffsetUnit {
123 Utf8,
124 Utf16,
125 Scalar,
126}
127
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub struct TextOffsetError {
130 offset: usize,
131 length: usize,
132 unit: OffsetUnit,
133 boundary: bool,
134}
135
136impl TextOffsetError {
137 fn out_of_bounds(offset: usize, length: usize, unit: OffsetUnit) -> Self {
138 Self {
139 offset,
140 length,
141 unit,
142 boundary: false,
143 }
144 }
145
146 fn not_boundary(offset: usize, length: usize, unit: OffsetUnit) -> Self {
147 Self {
148 offset,
149 length,
150 unit,
151 boundary: true,
152 }
153 }
154}
155
156impl fmt::Display for TextOffsetError {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 let unit = match self.unit {
159 OffsetUnit::Utf8 => "UTF-8 bytes",
160 OffsetUnit::Utf16 => "UTF-16 code units",
161 OffsetUnit::Scalar => "Unicode scalar values",
162 };
163 if self.boundary {
164 write!(
165 f,
166 "text offset {} is not a character boundary in {} (length {})",
167 self.offset, unit, self.length
168 )
169 } else {
170 write!(
171 f,
172 "text offset {} is outside {} length {}",
173 self.offset, unit, self.length
174 )
175 }
176 }
177}
178
179impl Error for TextOffsetError {}
180
181#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
183pub enum TextAffinity {
184 Upstream,
185 #[default]
186 Downstream,
187}
188
189#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
191pub struct TextRange {
192 pub start: TextPosition,
193 pub end: TextPosition,
194}
195
196impl TextRange {
197 pub fn new(text: &str, start: usize, end: usize) -> Result<Self, TextOffsetError> {
198 let start = TextPosition::from_utf8(text, start)?;
199 let end = TextPosition::from_utf8(text, end)?;
200 Ok(Self::from_positions(start, end))
201 }
202
203 pub const fn collapsed(at: TextPosition) -> Self {
204 Self { start: at, end: at }
205 }
206
207 pub fn from_positions(a: TextPosition, b: TextPosition) -> Self {
208 Self {
209 start: a.min(b),
210 end: a.max(b),
211 }
212 }
213
214 pub fn validate(self, text: &str) -> Result<Self, TextOffsetError> {
215 Self::new(text, self.start.0, self.end.0)
216 }
217
218 pub const fn is_collapsed(self) -> bool {
219 self.start.0 == self.end.0
220 }
221 pub const fn len_bytes(self) -> usize {
222 self.end.0 - self.start.0
223 }
224}
225
226#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
228pub struct TextSelection {
229 pub base: TextPosition,
230 pub extent: TextPosition,
231 pub affinity: TextAffinity,
232}
233
234impl TextSelection {
235 pub const fn collapsed(at: TextPosition) -> Self {
236 Self {
237 base: at,
238 extent: at,
239 affinity: TextAffinity::Downstream,
240 }
241 }
242
243 pub fn new(
244 text: &str,
245 base: usize,
246 extent: usize,
247 affinity: TextAffinity,
248 ) -> Result<Self, TextOffsetError> {
249 Ok(Self {
250 base: TextPosition::from_utf8(text, base)?,
251 extent: TextPosition::from_utf8(text, extent)?,
252 affinity,
253 })
254 }
255
256 pub fn validate(self, text: &str) -> Result<Self, TextOffsetError> {
257 Self::new(text, self.base.0, self.extent.0, self.affinity)
258 }
259
260 pub fn range(self) -> TextRange {
261 TextRange::from_positions(self.base, self.extent)
262 }
263 pub const fn is_collapsed(self) -> bool {
264 self.base.0 == self.extent.0
265 }
266}
267
268#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
270pub struct TextEditingValue {
271 pub text: String,
272 pub selection: TextSelection,
273 pub composing: Option<TextRange>,
274}
275
276impl Default for TextEditingValue {
277 fn default() -> Self {
278 Self::empty()
279 }
280}
281
282impl TextEditingValue {
283 pub fn empty() -> Self {
284 Self {
285 text: String::new(),
286 selection: TextSelection::collapsed(TextPosition::START),
287 composing: None,
288 }
289 }
290
291 pub fn from_text(text: impl Into<String>) -> Self {
292 let text = text.into();
293 let end = TextPosition::at_end(&text);
294 Self {
295 text,
296 selection: TextSelection::collapsed(end),
297 composing: None,
298 }
299 }
300
301 pub fn new(
302 text: impl Into<String>,
303 selection: TextSelection,
304 composing: Option<TextRange>,
305 ) -> Result<Self, TextOffsetError> {
306 let text = text.into();
307 let selection = selection.validate(&text)?;
308 let composing = composing.map(|range| range.validate(&text)).transpose()?;
309 Ok(Self {
310 text,
311 selection,
312 composing,
313 })
314 }
315
316 pub fn validate(&self) -> Result<(), TextOffsetError> {
317 self.selection.validate(&self.text)?;
318 if let Some(range) = self.composing {
319 range.validate(&self.text)?;
320 }
321 Ok(())
322 }
323
324 pub fn selection_range(&self) -> TextRange {
325 self.selection.range()
326 }
327}
328
329#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
330pub enum TextEditSource {
331 #[default]
332 Programmatic,
333 Keyboard,
334 Pointer,
335 Ime,
336 Clipboard,
337 Accessibility,
338 Autocorrect,
339 Autofill,
340 Handwriting,
341 Dictation,
342}
343
344#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
345pub enum TextEditPhase {
346 #[default]
347 Committed,
348 Selection,
349 CompositionStarted,
350 CompositionUpdated,
351 CompositionCancelled,
352 CompositionCommitted,
353 Submitted,
354 EditingCompleted,
355 Focused,
356 Blurred,
357 Validated,
358 TapOutside,
359}
360
361#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
363pub enum TextEditCommand {
364 Replace {
365 range: TextRange,
366 text: String,
367 source: TextEditSource,
368 },
369 SetSelection {
370 selection: TextSelection,
371 source: TextEditSource,
372 },
373 SetComposing {
374 range: TextRange,
375 },
376 CancelComposition,
377 CommitComposition {
378 text: String,
379 source: TextEditSource,
380 },
381 SetValue {
382 value: TextEditingValue,
383 source: TextEditSource,
384 phase: TextValuePhase,
385 },
386 Delete {
387 direction: TextEditDirection,
388 boundary: TextEditBoundary,
389 source: TextEditSource,
390 },
391 MoveSelection {
392 direction: TextEditDirection,
393 boundary: TextEditBoundary,
394 extend: bool,
395 source: TextEditSource,
396 },
397 Submit,
398 Complete,
399}
400
401#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
402pub enum TextEditDirection {
403 Backward,
404 Forward,
405}
406
407#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
408pub enum TextEditBoundary {
409 Grapheme,
410 Word,
411 Line,
412 Paragraph,
413 Document,
414}
415
416#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
418pub enum TextValuePhase {
419 #[default]
420 Committed,
421 CompositionStarted,
422 CompositionUpdated,
423 CompositionCommitted,
424}
425
426impl From<TextValuePhase> for TextEditPhase {
427 fn from(value: TextValuePhase) -> Self {
428 match value {
429 TextValuePhase::Committed => Self::Committed,
430 TextValuePhase::CompositionStarted => Self::CompositionStarted,
431 TextValuePhase::CompositionUpdated => Self::CompositionUpdated,
432 TextValuePhase::CompositionCommitted => Self::CompositionCommitted,
433 }
434 }
435}
436
437impl TextEditCommand {
438 pub fn source(&self) -> TextEditSource {
439 match self {
440 Self::Replace { source, .. }
441 | Self::CommitComposition { source, .. }
442 | Self::SetValue { source, .. } => *source,
443 Self::Delete { source, .. } | Self::MoveSelection { source, .. } => *source,
444 Self::SetSelection { source, .. } => *source,
445 Self::SetComposing { .. } | Self::CancelComposition => TextEditSource::Ime,
446 Self::Submit | Self::Complete => TextEditSource::Keyboard,
447 }
448 }
449}
450
451#[derive(Clone, Debug, PartialEq, Eq)]
452pub struct TextEditResult {
453 pub old_value: TextEditingValue,
454 pub new_value: TextEditingValue,
455 pub source: TextEditSource,
456 pub phase: TextEditPhase,
457}
458
459pub trait CompleteTextInputFormatter: Send + Sync + fmt::Debug {
462 fn format(
463 &self,
464 old_value: &TextEditingValue,
465 proposed_value: TextEditingValue,
466 ) -> TextEditingValue;
467}
468
469pub type SharedTextInputFormatter = Arc<dyn CompleteTextInputFormatter>;
470
471#[derive(Clone, Default)]
472pub struct TextEditPipeline {
473 pub formatters: Vec<InputFormatter>,
474 pub custom_formatters: Vec<SharedTextInputFormatter>,
475 pub max_length: Option<usize>,
476 pub max_length_enforcement: MaxLengthEnforcement,
477}
478
479impl TextEditPipeline {
480 pub fn apply(
481 &self,
482 old: &TextEditingValue,
483 command: TextEditCommand,
484 ) -> Result<TextEditResult, TextOffsetError> {
485 old.validate()?;
486 let source = command.source();
487 let (mut proposed, phase) = apply_command(old, command)?;
488 if matches!(
489 phase,
490 TextEditPhase::Committed | TextEditPhase::CompositionCommitted
491 ) {
492 for formatter in &self.formatters {
493 proposed = apply_builtin_formatter(formatter, proposed);
494 }
495 for formatter in &self.custom_formatters {
496 proposed = formatter.format(old, proposed);
497 proposed.validate()?;
498 }
499 }
500 let mutates_text = matches!(
501 phase,
502 TextEditPhase::Committed
503 | TextEditPhase::CompositionStarted
504 | TextEditPhase::CompositionUpdated
505 | TextEditPhase::CompositionCommitted
506 );
507 let enforce_length = mutates_text
508 && match self.max_length_enforcement {
509 MaxLengthEnforcement::None => false,
510 MaxLengthEnforcement::Enforced => true,
511 MaxLengthEnforcement::AfterComposition => !matches!(
512 phase,
513 TextEditPhase::CompositionStarted | TextEditPhase::CompositionUpdated
514 ),
515 };
516 if enforce_length {
517 if let Some(max) = self.max_length {
518 proposed = enforce_grapheme_limit(proposed, max);
519 }
520 }
521 proposed.validate()?;
522 Ok(TextEditResult {
523 old_value: old.clone(),
524 new_value: proposed,
525 source,
526 phase,
527 })
528 }
529}
530
531fn apply_command(
532 old: &TextEditingValue,
533 command: TextEditCommand,
534) -> Result<(TextEditingValue, TextEditPhase), TextOffsetError> {
535 match command {
536 TextEditCommand::Replace { range, text, .. } => {
537 let range = range.validate(&old.text)?;
538 let start = range.start.0;
539 let mut next = old.text.clone();
540 next.replace_range(start..range.end.0, &text);
541 let caret = TextPosition::from_utf8(&next, start + text.len())?;
542 Ok((
543 TextEditingValue {
544 text: next,
545 selection: TextSelection::collapsed(caret),
546 composing: None,
547 },
548 TextEditPhase::Committed,
549 ))
550 }
551 TextEditCommand::SetSelection { selection, .. } => {
552 let selection = selection.validate(&old.text)?;
553 Ok((
554 TextEditingValue {
555 selection,
556 ..old.clone()
557 },
558 TextEditPhase::Selection,
559 ))
560 }
561 TextEditCommand::SetComposing { range } => {
562 let range = range.validate(&old.text)?;
563 let phase = if old.composing.is_some() {
564 TextEditPhase::CompositionUpdated
565 } else {
566 TextEditPhase::CompositionStarted
567 };
568 Ok((
569 TextEditingValue {
570 composing: Some(range),
571 ..old.clone()
572 },
573 phase,
574 ))
575 }
576 TextEditCommand::CancelComposition => Ok((
577 TextEditingValue {
578 composing: None,
579 ..old.clone()
580 },
581 TextEditPhase::CompositionCancelled,
582 )),
583 TextEditCommand::CommitComposition { text, .. } => {
584 let range = old.composing.unwrap_or_else(|| old.selection_range());
585 let (mut value, _) = apply_command(
586 old,
587 TextEditCommand::Replace {
588 range,
589 text,
590 source: TextEditSource::Ime,
591 },
592 )?;
593 value.composing = None;
594 Ok((value, TextEditPhase::CompositionCommitted))
595 }
596 TextEditCommand::SetValue { value, phase, .. } => {
597 value.validate()?;
598 Ok((value, phase.into()))
599 }
600 TextEditCommand::Delete {
601 direction,
602 boundary,
603 source,
604 } => {
605 let selected = old.selection_range();
606 let range = if !selected.is_collapsed() {
607 selected
608 } else {
609 deletion_range(old, direction, boundary)
610 };
611 apply_command(
612 old,
613 TextEditCommand::Replace {
614 range,
615 text: String::new(),
616 source,
617 },
618 )
619 }
620 TextEditCommand::MoveSelection {
621 direction,
622 boundary,
623 extend,
624 ..
625 } => {
626 let target = movement_position(old, direction, boundary);
627 let selection = if extend {
628 TextSelection {
629 base: old.selection.base,
630 extent: target,
631 affinity: old.selection.affinity,
632 }
633 } else {
634 TextSelection::collapsed(target)
635 };
636 Ok((
637 TextEditingValue {
638 selection,
639 ..old.clone()
640 },
641 TextEditPhase::Selection,
642 ))
643 }
644 TextEditCommand::Submit => Ok((old.clone(), TextEditPhase::Submitted)),
645 TextEditCommand::Complete => Ok((old.clone(), TextEditPhase::EditingCompleted)),
646 }
647}
648
649fn deletion_range(
650 value: &TextEditingValue,
651 direction: TextEditDirection,
652 boundary: TextEditBoundary,
653) -> TextRange {
654 let caret = value.selection.extent.0;
655 let target = boundary_position(&value.text, caret, direction, boundary);
656 TextRange::from_positions(TextPosition(caret), TextPosition(target))
657}
658
659fn movement_position(
660 value: &TextEditingValue,
661 direction: TextEditDirection,
662 boundary: TextEditBoundary,
663) -> TextPosition {
664 TextPosition(boundary_position(
665 &value.text,
666 value.selection.extent.0,
667 direction,
668 boundary,
669 ))
670}
671
672fn boundary_position(
673 text: &str,
674 caret: usize,
675 direction: TextEditDirection,
676 boundary: TextEditBoundary,
677) -> usize {
678 match (direction, boundary) {
679 (TextEditDirection::Backward, TextEditBoundary::Grapheme) => text[..caret]
680 .grapheme_indices(true)
681 .next_back()
682 .map(|(i, _)| i)
683 .unwrap_or(0),
684 (TextEditDirection::Forward, TextEditBoundary::Grapheme) => text[caret..]
685 .grapheme_indices(true)
686 .nth(1)
687 .map(|(i, _)| caret + i)
688 .unwrap_or(text.len()),
689 (TextEditDirection::Backward, TextEditBoundary::Word) => text[..caret]
690 .split_word_bound_indices()
691 .filter(|(_, part)| part.chars().any(char::is_alphanumeric))
692 .next_back()
693 .map(|(i, _)| i)
694 .unwrap_or(0),
695 (TextEditDirection::Forward, TextEditBoundary::Word) => text[caret..]
696 .split_word_bound_indices()
697 .find(|(_, part)| part.chars().any(char::is_alphanumeric))
698 .map(|(i, part)| caret + i + part.len())
699 .unwrap_or(text.len()),
700 (TextEditDirection::Backward, TextEditBoundary::Line | TextEditBoundary::Paragraph) => {
701 text[..caret].rfind('\n').map(|i| i + 1).unwrap_or(0)
702 }
703 (TextEditDirection::Forward, TextEditBoundary::Line | TextEditBoundary::Paragraph) => text
704 [caret..]
705 .find('\n')
706 .map(|i| caret + i)
707 .unwrap_or(text.len()),
708 (TextEditDirection::Backward, TextEditBoundary::Document) => 0,
709 (TextEditDirection::Forward, TextEditBoundary::Document) => text.len(),
710 }
711}
712
713fn apply_builtin_formatter(
714 formatter: &InputFormatter,
715 value: TextEditingValue,
716) -> TextEditingValue {
717 let transform = |input: &str| -> String {
718 match formatter {
719 InputFormatter::DigitsOnly => input.chars().filter(|ch| ch.is_ascii_digit()).collect(),
720 InputFormatter::AsciiOnly => input.chars().filter(|ch| ch.is_ascii()).collect(),
721 InputFormatter::InternalLowercase => input.to_lowercase(),
722 InputFormatter::Uppercase => input.to_uppercase(),
723 InputFormatter::TrimWhitespace => input.trim().to_string(),
724 InputFormatter::SingleLine => input.replace(['\r', '\n'], ""),
725 }
726 };
727 let map = |position: TextPosition| TextPosition(transform(&value.text[..position.0]).len());
728 let selection = TextSelection {
729 base: map(value.selection.base),
730 extent: map(value.selection.extent),
731 affinity: value.selection.affinity,
732 };
733 let composing = value
734 .composing
735 .map(|range| TextRange::from_positions(map(range.start), map(range.end)));
736 TextEditingValue {
737 text: transform(&value.text),
738 selection,
739 composing,
740 }
741}
742
743fn enforce_grapheme_limit(mut value: TextEditingValue, max: usize) -> TextEditingValue {
744 let end = value
745 .text
746 .grapheme_indices(true)
747 .nth(max)
748 .map(|(offset, _)| offset)
749 .unwrap_or(value.text.len());
750 if end == value.text.len() {
751 return value;
752 }
753 value.text.truncate(end);
754 let clamp = |position: TextPosition| TextPosition(position.0.min(end));
755 value.selection.base = clamp(value.selection.base);
756 value.selection.extent = clamp(value.selection.extent);
757 value.composing = value
758 .composing
759 .map(|range| TextRange::from_positions(clamp(range.start), clamp(range.end)));
760 value
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 #[test]
768 fn utf16_conversion_rejects_surrogate_interior_and_round_trips() {
769 let text = "aπe\u{301}";
770 assert!(TextPosition::from_utf16(text, 2).is_err());
771 for offset in [0, 1, 5, 6, text.len()] {
772 let position = TextPosition::from_utf8(text, offset).unwrap();
773 assert_eq!(
774 TextPosition::from_utf16(text, position.utf16_offset(text).unwrap()).unwrap(),
775 position
776 );
777 }
778 }
779
780 #[test]
781 fn scalar_conversion_handles_non_bmp_and_combining_scalars() {
782 let text = "aπe\u{301}";
783 let offsets = [0, 1, 5, 6, text.len()];
784 for (scalar, byte) in offsets.into_iter().enumerate() {
785 let position = TextPosition::from_scalar_offset(text, scalar).unwrap();
786 assert_eq!(position.utf8_offset(), byte);
787 assert_eq!(position.scalar_offset(text).unwrap(), scalar);
788 }
789 assert!(TextPosition::from_scalar_offset(text, 5).is_err());
790 }
791
792 #[test]
793 fn directional_selection_has_ordered_range() {
794 let selection = TextSelection::new("hello", 5, 1, TextAffinity::Upstream).unwrap();
795 assert_eq!(selection.base.utf8_offset(), 5);
796 assert_eq!(selection.extent.utf8_offset(), 1);
797 assert_eq!(selection.range(), TextRange::new("hello", 1, 5).unwrap());
798 }
799
800 #[test]
801 fn replacement_is_atomic_and_preserves_source() {
802 let old = TextEditingValue::from_text("hello world");
803 let result = TextEditPipeline::default()
804 .apply(
805 &old,
806 TextEditCommand::Replace {
807 range: TextRange::new(&old.text, 6, 11).unwrap(),
808 text: "Fission".into(),
809 source: TextEditSource::Accessibility,
810 },
811 )
812 .unwrap();
813 assert_eq!(result.old_value, old);
814 assert_eq!(result.new_value.text, "hello Fission");
815 assert_eq!(result.new_value.selection.extent.utf8_offset(), 13);
816 assert_eq!(result.source, TextEditSource::Accessibility);
817 }
818
819 #[test]
820 fn max_length_counts_user_perceived_graphemes() {
821 let old = TextEditingValue::empty();
822 let pipeline = TextEditPipeline {
823 max_length: Some(2),
824 max_length_enforcement: MaxLengthEnforcement::Enforced,
825 ..Default::default()
826 };
827 let result = pipeline
828 .apply(
829 &old,
830 TextEditCommand::Replace {
831 range: TextRange::collapsed(TextPosition::START),
832 text: "e\u{301}π¨βπ©βπ§βπ¦x".into(),
833 source: TextEditSource::Keyboard,
834 },
835 )
836 .unwrap();
837 assert_eq!(result.new_value.text, "e\u{301}π¨βπ©βπ§βπ¦");
838 assert_eq!(
839 result.new_value.selection.extent.utf8_offset(),
840 result.new_value.text.len()
841 );
842 }
843
844 #[test]
845 fn formatters_transform_complete_value_and_selection() {
846 let old = TextEditingValue::empty();
847 let pipeline = TextEditPipeline {
848 formatters: vec![InputFormatter::DigitsOnly],
849 ..Default::default()
850 };
851 let result = pipeline
852 .apply(
853 &old,
854 TextEditCommand::Replace {
855 range: TextRange::collapsed(TextPosition::START),
856 text: "a1b2".into(),
857 source: TextEditSource::Keyboard,
858 },
859 )
860 .unwrap();
861 assert_eq!(result.new_value.text, "12");
862 assert_eq!(result.new_value.selection.extent.utf8_offset(), 2);
863 }
864
865 #[derive(Debug)]
866 struct PrefixFormatter;
867 impl CompleteTextInputFormatter for PrefixFormatter {
868 fn format(
869 &self,
870 _old: &TextEditingValue,
871 mut proposed: TextEditingValue,
872 ) -> TextEditingValue {
873 proposed.text.insert(0, '#');
874 let end = TextPosition::at_end(&proposed.text);
875 proposed.selection = TextSelection::collapsed(end);
876 proposed
877 }
878 }
879
880 #[test]
881 fn application_formatter_receives_complete_value() {
882 let pipeline = TextEditPipeline {
883 custom_formatters: vec![Arc::new(PrefixFormatter)],
884 ..Default::default()
885 };
886 let result = pipeline
887 .apply(
888 &TextEditingValue::empty(),
889 TextEditCommand::Replace {
890 range: TextRange::collapsed(TextPosition::START),
891 text: "tag".into(),
892 source: TextEditSource::Keyboard,
893 },
894 )
895 .unwrap();
896 assert_eq!(result.new_value.text, "#tag");
897 assert_eq!(result.new_value.selection.extent.utf8_offset(), 4);
898 }
899
900 #[test]
901 fn active_composition_is_not_truncated_until_commit() {
902 let old = TextEditingValue::new(
903 "abcd",
904 TextSelection::collapsed(TextPosition::at_end("abcd")),
905 Some(TextRange::new("abcd", 2, 4).unwrap()),
906 )
907 .unwrap();
908 let pipeline = TextEditPipeline {
909 max_length: Some(2),
910 max_length_enforcement: MaxLengthEnforcement::AfterComposition,
911 ..Default::default()
912 };
913 let result = pipeline
914 .apply(
915 &old,
916 TextEditCommand::CommitComposition {
917 text: "π".into(),
918 source: TextEditSource::Ime,
919 },
920 )
921 .unwrap();
922 assert_eq!(result.new_value.text, "ab");
923 assert!(result.new_value.composing.is_none());
924 }
925
926 #[test]
927 fn complete_platform_values_report_composition_lifecycle() {
928 let pipeline = TextEditPipeline::default();
929 let composing = TextEditingValue::new(
930 "δΈ",
931 TextSelection::collapsed(TextPosition::at_end("δΈ")),
932 Some(TextRange::new("δΈ", 0, "δΈ".len()).unwrap()),
933 )
934 .unwrap();
935 let started = pipeline
936 .apply(
937 &TextEditingValue::empty(),
938 TextEditCommand::SetValue {
939 value: composing.clone(),
940 source: TextEditSource::Ime,
941 phase: TextValuePhase::CompositionStarted,
942 },
943 )
944 .unwrap();
945 assert_eq!(started.phase, TextEditPhase::CompositionStarted);
946
947 let committed = pipeline
948 .apply(
949 &composing,
950 TextEditCommand::SetValue {
951 value: TextEditingValue::from_text("δΈ"),
952 source: TextEditSource::Ime,
953 phase: TextValuePhase::CompositionCommitted,
954 },
955 )
956 .unwrap();
957 assert_eq!(committed.phase, TextEditPhase::CompositionCommitted);
958 }
959
960 #[test]
961 fn delete_and_move_commands_share_unicode_boundaries() {
962 let value = TextEditingValue::from_text("aπ¨βπ©βπ§βπ¦ cafΓ©");
963 let pipeline = TextEditPipeline::default();
964 let deleted = pipeline
965 .apply(
966 &value,
967 TextEditCommand::Delete {
968 direction: TextEditDirection::Backward,
969 boundary: TextEditBoundary::Word,
970 source: TextEditSource::Keyboard,
971 },
972 )
973 .unwrap();
974 assert_eq!(deleted.new_value.text, "aπ¨βπ©βπ§βπ¦ ");
975 let moved = pipeline
976 .apply(
977 &TextEditingValue::from_text("aπ¨βπ©βπ§βπ¦"),
978 TextEditCommand::MoveSelection {
979 direction: TextEditDirection::Backward,
980 boundary: TextEditBoundary::Grapheme,
981 extend: false,
982 source: TextEditSource::Keyboard,
983 },
984 )
985 .unwrap();
986 assert_eq!(moved.new_value.selection.extent.utf8_offset(), 1);
987 }
988}