use alloc::boxed::Box;
use alloc::string::String;
#[cfg(test)]
use rlvgl_core::bitmap_font::FONT_6X10;
use rlvgl_core::draw::draw_widget_bg;
use rlvgl_core::edit::{AcceptFn, CARET_WIDTH, ChangeCallback, EditCore};
use rlvgl_core::event::{Event, Key};
use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr, wrap_greedy_ltr};
use rlvgl_core::renderer::{ClipRenderer, Renderer};
use rlvgl_core::style::Style;
use rlvgl_core::widget::{Color, Rect, Widget};
use crate::keyboard::KeyOutput;
type SubmitCallback = Box<dyn FnMut(&str)>;
const PASSWORD_GLYPH: char = '*';
pub struct Textarea {
core: EditCore,
bounds: Rect,
placeholder: Option<String>,
password_mode: bool,
one_line: bool,
scroll_offset_y: i32,
on_submit: Option<SubmitCallback>,
pub style: Style,
pub text_color: Color,
font: WidgetFont,
}
impl Textarea {
pub fn new(bounds: Rect) -> Self {
Self {
core: EditCore::new("", bounds, true),
bounds,
placeholder: None,
password_mode: false,
one_line: false,
scroll_offset_y: 0,
on_submit: None,
style: Style::default(),
text_color: Color(0, 0, 0, 255),
font: WidgetFont::new(),
}
}
pub fn set_text(&mut self, text: &str) {
self.core.set_text(text);
self.clamp_scroll();
}
pub fn text(&self) -> &str {
&self.core.buffer
}
pub fn set_placeholder(&mut self, text: &str) {
self.placeholder = if text.is_empty() {
None
} else {
Some(String::from(text))
};
}
pub fn placeholder(&self) -> Option<&str> {
self.placeholder.as_deref()
}
pub fn set_password_mode(&mut self, enable: bool) {
self.password_mode = enable;
}
pub fn password_mode(&self) -> bool {
self.password_mode
}
pub fn set_one_line(&mut self, enable: bool) {
self.one_line = enable;
self.core.multi_line = !enable;
}
pub fn one_line(&self) -> bool {
self.one_line
}
pub fn is_active(&self) -> bool {
self.core.active
}
pub fn set_active(&mut self, active: bool) {
self.core.active = active;
}
pub fn caret(&self) -> usize {
self.core.caret
}
pub fn widget_style(&self) -> &Style {
&self.style
}
pub fn widget_style_mut(&mut self) -> &mut Style {
&mut self.style
}
pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
self.font.set(font);
}
pub fn on_change<F: FnMut(&str) + 'static>(mut self, handler: F) -> Self {
self.core.on_change = Some(Box::new(handler) as ChangeCallback);
self
}
pub fn on_submit<F: FnMut(&str) + 'static>(mut self, handler: F) -> Self {
self.on_submit = Some(Box::new(handler));
self
}
pub fn with_accept<F: Fn(char) -> bool + 'static>(mut self, accept: F) -> Self {
self.core.accept = Some(Box::new(accept) as AcceptFn);
self
}
pub fn with_max_len(mut self, max: usize) -> Self {
self.core.max_len = Some(max);
self
}
pub fn apply_key_output(&mut self, ko: KeyOutput) {
match ko {
KeyOutput::Char(c) => {
self.core.try_insert(c);
self.clamp_scroll();
}
KeyOutput::Backspace => {
self.core.try_backspace();
self.clamp_scroll();
}
KeyOutput::Enter => {
if self.one_line {
if let Some(cb) = self.on_submit.as_mut() {
cb(&self.core.buffer);
}
} else {
self.core.try_insert('\n');
self.clamp_scroll();
}
}
KeyOutput::Escape => {
self.core.active = false;
}
KeyOutput::Tab | KeyOutput::Control(_) => {}
}
}
pub fn scroll_offset_y(&self) -> i32 {
self.scroll_offset_y
}
fn clamp_scroll(&mut self) {
let line_h = self.core.line_height;
let visible_h = self.bounds.height;
let (row, _) = self.core.caret_row_col();
let caret_top = row * line_h;
let caret_bottom = caret_top + line_h;
if caret_bottom > self.scroll_offset_y + visible_h {
self.scroll_offset_y = caret_bottom - visible_h;
}
if caret_top < self.scroll_offset_y {
self.scroll_offset_y = caret_top;
}
if self.scroll_offset_y < 0 {
self.scroll_offset_y = 0;
}
}
fn display_text(&self) -> String {
if self.password_mode {
self.core.buffer.chars().map(|_| PASSWORD_GLYPH).collect()
} else {
self.core.buffer.clone()
}
}
fn draw_line(&self, renderer: &mut dyn Renderer, line: &str, baseline: i32, clip: Rect) {
if line.is_empty() {
return;
}
let font = self.font.resolve();
let shaped = shape_text_ltr(font, line, (self.bounds.x, baseline), 0);
let mut clip_r = ClipRenderer::new(renderer, clip);
clip_r.draw_text_shaped(&shaped, (0, 0), self.text_color);
}
fn draw_caret(&self, renderer: &mut dyn Renderer) {
if !self.core.active {
return;
}
let (row, col) = self.core.caret_row_col();
let x = self.bounds.x + col * self.core.char_width;
let y = self.bounds.y + row * self.core.line_height - self.scroll_offset_y;
if y + self.core.line_height <= self.bounds.y || y >= self.bounds.y + self.bounds.height {
return;
}
renderer.fill_rect(
Rect {
x,
y,
width: CARET_WIDTH,
height: self.core.line_height,
},
self.style.border_color,
);
}
}
impl Widget for Textarea {
fn bounds(&self) -> Rect {
self.bounds
}
fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
Some(&mut self.font)
}
fn set_bounds(&mut self, new_bounds: Rect) {
self.bounds = new_bounds;
self.core.bounds = new_bounds;
self.clamp_scroll();
}
fn draw(&self, renderer: &mut dyn Renderer) {
draw_widget_bg(renderer, self.bounds, &self.style);
let clip = self.bounds;
let buf = self.display_text();
let font = self.font.resolve();
if buf.is_empty() && !self.core.active {
if let Some(ph) = self.placeholder.as_deref() {
let ph_color = Color(
self.text_color.0,
self.text_color.1,
self.text_color.2,
self.text_color.3 / 2,
);
let lm = font.line_metrics();
let baseline = self.bounds.y + lm.ascent as i32 - self.scroll_offset_y;
let shaped = shape_text_ltr(font, ph, (self.bounds.x, baseline), 0);
let mut clip_r = ClipRenderer::new(renderer, clip);
clip_r.draw_text_shaped(&shaped, (0, 0), ph_color);
}
return;
}
if self.one_line {
let lm = font.line_metrics();
let baseline = self.bounds.y + lm.ascent as i32 - self.scroll_offset_y;
self.draw_line(renderer, &buf, baseline, clip);
} else {
let lm = font.line_metrics();
let line_h = lm.line_height as i32;
let wrapped = wrap_greedy_ltr(font, &buf, self.bounds.width, 0, 0);
for (i, wl) in wrapped.lines.iter().enumerate() {
let top = i as i32 * line_h - self.scroll_offset_y;
if top + line_h <= 0 || top >= self.bounds.height {
continue;
}
let baseline = self.bounds.y + top + lm.ascent as i32;
let segment = &buf[wl.start..wl.end];
self.draw_line(renderer, segment, baseline, clip);
}
}
self.draw_caret(renderer);
}
fn handle_event(&mut self, event: &Event) -> bool {
if !self.core.active {
return false;
}
if let Event::KeyDown { key } = event {
match key {
Key::Enter => {
if self.one_line {
if let Some(cb) = self.on_submit.as_mut() {
cb(&self.core.buffer);
}
} else {
self.core.try_insert('\n');
self.clamp_scroll();
}
return true;
}
other => {
let consumed = self.core.handle_key(other);
if consumed {
self.clamp_scroll();
}
return consumed;
}
}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use rlvgl_core::widget::Rect;
const BOUNDS: Rect = Rect {
x: 0,
y: 0,
width: 200,
height: 100,
};
fn narrow() -> Rect {
Rect {
x: 0,
y: 0,
width: 24,
height: 200,
}
}
#[test]
fn apply_char_inserts_into_buffer() {
let mut ta = Textarea::new(BOUNDS);
ta.set_active(true);
ta.apply_key_output(KeyOutput::Char('H'));
ta.apply_key_output(KeyOutput::Char('i'));
assert_eq!(ta.text(), "Hi");
assert_eq!(ta.caret(), 2);
}
#[test]
fn apply_backspace_removes_last_char() {
let mut ta = Textarea::new(BOUNDS);
ta.set_active(true);
ta.apply_key_output(KeyOutput::Char('X'));
ta.apply_key_output(KeyOutput::Backspace);
assert_eq!(ta.text(), "");
assert_eq!(ta.caret(), 0);
}
#[test]
fn apply_enter_inserts_newline_in_multiline_mode() {
let mut ta = Textarea::new(BOUNDS);
ta.set_active(true);
ta.apply_key_output(KeyOutput::Char('A'));
ta.apply_key_output(KeyOutput::Enter);
ta.apply_key_output(KeyOutput::Char('B'));
assert_eq!(ta.text(), "A\nB");
}
#[test]
fn apply_enter_fires_submit_in_one_line_mode() {
use alloc::rc::Rc;
use alloc::string::String;
use alloc::vec::Vec;
use core::cell::RefCell;
let log: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
let log2 = log.clone();
let mut ta =
Textarea::new(BOUNDS).on_submit(move |s| log2.borrow_mut().push(String::from(s)));
ta.set_one_line(true);
ta.set_active(true);
ta.apply_key_output(KeyOutput::Char('O'));
ta.apply_key_output(KeyOutput::Char('K'));
ta.apply_key_output(KeyOutput::Enter);
assert_eq!(*log.borrow(), alloc::vec!["OK"]);
assert_eq!(ta.text(), "OK");
}
#[test]
fn apply_escape_deactivates() {
let mut ta = Textarea::new(BOUNDS);
ta.set_active(true);
ta.apply_key_output(KeyOutput::Escape);
assert!(!ta.is_active());
}
#[test]
fn apply_tab_and_control_are_ignored() {
let mut ta = Textarea::new(BOUNDS);
ta.set_active(true);
ta.apply_key_output(KeyOutput::Tab);
assert_eq!(ta.text(), "");
ta.apply_key_output(KeyOutput::Control(
crate::keyboard::KeyboardControl::SwitchMode(crate::keyboard::KeyboardMode::Number),
));
assert_eq!(ta.text(), "");
}
#[test]
fn placeholder_set_and_clear() {
let mut ta = Textarea::new(BOUNDS);
ta.set_placeholder("hint");
assert_eq!(ta.placeholder(), Some("hint"));
ta.set_placeholder("");
assert_eq!(ta.placeholder(), None);
}
#[test]
fn password_mode_masks_display_text() {
let mut ta = Textarea::new(BOUNDS);
ta.set_password_mode(true);
ta.set_text("secret");
assert_eq!(ta.text(), "secret");
assert_eq!(ta.display_text(), "******");
}
#[test]
fn one_line_prevents_newline_insertion() {
let mut ta = Textarea::new(BOUNDS);
ta.set_one_line(true);
ta.set_active(true);
ta.handle_event(&Event::KeyDown { key: Key::Enter });
assert_eq!(ta.text(), "");
}
#[test]
fn wrap_reflow_on_narrow_bounds() {
let mut ta = Textarea::new(narrow());
ta.set_active(true);
ta.set_text("abcdefghij");
let wrapped = wrap_greedy_ltr(&FONT_6X10, ta.text(), 24, 0, 0);
assert!(
wrapped.lines.len() > 1,
"expected wrapping for narrow bounds, got {} lines",
wrapped.lines.len()
);
}
#[test]
fn scroll_advances_on_overflow() {
let bounds = Rect {
x: 0,
y: 0,
width: 200,
height: 32, };
let mut ta = Textarea::new(bounds);
ta.set_active(true);
for _ in 0..4 {
ta.apply_key_output(KeyOutput::Char('A'));
ta.apply_key_output(KeyOutput::Enter);
}
assert!(
ta.scroll_offset_y() > 0,
"expected positive scroll, got {}",
ta.scroll_offset_y()
);
}
}