smudge_text/label.rs
1//! The label widget: one block of static or reactive text.
2//!
3//! A [`Label`] lays its text out through the thread's [`TextSystem`] with one
4//! [`TextStyle`], measures itself from the laid out lines, and paints the
5//! resulting glyph runs. A change to the text, the font size, the weight,
6//! the family or the line cap lays the label out again, and a change to the
7//! color only repaints it.
8//!
9//! Create one with [`label`] and shape it with the [`LabelExt`] builder
10//! methods. At mount the label reads the ambient text style, which
11//! [`provide_text_style`](crate::provide_text_style) sets, and fills every
12//! style field that no builder method set. The color the glyphs paint with
13//! resolves against the theme when the label paints, so a theme switch
14//! repaints it: the node's
15//! [`text_color`](smudge_core::element::Element::text_color) when it sets
16//! one, which may name a theme token, and otherwise the ambient style's
17//! color, which defaults to the theme's text token.
18
19use std::cell::RefCell;
20use std::rc::Rc;
21
22use parley::FontFamily;
23use parley::FontWeight;
24use smudge_core::element::Element;
25use smudge_core::element::Value;
26use smudge_core::geometry::Color;
27use smudge_core::geometry::Point;
28use smudge_core::geometry::Size;
29use smudge_core::paint::GlyphRun;
30use smudge_core::paint::PaintOp;
31use smudge_core::semantics::{Role, Semantics};
32use smudge_core::tree::Dirty;
33use smudge_core::widget::AvailableSpace;
34use smudge_core::widget::MeasureInput;
35use smudge_core::widget::PaintCx;
36use smudge_core::widget::Widget;
37
38use crate::layout::TextLayout;
39use crate::style::TextStyle;
40use crate::style::current_text_style;
41
42/// A block of text laid out with one text style.
43///
44/// Build one with [`label`]. The label keeps the layout of its current text
45/// and rebuilds it only when the text, the style or the offered width
46/// changes, and keeps the glyph runs of that layout and rebuilds them only
47/// when the layout, the node's position or the resolved text color changes.
48pub struct Label {
49 /// The text the label shows.
50 text: String,
51 /// The text style the label lays out and paints with, resolved from the
52 /// ambient style and the explicit fields at mount.
53 style: TextStyle,
54 /// The style fields an explicit write set, which the ambient style leaves
55 /// alone.
56 explicit: ExplicitStyle,
57 /// The most lines the label shows, or `None` for every line.
58 max_lines: Option<u32>,
59 /// The layout of [`Label::text`], or `None` before the first measure and
60 /// after every change that invalidates it.
61 ///
62 /// The layout holds the shaped text once; measuring at another offered
63 /// width re-breaks its lines instead of shaping the text again, which is
64 /// what keeps a layout engine's repeated measure calls for one node cheap.
65 layout: Option<TextLayout>,
66 /// The width the lines of [`Label::layout`] were last broken at, `None`
67 /// for max content, or `None` inside `None` when nothing is laid out.
68 broken_width: Option<Option<f32>>,
69 /// The size of the text wrapped at every opportunity, which is its
70 /// min-content size, or `None` until it is asked for.
71 ///
72 /// It is cached apart from the line breaking, so answering a min-content
73 /// request never depends on the width other calls measured at.
74 min_content: Option<Size>,
75 /// The glyph runs of the current layout, with the origin and the resolved
76 /// color they were built at. Paint operations share them through an `Rc`.
77 runs: RefCell<Option<CachedRuns>>,
78}
79
80/// The glyph runs of one layout, with the origin and resolved color they were
81/// built at.
82struct CachedRuns {
83 /// The origin the glyph positions are absolute to.
84 origin: Point,
85 /// The color every run was built with.
86 color: Color,
87 /// The runs, shared by the paint operations that show them.
88 runs: Vec<Rc<GlyphRun>>,
89}
90
91impl Label {
92 /// Creates a label with the default style until the mount reads the
93 /// ambient one.
94 fn new(text: String) -> Label {
95 Label {
96 text,
97 style: TextStyle::default(),
98 explicit: ExplicitStyle::default(),
99 max_lines: None,
100 layout: None,
101 broken_width: None,
102 min_content: None,
103 runs: RefCell::new(None),
104 }
105 }
106
107 /// Drops the cached layout, the glyph runs built from it and the cached
108 /// min-content size.
109 fn invalidate_layout(&mut self) {
110 self.layout = None;
111 self.broken_width = None;
112 self.min_content = None;
113 *self.runs.borrow_mut() = None;
114 }
115}
116
117impl Widget for Label {
118 /// Reports the text as the accessible value.
119 ///
120 /// The role is what an accessibility backend maps a static text to, and
121 /// such a node takes its name from its value there, so the text rides in
122 /// the value and the label stays empty.
123 fn semantics(&self) -> Option<Semantics> {
124 Some(Semantics {
125 role: Role::Label,
126 label: None,
127 value: Some(self.text.clone()),
128 })
129 }
130
131 /// Lays the text out against the offered width and returns its size.
132 ///
133 /// A definite [`AvailableSpace::Definite`] wraps the text at that width,
134 /// and [`AvailableSpace::MaxContent`] lays it out on one line. An
135 /// [`AvailableSpace::MinContent`] request gets the text wrapped at every
136 /// opportunity, whose width is its longest unbreakable word, so a label
137 /// in a row too narrow for it can shrink and wrap instead of overflowing
138 /// and squeezing its siblings. A label capped to one line answers with
139 /// its ellipsis alone and one line of height, since it can cut any text
140 /// down to that. Under a cap of more lines, the min-content height stays
141 /// the full text's, since it answers what the text needs rather than what
142 /// the cap shows; the line cap reports its height at a definite width.
143 ///
144 /// The text is shaped once and kept; a call that offers another width
145 /// than the last one re-breaks the shaped text's lines, because the
146 /// layout engine measures one node at several widths within a pass. The
147 /// min-content answer breaks the same shaped text at every opportunity
148 /// and then breaks it back at the width the label was last measured at,
149 /// so once the label has been measured at one, the answer never changes
150 /// the lines paint draws. The glyph runs are dropped whenever the shaped text
151 /// or its line breaking changed, so paint rebuilds them from the lines the
152 /// last call left.
153 ///
154 /// # Panics
155 ///
156 /// Panics under the conditions of [`TextSystem::with`](crate::TextSystem::with),
157 /// in particular when called from inside another `with` call on the same
158 /// thread.
159 fn measure(&mut self, input: MeasureInput) -> Option<Size> {
160 if input.available_width == AvailableSpace::MinContent && input.known_width.is_none() {
161 if self.min_content.is_none() {
162 let painted = self.broken_width;
163 self.shape_and_break(Some(0.0));
164 let layout = self
165 .layout
166 .as_mut()
167 .expect("the layout was just built or is cached");
168 if matches!(self.max_lines, Some(0 | 1)) {
169 layout.truncate(self.max_lines, Some(0.0), &self.style);
170 }
171 self.min_content = Some(layout.size());
172 // Paint draws the lines the layout holds, so they go back to
173 // the width the label was last measured at, truncated as the
174 // label was last shown. A label measured nowhere yet goes
175 // back to max content, so the cache key the next measure
176 // reads never describes a state the label was not left in.
177 self.shape_and_show(painted.unwrap_or(None));
178 }
179 return self.min_content;
180 }
181 let max_width = wrap_width(input.available_width);
182 if self.broken_width != Some(max_width) {
183 self.shape_and_show(max_width);
184 }
185 let layout = self
186 .layout
187 .as_ref()
188 .expect("the layout was just built or is cached");
189 Some(layout.size())
190 }
191
192 /// Appends one [`PaintOp::Glyphs`] per run of the current layout.
193 ///
194 /// The runs are rebuilt when they were invalidated, when the node paints
195 /// at another position, or when the resolved text color changed, because
196 /// glyph positions and colors are baked into them. Each run carries the
197 /// color the node's text color or the label's style resolves to under the
198 /// current theme. When that color is a token, resolving it marks the node
199 /// to repaint on a theme switch, and a literal color does not.
200 ///
201 /// # Panics
202 ///
203 /// Panics when the glyph runs are already borrowed on this thread, which
204 /// the paint pass cannot do: it paints one node at a time and a widget
205 /// never paints its own node again from inside the call.
206 fn paint(&self, cx: &PaintCx<'_>, out: &mut Vec<PaintOp>) {
207 let Some(layout) = &self.layout else {
208 return;
209 };
210 let color = self.text_color(cx);
211 let origin = cx.rect.origin();
212 let mut cache = self.runs.borrow_mut();
213 let rebuild = match &*cache {
214 Some(cached) => cached.origin != origin || cached.color != color,
215 None => true,
216 };
217 if rebuild {
218 // The color is baked into the runs here rather than into the
219 // layout, so a color change repaints the label without laying
220 // its text out again.
221 let runs = layout
222 .glyph_runs_at(origin, color)
223 .into_iter()
224 .map(Rc::new)
225 .collect();
226 *cache = Some(CachedRuns {
227 origin,
228 color,
229 runs,
230 });
231 }
232 if let Some(cached) = cache.as_ref() {
233 out.extend(cached.runs.iter().cloned().map(PaintOp::Glyphs));
234 }
235 }
236}
237
238impl Label {
239 /// Shapes [`Label::text`] with [`Label::style`] when no shaped layout is
240 /// kept, breaks its lines at `max_width`, and cuts the lines to
241 /// [`Label::max_lines`].
242 ///
243 /// Both outcomes change the lines, so the cached glyph runs, which bake
244 /// the line positions in, are dropped here.
245 fn shape_and_show(&mut self, max_width: Option<f32>) {
246 self.shape_and_break(max_width);
247 if let Some(layout) = &mut self.layout {
248 layout.truncate(self.max_lines, max_width, &self.style);
249 }
250 }
251
252 /// Shapes [`Label::text`] with [`Label::style`] when no shaped layout is
253 /// kept, and breaks its lines at `max_width` without truncating them.
254 ///
255 /// The min-content measure breaks at every opportunity to read the
256 /// longest unbreakable word off the lines, which truncation would
257 /// shorten, so it asks for the untruncated break.
258 fn shape_and_break(&mut self, max_width: Option<f32>) {
259 match &mut self.layout {
260 Some(layout) => layout.rebreak(max_width),
261 None => {
262 self.layout = Some(TextLayout::new(&self.text, &self.style, max_width));
263 }
264 }
265 self.broken_width = Some(max_width);
266 *self.runs.borrow_mut() = None;
267 }
268
269 /// Returns the color the glyphs paint with: the node's text color when it
270 /// sets one, otherwise the label's style color. Both may name a theme
271 /// token, so both resolve against the theme that is current at paint
272 /// time.
273 fn text_color(&self, cx: &PaintCx<'_>) -> Color {
274 match &cx.style.paint.text_color {
275 Some(color) => color.resolve(cx.theme),
276 None => self.style.color.resolve(cx.theme),
277 }
278 }
279}
280
281/// Creates a label that shows `text`.
282///
283/// The text can be fixed or read from signals:
284///
285/// ```
286/// use smudge_core::reactive::{prelude::*, signal_local};
287/// use smudge_text::label;
288///
289/// let (name, set_name) = signal_local("World".to_owned());
290/// let greeting = label(move || format!("Hello, {}", name.get()));
291/// set_name.set("Smudge".to_owned());
292/// ```
293pub fn label(text: impl Into<Value<String>>) -> Element<Label> {
294 // The ambient style binding is installed first, so its update runs before
295 // the updates of the builder methods and an explicit field wins over it.
296 Element::new(Label::new(String::new()))
297 .bind(AmbientStyleKey, current_text_style, apply_ambient_style)
298 .bind(TextKey, text, set_text)
299}
300
301/// Builder methods that set the text style of a [`label`] element.
302///
303/// Import the trait to use them. The style types the methods name come from
304/// this crate, so nothing else is needed:
305///
306/// ```
307/// use smudge_text::FontWeight;
308/// use smudge_text::LabelExt;
309/// use smudge_text::label;
310///
311/// let element = label("Hello").text_size(18.0).weight(FontWeight::SEMI_BOLD);
312/// ```
313///
314/// Every method sets one field of the label's text style; the remaining
315/// fields come from the ambient style at mount. The color the glyphs paint
316/// with is a paint property of the node, set with
317/// [`text_color`](smudge_core::element::Element::text_color), which needs no
318/// import.
319pub trait LabelExt: Sized {
320 /// Sets the font size in logical pixels.
321 ///
322 /// The size changes what the label measures, so the node is laid out
323 /// again.
324 fn text_size(self, size: impl Into<Value<f32>>) -> Self;
325
326 /// Sets the weight the family is matched with.
327 ///
328 /// The weight can change what the label measures, so the node is laid out
329 /// again.
330 fn weight(self, weight: FontWeight) -> Self;
331
332 /// Sets the font family the text resolves to.
333 ///
334 /// The family can change what the label measures, so the node is laid out
335 /// again.
336 fn family(self, family: FontFamily<'static>) -> Self;
337
338 /// Caps the lines the label shows at `max_lines`.
339 ///
340 /// A layout that breaks into more lines, or whose last kept line is
341 /// wider than the label, keeps its first `max_lines` and ends the last
342 /// one with an ellipsis, cut back on a cluster boundary so the whole fits
343 /// the width the label wraps at. A width below the ellipsis's own advance
344 /// leaves it standing alone and past the width. Right-to-left text keeps
345 /// its first lines without an ellipsis, and so does text in fonts that
346 /// map neither an ellipsis nor a full stop. `None`, the default, shows
347 /// every line. The accessible value keeps the full text either way. The
348 /// cap can change what the label measures, so the node is laid out
349 /// again, without shaping the text again.
350 ///
351 /// ```
352 /// use smudge_text::LabelExt;
353 /// use smudge_text::label;
354 ///
355 /// let capped = label("Hello").max_lines(Some(2));
356 /// let uncapped = label("Hello").max_lines(None);
357 /// ```
358 fn max_lines(self, max_lines: impl Into<Value<Option<u32>>>) -> Self;
359}
360
361/// The key of a label's ambient text style binding.
362struct AmbientStyleKey;
363
364/// The key of a label's text binding.
365struct TextKey;
366
367/// The key of a label's font size binding.
368struct TextSizeKey;
369
370/// The key of a label's weight binding.
371struct WeightKey;
372
373/// The key of a label's family binding.
374struct FamilyKey;
375
376/// The key of a label's line cap binding.
377struct MaxLinesKey;
378
379impl LabelExt for Element<Label> {
380 fn text_size(self, size: impl Into<Value<f32>>) -> Self {
381 self.bind(TextSizeKey, size, set_text_size)
382 }
383
384 fn weight(self, weight: FontWeight) -> Self {
385 self.bind(WeightKey, Value::Static(weight), set_weight)
386 }
387
388 fn family(self, family: FontFamily<'static>) -> Self {
389 self.bind(FamilyKey, Value::Static(family), set_family)
390 }
391
392 fn max_lines(self, max_lines: impl Into<Value<Option<u32>>>) -> Self {
393 self.bind(MaxLinesKey, max_lines, set_max_lines)
394 }
395}
396
397/// Which fields of the text style an explicit write has set.
398///
399/// The ambient style is read once at mount and fills only the other fields,
400/// so an explicit size keeps its value when the ambient style differs.
401#[derive(Clone, Copy, Default)]
402struct ExplicitStyle {
403 /// The family was set by [`LabelExt::family`].
404 family: bool,
405 /// The size was set by [`LabelExt::text_size`].
406 size: bool,
407 /// The weight was set by [`LabelExt::weight`].
408 weight: bool,
409}
410
411/// Writes `text` into the label and relays it out when it changed.
412fn set_text(label: &mut Label, text: String) -> Dirty {
413 if label.text == text {
414 return Dirty::Clean;
415 }
416 label.text = text;
417 label.invalidate_layout();
418 Dirty::Layout
419}
420
421/// Writes `size` into the label's style and relays it out when it changed.
422fn set_text_size(label: &mut Label, size: f32) -> Dirty {
423 label.explicit.size = true;
424 if label.style.size == size {
425 return Dirty::Clean;
426 }
427 label.style.size = size;
428 label.invalidate_layout();
429 Dirty::Layout
430}
431
432/// Writes `weight` into the label's style and relays it out when it changed.
433fn set_weight(label: &mut Label, weight: FontWeight) -> Dirty {
434 label.explicit.weight = true;
435 if label.style.weight == weight {
436 return Dirty::Clean;
437 }
438 label.style.weight = weight;
439 label.invalidate_layout();
440 Dirty::Layout
441}
442
443/// Writes `family` into the label's style and relays it out when it changed.
444fn set_family(label: &mut Label, family: FontFamily<'static>) -> Dirty {
445 label.explicit.family = true;
446 if label.style.family == family {
447 return Dirty::Clean;
448 }
449 label.style.family = family;
450 label.invalidate_layout();
451 Dirty::Layout
452}
453
454/// Writes the line cap into the label and relays it out when it changed.
455///
456/// The shaped text stays: only its lines are broken and cut again.
457fn set_max_lines(label: &mut Label, max_lines: Option<u32>) -> Dirty {
458 if label.max_lines == max_lines {
459 return Dirty::Clean;
460 }
461 label.max_lines = max_lines;
462 label.broken_width = None;
463 label.min_content = None;
464 *label.runs.borrow_mut() = None;
465 Dirty::Layout
466}
467
468/// Fills the fields of the label's style that no explicit write has set from
469/// the ambient style, which is read once at mount.
470fn apply_ambient_style(label: &mut Label, mut ambient: TextStyle) -> Dirty {
471 let explicit = label.explicit;
472 if explicit.family {
473 ambient.family = label.style.family.clone();
474 }
475 if explicit.size {
476 ambient.size = label.style.size;
477 }
478 if explicit.weight {
479 ambient.weight = label.style.weight;
480 }
481 if ambient == label.style {
482 return Dirty::Clean;
483 }
484 label.style = ambient;
485 label.invalidate_layout();
486 Dirty::Layout
487}
488
489/// The maximum width a layout wraps at for the space offered on the x axis.
490///
491/// A definite space wraps the text at that width, a max-content request lays
492/// it out on one line, and a min-content request wraps it at every
493/// opportunity. `measure` answers min-content from its own cache before it
494/// gets here, so the last arm only keeps the mapping right for any other
495/// caller.
496fn wrap_width(space: AvailableSpace) -> Option<f32> {
497 match space {
498 AvailableSpace::Definite(width) => Some(width),
499 AvailableSpace::MaxContent => None,
500 AvailableSpace::MinContent => Some(0.0),
501 }
502}
503
504#[cfg(test)]
505#[path = "label_tests.rs"]
506mod tests;