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