Skip to main content

inset_embedder/
text_editing.rs

1//! Flutter counterpart: `services/text_editing.dart`.
2//!
3//! Lives here because [`crate::View`] names [`crate::TextEditingValue`], which holds this type.
4
5use std::fmt;
6use std::hash::{Hash, Hasher};
7
8use crate::{TextAffinity, TextPosition, TextRange};
9
10/// A range of text that represents a selection.
11///
12/// Dart's `TextSelection extends TextRange`: the range is [`range`](Self::range), and
13/// [`start`](Self::start) / [`end`](Self::end) are the normalized bounds.
14#[derive(Clone, Copy, Debug)]
15pub struct TextSelection {
16    /// The offset at which the selection originates.
17    ///
18    /// Might be larger than, smaller than, or equal to extent.
19    pub base_offset: i32,
20    /// The offset at which the selection terminates.
21    ///
22    /// When the user uses the arrow keys to adjust the selection, this is the value that
23    /// changes. Similarly, if the current theme paints a caret on one side of the selection,
24    /// this is the location at which to paint the caret.
25    ///
26    /// Might be larger than, smaller than, or equal to base.
27    pub extent_offset: i32,
28    /// If the text range is collapsed and has more than one visual location (e.g., occurs at
29    /// a line break), which of the two locations to use when painting the caret.
30    pub affinity: TextAffinity,
31    /// Whether this selection has disambiguated its base and extent.
32    ///
33    /// On some platforms, the base and extent are not disambiguated until the first time the
34    /// user adjusts the selection. At that point, either the start or the end of the
35    /// selection becomes the base and the other one becomes the extent and is adjusted.
36    pub is_directional: bool,
37}
38
39impl TextSelection {
40    /// Creates a text selection.
41    pub const fn new(base_offset: i32, extent_offset: i32) -> TextSelection {
42        TextSelection {
43            base_offset,
44            extent_offset,
45            affinity: TextAffinity::Downstream,
46            is_directional: false,
47        }
48    }
49
50    /// Creates a collapsed selection at the given offset.
51    ///
52    /// A collapsed selection starts and ends at the same offset, which means it contains zero
53    /// characters but instead serves as an insertion point in the text.
54    pub const fn collapsed(offset: i32, affinity: TextAffinity) -> TextSelection {
55        TextSelection {
56            base_offset: offset,
57            extent_offset: offset,
58            affinity,
59            is_directional: false,
60        }
61    }
62
63    /// Creates a collapsed selection at the given text position.
64    ///
65    /// A collapsed selection starts and ends at the same offset, which means it contains zero
66    /// characters but instead serves as an insertion point in the text.
67    pub const fn from_position(position: TextPosition) -> TextSelection {
68        TextSelection {
69            base_offset: position.offset,
70            extent_offset: position.offset,
71            affinity: position.affinity,
72            is_directional: false,
73        }
74    }
75
76    /// The normalized range this selection covers (Dart's `TextRange` superclass).
77    pub fn range(&self) -> TextRange {
78        if self.base_offset < self.extent_offset {
79            TextRange::new(self.base_offset, self.extent_offset)
80        } else {
81            TextRange::new(self.extent_offset, self.base_offset)
82        }
83    }
84
85    /// The index of the first character in the range.
86    pub fn start(&self) -> i32 {
87        self.range().start
88    }
89
90    /// The next index after the characters in this range.
91    pub fn end(&self) -> i32 {
92        self.range().end
93    }
94
95    /// Whether this range represents a valid position in the text.
96    pub fn is_valid(&self) -> bool {
97        self.range().is_valid()
98    }
99
100    /// Whether this range is empty (but still potentially placed inside the text).
101    pub fn is_collapsed(&self) -> bool {
102        self.range().is_collapsed()
103    }
104
105    /// Whether the start of this range precedes the end.
106    pub fn is_normalized(&self) -> bool {
107        self.range().is_normalized()
108    }
109
110    /// The position at which the selection originates.
111    ///
112    /// The [`TextAffinity`] of the resulting [`TextPosition`] is based on the relative logical
113    /// position in the text to the other selection endpoint:
114    ///  * if [`base_offset`](Self::base_offset) < [`extent_offset`](Self::extent_offset),
115    ///    [`base`](Self::base) will have a [`TextAffinity::Downstream`] affinity;
116    ///  * if [`base_offset`](Self::base_offset) > [`extent_offset`](Self::extent_offset),
117    ///    [`base`](Self::base) will have a [`TextAffinity::Upstream`] affinity;
118    ///  * if the selection is collapsed, the affinity is [`affinity`](Self::affinity).
119    pub fn base(&self) -> TextPosition {
120        let affinity = if !self.is_valid() || self.base_offset == self.extent_offset {
121            self.affinity
122        } else if self.base_offset < self.extent_offset {
123            TextAffinity::Downstream
124        } else {
125            TextAffinity::Upstream
126        };
127        TextPosition::with_affinity(self.base_offset, affinity)
128    }
129
130    /// The position at which the selection terminates.
131    ///
132    /// When the user uses the arrow keys to adjust the selection, this is the value that
133    /// changes. Similarly, if the current theme paints a caret on one side of the selection,
134    /// this is the location at which to paint the caret.
135    pub fn extent(&self) -> TextPosition {
136        let affinity = if !self.is_valid() || self.base_offset == self.extent_offset {
137            self.affinity
138        } else if self.base_offset < self.extent_offset {
139            TextAffinity::Upstream
140        } else {
141            TextAffinity::Downstream
142        };
143        TextPosition::with_affinity(self.extent_offset, affinity)
144    }
145
146    /// Creates a new [`TextSelection`] based on the current selection, with the provided
147    /// parameters overridden. Dart's `copyWith` named arguments are the fluent setters that
148    /// follow.
149    pub fn copy_with(&self) -> TextSelection {
150        *self
151    }
152
153    /// Dart `copyWith(baseOffset:)`.
154    pub fn base_offset(mut self, base_offset: i32) -> TextSelection {
155        self.base_offset = base_offset;
156        self
157    }
158
159    /// Dart `copyWith(extentOffset:)`.
160    pub fn extent_offset(mut self, extent_offset: i32) -> TextSelection {
161        self.extent_offset = extent_offset;
162        self
163    }
164
165    /// Dart `copyWith(affinity:)`.
166    pub fn affinity(mut self, affinity: TextAffinity) -> TextSelection {
167        self.affinity = affinity;
168        self
169    }
170
171    /// Dart `copyWith(isDirectional:)`.
172    pub fn is_directional(mut self, is_directional: bool) -> TextSelection {
173        self.is_directional = is_directional;
174        self
175    }
176
177    /// Returns the smallest [`TextSelection`] that this could expand to in order to include
178    /// the given [`TextPosition`].
179    ///
180    /// If the given [`TextPosition`] is already inside of the selection, then returns `self`
181    /// without change.
182    ///
183    /// The returned selection will always be a strict superset of the current selection. In
184    /// other words, the selection grows to include the given [`TextPosition`].
185    ///
186    /// If `extent_at_index` is set to true, then the [`extent`](Self::extent) will be
187    /// placed at the given index regardless of the original order of it and the
188    /// [`base`](Self::base). Otherwise, the base and extent are ordered so as to keep the
189    /// original selection's direction.
190    pub fn expand_to(&self, position: TextPosition, extent_at_index: bool) -> TextSelection {
191        // If position is already within in the selection, there's nothing to do.
192        if position.offset >= self.start() && position.offset <= self.end() {
193            return *self;
194        }
195        let normalized = self.base_offset <= self.extent_offset;
196        if position.offset <= self.start() {
197            // Here the position is somewhere before the selection: ..|..[...].....
198            if extent_at_index {
199                return self
200                    .copy_with()
201                    .base_offset(self.end())
202                    .extent_offset(position.offset)
203                    .affinity(position.affinity);
204            }
205            return self
206                .copy_with()
207                .base_offset(if normalized {
208                    position.offset
209                } else {
210                    self.base_offset
211                })
212                .extent_offset(if normalized {
213                    self.extent_offset
214                } else {
215                    position.offset
216                });
217        }
218        // Here the position is somewhere after the selection: .....[...]..|..
219        if extent_at_index {
220            return self
221                .copy_with()
222                .base_offset(self.start())
223                .extent_offset(position.offset)
224                .affinity(position.affinity);
225        }
226        self.copy_with()
227            .base_offset(if normalized {
228                self.base_offset
229            } else {
230                position.offset
231            })
232            .extent_offset(if normalized {
233                position.offset
234            } else {
235                self.extent_offset
236            })
237    }
238
239    /// Keeping the selection's [`base`](Self::base) fixed, pivot the
240    /// [`extent`](Self::extent) to the given [`TextPosition`].
241    ///
242    /// In some cases, the [`base`](Self::base) and [`extent`](Self::extent) can be flipped
243    /// during this operation, or the size of the selection can be reduced.
244    pub fn extend_to(&self, position: TextPosition) -> TextSelection {
245        // If the selection's extent is at the position already, then nothing
246        // happens.
247        if self.extent() == position {
248            return *self;
249        }
250        self.copy_with()
251            .extent_offset(position.offset)
252            .affinity(position.affinity)
253    }
254}
255
256impl From<TextSelection> for TextRange {
257    fn from(selection: TextSelection) -> TextRange {
258        selection.range()
259    }
260}
261
262impl PartialEq for TextSelection {
263    fn eq(&self, other: &TextSelection) -> bool {
264        if !self.is_valid() {
265            return !other.is_valid();
266        }
267        other.base_offset == self.base_offset
268            && other.extent_offset == self.extent_offset
269            && (!self.is_collapsed() || other.affinity == self.affinity)
270            && other.is_directional == self.is_directional
271    }
272}
273
274impl Eq for TextSelection {}
275
276impl Hash for TextSelection {
277    fn hash<H: Hasher>(&self, state: &mut H) {
278        if !self.is_valid() {
279            (-1i32).hash(state);
280            (-1i32).hash(state);
281            TextAffinity::Downstream.hash(state);
282            return;
283        }
284        self.base_offset.hash(state);
285        self.extent_offset.hash(state);
286        if self.is_collapsed() {
287            self.affinity.hash(state);
288        } else {
289            TextAffinity::Downstream.hash(state);
290        }
291        self.is_directional.hash(state);
292    }
293}
294
295impl fmt::Display for TextSelection {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        if !self.is_valid() {
298            return write!(f, "TextSelection.invalid");
299        }
300        if self.is_collapsed() {
301            write!(
302                f,
303                "TextSelection.collapsed(offset: {}, affinity: {:?}, isDirectional: {})",
304                self.base_offset, self.affinity, self.is_directional
305            )
306        } else {
307            write!(
308                f,
309                "TextSelection(baseOffset: {}, extentOffset: {}, isDirectional: {})",
310                self.base_offset, self.extent_offset, self.is_directional
311            )
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn a_reversed_selection_normalizes_its_range() {
322        let selection = TextSelection::new(5, 2);
323        assert_eq!(selection.start(), 2);
324        assert_eq!(selection.end(), 5);
325        assert_eq!(selection.base().affinity, TextAffinity::Upstream);
326        assert_eq!(selection.extent().affinity, TextAffinity::Downstream);
327        assert!(!selection.is_collapsed());
328    }
329
330    #[test]
331    fn expand_to_grows_toward_the_position() {
332        let selection = TextSelection::new(2, 5);
333        assert_eq!(selection.expand_to(TextPosition::new(3), false), selection);
334        let before = selection.expand_to(TextPosition::new(0), false);
335        assert_eq!((before.base_offset, before.extent_offset), (0, 5));
336        let after = selection.expand_to(TextPosition::new(8), true);
337        assert_eq!((after.base_offset, after.extent_offset), (2, 8));
338    }
339
340    #[test]
341    fn extend_to_moves_the_extent_only() {
342        let selection = TextSelection::new(2, 5);
343        let extended = selection.extend_to(TextPosition::new(1));
344        assert_eq!((extended.base_offset, extended.extent_offset), (2, 1));
345        assert_eq!(selection.extend_to(selection.extent()), selection);
346    }
347
348    #[test]
349    fn invalid_selections_compare_equal() {
350        assert_eq!(
351            TextSelection::new(-1, -1),
352            TextSelection::collapsed(-1, TextAffinity::Upstream)
353        );
354        assert_eq!(
355            TextSelection::collapsed(3, TextAffinity::Upstream).to_string(),
356            "TextSelection.collapsed(offset: 3, affinity: Upstream, isDirectional: false)"
357        );
358    }
359}