Skip to main content

qframe/widgets/
text.rs

1//! Styled, wrapping text.
2
3use std::ops::Range;
4
5use crate::geometry::{Rect, Size, clamp_u16};
6use crate::style::CellStyle;
7use crate::text;
8use crate::widget::{Align, MeasureCx, PaintCx, Widget};
9
10/// A run of text with its own look inside a [`Text`].
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Span {
13    text: String,
14    role: Option<String>,
15    color: Option<String>,
16    background: Option<String>,
17    bold: bool,
18}
19
20impl Span {
21    /// A span that looks like the rest of its text.
22    #[must_use]
23    pub fn new(text: impl Into<String>) -> Self {
24        Self { text: text.into(), role: None, color: None, background: None, bold: false }
25    }
26
27    /// Uses typography role `role` (`title`, `body`, `secondary`, `faint`) for this span.
28    #[must_use]
29    pub fn role(mut self, role: impl Into<String>) -> Self {
30        self.role = Some(role.into());
31        self
32    }
33
34    /// Colours this span with theme token `token`, e.g. `"accent"` or `"danger"`.
35    #[must_use]
36    pub fn color(mut self, token: impl Into<String>) -> Self {
37        self.color = Some(token.into());
38        self
39    }
40
41    /// Paints the span's background with theme token `token`, e.g. to mark a word or show a
42    /// colour swatch.
43    #[must_use]
44    pub fn on(mut self, token: impl Into<String>) -> Self {
45        self.background = Some(token.into());
46        self
47    }
48
49    /// Makes this span bold.
50    #[must_use]
51    pub fn bold(mut self) -> Self {
52        self.bold = true;
53        self
54    }
55}
56
57/// Text that wraps to its width, or is cut with `…` when wrapping is off.
58///
59/// Its look comes from a typography role of the theme (`body` by default); spans can use
60/// other roles, colour tokens and bold.
61///
62/// Text is not selectable with the mouse by itself: titles, labels and hints are not content to
63/// copy. Make it selectable where it is, e.g. a message or an address, with
64/// `ui.add(Text::new(url)).selectable(true)` ([`NodeMut::selectable`](crate::widget::NodeMut::selectable)).
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Text {
67    spans: Vec<Span>,
68    role: String,
69    wrap: bool,
70    align: Align,
71}
72
73impl Text {
74    /// Plain text.
75    #[must_use]
76    pub fn new(text: impl Into<String>) -> Self {
77        Self::rich([Span::new(text)])
78    }
79
80    /// Text made of differently styled spans.
81    #[must_use]
82    pub fn rich(spans: impl IntoIterator<Item = Span>) -> Self {
83        Self { spans: spans.into_iter().collect(), role: "body".to_owned(), wrap: true, align: Align::Start }
84    }
85
86    /// Uses typography role `role` for the whole text.
87    #[must_use]
88    pub fn role(mut self, role: impl Into<String>) -> Self {
89        self.role = role.into();
90        self
91    }
92
93    /// Colours every span with theme token `token`.
94    #[must_use]
95    pub fn color(mut self, token: impl Into<String>) -> Self {
96        let token = token.into();
97        for span in &mut self.spans {
98            span.color.get_or_insert_with(|| token.clone());
99        }
100        self
101    }
102
103    /// Makes every span bold.
104    #[must_use]
105    pub fn bold(mut self) -> Self {
106        for span in &mut self.spans {
107            span.bold = true;
108        }
109        self
110    }
111
112    /// Cuts the text with `…` instead of wrapping it.
113    #[must_use]
114    pub fn no_wrap(mut self) -> Self {
115        self.wrap = false;
116        self
117    }
118
119    /// Aligns every line.
120    #[must_use]
121    pub fn align(mut self, align: Align) -> Self {
122        self.align = align;
123        self
124    }
125
126    fn joined(&self) -> (String, Vec<Range<usize>>) {
127        let mut joined = String::new();
128        let mut ranges = Vec::new();
129        for span in &self.spans {
130            let start = joined.len();
131            joined.push_str(&span.text);
132            ranges.push(start..joined.len());
133        }
134        (joined, ranges)
135    }
136
137    fn lines(&self, joined: &str, max: u16) -> Vec<Range<usize>> {
138        if self.wrap {
139            return text::wrap_ranges(joined, max);
140        }
141        let mut lines = Vec::new();
142        let mut start = 0;
143        for line in joined.split('\n') {
144            lines.push(start..start + line.len());
145            start += line.len() + 1;
146        }
147        lines
148    }
149}
150
151impl<Msg: 'static> Widget<Msg> for Text {
152    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
153        let (joined, _) = self.joined();
154        let lines = self.lines(&joined, available.width);
155        let width = lines.iter().map(|line| text::width(&joined[line.clone()])).max().unwrap_or(0);
156        Size::new(
157            width.min(available.width),
158            clamp_u16(i32::try_from(lines.len()).unwrap_or(i32::MAX)).min(available.height),
159        )
160    }
161
162    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
163        if area.width == 0 || area.height == 0 {
164            return;
165        }
166        let (joined, ranges) = self.joined();
167        let base = typography(cx, &self.role);
168        let styles: Vec<CellStyle> = self
169            .spans
170            .iter()
171            .map(|span| {
172                let mut style = span.role.as_deref().map_or(base, |role| typography(cx, role));
173                if let Some(token) = &span.color {
174                    style.fg = Some(cx.color(token));
175                }
176                if let Some(token) = &span.background {
177                    style.bg = Some(cx.color(token));
178                }
179                style.bold |= span.bold;
180                style
181            })
182            .collect();
183
184        for (row, line) in self.lines(&joined, area.width).into_iter().enumerate() {
185            let Ok(row) = u16::try_from(row) else { break };
186            if row >= area.height {
187                break;
188            }
189            let line_text = &joined[line.clone()];
190            let full_width = text::width(line_text);
191            let (visible, cut) = if full_width > area.width {
192                (text::truncate(line_text, area.width).into_owned(), true)
193            } else {
194                (line_text.to_owned(), false)
195            };
196            let visible_width = text::width(&visible);
197            let offset = match self.align {
198                Align::Start => 0,
199                Align::Center => area.width.saturating_sub(visible_width) / 2,
200                Align::End => area.width.saturating_sub(visible_width),
201            };
202            let y = area.y + i32::from(row);
203            let mut x = area.x + i32::from(offset);
204            let mut remaining = if cut { area.width.saturating_sub(1) } else { visible_width };
205            for (range, style) in ranges.iter().zip(&styles) {
206                let start = range.start.max(line.start);
207                let end = range.end.min(line.end);
208                if start >= end || remaining == 0 {
209                    continue;
210                }
211                let piece = &joined[start..end];
212                let drawn = cx.text(x, y, piece, *style, remaining).min(remaining);
213                x += i32::from(drawn);
214                remaining -= drawn;
215            }
216            if cut {
217                let last_style = styles.last().copied().unwrap_or(base);
218                cx.text(x, y, text::ELLIPSIS, last_style, 1);
219            }
220        }
221    }
222}
223
224/// The text style of typography role `role`.
225pub(crate) fn typography(cx: &PaintCx<'_>, role: &str) -> CellStyle {
226    let Some(props) = cx.env().theme().typography(role) else {
227        return CellStyle::fg(cx.color("text"));
228    };
229    let style = crate::style::WidgetStyle::new(props.clone(), cx.pulse_phase());
230    let mut text_style = style.text();
231    text_style.bg = None;
232    text_style
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::runtime::{App, Command, Harness};
239    use crate::widget::View;
240
241    struct Demo(Text);
242
243    impl App for Demo {
244        type Msg = ();
245        fn update(&mut self, _: ()) -> Command<()> {
246            Command::none()
247        }
248        fn view(&self, ui: &mut View<'_, ()>) {
249            ui.add(self.0.clone()).fill_width();
250        }
251    }
252
253    #[test]
254    fn wraps_and_truncates() {
255        let wrapped = Harness::new(Demo(Text::new("terminal interfaces with taste")), 12, 4);
256        assert_eq!(wrapped.screen(), "terminal\ninterfaces\nwith taste\n\n");
257        let cut = Harness::new(Demo(Text::new("terminal interfaces").no_wrap()), 12, 2);
258        assert_eq!(cut.screen(), "terminal in…\n\n");
259    }
260
261    #[test]
262    fn spans_keep_their_styles_and_alignment() {
263        let text =
264            Text::rich([Span::new("ok "), Span::new("done").color("success").on("raised").bold()]).align(Align::End);
265        let h = Harness::new(Demo(text), 10, 1);
266        assert_eq!(h.screen(), "   ok done\n");
267        let theme = h.env().theme();
268        assert_eq!(h.fg(3, 0), theme.color("text"));
269        assert_eq!(h.fg(6, 0), theme.color("success"));
270        assert!(h.is_bold(6, 0) && !h.is_bold(3, 0));
271        assert_eq!(h.bg(6, 0), theme.color("raised"));
272    }
273}