Skip to main content

telar_ui_core/
text.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3use std::sync::Arc;
4
5use geometry_core::Rect;
6use layout_core::{LayoutError, LayoutStyle};
7use platform_core::Event;
8use reactive_core::{Effect, effect};
9use renderer_core::TextStyle;
10use ui_tree::{Component, EventResult, RenderNode};
11
12use crate::context::mark_dirty;
13use crate::impl_leaf_widget;
14use crate::layout_leaf::LayoutLeaf;
15
16/// The run the glyph band is measured from: a capital, an x-height letter and a descender, which between
17/// them span the extent a Latin face actually draws in. Any string of the same style is then centred by the
18/// same amount, which is what puts a row of labels on one baseline.
19const REFERENCE: &str = "Hxg";
20/// Room the reference cannot fill: it is three characters, and cosmic-text overflows on an unbounded one.
21const REFERENCE_WIDTH: f32 = 1_000.0;
22
23pub struct Text {
24    content: Rc<dyn Fn() -> String>,
25    cached_content: RefCell<(String, Arc<str>)>,
26    // Glyph-band memo for optical vertical centering: font_size bits -> (ink_top, ink_height, line_height).
27    // Keyed on the size and not on the text, because the band is measured from a reference run — see `view`.
28    cached_ink: RefCell<Option<(u32, f32, f32, f32)>>,
29    style: Rc<dyn Fn() -> TextStyle>,
30    leaf: LayoutLeaf,
31    // Held for its subscription: without it a measured leaf keeps the width the previous string wanted, and `view` shapes the new one into that box — a label that grew soft-wraps into a slot built for the old text. `None` for `Text::new`, whose size is its style.
32    _remeasure: Option<Effect>,
33}
34
35impl Text {
36    pub fn new(
37        content_fn: impl Fn() -> String + 'static,
38        layout_style: LayoutStyle,
39        style_fn: impl Fn() -> TextStyle + 'static,
40    ) -> Result<Self, LayoutError> {
41        // Stretch overrides any parent align-items (e.g. center) so text always fills the parent's cross-axis width instead of collapsing to 0.
42        let leaf = LayoutLeaf::register(layout_style.align_self_stretch())?;
43        Ok(Self {
44            content: Rc::new(content_fn),
45            cached_content: RefCell::new((String::new(), Arc::from(""))),
46            cached_ink: RefCell::new(None),
47            style: Rc::new(style_fn),
48            leaf,
49            _remeasure: None,
50        })
51    }
52
53    /// Like [`Text::new`], but the leaf's height is measured from the content at its
54    /// resolved width, so the box grows to fit however many lines the text wraps
55    /// into and pushes following siblings down instead of overflowing onto them.
56    pub fn auto(
57        content_fn: impl Fn() -> String + 'static,
58        layout_style: LayoutStyle,
59        style_fn: impl Fn() -> TextStyle + 'static,
60    ) -> Result<Self, LayoutError> {
61        let content_fn: Rc<dyn Fn() -> String> = Rc::new(content_fn);
62        let style: Rc<dyn Fn() -> TextStyle> = Rc::new(style_fn);
63
64        let measure_content = Rc::clone(&content_fn);
65        let measure_style = Rc::clone(&style);
66        let measure = Box::new(move |max_width: f32| {
67            let s = (measure_style)();
68            crate::text_metrics::measure_text(&(measure_content)(), max_width, &s)
69        });
70
71        let (node, rect) =
72            crate::context::new_measured_leaf(layout_style.align_self_stretch(), measure)?;
73        // Reads through the measure closure so it subscribes to exactly the signals the measure depends on, and keeps the string it last dirtied for: a signal re-set to its own value would otherwise cost a shaping pass and a relayout of the surface for nothing.
74        let dirty_content = Rc::clone(&content_fn);
75        let measured = RefCell::new(Option::<String>::None);
76        let remeasure = effect(move || {
77            let next = (dirty_content)();
78            if measured.borrow().as_deref() == Some(next.as_str()) {
79                return;
80            }
81            *measured.borrow_mut() = Some(next);
82            mark_dirty(node).ok();
83        });
84        Ok(Self {
85            content: content_fn,
86            cached_content: RefCell::new((String::new(), Arc::from(""))),
87            cached_ink: RefCell::new(None),
88            style,
89            leaf: LayoutLeaf { node, rect },
90            _remeasure: Some(remeasure),
91        })
92    }
93
94    pub fn single_line(
95        content_fn: impl Fn() -> String + 'static,
96        style_fn: impl Fn() -> TextStyle + 'static,
97    ) -> Result<Self, LayoutError> {
98        let height = style_fn().font_size * 1.4;
99        Text::new(content_fn, LayoutStyle::new().height(height), style_fn)
100    }
101}
102
103impl Component for Text {
104    fn view(&self) -> RenderNode {
105        let r = self.leaf.rect.get();
106        let text: Arc<str> = {
107            let new_str = (self.content)();
108            let mut cache = self.cached_content.borrow_mut();
109            if cache.0 != new_str {
110                let rc = Arc::from(new_str.as_str());
111                *cache = (new_str, Arc::clone(&rc));
112                rc
113            } else {
114                Arc::clone(&cache.1)
115            }
116        };
117        let style = (self.style)();
118        // Optically center the glyph band within the leaf. A text leaf stretches to fill its parent's cross
119        // axis (`align_self_stretch`), and the font's line box reserves ascent room for accents that a run
120        // never uses — so line-box-centered text sits visibly high next to an icon.
121        //
122        // The band is measured from a fixed REFERENCE run and not from this text, and that distinction is
123        // the whole point: it makes the offset a property of the *font at this size*, which every label in
124        // the same style then shares. Centering each string on its own ink instead moved it by whether it
125        // happened to contain a descender — so `Modeling` and `Setup` sat on one baseline and `Simulation`
126        // and `Results` sat 1.5px below it, in the same row of tabs. A row of labels that does not share a
127        // baseline is the kind of wrong that is obvious once seen and invisible until then.
128        let (ink_top, ink_height, reference_line) = {
129            let key = style.font_size.to_bits();
130            let mut cache = self.cached_ink.borrow_mut();
131            match cache.as_ref() {
132                Some((k, top, h, line)) if *k == key => (*top, *h, *line),
133                _ => {
134                    let (top, h) =
135                        crate::text_metrics::measure_ink_bounds(REFERENCE, REFERENCE_WIDTH, &style);
136                    let (_, line) =
137                        crate::text_metrics::measure_text(REFERENCE, REFERENCE_WIDTH, &style);
138                    *cache = Some((key, top, h, line));
139                    (top, h, line)
140                }
141            }
142        };
143        // Centre the whole block, then nudge it by how far the glyph band sits off the middle of **one**
144        // line box. Splitting it that way is what makes it work for more than one line: the nudge is a
145        // property of the font at this size, so it applies once however many lines there are, while
146        // centring against the band alone would push an N-line block down by (N-1)/2 lines — which is what
147        // a two-line tooltip did, sinking its second line out of the bubble.
148        let (_, text_height) = crate::text_metrics::measure_text(&text, r.width, &style);
149        let nudge = if ink_height > 0.0 {
150            reference_line / 2.0 - ink_top - ink_height / 2.0
151        } else {
152            0.0
153        };
154        // Rounded onto the pixel grid. Centring lands on a half pixel whenever the box and the text differ
155        // by an odd amount, and a glyph drawn half a row down is resampled across two rows: it does not move,
156        // it goes **soft**. Which is why it looked like a placement bug — the same bubble was crisp beside a
157        // button and blurred under one, because the sideways placement centres on the trigger and contributed
158        // its own half pixel, cancelling this one. Vertical position is the axis to snap; horizontal subpixel
159        // placement is what keeps letter spacing even, and the shaper bins it on purpose.
160        // Within the leaf, always. The nudge is a *centring* refinement, and a box no taller than one line
161        // has nothing to centre in: applying it there walks the glyphs out through the top of the box the
162        // layout reserved for them, so a `pad:6` label inked at row 5.
163        let slack = (r.height - text_height).max(0.0);
164        let y = ((slack / 2.0 + nudge).clamp(0.0, slack)).round();
165        // Render the full line box so nothing clips.
166        let line_height = text_height;
167        self.leaf.at_layout_position(RenderNode::text(
168            text,
169            Rect {
170                x: 0.0,
171                y,
172                width: r.width,
173                height: line_height,
174            },
175            style,
176        ))
177    }
178
179    fn on_event(&mut self, _event: &Event) -> EventResult {
180        EventResult::Ignored
181    }
182
183    fn debug_name(&self) -> &'static str {
184        "Text"
185    }
186}
187
188impl_leaf_widget!(Text);
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::context::{
194        compute_layout, new_container, relayout_if_dirty, reset_layout_runtime, track_layout,
195    };
196    use crate::layout_item::LayoutItem;
197    use layout_core::AvailableSpace;
198    use reactive_core::signal;
199    use renderer_core::Color;
200
201    // Auto-height text must reserve more vertical space when it is narrower (more wrapped lines), so
202    // following content is pushed down instead of overlapped.
203    #[test]
204    fn auto_text_height_grows_when_narrower() {
205        let long = "This is a deliberately long paragraph of text that wraps onto several \
206                    lines when the available width is small, and fewer lines when it is wide.";
207        let height_at = |w: f32| -> f32 {
208            reset_layout_runtime();
209            let t = Text::auto(
210                move || long.to_string(),
211                LayoutStyle::new(),
212                || TextStyle::new(16.0, Color::BLACK),
213            )
214            .unwrap();
215            let node = t.layout_node();
216            compute_layout(
217                node,
218                AvailableSpace::Definite(w),
219                AvailableSpace::MaxContent,
220            )
221            .unwrap();
222            track_layout(node).unwrap().get().height
223        };
224        let narrow = height_at(200.0);
225        let wide = height_at(800.0);
226        assert!(
227            narrow > wide + 20.0,
228            "narrow text should be taller: narrow={narrow} wide={wide}"
229        );
230    }
231
232    /// Two labels of the same style sit on the same baseline, whatever letters they happen to contain.
233    ///
234    /// They did not: the optical centring measured each string's own ink, and a string with a descender has
235    /// ink reaching lower than one without — so `Setup` and `Simulation`, side by side in a row of tabs at
236    /// the same size in the same box, were drawn 2.5px apart. Measuring the band from a reference run makes
237    /// the offset a property of the font at that size, which every label in the style then shares.
238    #[test]
239    fn two_labels_of_one_style_share_a_baseline_whatever_letters_they_have() {
240        reset_layout_runtime();
241        let drawn = |content: &'static str| {
242            let text = Text::new(
243                move || content.to_string(),
244                LayoutStyle::new().width(200.0).height(30.0),
245                || TextStyle::new(13.0, Color::BLACK),
246            )
247            .unwrap();
248            let root = new_container(
249                LayoutStyle::new().flex_column().width(200.0).height(30.0),
250                &[text.layout_node()],
251            )
252            .unwrap();
253            compute_layout(
254                root,
255                AvailableSpace::Definite(200.0),
256                AvailableSpace::Definite(30.0),
257            )
258            .unwrap();
259            // The leaf places itself with a transform, so the text command sits under it.
260            fn text_y(node: &RenderNode) -> Option<f32> {
261                match node {
262                    RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => {
263                        Some(rect.y)
264                    }
265                    RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
266                        children.iter().find_map(text_y)
267                    }
268                    _ => None,
269                }
270            }
271            text_y(&text.view()).expect("a text leaf draws text")
272        };
273        // `Setup` has a descender and `Simulation` has none — the exact pair that drifted.
274        let (with_tail, without) = (drawn("Setup"), drawn("Simulation"));
275        assert!(
276            (with_tail - without).abs() < 0.01,
277            "a descender must not move the line: {with_tail} vs {without}"
278        );
279    }
280
281    /// Text lands on a whole pixel row, whatever its box measures.
282    ///
283    /// Centring puts it on a half pixel whenever the box and the text differ by an odd amount, and a glyph
284    /// drawn half a row down does not move — it is resampled across two rows and goes **soft**. It read as a
285    /// *placement* bug: the same bubble was crisp beside a button and blurred under one, because the sideways
286    /// placement centres on its trigger and happened to contribute a second half pixel that cancelled this
287    /// one. Only the vertical axis is snapped; horizontal subpixel placement is what keeps letter spacing
288    /// even, and the shaper bins it deliberately.
289    #[test]
290    fn text_lands_on_a_whole_pixel_row() {
291        fn text_y(node: &RenderNode) -> Option<f32> {
292            match node {
293                RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => {
294                    Some(rect.y)
295                }
296                RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
297                    children.iter().find_map(text_y)
298                }
299                _ => None,
300            }
301        }
302        reset_layout_runtime();
303        // Odd and fractional box heights, where an unsnapped centre lands on the half.
304        for height in [29.0_f32, 30.0, 31.0, 44.5] {
305            let text = Text::new(
306                || "Setup".to_string(),
307                LayoutStyle::new().width(200.0).height(height),
308                || TextStyle::new(13.0, Color::BLACK),
309            )
310            .unwrap();
311            let root = new_container(
312                LayoutStyle::new().flex_column().width(200.0).height(height),
313                &[text.layout_node()],
314            )
315            .unwrap();
316            compute_layout(
317                root,
318                AvailableSpace::Definite(200.0),
319                AvailableSpace::Definite(height),
320            )
321            .unwrap();
322            let y = text_y(&text.view()).expect("a text leaf draws text");
323            assert_eq!(y, y.round(), "a {height}px box put the text at {y}");
324        }
325    }
326
327    /// The optical nudge never walks the glyphs out of the box the layout reserved for them.
328    ///
329    /// The nudge centres the glyph band, and a box no taller than one line has nothing to centre in — so
330    /// applying it there put the text at a negative `y`. A `pad:6` status line then inked at row 5, one row
331    /// above its own padding, which is how an out-of-tree app found this.
332    #[test]
333    fn text_stays_inside_a_box_that_is_exactly_one_line_tall() {
334        fn text_rect(node: &RenderNode) -> Option<Rect> {
335            match node {
336                RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => Some(*rect),
337                RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
338                    children.iter().find_map(text_rect)
339                }
340                _ => None,
341            }
342        }
343        reset_layout_runtime();
344        for size in [11.0_f32, 13.0, 15.0, 24.0] {
345            let text = Text::auto(
346                || "Ag".to_string(),
347                LayoutStyle::new().width(200.0),
348                move || TextStyle::new(size, Color::BLACK),
349            )
350            .unwrap();
351            let root = new_container(
352                LayoutStyle::new().flex_column().width(200.0),
353                &[text.layout_node()],
354            )
355            .unwrap();
356            compute_layout(
357                root,
358                AvailableSpace::Definite(200.0),
359                AvailableSpace::MaxContent,
360            )
361            .unwrap();
362            let rect = text_rect(&text.view()).expect("a text leaf draws text");
363            assert!(
364                rect.y >= 0.0,
365                "at {size}px the text starts {}px above its own box",
366                -rect.y
367            );
368        }
369    }
370
371    /// A block that wraps sits where its box is, not half a line below it.
372    ///
373    /// Optical centring works on the glyph band, and the band is measured from a one-line reference — so
374    /// centring an N-line block against it pushes the block down by (N-1)/2 lines. A two-line tooltip
375    /// description came out sunk, with its second line hanging out of the bubble. Centring the *block* and
376    /// nudging by the one-line correction is what makes the two cases the same case.
377    #[test]
378    fn a_wrapped_block_is_not_pushed_down_by_the_lines_it_gained() {
379        reset_layout_runtime();
380        let style = || TextStyle::new(13.0, Color::BLACK);
381        let text = Text::auto(
382            || "Name regions and say what the model is made of".to_string(),
383            LayoutStyle::new(),
384            style,
385        )
386        .unwrap();
387        let root = new_container(
388            LayoutStyle::new().flex_column().width(150.0),
389            &[text.layout_node()],
390        )
391        .unwrap();
392        compute_layout(
393            root,
394            AvailableSpace::Definite(150.0),
395            AvailableSpace::MaxContent,
396        )
397        .unwrap();
398
399        let (_, one_line) = crate::text_metrics::measure_text("Hxg", 1_000.0, &style());
400        let (_, block) = crate::text_metrics::measure_text(
401            "Name regions and say what the model is made of",
402            150.0,
403            &style(),
404        );
405        assert!(
406            block > one_line * 1.5,
407            "the test needs a string that actually wraps"
408        );
409
410        fn text_y(node: &RenderNode) -> Option<f32> {
411            match node {
412                RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => {
413                    Some(rect.y)
414                }
415                RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
416                    children.iter().find_map(text_y)
417                }
418                _ => None,
419            }
420        }
421        let y = text_y(&text.view()).expect("a text leaf draws text");
422        assert!(
423            y.abs() < one_line / 3.0,
424            "a block in a box its own size starts at the top: y = {y} against a {one_line}px line"
425        );
426    }
427
428    /// A label that grows re-measures, instead of being shaped into the width the previous string wanted.
429    ///
430    /// The regression it guards is invisible in the widget tree and obvious on screen: a measured leaf is
431    /// dirtied by the layout runtime, never by a content closure, so a bar chip whose title went from
432    /// "Desktop" to a full window title kept the narrow box the short one had measured — and `view` soft-wrapped
433    /// the long title into it, spilling several lines out of a chip one line tall.
434    #[test]
435    fn a_measured_label_re_measures_when_its_content_changes() {
436        reset_layout_runtime();
437        let title = signal(String::from("Desktop"));
438        let read = title.read_only();
439        let label = Text::auto(
440            move || read.get(),
441            LayoutStyle::new(),
442            || TextStyle::new(13.0, Color::BLACK),
443        )
444        .unwrap();
445        let node = label.layout_node();
446        let root = new_container(
447            LayoutStyle::new().flex_row().width(1920.0).height(32.0),
448            &[node],
449        )
450        .unwrap();
451        let space = || {
452            compute_layout(
453                root,
454                AvailableSpace::Definite(1920.0),
455                AvailableSpace::Definite(32.0),
456            )
457            .unwrap()
458        };
459
460        space();
461        let short = label.leaf.rect.get().width;
462
463        title.set("hyprshell - Rust - Visual Studio Code".to_string());
464        relayout_if_dirty();
465        let long = label.leaf.rect.get().width;
466
467        assert!(
468            long > short,
469            "a title five times longer still measured {long}px, the width \"Desktop\" wanted ({short}px) — \
470             it will be wrapped into a box built for the old text"
471        );
472    }
473}