Skip to main content

fission_core/
text_editing.rs

1//! Canonical, platform-neutral text editing values and edit transactions.
2//!
3//! All offsets in this module are validated UTF-8 byte offsets. Platform
4//! adapters must use the named UTF-16 conversion functions rather than mixing
5//! offset units at their boundaries.
6
7use fission_ir::semantics::{InputFormatter, MaxLengthEnforcement};
8use serde::{Deserialize, Serialize};
9use std::{error::Error, fmt, sync::Arc};
10use unicode_segmentation::UnicodeSegmentation;
11
12/// A validated UTF-8 byte position in a particular text value.
13#[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    /// Validates a canonical UTF-8 byte offset for `text`.
23    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    /// Converts and validates a platform UTF-16 code-unit offset.
42    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    /// Converts a Unicode scalar-value index used by accessibility APIs.
72    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/// Direction used to resolve an ambiguous position at a soft line break.
182#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
183pub enum TextAffinity {
184    Upstream,
185    #[default]
186    Downstream,
187}
188
189/// A validated half-open text range.
190#[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/// A directional selection. `base` is the fixed anchor and `extent` is the caret.
227#[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/// The sole value required to synchronize an editable text session.
269#[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/// A complete proposed edit. Every mutation source is normalized to this model.
362#[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/// Lifecycle meaning of a complete value supplied by a platform adapter.
417#[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
459/// Application formatter extension point. Formatters receive complete values,
460/// including selection and composing state, and must return another valid value.
461pub 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}