Skip to main content

cranpose_ui/
text_modifier_node.rs

1use std::{
2    cell::{Cell, Ref, RefCell},
3    hash::{Hash, Hasher},
4    rc::Rc,
5    sync::Arc,
6};
7
8use cranpose_foundation::{
9    Constraints, DelegatableNode, DrawModifierNode, InvalidationKind, LayoutModifierNode,
10    Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement, NodeCapabilities,
11    NodeState, SemanticsConfiguration, SemanticsNode, Size,
12};
13use smallvec::SmallVec;
14
15use crate::{
16    density::Density,
17    text::{AnnotatedString, TextLayoutOptions, TextStyle},
18};
19
20/// Node that stores text content and handles measurement, drawing, and semantics.
21///
22/// This node implements three capabilities:
23/// - **Layout**: Measures text and returns appropriate size
24/// - **Draw**: Supplies prepared text state consumed by scene building
25/// - **Semantics**: Provides text content for accessibility
26///
27/// Matches Jetpack Compose: `TextStringSimpleNode` in
28/// `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt`
29#[derive(Debug)]
30pub struct TextModifierNode {
31    layout: Rc<TextPreparedLayoutOwner>,
32    density: Density,
33    state: NodeState,
34}
35
36const PREPARED_LAYOUT_CACHE_CAPACITY: usize = 4;
37
38#[derive(Clone, Debug)]
39struct TextPreparedLayoutCacheEntry {
40    widths: crate::text::measure::PreparedWidths,
41    text_generation: u64,
42    font_scale_fingerprint: u32,
43    layout: Rc<crate::text::PreparedTextLayout>,
44}
45
46/// A text node's text and its prepared layouts. The node and the slices
47/// built from it share one owner for the node's life: an update changes its
48/// source in place, so the slices read the new text without being rebuilt.
49#[derive(Debug)]
50struct TextPreparedLayoutOwner {
51    source: RefCell<TextLayoutSource>,
52    node_id: Cell<Option<cranpose_core::NodeId>>,
53    measured_max_width: Cell<Option<Option<f32>>>,
54    cache: RefCell<SmallVec<[TextPreparedLayoutCacheEntry; 1]>>,
55}
56
57/// What a text is laid out from.
58#[derive(Debug)]
59struct TextLayoutSource {
60    text: Rc<AnnotatedString>,
61    style: Arc<TextStyle>,
62    style_hash: u64,
63    options: TextLayoutOptions,
64    /// Whether layouts come from the text service's shared cache. A text
65    /// changed in place, as a ticker's is every frame, lays out unshared.
66    shares_layouts: bool,
67}
68
69#[derive(Clone, Debug)]
70pub(crate) struct TextPreparedLayoutHandle {
71    owner: Rc<TextPreparedLayoutOwner>,
72}
73
74impl TextPreparedLayoutOwner {
75    fn new(text: Rc<AnnotatedString>, style: TextStyle, options: TextLayoutOptions) -> Self {
76        let style_hash = style.render_hash();
77        Self {
78            source: RefCell::new(TextLayoutSource {
79                text,
80                style: Arc::new(style),
81                style_hash,
82                options: options.normalized(),
83                shares_layouts: true,
84            }),
85            node_id: Cell::new(None),
86            measured_max_width: Cell::new(None),
87            cache: RefCell::new(SmallVec::new()),
88        }
89    }
90
91    /// Lays the text out from `text`, `style` and normalized `options` from
92    /// now on, dropping the layouts prepared from the old ones. A style equal
93    /// to the current one keeps being shared.
94    fn update(&self, text: &Rc<AnnotatedString>, style: &TextStyle, options: TextLayoutOptions) {
95        let mut source = self.source.borrow_mut();
96        let same_style = *source.style == *style;
97        if source.text == *text && same_style && source.options == options {
98            return;
99        }
100        if !same_style {
101            source.style = Arc::new(style.clone());
102            source.style_hash = style.render_hash();
103        }
104        source.text = Rc::clone(text);
105        source.options = options;
106        source.shares_layouts = false;
107        drop(source);
108        self.cache.borrow_mut().clear();
109    }
110
111    fn annotated_text(&self) -> Ref<'_, Rc<AnnotatedString>> {
112        Ref::map(self.source.borrow(), |source| &source.text)
113    }
114
115    fn style(&self) -> Ref<'_, TextStyle> {
116        Ref::map(self.source.borrow(), |source| &*source.style)
117    }
118
119    fn options(&self) -> TextLayoutOptions {
120        self.source.borrow().options
121    }
122
123    fn node_id(&self) -> Option<cranpose_core::NodeId> {
124        self.node_id.get()
125    }
126
127    fn set_node_id(&self, node_id: Option<cranpose_core::NodeId>) {
128        if self.node_id.replace(node_id) != node_id {
129            self.cache.borrow_mut().clear();
130        }
131    }
132
133    fn with_prepared<R>(
134        &self,
135        max_width: Option<f32>,
136        read: impl FnOnce(&Rc<crate::text::PreparedTextLayout>) -> R,
137    ) -> R {
138        let normalized_max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
139        let (text_generation, font_scale_fingerprint) = crate::render_state::text_layout_stamp();
140
141        {
142            let mut cache = self.cache.borrow_mut();
143            if let Some(index) = cache.iter().position(|entry| {
144                entry.widths.hold(normalized_max_width)
145                    && entry.text_generation == text_generation
146                    && entry.font_scale_fingerprint == font_scale_fingerprint
147            }) {
148                cache[..=index].rotate_right(1);
149                return read(&cache[0].layout);
150            }
151        }
152
153        let source = self.source.borrow();
154        let prepare = if source.shares_layouts {
155            crate::text::prepare_text_layout_for_node
156        } else {
157            crate::text::measure::prepare_unshared_text_layout_for_node
158        };
159        let prepared = prepare(
160            self.node_id(),
161            &source.text,
162            &source.style,
163            source.options,
164            normalized_max_width,
165        );
166        if Arc::ptr_eq(&prepared.visual_style, &source.style) {
167            let _ = prepared.visual_style_hash.set(source.style_hash);
168        }
169        let widths = crate::text::measure::PreparedWidths::of(
170            source.text.as_ref(),
171            source.options,
172            normalized_max_width,
173            &prepared,
174        );
175        drop(source);
176
177        let mut cache = self.cache.borrow_mut();
178        cache.insert(
179            0,
180            TextPreparedLayoutCacheEntry {
181                widths,
182                text_generation,
183                font_scale_fingerprint,
184                layout: prepared,
185            },
186        );
187        cache.truncate(PREPARED_LAYOUT_CACHE_CAPACITY);
188        read(&cache[0].layout)
189    }
190
191    /// The size of the layout the cache holds for `max_width` and the max
192    /// widths that layout holds for; `None` when the cache holds none.
193    fn cached_widths(
194        &self,
195        max_width: Option<f32>,
196    ) -> Option<(Size, crate::text::measure::PreparedWidths)> {
197        let normalized_max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
198        let (text_generation, font_scale_fingerprint) = crate::render_state::text_layout_stamp();
199        self.cache
200            .borrow()
201            .iter()
202            .find(|entry| {
203                entry.widths.hold(normalized_max_width)
204                    && entry.text_generation == text_generation
205                    && entry.font_scale_fingerprint == font_scale_fingerprint
206            })
207            .map(|entry| {
208                (
209                    Size::new(entry.layout.metrics.width, entry.layout.metrics.height),
210                    entry.widths,
211                )
212            })
213    }
214
215    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
216        self.with_prepared(max_width, |prepared| Size {
217            width: prepared.metrics.width,
218            height: prepared.metrics.height,
219        })
220    }
221
222    fn measure_layout(&self, max_width: Option<f32>) -> (Size, cranpose_ui_layout::AlignmentLines) {
223        self.measured_max_width.set(Some(max_width));
224        self.with_prepared(max_width, |prepared| {
225            (
226                Size::new(prepared.metrics.width, prepared.metrics.height),
227                prepared.alignment_lines,
228            )
229        })
230    }
231
232    fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
233        self.measured_max_width
234            .get()
235            .map(|max_width| self.with_prepared(max_width, Rc::clone))
236    }
237}
238
239impl TextPreparedLayoutHandle {
240    fn new(owner: Rc<TextPreparedLayoutOwner>) -> Self {
241        Self { owner }
242    }
243
244    pub(crate) fn annotated_text(&self) -> Ref<'_, Rc<AnnotatedString>> {
245        self.owner.annotated_text()
246    }
247
248    pub(crate) fn style(&self) -> Ref<'_, TextStyle> {
249        self.owner.style()
250    }
251
252    pub(crate) fn options(&self) -> TextLayoutOptions {
253        self.owner.options()
254    }
255
256    pub(crate) fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
257        self.owner.measured_layout()
258    }
259}
260
261impl TextModifierNode {
262    /// A text node sized on `density`'s device pixel grid.
263    pub fn new(
264        text: Rc<AnnotatedString>,
265        style: TextStyle,
266        options: TextLayoutOptions,
267        density: Density,
268    ) -> Self {
269        Self {
270            layout: Rc::new(TextPreparedLayoutOwner::new(text, style, options)),
271            density,
272            state: NodeState::new(),
273        }
274    }
275
276    /// The text's size rounded up to whole device pixels, as Compose sizes a
277    /// text node (`TextLayoutResult.size` is the paragraph's size, `ceil`ed),
278    /// so whatever follows it starts on the pixel grid.
279    fn pixel_size(&self, size: Size) -> Size {
280        Size {
281            width: self.density.ceil(size.width),
282            height: self.density.ceil(size.height),
283        }
284    }
285
286    pub fn text(&self) -> Ref<'_, str> {
287        Ref::map(self.layout.annotated_text(), |text| text.text.as_str())
288    }
289
290    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
291        Rc::clone(&self.layout.annotated_text())
292    }
293
294    pub fn style(&self) -> Ref<'_, TextStyle> {
295        self.layout.style()
296    }
297
298    pub fn options(&self) -> TextLayoutOptions {
299        self.layout.options()
300    }
301
302    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
303        self.layout.measure_text_content(max_width)
304    }
305
306    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
307        TextPreparedLayoutHandle::new(self.layout.clone())
308    }
309}
310
311impl DelegatableNode for TextModifierNode {
312    fn node_state(&self) -> &NodeState {
313        &self.state
314    }
315}
316
317impl ModifierNode for TextModifierNode {
318    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
319        self.layout.set_node_id(context.node_id());
320        context.invalidate(InvalidationKind::Layout);
321        context.invalidate(InvalidationKind::Draw);
322        context.invalidate(InvalidationKind::Semantics);
323    }
324
325    fn on_detach(&mut self) {
326        self.layout.set_node_id(None);
327    }
328
329    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
330        Some(self)
331    }
332
333    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
334        Some(self)
335    }
336
337    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
338        Some(self)
339    }
340
341    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
342        Some(self)
343    }
344
345    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
346        Some(self)
347    }
348
349    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
350        Some(self)
351    }
352}
353
354impl LayoutModifierNode for TextModifierNode {
355    /// A text holds while its layout does and the bounds keep its size: it
356    /// measures nothing it wraps.
357    fn measure_hold(
358        &self,
359        _density: f32,
360        constraints: Constraints,
361        size: Size,
362        _wrapped: cranpose_ui_layout::WrappedHold,
363    ) -> Option<cranpose_ui_layout::ConstraintsHold> {
364        let max_width = constraints
365            .max_width
366            .is_finite()
367            .then_some(constraints.max_width);
368        let (text_size, widths) = self.layout.cached_widths(max_width)?;
369        let text_size = self.pixel_size(text_size);
370        if size != text_size {
371            return None;
372        }
373        Some(cranpose_ui_layout::ConstraintsHold {
374            width: cranpose_ui_layout::AxisHold {
375                min: cranpose_ui_layout::BoundRange::up_to(text_size.width),
376                max: cranpose_ui_layout::BoundRange::from(text_size.width)
377                    .intersect(widths.max_width_range())?,
378            },
379            height: cranpose_ui_layout::AxisHold::sized(text_size.height),
380        })
381    }
382
383    fn measure(
384        &self,
385        _context: &mut dyn ModifierNodeContext,
386        _measurable: &dyn Measurable,
387        constraints: Constraints,
388    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
389        let max_width = constraints
390            .max_width
391            .is_finite()
392            .then_some(constraints.max_width);
393        let (text_size, alignment_lines) = self.layout.measure_layout(max_width);
394        let text_size = self.pixel_size(text_size);
395
396        let width = text_size
397            .width
398            .clamp(constraints.min_width, constraints.max_width);
399        let height = text_size
400            .height
401            .clamp(constraints.min_height, constraints.max_height);
402
403        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
404            .with_alignment_lines(alignment_lines)
405    }
406
407    fn min_intrinsic_width(
408        &self,
409        _measurable: &dyn Measurable,
410        _height: f32,
411        _density: f32,
412    ) -> f32 {
413        self.pixel_size(self.measure_text_content(None)).width
414    }
415
416    fn max_intrinsic_width(
417        &self,
418        _measurable: &dyn Measurable,
419        _height: f32,
420        _density: f32,
421    ) -> f32 {
422        self.pixel_size(self.measure_text_content(None)).width
423    }
424
425    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
426        self.pixel_size(
427            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
428        )
429        .height
430    }
431
432    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
433        self.pixel_size(
434            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
435        )
436        .height
437    }
438}
439
440impl DrawModifierNode for TextModifierNode {}
441
442impl SemanticsNode for TextModifierNode {
443    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
444        config
445            .content_description
446            .get_or_insert_with(|| self.text().to_string());
447    }
448
449    fn reach(&self) -> cranpose_foundation::SemanticsReach {
450        cranpose_foundation::SemanticsReach::default()
451    }
452}
453
454/// Element that creates and updates TextModifierNode instances.
455///
456/// This follows the modifier element pattern where the element is responsible for:
457/// - Creating new nodes (via `create`)
458/// - Updating existing nodes when properties change (via `update`)
459/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
460///
461/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
462#[derive(Debug, Clone, PartialEq)]
463pub struct TextModifierElement {
464    text: Rc<AnnotatedString>,
465    style: TextStyle,
466    options: TextLayoutOptions,
467    density: Density,
468}
469
470impl TextModifierElement {
471    /// A text laid out on `density`'s device pixel grid, the composition's
472    /// [`crate::density::density`] where a `Text` is composed.
473    pub fn new(
474        text: Rc<AnnotatedString>,
475        style: TextStyle,
476        options: TextLayoutOptions,
477        density: Density,
478    ) -> Self {
479        Self {
480            text,
481            style,
482            options: options.normalized(),
483            density,
484        }
485    }
486}
487
488impl Hash for TextModifierElement {
489    fn hash<H: Hasher>(&self, state: &mut H) {
490        self.text.render_hash().hash(state);
491        self.options.hash(state);
492        self.density.density().to_bits().hash(state);
493    }
494}
495
496impl ModifierNodeElement for TextModifierElement {
497    type Node = TextModifierNode;
498
499    fn create(&self) -> Self::Node {
500        TextModifierNode::new(
501            self.text.clone(),
502            self.style.clone(),
503            self.options,
504            self.density,
505        )
506    }
507
508    fn update(&self, node: &mut Self::Node) {
509        node.density = self.density;
510        node.layout.update(&self.text, &self.style, self.options);
511    }
512
513    fn capabilities(&self) -> NodeCapabilities {
514        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
515    }
516}
517
518#[cfg(test)]
519#[path = "tests/text_modifier_node_tests.rs"]
520mod tests;