Skip to main content

rlvgl_core/
edit.rs

1// SPDX-License-Identifier: MIT
2//! Shared edit-state machine promoted from `rlvgl-ui` (LPAR-14 §5.C).
3//!
4//! [`EditCore`] holds the edit buffer, caret, and mutation gates that are
5//! shared between single-line [`Input`](rlvgl_ui::input::Input), the
6//! `ui`-layer [`Textarea`](rlvgl_ui::input::Textarea), and the new
7//! `widgets`-layer [`Textarea`](crate::edit) (LPAR-14).
8//!
9//! The promotion from `pub(crate)` in `rlvgl-ui` to `pub` in `rlvgl-core`
10//! breaks the crate-cycle that would arise if `rlvgl-widgets::textarea`
11//! depended on `rlvgl-ui` (which itself depends on `rlvgl-widgets`).
12//!
13//! # WID-00 compatibility guarantee
14//!
15//! Every method on [`EditCore`] preserves its WID-00 / WID-01 contract.  No
16//! public behaviour changes are permitted without a ratified LPAR chapter.
17
18use alloc::boxed::Box;
19use alloc::string::String;
20
21use crate::widget::Rect;
22
23// Re-exported so that crates that previously used constants from `rlvgl_ui::input`
24// can import them from either location.
25
26/// Default nominal character advance for caret geometry (WID-00 §6.3).
27pub const DEFAULT_CHAR_WIDTH: i32 = 8;
28/// Default nominal line height for caret geometry (WID-00 §6.3).
29pub const DEFAULT_LINE_HEIGHT: i32 = 16;
30/// Caret thickness in pixels.
31pub const CARET_WIDTH: i32 = 2;
32
33/// Callback type invoked when the edit buffer changes.
34pub type ChangeCallback = Box<dyn FnMut(&str)>;
35/// Accepted-charset predicate (evaluated after the printable-ASCII gate).
36pub type AcceptFn = Box<dyn Fn(char) -> bool>;
37
38/// Shared edit-state machine for single-line and multi-line text fields
39/// (WID-00 §5; LPAR-14 §5.C).
40///
41/// `EditCore` owns the edit buffer, caret position, and the optional
42/// mutation gates (`max_len`, `accept`, `on_change`).  Rendering helpers
43/// that need glyph geometry or a [`Label`] remain in the widget wrappers
44/// that embed this struct.
45///
46/// # Buffer and caret invariants
47///
48/// * The buffer may contain arbitrary Unicode inserted via [`set_text`].
49/// * Interactive insertion via [`try_insert`] is limited to printable ASCII
50///   (`0x20..=0x7E`) plus `'\n'` on multi-line fields (WID-00 §5.2).
51/// * `caret` is a **char** index (`0..=char_count()`).  [`byte_index`]
52///   converts it to a byte offset for safe `String` mutation.
53///
54/// [`set_text`]: EditCore::set_text
55/// [`try_insert`]: EditCore::try_insert
56/// [`byte_index`]: EditCore::byte_index
57pub struct EditCore {
58    /// Text bounds used for caret geometry and derived rendering.
59    ///
60    /// Widgets that embed `EditCore` expose `set_bounds` on themselves and
61    /// forward the update here.
62    pub bounds: Rect,
63    /// Edit buffer.
64    ///
65    /// ASCII-bounded for keyboard input so byte index == char index in
66    /// practice; stored as `String` so a programmatic `set_text` can hold
67    /// arbitrary Unicode without corrupting subsequent edits.
68    pub buffer: String,
69    /// Caret position as a char index in `0..=char_count()`.
70    pub caret: usize,
71    /// Whether this field is currently consuming keyboard events.
72    pub active: bool,
73    /// Whether Enter inserts a newline (`true`) or signals submit (`false`).
74    pub multi_line: bool,
75    /// Optional cap on the number of characters in the buffer.
76    pub max_len: Option<usize>,
77    /// Optional accepted-charset predicate (applied after the ASCII gate).
78    pub accept: Option<AcceptFn>,
79    /// Optional change callback invoked after every committed edit.
80    pub on_change: Option<ChangeCallback>,
81    /// Nominal character advance used for caret geometry (pixels).
82    pub char_width: i32,
83    /// Nominal line height used for caret geometry and line pitch (pixels).
84    pub line_height: i32,
85}
86
87impl EditCore {
88    /// Create an `EditCore` with `text` pre-loaded, caret at the end.
89    ///
90    /// `multi_line` controls whether Enter inserts a newline.
91    pub fn new(text: &str, bounds: Rect, multi_line: bool) -> Self {
92        let buffer = String::from(text);
93        let caret = buffer.chars().count();
94        Self {
95            bounds,
96            buffer,
97            caret,
98            active: false,
99            multi_line,
100            max_len: None,
101            accept: None,
102            on_change: None,
103            char_width: DEFAULT_CHAR_WIDTH,
104            line_height: DEFAULT_LINE_HEIGHT,
105        }
106    }
107
108    /// Replace the buffer with `text`, clamp the caret, fire `on_change`.
109    ///
110    /// This is the programmatic path; it bypasses the ASCII gate and
111    /// `accept` predicate.
112    pub fn set_text(&mut self, text: &str) {
113        self.buffer = String::from(text);
114        self.caret = self.caret.min(self.buffer.chars().count());
115        if let Some(cb) = self.on_change.as_mut() {
116            cb(&self.buffer);
117        }
118    }
119
120    /// Sync the label/display state and fire `on_change` after a committed
121    /// edit (WID-00 §5.1).
122    ///
123    /// Called internally by [`try_insert`] and [`try_backspace`]; also
124    /// available for widget wrappers that perform their own buffer mutations.
125    ///
126    /// [`try_insert`]: Self::try_insert
127    /// [`try_backspace`]: Self::try_backspace
128    pub fn committed(&mut self) {
129        if let Some(cb) = self.on_change.as_mut() {
130            cb(&self.buffer);
131        }
132    }
133
134    /// Convert a char index to a byte offset in [`Self::buffer`].
135    ///
136    /// Safe for any char index in `0..=char_count()`; returns
137    /// `buffer.len()` for out-of-range values.
138    pub fn byte_index(&self, char_index: usize) -> usize {
139        self.buffer
140            .char_indices()
141            .nth(char_index)
142            .map(|(i, _)| i)
143            .unwrap_or(self.buffer.len())
144    }
145
146    /// Return the number of chars currently in the buffer.
147    pub fn char_count(&self) -> usize {
148        self.buffer.chars().count()
149    }
150
151    /// Attempt to insert `c` at the caret.
152    ///
153    /// Returns `true` when the edit was applied.  Failed insertions leave
154    /// the buffer and caret untouched and do **not** fire `on_change`
155    /// (WID-00 §5.1).
156    ///
157    /// Gate order:
158    /// 1. ASCII printable bound (`0x20..=0x7E`) plus `'\n'` on multi-line.
159    /// 2. `accept` predicate (skipped for `'\n'`).
160    /// 3. `max_len` cap.
161    pub fn try_insert(&mut self, c: char) -> bool {
162        let printable = ('\u{20}'..='\u{7e}').contains(&c);
163        if !(printable || (c == '\n' && self.multi_line)) {
164            return false;
165        }
166        if c != '\n'
167            && let Some(accept) = self.accept.as_ref()
168            && !accept(c)
169        {
170            return false;
171        }
172        if let Some(max) = self.max_len
173            && self.char_count() >= max
174        {
175            return false;
176        }
177        let at = self.byte_index(self.caret);
178        self.buffer.insert(at, c);
179        self.caret += 1;
180        self.committed();
181        true
182    }
183
184    /// Delete the character before the caret.
185    ///
186    /// Returns `true` if a character was removed.  No-ops silently at
187    /// position 0 (WID-00 §5.1).
188    pub fn try_backspace(&mut self) -> bool {
189        if self.caret == 0 {
190            return false;
191        }
192        let at = self.byte_index(self.caret - 1);
193        self.buffer.remove(at);
194        self.caret -= 1;
195        self.committed();
196        true
197    }
198
199    /// Handle a raw key event while the field is active.
200    ///
201    /// Returns `true` when the key was consumed.  `Enter` is **not**
202    /// handled here — its semantics differ between single-line (`Input`
203    /// fires submit) and multi-line (`Textarea` inserts newline), so the
204    /// wrapper is responsible (WID-00 §5.3).
205    pub fn handle_key(&mut self, key: &crate::event::Key) -> bool {
206        use crate::event::Key;
207        match key {
208            Key::Character(c) => {
209                self.try_insert(*c);
210                true // consumed even when rejected: the key targeted us
211            }
212            Key::Space => {
213                self.try_insert(' ');
214                true
215            }
216            Key::Backspace => {
217                self.try_backspace();
218                true
219            }
220            Key::ArrowLeft => {
221                self.caret = self.caret.saturating_sub(1);
222                true
223            }
224            Key::ArrowRight => {
225                self.caret = (self.caret + 1).min(self.char_count());
226                true
227            }
228            _ => false,
229        }
230    }
231
232    /// Return the `(row, col)` of the caret in `'\n'`-split line space.
233    ///
234    /// Row 0 is the first line; col 0 is the start of a line.
235    pub fn caret_row_col(&self) -> (i32, i32) {
236        let mut row = 0i32;
237        let mut col = 0i32;
238        for c in self.buffer.chars().take(self.caret) {
239            if c == '\n' {
240                row += 1;
241                col = 0;
242            } else {
243                col += 1;
244            }
245        }
246        (row, col)
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Tests
252// ---------------------------------------------------------------------------
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::event::Key;
258    use crate::widget::Rect;
259
260    const BOUNDS: Rect = Rect {
261        x: 0,
262        y: 0,
263        width: 200,
264        height: 100,
265    };
266
267    fn core(multi: bool) -> EditCore {
268        EditCore::new("", BOUNDS, multi)
269    }
270
271    #[test]
272    fn insert_printable_ascii() {
273        let mut ec = core(false);
274        ec.active = true;
275        assert!(ec.try_insert('A'));
276        assert_eq!(ec.buffer, "A");
277        assert_eq!(ec.caret, 1);
278    }
279
280    #[test]
281    fn insert_newline_only_on_multiline() {
282        let mut single = core(false);
283        let mut multi = core(true);
284        assert!(!single.try_insert('\n'));
285        assert!(multi.try_insert('\n'));
286    }
287
288    #[test]
289    fn backspace_removes_char_and_decrements_caret() {
290        let mut ec = core(false);
291        ec.try_insert('X');
292        assert!(ec.try_backspace());
293        assert_eq!(ec.buffer, "");
294        assert_eq!(ec.caret, 0);
295        // no-op at position 0
296        assert!(!ec.try_backspace());
297    }
298
299    #[test]
300    fn handle_key_arrow_navigation() {
301        let mut ec = core(false);
302        ec.try_insert('A');
303        ec.try_insert('B');
304        ec.handle_key(&Key::ArrowLeft);
305        assert_eq!(ec.caret, 1);
306        ec.handle_key(&Key::ArrowLeft);
307        assert_eq!(ec.caret, 0);
308        // clamp at 0
309        ec.handle_key(&Key::ArrowLeft);
310        assert_eq!(ec.caret, 0);
311        ec.handle_key(&Key::ArrowRight);
312        assert_eq!(ec.caret, 1);
313        ec.handle_key(&Key::ArrowRight);
314        ec.handle_key(&Key::ArrowRight);
315        assert_eq!(ec.caret, 2, "clamped at end");
316    }
317
318    #[test]
319    fn max_len_gate() {
320        let mut ec = core(false);
321        ec.max_len = Some(2);
322        ec.try_insert('A');
323        ec.try_insert('B');
324        assert!(!ec.try_insert('C'));
325        assert_eq!(ec.buffer, "AB");
326    }
327
328    #[test]
329    fn accept_gate() {
330        let mut ec = core(false);
331        ec.accept = Some(Box::new(|c: char| c.is_ascii_digit()));
332        assert!(ec.try_insert('5'));
333        assert!(!ec.try_insert('x'));
334        assert_eq!(ec.buffer, "5");
335    }
336
337    #[test]
338    fn set_text_bypasses_ascii_gate_and_fires_callback() {
339        use alloc::rc::Rc;
340        use alloc::string::String;
341        use alloc::vec::Vec;
342        use core::cell::RefCell;
343        let log: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
344        let log2 = log.clone();
345        let mut ec = core(false);
346        ec.on_change = Some(Box::new(move |s| log2.borrow_mut().push(String::from(s))));
347        ec.set_text("héllo");
348        assert_eq!(ec.buffer, "héllo");
349        assert_eq!(log.borrow()[0], "héllo");
350    }
351
352    #[test]
353    fn caret_row_col_multiline() {
354        let mut ec = core(true);
355        ec.try_insert('A');
356        ec.try_insert('B');
357        ec.try_insert('\n');
358        ec.try_insert('C');
359        assert_eq!(ec.caret_row_col(), (1, 1));
360    }
361
362    #[test]
363    fn byte_index_is_char_aware() {
364        let mut ec = core(false);
365        // Insert via set_text to bypass ASCII gate
366        ec.set_text("abc");
367        assert_eq!(ec.byte_index(0), 0);
368        assert_eq!(ec.byte_index(1), 1);
369        assert_eq!(ec.byte_index(3), 3);
370    }
371}