//! A single-line text input. //! //! The caret buffer [`AlloyField`](crate::AlloyField) shows in edit mode, and //! the one piece of the form architecture that owns state rather than //! rendering it. Written for `alloy install`'s hostname and username steps, //! which were the first time an Alloy TUI accepted typing at all, and kept in //! the console binary until now for the reason a widget lands here at all: the //! crate is a separate published repo, so putting something in it is a //! release. This is that release. //! //! Indices are in `char`s throughout, never bytes. A hostname is ASCII by //! specification but a username need not be, and `String::insert` at a byte //! offset that lands mid-codepoint panics. /// A line of text with a caret in it. #[derive(Debug, Default, Clone)] pub struct TextField { value: String, /// Caret position, in `char`s from the start. Equal to the char count when /// the caret is past the last character, which is where typing appends. caret: usize, } impl TextField { pub fn new() -> Self { Self::default() } pub fn value(&self) -> &str { &self.value } /// Caret position, in `char`s. /// /// [`split`](Self::split) is what a renderer wants; this is the position /// itself, which is what a caller moving the caret and a test asserting /// where it landed both need. pub fn caret(&self) -> usize { self.caret } fn chars(&self) -> usize { self.value.chars().count() } /// Byte offset of char index `index`, for the `String` operations. /// /// `char_indices` stops at the last character, so an index one past the end /// (the caret appending at the tail) falls through to the string length /// rather than off it. fn byte_of(&self, index: usize) -> usize { self.value .char_indices() .nth(index) .map_or(self.value.len(), |(byte, _)| byte) } /// Type a character at the caret. pub fn insert(&mut self, c: char) { let at = self.byte_of(self.caret); self.value.insert(at, c); self.caret += 1; } /// Delete the character before the caret. pub fn backspace(&mut self) { if self.caret == 0 { return; } self.caret -= 1; let at = self.byte_of(self.caret); self.value.remove(at); } /// Delete the character under the caret. pub fn delete(&mut self) { if self.caret >= self.chars() { return; } let at = self.byte_of(self.caret); self.value.remove(at); } /// Clamped rather than wrapping: a caret that jumps to the far end of the /// line when you press Left once too often is the kind of thing that gets /// a character typed into the wrong place. pub fn left(&mut self) { self.caret = self.caret.saturating_sub(1); } pub fn right(&mut self) { if self.caret < self.chars() { self.caret += 1; } } pub fn home(&mut self) { self.caret = 0; } pub fn end(&mut self) { self.caret = self.chars(); } /// Replace the contents, caret to the end. /// /// For seeding a field with a default the user is expected to edit rather /// than retype, which is what the hostname step does. pub fn set(&mut self, value: impl Into) { self.value = value.into(); self.caret = self.chars(); } /// The line split at the caret: what is before it, the character under it, /// and what follows. /// /// Returned as three pieces rather than rendered here because drawing needs /// the theme, and this type deliberately knows nothing about one. The /// middle is `None` when the caret is past the end, where a renderer draws /// a block on empty space. pub fn split(&self) -> (&str, Option, &str) { let at = self.byte_of(self.caret); let (before, rest) = self.value.split_at(at); let mut chars = rest.chars(); match chars.next() { Some(under) => (before, Some(under), chars.as_str()), None => (before, None, ""), } } } #[cfg(test)] mod tests { use super::*; fn typed(text: &str) -> TextField { let mut field = TextField::new(); for c in text.chars() { field.insert(c); } field } #[test] fn typing_appends_and_moves_the_caret() { let field = typed("alloy"); assert_eq!(field.value(), "alloy"); assert_eq!(field.caret(), 5); } #[test] fn insert_lands_at_the_caret_not_the_end() { let mut field = typed("aloy"); field.home(); field.right(); field.insert('l'); assert_eq!(field.value(), "alloy"); assert_eq!(field.caret(), 2); } #[test] fn backspace_takes_the_character_before_the_caret() { let mut field = typed("alloyy"); field.backspace(); assert_eq!(field.value(), "alloy"); field.home(); field.backspace(); assert_eq!(field.value(), "alloy", "backspace at the start is inert"); assert_eq!(field.caret(), 0); } #[test] fn delete_takes_the_character_under_the_caret() { let mut field = typed("xalloy"); field.home(); field.delete(); assert_eq!(field.value(), "alloy"); field.end(); field.delete(); assert_eq!(field.value(), "alloy", "delete at the end is inert"); } // Clamped, not wrapping. A caret that leaps to the opposite end on one // keypress too many puts the next character somewhere the user did not look. #[test] fn the_caret_stops_at_both_ends() { let mut field = typed("ab"); field.home(); field.left(); assert_eq!(field.caret(), 0); field.end(); field.right(); assert_eq!(field.caret(), 2); } // The reason indices are chars. Byte offsets that land mid-codepoint panic // in `String::insert` and `String::remove`, and a username is not // guaranteed ASCII. #[test] fn multibyte_text_is_edited_without_panicking() { let mut field = typed("héllo"); assert_eq!(field.caret(), 5); field.home(); field.right(); field.delete(); assert_eq!(field.value(), "hllo", "the two-byte char came out whole"); field.insert('é'); assert_eq!(field.value(), "héllo"); field.end(); field.backspace(); assert_eq!(field.value(), "héll"); } #[test] fn split_reports_the_character_under_the_caret() { let mut field = typed("alloy"); field.home(); assert_eq!(field.split(), ("", Some('a'), "lloy")); field.right(); assert_eq!(field.split(), ("a", Some('l'), "loy")); field.end(); assert_eq!( field.split(), ("alloy", None, ""), "past the end there is nothing under the caret" ); } #[test] fn split_handles_an_empty_field() { assert_eq!(TextField::new().split(), ("", None, "")); } #[test] fn set_replaces_the_value_and_parks_the_caret_at_the_end() { let mut field = typed("old"); field.home(); field.set("alloy"); assert_eq!(field.value(), "alloy"); assert_eq!(field.caret(), 5, "ready to edit the tail, not retype it"); } }