Skip to main content

ferroui_base/input/text_input/
text_input_method_edit.rs

1use super::{TextInputMethodError, TextSelection};
2
3/// An exact platform edit in the current visible text, including an IME preview.
4/// All positions count UTF-16 code units. `range` is ordered; `selection` is
5/// directed and refers to the resulting text. Empty text is a real deletion.
6///
7/// This is the portable contract of CoreText's Range/Text/NewSelection request.
8/// A client must reject unsupported filtering, normalization or length changes
9/// before mutation; it must never acknowledge a different replacement as exact.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct TextInputMethodEdit {
12    pub range: TextSelection,
13    pub text: String,
14    pub selection: TextSelection,
15}
16
17impl TextInputMethodEdit {
18    pub fn new(range: TextSelection, text: impl Into<String>, selection: TextSelection) -> Self {
19        Self {
20            range,
21            text: text.into(),
22            selection,
23        }
24    }
25
26    /// Validates both ranges without clamping or splitting a surrogate pair and
27    /// returns the complete candidate text. No client state is changed.
28    pub fn apply_to(&self, current: &str) -> Result<String, TextInputMethodError> {
29        if self.range.start > self.range.end {
30            return Err(TextInputMethodError::InvalidRange);
31        }
32        let start = byte_offset(current, self.range.start)?;
33        let end = byte_offset(current, self.range.end)?;
34        let capacity = current
35            .len()
36            .checked_sub(end - start)
37            .and_then(|length| length.checked_add(self.text.len()))
38            .ok_or(TextInputMethodError::InvalidRange)?;
39        let mut result = String::with_capacity(capacity);
40        result.push_str(&current[..start]);
41        result.push_str(&self.text);
42        result.push_str(&current[end..]);
43        if result.encode_utf16().count() > i32::MAX as usize {
44            return Err(TextInputMethodError::InvalidRange);
45        }
46        validate_selection(&result, self.selection)?;
47        Ok(result)
48    }
49}
50
51/// A coherent visible state exposed by an exact-edit client. Unlike legacy
52/// surrounding-text APIs, this includes the current accepted composition.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct TextInputMethodState {
55    pub text: String,
56    pub selection: TextSelection,
57}
58
59pub(crate) fn validate_selection(
60    text: &str,
61    selection: TextSelection,
62) -> Result<(), TextInputMethodError> {
63    byte_offset(text, selection.start)?;
64    byte_offset(text, selection.end)?;
65    Ok(())
66}
67
68fn byte_offset(text: &str, position: i32) -> Result<usize, TextInputMethodError> {
69    let position = usize::try_from(position).map_err(|_| TextInputMethodError::InvalidRange)?;
70    let mut units = 0;
71    for (byte, character) in text.char_indices() {
72        if units == position {
73            return Ok(byte);
74        }
75        units += character.len_utf16();
76        if units > position {
77            return Err(TextInputMethodError::InvalidRange);
78        }
79    }
80    (units == position)
81        .then_some(text.len())
82        .ok_or(TextInputMethodError::InvalidRange)
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn exact_edit_replaces_utf16_range_and_keeps_directed_result_selection() {
91        let edit =
92            TextInputMethodEdit::new(TextSelection::new(1, 3), "xy", TextSelection::new(3, 1));
93        assert_eq!(edit.apply_to("a😀b").unwrap(), "axyb");
94    }
95
96    #[test]
97    fn exact_edit_deletion_is_not_an_empty_input_no_op() {
98        let edit = TextInputMethodEdit::new(TextSelection::new(0, 4), "", TextSelection::new(0, 0));
99        assert_eq!(edit.apply_to("a😀b").unwrap(), "");
100    }
101
102    #[test]
103    fn exact_edit_rejects_surrogate_interiors_and_out_of_bounds_without_clamping() {
104        for range in [
105            TextSelection::new(-1, 0),
106            TextSelection::new(2, 3),
107            TextSelection::new(3, 2),
108            TextSelection::new(0, 5),
109        ] {
110            let edit = TextInputMethodEdit::new(range, "x", TextSelection::new(0, 0));
111            assert_eq!(
112                edit.apply_to("a😀b"),
113                Err(TextInputMethodError::InvalidRange)
114            );
115        }
116        let edit =
117            TextInputMethodEdit::new(TextSelection::new(0, 0), "😀", TextSelection::new(1, 1));
118        assert_eq!(edit.apply_to(""), Err(TextInputMethodError::InvalidRange));
119    }
120}