Skip to main content

max / alloy_tui

7.3 KB · 250 lines History Blame Raw
1 //! A single-line text input.
2 //!
3 //! The caret buffer [`AlloyField`](crate::AlloyField) shows in edit mode, and
4 //! the one piece of the form architecture that owns state rather than
5 //! rendering it. Written for `alloy install`'s hostname and username steps,
6 //! which were the first time an Alloy TUI accepted typing at all, and kept in
7 //! the console binary until now for the reason a widget lands here at all: the
8 //! crate is a separate published repo, so putting something in it is a
9 //! release. This is that release.
10 //!
11 //! Indices are in `char`s throughout, never bytes. A hostname is ASCII by
12 //! specification but a username need not be, and `String::insert` at a byte
13 //! offset that lands mid-codepoint panics.
14
15 /// A line of text with a caret in it.
16 #[derive(Debug, Default, Clone)]
17 pub struct TextField {
18 value: String,
19 /// Caret position, in `char`s from the start. Equal to the char count when
20 /// the caret is past the last character, which is where typing appends.
21 caret: usize,
22 }
23
24 impl TextField {
25 pub fn new() -> Self {
26 Self::default()
27 }
28
29 pub fn value(&self) -> &str {
30 &self.value
31 }
32
33 /// Caret position, in `char`s.
34 ///
35 /// [`split`](Self::split) is what a renderer wants; this is the position
36 /// itself, which is what a caller moving the caret and a test asserting
37 /// where it landed both need.
38 pub fn caret(&self) -> usize {
39 self.caret
40 }
41
42 fn chars(&self) -> usize {
43 self.value.chars().count()
44 }
45
46 /// Byte offset of char index `index`, for the `String` operations.
47 ///
48 /// `char_indices` stops at the last character, so an index one past the end
49 /// (the caret appending at the tail) falls through to the string length
50 /// rather than off it.
51 fn byte_of(&self, index: usize) -> usize {
52 self.value
53 .char_indices()
54 .nth(index)
55 .map_or(self.value.len(), |(byte, _)| byte)
56 }
57
58 /// Type a character at the caret.
59 pub fn insert(&mut self, c: char) {
60 let at = self.byte_of(self.caret);
61 self.value.insert(at, c);
62 self.caret += 1;
63 }
64
65 /// Delete the character before the caret.
66 pub fn backspace(&mut self) {
67 if self.caret == 0 {
68 return;
69 }
70 self.caret -= 1;
71 let at = self.byte_of(self.caret);
72 self.value.remove(at);
73 }
74
75 /// Delete the character under the caret.
76 pub fn delete(&mut self) {
77 if self.caret >= self.chars() {
78 return;
79 }
80 let at = self.byte_of(self.caret);
81 self.value.remove(at);
82 }
83
84 /// Clamped rather than wrapping: a caret that jumps to the far end of the
85 /// line when you press Left once too often is the kind of thing that gets
86 /// a character typed into the wrong place.
87 pub fn left(&mut self) {
88 self.caret = self.caret.saturating_sub(1);
89 }
90
91 pub fn right(&mut self) {
92 if self.caret < self.chars() {
93 self.caret += 1;
94 }
95 }
96
97 pub fn home(&mut self) {
98 self.caret = 0;
99 }
100
101 pub fn end(&mut self) {
102 self.caret = self.chars();
103 }
104
105 /// Replace the contents, caret to the end.
106 ///
107 /// For seeding a field with a default the user is expected to edit rather
108 /// than retype, which is what the hostname step does.
109 pub fn set(&mut self, value: impl Into<String>) {
110 self.value = value.into();
111 self.caret = self.chars();
112 }
113
114 /// The line split at the caret: what is before it, the character under it,
115 /// and what follows.
116 ///
117 /// Returned as three pieces rather than rendered here because drawing needs
118 /// the theme, and this type deliberately knows nothing about one. The
119 /// middle is `None` when the caret is past the end, where a renderer draws
120 /// a block on empty space.
121 pub fn split(&self) -> (&str, Option<char>, &str) {
122 let at = self.byte_of(self.caret);
123 let (before, rest) = self.value.split_at(at);
124 let mut chars = rest.chars();
125 match chars.next() {
126 Some(under) => (before, Some(under), chars.as_str()),
127 None => (before, None, ""),
128 }
129 }
130 }
131
132 #[cfg(test)]
133 mod tests {
134 use super::*;
135
136 fn typed(text: &str) -> TextField {
137 let mut field = TextField::new();
138 for c in text.chars() {
139 field.insert(c);
140 }
141 field
142 }
143
144 #[test]
145 fn typing_appends_and_moves_the_caret() {
146 let field = typed("alloy");
147 assert_eq!(field.value(), "alloy");
148 assert_eq!(field.caret(), 5);
149 }
150
151 #[test]
152 fn insert_lands_at_the_caret_not_the_end() {
153 let mut field = typed("aloy");
154 field.home();
155 field.right();
156 field.insert('l');
157 assert_eq!(field.value(), "alloy");
158 assert_eq!(field.caret(), 2);
159 }
160
161 #[test]
162 fn backspace_takes_the_character_before_the_caret() {
163 let mut field = typed("alloyy");
164 field.backspace();
165 assert_eq!(field.value(), "alloy");
166
167 field.home();
168 field.backspace();
169 assert_eq!(field.value(), "alloy", "backspace at the start is inert");
170 assert_eq!(field.caret(), 0);
171 }
172
173 #[test]
174 fn delete_takes_the_character_under_the_caret() {
175 let mut field = typed("xalloy");
176 field.home();
177 field.delete();
178 assert_eq!(field.value(), "alloy");
179
180 field.end();
181 field.delete();
182 assert_eq!(field.value(), "alloy", "delete at the end is inert");
183 }
184
185 // Clamped, not wrapping. A caret that leaps to the opposite end on one
186 // keypress too many puts the next character somewhere the user did not look.
187 #[test]
188 fn the_caret_stops_at_both_ends() {
189 let mut field = typed("ab");
190 field.home();
191 field.left();
192 assert_eq!(field.caret(), 0);
193 field.end();
194 field.right();
195 assert_eq!(field.caret(), 2);
196 }
197
198 // The reason indices are chars. Byte offsets that land mid-codepoint panic
199 // in `String::insert` and `String::remove`, and a username is not
200 // guaranteed ASCII.
201 #[test]
202 fn multibyte_text_is_edited_without_panicking() {
203 let mut field = typed("héllo");
204 assert_eq!(field.caret(), 5);
205
206 field.home();
207 field.right();
208 field.delete();
209 assert_eq!(field.value(), "hllo", "the two-byte char came out whole");
210
211 field.insert('é');
212 assert_eq!(field.value(), "héllo");
213
214 field.end();
215 field.backspace();
216 assert_eq!(field.value(), "héll");
217 }
218
219 #[test]
220 fn split_reports_the_character_under_the_caret() {
221 let mut field = typed("alloy");
222 field.home();
223 assert_eq!(field.split(), ("", Some('a'), "lloy"));
224
225 field.right();
226 assert_eq!(field.split(), ("a", Some('l'), "loy"));
227
228 field.end();
229 assert_eq!(
230 field.split(),
231 ("alloy", None, ""),
232 "past the end there is nothing under the caret"
233 );
234 }
235
236 #[test]
237 fn split_handles_an_empty_field() {
238 assert_eq!(TextField::new().split(), ("", None, ""));
239 }
240
241 #[test]
242 fn set_replaces_the_value_and_parks_the_caret_at_the_end() {
243 let mut field = typed("old");
244 field.home();
245 field.set("alloy");
246 assert_eq!(field.value(), "alloy");
247 assert_eq!(field.caret(), 5, "ready to edit the tail, not retype it");
248 }
249 }
250