1#![forbid(unsafe_code)]
2
3use crate::block::{Alignment, Block};
4use crate::measurable::{MeasurableWidget, SizeConstraints};
5use crate::{Widget, draw_text_span_scrolled, draw_text_span_with_link, set_style_area};
6use ftui_core::geometry::{Rect, Size};
7use ftui_render::frame::Frame;
8use ftui_style::Style;
9use ftui_text::{Text, WrapMode, display_width};
10
11#[derive(Debug, Clone, Default)]
13pub struct Paragraph<'a> {
14 text: Text,
15 block: Option<Block<'a>>,
16 style: Style,
17 wrap: Option<WrapMode>,
18 alignment: Alignment,
19 scroll: (u16, u16),
20}
21
22impl<'a> Paragraph<'a> {
23 #[must_use]
25 pub fn new(text: impl Into<Text>) -> Self {
26 Self {
27 text: text.into(),
28 block: None,
29 style: Style::default(),
30 wrap: None,
31 alignment: Alignment::Left,
32 scroll: (0, 0),
33 }
34 }
35
36 #[must_use]
38 pub fn block(mut self, block: Block<'a>) -> Self {
39 self.block = Some(block);
40 self
41 }
42
43 #[must_use]
45 pub fn style(mut self, style: Style) -> Self {
46 self.style = style;
47 self
48 }
49
50 #[must_use]
52 pub fn wrap(mut self, wrap: WrapMode) -> Self {
53 self.wrap = Some(wrap);
54 self
55 }
56
57 #[must_use]
59 pub fn alignment(mut self, alignment: Alignment) -> Self {
60 self.alignment = alignment;
61 self
62 }
63
64 #[must_use]
66 pub fn scroll(mut self, offset: (u16, u16)) -> Self {
67 self.scroll = offset;
68 self
69 }
70}
71
72impl Widget for Paragraph<'_> {
73 fn render(&self, area: Rect, frame: &mut Frame) {
74 #[cfg(feature = "tracing")]
75 let _span = tracing::debug_span!(
76 "widget_render",
77 widget = "Paragraph",
78 x = area.x,
79 y = area.y,
80 w = area.width,
81 h = area.height
82 )
83 .entered();
84
85 let deg = frame.buffer.degradation;
86
87 if !deg.render_content() {
89 return;
90 }
91
92 let style = if deg.apply_styling() {
96 self.style
97 } else {
98 Style::default()
99 };
100 if self.block.is_none() && self.text.is_empty() {
101 let mut cell = ftui_render::cell::Cell::from_char(' ');
102 crate::apply_style(&mut cell, style);
103 frame.buffer.fill(area, cell);
104 return;
105 }
106
107 if deg.apply_styling() {
108 set_style_area(&mut frame.buffer, area, self.style);
109 }
110
111 let text_area = match self.block {
112 Some(ref b) => {
113 b.render(area, frame);
114 b.inner(area)
115 }
116 None => area,
117 };
118
119 if text_area.is_empty() {
120 return;
121 }
122
123 let mut text_style = style;
128 text_style.bg = None;
129
130 let mut y = text_area.y;
131 let mut current_visual_line = 0;
132 let scroll_offset = self.scroll.0 as usize;
133
134 let mut render_line = |line: &ftui_text::Line, y: u16| {
135 let line_width: usize = line.width();
137
138 let scroll_x = self.scroll.1;
139 let start_x = align_x(text_area, line_width, self.alignment);
140
141 let mut span_visual_offset = 0;
144
145 let alignment_offset = start_x.saturating_sub(text_area.x);
147
148 for span in line.spans() {
149 let span_width = span.width();
150
151 let line_rel_start = alignment_offset.saturating_add(span_visual_offset);
154
155 if line_rel_start.saturating_add(span_width as u16) <= scroll_x {
157 span_visual_offset = span_visual_offset.saturating_add(span_width as u16);
159 continue;
160 }
161
162 let draw_x;
164 let local_scroll;
165
166 if line_rel_start < scroll_x {
167 draw_x = text_area.x;
169 local_scroll = scroll_x - line_rel_start;
170 } else {
171 draw_x = text_area.x.saturating_add(line_rel_start - scroll_x);
173 local_scroll = 0;
174 }
175
176 if draw_x >= text_area.right() {
177 break;
179 }
180
181 let span_style = if deg.apply_styling() {
183 match span.style {
184 Some(s) => s.merge(&text_style),
185 None => text_style,
186 }
187 } else {
188 text_style };
190
191 if local_scroll > 0 {
192 draw_text_span_scrolled(
193 frame,
194 draw_x,
195 y,
196 span.content.as_ref(),
197 span_style,
198 text_area.right(),
199 local_scroll,
200 span.link.as_deref(),
201 );
202 } else {
203 draw_text_span_with_link(
204 frame,
205 draw_x,
206 y,
207 span.content.as_ref(),
208 span_style,
209 text_area.right(),
210 span.link.as_deref(),
211 );
212 }
213
214 span_visual_offset = span_visual_offset.saturating_add(span_width as u16);
215 }
216 };
217
218 for line in self.text.lines() {
219 if y >= text_area.bottom() {
220 break;
221 }
222
223 if let Some(wrap_mode) = self.wrap {
225 let line_width = line.width();
226 if line_width > text_area.width as usize {
227 let wrapped = line.wrap(text_area.width as usize, wrap_mode);
228 for wrapped_line in &wrapped {
229 if current_visual_line < scroll_offset {
230 current_visual_line += 1;
231 continue;
232 }
233
234 if y >= text_area.bottom() {
235 break;
236 }
237
238 render_line(wrapped_line, y);
239 y += 1;
240 current_visual_line += 1;
241 }
242 continue;
243 }
244 }
245
246 if current_visual_line < scroll_offset {
248 current_visual_line += 1;
249 continue;
250 }
251
252 render_line(line, y);
253 y = y.saturating_add(1);
254 current_visual_line += 1;
255 }
256 }
257}
258impl MeasurableWidget for Paragraph<'_> {
259 fn measure(&self, available: Size) -> SizeConstraints {
260 let text_width = self.text.width();
262 let text_height = self.text.height();
263
264 let min_width = self.calculate_min_width();
267
268 let (chrome_width, chrome_height) = self
270 .block
271 .as_ref()
272 .map(|b| b.chrome_size())
273 .unwrap_or((0, 0));
274
275 let (preferred_width, preferred_height) = if self.wrap.is_some() {
277 let wrap_width = if available.width > chrome_width {
279 (available.width - chrome_width) as usize
280 } else {
281 1
282 };
283
284 let wrapped_height = self.estimate_wrapped_height(wrap_width);
286
287 let pref_w = text_width.min(wrap_width);
289 (pref_w, wrapped_height)
290 } else {
291 (text_width, text_height)
293 };
294
295 let min_w = (min_width as u16).saturating_add(chrome_width);
297 let min_h = if preferred_height > 0 {
299 (1u16).saturating_add(chrome_height)
300 } else {
301 chrome_height
302 };
303
304 let pref_w = (preferred_width as u16).saturating_add(chrome_width);
305 let pref_h = (preferred_height as u16).saturating_add(chrome_height);
306
307 SizeConstraints {
308 min: Size::new(min_w, min_h),
309 preferred: Size::new(pref_w, pref_h),
310 max: None, }
312 }
313
314 fn has_intrinsic_size(&self) -> bool {
315 true
317 }
318}
319
320impl Paragraph<'_> {
321 fn calculate_min_width(&self) -> usize {
323 let mut max_word_width = 0;
324
325 for line in self.text.lines() {
326 let plain = line.to_plain_text();
327 for word in plain.split_whitespace() {
329 let word_width = display_width(word);
330 max_word_width = max_word_width.max(word_width);
331 }
332 }
333
334 if max_word_width == 0 {
336 return self.text.width();
337 }
338
339 max_word_width
340 }
341
342 fn estimate_wrapped_height(&self, wrap_width: usize) -> usize {
344 if wrap_width == 0 {
345 return self.text.height();
346 }
347
348 let wrap_mode = self.wrap.unwrap_or(WrapMode::Word);
349 let mut total_lines = 0;
350
351 for line in self.text.lines() {
352 let line_width = line.width();
353 if wrap_mode == WrapMode::None || line_width <= wrap_width {
354 total_lines += 1;
355 continue;
356 }
357
358 let wrapped = line.wrap(wrap_width, wrap_mode);
360 total_lines += wrapped.len().max(1);
361 }
362
363 total_lines.max(1)
364 }
365}
366
367fn align_x(area: Rect, line_width: usize, alignment: Alignment) -> u16 {
369 let line_width_u16 = u16::try_from(line_width).unwrap_or(u16::MAX);
370 match alignment {
371 Alignment::Left => area.x,
372 Alignment::Center => area
373 .x
374 .saturating_add(area.width.saturating_sub(line_width_u16) / 2),
375 Alignment::Right => area
376 .x
377 .saturating_add(area.width.saturating_sub(line_width_u16)),
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use ftui_render::grapheme_pool::GraphemePool;
385
386 #[test]
387 fn render_simple_text() {
388 let para = Paragraph::new(Text::raw("Hello"));
389 let area = Rect::new(0, 0, 10, 1);
390 let mut pool = GraphemePool::new();
391 let mut frame = Frame::new(10, 1, &mut pool);
392 para.render(area, &mut frame);
393
394 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
395 assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('o'));
396 }
397
398 #[test]
399 fn render_multiline_text() {
400 let para = Paragraph::new(Text::raw("AB\nCD"));
401 let area = Rect::new(0, 0, 5, 3);
402 let mut pool = GraphemePool::new();
403 let mut frame = Frame::new(5, 3, &mut pool);
404 para.render(area, &mut frame);
405
406 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
407 assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('B'));
408 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('C'));
409 assert_eq!(frame.buffer.get(1, 1).unwrap().content.as_char(), Some('D'));
410 }
411
412 #[test]
413 fn render_centered_text() {
414 let para = Paragraph::new(Text::raw("Hi")).alignment(Alignment::Center);
415 let area = Rect::new(0, 0, 10, 1);
416 let mut pool = GraphemePool::new();
417 let mut frame = Frame::new(10, 1, &mut pool);
418 para.render(area, &mut frame);
419
420 assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('H'));
422 assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('i'));
423 }
424
425 #[test]
426 fn render_with_scroll() {
427 let para = Paragraph::new(Text::raw("Line1\nLine2\nLine3")).scroll((1, 0));
428 let area = Rect::new(0, 0, 10, 2);
429 let mut pool = GraphemePool::new();
430 let mut frame = Frame::new(10, 2, &mut pool);
431 para.render(area, &mut frame);
432
433 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('L'));
435 assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('2'));
436 }
437
438 #[test]
439 fn render_empty_area() {
440 let para = Paragraph::new(Text::raw("Hello"));
441 let area = Rect::new(0, 0, 0, 0);
442 let mut pool = GraphemePool::new();
443 let mut frame = Frame::new(1, 1, &mut pool);
444 para.render(area, &mut frame);
445 }
446
447 #[test]
448 fn render_empty_text_clears_content() {
449 let para = Paragraph::new("");
450 let area = Rect::new(0, 0, 3, 1);
451 let mut pool = GraphemePool::new();
452 let mut frame = Frame::new(3, 1, &mut pool);
453
454 frame
456 .buffer
457 .fill(area, ftui_render::cell::Cell::from_char('X'));
458
459 para.render(area, &mut frame);
460
461 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
462 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some(' '));
463 }
464
465 #[test]
466 fn render_right_aligned() {
467 let para = Paragraph::new(Text::raw("Hi")).alignment(Alignment::Right);
468 let area = Rect::new(0, 0, 10, 1);
469 let mut pool = GraphemePool::new();
470 let mut frame = Frame::new(10, 1, &mut pool);
471 para.render(area, &mut frame);
472
473 assert_eq!(frame.buffer.get(8, 0).unwrap().content.as_char(), Some('H'));
475 assert_eq!(frame.buffer.get(9, 0).unwrap().content.as_char(), Some('i'));
476 }
477
478 #[test]
479 fn render_with_word_wrap() {
480 let para = Paragraph::new(Text::raw("hello world")).wrap(WrapMode::Word);
481 let area = Rect::new(0, 0, 6, 3);
482 let mut pool = GraphemePool::new();
483 let mut frame = Frame::new(6, 3, &mut pool);
484 para.render(area, &mut frame);
485
486 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('h'));
488 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('w'));
489 }
490
491 #[test]
492 fn render_with_char_wrap() {
493 let para = Paragraph::new(Text::raw("abcdefgh")).wrap(WrapMode::Char);
494 let area = Rect::new(0, 0, 4, 3);
495 let mut pool = GraphemePool::new();
496 let mut frame = Frame::new(4, 3, &mut pool);
497 para.render(area, &mut frame);
498
499 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('a'));
501 assert_eq!(frame.buffer.get(3, 0).unwrap().content.as_char(), Some('d'));
502 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('e'));
504 }
505
506 #[test]
507 fn scroll_past_all_lines() {
508 let para = Paragraph::new(Text::raw("AB")).scroll((5, 0));
509 let area = Rect::new(0, 0, 5, 2);
510 let mut pool = GraphemePool::new();
511 let mut frame = Frame::new(5, 2, &mut pool);
512 para.render(area, &mut frame);
513
514 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
516 }
517
518 #[test]
519 fn render_clipped_at_area_height() {
520 let para = Paragraph::new(Text::raw("A\nB\nC\nD\nE"));
521 let area = Rect::new(0, 0, 5, 2);
522 let mut pool = GraphemePool::new();
523 let mut frame = Frame::new(5, 2, &mut pool);
524 para.render(area, &mut frame);
525
526 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
528 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('B'));
529 }
530
531 #[test]
532 fn render_clipped_at_area_width() {
533 let para = Paragraph::new(Text::raw("ABCDEF"));
534 let area = Rect::new(0, 0, 3, 1);
535 let mut pool = GraphemePool::new();
536 let mut frame = Frame::new(3, 1, &mut pool);
537 para.render(area, &mut frame);
538
539 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
540 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
541 }
542
543 #[test]
544 fn align_x_left() {
545 let area = Rect::new(5, 0, 20, 1);
546 assert_eq!(align_x(area, 10, Alignment::Left), 5);
547 }
548
549 #[test]
550 fn align_x_center() {
551 let area = Rect::new(0, 0, 20, 1);
552 assert_eq!(align_x(area, 6, Alignment::Center), 7);
554 }
555
556 #[test]
557 fn align_x_right() {
558 let area = Rect::new(0, 0, 20, 1);
559 assert_eq!(align_x(area, 5, Alignment::Right), 15);
561 }
562
563 #[test]
564 fn align_x_wide_line_saturates() {
565 let area = Rect::new(0, 0, 10, 1);
566 assert_eq!(align_x(area, 20, Alignment::Right), 0);
568 assert_eq!(align_x(area, 20, Alignment::Center), 0);
569 }
570
571 #[test]
572 fn builder_methods_chain() {
573 let para = Paragraph::new(Text::raw("test"))
574 .style(Style::default())
575 .wrap(WrapMode::Word)
576 .alignment(Alignment::Center)
577 .scroll((1, 2));
578 let area = Rect::new(0, 0, 10, 5);
580 let mut pool = GraphemePool::new();
581 let mut frame = Frame::new(10, 5, &mut pool);
582 para.render(area, &mut frame);
583 }
584
585 #[test]
586 fn render_at_offset_area() {
587 let para = Paragraph::new(Text::raw("X"));
588 let area = Rect::new(3, 4, 5, 2);
589 let mut pool = GraphemePool::new();
590 let mut frame = Frame::new(10, 10, &mut pool);
591 para.render(area, &mut frame);
592
593 assert_eq!(frame.buffer.get(3, 4).unwrap().content.as_char(), Some('X'));
594 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
596 }
597
598 #[test]
599 fn wrap_clipped_at_area_bottom() {
600 let para = Paragraph::new(Text::raw("abcdefghijklmnop")).wrap(WrapMode::Char);
602 let area = Rect::new(0, 0, 4, 2);
603 let mut pool = GraphemePool::new();
604 let mut frame = Frame::new(4, 2, &mut pool);
605 para.render(area, &mut frame);
606
607 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('a'));
609 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('e'));
610 }
611
612 #[test]
615 fn degradation_skeleton_skips_content() {
616 use ftui_render::budget::DegradationLevel;
617
618 let para = Paragraph::new(Text::raw("Hello"));
619 let area = Rect::new(0, 0, 10, 1);
620 let mut pool = GraphemePool::new();
621 let mut frame = Frame::new(10, 1, &mut pool);
622 frame.set_degradation(DegradationLevel::Skeleton);
623 para.render(area, &mut frame);
624
625 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
627 }
628
629 #[test]
630 fn degradation_full_renders_content() {
631 use ftui_render::budget::DegradationLevel;
632
633 let para = Paragraph::new(Text::raw("Hello"));
634 let area = Rect::new(0, 0, 10, 1);
635 let mut pool = GraphemePool::new();
636 let mut frame = Frame::new(10, 1, &mut pool);
637 frame.set_degradation(DegradationLevel::Full);
638 para.render(area, &mut frame);
639
640 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
641 }
642
643 #[test]
644 fn degradation_essential_only_still_renders_text() {
645 use ftui_render::budget::DegradationLevel;
646
647 let para = Paragraph::new(Text::raw("Hello"));
648 let area = Rect::new(0, 0, 10, 1);
649 let mut pool = GraphemePool::new();
650 let mut frame = Frame::new(10, 1, &mut pool);
651 frame.set_degradation(DegradationLevel::EssentialOnly);
652 para.render(area, &mut frame);
653
654 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
656 }
657
658 #[test]
659 fn degradation_no_styling_ignores_span_styles() {
660 use ftui_render::budget::DegradationLevel;
661 use ftui_render::cell::PackedRgba;
662 use ftui_text::{Line, Span};
663
664 let styled_span = Span::styled("Hello", Style::new().fg(PackedRgba::RED));
666 let line = Line::from_spans([styled_span]);
667 let text = Text::from(line);
668 let para = Paragraph::new(text);
669 let area = Rect::new(0, 0, 10, 1);
670 let mut pool = GraphemePool::new();
671 let mut frame = Frame::new(10, 1, &mut pool);
672 frame.set_degradation(DegradationLevel::NoStyling);
673 para.render(area, &mut frame);
674
675 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('H'));
677 assert_ne!(
679 frame.buffer.get(0, 0).unwrap().fg,
680 PackedRgba::RED,
681 "Span fg color should be ignored at NoStyling"
682 );
683 }
684
685 use crate::MeasurableWidget;
688 use ftui_core::geometry::Size;
689
690 #[test]
691 fn measure_simple_text() {
692 let para = Paragraph::new(Text::raw("Hello"));
693 let constraints = para.measure(Size::MAX);
694
695 assert_eq!(constraints.preferred, Size::new(5, 1));
697 assert_eq!(constraints.min.height, 1);
698 assert_eq!(constraints.min.width, 5);
700 }
701
702 #[test]
703 fn measure_multiline_text() {
704 let para = Paragraph::new(Text::raw("Line1\nLine22\nL3"));
705 let constraints = para.measure(Size::MAX);
706
707 assert_eq!(constraints.preferred, Size::new(6, 3));
709 assert_eq!(constraints.min.height, 1);
710 assert_eq!(constraints.min.width, 6);
712 }
713
714 #[test]
715 fn measure_with_block() {
716 let block = crate::block::Block::bordered();
717 let para = Paragraph::new(Text::raw("Hi")).block(block);
718 let constraints = para.measure(Size::MAX);
719
720 assert_eq!(constraints.preferred, Size::new(4, 3));
722 assert_eq!(constraints.min.width, 4);
724 assert_eq!(constraints.min.height, 3);
726 }
727
728 #[test]
729 fn measure_with_word_wrap() {
730 let para = Paragraph::new(Text::raw("hello world")).wrap(WrapMode::Word);
731 let constraints = para.measure(Size::new(6, 10));
733
734 assert_eq!(constraints.preferred.height, 2);
737 assert_eq!(constraints.min.width, 5);
739 }
740
741 #[test]
742 fn measure_empty_text() {
743 let para = Paragraph::new(Text::raw(""));
744 let constraints = para.measure(Size::MAX);
745
746 assert_eq!(constraints.preferred.width, 0);
748 assert_eq!(constraints.preferred.height, 0);
749 assert_eq!(constraints.min.height, 0);
752 }
753
754 #[test]
755 fn calculate_min_width_single_long_word() {
756 let para = Paragraph::new(Text::raw("supercalifragilistic"));
757 assert_eq!(para.calculate_min_width(), 20);
758 }
759
760 #[test]
761 fn calculate_min_width_multiple_words() {
762 let para = Paragraph::new(Text::raw("the quick brown fox"));
763 assert_eq!(para.calculate_min_width(), 5);
765 }
766
767 #[test]
768 fn calculate_min_width_multiline() {
769 let para = Paragraph::new(Text::raw("short\nlongword\na"));
770 assert_eq!(para.calculate_min_width(), 8);
772 }
773
774 #[test]
775 fn estimate_wrapped_height_no_wrap_needed() {
776 let para = Paragraph::new(Text::raw("short")).wrap(WrapMode::Word);
777 assert_eq!(para.estimate_wrapped_height(10), 1);
779 }
780
781 #[test]
782 fn estimate_wrapped_height_needs_wrap() {
783 let para = Paragraph::new(Text::raw("hello world")).wrap(WrapMode::Word);
784 assert_eq!(para.estimate_wrapped_height(6), 2);
786 }
787
788 #[test]
789 fn has_intrinsic_size() {
790 let para = Paragraph::new(Text::raw("test"));
791 assert!(para.has_intrinsic_size());
792 }
793
794 #[test]
795 fn measure_is_pure() {
796 let para = Paragraph::new(Text::raw("Hello World"));
797 let a = para.measure(Size::new(100, 50));
798 let b = para.measure(Size::new(100, 50));
799 assert_eq!(a, b);
800 }
801}