1use 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
44type SubmitCallback = Box<dyn FnMut(&str)>;
46
47const PASSWORD_GLYPH: char = '*';
49
50pub struct Textarea {
69 core: EditCore,
71 bounds: Rect,
74 placeholder: Option<String>,
76 password_mode: bool,
79 one_line: bool,
82 scroll_offset_y: i32,
84 on_submit: Option<SubmitCallback>,
87 pub style: Style,
89 pub text_color: Color,
91 font: WidgetFont,
94}
95
96impl Textarea {
97 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 pub fn set_text(&mut self, text: &str) {
120 self.core.set_text(text);
121 self.clamp_scroll();
122 }
123
124 pub fn text(&self) -> &str {
126 &self.core.buffer
127 }
128
129 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 pub fn placeholder(&self) -> Option<&str> {
143 self.placeholder.as_deref()
144 }
145
146 pub fn set_password_mode(&mut self, enable: bool) {
153 self.password_mode = enable;
154 }
155
156 pub fn password_mode(&self) -> bool {
158 self.password_mode
159 }
160
161 pub fn set_one_line(&mut self, enable: bool) {
169 self.one_line = enable;
170 self.core.multi_line = !enable;
172 }
173
174 pub fn one_line(&self) -> bool {
176 self.one_line
177 }
178
179 pub fn is_active(&self) -> bool {
183 self.core.active
184 }
185
186 pub fn set_active(&mut self, active: bool) {
189 self.core.active = active;
190 }
191
192 pub fn caret(&self) -> usize {
196 self.core.caret
197 }
198
199 pub fn widget_style(&self) -> &Style {
203 &self.style
204 }
205
206 pub fn widget_style_mut(&mut self) -> &mut Style {
208 &mut self.style
209 }
210
211 pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
214 self.font.set(font);
215 }
216
217 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 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 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 pub fn with_max_len(mut self, max: usize) -> Self {
241 self.core.max_len = Some(max);
242 self
243 }
244
245 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 KeyOutput::Tab | KeyOutput::Control(_) => {}
284 }
285 }
286
287 pub fn scroll_offset_y(&self) -> i32 {
291 self.scroll_offset_y
292 }
293
294 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 if caret_bottom > self.scroll_offset_y + visible_h {
304 self.scroll_offset_y = caret_bottom - visible_h;
305 }
306 if caret_top < self.scroll_offset_y {
308 self.scroll_offset_y = caret_top;
309 }
310 if self.scroll_offset_y < 0 {
312 self.scroll_offset_y = 0;
313 }
314 }
315
316 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 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 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 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 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 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 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 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 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#[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 Rect {
478 x: 0,
479 y: 0,
480 width: 24,
481 height: 200,
482 }
483 }
484
485 #[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 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 #[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 #[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 #[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 assert_eq!(ta.text(), "");
590 }
591
592 #[test]
595 fn wrap_reflow_on_narrow_bounds() {
596 let mut ta = Textarea::new(narrow());
597 ta.set_active(true);
598 ta.set_text("abcdefghij");
600 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, };
618 let mut ta = Textarea::new(bounds);
619 ta.set_active(true);
620 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}