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::grid::Size;
7use retroglyph_core::text::{split_at_width, width, width_usize};
8
9use super::{InteractiveWidget, MinSize, StatefulWidget};
10use crate::Surface;
11use crate::interact::Density;
12use crate::interact::Response;
13use crate::interact::Sense;
14use crate::state::TextInputState;
15use crate::text::truncate as truncate_to_cols;
16use crate::theme::Theme;
17
18/// A single-line editable text field: the stateless drawing half of [`TextInputState`], the
19/// same split [`List`](super::List) has with [`ListState`](crate::state::ListState).
20///
21/// Draws `state.value()` (masked with `mask` if set, or `placeholder` while `state.value()` is
22/// empty), scrolled horizontally by `state.scroll()` and clipped to `surface.area()`'s width,
23/// with a caret cell at the cursor's display column. Neither scrolling nor the caret's column is
24/// byte- or char-based: both go
25/// through `retroglyph_core::text::width_usize`, the same display-width measurement
26/// [`truncate`](crate::text::truncate) uses, so a value containing a double-width character
27/// (CJK, most emoji) still puts the caret in the right screen column.
28///
29/// This widget does not call [`TextInputState::ensure_visible`]: like
30/// [`List`](super::List)/[`ListState::ensure_visible`](crate::state::ListState::ensure_visible), that's
31/// the caller's job, once per frame, with the actual current field width (which can change on
32/// resize).
33///
34/// The caret always renders as an inverted-color cell (`caret_style`), not by driving a real
35/// terminal cursor via a backend's `Cursor` facet: `render` only has a [`Surface`], not a
36/// `Backend`, and a cell-drawn caret renders identically (including in headless snapshot tests)
37/// on every backend. An app that wants a blinking, backend-native caret instead can position
38/// one itself from `state.cursor()`/`state.scroll()` alongside this widget.
39///
40/// As an [`InteractiveWidget`], [`sense`](InteractiveWidget::sense) is [`Sense::click`]: a click
41/// (or Tab) focuses the field the same way it would a [`Button`](super::Button), via the shared
42/// [`Interaction`](crate::interact::Interaction)'s [`FocusRing`](crate::interact::FocusRing).
43/// Routing keystrokes to whichever field currently holds focus is still the app's job, via
44/// [`TextInputState::handle_event`]: [`InteractiveWidget::render`] never sees raw input events,
45/// only the already-resolved [`Response`], so it can't do that itself. What Enter does,
46/// validation, and layout are the app's job too. IME/text composition and multi-line editing are
47/// out of scope for this crate entirely.
48///
49/// # Examples
50///
51/// ```
52/// use retroglyph_core::grid::{Grid, Rect};
53/// use retroglyph_ui::state::TextInputState;
54/// use retroglyph_ui::widget::{StatefulWidget, TextInput};
55/// use retroglyph_ui::Surface;
56///
57/// let mut state = TextInputState::new();
58/// state.set_value("hello");
59///
60/// let area = Rect::new(0, 0, 10, 1);
61/// let mut grid = Grid::new(10, 1);
62/// TextInput::new().render(&mut Surface::new(&mut grid, area, 0), &mut state);
63/// ```
64#[derive(Clone, Copy, Debug)]
65pub struct TextInput<'a> {
66    placeholder: Option<&'a str>,
67    mask: Option<char>,
68    style: Style,
69    placeholder_style: Style,
70    caret_style: Style,
71}
72
73impl<'a> TextInput<'a> {
74    /// An empty-placeholder, unmasked text input, styled from [`Theme::DARK`] (as if
75    /// [`TextInput::theme`] had been called); call [`TextInput::theme`]/[`TextInput::theme_on`]
76    /// for a different [`Theme`].
77    #[must_use]
78    pub fn new() -> Self {
79        Self {
80            placeholder: None,
81            mask: None,
82            style: Style::new(),
83            placeholder_style: Style::new(),
84            caret_style: Style::new(),
85        }
86        .theme(Theme::DARK)
87    }
88
89    /// Text shown, in [`placeholder_style`](Self::placeholder_style), when `state.value()` is
90    /// empty.
91    #[must_use]
92    pub const fn placeholder(mut self, placeholder: &'a str) -> Self {
93        self.placeholder = Some(placeholder);
94        self
95    }
96
97    /// Render every character of `state.value()` as `mask` instead of its real glyph, e.g. `'*'`
98    /// for a password field. Column math (scrolling, caret position) still uses the real value's
99    /// display width, not the mask's: masking only ever substitutes one fixed-width glyph, so
100    /// this is exact as long as `mask` itself is a single-column character.
101    #[must_use]
102    pub const fn mask(mut self, mask: char) -> Self {
103        self.mask = Some(mask);
104        self
105    }
106
107    /// Set the style of the value/placeholder text (`placeholder` uses
108    /// [`placeholder_style`](Self::placeholder_style) instead of this while shown).
109    #[must_use]
110    pub const fn style(mut self, style: Style) -> Self {
111        self.style = style;
112        self
113    }
114
115    /// Set the style of the placeholder text, shown in place of the value while it's empty.
116    #[must_use]
117    pub const fn placeholder_style(mut self, style: Style) -> Self {
118        self.placeholder_style = style;
119        self
120    }
121
122    /// Set the style of the caret cell.
123    #[must_use]
124    pub const fn caret_style(mut self, style: Style) -> Self {
125        self.caret_style = style;
126        self
127    }
128
129    /// Applies `theme`'s named roles: `style` becomes `theme.fg` on `theme.panel_bg`,
130    /// `placeholder_style` becomes `theme.dim` on the same background, and `caret_style` becomes
131    /// `theme.bg` on `theme.accent`. See [`List::theme`](super::List::theme) for why an explicit
132    /// background is baked in rather than left unset.
133    #[must_use]
134    pub fn theme(self, theme: Theme) -> Self {
135        self.theme_on(theme, theme.panel_bg)
136    }
137
138    /// Same as [`TextInput::theme`], but text is drawn on `bg` instead of `theme.panel_bg`.
139    #[must_use]
140    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
141        self.style = Style::new().fg(theme.fg).bg(bg);
142        self.placeholder_style = Style::new().fg(theme.dim).bg(bg);
143        self.caret_style = Style::new().fg(theme.bg).bg(theme.accent);
144        self
145    }
146}
147
148impl Default for TextInput<'_> {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154impl TextInput<'_> {
155    /// The shared drawing routine both [`StatefulWidget::render`] and
156    /// [`InteractiveWidget::render`] use: this widget's appearance never depends on a
157    /// [`Response`] (unlike [`Button`](super::Button)'s hovered/pressed/focused styling), so both
158    /// impls delegate here unchanged.
159    fn draw(&self, surface: &mut Surface<'_>, state: &TextInputState) {
160        let width = surface.width();
161        if width == 0 {
162            return;
163        }
164
165        let value = state.value();
166        let caret_col =
167            width_usize(&value[..state.cursor()]).saturating_sub(usize::from(state.scroll()));
168
169        let (text, style) = if value.is_empty() {
170            self.placeholder.map_or_else(
171                || (String::new(), self.style),
172                |placeholder| (placeholder.to_owned(), self.placeholder_style),
173            )
174        } else {
175            // Skip the scrolled-past prefix (by display width, not bytes) before truncating
176            // what remains to the field width, the same `split_at_width`/`truncate` split
177            // `retroglyph-ui::text` documents for exactly this "windowed" reason.
178            let (_, visible) = split_at_width(value, state.scroll());
179            let visible = self
180                .mask
181                .map_or_else(|| visible.to_owned(), |mask| masked(visible, mask));
182            (visible, self.style)
183        };
184        let text = truncate_to_cols(&text, width);
185        surface.print((0, 0), text, style);
186
187        if caret_col < usize::from(width) {
188            #[allow(clippy::cast_possible_truncation)] // caret_col < width, a u16
189            let x = caret_col as u16;
190            // Re-derive the glyph from what was actually just printed (placeholder, masked, or
191            // real text) rather than re-reading `value`, so the caret cell inverts whatever's
192            // underneath it instead of clobbering a placeholder character with a blank.
193            let glyph = glyph_at_column(text, caret_col).unwrap_or(' ');
194            surface.put((x, 0), glyph, self.caret_style);
195        }
196    }
197}
198
199impl MinSize for TextInput<'_> {
200    /// [`placeholder`](TextInput::placeholder)'s display width (`0` with no placeholder set)
201    /// plus 2 columns of padding on each side, matching [`Button`](super::Button)'s own padding,
202    /// one row tall, floored at `density`'s [`min_target_size`](Density::min_target_size) so an
203    /// empty field still claims a usable click/tap target.
204    fn min_size(&self, density: Density) -> Size {
205        let content_width = self.placeholder.map_or(0, width).saturating_add(4);
206        Size::new(content_width, 1).max(density.min_target_size())
207    }
208}
209
210impl StatefulWidget for TextInput<'_> {
211    type State = TextInputState;
212
213    fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
214        self.draw(surface, state);
215    }
216}
217
218impl<Id> InteractiveWidget<Id> for TextInput<'_> {
219    type State = TextInputState;
220
221    /// [`Sense::click`], the same as [`Button`](super::Button)/[`List`](super::List): a click
222    /// focuses the field (and joins the shared [`Interaction`](crate::interact::Interaction)'s
223    /// Tab order) via [`Sense::FOCUSABLE`], which [`Sense::click`] already includes. This impl's
224    /// `render` still never reads `response`: routing keystrokes to whichever field holds focus,
225    /// and calling [`TextInputState::handle_event`] on it, is the caller's job, the same as
226    /// [`List`](super::List) leaves arrow-key row navigation to the caller.
227    fn sense(&self) -> Sense {
228        Sense::click()
229    }
230
231    fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, _response: Response<Id>) {
232        self.draw(surface, state);
233    }
234}
235
236/// Replace every character in `s` with `mask`, preserving its display width for scroll/caret
237/// math: single-column masks (the common case, `'*'`) keep the same byte-for-column
238/// correspondence as the real value.
239fn masked(s: &str, mask: char) -> String {
240    s.chars().map(|_| mask).collect()
241}
242
243/// The character at display column `col` in `s`, or `None` past its last column.
244fn glyph_at_column(s: &str, col: usize) -> Option<char> {
245    #[allow(clippy::cast_possible_truncation)] // caller already clamped col < a surface's u16 width
246    let col = col.min(usize::from(u16::MAX)) as u16;
247    let (_, rest) = split_at_width(s, col);
248    rest.chars().next()
249}
250
251#[cfg(test)]
252mod tests {
253    use retroglyph_core::grid::{Grid, HasSize as _, Pos, Rect};
254
255    use super::*;
256
257    #[test]
258    fn renders_the_value_and_a_caret_at_the_end() {
259        let area = Rect::new(0, 0, 10, 1);
260        let mut grid = Grid::new(10, 1);
261        let mut state = TextInputState::new();
262        state.set_value("hi");
263
264        StatefulWidget::render(
265            &TextInput::new(),
266            &mut Surface::new(&mut grid, area, 0),
267            &mut state,
268        );
269
270        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h');
271        assert_eq!(grid[Pos::new(1, 0)].glyph(), 'i');
272        // Cursor is at the end (byte 2): the caret cell is the space just past "hi".
273        assert_eq!(grid[Pos::new(2, 0)].glyph(), ' ');
274        assert_ne!(
275            grid[Pos::new(2, 0)].style().background(),
276            grid[Pos::new(0, 0)].style().background()
277        );
278    }
279
280    #[test]
281    fn shows_the_placeholder_only_while_empty() {
282        let area = Rect::new(0, 0, 10, 1);
283        let mut grid = Grid::new(10, 1);
284        let mut state = TextInputState::new();
285
286        StatefulWidget::render(
287            &TextInput::new().placeholder("name"),
288            &mut Surface::new(&mut grid, area, 0),
289            &mut state,
290        );
291        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'n');
292
293        let mut grid = Grid::new(10, 1);
294        state.insert('x');
295        StatefulWidget::render(
296            &TextInput::new().placeholder("name"),
297            &mut Surface::new(&mut grid, area, 0),
298            &mut state,
299        );
300        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'x');
301    }
302
303    #[test]
304    fn mask_hides_the_real_characters() {
305        let area = Rect::new(0, 0, 10, 1);
306        let mut grid = Grid::new(10, 1);
307        let mut state = TextInputState::new();
308        state.set_value("secret");
309
310        StatefulWidget::render(
311            &TextInput::new().mask('*'),
312            &mut Surface::new(&mut grid, area, 0),
313            &mut state,
314        );
315
316        assert_eq!(grid[Pos::new(0, 0)].glyph(), '*');
317        assert_eq!(grid[Pos::new(5, 0)].glyph(), '*');
318    }
319
320    #[test]
321    fn scroll_offset_shifts_the_visible_window() {
322        let area = Rect::new(0, 0, 5, 1);
323        let mut grid = Grid::new(5, 1);
324        let mut state = TextInputState::new();
325        state.set_value("hello world");
326        state.ensure_visible(5);
327
328        StatefulWidget::render(
329            &TextInput::new(),
330            &mut Surface::new(&mut grid, area, 0),
331            &mut state,
332        );
333
334        // Scrolled to keep the end-of-value cursor in view: last 5 columns of "hello world".
335        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'o');
336        assert_eq!(grid[Pos::new(1, 0)].glyph(), 'r');
337    }
338
339    #[test]
340    fn caret_lands_on_the_correct_column_with_wide_characters() {
341        let area = Rect::new(0, 0, 10, 1);
342        let mut grid = Grid::new(10, 1);
343        let mut state = TextInputState::new();
344        state.set_value("aあ"); // 'a' (1 col) + 'あ' (2 cols)
345        state.move_home();
346        state.move_right(); // cursor after 'a', before the 2-wide 'あ'
347
348        StatefulWidget::render(
349            &TextInput::new(),
350            &mut Surface::new(&mut grid, area, 0),
351            &mut state,
352        );
353
354        // Caret is at column 1 (display width of "a"), not column 1 by char count coincidentally
355        // matching: column 2 would be wrong if this used byte/char counting for a value with a
356        // wider first character.
357        assert_eq!(grid[Pos::new(1, 0)].glyph(), 'あ');
358        assert_ne!(
359            grid[Pos::new(1, 0)].style().background(),
360            grid[Pos::new(0, 0)].style().background()
361        );
362    }
363
364    #[test]
365    fn text_input_caret_does_not_write_past_the_field() {
366        // retroglyph#712: `ensure_visible` used to leave `scroll` inside a wide character's own
367        // cell (scroll == 1 for "ああ"), which `split_at_width` can't honor, so the caret's
368        // spacer cell was printed one column past the field's own 4-wide area, clobbering a
369        // neighbor drawn at column 4. The same mechanism as #9.
370        let mut grid = Grid::new(6, 1);
371        let mut state = TextInputState::new();
372        state.set_value("ああ"); // two 2-column characters, 4 columns
373        state.ensure_visible(4);
374
375        // A neighbor widget occupies columns 4..6, to the right of the 4-wide text field.
376        Surface::new(&mut grid, Rect::new(0, 0, 6, 1), 0).print((4, 0), "Z", Style::default());
377
378        StatefulWidget::render(
379            &TextInput::new(),
380            &mut Surface::new(&mut grid, Rect::new(0, 0, 4, 1), 0),
381            &mut state,
382        );
383
384        assert_eq!(grid[Pos::new(4, 0)].glyph(), 'Z');
385    }
386
387    #[test]
388    fn interactive_render_draws_the_same_as_stateful_render() {
389        // `InteractiveWidget::render` never reads `response` (see its own doc comment), so it
390        // must draw identically to `StatefulWidget::render` regardless of what's in it.
391        let area = Rect::new(0, 0, 10, 1);
392        let mut state = TextInputState::new();
393        state.set_value("hi");
394
395        let mut stateful_grid = Grid::new(10, 1);
396        StatefulWidget::render(
397            &TextInput::new(),
398            &mut Surface::new(&mut stateful_grid, area, 0),
399            &mut state,
400        );
401
402        let response: Response<()> = Response {
403            hovered: true,
404            focused: true,
405            ..Response::default()
406        };
407        let mut interactive_grid = Grid::new(10, 1);
408        InteractiveWidget::render(
409            &TextInput::new(),
410            &mut Surface::new(&mut interactive_grid, area, 0),
411            &mut state,
412            response,
413        );
414
415        for x in 0..10 {
416            let pos = Pos::new(x, 0);
417            assert_eq!(stateful_grid[pos].glyph(), interactive_grid[pos].glyph());
418            assert_eq!(stateful_grid[pos].style(), interactive_grid[pos].style());
419        }
420    }
421
422    #[test]
423    fn sense_is_click_so_a_click_focuses_the_field_through_a_shared_interaction() {
424        // End-to-end proof that `TextInput` now participates in the same click-to-focus/Tab-ring
425        // routing `24_settings_form` already demonstrates by hand: `Sense::click()` includes
426        // `Sense::FOCUSABLE`, so registering it via `Interaction::interact` is enough.
427        use retroglyph_core::event::{
428            Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
429        };
430
431        use crate::interact::Interaction;
432
433        #[derive(Clone, Copy, PartialEq, Eq)]
434        enum Id {
435            Name,
436        }
437
438        let area = Rect::new(0, 0, 10, 1);
439        let mut grid = Grid::new(10, 1);
440        let mut state = TextInputState::new();
441        let mut interaction = Interaction::<Id>::new();
442        let text_input = TextInput::new();
443
444        let sense = <TextInput as InteractiveWidget<Id>>::sense(&text_input);
445        assert_eq!(sense, Sense::click());
446
447        // Frame 1: registers the field as focusable for the frame that follows.
448        interaction.begin_frame();
449        let response = interaction.interact(area, Id::Name, sense);
450        InteractiveWidget::render(
451            &text_input,
452            &mut Surface::new(&mut grid, area, 0),
453            &mut state,
454            response,
455        );
456        interaction.end_frame();
457
458        // Click lands on the field between frames, same as `interact/mod.rs`'s own click tests.
459        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
460            MouseEventKind::Down(MouseButton::Left),
461            Pos::new(2, 0),
462            KeyModifiers::NONE,
463        )));
464        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
465            MouseEventKind::Up(MouseButton::Left),
466            Pos::new(2, 0),
467            KeyModifiers::NONE,
468        )));
469
470        // Frame 2: resolves the click and requests focus, but (like `Response::hovered`) a
471        // `request`ed-this-call focus isn't visible on `Response` until the frame after: see
472        // `interact`'s own `is_focused`-before-`request` ordering.
473        interaction.begin_frame();
474        let _ = interaction.interact(area, Id::Name, sense);
475        interaction.end_frame();
476
477        // Frame 3: now reports focused.
478        interaction.begin_frame();
479        let response = interaction.interact(area, Id::Name, sense);
480        assert!(response.focused());
481        interaction.end_frame();
482    }
483
484    #[test]
485    fn min_size_floors_an_empty_placeholder_at_the_density_minimum() {
486        // No placeholder set: content width is 0 + padding 4 = 4 cols, under either density's
487        // 6-cell floor.
488        let size = TextInput::new().min_size(Density::Mouse);
489        assert_eq!(size, Density::Mouse.min_target_size());
490    }
491
492    #[test]
493    fn min_size_pads_the_placeholder_by_two_columns_each_side() {
494        // "Name" is 4 cols wide; padded to 8, past the 6-cell floor either density sets, so the
495        // placeholder's own width should win over `min_target_size`.
496        let size = TextInput::new()
497            .placeholder("Name")
498            .min_size(Density::Mouse);
499        assert_eq!(size.width(), 8);
500        assert_eq!(size.height(), 1);
501    }
502
503    #[test]
504    fn min_size_grows_taller_for_touch_density() {
505        let size = TextInput::new().min_size(Density::Touch);
506        assert_eq!(size, Density::Touch.min_target_size());
507    }
508}