Skip to main content

rosace_widgets/tree/
text_area.rs

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
13/// A multi-line, wrapped, virtualized-paint text field (D116 Step 4).
14///
15/// Built entirely on the same D116 core `TextInput` uses — `Transaction`/
16/// `Selection`/`Command`/`EditController` are unchanged; `TextLayoutSnapshot`
17/// already models "a list of visual lines" so `TextArea` just populates one
18/// [`LineLayout`] per WRAPPED line instead of `TextInput`'s single entry.
19/// Click/drag/double/triple-click dispatch in `engine.rs` is untouched —
20/// it only ever talks to `editable.layout`, never knows which widget it's
21/// looking at.
22///
23/// New in this widget: word-wrapping (`wrap_char_ranges` below), Enter
24/// inserts a real newline (`engine.rs`, gated on `multiline`), Up/Down
25/// cross wrapped lines with goal-column memory (`TextEditState::goal_x`),
26/// and vertical scrolling via the same zero-wiring [`rosace_scroll::ScrollController`]
27/// `ListView`/`ScrollView` use (D101) — wheel events mutate the
28/// controller's atoms directly, never the render tree, so there's no
29/// `!Sync`/`!Send` wall to work around here the way click dispatch has.
30///
31/// Paint is virtualized: every visual line's geometry is computed each
32/// frame (needed for correct click-anywhere/goal-column behavior), but
33/// only lines intersecting the current viewport actually emit paint
34/// commands — a large document's PAINT cost stays bounded even though its
35/// LAYOUT cost does not yet (a named follow-up, not this step's exit bar).
36pub struct TextArea {
37    pub value: String,
38    pub placeholder: String,
39    pub focused: bool,
40    pub width: Option<f32>,
41    pub height: f32,
42    /// `None` = read from the active theme's `typography.body_medium`
43    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
44    /// for the reasoning).
45    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    /// See `TextInput::background` — same seam, same contract.
89    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
90    /// See `TextInput::border` — same seam, same contract.
91    pub fn border(mut self, c: Color) -> Self { self.border_color = Some(c); self }
92    /// See `TextInput::focus_color` — same seam, same contract.
93    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    /// See `TextInput::spans` — same seam, same contract (D116 Step 5).
103    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    /// See `TextInput::cursor_style` — same seam, same contract.
108    pub fn cursor_style(mut self, s: CursorStyle) -> Self {
109        self.cursor_style = Some(s);
110        self
111    }
112    /// See `TextInput::field` — same seam, same contract (D116 Step 8).
113    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        // See `TextInput::field`'s identical eager-validate comment.
121        f.validate();
122        self.field = Some(f);
123        self
124    }
125    /// See `TextInput::filters` — same seam, same contract.
126    pub fn filters(mut self, filters: Vec<super::text_edit::InputFilter>) -> Self {
127        self.filters = filters;
128        self
129    }
130    /// Hide the vertical scroll-position thumb (see `ScrollView::no_scrollbar`
131    /// — same convention). Content still scrolls; only the indicator is gone.
132    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
144/// Greedy word-wrap that preserves EXACT char offsets into `chars` (no
145/// text is dropped or normalized) — every char index belongs to exactly
146/// one returned `(start, end)` line range, so click/caret placement is
147/// always well-defined. Hard breaks (`\n`) always start a new line,
148/// including a trailing empty line when `chars` ends with `\n`.
149///
150/// Known limitation: a single word wider than `max_width` overflows its
151/// line rather than hard-breaking mid-word — acceptable for prose, a
152/// named follow-up for pathological input (Step 4 exit bar doesn't
153/// require it).
154fn 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; // skip the '\n'
164        if para_start == n {
165            ranges.push((n, n)); // value ends with '\n' -> trailing empty line
166            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        // Extend to the end of the next token: a run of non-space chars
185        // plus its trailing spaces (spaces stay attached to the word
186        // BEFORE the break, matching every real editor's wrap).
187        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        // The scroll VIEWPORT only — `ctx.rect` may be taller than
223        // `self.height` when an error caption is reserved below it
224        // (D116 Step 8), same convention as `TextInput`.
225        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        // The scrollable extent is the text PLUS the top and bottom padding
246        // it's drawn inside (lines render at `PAD + i*line_h - scroll_y`).
247        // Using bare `content_h` here left the last line's bottom exactly
248        // PAD px past the clip at max scroll — a real clipped-last-line
249        // bug caught live.
250        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        // Scroll-into-view (D116 Step 4) — only meaningful while focused,
266        // and only when the caret actually MOVED since the last chase
267        // (typing, arrows, click). Chasing on every focused paint fought
268        // the user's wheel input: the caret-blink animation repaints
269        // constantly while focused, so with the caret on a bottom line
270        // every wheel-up was snapped straight back within a frame ("no
271        // scrolling at the bottom"), and a mid-document caret clamped
272        // scrolling to a viewport-sized window around itself.
273        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                // `+ PAD * 2.0`: the caret line must clear the bottom clip
280                // edge with its padding, mirroring `content_extent` above.
281                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        // `- PAD`: a line whose bottom pokes into the top padding band is
290        // still partially visible and must paint (the clip trims it).
291        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        // Styled spans (D116 Step 5) — computed ONCE for the whole value,
298        // sliced per-line below via `style_runs`. Never applied to the
299        // placeholder (there's no real text to tokenize).
300        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                    // Standard half-open interval overlap: this line spans
328                    // [ls, le); a multi-line selection highlights the
329                    // portion of EACH overlapping line separately —
330                    // `x_at` already clamps an out-of-range endpoint to
331                    // this line's own start/end boundary.
332                    if let Some((sel_s, sel_e)) = state.selection_range() {
333                        if sel_s < le && sel_e > ls {
334                            // Colors from the theme's SelectionStyle (D105
335                            // ext, flat default = the pre-themeable look).
336                            // The GLASS magnifier lens is single-line-field
337                            // only for now (TextInput); a multi-line lens
338                            // is a named deferral — here glass mode means
339                            // its tint + lollipop handles.
340                            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                            // Draggable selection handles (D116 Step 7) —
350                            // only on the line that actually OWNS each
351                            // endpoint (a multi-line selection's middle
352                            // lines get none). Grips stay at the line
353                            // bottom in both kinds — the engine's
354                            // handle_anchor targets that point.
355                            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                    // IME preedit underline (D116 Step 6) — same
374                    // half-open overlap technique as the selection above.
375                    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                // Report this field's caret rect to the platform (D116
410                // Step 6) so the OS's CJK candidate window anchors near
411                // it — regardless of whether the caret itself is visibly
412                // blinking this frame.
413                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        // Scroll-position thumb (same convention as `ScrollView` — drawn
439        // AFTER PopClip so it isn't clipped, re-reading the offset fresh
440        // rather than the `scroll_y` captured above, which predates this
441        // frame's wheel/scroll-into-view update).
442        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        // Inline validation error (D116 Step 8) — see `TextInput`'s
474        // identical block for the touched/`Role::Alert` reasoning.
475        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        // "aaaa bbbb cccc" at char_w=10, max_width=45 fits "aaaa " (50 >
518        // 45 already for "aaaa b"... use generous width so exactly two
519        // words fit per line): width for "aaaa bbbb " = 10*10=100.
520        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        // Every char index in [0, len) belongs to exactly one line —
524        // ranges are contiguous with no gaps or overlaps.
525        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        // Reassembling every line's slice must reconstruct the original
531        // text exactly (no characters dropped or duplicated).
532        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}