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