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, clear_text_area, draw_text_span_scrolled, draw_text_span_with_link};
6use ahash::AHashMap;
7use ftui_core::geometry::{Rect, Size};
8use ftui_render::frame::Frame;
9use ftui_style::Style;
10use ftui_text::{Line, Span, Text as FtuiText, WrapMode, display_width, graphemes};
11use std::cell::RefCell;
12use std::collections::VecDeque;
13use std::hash::{DefaultHasher, Hash, Hasher};
14use std::sync::Arc;
15
16type Text = FtuiText<'static>;
17
18const PARAGRAPH_METRICS_CACHE_CAPACITY: usize = 256;
19const PARAGRAPH_WRAP_CACHE_CAPACITY: usize = 256;
20
21#[derive(Debug, Clone)]
22struct CachedParagraphMetrics {
23    text_width: usize,
24    text_height: usize,
25    min_width: usize,
26    line_widths: Arc<[usize]>,
27}
28
29#[derive(Debug, Clone)]
30struct CachedWrappedParagraph {
31    lines: Arc<[Line<'static>]>,
32    line_widths: Arc<[usize]>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36struct ParagraphWrapCacheKey {
37    text_hash: u64,
38    wrap_mode: WrapMode,
39    width: usize,
40}
41
42#[derive(Debug, Default)]
43struct ParagraphCacheState {
44    metrics: AHashMap<u64, CachedParagraphMetrics>,
45    metrics_fifo: VecDeque<u64>,
46    wrapped: AHashMap<ParagraphWrapCacheKey, CachedWrappedParagraph>,
47    wrapped_fifo: VecDeque<ParagraphWrapCacheKey>,
48}
49
50impl ParagraphCacheState {
51    fn insert_metrics(&mut self, key: u64, value: CachedParagraphMetrics) {
52        cache_insert(
53            &mut self.metrics,
54            &mut self.metrics_fifo,
55            PARAGRAPH_METRICS_CACHE_CAPACITY,
56            key,
57            value,
58        );
59    }
60
61    fn insert_wrapped(&mut self, key: ParagraphWrapCacheKey, value: CachedWrappedParagraph) {
62        cache_insert(
63            &mut self.wrapped,
64            &mut self.wrapped_fifo,
65            PARAGRAPH_WRAP_CACHE_CAPACITY,
66            key,
67            value,
68        );
69    }
70}
71
72thread_local! {
73    static PARAGRAPH_CACHE: RefCell<ParagraphCacheState> = RefCell::new(ParagraphCacheState::default());
74}
75
76fn cache_insert<K, V>(
77    map: &mut AHashMap<K, V>,
78    fifo: &mut VecDeque<K>,
79    capacity: usize,
80    key: K,
81    value: V,
82) where
83    K: Copy + Eq + Hash,
84{
85    if !map.contains_key(&key) {
86        if map.len() >= capacity
87            && let Some(oldest) = fifo.pop_front()
88        {
89            map.remove(&oldest);
90        }
91        fifo.push_back(key);
92    }
93    map.insert(key, value);
94}
95
96fn text_into_owned(text: FtuiText<'_>) -> FtuiText<'static> {
97    FtuiText::from_lines(
98        text.into_iter()
99            .map(|line| Line::from_spans(line.into_iter().map(Span::into_owned))),
100    )
101}
102
103/// A widget that renders multi-line styled text.
104#[derive(Debug, Clone, Default)]
105pub struct Paragraph<'a> {
106    text: Text,
107    block: Option<Block<'a>>,
108    style: Style,
109    wrap: Option<WrapMode>,
110    alignment: Alignment,
111    scroll: (u16, u16),
112}
113
114fn hash_value<T: Hash>(value: &T) -> u64 {
115    let mut hasher = DefaultHasher::new();
116    value.hash(&mut hasher);
117    hasher.finish()
118}
119
120fn line_min_width(line: &Line<'_>) -> usize {
121    let mut max_word_width = 0;
122    let mut current_word_width = 0;
123
124    for span in line.spans() {
125        for grapheme in graphemes(span.content.as_ref()) {
126            let grapheme_width = display_width(grapheme);
127            if grapheme.chars().all(char::is_whitespace) {
128                max_word_width = max_word_width.max(current_word_width);
129                current_word_width = 0;
130            } else {
131                current_word_width += grapheme_width;
132            }
133        }
134    }
135
136    max_word_width.max(current_word_width)
137}
138
139impl<'a> Paragraph<'a> {
140    /// Create a new paragraph from the given text.
141    #[must_use]
142    pub fn new<'t>(text: impl Into<FtuiText<'t>>) -> Self {
143        Self {
144            text: text_into_owned(text.into()),
145            block: None,
146            style: Style::default(),
147            wrap: None,
148            alignment: Alignment::Left,
149            scroll: (0, 0),
150        }
151    }
152
153    /// Set the surrounding block.
154    #[must_use]
155    pub fn block(mut self, block: Block<'a>) -> Self {
156        self.block = Some(block);
157        self
158    }
159
160    /// Set the base text style.
161    #[must_use]
162    pub fn style(mut self, style: Style) -> Self {
163        self.style = style;
164        self
165    }
166
167    /// Set the text wrapping mode.
168    #[must_use]
169    pub fn wrap(mut self, wrap: WrapMode) -> Self {
170        self.wrap = Some(wrap);
171        self
172    }
173
174    /// Set the text alignment.
175    #[must_use]
176    pub fn alignment(mut self, alignment: Alignment) -> Self {
177        self.alignment = alignment;
178        self
179    }
180
181    /// Set the scroll offset as (vertical, horizontal).
182    #[must_use]
183    pub fn scroll(mut self, offset: (u16, u16)) -> Self {
184        self.scroll = offset;
185        self
186    }
187
188    fn text_hash(&self) -> u64 {
189        hash_value(&self.text)
190    }
191
192    fn cached_metrics(&self) -> CachedParagraphMetrics {
193        let text_hash = self.text_hash();
194        PARAGRAPH_CACHE.with(|cache| {
195            let mut cache = cache.borrow_mut();
196            if let Some(metrics) = cache.metrics.get(&text_hash) {
197                return metrics.clone();
198            }
199
200            let mut text_width = 0usize;
201            let mut min_width = 0usize;
202            let mut line_widths = Vec::with_capacity(self.text.lines().len());
203
204            for line in self.text.lines() {
205                let width = line.width();
206                text_width = text_width.max(width);
207                min_width = min_width.max(line_min_width(line));
208                line_widths.push(width);
209            }
210
211            let metrics = CachedParagraphMetrics {
212                text_width,
213                text_height: self.text.height(),
214                min_width: if min_width == 0 {
215                    text_width
216                } else {
217                    min_width
218                },
219                line_widths: Arc::from(line_widths),
220            };
221
222            cache.insert_metrics(text_hash, metrics.clone());
223            metrics
224        })
225    }
226
227    fn cached_wrapped_lines(&self, width: usize, wrap_mode: WrapMode) -> CachedWrappedParagraph {
228        let key = ParagraphWrapCacheKey {
229            text_hash: self.text_hash(),
230            wrap_mode,
231            width,
232        };
233
234        PARAGRAPH_CACHE.with(|cache| {
235            let mut cache = cache.borrow_mut();
236            if let Some(wrapped) = cache.wrapped.get(&key) {
237                return wrapped.clone();
238            }
239
240            let mut lines = Vec::new();
241            let mut line_widths = Vec::new();
242
243            for line in self.text.lines() {
244                let line_width = line.width();
245                if wrap_mode == WrapMode::None || line_width <= width {
246                    lines.push(line.clone());
247                    line_widths.push(line_width);
248                    continue;
249                }
250
251                let wrapped_lines = line.wrap(width, wrap_mode);
252                if wrapped_lines.is_empty() {
253                    lines.push(Line::new());
254                    line_widths.push(0);
255                    continue;
256                }
257
258                for wrapped_line in wrapped_lines {
259                    line_widths.push(wrapped_line.width());
260                    lines.push(wrapped_line);
261                }
262            }
263
264            let wrapped = CachedWrappedParagraph {
265                lines: Arc::from(lines),
266                line_widths: Arc::from(line_widths),
267            };
268
269            cache.insert_wrapped(key, wrapped.clone());
270            wrapped
271        })
272    }
273}
274
275impl Widget for Paragraph<'_> {
276    fn render(&self, area: Rect, frame: &mut Frame) {
277        #[cfg(feature = "tracing")]
278        let _span = tracing::debug_span!(
279            "widget_render",
280            widget = "Paragraph",
281            x = area.x,
282            y = area.y,
283            w = area.width,
284            h = area.height
285        )
286        .entered();
287
288        let deg = frame.buffer.degradation;
289
290        // Skeleton+: clear the owned area so previously rendered content does not linger.
291        if !deg.render_content() {
292            clear_text_area(frame, area, Style::default());
293            return;
294        }
295
296        // Special-case: an empty Paragraph with no Block is commonly used as a screen-clear.
297        // In that mode we must clear cell *content* (not just paint style), otherwise old
298        // borders/characters can bleed through Flex gaps.
299        let style = if deg.apply_styling() {
300            self.style
301        } else {
302            Style::default()
303        };
304        if self.block.is_none() && self.text.is_empty() {
305            clear_text_area(frame, area, style);
306            return;
307        }
308
309        clear_text_area(frame, area, style);
310
311        let text_area = match self.block {
312            Some(ref b) => {
313                b.render(area, frame);
314                b.inner(area)
315            }
316            None => area,
317        };
318
319        if text_area.is_empty() {
320            return;
321        }
322
323        // At NoStyling, render text without per-span styles
324        // Background is already applied for the whole area via `set_style_area()`. When drawing
325        // text we avoid re-applying the same background, otherwise semi-transparent BG colors
326        // get composited multiple times.
327        let mut text_style = style;
328        text_style.bg = None;
329
330        let mut y = text_area.y;
331        let mut current_visual_line = 0;
332        let scroll_offset = self.scroll.0 as usize;
333
334        let mut render_line = |line: &ftui_text::Line, line_width: usize, y: u16| {
335            let scroll_x = self.scroll.1;
336            let start_x = align_x(text_area, line_width, self.alignment);
337
338            // Let's iterate spans.
339            // `span_visual_offset`: relative to line start.
340            let mut span_visual_offset = 0;
341
342            // Alignment offset relative to text_area.x
343            let alignment_offset = start_x.saturating_sub(text_area.x);
344
345            for span in line.spans() {
346                let span_width = span.width();
347
348                // Effective position of this span relative to text_area.x
349                // pos = alignment_offset + span_visual_offset - scroll_x
350                let line_rel_start = alignment_offset.saturating_add(span_visual_offset);
351
352                // Check visibility
353                if line_rel_start.saturating_add(span_width as u16) <= scroll_x {
354                    // Fully scrolled out to the left
355                    span_visual_offset = span_visual_offset.saturating_add(span_width as u16);
356                    continue;
357                }
358
359                // Calculate actual draw position
360                let draw_x;
361                let local_scroll;
362
363                if line_rel_start < scroll_x {
364                    // Partially scrolled out left
365                    draw_x = text_area.x;
366                    local_scroll = scroll_x - line_rel_start;
367                } else {
368                    // Start is visible
369                    draw_x = text_area.x.saturating_add(line_rel_start - scroll_x);
370                    local_scroll = 0;
371                }
372
373                if draw_x >= text_area.right() {
374                    // Fully clipped to the right
375                    break;
376                }
377
378                // At NoStyling+, ignore span-level styles entirely
379                let span_style = if deg.apply_styling() {
380                    match span.style {
381                        Some(s) => s.merge(&text_style),
382                        None => text_style,
383                    }
384                } else {
385                    text_style // Style::default() at NoStyling
386                };
387
388                if local_scroll > 0 {
389                    draw_text_span_scrolled(
390                        frame,
391                        draw_x,
392                        y,
393                        span.content.as_ref(),
394                        span_style,
395                        text_area.right(),
396                        local_scroll,
397                        span.link.as_deref(),
398                    );
399                } else {
400                    draw_text_span_with_link(
401                        frame,
402                        draw_x,
403                        y,
404                        span.content.as_ref(),
405                        span_style,
406                        text_area.right(),
407                        span.link.as_deref(),
408                    );
409                }
410
411                span_visual_offset = span_visual_offset.saturating_add(span_width as u16);
412            }
413        };
414
415        let metrics = self.cached_metrics();
416        let rendered_lines: Option<CachedWrappedParagraph> = self
417            .wrap
418            .map(|wrap_mode| self.cached_wrapped_lines(text_area.width as usize, wrap_mode));
419
420        if let Some(wrapped) = rendered_lines {
421            for (line, line_width) in wrapped.lines.iter().zip(wrapped.line_widths.iter()) {
422                if current_visual_line < scroll_offset {
423                    current_visual_line += 1;
424                    continue;
425                }
426                if y >= text_area.bottom() {
427                    break;
428                }
429                render_line(line, *line_width, y);
430                y = y.saturating_add(1);
431                current_visual_line += 1;
432            }
433        } else {
434            for (line, line_width) in self.text.lines().iter().zip(metrics.line_widths.iter()) {
435                if current_visual_line < scroll_offset {
436                    current_visual_line += 1;
437                    continue;
438                }
439                if y >= text_area.bottom() {
440                    break;
441                }
442                render_line(line, *line_width, y);
443                y = y.saturating_add(1);
444                current_visual_line += 1;
445            }
446        }
447    }
448}
449impl MeasurableWidget for Paragraph<'_> {
450    fn measure(&self, available: Size) -> SizeConstraints {
451        let metrics = self.cached_metrics();
452        let text_width = metrics.text_width;
453        let text_height = metrics.text_height;
454        let min_width = metrics.min_width;
455
456        // Get block chrome if present
457        let (chrome_width, chrome_height) = self
458            .block
459            .as_ref()
460            .map(|b| b.chrome_size())
461            .unwrap_or((0, 0));
462
463        // If wrapping is enabled, calculate wrapped height
464        let (preferred_width, preferred_height) =
465            if self.wrap.is_some_and(|mode| mode != WrapMode::None) {
466                // When wrapping, preferred width is either the text width or available width
467                let wrap_width = if available.width > chrome_width {
468                    (available.width - chrome_width) as usize
469                } else {
470                    1
471                };
472
473                let wrapped_height = self
474                    .wrap
475                    .map(|wrap_mode| self.cached_wrapped_lines(wrap_width, wrap_mode).lines.len())
476                    .unwrap_or(text_height);
477
478                // Preferred width is min(text_width, available_width - chrome)
479                let pref_w = text_width.min(wrap_width);
480                (pref_w, wrapped_height)
481            } else {
482                // No wrapping: preferred is natural text dimensions
483                (text_width, text_height)
484            };
485
486        // Convert to u16, saturating at MAX
487        let min_w = (min_width as u16).saturating_add(chrome_width);
488        // Only require 1 line minimum if there's actual content
489        let min_h = if preferred_height > 0 {
490            (1u16).saturating_add(chrome_height)
491        } else {
492            chrome_height
493        };
494
495        let pref_w = (preferred_width as u16).saturating_add(chrome_width);
496        let pref_h = (preferred_height as u16).saturating_add(chrome_height);
497
498        SizeConstraints {
499            min: Size::new(min_w, min_h),
500            preferred: Size::new(pref_w, pref_h),
501            max: None, // Paragraph can use additional space for scrolling
502        }
503    }
504
505    fn has_intrinsic_size(&self) -> bool {
506        // Paragraph always has intrinsic size based on its text content
507        true
508    }
509}
510
511impl Paragraph<'_> {
512    #[cfg_attr(not(test), allow(dead_code))]
513    fn calculate_min_width(&self) -> usize {
514        self.cached_metrics().min_width
515    }
516
517    #[cfg_attr(not(test), allow(dead_code))]
518    fn estimate_wrapped_height(&self, wrap_width: usize) -> usize {
519        if wrap_width == 0 {
520            return self.cached_metrics().text_height;
521        }
522
523        self.wrap
524            .map(|wrap_mode| self.cached_wrapped_lines(wrap_width, wrap_mode).lines.len())
525            .unwrap_or_else(|| self.cached_metrics().text_height)
526            .max(1)
527    }
528}
529
530/// Calculate the starting x position for a line given alignment.
531fn align_x(area: Rect, line_width: usize, alignment: Alignment) -> u16 {
532    let line_width_u16 = u16::try_from(line_width).unwrap_or(u16::MAX);
533    match alignment {
534        Alignment::Left => area.x,
535        Alignment::Center => area
536            .x
537            .saturating_add(area.width.saturating_sub(line_width_u16) / 2),
538        Alignment::Right => area
539            .x
540            .saturating_add(area.width.saturating_sub(line_width_u16)),
541    }
542}
543
544fn truncate_accessible_text(text: &str) -> String {
545    const ACCESSIBLE_TEXT_LIMIT: usize = 200;
546    const ACCESSIBLE_TEXT_PREFIX_LIMIT: usize = 197;
547
548    if text.chars().count() <= ACCESSIBLE_TEXT_LIMIT {
549        text.to_owned()
550    } else {
551        let mut prefix = String::new();
552        let mut prefix_chars = 0usize;
553
554        for grapheme in graphemes(text) {
555            let grapheme_chars = grapheme.chars().count();
556            if prefix_chars + grapheme_chars > ACCESSIBLE_TEXT_PREFIX_LIMIT {
557                break;
558            }
559            prefix.push_str(grapheme);
560            prefix_chars += grapheme_chars;
561        }
562
563        format!("{prefix}...")
564    }
565}
566
567// ============================================================================
568// Accessibility
569// ============================================================================
570
571impl ftui_a11y::Accessible for Paragraph<'_> {
572    fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
573        use ftui_a11y::node::{A11yNodeInfo, A11yRole};
574
575        let id = crate::a11y_node_id(area);
576
577        // Extract the plain-text content for the accessible name.
578        let name: String = self
579            .text
580            .lines()
581            .iter()
582            .map(|line| {
583                line.spans()
584                    .iter()
585                    .map(|span| span.content.as_ref())
586                    .collect::<Vec<_>>()
587                    .join("")
588            })
589            .collect::<Vec<_>>()
590            .join(" ");
591
592        let block_title = self.block.as_ref().and_then(|b| b.title_text());
593        let truncated_name = truncate_accessible_text(&name);
594
595        let mut node = A11yNodeInfo::new(id, A11yRole::Label, area);
596        if let Some(title) = block_title {
597            node = node.with_name(title);
598            if !name.is_empty() {
599                node = node.with_description(truncated_name);
600            }
601        } else if !name.is_empty() {
602            node = node.with_name(truncated_name);
603        }
604
605        vec![node]
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use ftui_render::grapheme_pool::GraphemePool;
613
614    fn raw_row_text(frame: &Frame, y: u16) -> String {
615        let width = frame.buffer.width();
616        let mut actual = String::new();
617        for x in 0..width {
618            let ch = frame
619                .buffer
620                .get(x, y)
621                .and_then(|cell| cell.content.as_char())
622                .unwrap_or(' ');
623            actual.push(ch);
624        }
625        actual
626    }
627
628    #[test]
629    fn render_simple_text() {
630        let para = Paragraph::new(Text::raw("Hello"));
631        let area = Rect::new(0, 0, 10, 1);
632        let mut pool = GraphemePool::new();
633        let mut frame = Frame::new(10, 1, &mut pool);
634        para.render(area, &mut frame);
635
636        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
637        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('o'));
638    }
639
640    #[test]
641    fn render_multiline_text() {
642        let para = Paragraph::new(Text::raw("AB\nCD"));
643        let area = Rect::new(0, 0, 5, 3);
644        let mut pool = GraphemePool::new();
645        let mut frame = Frame::new(5, 3, &mut pool);
646        para.render(area, &mut frame);
647
648        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
649        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('B'));
650        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('C'));
651        assert_eq!(frame.buffer.get(1, 1).unwrap().content.as_char(), Some('D'));
652    }
653
654    #[test]
655    fn render_centered_text() {
656        let para = Paragraph::new(Text::raw("Hi")).alignment(Alignment::Center);
657        let area = Rect::new(0, 0, 10, 1);
658        let mut pool = GraphemePool::new();
659        let mut frame = Frame::new(10, 1, &mut pool);
660        para.render(area, &mut frame);
661
662        // "Hi" is 2 wide, area is 10, so starts at (10-2)/2 = 4
663        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('H'));
664        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('i'));
665    }
666
667    #[test]
668    fn render_with_scroll() {
669        let para = Paragraph::new(Text::raw("Line1\nLine2\nLine3")).scroll((1, 0));
670        let area = Rect::new(0, 0, 10, 2);
671        let mut pool = GraphemePool::new();
672        let mut frame = Frame::new(10, 2, &mut pool);
673        para.render(area, &mut frame);
674
675        // Should skip Line1, show Line2 and Line3
676        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('L'));
677        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('2'));
678    }
679
680    #[test]
681    fn render_empty_area() {
682        let para = Paragraph::new(Text::raw("Hello"));
683        let area = Rect::new(0, 0, 0, 0);
684        let mut pool = GraphemePool::new();
685        let mut frame = Frame::new(1, 1, &mut pool);
686        para.render(area, &mut frame);
687    }
688
689    #[test]
690    fn line_min_width_tracks_words_across_spans() {
691        let line = Line::from_spans([
692            Span::raw("alpha"),
693            Span::styled(" ", Style::new().bold()),
694            Span::raw("beta"),
695            Span::raw("  "),
696            Span::raw("gamma"),
697        ]);
698
699        assert_eq!(line_min_width(&line), 5);
700    }
701
702    #[test]
703    fn measure_wrap_counts_cached_visual_lines() {
704        let para = Paragraph::new(Text::raw("hello world from cache")).wrap(WrapMode::Word);
705        let constraints = para.measure(Size::new(8, 10));
706
707        assert_eq!(constraints.preferred.height, 4);
708        assert_eq!(constraints.min.width, 5);
709    }
710
711    #[test]
712    fn measure_wrap_none_preserves_natural_width() {
713        let para = Paragraph::new(Text::raw("abcdef")).wrap(WrapMode::None);
714        let constraints = para.measure(Size::new(3, 10));
715
716        assert_eq!(constraints.preferred.width, 6);
717        assert_eq!(constraints.preferred.height, 1);
718    }
719
720    #[test]
721    fn render_empty_text_clears_content() {
722        let para = Paragraph::new("");
723        let area = Rect::new(0, 0, 3, 1);
724        let mut pool = GraphemePool::new();
725        let mut frame = Frame::new(3, 1, &mut pool);
726
727        // Seed with non-space content; an empty Paragraph render should clear it.
728        frame
729            .buffer
730            .fill(area, ftui_render::cell::Cell::from_char('X'));
731
732        para.render(area, &mut frame);
733
734        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
735        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some(' '));
736    }
737
738    #[test]
739    fn render_right_aligned() {
740        let para = Paragraph::new(Text::raw("Hi")).alignment(Alignment::Right);
741        let area = Rect::new(0, 0, 10, 1);
742        let mut pool = GraphemePool::new();
743        let mut frame = Frame::new(10, 1, &mut pool);
744        para.render(area, &mut frame);
745
746        // "Hi" is 2 wide, area is 10, so starts at 10-2 = 8
747        assert_eq!(frame.buffer.get(8, 0).unwrap().content.as_char(), Some('H'));
748        assert_eq!(frame.buffer.get(9, 0).unwrap().content.as_char(), Some('i'));
749    }
750
751    #[test]
752    fn render_with_word_wrap() {
753        let para = Paragraph::new(Text::raw("hello world")).wrap(WrapMode::Word);
754        let area = Rect::new(0, 0, 6, 3);
755        let mut pool = GraphemePool::new();
756        let mut frame = Frame::new(6, 3, &mut pool);
757        para.render(area, &mut frame);
758
759        // "hello " fits in 6, " world" wraps to next line
760        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('h'));
761        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('w'));
762    }
763
764    #[test]
765    fn render_with_char_wrap() {
766        let para = Paragraph::new(Text::raw("abcdefgh")).wrap(WrapMode::Char);
767        let area = Rect::new(0, 0, 4, 3);
768        let mut pool = GraphemePool::new();
769        let mut frame = Frame::new(4, 3, &mut pool);
770        para.render(area, &mut frame);
771
772        // First line: abcd
773        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('a'));
774        assert_eq!(frame.buffer.get(3, 0).unwrap().content.as_char(), Some('d'));
775        // Second line: efgh
776        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('e'));
777    }
778
779    #[test]
780    fn scroll_past_all_lines() {
781        let para = Paragraph::new(Text::raw("AB")).scroll((5, 0));
782        let area = Rect::new(0, 0, 5, 2);
783        let mut pool = GraphemePool::new();
784        let mut frame = Frame::new(5, 2, &mut pool);
785        para.render(area, &mut frame);
786
787        // All lines skipped, but the paragraph still owns and clears its area.
788        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
789    }
790
791    #[test]
792    fn render_shorter_text_clears_stale_suffix_and_extra_lines() {
793        let area = Rect::new(0, 0, 8, 2);
794        let mut pool = GraphemePool::new();
795        let mut frame = Frame::new(8, 2, &mut pool);
796
797        Paragraph::new(Text::raw("Hello\nWorld")).render(area, &mut frame);
798        Paragraph::new(Text::raw("Hi")).render(area, &mut frame);
799
800        assert_eq!(raw_row_text(&frame, 0), "Hi      ");
801        assert_eq!(raw_row_text(&frame, 1), "        ");
802    }
803
804    #[test]
805    fn render_clipped_at_area_height() {
806        let para = Paragraph::new(Text::raw("A\nB\nC\nD\nE"));
807        let area = Rect::new(0, 0, 5, 2);
808        let mut pool = GraphemePool::new();
809        let mut frame = Frame::new(5, 2, &mut pool);
810        para.render(area, &mut frame);
811
812        // Only first 2 lines should render
813        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
814        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('B'));
815    }
816
817    #[test]
818    fn render_clipped_at_area_width() {
819        let para = Paragraph::new(Text::raw("ABCDEF"));
820        let area = Rect::new(0, 0, 3, 1);
821        let mut pool = GraphemePool::new();
822        let mut frame = Frame::new(3, 1, &mut pool);
823        para.render(area, &mut frame);
824
825        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
826        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
827    }
828
829    #[test]
830    fn align_x_left() {
831        let area = Rect::new(5, 0, 20, 1);
832        assert_eq!(align_x(area, 10, Alignment::Left), 5);
833    }
834
835    #[test]
836    fn align_x_center() {
837        let area = Rect::new(0, 0, 20, 1);
838        // line_width=6, area=20, so (20-6)/2 = 7
839        assert_eq!(align_x(area, 6, Alignment::Center), 7);
840    }
841
842    #[test]
843    fn align_x_right() {
844        let area = Rect::new(0, 0, 20, 1);
845        // line_width=5, area=20, so 20-5 = 15
846        assert_eq!(align_x(area, 5, Alignment::Right), 15);
847    }
848
849    #[test]
850    fn align_x_wide_line_saturates() {
851        let area = Rect::new(0, 0, 10, 1);
852        // line wider than area: should saturate to area.x
853        assert_eq!(align_x(area, 20, Alignment::Right), 0);
854        assert_eq!(align_x(area, 20, Alignment::Center), 0);
855    }
856
857    #[test]
858    fn builder_methods_chain() {
859        let para = Paragraph::new(Text::raw("test"))
860            .style(Style::default())
861            .wrap(WrapMode::Word)
862            .alignment(Alignment::Center)
863            .scroll((1, 2));
864        // Verify it builds without panic
865        let area = Rect::new(0, 0, 10, 5);
866        let mut pool = GraphemePool::new();
867        let mut frame = Frame::new(10, 5, &mut pool);
868        para.render(area, &mut frame);
869    }
870
871    #[test]
872    fn render_at_offset_area() {
873        let para = Paragraph::new(Text::raw("X"));
874        let area = Rect::new(3, 4, 5, 2);
875        let mut pool = GraphemePool::new();
876        let mut frame = Frame::new(10, 10, &mut pool);
877        para.render(area, &mut frame);
878
879        assert_eq!(frame.buffer.get(3, 4).unwrap().content.as_char(), Some('X'));
880        // Cell at (0,0) should be empty
881        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
882    }
883
884    #[test]
885    fn wrap_clipped_at_area_bottom() {
886        // Long wrapped text should stop at area height
887        let para = Paragraph::new(Text::raw("abcdefghijklmnop")).wrap(WrapMode::Char);
888        let area = Rect::new(0, 0, 4, 2);
889        let mut pool = GraphemePool::new();
890        let mut frame = Frame::new(4, 2, &mut pool);
891        para.render(area, &mut frame);
892
893        // Only 2 rows of 4 chars each
894        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('a'));
895        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('e'));
896    }
897
898    // --- Degradation tests ---
899
900    #[test]
901    fn degradation_skeleton_skips_content() {
902        use ftui_render::budget::DegradationLevel;
903
904        let para = Paragraph::new(Text::raw("Hello"));
905        let area = Rect::new(0, 0, 10, 1);
906        let mut pool = GraphemePool::new();
907        let mut frame = Frame::new(10, 1, &mut pool);
908        Paragraph::new(Text::raw("Stale")).render(area, &mut frame);
909        frame.set_degradation(DegradationLevel::Skeleton);
910        para.render(area, &mut frame);
911
912        // Skeleton clears previously rendered content instead of leaving it behind.
913        assert_eq!(raw_row_text(&frame, 0), "          ");
914    }
915
916    #[test]
917    fn degradation_full_renders_content() {
918        use ftui_render::budget::DegradationLevel;
919
920        let para = Paragraph::new(Text::raw("Hello"));
921        let area = Rect::new(0, 0, 10, 1);
922        let mut pool = GraphemePool::new();
923        let mut frame = Frame::new(10, 1, &mut pool);
924        frame.set_degradation(DegradationLevel::Full);
925        para.render(area, &mut frame);
926
927        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
928    }
929
930    #[test]
931    fn degradation_essential_only_still_renders_text() {
932        use ftui_render::budget::DegradationLevel;
933
934        let para = Paragraph::new(Text::raw("Hello"));
935        let area = Rect::new(0, 0, 10, 1);
936        let mut pool = GraphemePool::new();
937        let mut frame = Frame::new(10, 1, &mut pool);
938        frame.set_degradation(DegradationLevel::EssentialOnly);
939        para.render(area, &mut frame);
940
941        // EssentialOnly still renders content (< Skeleton)
942        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
943    }
944
945    #[test]
946    fn degradation_no_styling_ignores_span_styles() {
947        use ftui_render::budget::DegradationLevel;
948        use ftui_render::cell::PackedRgba;
949        use ftui_text::{Line, Span};
950
951        // Create text with a styled span
952        let styled_span = Span::styled("Hello", Style::new().fg(PackedRgba::RED));
953        let line = Line::from_spans([styled_span]);
954        let text = Text::from(line);
955        let para = Paragraph::new(text);
956        let area = Rect::new(0, 0, 10, 1);
957        let mut pool = GraphemePool::new();
958        let mut frame = Frame::new(10, 1, &mut pool);
959        frame.set_degradation(DegradationLevel::NoStyling);
960        para.render(area, &mut frame);
961
962        // Text should render but span style should be ignored
963        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
964        // Foreground color should NOT be red
965        assert_ne!(
966            frame.buffer.get(0, 0).unwrap().fg,
967            PackedRgba::RED,
968            "Span fg color should be ignored at NoStyling"
969        );
970    }
971
972    // --- MeasurableWidget tests ---
973
974    use crate::MeasurableWidget;
975    use ftui_core::geometry::Size;
976
977    #[test]
978    fn measure_simple_text() {
979        let para = Paragraph::new(Text::raw("Hello"));
980        let constraints = para.measure(Size::MAX);
981
982        // "Hello" is 5 chars wide, 1 line tall
983        assert_eq!(constraints.preferred, Size::new(5, 1));
984        assert_eq!(constraints.min.height, 1);
985        // Min width is the longest word = "Hello" = 5
986        assert_eq!(constraints.min.width, 5);
987    }
988
989    #[test]
990    fn measure_multiline_text() {
991        let para = Paragraph::new(Text::raw("Line1\nLine22\nL3"));
992        let constraints = para.measure(Size::MAX);
993
994        // Max width is "Line22" = 6, height = 3 lines
995        assert_eq!(constraints.preferred, Size::new(6, 3));
996        assert_eq!(constraints.min.height, 1);
997        // Min width is longest word = "Line22" = 6
998        assert_eq!(constraints.min.width, 6);
999    }
1000
1001    #[test]
1002    fn measure_with_block() {
1003        let block = crate::block::Block::bordered();
1004        let para = Paragraph::new(Text::raw("Hi")).block(block);
1005        let constraints = para.measure(Size::MAX);
1006
1007        // "Hi" = 2 wide, 1 tall, plus chrome (borders + padding) = 4 on each axis.
1008        assert_eq!(constraints.preferred, Size::new(6, 5));
1009        assert_eq!(constraints.min.width, 6);
1010        assert_eq!(constraints.min.height, 5);
1011    }
1012
1013    #[test]
1014    fn measure_with_word_wrap() {
1015        let para = Paragraph::new(Text::raw("hello world")).wrap(WrapMode::Word);
1016        // Measure with narrow available width
1017        let constraints = para.measure(Size::new(6, 10));
1018
1019        // With 6 chars available, "hello" fits, "world" wraps
1020        // Preferred width = 6 (available), height = 2 lines
1021        assert_eq!(constraints.preferred.height, 2);
1022        // Min width is longest word = "hello" = 5
1023        assert_eq!(constraints.min.width, 5);
1024    }
1025
1026    #[test]
1027    fn measure_empty_text() {
1028        let para = Paragraph::new(Text::raw(""));
1029        let constraints = para.measure(Size::MAX);
1030
1031        // Empty text: 0 width, 0 height (no lines)
1032        assert_eq!(constraints.preferred.width, 0);
1033        assert_eq!(constraints.preferred.height, 0);
1034        // Min height is 0 for empty text (no content to display)
1035        // This ensures min <= preferred invariant holds
1036        assert_eq!(constraints.min.height, 0);
1037    }
1038
1039    #[test]
1040    fn calculate_min_width_single_long_word() {
1041        let para = Paragraph::new(Text::raw("supercalifragilistic"));
1042        assert_eq!(para.calculate_min_width(), 20);
1043    }
1044
1045    #[test]
1046    fn calculate_min_width_multiple_words() {
1047        let para = Paragraph::new(Text::raw("the quick brown fox"));
1048        // Longest word is "quick" or "brown" = 5
1049        assert_eq!(para.calculate_min_width(), 5);
1050    }
1051
1052    #[test]
1053    fn calculate_min_width_multiline() {
1054        let para = Paragraph::new(Text::raw("short\nlongword\na"));
1055        // Longest word is "longword" = 8
1056        assert_eq!(para.calculate_min_width(), 8);
1057    }
1058
1059    #[test]
1060    fn estimate_wrapped_height_no_wrap_needed() {
1061        let para = Paragraph::new(Text::raw("short")).wrap(WrapMode::Word);
1062        // Width 10 is enough for "short" (5 chars)
1063        assert_eq!(para.estimate_wrapped_height(10), 1);
1064    }
1065
1066    #[test]
1067    fn estimate_wrapped_height_needs_wrap() {
1068        let para = Paragraph::new(Text::raw("hello world")).wrap(WrapMode::Word);
1069        // Width 6: "hello " fits (6 chars), "world" (5 chars) wraps
1070        assert_eq!(para.estimate_wrapped_height(6), 2);
1071    }
1072
1073    #[test]
1074    fn has_intrinsic_size() {
1075        let para = Paragraph::new(Text::raw("test"));
1076        assert!(para.has_intrinsic_size());
1077    }
1078
1079    #[test]
1080    fn measure_is_pure() {
1081        let para = Paragraph::new(Text::raw("Hello World"));
1082        let a = para.measure(Size::new(100, 50));
1083        let b = para.measure(Size::new(100, 50));
1084        assert_eq!(a, b);
1085    }
1086
1087    #[test]
1088    fn accessibility_truncates_long_unicode_without_panicking() {
1089        use ftui_a11y::Accessible;
1090
1091        let para = Paragraph::new(Text::raw("界".repeat(210)));
1092        let nodes = para.accessibility_nodes(Rect::new(0, 0, 10, 1));
1093        let name = nodes[0]
1094            .name
1095            .as_deref()
1096            .expect("paragraph should have a name");
1097
1098        assert!(name.ends_with("..."));
1099        assert_eq!(name.chars().count(), 200);
1100    }
1101
1102    #[test]
1103    fn accessibility_truncates_description_when_block_title_present() {
1104        use ftui_a11y::Accessible;
1105
1106        let para =
1107            Paragraph::new(Text::raw("界".repeat(210))).block(Block::bordered().title("Body"));
1108        let nodes = para.accessibility_nodes(Rect::new(0, 0, 10, 1));
1109        let node = &nodes[0];
1110
1111        assert_eq!(node.name.as_deref(), Some("Body"));
1112        let description = node
1113            .description
1114            .as_deref()
1115            .expect("paragraph should have a description");
1116        assert!(description.ends_with("..."));
1117        assert_eq!(description.chars().count(), 200);
1118    }
1119
1120    #[test]
1121    fn accessibility_preserves_exactly_200_chars_without_ellipsis() {
1122        use ftui_a11y::Accessible;
1123
1124        let para = Paragraph::new(Text::raw("界".repeat(200)));
1125        let nodes = para.accessibility_nodes(Rect::new(0, 0, 10, 1));
1126        let name = nodes[0]
1127            .name
1128            .as_deref()
1129            .expect("paragraph should have a name");
1130
1131        assert!(!name.ends_with("..."));
1132        assert_eq!(name.chars().count(), 200);
1133    }
1134
1135    #[test]
1136    fn accessibility_truncates_on_grapheme_boundaries() {
1137        use ftui_a11y::Accessible;
1138
1139        let para = Paragraph::new(Text::raw("e\u{301}".repeat(210)));
1140        let nodes = para.accessibility_nodes(Rect::new(0, 0, 10, 1));
1141        let name = nodes[0]
1142            .name
1143            .as_deref()
1144            .expect("paragraph should have a name");
1145
1146        let prefix = name
1147            .strip_suffix("...")
1148            .expect("paragraph should be truncated");
1149        assert!(name.chars().count() <= 200);
1150        assert_eq!(ftui_text::graphemes(prefix).count(), 98);
1151        assert!(prefix.ends_with("e\u{301}"));
1152    }
1153}