Skip to main content

zest_widget/widget/
span.rs

1//! Rich text: a paragraph of independently-styled inline runs.
2//!
3//! A [`Span`] is one styled run — its text plus an optional color and
4//! optional font override. A [`SpanGroup`] is a passive widget holding a
5//! `Vec<Span>` that lays the runs out inline, left to right, wrapping to
6//! the next line at word boundaries when a run would overflow the
7//! arranged width. Each run is painted with its own color and font via
8//! [`Renderer::draw_text`].
9//!
10//! Wrapping is whitespace-based and works per-character for runs without
11//! spaces, using the run font's fixed `character_size` (mono fonts only).
12
13use super::Widget;
14use alloc::{borrow::Cow, vec::Vec};
15use core::marker::PhantomData;
16use embedded_graphics::{
17    mono_font::MonoFont, pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
18};
19use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase};
20use zest_theme::Theme;
21
22/// One styled run of text within a [`SpanGroup`].
23///
24/// `color` and `font` are optional overrides; when `None` the
25/// [`SpanGroup`] falls back to the theme's `background.on_base` color and
26/// default font respectively.
27#[derive(Clone)]
28pub struct Span<'a, C: PixelColor> {
29    /// The run's text.
30    pub text: Cow<'a, str>,
31    /// Optional color override.
32    pub color: Option<C>,
33    /// Optional font override.
34    pub font: Option<&'a MonoFont<'a>>,
35}
36
37impl<'a, C: PixelColor> Span<'a, C> {
38    /// Create a plain run with no style overrides (inherits the group's
39    /// theme color and default font).
40    pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
41        Self {
42            text: text.into(),
43            color: None,
44            font: None,
45        }
46    }
47
48    /// Override this run's color.
49    #[must_use]
50    pub fn color(mut self, color: C) -> Self {
51        self.color = Some(color);
52        self
53    }
54
55    /// Override this run's font.
56    #[must_use]
57    pub fn font(mut self, font: &'a MonoFont<'a>) -> Self {
58        self.font = Some(font);
59        self
60    }
61}
62
63/// Passive widget laying out a sequence of [`Span`] runs inline with word
64/// wrapping.
65pub struct SpanGroup<'a, C: PixelColor, M: Clone> {
66    rect: Rectangle,
67    spans: Vec<Span<'a, C>>,
68    line_spacing: u32,
69    width: Length,
70    height: Length,
71    _phantom: PhantomData<M>,
72}
73
74impl<'a, C: PixelColor, M: Clone> SpanGroup<'a, C, M> {
75    /// Create a new empty span group.
76    pub fn new() -> Self {
77        Self {
78            rect: Rectangle::zero(),
79            spans: Vec::new(),
80            line_spacing: 2,
81            width: Length::Fill,
82            height: Length::Fill,
83            _phantom: PhantomData,
84        }
85    }
86
87    /// Append a fully-built [`Span`].
88    #[must_use]
89    pub fn push(mut self, span: Span<'a, C>) -> Self {
90        self.spans.push(span);
91        self
92    }
93
94    /// Append a plain run by text. Chain `.color(..)` / `.font(..)`
95    /// via [`Span`] when more control is needed, or use [`SpanGroup::push`].
96    #[must_use]
97    pub fn span(mut self, text: impl Into<Cow<'a, str>>) -> Self {
98        self.spans.push(Span::new(text));
99        self
100    }
101
102    /// Extra vertical gap between wrapped lines, in pixels.
103    #[must_use]
104    pub fn line_spacing(mut self, spacing: u32) -> Self {
105        self.line_spacing = spacing;
106        self
107    }
108
109    /// Width sizing intent.
110    #[must_use]
111    pub fn width(mut self, width: impl Into<Length>) -> Self {
112        self.width = width.into();
113        self
114    }
115
116    /// Height sizing intent.
117    #[must_use]
118    pub fn height(mut self, height: impl Into<Length>) -> Self {
119        self.height = height.into();
120        self
121    }
122
123    /// The tallest run font height, used as the line height. Falls back to
124    /// `fallback` (the theme default font height) when there are no runs
125    /// with an explicit font.
126    fn line_height(&self, fallback: u32) -> u32 {
127        let mut h = fallback;
128        for span in &self.spans {
129            if let Some(font) = span.font {
130                h = h.max(font.character_size.height);
131            }
132        }
133        h
134    }
135}
136
137impl<'a, C: PixelColor, M: Clone> Default for SpanGroup<'a, C, M> {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl<'a, C: PixelColor, M: Clone> Widget<C, M> for SpanGroup<'a, C, M> {
144    fn measure(&mut self, constraints: Constraints) -> Size {
145        let w = self
146            .width
147            .resolve(constraints.max.width, constraints.max.width);
148        let h = self
149            .height
150            .resolve(constraints.max.height, constraints.max.height);
151        constraints.clamp(Size::new(w, h))
152    }
153
154    fn preferred_size(&self) -> (Length, Length) {
155        (self.width, self.height)
156    }
157
158    fn arrange(&mut self, rect: Rectangle) {
159        self.rect = rect;
160    }
161
162    fn rect(&self) -> Rectangle {
163        self.rect
164    }
165
166    fn handle_touch(&mut self, _point: Point, _phase: TouchPhase) -> Option<M> {
167        None
168    }
169
170    fn draw<'t>(
171        &self,
172        renderer: &mut dyn Renderer<C>,
173        theme: &Theme<'t, C>,
174    ) -> Result<(), RenderError> {
175        let default_font = theme.default_font();
176        let default_color = theme.background.on_base;
177        let line_h = self.line_height(default_font.character_size.height);
178
179        let left = self.rect.top_left.x;
180        let right = self.rect.top_left.x + self.rect.size.width as i32;
181        let bottom = self.rect.top_left.y + self.rect.size.height as i32;
182
183        // Pen position tracks the top-left of the next glyph cell.
184        let mut pen_x = left;
185        let mut pen_y = self.rect.top_left.y;
186
187        for span in &self.spans {
188            let font = span.font.unwrap_or(default_font);
189            let color = span.color.unwrap_or(default_color);
190            let glyph_w = font.character_size.width as i32;
191            let glyph_h = font.character_size.height as i32;
192
193            // Split the run into whitespace-preserving words so wrapping
194            // breaks at spaces. A leading word may continue the current
195            // line; subsequent words wrap as needed.
196            for word in split_keep_spaces(&span.text) {
197                let word_w = word.chars().count() as i32 * glyph_w;
198                // Wrap if this word would overflow and we are not at the
199                // line start (avoid wrapping a word wider than the line).
200                if pen_x > left && pen_x + word_w > right {
201                    pen_x = left;
202                    pen_y += line_h as i32 + self.line_spacing as i32;
203                }
204                if pen_y >= bottom {
205                    return Ok(());
206                }
207                // A leading space at the start of a fresh line is dropped
208                // so wrapped lines align flush left.
209                let to_draw = if pen_x == left {
210                    word.trim_start_matches(' ')
211                } else {
212                    word
213                };
214                if !to_draw.is_empty() {
215                    // draw_text's baseline anchor sits at the glyph
216                    // bottom; offset from the cell top.
217                    let baseline = pen_y + glyph_h;
218                    renderer.draw_text(
219                        to_draw,
220                        Point::new(pen_x, baseline),
221                        font,
222                        color,
223                        Alignment::Left,
224                    )?;
225                    pen_x += to_draw.chars().count() as i32 * glyph_w;
226                }
227            }
228        }
229        Ok(())
230    }
231}
232
233/// Split `s` into segments where each space character starts a new
234/// segment, preserving the spaces. e.g. `"a b"` → `["a", " b"]`. This
235/// lets the layout wrap at word boundaries while keeping inter-word
236/// spacing.
237fn split_keep_spaces(s: &str) -> Vec<&str> {
238    let mut out = Vec::new();
239    let mut start = 0;
240    let bytes = s.as_bytes();
241    let mut i = 0;
242    while i < bytes.len() {
243        if bytes[i] == b' ' && i > start {
244            out.push(&s[start..i]);
245            start = i;
246        }
247        i += 1;
248    }
249    if start < s.len() {
250        out.push(&s[start..]);
251    }
252    out
253}