web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
src/lineedit.rs (10.1K)
1 //! A one-line text field: the text, a caret, and a selection.
2 //!
3 //! Written as the shared editor the chrome should use everywhere. The URL bar
4 //! still has its own copy of this logic welded into `BrowserApp` (59 call
5 //! sites); migrating it is a mechanical change worth doing on its own rather
6 //! than folded into a feature, so for now this backs the dialog fields only.
7
8 use cce_ui::widget::{ElementState, Key, KeyEvent, NamedKey};
9
10 /// What a keystroke meant, beyond editing the text.
11 #[derive(Debug, PartialEq)]
12 pub enum EditOutcome {
13 /// Nothing structural — redraw and carry on.
14 Edited,
15 /// Enter: the caller commits.
16 Submit,
17 /// Escape: the caller cancels.
18 Cancel,
19 /// Not ours (a chord the chrome owns).
20 Ignored,
21 }
22
23 #[derive(Default)]
24 pub struct LineEdit {
25 pub text: String,
26 pub cursor: usize,
27 /// Normalized (start < end). Any edit replaces or drops it.
28 pub selection: Option<(usize, usize)>,
29 /// Render as bullets. Set for password fields.
30 pub masked: bool,
31 }
32
33 /// Also used by the chrome's text elision.
34 pub fn prev_boundary(s: &str, i: usize) -> usize {
35 let mut j = i;
36 while j > 0 {
37 j -= 1;
38 if s.is_char_boundary(j) {
39 return j;
40 }
41 }
42 0
43 }
44
45 fn next_boundary(s: &str, i: usize) -> usize {
46 let mut j = i;
47 while j < s.len() {
48 j += 1;
49 if s.is_char_boundary(j) {
50 return j;
51 }
52 }
53 s.len()
54 }
55
56 impl LineEdit {
57 pub fn with_text(text: impl Into<String>) -> Self {
58 let text = text.into();
59 Self { cursor: text.len(), text, ..Self::default() }
60 }
61
62 pub fn masked() -> Self {
63 Self { masked: true, ..Self::default() }
64 }
65
66 /// What to draw. Never returns the password itself.
67 pub fn display(&self) -> String {
68 if self.masked {
69 "\u{2022}".repeat(self.text.chars().count())
70 } else {
71 self.text.clone()
72 }
73 }
74
75 pub fn select_all(&mut self) {
76 self.cursor = self.text.len();
77 self.selection = (self.cursor > 0).then_some((0, self.cursor));
78 }
79
80 fn take_selection(&mut self) -> bool {
81 match self.selection.take() {
82 Some((a, b)) if a < b && b <= self.text.len() => {
83 self.text.replace_range(a..b, "");
84 self.cursor = a;
85 true
86 }
87 _ => false,
88 }
89 }
90
91 pub fn handle_key(&mut self, event: &KeyEvent) -> EditOutcome {
92 if event.state != ElementState::Pressed {
93 return EditOutcome::Ignored;
94 }
95 match &event.logical_key {
96 Key::Named(NamedKey::Enter) => return EditOutcome::Submit,
97 Key::Named(NamedKey::Escape) => return EditOutcome::Cancel,
98 Key::Named(NamedKey::Backspace) => {
99 if !self.take_selection() && self.cursor > 0 {
100 let prev = prev_boundary(&self.text, self.cursor);
101 self.text.replace_range(prev..self.cursor, "");
102 self.cursor = prev;
103 }
104 }
105 Key::Named(NamedKey::Delete) => {
106 if !self.take_selection() && self.cursor < self.text.len() {
107 let next = next_boundary(&self.text, self.cursor);
108 self.text.replace_range(self.cursor..next, "");
109 }
110 }
111 // Arrows collapse a selection to the edge they move toward.
112 Key::Named(NamedKey::ArrowLeft) => {
113 self.cursor = match self.selection.take() {
114 Some((a, _)) => a,
115 None => prev_boundary(&self.text, self.cursor),
116 };
117 }
118 Key::Named(NamedKey::ArrowRight) => {
119 self.cursor = match self.selection.take() {
120 Some((_, b)) => b,
121 None => next_boundary(&self.text, self.cursor),
122 };
123 }
124 Key::Named(NamedKey::Home) => {
125 self.selection = None;
126 self.cursor = 0;
127 }
128 Key::Named(NamedKey::End) => {
129 self.selection = None;
130 self.cursor = self.text.len();
131 }
132 Key::Character(c) if event.ctrl => match c.as_str() {
133 "a" => self.select_all(),
134 "u" => {
135 self.text.clear();
136 self.cursor = 0;
137 self.selection = None;
138 }
139 // Copy and cut are deliberately absent on a masked field:
140 // a password should not leave through the clipboard by a
141 // chord the user may not have meant. Paste is allowed, since
142 // that is how password managers hand one over.
143 "v" => {
144 if let Some(t) = cce_ui::widget::clipboard::read_from_clipboard() {
145 let flat: String = t.chars().filter(|c| !c.is_control()).collect();
146 if !flat.is_empty() {
147 self.take_selection();
148 self.text.insert_str(self.cursor, &flat);
149 self.cursor += flat.len();
150 }
151 }
152 }
153 "c" | "x" if !self.masked => {
154 if let Some((a, b)) = self.selection.filter(|&(a, b)| a < b) {
155 cce_ui::widget::clipboard::copy_to_clipboard(&self.text[a..b]);
156 if c == "x" {
157 self.take_selection();
158 }
159 }
160 }
161 _ => return EditOutcome::Ignored,
162 },
163 _ => {
164 let insert = match (&event.text, &event.logical_key) {
165 (Some(t), _) if !event.ctrl && !t.chars().any(char::is_control) => {
166 Some(t.clone())
167 }
168 (None, Key::Named(NamedKey::Space)) => Some(" ".to_string()),
169 (None, Key::Character(c)) if !event.ctrl => Some(c.clone()),
170 _ => return EditOutcome::Ignored,
171 };
172 if let Some(t) = insert {
173 self.take_selection();
174 self.text.insert_str(self.cursor, &t);
175 self.cursor += t.len();
176 }
177 }
178 }
179 EditOutcome::Edited
180 }
181 }
182
183 #[cfg(test)]
184 mod tests {
185 use super::*;
186 use cce_ui::widget::{ElementState, Key, NamedKey};
187
188 fn ev(key: Key, ctrl: bool) -> KeyEvent {
189 let text = match &key {
190 Key::Character(c) if !ctrl => Some(c.clone()),
191 _ => None,
192 };
193 KeyEvent {
194 state: ElementState::Pressed,
195 logical_key: key,
196 text,
197 repeat: false,
198 ctrl,
199 shift: false,
200 alt: false,
201 }
202 }
203 fn ch(c: &str) -> KeyEvent {
204 ev(Key::Character(c.into()), false)
205 }
206 fn ctrl(c: &str) -> KeyEvent {
207 ev(Key::Character(c.into()), true)
208 }
209 fn named(n: NamedKey) -> KeyEvent {
210 ev(Key::Named(n), false)
211 }
212
213 fn typed(e: &mut LineEdit, s: &str) {
214 for c in s.chars() {
215 e.handle_key(&ch(&c.to_string()));
216 }
217 }
218
219 #[test]
220 fn typing_inserts_at_the_caret() {
221 let mut e = LineEdit::default();
222 typed(&mut e, "abc");
223 assert_eq!(e.text, "abc");
224 assert_eq!(e.cursor, 3);
225 }
226
227 /// The URL bar's defining behaviour: entering it selects everything, so
228 /// the next keystroke replaces the address rather than appending to it.
229 #[test]
230 fn typing_over_a_selection_replaces_it() {
231 let mut e = LineEdit::with_text("https://example.com");
232 e.select_all();
233 typed(&mut e, "x");
234 assert_eq!(e.text, "x");
235 assert_eq!(e.selection, None);
236 }
237
238 #[test]
239 fn ctrl_a_selects_all_and_ctrl_u_clears() {
240 let mut e = LineEdit::with_text("abc");
241 e.handle_key(&ctrl("a"));
242 assert_eq!(e.selection, Some((0, 3)));
243 e.handle_key(&ctrl("u"));
244 assert_eq!(e.text, "");
245 assert_eq!(e.selection, None);
246 }
247
248 #[test]
249 fn backspace_deletes_a_selection_whole_or_one_char() {
250 let mut e = LineEdit::with_text("abc");
251 e.select_all();
252 e.handle_key(&named(NamedKey::Backspace));
253 assert_eq!(e.text, "");
254
255 let mut e = LineEdit::with_text("abc");
256 e.handle_key(&named(NamedKey::Backspace));
257 assert_eq!(e.text, "ab");
258 }
259
260 /// Arrows collapse to the edge they move toward rather than stepping from
261 /// the caret — otherwise Left after a select-all lands in the wrong place.
262 #[test]
263 fn arrows_collapse_a_selection_to_its_edge() {
264 let mut e = LineEdit::with_text("abc");
265 e.select_all();
266 e.handle_key(&named(NamedKey::ArrowLeft));
267 assert_eq!((e.cursor, e.selection), (0, None));
268
269 e.select_all();
270 e.handle_key(&named(NamedKey::ArrowRight));
271 assert_eq!((e.cursor, e.selection), (3, None));
272 }
273
274 #[test]
275 fn enter_and_escape_are_reported_not_swallowed() {
276 let mut e = LineEdit::with_text("x");
277 assert_eq!(e.handle_key(&named(NamedKey::Enter)), EditOutcome::Submit);
278 assert_eq!(e.handle_key(&named(NamedKey::Escape)), EditOutcome::Cancel);
279 }
280
281 /// Multi-byte text must not be split mid-character.
282 #[test]
283 fn caret_moves_by_character_not_byte() {
284 let mut e = LineEdit::with_text("é1");
285 e.handle_key(&named(NamedKey::Home));
286 e.handle_key(&named(NamedKey::ArrowRight));
287 assert_eq!(e.cursor, 2, "é is two bytes");
288 e.handle_key(&named(NamedKey::Backspace));
289 assert_eq!(e.text, "1");
290 }
291
292 /// A password must not leave through a chord the user may not have meant.
293 #[test]
294 fn a_masked_field_hides_its_text_and_refuses_copy() {
295 let mut e = LineEdit::masked();
296 typed(&mut e, "hunter2");
297 assert_eq!(e.display(), "•".repeat(7));
298 assert_ne!(e.display(), e.text);
299 e.select_all();
300 assert_eq!(e.handle_key(&ctrl("c")), EditOutcome::Ignored);
301 assert_eq!(e.handle_key(&ctrl("x")), EditOutcome::Ignored);
302 assert_eq!(e.text, "hunter2", "cut must not have removed it");
303 }
304 }