iocraft/components/
text.rs

1use crate::{
2    render::MeasureFunc, segmented_string::SegmentedString, CanvasTextStyle, Color, Component,
3    ComponentDrawer, ComponentUpdater, Hooks, Props, Weight,
4};
5use taffy::{AvailableSpace, Size};
6use unicode_width::UnicodeWidthStr;
7
8/// The text wrapping behavior of a [`Text`] component.
9#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
10pub enum TextWrap {
11    /// Text is wrapped at appropriate characters to minimize overflow. This is the default.
12    #[default]
13    Wrap,
14    /// Text is not wrapped, and may overflow the bounds of the component.
15    NoWrap,
16}
17
18/// The text alignment of a [`Text`] component.
19#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
20pub enum TextAlign {
21    /// Text is aligned to the left. This is the default.
22    #[default]
23    Left,
24    /// Text is aligned to the right.
25    Right,
26    /// Text is aligned to the center.
27    Center,
28}
29
30/// The text decoration of a [`Text`] component.
31#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
32pub enum TextDecoration {
33    /// No text decoration. This is the default.
34    #[default]
35    None,
36    /// The text is underlined.
37    Underline,
38}
39
40/// The props which can be passed to the [`Text`] component.
41#[non_exhaustive]
42#[derive(Default, Props)]
43pub struct TextProps {
44    /// The color to make the text.
45    pub color: Option<Color>,
46
47    /// The content of the text.
48    pub content: String,
49
50    /// The weight of the text.
51    pub weight: Weight,
52
53    /// The text wrapping behavior.
54    pub wrap: TextWrap,
55
56    /// The text alignment.
57    pub align: TextAlign,
58
59    /// The text decoration.
60    pub decoration: TextDecoration,
61
62    /// Whether to italicize the text.
63    pub italic: bool,
64}
65
66/// `Text` is a component that renders a text string.
67///
68/// # Example
69///
70/// ```
71/// # use iocraft::prelude::*;
72/// # fn my_element() -> impl Into<AnyElement<'static>> {
73/// element! {
74///     Text(content: "Hello!")
75/// }
76/// # }
77/// ```
78#[derive(Default)]
79pub struct Text {
80    style: CanvasTextStyle,
81    content: String,
82    wrap: TextWrap,
83    align: TextAlign,
84}
85
86impl Text {
87    pub(crate) fn measure_func(content: String, text_wrap: TextWrap) -> MeasureFunc {
88        Box::new(move |known_size, available_space, _| {
89            let content = Text::wrap(&content, text_wrap, known_size.width, available_space.width);
90            let mut max_width = 0;
91            let mut num_lines = 0;
92            for line in content.lines() {
93                max_width = max_width.max(line.width());
94                num_lines += 1;
95            }
96            Size {
97                width: max_width as _,
98                height: num_lines.max(1) as _,
99            }
100        })
101    }
102
103    fn do_wrap(s: &str, width: usize) -> String {
104        let s: SegmentedString = s.into();
105        let mut ret = String::new();
106        for line in s.wrap(width) {
107            if !ret.is_empty() {
108                ret.push('\n');
109            }
110            ret.push_str(line.to_string().trim_end());
111        }
112        ret
113    }
114
115    fn wrap(
116        content: &str,
117        text_wrap: TextWrap,
118        known_width: Option<f32>,
119        available_width: AvailableSpace,
120    ) -> String {
121        match text_wrap {
122            TextWrap::Wrap => match known_width {
123                Some(w) => Self::do_wrap(content, w as usize),
124                None => match available_width {
125                    AvailableSpace::Definite(w) => Self::do_wrap(content, w as usize),
126                    AvailableSpace::MaxContent => content.to_string(),
127                    AvailableSpace::MinContent => Self::do_wrap(content, 1),
128                },
129            },
130            TextWrap::NoWrap => content.to_string(),
131        }
132    }
133
134    pub(crate) fn alignment_padding(line_width: usize, align: TextAlign, width: usize) -> usize {
135        match align {
136            TextAlign::Left => 0,
137            TextAlign::Right => width - line_width,
138            TextAlign::Center => width / 2 - line_width / 2,
139        }
140    }
141
142    fn align(content: String, align: TextAlign, width: usize) -> String {
143        match align {
144            TextAlign::Left => content,
145            _ => content
146                .lines()
147                .map(|line| {
148                    format!(
149                        "{:width$}{}",
150                        "",
151                        line,
152                        width = Self::alignment_padding(line.width(), align, width)
153                    )
154                })
155                .collect::<Vec<_>>()
156                .join("\n"),
157        }
158    }
159}
160
161pub(crate) struct TextDrawer<'a, 'b> {
162    x: isize,
163    y: isize,
164    drawer: &'a mut ComponentDrawer<'b>,
165    line_encountered_non_whitespace: bool,
166    skip_leading_whitespace: bool,
167}
168
169impl<'a, 'b> TextDrawer<'a, 'b> {
170    pub fn new(drawer: &'a mut ComponentDrawer<'b>, skip_leading_whitespace: bool) -> Self {
171        TextDrawer {
172            x: 0,
173            y: 0,
174            drawer,
175            line_encountered_non_whitespace: false,
176            skip_leading_whitespace,
177        }
178    }
179
180    pub fn append_lines<'c>(
181        &mut self,
182        lines: impl IntoIterator<Item = &'c str>,
183        style: CanvasTextStyle,
184    ) {
185        let mut lines = lines.into_iter().peekable();
186        while let Some(mut line) = lines.next() {
187            if self.skip_leading_whitespace && !self.line_encountered_non_whitespace {
188                let to_skip = line
189                    .chars()
190                    .position(|c| !c.is_whitespace())
191                    .unwrap_or(line.len());
192                let (whitespace, remaining) = line.split_at(to_skip);
193                self.x += whitespace.width() as isize;
194                line = remaining;
195                if !line.is_empty() {
196                    self.line_encountered_non_whitespace = true;
197                }
198            }
199            self.drawer.canvas().set_text(self.x, self.y, line, style);
200            if lines.peek().is_some() {
201                self.y += 1;
202                self.x = 0;
203                self.line_encountered_non_whitespace = false;
204            } else {
205                self.x += line.width() as isize;
206            }
207        }
208    }
209}
210
211impl Component for Text {
212    type Props<'a> = TextProps;
213
214    fn new(_props: &Self::Props<'_>) -> Self {
215        Self::default()
216    }
217
218    fn update(
219        &mut self,
220        props: &mut Self::Props<'_>,
221        _hooks: Hooks,
222        updater: &mut ComponentUpdater,
223    ) {
224        self.style = CanvasTextStyle {
225            color: props.color,
226            weight: props.weight,
227            underline: props.decoration == TextDecoration::Underline,
228            italic: props.italic,
229        };
230        self.content = props.content.clone();
231        self.wrap = props.wrap;
232        self.align = props.align;
233        updater.set_measure_func(Self::measure_func(self.content.clone(), props.wrap));
234    }
235
236    fn draw(&mut self, drawer: &mut ComponentDrawer<'_>) {
237        let width = drawer.layout().size.width;
238        let content = Self::wrap(
239            &self.content,
240            self.wrap,
241            None,
242            AvailableSpace::Definite(width),
243        );
244        let content = Self::align(content, self.align, width as _);
245        let mut drawer = TextDrawer::new(drawer, self.align != TextAlign::Left);
246        drawer.append_lines(content.lines(), self.style);
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use crate::prelude::*;
253    use crossterm::{csi, style::Attribute};
254    use std::io::Write;
255
256    #[test]
257    fn test_text() {
258        assert_eq!(element!(Text).to_string(), "\n");
259
260        assert_eq!(element!(Text(content: "foo")).to_string(), "foo\n");
261
262        assert_eq!(
263            element!(Text(content: "foo\nbar")).to_string(),
264            "foo\nbar\n"
265        );
266
267        assert_eq!(element!(Text(content: "😀")).to_string(), "😀\n");
268
269        assert_eq!(
270            element! {
271                View(width: 14) {
272                    Text(content: "this is a wrapping test")
273                }
274            }
275            .to_string(),
276            "this is a\nwrapping test\n"
277        );
278
279        assert_eq!(
280            element! {
281                View(width: 15) {
282                    Text(content: "this is an alignment test", align: TextAlign::Right)
283                }
284            }
285            .to_string(),
286            "     this is an\n alignment test\n"
287        );
288
289        assert_eq!(
290            element! {
291                View(width: 15) {
292                    Text(content: "this is an alignment test", align: TextAlign::Center)
293                }
294            }
295            .to_string(),
296            "  this is an\nalignment test\n"
297        );
298
299        // Make sure that when the text is not left-aligned, leading whitespace is not underlined.
300        {
301            let canvas = element! {
302                View(width: 16) {
303                    Text(content: "this is an alignment test", align: TextAlign::Center, decoration: TextDecoration::Underline)
304                }
305            }
306            .render(None);
307            let mut actual = Vec::new();
308            canvas.write_ansi(&mut actual).unwrap();
309
310            let mut expected = Vec::new();
311            write!(expected, csi!("0m")).unwrap();
312            write!(expected, "   ").unwrap();
313            write!(expected, csi!("{}m"), Attribute::Underlined.sgr()).unwrap();
314            write!(expected, "this is an").unwrap();
315            write!(expected, csi!("K")).unwrap();
316            write!(expected, "\r\n").unwrap();
317            write!(expected, csi!("0m")).unwrap();
318            write!(expected, " ").unwrap();
319            write!(expected, csi!("{}m"), Attribute::Underlined.sgr()).unwrap();
320            write!(expected, "alignment test").unwrap();
321            write!(expected, csi!("K")).unwrap();
322            write!(expected, csi!("0m")).unwrap();
323            write!(expected, "\r\n").unwrap();
324
325            assert_eq!(actual, expected);
326        }
327    }
328}