Skip to main content

cranpose_ui/
text_modifier_node.rs

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