Skip to main content

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}