Skip to main content

ftui_widgets/
paragraph.rs

1#![forbid(unsafe_code)]
2
3use crate::block::{Alignment, Block};
4use crate::measurable::{MeasurableWidget, SizeConstraints};
5use crate::{Widget, draw_text_span_scrolled, draw_text_span_with_link, set_style_area};
6use ftui_core::geometry::{Rect, Size};
7use ftui_render::frame::Frame;
8use ftui_style::Style;
9use ftui_text::{Text, WrapMode, display_width};
10
11/// A widget that renders multi-line styled text.
12#[derive(Debug, Clone, Default)]
13pub struct Paragraph<'a> {
14    text: Text,
15    block: Option<Block<'a>>,
16    style: Style,
17    wrap: Option<WrapMode>,
18    alignment: Alignment,
19    scroll: (u16, u16),
20}
21
22impl<'a> Paragraph<'a> {
23    /// Create a new paragraph from the given text.
24    #[must_use]
25    pub fn new(text: impl Into<Text>) -> Self {
26        Self {
27            text: text.into(),
28            block: None,
29            style: Style::default(),
30            wrap: None,
31            alignment: Alignment::Left,
32            scroll: (0, 0),
33        }
34    }
35
36    /// Set the surrounding block.
37    #[must_use]
38    pub fn block(mut self, block: Block<'a>) -> Self {
39        self.block = Some(block);
40        self
41    }
42
43    /// Set the base text style.
44    #[must_use]
45    pub fn style(mut self, style: Style) -> Self {
46        self.style = style;
47        self
48    }
49
50    /// Set the text wrapping mode.
51    #[must_use]
52    pub fn wrap(mut self, wrap: WrapMode) -> Self {
53        self.wrap = Some(wrap);
54        self
55    }
56
57    /// Set the text alignment.
58    #[must_use]
59    pub fn alignment(mut self, alignment: Alignment) -> Self {
60        self.alignment = alignment;
61        self
62    }
63
64    /// Set the scroll offset as (vertical, horizontal).
65    #[must_use]
66    pub fn scroll(mut self, offset: (u16, u16)) -> Self {
67        self.scroll = offset;
68        self
69    }
70}
71
72impl Widget for Paragraph<'_> {
73    fn render(&self, area: Rect, frame: &mut Frame) {
74        #[cfg(feature = "tracing")]
75        let _span = tracing::debug_span!(
76            "widget_render",
77            widget = "Paragraph",
78            x = area.x,
79            y = area.y,
80            w = area.width,
81            h = area.height
82        )
83        .entered();
84
85        let deg = frame.buffer.degradation;
86
87        // Skeleton+: nothing to render
88        if !deg.render_content() {
89            return;
90        }
91
92        // Special-case: an empty Paragraph with no Block is commonly used as a screen-clear.
93        // In that mode we must clear cell *content* (not just paint style), otherwise old
94        // borders/characters can bleed through Flex gaps.
95        let style = if deg.apply_styling() {
96            self.style
97        } else {
98            Style::default()
99        };
100        if self.block.is_none() && self.text.is_empty() {
101            let mut cell = ftui_render::cell::Cell::from_char(' ');
102            crate::apply_style(&mut cell, style);
103            frame.buffer.fill(area, cell);
104            return;
105        }
106
107        if deg.apply_styling() {
108            set_style_area(&mut frame.buffer, area, self.style);
109        }
110
111        let text_area = match self.block {
112            Some(ref b) => {
113                b.render(area, frame);
114                b.inner(area)
115            }
116            None => area,
117        };
118
119        if text_area.is_empty() {
120            return;
121        }
122
123        // At NoStyling, render text without per-span styles
124        // Background is already applied for the whole area via `set_style_area()`. When drawing
125        // text we avoid re-applying the same background, otherwise semi-transparent BG colors
126        // get composited multiple times.
127        let mut text_style = style;
128        text_style.bg = None;
129
130        let mut y = text_area.y;
131        let mut current_visual_line = 0;
132        let scroll_offset = self.scroll.0 as usize;
133
134        let mut render_line = |line: &ftui_text::Line, y: u16| {
135            // Render spans with proper Unicode widths
136            let line_width: usize = line.width();
137
138            let scroll_x = self.scroll.1;
139            let start_x = align_x(text_area, line_width, self.alignment);
140
141            // Let's iterate spans.
142            // `span_visual_offset`: relative to line start.
143            let mut span_visual_offset = 0;
144
145            // Alignment offset relative to text_area.x
146            let alignment_offset = start_x.saturating_sub(text_area.x);
147
148            for span in line.spans() {
149                let span_width = span.width();
150
151                // Effective position of this span relative to text_area.x
152                // pos = alignment_offset + span_visual_offset - scroll_x
153                let line_rel_start = alignment_offset.saturating_add(span_visual_offset);
154
155                // Check visibility
156                if line_rel_start.saturating_add(span_width as u16) <= scroll_x {
157                    // Fully scrolled out to the left
158                    span_visual_offset = span_visual_offset.saturating_add(span_width as u16);
159                    continue;
160                }
161
162                // Calculate actual draw position
163                let draw_x;
164                let local_scroll;
165
166                if line_rel_start < scroll_x {
167                    // Partially scrolled out left
168                    draw_x = text_area.x;
169                    local_scroll = scroll_x - line_rel_start;
170                } else {
171                    // Start is visible
172                    draw_x = text_area.x.saturating_add(line_rel_start - scroll_x);
173                    local_scroll = 0;
174                }
175
176                if draw_x >= text_area.right() {
177                    // Fully clipped to the right
178                    break;
179                }
180
181                // At NoStyling+, ignore span-level styles entirely
182                let span_style = if deg.apply_styling() {
183                    match span.style {
184                        Some(s) => s.merge(&text_style),
185                        None => text_style,
186                    }
187                } else {
188                    text_style // Style::default() at NoStyling
189                };
190
191                if local_scroll > 0 {
192                    draw_text_span_scrolled(
193                        frame,
194                        draw_x,
195                        y,
196                        span.content.as_ref(),
197                        span_style,
198                        text_area.right(),
199                        local_scroll,
200                        span.link.as_deref(),
201                    );
202                } else {
203                    draw_text_span_with_link(
204                        frame,
205                        draw_x,
206                        y,
207                        span.content.as_ref(),
208                        span_style,
209                        text_area.right(),
210                        span.link.as_deref(),
211                    );
212                }
213
214                span_visual_offset = span_visual_offset.saturating_add(span_width as u16);
215            }
216        };
217
218        for line in self.text.lines() {
219            if y >= text_area.bottom() {
220                break;
221            }
222
223            // If wrapping is enabled and line is wider than area, wrap it
224            if let Some(wrap_mode) = self.wrap {
225                let line_width = line.width();
226                if line_width > text_area.width as usize {
227                    let wrapped = line.wrap(text_area.width as usize, wrap_mode);
228                    for wrapped_line in &wrapped {
229                        if current_visual_line < scroll_offset {
230                            current_visual_line += 1;
231                            continue;
232                        }
233
234                        if y >= text_area.bottom() {
235                            break;
236                        }
237
238                        render_line(wrapped_line, y);
239                        y += 1;
240                        current_visual_line += 1;
241                    }
242                    continue;
243                }
244            }
245
246            // Non-wrapped line (or fits in width)
247            if current_visual_line < scroll_offset {
248                current_visual_line += 1;
249                continue;
250            }
251
252            render_line(line, y);
253            y = y.saturating_add(1);
254            current_visual_line += 1;
255        }
256    }
257}
258impl MeasurableWidget for Paragraph<'_> {
259    fn measure(&self, available: Size) -> SizeConstraints {
260        // Calculate text measurements
261        let text_width = self.text.width();
262        let text_height = self.text.height();
263
264        // Find the minimum width (longest word or longest non-breakable segment)
265        // This requires iterating through the text to find word boundaries
266        let min_width = self.calculate_min_width();
267
268        // Get block chrome if present
269        let (chrome_width, chrome_height) = self
270            .block
271            .as_ref()
272            .map(|b| b.chrome_size())
273            .unwrap_or((0, 0));
274
275        // If wrapping is enabled, calculate wrapped height
276        let (preferred_width, preferred_height) = if self.wrap.is_some() {
277            // When wrapping, preferred width is either the text width or available width
278            let wrap_width = if available.width > chrome_width {
279                (available.width - chrome_width) as usize
280            } else {
281                1
282            };
283
284            // Estimate wrapped height by calculating how text would wrap
285            let wrapped_height = self.estimate_wrapped_height(wrap_width);
286
287            // Preferred width is min(text_width, available_width - chrome)
288            let pref_w = text_width.min(wrap_width);
289            (pref_w, wrapped_height)
290        } else {
291            // No wrapping: preferred is natural text dimensions
292            (text_width, text_height)
293        };
294
295        // Convert to u16, saturating at MAX
296        let min_w = (min_width as u16).saturating_add(chrome_width);
297        // Only require 1 line minimum if there's actual content
298        let min_h = if preferred_height > 0 {
299            (1u16).saturating_add(chrome_height)
300        } else {
301            chrome_height
302        };
303
304        let pref_w = (preferred_width as u16).saturating_add(chrome_width);
305        let pref_h = (preferred_height as u16).saturating_add(chrome_height);
306
307        SizeConstraints {
308            min: Size::new(min_w, min_h),
309            preferred: Size::new(pref_w, pref_h),
310            max: None, // Paragraph can use additional space for scrolling
311        }
312    }
313
314    fn has_intrinsic_size(&self) -> bool {
315        // Paragraph always has intrinsic size based on its text content
316        true
317    }
318}
319
320impl Paragraph<'_> {
321    /// Calculate the minimum width needed (longest word).
322    fn calculate_min_width(&self) -> usize {
323        let mut max_word_width = 0;
324
325        for line in self.text.lines() {
326            let plain = line.to_plain_text();
327            // Split on whitespace to find words
328            for word in plain.split_whitespace() {
329                let word_width = display_width(word);
330                max_word_width = max_word_width.max(word_width);
331            }
332        }
333
334        // If there are no words, use the full text width
335        if max_word_width == 0 {
336            return self.text.width();
337        }
338
339        max_word_width
340    }
341
342    /// Estimate the height when text is wrapped at the given width.
343    fn estimate_wrapped_height(&self, wrap_width: usize) -> usize {
344        if wrap_width == 0 {
345            return self.text.height();
346        }
347
348        let wrap_mode = self.wrap.unwrap_or(WrapMode::Word);
349        let mut total_lines = 0;
350
351        for line in self.text.lines() {
352            let line_width = line.width();
353            if wrap_mode == WrapMode::None || line_width <= wrap_width {
354                total_lines += 1;
355                continue;
356            }
357
358            // Wrap this line and count resulting lines (style-preserving path).
359            let wrapped = line.wrap(wrap_width, wrap_mode);
360            total_lines += wrapped.len().max(1);
361        }
362
363        total_lines.max(1)
364    }
365}
366
367/// Calculate the starting x position for a line given alignment.
368fn align_x(area: Rect, line_width: usize, alignment: Alignment) -> u16 {
369    let line_width_u16 = u16::try_from(line_width).unwrap_or(u16::MAX);
370    match alignment {
371        Alignment::Left => area.x,
372        Alignment::Center => area
373            .x
374            .saturating_add(area.width.saturating_sub(line_width_u16) / 2),
375        Alignment::Right => area
376            .x
377            .saturating_add(area.width.saturating_sub(line_width_u16)),
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use ftui_render::grapheme_pool::GraphemePool;
385
386    #[test]
387    fn render_simple_text() {
388        let para = Paragraph::new(Text::raw("Hello"));
389        let area = Rect::new(0, 0, 10, 1);
390        let mut pool = GraphemePool::new();
391        let mut frame = Frame::new(10, 1, &mut pool);
392        para.render(area, &mut frame);
393
394        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
395        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('o'));
396    }
397
398    #[test]
399    fn render_multiline_text() {
400        let para = Paragraph::new(Text::raw("AB\nCD"));
401        let area = Rect::new(0, 0, 5, 3);
402        let mut pool = GraphemePool::new();
403        let mut frame = Frame::new(5, 3, &mut pool);
404        para.render(area, &mut frame);
405
406        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
407        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('B'));
408        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('C'));
409        assert_eq!(frame.buffer.get(1, 1).unwrap().content.as_char(), Some('D'));
410    }
411
412    #[test]
413    fn render_centered_text() {
414        let para = Paragraph::new(Text::raw("Hi")).alignment(Alignment::Center);
415        let area = Rect::new(0, 0, 10, 1);
416        let mut pool = GraphemePool::new();
417        let mut frame = Frame::new(10, 1, &mut pool);
418        para.render(area, &mut frame);
419
420        // "Hi" is 2 wide, area is 10, so starts at (10-2)/2 = 4
421        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('H'));
422        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('i'));
423    }
424
425    #[test]
426    fn render_with_scroll() {
427        let para = Paragraph::new(Text::raw("Line1\nLine2\nLine3")).scroll((1, 0));
428        let area = Rect::new(0, 0, 10, 2);
429        let mut pool = GraphemePool::new();
430        let mut frame = Frame::new(10, 2, &mut pool);
431        para.render(area, &mut frame);
432
433        // Should skip Line1, show Line2 and Line3
434        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('L'));
435        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('2'));
436    }
437
438    #[test]
439    fn render_empty_area() {
440        let para = Paragraph::new(Text::raw("Hello"));
441        let area = Rect::new(0, 0, 0, 0);
442        let mut pool = GraphemePool::new();
443        let mut frame = Frame::new(1, 1, &mut pool);
444        para.render(area, &mut frame);
445    }
446
447    #[test]
448    fn render_empty_text_clears_content() {
449        let para = Paragraph::new("");
450        let area = Rect::new(0, 0, 3, 1);
451        let mut pool = GraphemePool::new();
452        let mut frame = Frame::new(3, 1, &mut pool);
453
454        // Seed with non-space content; an empty Paragraph render should clear it.
455        frame
456            .buffer
457            .fill(area, ftui_render::cell::Cell::from_char('X'));
458
459        para.render(area, &mut frame);
460
461        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
462        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some(' '));
463    }
464
465    #[test]
466    fn render_right_aligned() {
467        let para = Paragraph::new(Text::raw("Hi")).alignment(Alignment::Right);
468        let area = Rect::new(0, 0, 10, 1);
469        let mut pool = GraphemePool::new();
470        let mut frame = Frame::new(10, 1, &mut pool);
471        para.render(area, &mut frame);
472
473        // "Hi" is 2 wide, area is 10, so starts at 10-2 = 8
474        assert_eq!(frame.buffer.get(8, 0).unwrap().content.as_char(), Some('H'));
475        assert_eq!(frame.buffer.get(9, 0).unwrap().content.as_char(), Some('i'));
476    }
477
478    #[test]
479    fn render_with_word_wrap() {
480        let para = Paragraph::new(Text::raw("hello world")).wrap(WrapMode::Word);
481        let area = Rect::new(0, 0, 6, 3);
482        let mut pool = GraphemePool::new();
483        let mut frame = Frame::new(6, 3, &mut pool);
484        para.render(area, &mut frame);
485
486        // "hello " fits in 6, " world" wraps to next line
487        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('h'));
488        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('w'));
489    }
490
491    #[test]
492    fn render_with_char_wrap() {
493        let para = Paragraph::new(Text::raw("abcdefgh")).wrap(WrapMode::Char);
494        let area = Rect::new(0, 0, 4, 3);
495        let mut pool = GraphemePool::new();
496        let mut frame = Frame::new(4, 3, &mut pool);
497        para.render(area, &mut frame);
498
499        // First line: abcd
500        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('a'));
501        assert_eq!(frame.buffer.get(3, 0).unwrap().content.as_char(), Some('d'));
502        // Second line: efgh
503        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('e'));
504    }
505
506    #[test]
507    fn scroll_past_all_lines() {
508        let para = Paragraph::new(Text::raw("AB")).scroll((5, 0));
509        let area = Rect::new(0, 0, 5, 2);
510        let mut pool = GraphemePool::new();
511        let mut frame = Frame::new(5, 2, &mut pool);
512        para.render(area, &mut frame);
513
514        // All lines skipped, buffer should remain empty
515        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
516    }
517
518    #[test]
519    fn render_clipped_at_area_height() {
520        let para = Paragraph::new(Text::raw("A\nB\nC\nD\nE"));
521        let area = Rect::new(0, 0, 5, 2);
522        let mut pool = GraphemePool::new();
523        let mut frame = Frame::new(5, 2, &mut pool);
524        para.render(area, &mut frame);
525
526        // Only first 2 lines should render
527        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
528        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('B'));
529    }
530
531    #[test]
532    fn render_clipped_at_area_width() {
533        let para = Paragraph::new(Text::raw("ABCDEF"));
534        let area = Rect::new(0, 0, 3, 1);
535        let mut pool = GraphemePool::new();
536        let mut frame = Frame::new(3, 1, &mut pool);
537        para.render(area, &mut frame);
538
539        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
540        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
541    }
542
543    #[test]
544    fn align_x_left() {
545        let area = Rect::new(5, 0, 20, 1);
546        assert_eq!(align_x(area, 10, Alignment::Left), 5);
547    }
548
549    #[test]
550    fn align_x_center() {
551        let area = Rect::new(0, 0, 20, 1);
552        // line_width=6, area=20, so (20-6)/2 = 7
553        assert_eq!(align_x(area, 6, Alignment::Center), 7);
554    }
555
556    #[test]
557    fn align_x_right() {
558        let area = Rect::new(0, 0, 20, 1);
559        // line_width=5, area=20, so 20-5 = 15
560        assert_eq!(align_x(area, 5, Alignment::Right), 15);
561    }
562
563    #[test]
564    fn align_x_wide_line_saturates() {
565        let area = Rect::new(0, 0, 10, 1);
566        // line wider than area: should saturate to area.x
567        assert_eq!(align_x(area, 20, Alignment::Right), 0);
568        assert_eq!(align_x(area, 20, Alignment::Center), 0);
569    }
570
571    #[test]
572    fn builder_methods_chain() {
573        let para = Paragraph::new(Text::raw("test"))
574            .style(Style::default())
575            .wrap(WrapMode::Word)
576            .alignment(Alignment::Center)
577            .scroll((1, 2));
578        // Verify it builds without panic
579        let area = Rect::new(0, 0, 10, 5);
580        let mut pool = GraphemePool::new();
581        let mut frame = Frame::new(10, 5, &mut pool);
582        para.render(area, &mut frame);
583    }
584
585    #[test]
586    fn render_at_offset_area() {
587        let para = Paragraph::new(Text::raw("X"));
588        let area = Rect::new(3, 4, 5, 2);
589        let mut pool = GraphemePool::new();
590        let mut frame = Frame::new(10, 10, &mut pool);
591        para.render(area, &mut frame);
592
593        assert_eq!(frame.buffer.get(3, 4).unwrap().content.as_char(), Some('X'));
594        // Cell at (0,0) should be empty
595        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
596    }
597
598    #[test]
599    fn wrap_clipped_at_area_bottom() {
600        // Long wrapped text should stop at area height
601        let para = Paragraph::new(Text::raw("abcdefghijklmnop")).wrap(WrapMode::Char);
602        let area = Rect::new(0, 0, 4, 2);
603        let mut pool = GraphemePool::new();
604        let mut frame = Frame::new(4, 2, &mut pool);
605        para.render(area, &mut frame);
606
607        // Only 2 rows of 4 chars each
608        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('a'));
609        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('e'));
610    }
611
612    // --- Degradation tests ---
613
614    #[test]
615    fn degradation_skeleton_skips_content() {
616        use ftui_render::budget::DegradationLevel;
617
618        let para = Paragraph::new(Text::raw("Hello"));
619        let area = Rect::new(0, 0, 10, 1);
620        let mut pool = GraphemePool::new();
621        let mut frame = Frame::new(10, 1, &mut pool);
622        frame.set_degradation(DegradationLevel::Skeleton);
623        para.render(area, &mut frame);
624
625        // No text should be rendered at Skeleton level
626        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
627    }
628
629    #[test]
630    fn degradation_full_renders_content() {
631        use ftui_render::budget::DegradationLevel;
632
633        let para = Paragraph::new(Text::raw("Hello"));
634        let area = Rect::new(0, 0, 10, 1);
635        let mut pool = GraphemePool::new();
636        let mut frame = Frame::new(10, 1, &mut pool);
637        frame.set_degradation(DegradationLevel::Full);
638        para.render(area, &mut frame);
639
640        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
641    }
642
643    #[test]
644    fn degradation_essential_only_still_renders_text() {
645        use ftui_render::budget::DegradationLevel;
646
647        let para = Paragraph::new(Text::raw("Hello"));
648        let area = Rect::new(0, 0, 10, 1);
649        let mut pool = GraphemePool::new();
650        let mut frame = Frame::new(10, 1, &mut pool);
651        frame.set_degradation(DegradationLevel::EssentialOnly);
652        para.render(area, &mut frame);
653
654        // EssentialOnly still renders content (< Skeleton)
655        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
656    }
657
658    #[test]
659    fn degradation_no_styling_ignores_span_styles() {
660        use ftui_render::budget::DegradationLevel;
661        use ftui_render::cell::PackedRgba;
662        use ftui_text::{Line, Span};
663
664        // Create text with a styled span
665        let styled_span = Span::styled("Hello", Style::new().fg(PackedRgba::RED));
666        let line = Line::from_spans([styled_span]);
667        let text = Text::from(line);
668        let para = Paragraph::new(text);
669        let area = Rect::new(0, 0, 10, 1);
670        let mut pool = GraphemePool::new();
671        let mut frame = Frame::new(10, 1, &mut pool);
672        frame.set_degradation(DegradationLevel::NoStyling);
673        para.render(area, &mut frame);
674
675        // Text should render but span style should be ignored
676        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
677        // Foreground color should NOT be red
678        assert_ne!(
679            frame.buffer.get(0, 0).unwrap().fg,
680            PackedRgba::RED,
681            "Span fg color should be ignored at NoStyling"
682        );
683    }
684
685    // --- MeasurableWidget tests ---
686
687    use crate::MeasurableWidget;
688    use ftui_core::geometry::Size;
689
690    #[test]
691    fn measure_simple_text() {
692        let para = Paragraph::new(Text::raw("Hello"));
693        let constraints = para.measure(Size::MAX);
694
695        // "Hello" is 5 chars wide, 1 line tall
696        assert_eq!(constraints.preferred, Size::new(5, 1));
697        assert_eq!(constraints.min.height, 1);
698        // Min width is the longest word = "Hello" = 5
699        assert_eq!(constraints.min.width, 5);
700    }
701
702    #[test]
703    fn measure_multiline_text() {
704        let para = Paragraph::new(Text::raw("Line1\nLine22\nL3"));
705        let constraints = para.measure(Size::MAX);
706
707        // Max width is "Line22" = 6, height = 3 lines
708        assert_eq!(constraints.preferred, Size::new(6, 3));
709        assert_eq!(constraints.min.height, 1);
710        // Min width is longest word = "Line22" = 6
711        assert_eq!(constraints.min.width, 6);
712    }
713
714    #[test]
715    fn measure_with_block() {
716        let block = crate::block::Block::bordered();
717        let para = Paragraph::new(Text::raw("Hi")).block(block);
718        let constraints = para.measure(Size::MAX);
719
720        // "Hi" = 2 wide, 1 tall, plus 2 for borders on each axis
721        assert_eq!(constraints.preferred, Size::new(4, 3));
722        // Min width: "Hi" = 2 + 2 (borders) = 4
723        assert_eq!(constraints.min.width, 4);
724        // Min height: 1 + 2 (borders) = 3
725        assert_eq!(constraints.min.height, 3);
726    }
727
728    #[test]
729    fn measure_with_word_wrap() {
730        let para = Paragraph::new(Text::raw("hello world")).wrap(WrapMode::Word);
731        // Measure with narrow available width
732        let constraints = para.measure(Size::new(6, 10));
733
734        // With 6 chars available, "hello" fits, "world" wraps
735        // Preferred width = 6 (available), height = 2 lines
736        assert_eq!(constraints.preferred.height, 2);
737        // Min width is longest word = "hello" = 5
738        assert_eq!(constraints.min.width, 5);
739    }
740
741    #[test]
742    fn measure_empty_text() {
743        let para = Paragraph::new(Text::raw(""));
744        let constraints = para.measure(Size::MAX);
745
746        // Empty text: 0 width, 0 height (no lines)
747        assert_eq!(constraints.preferred.width, 0);
748        assert_eq!(constraints.preferred.height, 0);
749        // Min height is 0 for empty text (no content to display)
750        // This ensures min <= preferred invariant holds
751        assert_eq!(constraints.min.height, 0);
752    }
753
754    #[test]
755    fn calculate_min_width_single_long_word() {
756        let para = Paragraph::new(Text::raw("supercalifragilistic"));
757        assert_eq!(para.calculate_min_width(), 20);
758    }
759
760    #[test]
761    fn calculate_min_width_multiple_words() {
762        let para = Paragraph::new(Text::raw("the quick brown fox"));
763        // Longest word is "quick" or "brown" = 5
764        assert_eq!(para.calculate_min_width(), 5);
765    }
766
767    #[test]
768    fn calculate_min_width_multiline() {
769        let para = Paragraph::new(Text::raw("short\nlongword\na"));
770        // Longest word is "longword" = 8
771        assert_eq!(para.calculate_min_width(), 8);
772    }
773
774    #[test]
775    fn estimate_wrapped_height_no_wrap_needed() {
776        let para = Paragraph::new(Text::raw("short")).wrap(WrapMode::Word);
777        // Width 10 is enough for "short" (5 chars)
778        assert_eq!(para.estimate_wrapped_height(10), 1);
779    }
780
781    #[test]
782    fn estimate_wrapped_height_needs_wrap() {
783        let para = Paragraph::new(Text::raw("hello world")).wrap(WrapMode::Word);
784        // Width 6: "hello " fits (6 chars), "world" (5 chars) wraps
785        assert_eq!(para.estimate_wrapped_height(6), 2);
786    }
787
788    #[test]
789    fn has_intrinsic_size() {
790        let para = Paragraph::new(Text::raw("test"));
791        assert!(para.has_intrinsic_size());
792    }
793
794    #[test]
795    fn measure_is_pure() {
796        let para = Paragraph::new(Text::raw("Hello World"));
797        let a = para.measure(Size::new(100, 50));
798        let b = para.measure(Size::new(100, 50));
799        assert_eq!(a, b);
800    }
801}