1use super::Widget;
40use alloc::borrow::Cow;
41use alloc::boxed::Box;
42use alloc::vec::Vec;
43use core::marker::PhantomData;
44use embedded_graphics::{
45 mono_font::MonoFont, pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
46};
47use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
48use zest_theme::Theme;
49
50const CURSOR_W: u32 = 2;
52const INTRINSIC_W: u32 = 200;
54const INTRINSIC_H: u32 = 96;
56
57#[derive(Copy, Clone)]
60struct VisualLine {
61 start: usize,
63 end: usize,
65 char_start: usize,
67 char_len: usize,
69}
70
71pub struct TextArea<'a, C: PixelColor, M: Clone> {
77 rect: Rectangle,
78 text: Cow<'a, str>,
79 cursor: usize,
80 placeholder: Cow<'a, str>,
81 id: Option<WidgetId>,
82 color: Option<C>,
83 cursor_color: Option<C>,
84 placeholder_color: Option<C>,
85 font: Option<&'a MonoFont<'a>>,
86 on_tap: Option<Box<dyn Fn(usize) -> M + 'a>>,
87 on_action: Option<Box<dyn Fn(UiAction) -> M + 'a>>,
88 focused: bool,
89 width: Length,
90 height: Length,
91 _color: PhantomData<C>,
92}
93
94impl<'a, C: PixelColor, M: Clone> TextArea<'a, C, M> {
95 pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
99 Self {
100 rect: Rectangle::zero(),
101 text: text.into(),
102 cursor: 0,
103 placeholder: Cow::Borrowed(""),
104 id: None,
105 color: None,
106 cursor_color: None,
107 placeholder_color: None,
108 font: None,
109 on_tap: None,
110 on_action: None,
111 focused: false,
112 width: Length::Fill,
113 height: Length::Fill,
114 _color: PhantomData,
115 }
116 }
117
118 #[must_use]
121 pub fn cursor(mut self, index: usize) -> Self {
122 self.cursor = index;
123 self
124 }
125
126 #[must_use]
128 pub fn placeholder(mut self, text: impl Into<Cow<'a, str>>) -> Self {
129 self.placeholder = text.into();
130 self
131 }
132
133 #[must_use]
135 pub fn id(mut self, id: WidgetId) -> Self {
136 self.id = Some(id);
137 self
138 }
139
140 #[must_use]
142 pub fn color(mut self, color: C) -> Self {
143 self.color = Some(color);
144 self
145 }
146
147 #[must_use]
149 pub fn cursor_color(mut self, color: C) -> Self {
150 self.cursor_color = Some(color);
151 self
152 }
153
154 #[must_use]
157 pub fn placeholder_color(mut self, color: C) -> Self {
158 self.placeholder_color = Some(color);
159 self
160 }
161
162 #[must_use]
165 pub fn font(mut self, font: &'a MonoFont<'a>) -> Self {
166 self.font = Some(font);
167 self
168 }
169
170 #[must_use]
178 pub fn on_tap<F: Fn(usize) -> M + 'a>(mut self, f: F) -> Self {
179 self.on_tap = Some(Box::new(f));
180 self
181 }
182
183 #[must_use]
185 pub fn on_action<F: Fn(UiAction) -> M + 'a>(mut self, f: F) -> Self {
186 self.on_action = Some(Box::new(f));
187 self
188 }
189
190 #[must_use]
192 pub fn width(mut self, width: impl Into<Length>) -> Self {
193 self.width = width.into();
194 self
195 }
196
197 #[must_use]
199 pub fn height(mut self, height: impl Into<Length>) -> Self {
200 self.height = height.into();
201 self
202 }
203
204 fn resolved_font<'t>(&'t self, theme: &'t Theme<'a, C>) -> &'t MonoFont<'a> {
206 self.font.unwrap_or(theme.default_font())
207 }
208
209 fn glyph_w(font: &MonoFont<'_>) -> u32 {
211 font.character_size.width.max(1)
212 }
213
214 fn glyph_h(font: &MonoFont<'_>) -> u32 {
216 font.character_size.height.max(1)
217 }
218
219 fn cols(&self, font: &MonoFont<'_>) -> usize {
221 let w = self.rect.size.width;
222 (w / Self::glyph_w(font)).max(1) as usize
223 }
224
225 fn layout_lines(&self, font: &MonoFont<'_>) -> Vec<VisualLine> {
229 let cols = self.cols(font);
230 let mut lines = Vec::new();
231
232 let mut line_byte_start = 0usize;
235 let mut line_char_start = 0usize;
236 let mut col = 0usize;
237 let mut last_byte = 0usize;
238
239 for (byte_idx, ch) in self.text.char_indices() {
240 last_byte = byte_idx + ch.len_utf8();
241 if ch == '\n' {
242 lines.push(VisualLine {
243 start: line_byte_start,
244 end: byte_idx,
245 char_start: line_char_start,
246 char_len: col,
247 });
248 line_byte_start = byte_idx + 1;
249 line_char_start += col + 1; col = 0;
251 continue;
252 }
253 if col >= cols {
254 lines.push(VisualLine {
256 start: line_byte_start,
257 end: byte_idx,
258 char_start: line_char_start,
259 char_len: col,
260 });
261 line_byte_start = byte_idx;
262 line_char_start += col;
263 col = 0;
264 }
265 col += 1;
266 }
267
268 lines.push(VisualLine {
270 start: line_byte_start,
271 end: last_byte.max(line_byte_start),
272 char_start: line_char_start,
273 char_len: col,
274 });
275
276 lines
277 }
278
279 fn cursor_line_col(&self, lines: &[VisualLine], cursor_chars: usize) -> (usize, usize) {
285 for (i, line) in lines.iter().enumerate() {
286 let line_end_char = line.char_start + line.char_len;
287 let is_last = i + 1 == lines.len();
288 if cursor_chars < line_end_char || (cursor_chars == line_end_char && is_last) {
291 return (i, cursor_chars.saturating_sub(line.char_start));
292 }
293 if cursor_chars == line_end_char {
296 if let Some(next) = lines.get(i + 1) {
297 if next.char_start == line_end_char {
298 return (i, line.char_len);
300 }
301 }
302 }
303 }
304 let last = lines.len().saturating_sub(1);
306 let col = lines.last().map_or(0, |l| l.char_len);
307 (last, col)
308 }
309
310 fn hit_test(&self, point: Point) -> bool {
311 let tl = self.rect.top_left;
312 let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
313 point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
314 }
315
316 fn index_at(&self, point: Point, font: &MonoFont<'_>) -> usize {
319 let lines = self.layout_lines(font);
320 if lines.is_empty() {
321 return 0;
322 }
323 let gh = Self::glyph_h(font) as i32;
324 let gw = Self::glyph_w(font) as i32;
325 let first_line = self.first_visible_line(&lines, font);
326
327 let rel_y = (point.y - self.rect.top_left.y).max(0);
329 let visible_row = (rel_y / gh) as usize;
330 let line_idx = (first_line + visible_row).min(lines.len() - 1);
331 let line = lines[line_idx];
332
333 let rel_x = (point.x - self.rect.top_left.x).max(0);
336 let col = ((rel_x + gw / 2) / gw) as usize;
337 let col = col.min(line.char_len);
338 line.char_start + col
339 }
340
341 fn first_visible_line(&self, lines: &[VisualLine], font: &MonoFont<'_>) -> usize {
343 let gh = Self::glyph_h(font);
344 let rows = (self.rect.size.height / gh).max(1) as usize;
345 let cursor_chars = self.cursor.min(self.text.chars().count());
346 let (cursor_line, _) = self.cursor_line_col(lines, cursor_chars);
347 if cursor_line + 1 > rows {
349 cursor_line + 1 - rows
350 } else {
351 0
352 }
353 }
354}
355
356impl<'a, C: PixelColor, M: Clone> Widget<C, M> for TextArea<'a, C, M> {
357 fn measure(&mut self, constraints: Constraints) -> Size {
358 let w = self.width.resolve(INTRINSIC_W, constraints.max.width);
359 let h = self.height.resolve(INTRINSIC_H, constraints.max.height);
360 constraints.clamp(Size::new(w, h))
361 }
362
363 fn preferred_size(&self) -> (Length, Length) {
364 (self.width, self.height)
365 }
366
367 fn arrange(&mut self, rect: Rectangle) {
368 self.rect = rect;
369 }
370
371 fn rect(&self) -> Rectangle {
372 self.rect
373 }
374
375 fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
376 let on_tap = self.on_tap.as_ref()?;
377 if phase != TouchPhase::Down || !self.hit_test(point) {
378 return None;
379 }
380 let font = self.font?;
386 Some(on_tap(self.index_at(point, font)))
387 }
388
389 fn widget_id(&self) -> Option<WidgetId> {
390 self.id
391 }
392
393 fn is_focusable(&self) -> bool {
394 self.id.is_some()
395 }
396
397 fn handle_action(&mut self, action: UiAction) -> Option<M> {
398 self.on_action.as_ref().map(|cb| cb(action))
399 }
400
401 fn sync_focus(&mut self, focused: Option<WidgetId>) {
402 self.focused = self.id.is_some() && self.id == focused;
403 }
404
405 fn focus_at(&self, point: Point) -> Option<WidgetId> {
406 if self.is_focusable() && self.hit_test(point) {
407 self.id
408 } else {
409 None
410 }
411 }
412
413 fn draw<'t>(
414 &self,
415 renderer: &mut dyn Renderer<C>,
416 theme: &Theme<'t, C>,
417 ) -> Result<(), RenderError> {
418 let font = self.resolved_font(theme);
419 let gh = Self::glyph_h(font) as i32;
420 let gw = Self::glyph_w(font) as i32;
421 let text_color = self.color.unwrap_or(theme.background.on_base);
422 let cursor_color = self.cursor_color.unwrap_or(theme.accent.base);
423
424 renderer.push_clip(self.rect);
426
427 let x0 = self.rect.top_left.x;
428 let y0 = self.rect.top_left.y;
429
430 if self.text.is_empty() {
432 if !self.placeholder.is_empty() {
433 let ph_color = self.placeholder_color.unwrap_or(theme.palette.neutral_2);
434 renderer.draw_text(
435 &self.placeholder,
436 Point::new(x0, y0 + gh),
437 font,
438 ph_color,
439 Alignment::Left,
440 )?;
441 }
442 let cursor_rect = Rectangle::new(
443 Point::new(x0, y0 + 2),
444 Size::new(CURSOR_W, gh.max(1) as u32),
445 );
446 renderer.fill_rect(cursor_rect, cursor_color)?;
447 renderer.pop_clip();
448 return Ok(());
449 }
450
451 let lines = self.layout_lines(font);
452 let rows = (self.rect.size.height / Self::glyph_h(font)).max(1) as usize;
453 let first_line = self.first_visible_line(&lines, font);
454
455 for (row, line) in lines.iter().enumerate().skip(first_line).take(rows) {
458 let slice = &self.text[line.start..line.end];
459 if !slice.is_empty() {
460 let draw_y = y0 + (row - first_line) as i32 * gh + gh;
461 renderer.draw_text(
462 slice,
463 Point::new(x0, draw_y),
464 font,
465 text_color,
466 Alignment::Left,
467 )?;
468 }
469 }
470
471 let cursor_chars = self.cursor.min(self.text.chars().count());
473 let (cursor_line, cursor_col) = self.cursor_line_col(&lines, cursor_chars);
474 if cursor_line >= first_line && cursor_line < first_line + rows {
475 let cx = x0 + cursor_col as i32 * gw;
476 let cy = y0 + (cursor_line - first_line) as i32 * gh;
477 let cursor_rect = Rectangle::new(
478 Point::new(cx, cy + 2),
479 Size::new(CURSOR_W, gh.max(1) as u32),
480 );
481 renderer.fill_rect(cursor_rect, cursor_color)?;
482 }
483
484 renderer.pop_clip();
485 if self.focused {
486 renderer.stroke_rect(self.rect, theme.accent.base)?;
487 }
488 Ok(())
489 }
490}