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#[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 #[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 #[must_use]
159 pub fn from_static_text(text: FtuiText<'static>) -> Self {
160 Self::with_static_text(text)
161 }
162
163 #[must_use]
165 pub fn from_static_str(text: &'static str) -> Self {
166 Self::from_static_text(FtuiText::raw(text))
167 }
168
169 #[must_use]
171 pub fn block(mut self, block: Block<'a>) -> Self {
172 self.block = Some(block);
173 self
174 }
175
176 #[must_use]
178 pub fn style(mut self, style: Style) -> Self {
179 self.style = style;
180 self
181 }
182
183 #[must_use]
185 pub fn wrap(mut self, wrap: WrapMode) -> Self {
186 self.wrap = Some(wrap);
187 self
188 }
189
190 #[must_use]
192 pub fn alignment(mut self, alignment: Alignment) -> Self {
193 self.alignment = alignment;
194 self
195 }
196
197 #[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 if !deg.render_content() {
308 clear_text_area(frame, area, Style::default());
309 return;
310 }
311
312 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 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 mut span_visual_offset = 0;
357
358 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 let line_rel_start = alignment_offset.saturating_add(span_visual_offset);
367
368 if line_rel_start.saturating_add(span_width as u16) <= scroll_x {
370 span_visual_offset = span_visual_offset.saturating_add(span_width as u16);
372 continue;
373 }
374
375 let draw_x;
377 let local_scroll;
378
379 if line_rel_start < scroll_x {
380 draw_x = text_area.x;
382 local_scroll = scroll_x - line_rel_start;
383 } else {
384 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 break;
392 }
393
394 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 };
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 let (chrome_width, chrome_height) = self
474 .block
475 .as_ref()
476 .map(|b| b.chrome_size())
477 .unwrap_or((0, 0));
478
479 let (preferred_width, preferred_height) =
481 if self.wrap.is_some_and(|mode| mode != WrapMode::None) {
482 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 let pref_w = text_width.min(wrap_width);
496 (pref_w, wrapped_height)
497 } else {
498 (text_width, text_height)
500 };
501
502 let min_w = (min_width as u16).saturating_add(chrome_width);
504 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, }
519 }
520
521 fn has_intrinsic_size(&self) -> bool {
522 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
546fn 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
583impl 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
923 }
924
925 #[test]
926 fn wrap_clipped_at_area_bottom() {
927 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 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 #[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 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 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 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 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
1005 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 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 assert_eq!(constraints.preferred, Size::new(5, 1));
1025 assert_eq!(constraints.min.height, 1);
1026 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 assert_eq!(constraints.preferred, Size::new(6, 3));
1037 assert_eq!(constraints.min.height, 1);
1038 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 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 let constraints = para.measure(Size::new(6, 10));
1059
1060 assert_eq!(constraints.preferred.height, 2);
1063 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 assert_eq!(constraints.preferred.width, 0);
1074 assert_eq!(constraints.preferred.height, 0);
1075 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 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 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 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 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}