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
16pub struct Text {
17    content: Rc<dyn Fn() -> String>,
18    cached_content: RefCell<(String, Arc<str>)>,
19    // Ink bounds memo for optical vertical centering: (text, width_bits) -> (ink_top, ink_height).
20    // Recomputed only when the text or its resolved width changes, so a static label costs no per-frame shaping.
21    cached_ink: RefCell<Option<(String, u32, f32, f32)>>,
22    style: Rc<dyn Fn() -> TextStyle>,
23    leaf: LayoutLeaf,
24    // 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.
25    _remeasure: Option<Effect>,
26}
27
28impl Text {
29    pub fn new(
30        content_fn: impl Fn() -> String + 'static,
31        layout_style: LayoutStyle,
32        style_fn: impl Fn() -> TextStyle + 'static,
33    ) -> Result<Self, LayoutError> {
34        // Stretch overrides any parent align-items (e.g. center) so text always fills the parent's cross-axis width instead of collapsing to 0.
35        let leaf = LayoutLeaf::register(layout_style.align_self_stretch())?;
36        Ok(Self {
37            content: Rc::new(content_fn),
38            cached_content: RefCell::new((String::new(), Arc::from(""))),
39            cached_ink: RefCell::new(None),
40            style: Rc::new(style_fn),
41            leaf,
42            _remeasure: None,
43        })
44    }
45
46    /// Like [`Text::new`], but the leaf's height is measured from the content at its
47    /// resolved width, so the box grows to fit however many lines the text wraps
48    /// into and pushes following siblings down instead of overflowing onto them.
49    pub fn auto(
50        content_fn: impl Fn() -> String + 'static,
51        layout_style: LayoutStyle,
52        style_fn: impl Fn() -> TextStyle + 'static,
53    ) -> Result<Self, LayoutError> {
54        let content_fn: Rc<dyn Fn() -> String> = Rc::new(content_fn);
55        let style: Rc<dyn Fn() -> TextStyle> = Rc::new(style_fn);
56
57        let measure_content = Rc::clone(&content_fn);
58        let measure_style = Rc::clone(&style);
59        let measure = Box::new(move |max_width: f32| {
60            let s = (measure_style)();
61            renderer_text::measure_text(&(measure_content)(), max_width, &s)
62        });
63
64        let (node, rect) =
65            crate::context::new_measured_leaf(layout_style.align_self_stretch(), measure)?;
66        // 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.
67        let dirty_content = Rc::clone(&content_fn);
68        let measured = RefCell::new(Option::<String>::None);
69        let remeasure = effect(move || {
70            let next = (dirty_content)();
71            if measured.borrow().as_deref() == Some(next.as_str()) {
72                return;
73            }
74            *measured.borrow_mut() = Some(next);
75            mark_dirty(node).ok();
76        });
77        Ok(Self {
78            content: content_fn,
79            cached_content: RefCell::new((String::new(), Arc::from(""))),
80            cached_ink: RefCell::new(None),
81            style,
82            leaf: LayoutLeaf { node, rect },
83            _remeasure: Some(remeasure),
84        })
85    }
86
87    pub fn single_line(
88        content_fn: impl Fn() -> String + 'static,
89        style_fn: impl Fn() -> TextStyle + 'static,
90    ) -> Result<Self, LayoutError> {
91        let height = style_fn().font_size * 1.4;
92        Text::new(content_fn, LayoutStyle::new().height(height), style_fn)
93    }
94}
95
96impl Component for Text {
97    fn view(&self) -> RenderNode {
98        let r = self.leaf.rect.get();
99        let text: Arc<str> = {
100            let new_str = (self.content)();
101            let mut cache = self.cached_content.borrow_mut();
102            if cache.0 != new_str {
103                let rc = Arc::from(new_str.as_str());
104                *cache = (new_str, Arc::clone(&rc));
105                rc
106            } else {
107                Arc::clone(&cache.1)
108            }
109        };
110        let style = (self.style)();
111        // Optically center the text's INK within the leaf. A text leaf stretches to fill its parent's cross
112        // axis (`align_self_stretch`), and the font's line box reserves ascent room for accents/descenders
113        // that a short run ("72%") leaves empty — so line-box-centered text sits visibly high next to an
114        // icon. Centering the actual drawn glyph extent lines the two up. Memoized per (text, width).
115        let (ink_top, ink_height) = {
116            let width_bits = r.width.to_bits();
117            let mut cache = self.cached_ink.borrow_mut();
118            match cache.as_ref() {
119                Some((t, w, top, h)) if *t == *text && *w == width_bits => (*top, *h),
120                _ => {
121                    let (top, h) = renderer_text::measure_ink_bounds(&text, r.width, &style);
122                    *cache = Some((text.to_string(), width_bits, top, h));
123                    (top, h)
124                }
125            }
126        };
127        // Render the full line box (so nothing clips), offset so the ink's own center lands on the leaf's
128        // center. When there is no ink (empty run) fall back to a top-aligned box.
129        let (_, line_height) = renderer_text::measure_text(&text, r.width, &style);
130        let y = if ink_height > 0.0 {
131            r.height / 2.0 - ink_top - ink_height / 2.0
132        } else {
133            0.0
134        };
135        self.leaf.at_layout_position(RenderNode::text(
136            text,
137            Rect {
138                x: 0.0,
139                y,
140                width: r.width,
141                height: line_height,
142            },
143            style,
144        ))
145    }
146
147    fn on_event(&mut self, _event: &Event) -> EventResult {
148        EventResult::Ignored
149    }
150
151    fn debug_name(&self) -> &'static str {
152        "Text"
153    }
154}
155
156impl_leaf_widget!(Text);
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::context::{compute_layout, new_container, relayout_if_dirty, reset_layout_runtime};
162    use crate::layout_item::LayoutItem;
163    use layout_core::AvailableSpace;
164    use reactive_core::signal;
165    use renderer_core::Color;
166
167    /// A label that grows re-measures, instead of being shaped into the width the previous string wanted.
168    ///
169    /// The regression it guards is invisible in the widget tree and obvious on screen: a measured leaf is
170    /// dirtied by the layout runtime, never by a content closure, so a bar chip whose title went from
171    /// "Desktop" to a full window title kept the narrow box the short one had measured — and `view` soft-wrapped
172    /// the long title into it, spilling several lines out of a chip one line tall.
173    #[test]
174    fn a_measured_label_re_measures_when_its_content_changes() {
175        reset_layout_runtime();
176        let title = signal(String::from("Desktop"));
177        let read = title.read_only();
178        let label = Text::auto(
179            move || read.get(),
180            LayoutStyle::new(),
181            || TextStyle::new(13.0, Color::BLACK),
182        )
183        .unwrap();
184        let node = label.layout_node();
185        let root = new_container(
186            LayoutStyle::new().flex_row().width(1920.0).height(32.0),
187            &[node],
188        )
189        .unwrap();
190        let space = || {
191            compute_layout(
192                root,
193                AvailableSpace::Definite(1920.0),
194                AvailableSpace::Definite(32.0),
195            )
196            .unwrap()
197        };
198
199        space();
200        let short = label.leaf.rect.get().width;
201
202        title.set("hyprshell - Rust - Visual Studio Code".to_string());
203        relayout_if_dirty();
204        let long = label.leaf.rect.get().width;
205
206        assert!(
207            long > short,
208            "a title five times longer still measured {long}px, the width \"Desktop\" wanted ({short}px) — \
209             it will be wrapped into a box built for the old text"
210        );
211    }
212}