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