1use std::sync::Arc;
2
3use rosace_core::types::{Point, Rect, Size};
4use rosace_render::{Color, DrawCommand, FontWeight};
5use super::{Widget, LayoutCtx, PaintCtx, ScrollAxes};
6use super::container::draw_rounded_rect_pub;
7use super::text_input::paint_caret;
8use super::text_edit::{
9 char_byte_offset, grapheme_boundaries, style_runs, CursorStyle, EditController, EditableDecl,
10 LineLayout, SpanFn, TextLayoutSnapshot,
11};
12
13pub struct TextArea {
37 pub value: String,
38 pub placeholder: String,
39 pub focused: bool,
40 pub width: Option<f32>,
41 pub height: f32,
42 pub font_size: Option<f32>,
46 pub radius: f32,
47 background: Option<Color>,
48 border_color: Option<Color>,
49 focus_color: Option<Color>,
50 on_change: Option<Arc<dyn Fn(String) + Send + Sync>>,
51 controller: Option<EditController>,
52 spans: Option<Arc<SpanFn>>,
53 cursor_style: Option<CursorStyle>,
54 field: Option<rosace_forms::FormField>,
55 filters: Vec<super::text_edit::InputFilter>,
56 show_scrollbar: bool,
57 scrollbar_color: Color,
58}
59
60impl TextArea {
61 pub fn new() -> Self {
62 Self {
63 value: String::new(),
64 placeholder: String::from("Type here..."),
65 focused: false,
66 width: None,
67 height: 160.0,
68 font_size: None,
69 radius: 6.0,
70 background: None,
71 border_color: None,
72 focus_color: None,
73 on_change: None,
74 controller: None,
75 spans: None,
76 cursor_style: None,
77 field: None,
78 filters: Vec::new(),
79 show_scrollbar: true,
80 scrollbar_color: Color::rgb(60, 65, 95),
81 }
82 }
83 pub fn value(mut self, v: impl Into<String>) -> Self { self.value = v.into(); self }
84 pub fn placeholder(mut self, p: impl Into<String>) -> Self { self.placeholder = p.into(); self }
85 pub fn focused(mut self) -> Self { self.focused = true; self }
86 pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
87 pub fn height(mut self, h: f32) -> Self { self.height = h; self }
88 pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
90 pub fn border(mut self, c: Color) -> Self { self.border_color = Some(c); self }
92 pub fn focus_color(mut self, c: Color) -> Self { self.focus_color = Some(c); self }
94 pub fn on_change(mut self, f: impl Fn(String) + Send + Sync + 'static) -> Self {
95 self.on_change = Some(Arc::new(f));
96 self
97 }
98 pub fn controller(mut self, c: EditController) -> Self {
99 self.controller = Some(c);
100 self
101 }
102 pub fn spans(mut self, f: impl Fn(&str, Option<(usize, usize)>) -> Vec<super::text_edit::Span> + Send + Sync + 'static) -> Self {
104 self.spans = Some(Arc::new(f));
105 self
106 }
107 pub fn cursor_style(mut self, s: CursorStyle) -> Self {
109 self.cursor_style = Some(s);
110 self
111 }
112 pub fn field(mut self, f: rosace_forms::FormField) -> Self {
114 self.value = f.get();
115 let bound = f.clone();
116 self.on_change = Some(Arc::new(move |v| {
117 bound.set(v);
118 bound.validate();
119 }));
120 f.validate();
122 self.field = Some(f);
123 self
124 }
125 pub fn filters(mut self, filters: Vec<super::text_edit::InputFilter>) -> Self {
127 self.filters = filters;
128 self
129 }
130 pub fn no_scrollbar(mut self) -> Self { self.show_scrollbar = false; self }
133 pub fn scrollbar_color(mut self, c: Color) -> Self { self.scrollbar_color = c; self }
134
135 fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
136 self.font_size.unwrap_or(theme.typography.body_medium.size)
137 }
138}
139
140impl Default for TextArea {
141 fn default() -> Self { Self::new() }
142}
143
144fn wrap_char_ranges(chars: &[char], max_width: f32, measure: &dyn Fn(&str) -> f32) -> Vec<(usize, usize)> {
155 let n = chars.len();
156 let mut ranges = Vec::new();
157 let mut para_start = 0usize;
158 loop {
159 let mut para_end = para_start;
160 while para_end < n && chars[para_end] != '\n' { para_end += 1; }
161 wrap_paragraph(chars, para_start, para_end, max_width, measure, &mut ranges);
162 if para_end >= n { break; }
163 para_start = para_end + 1; if para_start == n {
165 ranges.push((n, n)); break;
167 }
168 }
169 if ranges.is_empty() { ranges.push((0, 0)); }
170 ranges
171}
172
173fn wrap_paragraph(
174 chars: &[char], start: usize, end: usize, max_width: f32,
175 measure: &dyn Fn(&str) -> f32, ranges: &mut Vec<(usize, usize)>,
176) {
177 if start == end {
178 ranges.push((start, end));
179 return;
180 }
181 let mut line_start = start;
182 let mut cursor = start;
183 while cursor < end {
184 let mut tok_end = cursor;
188 while tok_end < end && chars[tok_end] != ' ' { tok_end += 1; }
189 while tok_end < end && chars[tok_end] == ' ' { tok_end += 1; }
190 if tok_end == cursor { tok_end = end; }
191 if cursor > line_start {
192 let candidate: String = chars[line_start..tok_end].iter().collect();
193 if measure(&candidate) > max_width {
194 ranges.push((line_start, cursor));
195 line_start = cursor;
196 }
197 }
198 cursor = tok_end;
199 }
200 ranges.push((line_start, end));
201}
202
203impl Widget for TextArea {
204 fn layout(&self, ctx: &LayoutCtx) -> Size {
205 let constraints = ctx.constraints;
206 let show_error = self.field.as_ref().is_some_and(|f| f.is_touched() && !f.is_valid());
207 Size {
208 width: self.width.unwrap_or(super::avail_w(constraints)),
209 height: self.height + if show_error { super::text_input::ERROR_ROW_H } else { 0.0 },
210 }
211 }
212
213 fn paint(&self, ctx: &mut PaintCtx) {
214 ctx.semantics(super::Semantics::new(rosace_core::Role::TextInput)
215 .label(&self.placeholder).value(&self.value));
216 let font_size = self.resolved_font_size(&ctx.theme);
217
218 let focus = ctx.focus_node_seeded(self.focused);
219 ctx.register_focus(focus.clone());
220 let is_focused = focus.is_focused();
221
222 let full_rect = ctx.rect;
226 let r = Rect { origin: full_rect.origin, size: Size { width: full_rect.size.width, height: self.height } };
227 let bg = self.background.unwrap_or(Color::rgb(15, 16, 28));
228 let border = if is_focused {
229 self.focus_color.unwrap_or(Color::rgb(110, 75, 210))
230 } else {
231 self.border_color.unwrap_or(Color::rgb(32, 35, 58))
232 };
233 draw_rounded_rect_pub(ctx, r, bg, self.radius);
234 ctx.stroke_rrect(r, self.radius, border, if is_focused { 1.5 } else { 1.0 });
235
236 const PAD: f32 = 10.0;
237 let line_h = ctx.font.line_height(font_size);
238 let max_w = (r.size.width - PAD * 2.0).max(1.0);
239 let has_value = !self.value.is_empty();
240
241 let chars: Vec<char> = self.value.chars().collect();
242 let ranges = wrap_char_ranges(&chars, max_w, &|s: &str| ctx.font.measure_text(s, font_size));
243 let n_lines = ranges.len();
244 let content_h = n_lines as f32 * line_h;
245 let content_extent = content_h + PAD * 2.0;
251
252 let ctrl = ctx.scroll_controller();
253 let vp_s = [r.size.width, r.size.height];
254 if ctrl.viewport_size.get() != vp_s { ctrl.viewport_size.set(vp_s); }
255 let cs = [r.size.width, content_extent];
256 if ctrl.content_size.get() != cs { ctrl.content_size.set(cs); }
257
258 let max_scroll = (content_extent - r.size.height).max(0.0);
259 let mut scroll_y = ctrl.offset.get()[1].clamp(0.0, max_scroll);
260
261 let state = ctx.text_edit();
262 let cursor = state.cursor();
263 let cursor_line = ranges.iter().position(|&(s, e)| cursor >= s && cursor <= e).unwrap_or(0);
264
265 if is_focused && state.scrolled_cursor != Some(cursor) {
274 ctx.set_scrolled_cursor(Some(cursor));
275 let cursor_y = cursor_line as f32 * line_h;
276 if cursor_y < scroll_y {
277 scroll_y = cursor_y;
278 } else if cursor_y + line_h + PAD * 2.0 > scroll_y + r.size.height {
279 scroll_y = cursor_y + line_h + PAD * 2.0 - r.size.height;
282 }
283 scroll_y = scroll_y.clamp(0.0, max_scroll);
284 }
285 if ctrl.offset.get()[1] != scroll_y {
286 ctrl.offset.set([ctrl.offset.get()[0], scroll_y]);
287 }
288
289 let first_visible = (((scroll_y - PAD) / line_h).floor().max(0.0)) as usize;
292 let last_visible = (((scroll_y + r.size.height) / line_h).ceil() as usize).min(n_lines);
293
294 let boundaries = grapheme_boundaries(&self.value);
295 let text_color = if has_value { Color::rgb(220, 222, 240) } else { Color::rgb(80, 85, 118) };
296
297 let spans = if has_value {
301 self.spans.as_ref().map(|f| f(&self.value, state.last_edit_range))
302 } else {
303 None
304 };
305
306 let cursor_style = self.cursor_style.clone()
307 .unwrap_or_else(|| ctx.theme.ext::<CursorStyle>().cloned().unwrap_or_default());
308
309 ctx.record(DrawCommand::PushClip { rect: r });
310
311 let mut lines: Vec<LineLayout> = Vec::with_capacity(n_lines);
312 for (i, &(ls, le)) in ranges.iter().enumerate() {
313 let y = r.origin.y + PAD + i as f32 * line_h - scroll_y;
314 let bchars: Vec<usize> = boundaries.iter().copied().filter(|&c| c >= ls && c <= le).collect();
315 let lb = char_byte_offset(&self.value, ls);
316 let bx: Vec<f32> = bchars.iter().map(|&c| {
317 let cb = char_byte_offset(&self.value, c);
318 r.origin.x + PAD + ctx.font.measure_text(&self.value[lb..cb], font_size)
319 }).collect();
320 lines.push(LineLayout { char_range: (ls, le), y, height: line_h, boundary_chars: bchars, boundary_x: bx });
321
322 if i < first_visible || i >= last_visible { continue; }
323 let ll = &lines[i];
324
325 if has_value {
326 if is_focused {
327 if let Some((sel_s, sel_e)) = state.selection_range() {
333 if sel_s < le && sel_e > ls {
334 let sel_style = ctx.theme.ext::<super::SelectionStyle>().cloned().unwrap_or_default();
341 let x0 = ll.x_at(sel_s);
342 let x1 = ll.x_at(sel_e);
343 if x1 > x0 {
344 ctx.fill_rect(Rect {
345 origin: Point { x: x0, y },
346 size: Size { width: x1 - x0, height: line_h },
347 }, sel_style.highlight);
348 }
349 let handle_y = y + line_h;
356 let glass = sel_style.kind == super::SelectionKind::Glass;
357 for (endpoint, x) in [(sel_s, x0), (sel_e, x1)] {
358 if endpoint >= ls && endpoint <= le {
359 if glass {
360 ctx.fill_rect(Rect {
361 origin: Point { x: x - 1.0, y },
362 size: Size { width: 2.0, height: line_h },
363 }, sel_style.handle);
364 ctx.fill_circle(Point { x, y: handle_y }, 4.5, sel_style.handle);
365 } else {
366 ctx.fill_circle(Point { x, y: handle_y }, 4.0, sel_style.handle);
367 }
368 }
369 }
370 }
371 }
372
373 if let Some((ims, ime_)) = state.ime_range {
376 if ims < le && ime_ > ls {
377 let x0 = ll.x_at(ims);
378 let x1 = ll.x_at(ime_);
379 if x1 > x0 {
380 ctx.fill_rect(Rect {
381 origin: Point { x: x0, y: y + line_h - 1.0 },
382 size: Size { width: x1 - x0, height: 1.5 },
383 }, text_color);
384 }
385 }
386 }
387 }
388 if let Some(spans) = &spans {
389 for (rs, re, color, weight) in style_runs(spans, ls, le) {
390 if rs >= re { continue; }
391 let rb = char_byte_offset(&self.value, rs);
392 let reb = char_byte_offset(&self.value, re);
393 ctx.record(DrawCommand::DrawText {
394 text: self.value[rb..reb].to_string(),
395 origin: Point { x: ll.x_at(rs), y },
396 color: color.unwrap_or(text_color),
397 px: font_size,
398 weight: weight.unwrap_or(FontWeight::Regular),
399 });
400 }
401 } else {
402 let ub = char_byte_offset(&self.value, le);
403 let line_text = &self.value[lb..ub];
404 ctx.draw_text_at(line_text, Point { x: r.origin.x + PAD, y }, text_color, font_size);
405 }
406 }
407
408 if is_focused && i == cursor_line {
409 let cx = ll.x_at(cursor);
414 rosace_core::set_ime_cursor_area(Some(Rect {
415 origin: Point { x: cx, y },
416 size: Size { width: 2.0, height: line_h },
417 }));
418
419 if state.selection_range().is_none() {
420 let t = super::anim_clock() - state.last_edit_at;
421 let blink_on = t < 0.5 || (((t - 0.5) / cursor_style.blink_rate) as i64 % 2 == 0);
422 if blink_on {
423 paint_caret(ctx, &cursor_style, cx, y, line_h, font_size, ll, cursor);
424 }
425 }
426 }
427 }
428
429 if !has_value {
430 ctx.text(&self.placeholder, PAD, PAD, text_color, font_size);
431 }
432 if is_focused {
433 super::request_animation();
434 }
435
436 ctx.record(DrawCommand::PopClip);
437
438 if self.show_scrollbar {
443 let fresh_y = ctrl.offset.get()[1].clamp(0.0, max_scroll);
444 let ratio = (r.size.height / content_extent.max(1.0)).min(1.0);
445 if ratio < 1.0 {
446 let bar_h = r.size.height * ratio;
447 let max_bar_y = r.origin.y + r.size.height - bar_h;
448 let bar_y = (r.origin.y + (fresh_y / content_extent) * r.size.height)
449 .clamp(r.origin.y, max_bar_y.max(r.origin.y));
450 ctx.fill_rect(Rect {
451 origin: Point { x: r.origin.x + r.size.width - 4.0, y: bar_y },
452 size: Size { width: 3.0, height: bar_h },
453 }, self.scrollbar_color);
454 }
455 }
456
457 ctx.register_editable(EditableDecl {
458 value: self.value.clone(),
459 rect: r,
460 multiline: true,
461 obscure: false,
462 on_change: self.on_change.clone().unwrap_or_else(|| Arc::new(|_| {})),
463 controller: self.controller.clone(),
464 layout: TextLayoutSnapshot { lines },
465 filters: self.filters.clone(),
466 });
467
468 let wheel = ctrl.clone();
469 ctx.register_scroll_target(r, ScrollAxes::Y, Arc::new(move |_dx, dy| {
470 wheel.scroll_by(0.0, -dy);
471 }));
472
473 if let Some(field) = &self.field {
476 if field.is_touched() {
477 if let Some(err) = field.errors().first() {
478 ctx.semantics(super::Semantics::new(rosace_core::Role::Alert).label(&err.message));
479 ctx.record(DrawCommand::DrawText {
480 text: err.message.clone(),
481 origin: Point { x: full_rect.origin.x + 2.0, y: r.origin.y + r.size.height + 2.0 },
482 color: Color::rgb(230, 90, 90),
483 px: 10.0,
484 weight: FontWeight::Regular,
485 });
486 }
487 }
488 }
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 fn ranges_of(s: &str, max_width: f32, char_w: f32) -> Vec<(usize, usize)> {
497 let chars: Vec<char> = s.chars().collect();
498 wrap_char_ranges(&chars, max_width, &|t: &str| t.chars().count() as f32 * char_w)
499 }
500 fn slice(s: &str, r: (usize, usize)) -> String {
501 s.chars().skip(r.0).take(r.1 - r.0).collect()
502 }
503
504 #[test]
505 fn short_text_that_fits_is_a_single_line() {
506 let r = ranges_of("hello", 1000.0, 10.0);
507 assert_eq!(r, vec![(0, 5)]);
508 }
509
510 #[test]
511 fn empty_string_is_one_empty_line() {
512 assert_eq!(ranges_of("", 1000.0, 10.0), vec![(0, 0)]);
513 }
514
515 #[test]
516 fn long_text_wraps_at_a_word_boundary_covering_every_char_with_no_gaps() {
517 let s = "aaaa bbbb cccc";
521 let r = ranges_of(s, 100.0, 10.0);
522 assert!(r.len() >= 2, "must wrap into multiple lines, got {r:?}");
523 assert_eq!(r.first().unwrap().0, 0);
526 assert_eq!(r.last().unwrap().1, s.chars().count());
527 for w in r.windows(2) {
528 assert_eq!(w[0].1, w[1].0, "line ranges must be contiguous: {r:?}");
529 }
530 let rebuilt: String = r.iter().map(|&rg| slice(s, rg)).collect();
533 assert_eq!(rebuilt, s);
534 }
535
536 #[test]
537 fn explicit_newline_always_starts_a_new_line_regardless_of_width() {
538 let r = ranges_of("ab\ncd", 1000.0, 10.0);
539 assert_eq!(r, vec![(0, 2), (3, 5)], "the '\\n' itself (index 2) is consumed, not part of either line");
540 }
541
542 #[test]
543 fn trailing_newline_produces_a_final_empty_line() {
544 let r = ranges_of("ab\n", 1000.0, 10.0);
545 assert_eq!(r, vec![(0, 2), (3, 3)]);
546 }
547
548 #[test]
549 fn multiple_consecutive_newlines_produce_empty_lines_between_them() {
550 let r = ranges_of("a\n\nb", 1000.0, 10.0);
551 assert_eq!(r, vec![(0, 1), (2, 2), (3, 4)]);
552 }
553
554 #[test]
555 fn background_border_focus_color_builders_do_not_change_layout_size() {
556 let font = rosace_render::FontCache::embedded();
557 let theme = rosace_theme::built_in::dark_theme();
558 let ctx = LayoutCtx::new(rosace_layout::Constraints::loose(400.0, 400.0), &font, &theme);
559 let base = TextArea::new().width(200.0);
560 let customized = TextArea::new().width(200.0)
561 .background(Color::rgb(10, 10, 10))
562 .border(Color::rgb(200, 0, 0))
563 .focus_color(Color::rgb(0, 200, 0));
564 assert_eq!(base.layout(&ctx), customized.layout(&ctx));
565 }
566}