retroglyph_ui/widget/text_input.rs
1//! [`TextInput`]: a single-line, `TextInputState`-driven editable text field.
2use alloc::borrow::ToOwned as _;
3use alloc::string::String;
4
5use retroglyph_core::color::{Color, Style};
6use retroglyph_core::text::{split_at_width, width_usize};
7
8use super::StatefulWidget;
9use crate::Surface;
10use crate::TextInputState;
11use crate::Theme;
12use crate::text::truncate as truncate_to_cols;
13
14/// A single-line editable text field: the stateless drawing half of [`TextInputState`], the
15/// same split [`List`](super::List) has with [`ListState`](crate::ListState).
16///
17/// Draws `state.value()` (masked with `mask` if set, or `placeholder` while `state.value()` is
18/// empty), scrolled horizontally by `state.scroll()` and clipped to `surface.area()`'s width,
19/// with a caret cell at the cursor's display column. Neither scrolling nor the caret's column is
20/// byte- or char-based: both go
21/// through `retroglyph_core::text::width_usize`, the same display-width measurement
22/// [`truncate`](crate::text::truncate) uses, so a value containing a double-width character
23/// (CJK, most emoji) still puts the caret in the right screen column.
24///
25/// This widget does not call [`TextInputState::ensure_visible`]: like
26/// [`List`](super::List)/[`ListState::ensure_visible`](crate::ListState::ensure_visible), that's
27/// the caller's job, once per frame, with the actual current field width (which can change on
28/// resize).
29///
30/// The caret always renders as an inverted-color cell (`caret_style`), not by driving a real
31/// terminal cursor via a backend's `Cursor` facet: `render` only has a [`Surface`], not a
32/// `Backend`, and a cell-drawn caret renders identically (including in headless snapshot tests)
33/// on every backend. An app that wants a blinking, backend-native caret instead can position
34/// one itself from `state.cursor()`/`state.scroll()` alongside this widget.
35///
36/// This widget draws one field, nothing more: which field is focused (and therefore routed
37/// input), what Enter does, validation, and layout are all the app's job. IME/text composition
38/// and multi-line editing are out of scope for this crate entirely; see `docs/ROADMAP.md`.
39///
40/// # Examples
41///
42/// ```
43/// use retroglyph_core::grid::{Grid, Rect};
44/// use retroglyph_ui::{StatefulWidget, Surface, TextInput, TextInputState};
45///
46/// let mut state = TextInputState::new();
47/// state.set_value("hello");
48///
49/// let area = Rect::new(0, 0, 10, 1);
50/// let mut grid = Grid::new(10, 1);
51/// TextInput::new().render(&mut Surface::new(&mut grid, area, 0), &mut state);
52/// ```
53#[derive(Clone, Copy, Debug)]
54pub struct TextInput<'a> {
55 placeholder: Option<&'a str>,
56 mask: Option<char>,
57 style: Style,
58 placeholder_style: Style,
59 caret_style: Style,
60}
61
62impl<'a> TextInput<'a> {
63 /// An empty-placeholder, unmasked text input, styled from [`Theme::DARK`] (as if
64 /// [`TextInput::theme`] had been called); call [`TextInput::theme`]/[`TextInput::theme_on`]
65 /// for a different [`Theme`].
66 #[must_use]
67 pub fn new() -> Self {
68 Self {
69 placeholder: None,
70 mask: None,
71 style: Style::new(),
72 placeholder_style: Style::new(),
73 caret_style: Style::new(),
74 }
75 .theme(Theme::DARK)
76 }
77
78 /// Text shown, in [`placeholder_style`](Self::placeholder_style), when `state.value()` is
79 /// empty.
80 #[must_use]
81 pub const fn placeholder(mut self, placeholder: &'a str) -> Self {
82 self.placeholder = Some(placeholder);
83 self
84 }
85
86 /// Render every character of `state.value()` as `mask` instead of its real glyph, e.g. `'*'`
87 /// for a password field. Column math (scrolling, caret position) still uses the real value's
88 /// display width, not the mask's: masking only ever substitutes one fixed-width glyph, so
89 /// this is exact as long as `mask` itself is a single-column character.
90 #[must_use]
91 pub const fn mask(mut self, mask: char) -> Self {
92 self.mask = Some(mask);
93 self
94 }
95
96 /// Set the style of the value/placeholder text (`placeholder` uses
97 /// [`placeholder_style`](Self::placeholder_style) instead of this while shown).
98 #[must_use]
99 pub const fn style(mut self, style: Style) -> Self {
100 self.style = style;
101 self
102 }
103
104 /// Set the style of the placeholder text, shown in place of the value while it's empty.
105 #[must_use]
106 pub const fn placeholder_style(mut self, style: Style) -> Self {
107 self.placeholder_style = style;
108 self
109 }
110
111 /// Set the style of the caret cell.
112 #[must_use]
113 pub const fn caret_style(mut self, style: Style) -> Self {
114 self.caret_style = style;
115 self
116 }
117
118 /// Applies `theme`'s named roles: `style` becomes `theme.fg` on `theme.panel_bg`,
119 /// `placeholder_style` becomes `theme.dim` on the same background, and `caret_style` becomes
120 /// `theme.bg` on `theme.accent`. See [`List::theme`](super::List::theme) for why an explicit
121 /// background is baked in rather than left unset.
122 #[must_use]
123 pub fn theme(self, theme: Theme) -> Self {
124 self.theme_on(theme, theme.panel_bg)
125 }
126
127 /// Same as [`TextInput::theme`], but text is drawn on `bg` instead of `theme.panel_bg`.
128 #[must_use]
129 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
130 self.style = Style::new().fg(theme.fg).bg(bg);
131 self.placeholder_style = Style::new().fg(theme.dim).bg(bg);
132 self.caret_style = Style::new().fg(theme.bg).bg(theme.accent);
133 self
134 }
135}
136
137impl Default for TextInput<'_> {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl StatefulWidget for TextInput<'_> {
144 type State = TextInputState;
145
146 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
147 let width = surface.width();
148 if width == 0 {
149 return;
150 }
151
152 let value = state.value();
153 let caret_col =
154 width_usize(&value[..state.cursor()]).saturating_sub(usize::from(state.scroll()));
155
156 let (text, style) = if value.is_empty() {
157 self.placeholder.map_or_else(
158 || (String::new(), self.style),
159 |placeholder| (placeholder.to_owned(), self.placeholder_style),
160 )
161 } else {
162 // Skip the scrolled-past prefix (by display width, not bytes) before truncating
163 // what remains to the field width, the same `split_at_width`/`truncate` split
164 // `retroglyph-ui::text` documents for exactly this "windowed" reason.
165 let (_, visible) = split_at_width(value, state.scroll());
166 let visible = self
167 .mask
168 .map_or_else(|| visible.to_owned(), |mask| masked(visible, mask));
169 (visible, self.style)
170 };
171 let text = truncate_to_cols(&text, width);
172 surface.print((0, 0), text, style);
173
174 if caret_col < usize::from(width) {
175 #[allow(clippy::cast_possible_truncation)] // caret_col < width, a u16
176 let x = caret_col as u16;
177 // Re-derive the glyph from what was actually just printed (placeholder, masked, or
178 // real text) rather than re-reading `value`, so the caret cell inverts whatever's
179 // underneath it instead of clobbering a placeholder character with a blank.
180 let glyph = glyph_at_column(text, caret_col).unwrap_or(' ');
181 surface.put((x, 0), glyph, self.caret_style);
182 }
183 }
184}
185
186/// Replace every character in `s` with `mask`, preserving its display width for scroll/caret
187/// math: single-column masks (the common case, `'*'`) keep the same byte-for-column
188/// correspondence as the real value.
189fn masked(s: &str, mask: char) -> String {
190 s.chars().map(|_| mask).collect()
191}
192
193/// The character at display column `col` in `s`, or `None` past its last column.
194fn glyph_at_column(s: &str, col: usize) -> Option<char> {
195 #[allow(clippy::cast_possible_truncation)] // caller already clamped col < a surface's u16 width
196 let col = col.min(usize::from(u16::MAX)) as u16;
197 let (_, rest) = split_at_width(s, col);
198 rest.chars().next()
199}
200
201#[cfg(test)]
202mod tests {
203 use retroglyph_core::grid::{Grid, Pos, Rect};
204
205 use super::*;
206
207 #[test]
208 fn renders_the_value_and_a_caret_at_the_end() {
209 let area = Rect::new(0, 0, 10, 1);
210 let mut grid = Grid::new(10, 1);
211 let mut state = TextInputState::new();
212 state.set_value("hi");
213
214 TextInput::new().render(&mut Surface::new(&mut grid, area, 0), &mut state);
215
216 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h');
217 assert_eq!(grid[Pos::new(1, 0)].glyph(), 'i');
218 // Cursor is at the end (byte 2): the caret cell is the space just past "hi".
219 assert_eq!(grid[Pos::new(2, 0)].glyph(), ' ');
220 assert_ne!(
221 grid[Pos::new(2, 0)].style().background(),
222 grid[Pos::new(0, 0)].style().background()
223 );
224 }
225
226 #[test]
227 fn shows_the_placeholder_only_while_empty() {
228 let area = Rect::new(0, 0, 10, 1);
229 let mut grid = Grid::new(10, 1);
230 let mut state = TextInputState::new();
231
232 TextInput::new()
233 .placeholder("name")
234 .render(&mut Surface::new(&mut grid, area, 0), &mut state);
235 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'n');
236
237 let mut grid = Grid::new(10, 1);
238 state.insert('x');
239 TextInput::new()
240 .placeholder("name")
241 .render(&mut Surface::new(&mut grid, area, 0), &mut state);
242 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'x');
243 }
244
245 #[test]
246 fn mask_hides_the_real_characters() {
247 let area = Rect::new(0, 0, 10, 1);
248 let mut grid = Grid::new(10, 1);
249 let mut state = TextInputState::new();
250 state.set_value("secret");
251
252 TextInput::new()
253 .mask('*')
254 .render(&mut Surface::new(&mut grid, area, 0), &mut state);
255
256 assert_eq!(grid[Pos::new(0, 0)].glyph(), '*');
257 assert_eq!(grid[Pos::new(5, 0)].glyph(), '*');
258 }
259
260 #[test]
261 fn scroll_offset_shifts_the_visible_window() {
262 let area = Rect::new(0, 0, 5, 1);
263 let mut grid = Grid::new(5, 1);
264 let mut state = TextInputState::new();
265 state.set_value("hello world");
266 state.ensure_visible(5);
267
268 TextInput::new().render(&mut Surface::new(&mut grid, area, 0), &mut state);
269
270 // Scrolled to keep the end-of-value cursor in view: last 5 columns of "hello world".
271 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'o');
272 assert_eq!(grid[Pos::new(1, 0)].glyph(), 'r');
273 }
274
275 #[test]
276 fn caret_lands_on_the_correct_column_with_wide_characters() {
277 let area = Rect::new(0, 0, 10, 1);
278 let mut grid = Grid::new(10, 1);
279 let mut state = TextInputState::new();
280 state.set_value("aあ"); // 'a' (1 col) + 'あ' (2 cols)
281 state.move_home();
282 state.move_right(); // cursor after 'a', before the 2-wide 'あ'
283
284 TextInput::new().render(&mut Surface::new(&mut grid, area, 0), &mut state);
285
286 // Caret is at column 1 (display width of "a"), not column 1 by char count coincidentally
287 // matching: column 2 would be wrong if this used byte/char counting for a value with a
288 // wider first character.
289 assert_eq!(grid[Pos::new(1, 0)].glyph(), 'あ');
290 assert_ne!(
291 grid[Pos::new(1, 0)].style().background(),
292 grid[Pos::new(0, 0)].style().background()
293 );
294 }
295
296 #[test]
297 fn text_input_caret_does_not_write_past_the_field() {
298 // retroglyph#712: `ensure_visible` used to leave `scroll` inside a wide character's own
299 // cell (scroll == 1 for "ああ"), which `split_at_width` can't honor, so the caret's
300 // spacer cell was printed one column past the field's own 4-wide area, clobbering a
301 // neighbor drawn at column 4. The same mechanism as #9.
302 let mut grid = Grid::new(6, 1);
303 let mut state = TextInputState::new();
304 state.set_value("ああ"); // two 2-column characters, 4 columns
305 state.ensure_visible(4);
306
307 // A neighbor widget occupies columns 4..6, to the right of the 4-wide text field.
308 Surface::new(&mut grid, Rect::new(0, 0, 6, 1), 0).print((4, 0), "Z", Style::default());
309
310 TextInput::new().render(
311 &mut Surface::new(&mut grid, Rect::new(0, 0, 4, 1), 0),
312 &mut state,
313 );
314
315 assert_eq!(grid[Pos::new(4, 0)].glyph(), 'Z');
316 }
317}