Skip to main content

ratatui_kit/components/
wrapped_text.rs

1// WrappedText 组件:按指定宽度自动换行,并把换行后的行数暴露给布局系统。
2//
3// 与 `Text(wrap: true)` 不同,`WrappedText` 会根据 `wrap_width` 计算自身高度,
4// 因而适合放进 `ScrollView` 渲染长文档、日志、说明文本或小说正文。
5
6use crate::{Component, ComponentDrawer, ComponentUpdater, Hooks};
7use ratatui::{
8    layout::{Alignment, Constraint, Position},
9    style::Style,
10    widgets::Paragraph,
11};
12use ratatui_kit_macros::{Props, with_layout_style};
13
14const DEFAULT_WRAP_WIDTH: u16 = 80;
15
16#[with_layout_style]
17#[derive(Default, Props)]
18// 自动换行文本属性。
19pub struct WrappedTextProps {
20    // 要渲染的纯文本。可直接传 `&str` 或 `String`。
21    pub text: String,
22    // 应用于整段文本的样式。
23    pub style: Style,
24    // 文本对齐方式。
25    pub alignment: Alignment,
26    // 文本滚动偏移,语义与 ratatui `Paragraph::scroll` 保持一致。
27    pub scroll: Position,
28    // 用于计算换行和自动高度的宽度。长正文放进 `ScrollView` 时建议显式传入。
29    pub wrap_width: Option<u16>,
30    // 是否拆分超过宽度的长词/长串。默认 `true`,适合 CJK 正文和长日志。
31    pub break_words: Option<bool>,
32    // 是否把自身高度设为换行后的行数。默认 `true`。
33    pub auto_height: Option<bool>,
34}
35
36// 自动换行文本组件。
37pub struct WrappedText {
38    paragraph: Paragraph<'static>,
39    line_count: u16,
40}
41
42impl WrappedText {
43    fn from_props(props: &WrappedTextProps) -> Self {
44        let wrap_width = props
45            .wrap_width
46            .filter(|width| *width > 0)
47            .unwrap_or(match props.width {
48                Constraint::Length(width) if width > 0 => width,
49                _ => DEFAULT_WRAP_WIDTH,
50            });
51        let wrapped = wrap_text(&props.text, wrap_width, props.break_words.unwrap_or(true));
52        let line_count = wrapped_line_count(&wrapped);
53
54        Self {
55            paragraph: Paragraph::new(wrapped)
56                .style(props.style)
57                .scroll((props.scroll.x, props.scroll.y))
58                .alignment(props.alignment),
59            line_count,
60        }
61    }
62
63    fn line_count(&self) -> u16 {
64        self.line_count
65    }
66}
67
68impl Component for WrappedText {
69    type Props<'a> = WrappedTextProps;
70
71    fn new(props: &Self::Props<'_>) -> Self {
72        Self::from_props(props)
73    }
74
75    fn update(
76        &mut self,
77        props: &mut Self::Props<'_>,
78        _hooks: Hooks,
79        updater: &mut ComponentUpdater,
80    ) {
81        *self = Self::from_props(props);
82
83        let mut layout_style = props.layout_style();
84        if props.auto_height.unwrap_or(true) {
85            layout_style.height = Constraint::Length(self.line_count());
86        }
87        updater.set_layout_style(layout_style);
88    }
89
90    fn draw(&mut self, drawer: &mut ComponentDrawer<'_, '_>) {
91        drawer.render_widget(&self.paragraph, drawer.area);
92    }
93}
94
95fn wrap_text(text: &str, width: u16, break_words: bool) -> String {
96    let options = textwrap::Options::new(width.max(1) as usize).break_words(break_words);
97    textwrap::fill(text, options)
98}
99
100fn wrapped_line_count(text: &str) -> u16 {
101    text.lines().count().max(1).min(u16::MAX as usize) as u16
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn line_count_uses_wrap_width() {
110        let props = WrappedTextProps {
111            text: "alpha beta gamma".into(),
112            wrap_width: Some(5),
113            ..Default::default()
114        };
115        let text = WrappedText::from_props(&props);
116
117        assert_eq!(text.line_count(), 3);
118    }
119
120    #[test]
121    fn line_count_falls_back_to_length_width() {
122        let props = WrappedTextProps {
123            text: "alpha beta".into(),
124            width: Constraint::Length(5),
125            ..Default::default()
126        };
127        let text = WrappedText::from_props(&props);
128
129        assert_eq!(text.line_count(), 2);
130    }
131}