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}