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`, `heading`) 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: `title`, `body` (the default), `secondary`,
87    /// `faint`, or `heading`, which looks like a [`SettingsList`](super::SettingsList)'s group
88    /// headings, for a heading above something that is not a settings list.
89    #[must_use]
90    pub fn role(mut self, role: impl Into<String>) -> Self {
91        self.role = role.into();
92        self
93    }
94
95    /// Colours every span with theme token `token`.
96    #[must_use]
97    pub fn color(mut self, token: impl Into<String>) -> Self {
98        let token = token.into();
99        for span in &mut self.spans {
100            span.color.get_or_insert_with(|| token.clone());
101        }
102        self
103    }
104
105    /// Makes every span bold.
106    #[must_use]
107    pub fn bold(mut self) -> Self {
108        for span in &mut self.spans {
109            span.bold = true;
110        }
111        self
112    }
113
114    /// Cuts the text with `…` instead of wrapping it.
115    #[must_use]
116    pub fn no_wrap(mut self) -> Self {
117        self.wrap = false;
118        self
119    }
120
121    /// Aligns every line.
122    #[must_use]
123    pub fn align(mut self, align: Align) -> Self {
124        self.align = align;
125        self
126    }
127
128    fn joined(&self) -> (String, Vec<Range<usize>>) {
129        let mut joined = String::new();
130        let mut ranges = Vec::new();
131        for span in &self.spans {
132            let start = joined.len();
133            joined.push_str(&span.text);
134            ranges.push(start..joined.len());
135        }
136        (joined, ranges)
137    }
138
139    fn lines(&self, joined: &str, max: u16) -> Vec<Range<usize>> {
140        if self.wrap {
141            return text::wrap_ranges(joined, max);
142        }
143        let mut lines = Vec::new();
144        let mut start = 0;
145        for line in joined.split('\n') {
146            lines.push(start..start + line.len());
147            start += line.len() + 1;
148        }
149        lines
150    }
151}
152
153impl<Msg: 'static> Widget<Msg> for Text {
154    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
155        let (joined, _) = self.joined();
156        let lines = self.lines(&joined, available.width);
157        let width = lines.iter().map(|line| text::width(&joined[line.clone()])).max().unwrap_or(0);
158        Size::new(
159            width.min(available.width),
160            clamp_u16(i32::try_from(lines.len()).unwrap_or(i32::MAX)).min(available.height),
161        )
162    }
163
164    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
165        if area.width == 0 || area.height == 0 {
166            return;
167        }
168        let (joined, ranges) = self.joined();
169        let base = typography(cx, &self.role);
170        let styles: Vec<CellStyle> = self
171            .spans
172            .iter()
173            .map(|span| {
174                let mut style = span.role.as_deref().map_or(base, |role| typography(cx, role));
175                if let Some(token) = &span.color {
176                    style.fg = Some(cx.color(token));
177                }
178                if let Some(token) = &span.background {
179                    style.bg = Some(cx.color(token));
180                }
181                style.bold |= span.bold;
182                style
183            })
184            .collect();
185
186        for (row, line) in self.lines(&joined, area.width).into_iter().enumerate() {
187            let Ok(row) = u16::try_from(row) else { break };
188            if row >= area.height {
189                break;
190            }
191            let line_text = &joined[line.clone()];
192            let full_width = text::width(line_text);
193            let (visible, cut) = if full_width > area.width {
194                (text::truncate(line_text, area.width).into_owned(), true)
195            } else {
196                (line_text.to_owned(), false)
197            };
198            let visible_width = text::width(&visible);
199            let offset = match self.align {
200                Align::Start => 0,
201                Align::Center => area.width.saturating_sub(visible_width) / 2,
202                Align::End => area.width.saturating_sub(visible_width),
203            };
204            let y = area.y + i32::from(row);
205            let mut x = area.x + i32::from(offset);
206            let mut remaining = if cut { area.width.saturating_sub(1) } else { visible_width };
207            for (range, style) in ranges.iter().zip(&styles) {
208                let start = range.start.max(line.start);
209                let end = range.end.min(line.end);
210                if start >= end || remaining == 0 {
211                    continue;
212                }
213                let piece = &joined[start..end];
214                let drawn = cx.text(x, y, piece, *style, remaining).min(remaining);
215                x += i32::from(drawn);
216                remaining -= drawn;
217            }
218            if cut {
219                let last_style = styles.last().copied().unwrap_or(base);
220                cx.text(x, y, text::ELLIPSIS, last_style, 1);
221            }
222        }
223    }
224}
225
226/// The text style of typography role `role`.
227pub(crate) fn typography(cx: &PaintCx<'_>, role: &str) -> CellStyle {
228    let Some(props) = cx.env().theme().typography(role) else {
229        return CellStyle::fg(cx.color("text"));
230    };
231    let style = crate::style::WidgetStyle::new(props.clone(), cx.pulse_phase());
232    let mut text_style = style.text();
233    text_style.bg = None;
234    text_style
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::runtime::{App, Command, Harness};
241    use crate::widget::View;
242
243    struct Demo(Text);
244
245    impl App for Demo {
246        type Msg = ();
247        fn update(&mut self, _: ()) -> Command<()> {
248            Command::none()
249        }
250        fn view(&self, ui: &mut View<'_, ()>) {
251            ui.add(self.0.clone()).fill_width();
252        }
253    }
254
255    #[test]
256    fn wraps_and_truncates() {
257        let wrapped = Harness::new(Demo(Text::new("terminal interfaces with taste")), 12, 4);
258        assert_eq!(wrapped.screen(), "terminal\ninterfaces\nwith taste\n\n");
259        let cut = Harness::new(Demo(Text::new("terminal interfaces").no_wrap()), 12, 2);
260        assert_eq!(cut.screen(), "terminal in…\n\n");
261    }
262
263    #[test]
264    fn spans_keep_their_styles_and_alignment() {
265        let text =
266            Text::rich([Span::new("ok "), Span::new("done").color("success").on("raised").bold()]).align(Align::End);
267        let h = Harness::new(Demo(text), 10, 1);
268        assert_eq!(h.screen(), "   ok done\n");
269        let theme = h.env().theme();
270        assert_eq!(h.fg(3, 0), theme.color("text"));
271        assert_eq!(h.fg(6, 0), theme.color("success"));
272        assert!(h.is_bold(6, 0) && !h.is_bold(3, 0));
273        assert_eq!(h.bg(6, 0), theme.color("raised"));
274    }
275}