Skip to main content

zest_widget/widget/
text_area.rs

1//! Editable multi-line text field.
2//!
3//! `TextArea` is the display half of an editable text input. Following the
4//! "zest way", it is **transient and stateless**: the host owns the text
5//! (a `String`) and the cursor position (a char index), rebuilds the widget
6//! each frame via [`TextArea::new`] + [`cursor`](TextArea::cursor), and
7//! mutates its own state in `update`. The widget itself only *renders* and
8//! *maps taps to char indices*.
9//!
10//! It pairs naturally with [`Keyboard`](super::keyboard::Keyboard): wire the
11//! keyboard's [`KeyAction`](super::keyboard::KeyAction) messages into the
12//! host's `update` to insert/delete characters and move the cursor, and wire
13//! [`on_tap`](TextArea::on_tap) to reposition the cursor on touch. See
14//! `examples/text_area.rs` for a complete editor.
15//!
16//! # Layout & wrapping
17//!
18//! Text is laid out into visual lines by **char-wrapping** to the available
19//! pixel width using the mono font's `character_size.width`, and is also
20//! broken on `'\n'`. The widget **assumes ASCII**: wrapping and the
21//! tap-to-index mapping count `char`s and multiply by the fixed glyph width,
22//! which is only correct for single-cell ASCII text. Non-ASCII / wide / 0-cell
23//! characters will misalign the cursor and hit-testing.
24//!
25//! # Scrolling
26//!
27//! Rendering is clipped to the arranged rect via
28//! [`push_clip`](Renderer::push_clip) / [`pop_clip`](Renderer::pop_clip).
29//! As you type past the last visible row the view scrolls down to keep the
30//! cursor's line within the bottom of the viewport. It does not scroll back
31//! up when the cursor moves above the first visible line.
32//!
33//! # Colors
34//!
35//! Text defaults to `theme.background.on_base`, the cursor bar to
36//! `theme.accent.base`, and the (dimmed) placeholder to
37//! `theme.palette.neutral_2`. All three are overridable via builders.
38
39use super::Widget;
40use alloc::borrow::Cow;
41use alloc::boxed::Box;
42use alloc::vec::Vec;
43use core::marker::PhantomData;
44use embedded_graphics::{
45    mono_font::MonoFont, pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
46};
47use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
48use zest_theme::Theme;
49
50/// Cursor bar thickness in pixels.
51const CURSOR_W: u32 = 2;
52/// Default intrinsic width when sized `Shrink`.
53const INTRINSIC_W: u32 = 200;
54/// Default intrinsic height when sized `Shrink` (≈ a few lines).
55const INTRINSIC_H: u32 = 96;
56
57/// A single visual line: a byte range into the source text plus whether it
58/// ends at a hard `'\n'` (so a trailing-newline empty line can be modeled).
59#[derive(Copy, Clone)]
60struct VisualLine {
61    /// Byte offset of the line's first char in the source text.
62    start: usize,
63    /// Byte offset one past the line's last *rendered* char.
64    end: usize,
65    /// Char index of the first char on this line.
66    char_start: usize,
67    /// Number of chars rendered on this line (excludes a hard newline).
68    char_len: usize,
69}
70
71/// Editable multi-line text field.
72///
73/// Borrows the text as `Cow<'a, str>` and reads a host-owned cursor position
74/// (char index). It emits a message via [`on_tap`](TextArea::on_tap) carrying
75/// the nearest char index when tapped, so the host can move its cursor.
76pub struct TextArea<'a, C: PixelColor, M: Clone> {
77    rect: Rectangle,
78    text: Cow<'a, str>,
79    cursor: usize,
80    placeholder: Cow<'a, str>,
81    id: Option<WidgetId>,
82    color: Option<C>,
83    cursor_color: Option<C>,
84    placeholder_color: Option<C>,
85    font: Option<&'a MonoFont<'a>>,
86    on_tap: Option<Box<dyn Fn(usize) -> M + 'a>>,
87    on_action: Option<Box<dyn Fn(UiAction) -> M + 'a>>,
88    focused: bool,
89    width: Length,
90    height: Length,
91    _color: PhantomData<C>,
92}
93
94impl<'a, C: PixelColor, M: Clone> TextArea<'a, C, M> {
95    /// New text area displaying `text`. The cursor defaults to index `0`;
96    /// set it with [`cursor`](Self::cursor). Position and size are assigned
97    /// by the parent container via `arrange`.
98    pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
99        Self {
100            rect: Rectangle::zero(),
101            text: text.into(),
102            cursor: 0,
103            placeholder: Cow::Borrowed(""),
104            id: None,
105            color: None,
106            cursor_color: None,
107            placeholder_color: None,
108            font: None,
109            on_tap: None,
110            on_action: None,
111            focused: false,
112            width: Length::Fill,
113            height: Length::Fill,
114            _color: PhantomData,
115        }
116    }
117
118    /// Cursor position as a **char index** into the text. Values
119    /// past the end are clamped to the char count at draw time.
120    #[must_use]
121    pub fn cursor(mut self, index: usize) -> Self {
122        self.cursor = index;
123        self
124    }
125
126    /// Dimmed placeholder shown when the text is empty.
127    #[must_use]
128    pub fn placeholder(mut self, text: impl Into<Cow<'a, str>>) -> Self {
129        self.placeholder = text.into();
130        self
131    }
132
133    /// Set a stable id so this text area can participate in focus traversal.
134    #[must_use]
135    pub fn id(mut self, id: WidgetId) -> Self {
136        self.id = Some(id);
137        self
138    }
139
140    /// Override the text color (default: `theme.background.on_base`).
141    #[must_use]
142    pub fn color(mut self, color: C) -> Self {
143        self.color = Some(color);
144        self
145    }
146
147    /// Override the cursor bar color (default: `theme.accent.base`).
148    #[must_use]
149    pub fn cursor_color(mut self, color: C) -> Self {
150        self.cursor_color = Some(color);
151        self
152    }
153
154    /// Override the placeholder color (default:
155    /// `theme.palette.neutral_2`).
156    #[must_use]
157    pub fn placeholder_color(mut self, color: C) -> Self {
158        self.placeholder_color = Some(color);
159        self
160    }
161
162    /// Override the font (default: `theme.default_font()`). Must be a
163    /// mono font; wrapping relies on `character_size.width`.
164    #[must_use]
165    pub fn font(mut self, font: &'a MonoFont<'a>) -> Self {
166        self.font = Some(font);
167        self
168    }
169
170    /// Callback receiving the nearest char index when the area is
171    /// tapped. Without it, taps are ignored. The host typically stores the
172    /// returned index as its new cursor position.
173    ///
174    /// Tap-to-cursor mapping requires an explicit [`font`](Self::font) (the
175    /// theme is not reachable during touch dispatch); without one, taps are
176    /// silently ignored.
177    #[must_use]
178    pub fn on_tap<F: Fn(usize) -> M + 'a>(mut self, f: F) -> Self {
179        self.on_tap = Some(Box::new(f));
180        self
181    }
182
183    /// Callback receiving semantic editing/navigation actions while focused.
184    #[must_use]
185    pub fn on_action<F: Fn(UiAction) -> M + 'a>(mut self, f: F) -> Self {
186        self.on_action = Some(Box::new(f));
187        self
188    }
189
190    /// Width sizing intent.
191    #[must_use]
192    pub fn width(mut self, width: impl Into<Length>) -> Self {
193        self.width = width.into();
194        self
195    }
196
197    /// Height sizing intent.
198    #[must_use]
199    pub fn height(mut self, height: impl Into<Length>) -> Self {
200        self.height = height.into();
201        self
202    }
203
204    /// Resolve the effective font for layout/draw.
205    fn resolved_font<'t>(&'t self, theme: &'t Theme<'a, C>) -> &'t MonoFont<'a> {
206        self.font.unwrap_or(theme.default_font())
207    }
208
209    /// Glyph cell width for `font`, never zero (guards division).
210    fn glyph_w(font: &MonoFont<'_>) -> u32 {
211        font.character_size.width.max(1)
212    }
213
214    /// Glyph cell height for `font`, never zero.
215    fn glyph_h(font: &MonoFont<'_>) -> u32 {
216        font.character_size.height.max(1)
217    }
218
219    /// Maximum number of chars that fit across the available width.
220    fn cols(&self, font: &MonoFont<'_>) -> usize {
221        let w = self.rect.size.width;
222        (w / Self::glyph_w(font)).max(1) as usize
223    }
224
225    /// Lay the source text out into visual lines: split on `'\n'`, then
226    /// char-wrap each paragraph to `cols`. Always yields at least one line
227    /// (possibly empty) so the cursor and placeholder have somewhere to sit.
228    fn layout_lines(&self, font: &MonoFont<'_>) -> Vec<VisualLine> {
229        let cols = self.cols(font);
230        let mut lines = Vec::new();
231
232        // Walk the text char by char, tracking byte and char offsets, and
233        // emit a visual line whenever we hit a newline or run out of columns.
234        let mut line_byte_start = 0usize;
235        let mut line_char_start = 0usize;
236        let mut col = 0usize;
237        let mut last_byte = 0usize;
238
239        for (byte_idx, ch) in self.text.char_indices() {
240            last_byte = byte_idx + ch.len_utf8();
241            if ch == '\n' {
242                lines.push(VisualLine {
243                    start: line_byte_start,
244                    end: byte_idx,
245                    char_start: line_char_start,
246                    char_len: col,
247                });
248                line_byte_start = byte_idx + 1;
249                line_char_start += col + 1; // +1 for the consumed newline.
250                col = 0;
251                continue;
252            }
253            if col >= cols {
254                // Char-wrap: close the current line just before this char.
255                lines.push(VisualLine {
256                    start: line_byte_start,
257                    end: byte_idx,
258                    char_start: line_char_start,
259                    char_len: col,
260                });
261                line_byte_start = byte_idx;
262                line_char_start += col;
263                col = 0;
264            }
265            col += 1;
266        }
267
268        // Final (or only) line — the tail after the last newline/wrap.
269        lines.push(VisualLine {
270            start: line_byte_start,
271            end: last_byte.max(line_byte_start),
272            char_start: line_char_start,
273            char_len: col,
274        });
275
276        lines
277    }
278
279    /// Locate the cursor as a (line index, column) pair within `lines`.
280    /// The cursor is a char index; it belongs to the line whose
281    /// `char_start..=char_start+char_len` contains it, biased to the *end*
282    /// of a wrapped line so a cursor at a wrap boundary shows on the line it
283    /// was typed into.
284    fn cursor_line_col(&self, lines: &[VisualLine], cursor_chars: usize) -> (usize, usize) {
285        for (i, line) in lines.iter().enumerate() {
286            let line_end_char = line.char_start + line.char_len;
287            let is_last = i + 1 == lines.len();
288            // A cursor exactly at the end of a non-last line that was *wrapped*
289            // (not newline-terminated) should still render on this line.
290            if cursor_chars < line_end_char || (cursor_chars == line_end_char && is_last) {
291                return (i, cursor_chars.saturating_sub(line.char_start));
292            }
293            // Cursor sits right at a hard line break or wrap end: if the next
294            // line starts at the same char (a wrap), keep it here.
295            if cursor_chars == line_end_char {
296                if let Some(next) = lines.get(i + 1) {
297                    if next.char_start == line_end_char {
298                        // Wrapped boundary: place at end of this line.
299                        return (i, line.char_len);
300                    }
301                }
302            }
303        }
304        // Past the end: end of the last line.
305        let last = lines.len().saturating_sub(1);
306        let col = lines.last().map_or(0, |l| l.char_len);
307        (last, col)
308    }
309
310    fn hit_test(&self, point: Point) -> bool {
311        let tl = self.rect.top_left;
312        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
313        point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
314    }
315
316    /// Map a touched point to the nearest char index, using the same first
317    /// visible line as the draw pass.
318    fn index_at(&self, point: Point, font: &MonoFont<'_>) -> usize {
319        let lines = self.layout_lines(font);
320        if lines.is_empty() {
321            return 0;
322        }
323        let gh = Self::glyph_h(font) as i32;
324        let gw = Self::glyph_w(font) as i32;
325        let first_line = self.first_visible_line(&lines, font);
326
327        // Which visible row was tapped → absolute line index.
328        let rel_y = (point.y - self.rect.top_left.y).max(0);
329        let visible_row = (rel_y / gh) as usize;
330        let line_idx = (first_line + visible_row).min(lines.len() - 1);
331        let line = lines[line_idx];
332
333        // Column from x, rounded to the nearest char boundary, clamped to the
334        // line's char length.
335        let rel_x = (point.x - self.rect.top_left.x).max(0);
336        let col = ((rel_x + gw / 2) / gw) as usize;
337        let col = col.min(line.char_len);
338        line.char_start + col
339    }
340
341    /// Index of the first line to draw so the cursor's line stays visible.
342    fn first_visible_line(&self, lines: &[VisualLine], font: &MonoFont<'_>) -> usize {
343        let gh = Self::glyph_h(font);
344        let rows = (self.rect.size.height / gh).max(1) as usize;
345        let cursor_chars = self.cursor.min(self.text.chars().count());
346        let (cursor_line, _) = self.cursor_line_col(lines, cursor_chars);
347        // Keep the cursor line within the bottom of the viewport.
348        if cursor_line + 1 > rows {
349            cursor_line + 1 - rows
350        } else {
351            0
352        }
353    }
354}
355
356impl<'a, C: PixelColor, M: Clone> Widget<C, M> for TextArea<'a, C, M> {
357    fn measure(&mut self, constraints: Constraints) -> Size {
358        let w = self.width.resolve(INTRINSIC_W, constraints.max.width);
359        let h = self.height.resolve(INTRINSIC_H, constraints.max.height);
360        constraints.clamp(Size::new(w, h))
361    }
362
363    fn preferred_size(&self) -> (Length, Length) {
364        (self.width, self.height)
365    }
366
367    fn arrange(&mut self, rect: Rectangle) {
368        self.rect = rect;
369    }
370
371    fn rect(&self) -> Rectangle {
372        self.rect
373    }
374
375    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
376        let on_tap = self.on_tap.as_ref()?;
377        if phase != TouchPhase::Down || !self.hit_test(point) {
378            return None;
379        }
380        // Mapping a tap to a char index requires the font's glyph metrics.
381        // `handle_touch` has no access to the theme (that arrives only at
382        // `draw`), so tap-to-cursor needs an explicit [`font`](Self::font).
383        // Without one the tap is ignored — set `.font(...)` to enable it
384        // (the example does). The font is identical to the one used at draw.
385        let font = self.font?;
386        Some(on_tap(self.index_at(point, font)))
387    }
388
389    fn widget_id(&self) -> Option<WidgetId> {
390        self.id
391    }
392
393    fn is_focusable(&self) -> bool {
394        self.id.is_some()
395    }
396
397    fn handle_action(&mut self, action: UiAction) -> Option<M> {
398        self.on_action.as_ref().map(|cb| cb(action))
399    }
400
401    fn sync_focus(&mut self, focused: Option<WidgetId>) {
402        self.focused = self.id.is_some() && self.id == focused;
403    }
404
405    fn focus_at(&self, point: Point) -> Option<WidgetId> {
406        if self.is_focusable() && self.hit_test(point) {
407            self.id
408        } else {
409            None
410        }
411    }
412
413    fn draw<'t>(
414        &self,
415        renderer: &mut dyn Renderer<C>,
416        theme: &Theme<'t, C>,
417    ) -> Result<(), RenderError> {
418        let font = self.resolved_font(theme);
419        let gh = Self::glyph_h(font) as i32;
420        let gw = Self::glyph_w(font) as i32;
421        let text_color = self.color.unwrap_or(theme.background.on_base);
422        let cursor_color = self.cursor_color.unwrap_or(theme.accent.base);
423
424        // Clip everything to the widget rect.
425        renderer.push_clip(self.rect);
426
427        let x0 = self.rect.top_left.x;
428        let y0 = self.rect.top_left.y;
429
430        // Empty text → dimmed placeholder + a cursor at the start.
431        if self.text.is_empty() {
432            if !self.placeholder.is_empty() {
433                let ph_color = self.placeholder_color.unwrap_or(theme.palette.neutral_2);
434                renderer.draw_text(
435                    &self.placeholder,
436                    Point::new(x0, y0 + gh),
437                    font,
438                    ph_color,
439                    Alignment::Left,
440                )?;
441            }
442            let cursor_rect = Rectangle::new(
443                Point::new(x0, y0 + 2),
444                Size::new(CURSOR_W, gh.max(1) as u32),
445            );
446            renderer.fill_rect(cursor_rect, cursor_color)?;
447            renderer.pop_clip();
448            return Ok(());
449        }
450
451        let lines = self.layout_lines(font);
452        let rows = (self.rect.size.height / Self::glyph_h(font)).max(1) as usize;
453        let first_line = self.first_visible_line(&lines, font);
454
455        // Draw the visible window of lines. Baseline is one glyph height
456        // below the top of each row (matching `Text`'s top alignment).
457        for (row, line) in lines.iter().enumerate().skip(first_line).take(rows) {
458            let slice = &self.text[line.start..line.end];
459            if !slice.is_empty() {
460                let draw_y = y0 + (row - first_line) as i32 * gh + gh;
461                renderer.draw_text(
462                    slice,
463                    Point::new(x0, draw_y),
464                    font,
465                    text_color,
466                    Alignment::Left,
467                )?;
468            }
469        }
470
471        // Draw the cursor bar at (line, col).
472        let cursor_chars = self.cursor.min(self.text.chars().count());
473        let (cursor_line, cursor_col) = self.cursor_line_col(&lines, cursor_chars);
474        if cursor_line >= first_line && cursor_line < first_line + rows {
475            let cx = x0 + cursor_col as i32 * gw;
476            let cy = y0 + (cursor_line - first_line) as i32 * gh;
477            let cursor_rect = Rectangle::new(
478                Point::new(cx, cy + 2),
479                Size::new(CURSOR_W, gh.max(1) as u32),
480            );
481            renderer.fill_rect(cursor_rect, cursor_color)?;
482        }
483
484        renderer.pop_clip();
485        if self.focused {
486            renderer.stroke_rect(self.rect, theme.accent.base)?;
487        }
488        Ok(())
489    }
490}