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 #[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 #[must_use]
155 pub fn block(mut self, block: Block<'a>) -> Self {
156 self.block = Some(block);
157 self
158 }
159
160 #[must_use]
162 pub fn style(mut self, style: Style) -> Self {
163 self.style = style;
164 self
165 }
166
167 #[must_use]
169 pub fn wrap(mut self, wrap: WrapMode) -> Self {
170 self.wrap = Some(wrap);
171 self
172 }
173
174 #[must_use]
176 pub fn alignment(mut self, alignment: Alignment) -> Self {
177 self.alignment = alignment;
178 self
179 }
180
181 #[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 if !deg.render_content() {
292 clear_text_area(frame, area, Style::default());
293 return;
294 }
295
296 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 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 mut span_visual_offset = 0;
341
342 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 let line_rel_start = alignment_offset.saturating_add(span_visual_offset);
351
352 if line_rel_start.saturating_add(span_width as u16) <= scroll_x {
354 span_visual_offset = span_visual_offset.saturating_add(span_width as u16);
356 continue;
357 }
358
359 let draw_x;
361 let local_scroll;
362
363 if line_rel_start < scroll_x {
364 draw_x = text_area.x;
366 local_scroll = scroll_x - line_rel_start;
367 } else {
368 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 break;
376 }
377
378 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 };
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 let (chrome_width, chrome_height) = self
458 .block
459 .as_ref()
460 .map(|b| b.chrome_size())
461 .unwrap_or((0, 0));
462
463 let (preferred_width, preferred_height) =
465 if self.wrap.is_some_and(|mode| mode != WrapMode::None) {
466 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 let pref_w = text_width.min(wrap_width);
480 (pref_w, wrapped_height)
481 } else {
482 (text_width, text_height)
484 };
485
486 let min_w = (min_width as u16).saturating_add(chrome_width);
488 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, }
503 }
504
505 fn has_intrinsic_size(&self) -> bool {
506 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
530fn 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
567impl 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
882 }
883
884 #[test]
885 fn wrap_clipped_at_area_bottom() {
886 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 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 #[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 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 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 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 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
964 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 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 assert_eq!(constraints.preferred, Size::new(5, 1));
984 assert_eq!(constraints.min.height, 1);
985 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 assert_eq!(constraints.preferred, Size::new(6, 3));
996 assert_eq!(constraints.min.height, 1);
997 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 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 let constraints = para.measure(Size::new(6, 10));
1018
1019 assert_eq!(constraints.preferred.height, 2);
1022 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 assert_eq!(constraints.preferred.width, 0);
1033 assert_eq!(constraints.preferred.height, 0);
1034 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 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 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 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 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}