use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::GenericSignal;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
pub struct TextArea {
base: BaseWidget,
text: String,
cursor_pos: usize,
max_length: usize,
read_only: bool,
placeholder: String,
focused: bool,
pub changed: GenericSignal,
}
fn floor_char_boundary(s: &str, index: usize) -> usize {
let len = s.len();
if index >= len {
return len;
}
let bytes = s.as_bytes();
let mut i = index;
while i > 0 && bytes[i] & 0xC0 == 0x80 {
i -= 1;
}
i
}
impl TextArea {
pub fn new(text: String, rect: Rect) -> Self {
let cursor_pos = text.len();
Self {
base: BaseWidget::new(WidgetKind::TextArea, rect, "TextArea"),
text,
cursor_pos,
max_length: 0,
read_only: false,
placeholder: String::new(),
focused: false,
changed: GenericSignal::new(),
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: impl Into<String>) {
let text = text.into();
if self.text == text {
return;
}
let max = self.max_length;
self.text = if max > 0 && text.len() > max {
let boundary = floor_char_boundary(&text, max);
text[..boundary].to_string()
} else {
text
};
self.cursor_pos = self.text.len();
self.changed.emit();
self.base.request_redraw();
}
pub fn insert(&mut self, ch: char) {
if self.max_length > 0 && self.text.len() >= self.max_length {
return;
}
let boundary = floor_char_boundary(&self.text, self.cursor_pos);
self.cursor_pos = boundary;
self.text.insert(self.cursor_pos, ch);
self.cursor_pos += ch.len_utf8();
self.changed.emit();
}
pub fn delete_char(&mut self) {
if self.cursor_pos == 0 {
return;
}
let prev = self.text[..self.cursor_pos].char_indices().last().map(|(i, c)| {
if c == '\n' {
(i, 1)
} else {
(i, c.len_utf8())
}
});
if let Some((start, len)) = prev {
self.text.replace_range(start..start + len, "");
self.cursor_pos = start;
self.changed.emit();
}
}
pub fn cursor_pos(&self) -> usize {
self.cursor_pos
}
pub fn set_cursor_pos(&mut self, pos: usize) {
let clamped = pos.min(self.text.len());
if self.cursor_pos != clamped {
self.cursor_pos = clamped;
self.base.request_redraw();
}
}
pub fn set_max_length(&mut self, max: usize) {
let previous = self.max_length;
self.max_length = max;
if max > 0 && self.text.len() > max {
self.text.truncate(max);
self.cursor_pos = self.cursor_pos.min(max);
self.changed.emit();
self.base.request_redraw();
} else if previous != max {
self.base.request_redraw();
}
}
pub fn is_read_only(&self) -> bool {
self.read_only
}
pub fn set_read_only(&mut self, ro: bool) {
if self.read_only != ro {
self.read_only = ro;
self.base.request_redraw();
}
}
pub fn placeholder(&self) -> &str {
&self.placeholder
}
pub fn set_placeholder(&mut self, text: String) {
if self.placeholder != text {
self.placeholder = text;
self.base.request_redraw();
}
}
}
impl Widget for TextArea {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
let line_count = if self.text.is_empty() {
1
} else {
self.text.chars().filter(|&c| c == '\n').count() + 1
};
let max_line_width = self.text.lines().map(|l| l.len() as u32).max().unwrap_or(0);
let w = (max_line_width * 8 + 10).max(120);
let h = (line_count as u32 * 16 + 10).max(60);
Size::new(w, h)
}
}
impl EventHandler for TextArea {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::FocusGained => {
self.focused = true;
self.request_redraw();
}
Event::FocusLost => {
self.focused = false;
self.request_redraw();
}
Event::KeyPress { key, .. } => {
if self.read_only {
return;
}
match *key {
8 => {
self.delete_char();
self.request_redraw();
}
13 => {
self.insert('\n');
self.request_redraw();
}
37 => {
if self.cursor_pos > 0 {
let mut new_pos = self.cursor_pos;
while new_pos > 0 {
new_pos -= 1;
if self.text.is_char_boundary(new_pos) {
break;
}
}
self.cursor_pos = new_pos;
self.request_redraw();
}
}
39 => {
if self.cursor_pos < self.text.len() {
let mut new_pos = self.cursor_pos + 1;
while new_pos <= self.text.len() && !self.text.is_char_boundary(new_pos)
{
new_pos += 1;
}
self.cursor_pos = new_pos.min(self.text.len());
self.request_redraw();
}
}
_ => {
if let Some(ch) = char::from_u32(*key) {
if ch.is_ascii_graphic() || ch == ' ' {
self.insert(ch);
self.request_redraw();
}
}
}
}
}
_ => {}
}
}
}
const CHAR_W: i32 = 8;
const LINE_H: i32 = 16;
impl Draw for TextArea {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let padding = 4;
let bg = self.style().background_color.unwrap_or(Color::rgb(255, 255, 255));
context.fill_rect(rect, bg);
let border = self.style().border_color.unwrap_or(Color::rgb(200, 200, 200));
context.draw_rect(rect, border);
let text_color = self.style().text_color.unwrap_or(Color::rgb(0, 0, 0));
let placeholder_color = Color::rgb(180, 180, 180);
if self.text.is_empty() && !self.placeholder.is_empty() && !self.focused {
context.draw_text(
Point::new(rect.x + padding, rect.y + padding),
&self.placeholder,
&Font::default(),
placeholder_color,
HorizontalAlignment::Left,
);
} else if !self.text.is_empty() {
let mut y = rect.y + padding;
for line in self.text.lines() {
if y + LINE_H > rect.y + rect.height as i32 {
break;
}
context.draw_text(
Point::new(rect.x + padding, y),
line,
&Font::default(),
text_color,
HorizontalAlignment::Left,
);
y += LINE_H;
}
if self.text.ends_with('\n') {
}
}
if self.focused {
let cursor_x = self.cursor_screen_x(rect.x + padding);
let cursor_y = self.cursor_screen_y(rect.y + padding);
context.draw_line(
Point::new(cursor_x, cursor_y),
Point::new(cursor_x, cursor_y + LINE_H),
text_color,
);
}
}
}
impl TextArea {
fn cursor_screen_x(&self, origin_x: i32) -> i32 {
let text_before = &self.text[..self.cursor_pos];
let line_start = text_before.rfind('\n').map(|i| i + 1).unwrap_or(0);
let col = self.text[line_start..self.cursor_pos].len();
origin_x + col as i32 * CHAR_W
}
fn cursor_screen_y(&self, origin_y: i32) -> i32 {
let text_before = &self.text[..self.cursor_pos];
let lines_before = text_before.chars().filter(|&c| c == '\n').count();
origin_y + lines_before as i32 * LINE_H
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Rect;
#[test]
fn textarea_creation_defaults() {
let ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
assert_eq!(ta.text(), "");
assert_eq!(ta.cursor_pos(), 0);
assert_eq!(ta.max_length, 0);
assert!(!ta.is_read_only());
assert!(ta.placeholder().is_empty());
assert!(!ta.focused);
}
#[test]
fn textarea_set_text() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
ta.set_text("Hello\nWorld".to_string());
assert_eq!(ta.text(), "Hello\nWorld");
assert_eq!(ta.cursor_pos(), 11); }
#[test]
fn textarea_insert_char() {
let mut ta = TextArea::new("Helo".to_string(), Rect::new(0, 0, 300, 200));
ta.set_cursor_pos(3);
ta.insert('l');
assert_eq!(ta.text(), "Hello");
assert_eq!(ta.cursor_pos(), 4);
}
#[test]
fn textarea_delete_char() {
let mut ta = TextArea::new("Hello".to_string(), Rect::new(0, 0, 300, 200));
ta.set_cursor_pos(5);
ta.delete_char();
assert_eq!(ta.text(), "Hell");
assert_eq!(ta.cursor_pos(), 4);
}
#[test]
fn textarea_delete_char_at_start() {
let mut ta = TextArea::new("Hello".to_string(), Rect::new(0, 0, 300, 200));
ta.set_cursor_pos(0);
ta.delete_char();
assert_eq!(ta.text(), "Hello");
assert_eq!(ta.cursor_pos(), 0);
}
#[test]
fn textarea_delete_char_with_newline() {
let mut ta = TextArea::new("A\nB".to_string(), Rect::new(0, 0, 300, 200));
ta.set_cursor_pos(3);
ta.delete_char();
assert_eq!(ta.text(), "A\n");
assert_eq!(ta.cursor_pos(), 2);
}
#[test]
fn textarea_cursor_movement() {
let mut ta = TextArea::new("Hi".to_string(), Rect::new(0, 0, 300, 200));
assert_eq!(ta.cursor_pos(), 2);
ta.set_cursor_pos(0);
assert_eq!(ta.cursor_pos(), 0);
ta.set_cursor_pos(5); assert_eq!(ta.cursor_pos(), 2);
}
#[test]
fn textarea_placeholder() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
assert!(ta.placeholder().is_empty());
ta.set_placeholder("Enter text...".to_string());
assert_eq!(ta.placeholder(), "Enter text...");
}
#[test]
fn textarea_read_only() {
let mut ta = TextArea::new("Readable".to_string(), Rect::new(0, 0, 300, 200));
assert!(!ta.is_read_only());
ta.set_read_only(true);
assert!(ta.is_read_only());
}
#[test]
fn textarea_max_length() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
ta.set_max_length(5);
assert_eq!(ta.max_length, 5);
ta.set_text("Hello World".to_string());
assert_eq!(ta.text().len(), 5);
assert_eq!(ta.text(), "Hello");
}
#[test]
fn textarea_draw_does_not_panic() {
use crate::render::RenderContext;
use crate::render::SoftwarePaintBackend;
let mut ta = TextArea::new("Line1\nLine2".to_string(), Rect::new(0, 0, 200, 100));
let mut backend = SoftwarePaintBackend::new(Size::new(200, 100), 1.0);
let mut ctx = RenderContext::new(&mut backend);
ta.draw(&mut ctx);
}
#[test]
fn textarea_set_text_truncates_on_max_length() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
ta.set_max_length(3);
ta.set_text("Hello".to_string());
assert_eq!(ta.text(), "Hel");
assert_eq!(ta.cursor_pos(), 3);
}
#[test]
fn textarea_insert_respects_max_length() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
ta.set_max_length(3);
ta.set_text("ABC".to_string());
ta.insert('D');
assert_eq!(ta.text(), "ABC");
assert_eq!(ta.cursor_pos(), 3);
}
#[test]
fn textarea_empty_text_events_no_panic() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
ta.handle_event(&Event::KeyPress { key: 8, modifiers: 0 }); assert_eq!(ta.text(), "");
ta.handle_event(&Event::KeyPress { key: 37, modifiers: 0 }); assert_eq!(ta.cursor_pos(), 0);
ta.handle_event(&Event::KeyPress { key: 39, modifiers: 0 }); assert_eq!(ta.cursor_pos(), 0);
}
#[test]
fn textarea_focus_events() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
assert!(!ta.focused);
ta.handle_event(&Event::FocusGained);
assert!(ta.focused);
ta.handle_event(&Event::FocusLost);
assert!(!ta.focused);
}
#[test]
fn textarea_keypress_insertion() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
ta.handle_event(&Event::KeyPress { key: 72, modifiers: 0 }); ta.handle_event(&Event::KeyPress { key: 105, modifiers: 0 }); assert_eq!(ta.text(), "Hi");
}
#[test]
fn textarea_keypress_enter_inserts_newline() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
ta.handle_event(&Event::KeyPress { key: 65, modifiers: 0 }); ta.handle_event(&Event::KeyPress { key: 13, modifiers: 0 }); ta.handle_event(&Event::KeyPress { key: 66, modifiers: 0 }); assert_eq!(ta.text(), "A\nB");
}
#[test]
fn textarea_setters_request_redraw() {
let mut ta = TextArea::new(String::new(), Rect::new(0, 0, 300, 200));
let fired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
ta.base.redraw_requested.connect({
let flag = std::sync::Arc::clone(&fired);
move || flag.store(true, std::sync::atomic::Ordering::SeqCst)
});
ta.set_text("hello");
ta.set_cursor_pos(2);
ta.set_max_length(3);
ta.set_read_only(true);
ta.set_placeholder("hint".to_string());
assert!(fired.load(std::sync::atomic::Ordering::SeqCst));
}
}