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