git.lucas.co / cce-browser
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 }