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}