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: Arc<TextStyle>, options: TextLayoutOptions) -> Self {
76        let style_hash = style.render_hash();
77        Self {
78            source: RefCell::new(TextLayoutSource {
79                text,
80                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. The source
93    /// takes the element's style even when it equals the current one, so
94    /// the element, the source and the layouts prepared next share one
95    /// copy; the old one leaves with the layouts that hold it.
96    fn update(
97        &self,
98        text: &Rc<AnnotatedString>,
99        style: &Arc<TextStyle>,
100        options: TextLayoutOptions,
101    ) {
102        let mut source = self.source.borrow_mut();
103        let shared_style = Arc::ptr_eq(&source.style, style);
104        let same_style = shared_style || *source.style == **style;
105        if source.text == *text && same_style && source.options == options {
106            return;
107        }
108        if !same_style {
109            source.style_hash = style.render_hash();
110        }
111        if !shared_style {
112            source.style = Arc::clone(style);
113        }
114        source.text = Rc::clone(text);
115        source.options = options;
116        source.shares_layouts = false;
117        drop(source);
118        self.cache.borrow_mut().clear();
119    }
120
121    fn annotated_text(&self) -> Ref<'_, Rc<AnnotatedString>> {
122        Ref::map(self.source.borrow(), |source| &source.text)
123    }
124
125    fn style(&self) -> Ref<'_, TextStyle> {
126        Ref::map(self.source.borrow(), |source| &*source.style)
127    }
128
129    fn options(&self) -> TextLayoutOptions {
130        self.source.borrow().options
131    }
132
133    fn node_id(&self) -> Option<cranpose_core::NodeId> {
134        self.node_id.get()
135    }
136
137    fn set_node_id(&self, node_id: Option<cranpose_core::NodeId>) {
138        if self.node_id.replace(node_id) != node_id {
139            self.cache.borrow_mut().clear();
140        }
141    }
142
143    fn with_prepared<R>(
144        &self,
145        max_width: Option<f32>,
146        read: impl FnOnce(&Rc<crate::text::PreparedTextLayout>) -> R,
147    ) -> R {
148        let normalized_max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
149        let (text_generation, font_scale_fingerprint) = crate::render_state::text_layout_stamp();
150
151        {
152            let mut cache = self.cache.borrow_mut();
153            if let Some(index) = cache.iter().position(|entry| {
154                entry.widths.hold(normalized_max_width)
155                    && entry.text_generation == text_generation
156                    && entry.font_scale_fingerprint == font_scale_fingerprint
157            }) {
158                cache[..=index].rotate_right(1);
159                return read(&cache[0].layout);
160            }
161        }
162
163        let source = self.source.borrow();
164        let prepare = if source.shares_layouts {
165            crate::text::prepare_text_layout_for_node
166        } else {
167            crate::text::measure::prepare_unshared_text_layout_for_node
168        };
169        let prepared = prepare(
170            self.node_id(),
171            &source.text,
172            &source.style,
173            source.options,
174            normalized_max_width,
175        );
176        if Arc::ptr_eq(&prepared.visual_style, &source.style) {
177            let _ = prepared.visual_style_hash.set(source.style_hash);
178        }
179        let widths = crate::text::measure::PreparedWidths::of(
180            source.text.as_ref(),
181            source.options,
182            normalized_max_width,
183            &prepared,
184        );
185        drop(source);
186
187        let mut cache = self.cache.borrow_mut();
188        cache.insert(
189            0,
190            TextPreparedLayoutCacheEntry {
191                widths,
192                text_generation,
193                font_scale_fingerprint,
194                layout: prepared,
195            },
196        );
197        cache.truncate(PREPARED_LAYOUT_CACHE_CAPACITY);
198        read(&cache[0].layout)
199    }
200
201    /// The size of the layout the cache holds for `max_width` and the max
202    /// widths that layout holds for; `None` when the cache holds none.
203    fn cached_widths(
204        &self,
205        max_width: Option<f32>,
206    ) -> Option<(Size, crate::text::measure::PreparedWidths)> {
207        let normalized_max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
208        let (text_generation, font_scale_fingerprint) = crate::render_state::text_layout_stamp();
209        self.cache
210            .borrow()
211            .iter()
212            .find(|entry| {
213                entry.widths.hold(normalized_max_width)
214                    && entry.text_generation == text_generation
215                    && entry.font_scale_fingerprint == font_scale_fingerprint
216            })
217            .map(|entry| {
218                (
219                    Size::new(entry.layout.metrics.width, entry.layout.metrics.height),
220                    entry.widths,
221                )
222            })
223    }
224
225    /// The widest word of the text: see [`crate::text::measure::min_intrinsic_text_width`].
226    fn min_intrinsic_width(&self) -> f32 {
227        let source = self.source.borrow();
228        crate::text::measure::min_intrinsic_text_width(&source.text, &source.style)
229    }
230
231    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
232        self.with_prepared(max_width, |prepared| Size {
233            width: prepared.metrics.width,
234            height: prepared.metrics.height,
235        })
236    }
237
238    fn measure_layout(&self, max_width: Option<f32>) -> (Size, cranpose_ui_layout::AlignmentLines) {
239        self.measured_max_width.set(Some(max_width));
240        self.with_prepared(max_width, |prepared| {
241            (
242                Size::new(prepared.metrics.width, prepared.metrics.height),
243                prepared.alignment_lines,
244            )
245        })
246    }
247
248    fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
249        self.measured_max_width
250            .get()
251            .map(|max_width| self.with_prepared(max_width, Rc::clone))
252    }
253}
254
255impl TextPreparedLayoutHandle {
256    fn new(owner: Rc<TextPreparedLayoutOwner>) -> Self {
257        Self { owner }
258    }
259
260    pub(crate) fn annotated_text(&self) -> Ref<'_, Rc<AnnotatedString>> {
261        self.owner.annotated_text()
262    }
263
264    pub(crate) fn style(&self) -> Ref<'_, TextStyle> {
265        self.owner.style()
266    }
267
268    pub(crate) fn options(&self) -> TextLayoutOptions {
269        self.owner.options()
270    }
271
272    pub(crate) fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
273        self.owner.measured_layout()
274    }
275}
276
277impl TextModifierNode {
278    /// A text node sized on `density`'s device pixel grid.
279    pub fn new(
280        text: Rc<AnnotatedString>,
281        style: impl Into<Arc<TextStyle>>,
282        options: TextLayoutOptions,
283        density: Density,
284    ) -> Self {
285        Self {
286            layout: Rc::new(TextPreparedLayoutOwner::new(text, style.into(), options)),
287            density,
288            state: NodeState::new(),
289        }
290    }
291
292    /// The text's size rounded up to whole device pixels, as Compose sizes a
293    /// text node (`TextLayoutResult.size` is the paragraph's size, `ceil`ed),
294    /// so whatever follows it starts on the pixel grid.
295    fn pixel_size(&self, size: Size) -> Size {
296        Size {
297            width: self.density.ceil(size.width),
298            height: self.density.ceil(size.height),
299        }
300    }
301
302    /// Returns the plain text of the node.
303    pub fn text(&self) -> Ref<'_, str> {
304        Ref::map(self.layout.annotated_text(), |text| text.text.as_str())
305    }
306
307    /// Returns the node's text with its span styles and annotations.
308    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
309        Rc::clone(&self.layout.annotated_text())
310    }
311
312    /// Returns the style the text lays out with.
313    pub fn style(&self) -> Ref<'_, TextStyle> {
314        self.layout.style()
315    }
316
317    /// Returns the layout options: overflow, soft wrap, and minimum and maximum lines.
318    pub fn options(&self) -> TextLayoutOptions {
319        self.layout.options()
320    }
321
322    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
323        self.layout.measure_text_content(max_width)
324    }
325
326    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
327        TextPreparedLayoutHandle::new(self.layout.clone())
328    }
329}
330
331impl DelegatableNode for TextModifierNode {
332    fn node_state(&self) -> &NodeState {
333        &self.state
334    }
335}
336
337impl ModifierNode for TextModifierNode {
338    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
339        self.layout.set_node_id(context.node_id());
340        context.invalidate(InvalidationKind::Layout);
341        context.invalidate(InvalidationKind::Draw);
342        context.invalidate(InvalidationKind::Semantics);
343    }
344
345    fn on_detach(&mut self) {
346        self.layout.set_node_id(None);
347    }
348
349    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
350        Some(self)
351    }
352
353    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
354        Some(self)
355    }
356
357    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
358        Some(self)
359    }
360
361    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
362        Some(self)
363    }
364
365    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
366        Some(self)
367    }
368
369    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
370        Some(self)
371    }
372}
373
374impl LayoutModifierNode for TextModifierNode {
375    /// A text holds while its layout does and the bounds keep its size: it
376    /// measures nothing it wraps.
377    fn measure_hold(
378        &self,
379        _density: f32,
380        constraints: Constraints,
381        size: Size,
382        _wrapped: cranpose_ui_layout::WrappedHold,
383    ) -> Option<cranpose_ui_layout::ConstraintsHold> {
384        let max_width = constraints
385            .max_width
386            .is_finite()
387            .then_some(constraints.max_width);
388        let (text_size, widths) = self.layout.cached_widths(max_width)?;
389        let text_size = self.pixel_size(text_size);
390        if size != text_size {
391            return None;
392        }
393        Some(cranpose_ui_layout::ConstraintsHold {
394            width: cranpose_ui_layout::AxisHold {
395                min: cranpose_ui_layout::BoundRange::up_to(text_size.width),
396                max: cranpose_ui_layout::BoundRange::from(text_size.width)
397                    .intersect(widths.max_width_range())?,
398            },
399            height: cranpose_ui_layout::AxisHold::sized(text_size.height),
400        })
401    }
402
403    fn measure(
404        &self,
405        _context: &mut dyn ModifierNodeContext,
406        _measurable: &dyn Measurable,
407        constraints: Constraints,
408    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
409        let max_width = constraints
410            .max_width
411            .is_finite()
412            .then_some(constraints.max_width);
413        let (text_size, alignment_lines) = self.layout.measure_layout(max_width);
414        let text_size = self.pixel_size(text_size);
415
416        let width = text_size
417            .width
418            .clamp(constraints.min_width, constraints.max_width);
419        let height = text_size
420            .height
421            .clamp(constraints.min_height, constraints.max_height);
422
423        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
424            .with_alignment_lines(alignment_lines)
425    }
426
427    fn min_intrinsic_width(
428        &self,
429        _measurable: &dyn Measurable,
430        _height: f32,
431        _density: f32,
432    ) -> f32 {
433        self.density.ceil(self.layout.min_intrinsic_width())
434    }
435
436    fn max_intrinsic_width(
437        &self,
438        _measurable: &dyn Measurable,
439        _height: f32,
440        _density: f32,
441    ) -> f32 {
442        self.pixel_size(self.measure_text_content(None)).width
443    }
444
445    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
446        self.pixel_size(
447            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
448        )
449        .height
450    }
451
452    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
453        self.pixel_size(
454            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
455        )
456        .height
457    }
458}
459
460impl DrawModifierNode for TextModifierNode {}
461
462impl SemanticsNode for TextModifierNode {
463    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
464        config
465            .content_description
466            .get_or_insert_with(|| self.text().to_string());
467    }
468
469    fn reach(&self) -> cranpose_foundation::SemanticsReach {
470        cranpose_foundation::SemanticsReach::default()
471    }
472}
473
474/// Element that creates and updates TextModifierNode instances.
475///
476/// This follows the modifier element pattern where the element is responsible for:
477/// - Creating new nodes (via `create`)
478/// - Updating existing nodes when properties change (via `update`)
479/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
480///
481/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
482#[derive(Debug, Clone)]
483pub struct TextModifierElement {
484    text: Rc<AnnotatedString>,
485    /// One copy for every text composed with an equal style: see
486    /// [`shared_text_style`].
487    style: Arc<TextStyle>,
488    options: TextLayoutOptions,
489    density: Density,
490}
491
492impl TextModifierElement {
493    /// A text laid out on `density`'s device pixel grid, the composition's
494    /// [`crate::density::density`] where a `Text` is composed.
495    pub fn new(
496        text: Rc<AnnotatedString>,
497        style: TextStyle,
498        options: TextLayoutOptions,
499        density: Density,
500    ) -> Self {
501        Self::with_shared_style(text, shared_text_style(style), options, density)
502    }
503
504    /// [`Self::new`] for a style [`shared_text_style`] already shared.
505    pub(crate) fn with_shared_style(
506        text: Rc<AnnotatedString>,
507        style: Arc<TextStyle>,
508        options: TextLayoutOptions,
509        density: Density,
510    ) -> Self {
511        Self {
512            text,
513            style,
514            options: options.normalized(),
515            density,
516        }
517    }
518}
519
520impl PartialEq for TextModifierElement {
521    fn eq(&self, other: &Self) -> bool {
522        self.text == other.text
523            && (Arc::ptr_eq(&self.style, &other.style) || self.style == other.style)
524            && self.options == other.options
525            && self.density == other.density
526    }
527}
528
529/// How many recent text styles [`shared_text_style`] keeps.
530const SHARED_TEXT_STYLES: usize = 16;
531
532/// `style` shared with the texts recently composed with an equal one, so a
533/// screen of texts in a few styles keeps a few copies, not one a text, and
534/// a node updated to an element of the same style compares pointers. A
535/// style none of the kept [`SHARED_TEXT_STYLES`] equals gets a copy of its
536/// own, kept first; the last kept one goes.
537pub(crate) fn shared_text_style(style: TextStyle) -> Arc<TextStyle> {
538    crate::thread_state::with(|state| {
539        let mut recent = state.recent_text_styles.borrow_mut();
540        match recent.iter().position(|kept| **kept == style) {
541            Some(0) => Arc::clone(&recent[0]),
542            // One step toward the front: styles in use stay among the first
543            // compared, without moving the whole list on every hit.
544            Some(index) => {
545                recent.swap(index, index - 1);
546                Arc::clone(&recent[index - 1])
547            }
548            None => {
549                let shared = Arc::new(style);
550                recent.truncate(SHARED_TEXT_STYLES - 1);
551                recent.insert(0, Arc::clone(&shared));
552                shared
553            }
554        }
555    })
556}
557
558impl Hash for TextModifierElement {
559    fn hash<H: Hasher>(&self, state: &mut H) {
560        self.text.render_hash().hash(state);
561        self.options.hash(state);
562        self.density.density().to_bits().hash(state);
563    }
564}
565
566impl ModifierNodeElement for TextModifierElement {
567    type Node = TextModifierNode;
568
569    fn create(&self) -> Self::Node {
570        TextModifierNode::new(
571            self.text.clone(),
572            Arc::clone(&self.style),
573            self.options,
574            self.density,
575        )
576    }
577
578    fn update(&self, node: &mut Self::Node) {
579        node.density = self.density;
580        node.layout.update(&self.text, &self.style, self.options);
581    }
582
583    fn capabilities(&self) -> NodeCapabilities {
584        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
585    }
586}
587
588#[cfg(test)]
589#[path = "tests/text_modifier_node_tests.rs"]
590mod tests;