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