Skip to main content

rlvgl_widgets/
textarea.rs

1// SPDX-License-Identifier: MIT
2//! Multi-line editable text area widget (LPAR-14 §5.C).
3//!
4//! [`Textarea`] is the `widgets`-layer v2 textarea.  It reuses
5//! [`rlvgl_core::edit::EditCore`] for the mutation model and
6//! [`rlvgl_core::font::wrap_greedy_ltr`] for multi-line wrapping —
7//! it does **not** reimplement either.
8//!
9//! ## Key differences from `ui::input::Textarea` (WID-01)
10//!
11//! | Feature | `ui::input::Textarea` | `widgets::textarea::Textarea` |
12//! |---|---|---|
13//! | Multi-line wrap | `'\n'`-split only | LPAR-08 greedy wrap + hard newlines |
14//! | Overflow scroll | no | `scroll_offset_y` |
15//! | Placeholder | no | yes |
16//! | Password mode | no | yes |
17//! | One-line mode | no | yes |
18//! | `Keyboard` binding | manual | `apply_key_output` |
19//!
20//! ## Usage
21//!
22//! ```ignore
23//! let mut ta = Textarea::new(Rect { x: 10, y: 10, width: 200, height: 100 });
24//! ta.set_placeholder("Type here…");
25//! ta.set_active(true);
26//! // Wire to a Keyboard:
27//! keyboard.set_key_output_hook(move |ko| ta.apply_key_output(ko));
28//! ```
29
30use alloc::boxed::Box;
31use alloc::string::String;
32#[cfg(test)]
33use rlvgl_core::bitmap_font::FONT_6X10;
34use rlvgl_core::draw::draw_widget_bg;
35use rlvgl_core::edit::{AcceptFn, CARET_WIDTH, ChangeCallback, EditCore};
36use rlvgl_core::event::{Event, Key};
37use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr, wrap_greedy_ltr};
38use rlvgl_core::renderer::{ClipRenderer, Renderer};
39use rlvgl_core::style::Style;
40use rlvgl_core::widget::{Color, Rect, Widget};
41
42use crate::keyboard::KeyOutput;
43
44/// Submit callback type for one-line mode.
45type SubmitCallback = Box<dyn FnMut(&str)>;
46
47/// Default masking glyph for password mode.
48const PASSWORD_GLYPH: char = '*';
49
50/// Multi-line editable text area widget (LPAR-14 §5.C).
51///
52/// Wraps [`EditCore`] (promoted to `rlvgl_core`) for the mutation model and
53/// uses the LPAR-08 greedy-wrap algorithm for line layout.  Supports
54/// placeholder text, password masking, optional single-line mode, and vertical
55/// overflow scrolling.
56///
57/// # Key navigation
58///
59/// Wire a [`Keyboard`](crate::keyboard::Keyboard) hook to
60/// [`apply_key_output`](Self::apply_key_output):
61///
62/// ```ignore
63/// keyboard.set_key_output_hook(|ko| ta.apply_key_output(ko));
64/// ```
65///
66/// For direct key injection (hardware keyboard / test harness) use
67/// [`Widget::handle_event`] with [`Event::KeyDown`].
68pub struct Textarea {
69    /// Inner edit state (buffer, caret, mutation gates).
70    core: EditCore,
71    /// Outer bounds — also stored in `core.bounds` but kept here for the
72    /// `Widget::bounds` fast path.
73    bounds: Rect,
74    /// Placeholder shown when the buffer is empty and the field is inactive.
75    placeholder: Option<String>,
76    /// When `true`, every character in the display is replaced by
77    /// [`PASSWORD_GLYPH`] (buffer is unchanged).
78    password_mode: bool,
79    /// When `true`, `Enter` emits a submit signal rather than a newline, and
80    /// wrapping is suppressed (display is a single scrollable line).
81    one_line: bool,
82    /// Vertical scroll offset in pixels (non-negative).
83    scroll_offset_y: i32,
84    /// Optional submit callback fired when `Enter` is pressed in one-line mode
85    /// or via [`KeyOutput::Enter`] when `one_line` is active.
86    on_submit: Option<SubmitCallback>,
87    /// Widget style (background, border).
88    pub style: Style,
89    /// Text colour.
90    pub text_color: Color,
91    /// Font assignment for this widget (FONT-00 §5); resolves to `FONT_6X10`
92    /// when unset.
93    font: WidgetFont,
94}
95
96impl Textarea {
97    /// Create a new `Textarea` occupying `bounds` with an empty buffer.
98    pub fn new(bounds: Rect) -> Self {
99        Self {
100            core: EditCore::new("", bounds, true),
101            bounds,
102            placeholder: None,
103            password_mode: false,
104            one_line: false,
105            scroll_offset_y: 0,
106            on_submit: None,
107            style: Style::default(),
108            text_color: Color(0, 0, 0, 255),
109            font: WidgetFont::new(),
110        }
111    }
112
113    // ── Buffer ────────────────────────────────────────────────────────────────
114
115    /// Replace the buffer with `text` programmatically.
116    ///
117    /// Bypasses the ASCII gate and `accept` predicate.  Clamps the caret to
118    /// the new length.  Fires `on_change` if registered.
119    pub fn set_text(&mut self, text: &str) {
120        self.core.set_text(text);
121        self.clamp_scroll();
122    }
123
124    /// Return a reference to the current buffer contents.
125    pub fn text(&self) -> &str {
126        &self.core.buffer
127    }
128
129    // ── Placeholder ───────────────────────────────────────────────────────────
130
131    /// Set the placeholder string shown when the buffer is empty and the
132    /// field is inactive.  Pass `""` to clear.
133    pub fn set_placeholder(&mut self, text: &str) {
134        self.placeholder = if text.is_empty() {
135            None
136        } else {
137            Some(String::from(text))
138        };
139    }
140
141    /// Return the current placeholder string, if any.
142    pub fn placeholder(&self) -> Option<&str> {
143        self.placeholder.as_deref()
144    }
145
146    // ── Password mode ─────────────────────────────────────────────────────────
147
148    /// Enable or disable password masking.
149    ///
150    /// When enabled, every character in the rendered display is replaced by
151    /// [`PASSWORD_GLYPH`] (`*`).  The buffer stores the real text.
152    pub fn set_password_mode(&mut self, enable: bool) {
153        self.password_mode = enable;
154    }
155
156    /// Return `true` if password masking is active.
157    pub fn password_mode(&self) -> bool {
158        self.password_mode
159    }
160
161    // ── One-line mode ─────────────────────────────────────────────────────────
162
163    /// Enable or disable single-line mode.
164    ///
165    /// In one-line mode:
166    /// * `'\n'` is never inserted; Enter fires `on_submit` instead.
167    /// * Wrapping is suppressed; the content scrolls horizontally.
168    pub fn set_one_line(&mut self, enable: bool) {
169        self.one_line = enable;
170        // Sync the core's multi_line flag: multi_line == !one_line.
171        self.core.multi_line = !enable;
172    }
173
174    /// Return `true` if single-line mode is active.
175    pub fn one_line(&self) -> bool {
176        self.one_line
177    }
178
179    // ── Focus / activation ────────────────────────────────────────────────────
180
181    /// Return `true` when this field is consuming keyboard events.
182    pub fn is_active(&self) -> bool {
183        self.core.active
184    }
185
186    /// Toggle key consumption.  Applications keep at most one field active;
187    /// no focus framework is imposed (WID-00 §7.1).
188    pub fn set_active(&mut self, active: bool) {
189        self.core.active = active;
190    }
191
192    // ── Caret ─────────────────────────────────────────────────────────────────
193
194    /// Return the current caret position (char index, `0..=char_count`).
195    pub fn caret(&self) -> usize {
196        self.core.caret
197    }
198
199    // ── Style ─────────────────────────────────────────────────────────────────
200
201    /// Immutable access to the widget style.
202    pub fn widget_style(&self) -> &Style {
203        &self.style
204    }
205
206    /// Mutable access to the widget style.
207    pub fn widget_style_mut(&mut self) -> &mut Style {
208        &mut self.style
209    }
210
211    /// Assign the font used to render this widget (FONT-00 §5); resolves to
212    /// `FONT_6X10` when unset.
213    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
214        self.font.set(font);
215    }
216
217    // ── Mutation gates ────────────────────────────────────────────────────────
218
219    /// Register a change callback invoked after every committed edit and on
220    /// programmatic [`set_text`](Self::set_text) calls.
221    pub fn on_change<F: FnMut(&str) + 'static>(mut self, handler: F) -> Self {
222        self.core.on_change = Some(Box::new(handler) as ChangeCallback);
223        self
224    }
225
226    /// Register a submit callback fired when Enter is pressed in one-line
227    /// mode.
228    pub fn on_submit<F: FnMut(&str) + 'static>(mut self, handler: F) -> Self {
229        self.on_submit = Some(Box::new(handler));
230        self
231    }
232
233    /// Install an accepted-charset predicate (evaluated after the ASCII gate).
234    pub fn with_accept<F: Fn(char) -> bool + 'static>(mut self, accept: F) -> Self {
235        self.core.accept = Some(Box::new(accept) as AcceptFn);
236        self
237    }
238
239    /// Cap the buffer at `max` characters.
240    pub fn with_max_len(mut self, max: usize) -> Self {
241        self.core.max_len = Some(max);
242        self
243    }
244
245    // ── Keyboard binding (§5.I) ───────────────────────────────────────────────
246
247    /// Apply a [`KeyOutput`] value produced by a [`Keyboard`] widget.
248    ///
249    /// Mapping (LPAR-14 §5.I):
250    ///
251    /// | `KeyOutput` | one-line=false | one-line=true |
252    /// |---|---|---|
253    /// | `Char(c)` | `try_insert(c)` | `try_insert(c)` |
254    /// | `Backspace` | `try_backspace()` | `try_backspace()` |
255    /// | `Enter` | `try_insert('\n')` | fire `on_submit`, no mutation |
256    /// | `Tab` | ignored | ignored |
257    /// | `Escape` | deactivate | deactivate |
258    /// | `Control(_)` | ignored (mode handled by Keyboard) | same |
259    pub fn apply_key_output(&mut self, ko: KeyOutput) {
260        match ko {
261            KeyOutput::Char(c) => {
262                self.core.try_insert(c);
263                self.clamp_scroll();
264            }
265            KeyOutput::Backspace => {
266                self.core.try_backspace();
267                self.clamp_scroll();
268            }
269            KeyOutput::Enter => {
270                if self.one_line {
271                    if let Some(cb) = self.on_submit.as_mut() {
272                        cb(&self.core.buffer);
273                    }
274                } else {
275                    self.core.try_insert('\n');
276                    self.clamp_scroll();
277                }
278            }
279            KeyOutput::Escape => {
280                self.core.active = false;
281            }
282            // Tab and Control are silently ignored at v1.
283            KeyOutput::Tab | KeyOutput::Control(_) => {}
284        }
285    }
286
287    // ── Scroll ────────────────────────────────────────────────────────────────
288
289    /// Return the current vertical scroll offset in pixels.
290    pub fn scroll_offset_y(&self) -> i32 {
291        self.scroll_offset_y
292    }
293
294    /// Clamp `scroll_offset_y` so the caret remains visible inside `bounds`.
295    fn clamp_scroll(&mut self) {
296        let line_h = self.core.line_height;
297        let visible_h = self.bounds.height;
298        let (row, _) = self.core.caret_row_col();
299        let caret_top = row * line_h;
300        let caret_bottom = caret_top + line_h;
301
302        // Scroll down if caret is below the visible area.
303        if caret_bottom > self.scroll_offset_y + visible_h {
304            self.scroll_offset_y = caret_bottom - visible_h;
305        }
306        // Scroll up if caret is above the visible area.
307        if caret_top < self.scroll_offset_y {
308            self.scroll_offset_y = caret_top;
309        }
310        // Never scroll past the top.
311        if self.scroll_offset_y < 0 {
312            self.scroll_offset_y = 0;
313        }
314    }
315
316    // ── Rendering helpers ─────────────────────────────────────────────────────
317
318    /// Return the display string: either the real buffer or a password-masked
319    /// version of the same length.
320    fn display_text(&self) -> String {
321        if self.password_mode {
322            self.core.buffer.chars().map(|_| PASSWORD_GLYPH).collect()
323        } else {
324            self.core.buffer.clone()
325        }
326    }
327
328    /// Draw shaped text for a single line into `renderer`, clipped to `clip`.
329    fn draw_line(&self, renderer: &mut dyn Renderer, line: &str, baseline: i32, clip: Rect) {
330        if line.is_empty() {
331            return;
332        }
333        let font = self.font.resolve();
334        let shaped = shape_text_ltr(font, line, (self.bounds.x, baseline), 0);
335        let mut clip_r = ClipRenderer::new(renderer, clip);
336        clip_r.draw_text_shaped(&shaped, (0, 0), self.text_color);
337    }
338
339    /// Draw the caret line if the field is active.
340    fn draw_caret(&self, renderer: &mut dyn Renderer) {
341        if !self.core.active {
342            return;
343        }
344        let (row, col) = self.core.caret_row_col();
345        let x = self.bounds.x + col * self.core.char_width;
346        let y = self.bounds.y + row * self.core.line_height - self.scroll_offset_y;
347        // Only draw caret when it is inside the visible clip.
348        if y + self.core.line_height <= self.bounds.y || y >= self.bounds.y + self.bounds.height {
349            return;
350        }
351        renderer.fill_rect(
352            Rect {
353                x,
354                y,
355                width: CARET_WIDTH,
356                height: self.core.line_height,
357            },
358            self.style.border_color,
359        );
360    }
361}
362
363impl Widget for Textarea {
364    fn bounds(&self) -> Rect {
365        self.bounds
366    }
367
368    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
369        Some(&mut self.font)
370    }
371
372    fn set_bounds(&mut self, new_bounds: Rect) {
373        self.bounds = new_bounds;
374        self.core.bounds = new_bounds;
375        // Reflow: clamp scroll after bounds change.
376        self.clamp_scroll();
377    }
378
379    fn draw(&self, renderer: &mut dyn Renderer) {
380        draw_widget_bg(renderer, self.bounds, &self.style);
381
382        let clip = self.bounds;
383        let buf = self.display_text();
384        let font = self.font.resolve();
385
386        // Placeholder path: show placeholder when buffer is empty and inactive.
387        if buf.is_empty() && !self.core.active {
388            if let Some(ph) = self.placeholder.as_deref() {
389                let ph_color = Color(
390                    self.text_color.0,
391                    self.text_color.1,
392                    self.text_color.2,
393                    self.text_color.3 / 2,
394                );
395                let lm = font.line_metrics();
396                let baseline = self.bounds.y + lm.ascent as i32 - self.scroll_offset_y;
397                let shaped = shape_text_ltr(font, ph, (self.bounds.x, baseline), 0);
398                let mut clip_r = ClipRenderer::new(renderer, clip);
399                clip_r.draw_text_shaped(&shaped, (0, 0), ph_color);
400            }
401            return;
402        }
403
404        if self.one_line {
405            // Single-line: draw the buffer as one unwrapped line.
406            let lm = font.line_metrics();
407            let baseline = self.bounds.y + lm.ascent as i32 - self.scroll_offset_y;
408            self.draw_line(renderer, &buf, baseline, clip);
409        } else {
410            // Multi-line: use the LPAR-08 greedy wrap.
411            let lm = font.line_metrics();
412            let line_h = lm.line_height as i32;
413            let wrapped = wrap_greedy_ltr(font, &buf, self.bounds.width, 0, 0);
414            for (i, wl) in wrapped.lines.iter().enumerate() {
415                let top = i as i32 * line_h - self.scroll_offset_y;
416                // Skip lines that are entirely outside the visible area.
417                if top + line_h <= 0 || top >= self.bounds.height {
418                    continue;
419                }
420                let baseline = self.bounds.y + top + lm.ascent as i32;
421                let segment = &buf[wl.start..wl.end];
422                self.draw_line(renderer, segment, baseline, clip);
423            }
424        }
425
426        self.draw_caret(renderer);
427    }
428
429    fn handle_event(&mut self, event: &Event) -> bool {
430        if !self.core.active {
431            return false;
432        }
433        if let Event::KeyDown { key } = event {
434            match key {
435                Key::Enter => {
436                    if self.one_line {
437                        if let Some(cb) = self.on_submit.as_mut() {
438                            cb(&self.core.buffer);
439                        }
440                    } else {
441                        self.core.try_insert('\n');
442                        self.clamp_scroll();
443                    }
444                    return true;
445                }
446                other => {
447                    let consumed = self.core.handle_key(other);
448                    if consumed {
449                        self.clamp_scroll();
450                    }
451                    return consumed;
452                }
453            }
454        }
455        false
456    }
457}
458
459// ---------------------------------------------------------------------------
460// Tests
461// ---------------------------------------------------------------------------
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use rlvgl_core::widget::Rect;
467
468    const BOUNDS: Rect = Rect {
469        x: 0,
470        y: 0,
471        width: 200,
472        height: 100,
473    };
474
475    fn narrow() -> Rect {
476        // Width just fits ~3 chars of FONT_6X10 (advance ≈ 6 px each).
477        Rect {
478            x: 0,
479            y: 0,
480            width: 24,
481            height: 200,
482        }
483    }
484
485    // ── apply_key_output ─────────────────────────────────────────────────────
486
487    #[test]
488    fn apply_char_inserts_into_buffer() {
489        let mut ta = Textarea::new(BOUNDS);
490        ta.set_active(true);
491        ta.apply_key_output(KeyOutput::Char('H'));
492        ta.apply_key_output(KeyOutput::Char('i'));
493        assert_eq!(ta.text(), "Hi");
494        assert_eq!(ta.caret(), 2);
495    }
496
497    #[test]
498    fn apply_backspace_removes_last_char() {
499        let mut ta = Textarea::new(BOUNDS);
500        ta.set_active(true);
501        ta.apply_key_output(KeyOutput::Char('X'));
502        ta.apply_key_output(KeyOutput::Backspace);
503        assert_eq!(ta.text(), "");
504        assert_eq!(ta.caret(), 0);
505    }
506
507    #[test]
508    fn apply_enter_inserts_newline_in_multiline_mode() {
509        let mut ta = Textarea::new(BOUNDS);
510        ta.set_active(true);
511        ta.apply_key_output(KeyOutput::Char('A'));
512        ta.apply_key_output(KeyOutput::Enter);
513        ta.apply_key_output(KeyOutput::Char('B'));
514        assert_eq!(ta.text(), "A\nB");
515    }
516
517    #[test]
518    fn apply_enter_fires_submit_in_one_line_mode() {
519        use alloc::rc::Rc;
520        use alloc::string::String;
521        use alloc::vec::Vec;
522        use core::cell::RefCell;
523
524        let log: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
525        let log2 = log.clone();
526        let mut ta =
527            Textarea::new(BOUNDS).on_submit(move |s| log2.borrow_mut().push(String::from(s)));
528        ta.set_one_line(true);
529        ta.set_active(true);
530        ta.apply_key_output(KeyOutput::Char('O'));
531        ta.apply_key_output(KeyOutput::Char('K'));
532        ta.apply_key_output(KeyOutput::Enter);
533        assert_eq!(*log.borrow(), alloc::vec!["OK"]);
534        // Buffer is not mutated by Enter in one-line mode.
535        assert_eq!(ta.text(), "OK");
536    }
537
538    #[test]
539    fn apply_escape_deactivates() {
540        let mut ta = Textarea::new(BOUNDS);
541        ta.set_active(true);
542        ta.apply_key_output(KeyOutput::Escape);
543        assert!(!ta.is_active());
544    }
545
546    #[test]
547    fn apply_tab_and_control_are_ignored() {
548        let mut ta = Textarea::new(BOUNDS);
549        ta.set_active(true);
550        ta.apply_key_output(KeyOutput::Tab);
551        assert_eq!(ta.text(), "");
552        ta.apply_key_output(KeyOutput::Control(
553            crate::keyboard::KeyboardControl::SwitchMode(crate::keyboard::KeyboardMode::Number),
554        ));
555        assert_eq!(ta.text(), "");
556    }
557
558    // ── Placeholder ───────────────────────────────────────────────────────────
559
560    #[test]
561    fn placeholder_set_and_clear() {
562        let mut ta = Textarea::new(BOUNDS);
563        ta.set_placeholder("hint");
564        assert_eq!(ta.placeholder(), Some("hint"));
565        ta.set_placeholder("");
566        assert_eq!(ta.placeholder(), None);
567    }
568
569    // ── Password mode ─────────────────────────────────────────────────────────
570
571    #[test]
572    fn password_mode_masks_display_text() {
573        let mut ta = Textarea::new(BOUNDS);
574        ta.set_password_mode(true);
575        ta.set_text("secret");
576        assert_eq!(ta.text(), "secret");
577        assert_eq!(ta.display_text(), "******");
578    }
579
580    // ── One-line mode ─────────────────────────────────────────────────────────
581
582    #[test]
583    fn one_line_prevents_newline_insertion() {
584        let mut ta = Textarea::new(BOUNDS);
585        ta.set_one_line(true);
586        ta.set_active(true);
587        ta.handle_event(&Event::KeyDown { key: Key::Enter });
588        // Enter should NOT insert '\n' in one-line mode.
589        assert_eq!(ta.text(), "");
590    }
591
592    // ── Wrapping / scroll ─────────────────────────────────────────────────────
593
594    #[test]
595    fn wrap_reflow_on_narrow_bounds() {
596        let mut ta = Textarea::new(narrow());
597        ta.set_active(true);
598        // Fill enough text to force wrapping.
599        ta.set_text("abcdefghij");
600        // The wrap engine must produce multiple lines for a 24 px wide box
601        // with FONT_6X10 (≈6 px/char → 4 chars/line max).
602        let wrapped = wrap_greedy_ltr(&FONT_6X10, ta.text(), 24, 0, 0);
603        assert!(
604            wrapped.lines.len() > 1,
605            "expected wrapping for narrow bounds, got {} lines",
606            wrapped.lines.len()
607        );
608    }
609
610    #[test]
611    fn scroll_advances_on_overflow() {
612        let bounds = Rect {
613            x: 0,
614            y: 0,
615            width: 200,
616            height: 32, // only fits 2 lines of FONT_6X10 (16 px each)
617        };
618        let mut ta = Textarea::new(bounds);
619        ta.set_active(true);
620        // Force 3+ lines worth of text via hard newlines.
621        for _ in 0..4 {
622            ta.apply_key_output(KeyOutput::Char('A'));
623            ta.apply_key_output(KeyOutput::Enter);
624        }
625        assert!(
626            ta.scroll_offset_y() > 0,
627            "expected positive scroll, got {}",
628            ta.scroll_offset_y()
629        );
630    }
631}