use super::Widget;
use alloc::borrow::Cow;
use alloc::boxed::Box;
use alloc::vec::Vec;
use core::marker::PhantomData;
use embedded_graphics::{
mono_font::MonoFont, pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
};
use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
use zest_theme::Theme;
const CURSOR_W: u32 = 2;
const INTRINSIC_W: u32 = 200;
const INTRINSIC_H: u32 = 96;
#[derive(Copy, Clone)]
struct VisualLine {
start: usize,
end: usize,
char_start: usize,
char_len: usize,
}
pub struct TextArea<'a, C: PixelColor, M: Clone> {
rect: Rectangle,
text: Cow<'a, str>,
cursor: usize,
placeholder: Cow<'a, str>,
id: Option<WidgetId>,
color: Option<C>,
cursor_color: Option<C>,
placeholder_color: Option<C>,
font: Option<&'a MonoFont<'a>>,
on_tap: Option<Box<dyn Fn(usize) -> M + 'a>>,
on_action: Option<Box<dyn Fn(UiAction) -> M + 'a>>,
focused: bool,
width: Length,
height: Length,
_color: PhantomData<C>,
}
impl<'a, C: PixelColor, M: Clone> TextArea<'a, C, M> {
pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
Self {
rect: Rectangle::zero(),
text: text.into(),
cursor: 0,
placeholder: Cow::Borrowed(""),
id: None,
color: None,
cursor_color: None,
placeholder_color: None,
font: None,
on_tap: None,
on_action: None,
focused: false,
width: Length::Fill,
height: Length::Fill,
_color: PhantomData,
}
}
#[must_use]
pub fn cursor(mut self, index: usize) -> Self {
self.cursor = index;
self
}
#[must_use]
pub fn placeholder(mut self, text: impl Into<Cow<'a, str>>) -> Self {
self.placeholder = text.into();
self
}
#[must_use]
pub fn id(mut self, id: WidgetId) -> Self {
self.id = Some(id);
self
}
#[must_use]
pub fn color(mut self, color: C) -> Self {
self.color = Some(color);
self
}
#[must_use]
pub fn cursor_color(mut self, color: C) -> Self {
self.cursor_color = Some(color);
self
}
#[must_use]
pub fn placeholder_color(mut self, color: C) -> Self {
self.placeholder_color = Some(color);
self
}
#[must_use]
pub fn font(mut self, font: &'a MonoFont<'a>) -> Self {
self.font = Some(font);
self
}
#[must_use]
pub fn on_tap<F: Fn(usize) -> M + 'a>(mut self, f: F) -> Self {
self.on_tap = Some(Box::new(f));
self
}
#[must_use]
pub fn on_action<F: Fn(UiAction) -> M + 'a>(mut self, f: F) -> Self {
self.on_action = Some(Box::new(f));
self
}
#[must_use]
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
#[must_use]
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
fn resolved_font<'t>(&'t self, theme: &'t Theme<'a, C>) -> &'t MonoFont<'a> {
self.font.unwrap_or(theme.default_font())
}
fn glyph_w(font: &MonoFont<'_>) -> u32 {
font.character_size.width.max(1)
}
fn glyph_h(font: &MonoFont<'_>) -> u32 {
font.character_size.height.max(1)
}
fn cols(&self, font: &MonoFont<'_>) -> usize {
let w = self.rect.size.width;
(w / Self::glyph_w(font)).max(1) as usize
}
fn layout_lines(&self, font: &MonoFont<'_>) -> Vec<VisualLine> {
let cols = self.cols(font);
let mut lines = Vec::new();
let mut line_byte_start = 0usize;
let mut line_char_start = 0usize;
let mut col = 0usize;
let mut last_byte = 0usize;
for (byte_idx, ch) in self.text.char_indices() {
last_byte = byte_idx + ch.len_utf8();
if ch == '\n' {
lines.push(VisualLine {
start: line_byte_start,
end: byte_idx,
char_start: line_char_start,
char_len: col,
});
line_byte_start = byte_idx + 1;
line_char_start += col + 1; col = 0;
continue;
}
if col >= cols {
lines.push(VisualLine {
start: line_byte_start,
end: byte_idx,
char_start: line_char_start,
char_len: col,
});
line_byte_start = byte_idx;
line_char_start += col;
col = 0;
}
col += 1;
}
lines.push(VisualLine {
start: line_byte_start,
end: last_byte.max(line_byte_start),
char_start: line_char_start,
char_len: col,
});
lines
}
fn cursor_line_col(&self, lines: &[VisualLine], cursor_chars: usize) -> (usize, usize) {
for (i, line) in lines.iter().enumerate() {
let line_end_char = line.char_start + line.char_len;
let is_last = i + 1 == lines.len();
if cursor_chars < line_end_char || (cursor_chars == line_end_char && is_last) {
return (i, cursor_chars.saturating_sub(line.char_start));
}
if cursor_chars == line_end_char {
if let Some(next) = lines.get(i + 1) {
if next.char_start == line_end_char {
return (i, line.char_len);
}
}
}
}
let last = lines.len().saturating_sub(1);
let col = lines.last().map_or(0, |l| l.char_len);
(last, col)
}
fn hit_test(&self, point: Point) -> bool {
let tl = self.rect.top_left;
let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
}
fn index_at(&self, point: Point, font: &MonoFont<'_>) -> usize {
let lines = self.layout_lines(font);
if lines.is_empty() {
return 0;
}
let gh = Self::glyph_h(font) as i32;
let gw = Self::glyph_w(font) as i32;
let first_line = self.first_visible_line(&lines, font);
let rel_y = (point.y - self.rect.top_left.y).max(0);
let visible_row = (rel_y / gh) as usize;
let line_idx = (first_line + visible_row).min(lines.len() - 1);
let line = lines[line_idx];
let rel_x = (point.x - self.rect.top_left.x).max(0);
let col = ((rel_x + gw / 2) / gw) as usize;
let col = col.min(line.char_len);
line.char_start + col
}
fn first_visible_line(&self, lines: &[VisualLine], font: &MonoFont<'_>) -> usize {
let gh = Self::glyph_h(font);
let rows = (self.rect.size.height / gh).max(1) as usize;
let cursor_chars = self.cursor.min(self.text.chars().count());
let (cursor_line, _) = self.cursor_line_col(lines, cursor_chars);
if cursor_line + 1 > rows {
cursor_line + 1 - rows
} else {
0
}
}
}
impl<'a, C: PixelColor, M: Clone> Widget<C, M> for TextArea<'a, C, M> {
fn measure(&mut self, constraints: Constraints) -> Size {
let w = self.width.resolve(INTRINSIC_W, constraints.max.width);
let h = self.height.resolve(INTRINSIC_H, constraints.max.height);
constraints.clamp(Size::new(w, h))
}
fn preferred_size(&self) -> (Length, Length) {
(self.width, self.height)
}
fn arrange(&mut self, rect: Rectangle) {
self.rect = rect;
}
fn rect(&self) -> Rectangle {
self.rect
}
fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
let on_tap = self.on_tap.as_ref()?;
if phase != TouchPhase::Down || !self.hit_test(point) {
return None;
}
let font = self.font?;
Some(on_tap(self.index_at(point, font)))
}
fn widget_id(&self) -> Option<WidgetId> {
self.id
}
fn is_focusable(&self) -> bool {
self.id.is_some()
}
fn handle_action(&mut self, action: UiAction) -> Option<M> {
self.on_action.as_ref().map(|cb| cb(action))
}
fn sync_focus(&mut self, focused: Option<WidgetId>) {
self.focused = self.id.is_some() && self.id == focused;
}
fn focus_at(&self, point: Point) -> Option<WidgetId> {
if self.is_focusable() && self.hit_test(point) {
self.id
} else {
None
}
}
fn draw<'t>(
&self,
renderer: &mut dyn Renderer<C>,
theme: &Theme<'t, C>,
) -> Result<(), RenderError> {
let font = self.resolved_font(theme);
let gh = Self::glyph_h(font) as i32;
let gw = Self::glyph_w(font) as i32;
let text_color = self.color.unwrap_or(theme.background.on_base);
let cursor_color = self.cursor_color.unwrap_or(theme.accent.base);
renderer.push_clip(self.rect);
let x0 = self.rect.top_left.x;
let y0 = self.rect.top_left.y;
if self.text.is_empty() {
if !self.placeholder.is_empty() {
let ph_color = self.placeholder_color.unwrap_or(theme.palette.neutral_2);
renderer.draw_text(
&self.placeholder,
Point::new(x0, y0 + gh),
font,
ph_color,
Alignment::Left,
)?;
}
let cursor_rect = Rectangle::new(
Point::new(x0, y0 + 2),
Size::new(CURSOR_W, gh.max(1) as u32),
);
renderer.fill_rect(cursor_rect, cursor_color)?;
renderer.pop_clip();
return Ok(());
}
let lines = self.layout_lines(font);
let rows = (self.rect.size.height / Self::glyph_h(font)).max(1) as usize;
let first_line = self.first_visible_line(&lines, font);
for (row, line) in lines.iter().enumerate().skip(first_line).take(rows) {
let slice = &self.text[line.start..line.end];
if !slice.is_empty() {
let draw_y = y0 + (row - first_line) as i32 * gh + gh;
renderer.draw_text(
slice,
Point::new(x0, draw_y),
font,
text_color,
Alignment::Left,
)?;
}
}
let cursor_chars = self.cursor.min(self.text.chars().count());
let (cursor_line, cursor_col) = self.cursor_line_col(&lines, cursor_chars);
if cursor_line >= first_line && cursor_line < first_line + rows {
let cx = x0 + cursor_col as i32 * gw;
let cy = y0 + (cursor_line - first_line) as i32 * gh;
let cursor_rect = Rectangle::new(
Point::new(cx, cy + 2),
Size::new(CURSOR_W, gh.max(1) as u32),
);
renderer.fill_rect(cursor_rect, cursor_color)?;
}
renderer.pop_clip();
if self.focused {
renderer.stroke_rect(self.rect, theme.accent.base)?;
}
Ok(())
}
}