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_core::PackedU64;
9use cranpose_foundation::{
10    Constraints, DelegatableNode, DrawModifierNode, InvalidationKind, LayoutModifierNode,
11    Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement, NodeCapabilities,
12    NodeState, SemanticsConfiguration, SemanticsNode, Size,
13};
14use smallvec::SmallVec;
15
16use crate::{
17    density::Density,
18    text::{
19        AnnotatedString, TextLayoutOptions, TextStyle, layout_cache::CacheAdmission,
20        recent_values::RecentValues,
21    },
22};
23
24/// Node that stores text content and handles measurement, drawing, and semantics.
25///
26/// This node implements three capabilities:
27/// - **Layout**: Measures text and returns appropriate size
28/// - **Draw**: Supplies prepared text state consumed by scene building
29/// - **Semantics**: Provides text content for accessibility
30///
31/// Matches Jetpack Compose: `TextStringSimpleNode` in
32/// `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt`
33#[derive(Debug)]
34pub struct TextModifierNode {
35    layout: Rc<TextPreparedLayoutOwner>,
36    density: Density,
37    state: NodeState,
38}
39
40const PREPARED_LAYOUT_CACHE_CAPACITY: usize = 4;
41
42#[derive(Clone, Debug)]
43struct TextPreparedLayoutCacheEntry {
44    min_width: f32,
45    // The max widths at which the text laid out from `min_width` gives
46    // `layout` again.
47    max_widths: cranpose_ui_layout::BoundRange,
48    text_generation: PackedU64,
49    font_scale_fingerprint: u32,
50    layout: Rc<crate::text::PreparedTextLayout>,
51}
52
53impl TextPreparedLayoutCacheEntry {
54    fn new(
55        min_width: f32,
56        (text_generation, font_scale_fingerprint): (u64, u32),
57        layout: Rc<crate::text::PreparedTextLayout>,
58    ) -> Self {
59        Self {
60            min_width,
61            max_widths: layout.max_widths_kept(min_width),
62            text_generation: PackedU64::new(text_generation),
63            font_scale_fingerprint,
64            layout,
65        }
66    }
67
68    // Whether the layout stands for the text laid out between `min_width`
69    // and `max_width` as it is: its lines and size the same.
70    fn keeps(&self, (min_width, max_width): (f32, f32), stamp: (u64, u32)) -> bool {
71        self.min_width.to_bits() == min_width.to_bits()
72            && (self.text_generation.get(), self.font_scale_fingerprint) == stamp
73            && self.max_widths.contains(max_width)
74    }
75}
76
77/// A text node's text and its prepared layouts. The node and the slices
78/// built from it share one owner for the node's life: an update changes its
79/// source in place, so the slices read the new text without being rebuilt.
80#[derive(Debug)]
81struct TextPreparedLayoutOwner {
82    source: RefCell<TextLayoutSource>,
83    measured_widths: Cell<Option<(f32, f32)>>,
84    cache: RefCell<SmallVec<[TextPreparedLayoutCacheEntry; 1]>>,
85    // Whether the text changed in place since a hold was last asked of it:
86    // a text that changes as a ticker's does measures again each frame,
87    // which no hold spares, so it gives none.
88    changed: Cell<bool>,
89}
90
91/// What a text is laid out from.
92#[derive(Debug)]
93struct TextLayoutSource {
94    text: Rc<AnnotatedString>,
95    style: Arc<TextStyle>,
96    style_hash: PackedU64,
97    options: TextLayoutOptions,
98    // How the text service's shared cache keeps the layouts: those of a
99    // text changed in place, as a ticker's is every frame, once it comes
100    // back.
101    admission: CacheAdmission,
102}
103
104impl TextLayoutSource {
105    // The frame the text is laid out in between `widths`.
106    fn frame(&self, (min_width, max_width): (f32, f32)) -> crate::text::measure::TextFrame {
107        crate::text::measure::TextFrame {
108            min_width,
109            max_width,
110            options: self.options,
111        }
112    }
113}
114
115#[derive(Clone, Debug)]
116pub(crate) struct TextPreparedLayoutHandle {
117    owner: Rc<TextPreparedLayoutOwner>,
118}
119
120impl TextPreparedLayoutOwner {
121    fn new(text: Rc<AnnotatedString>, style: Arc<TextStyle>, options: TextLayoutOptions) -> Self {
122        let style_hash = PackedU64::new(style.render_hash());
123        Self {
124            source: RefCell::new(TextLayoutSource {
125                text,
126                style,
127                style_hash,
128                options: options.normalized(),
129                admission: CacheAdmission::First,
130            }),
131            measured_widths: Cell::new(None),
132            changed: Cell::new(false),
133            cache: RefCell::new(SmallVec::new()),
134        }
135    }
136
137    /// Lays the text out from `text`, `style` and normalized `options` from
138    /// now on, dropping the layouts prepared from the old ones. The source
139    /// takes the element's style even when it equals the current one, so
140    /// the element, the source and the layouts prepared next share one
141    /// copy; the old one leaves with the layouts that hold it.
142    fn update(
143        &self,
144        text: &Rc<AnnotatedString>,
145        style: &Arc<TextStyle>,
146        options: TextLayoutOptions,
147    ) {
148        let mut source = self.source.borrow_mut();
149        let shared_style = Arc::ptr_eq(&source.style, style);
150        let same_style = shared_style || *source.style == **style;
151        if source.text == *text && same_style && source.options == options {
152            return;
153        }
154        if !same_style {
155            source.style_hash = PackedU64::new(style.render_hash());
156        }
157        if !shared_style {
158            source.style = Arc::clone(style);
159        }
160        source.text = Rc::clone(text);
161        source.options = options;
162        source.admission = CacheAdmission::Changed;
163        drop(source);
164        self.cache.borrow_mut().clear();
165        self.changed.set(true);
166    }
167
168    fn annotated_text(&self) -> Ref<'_, Rc<AnnotatedString>> {
169        Ref::map(self.source.borrow(), |source| &source.text)
170    }
171
172    fn style(&self) -> Ref<'_, TextStyle> {
173        Ref::map(self.source.borrow(), |source| &*source.style)
174    }
175
176    fn options(&self) -> TextLayoutOptions {
177        self.source.borrow().options
178    }
179
180    // The layout at `widths`, which the cache holds first: the latest one
181    // when it keeps the widths, else a cached one that takes them, else a
182    // new one. The reader holds the cache borrowed, and no callback runs
183    // while it is.
184    fn prepared(&self, widths: (f32, f32)) -> Ref<'_, Rc<crate::text::PreparedTextLayout>> {
185        let stamp = crate::render_state::text_layout_stamp();
186        {
187            let cache = self.cache.borrow();
188            if cache
189                .first()
190                .is_some_and(|latest| latest.keeps(widths, stamp))
191            {
192                return Ref::map(cache, |cache| &cache[0].layout);
193            }
194        }
195        if !self.bring_kept(widths, stamp) {
196            self.prepare(widths, stamp);
197        }
198        Ref::map(self.cache.borrow(), |cache| &cache[0].layout)
199    }
200
201    // Puts first a cached layout that takes `widths`: the latest one, which
202    // nothing else holds, realigned in place where it breaks into the same
203    // lines there, as a width that animates asks for each frame, or one the
204    // cache keeps. False when none does.
205    fn bring_kept(&self, widths: (f32, f32), stamp: (u64, u32)) -> bool {
206        let mut cache = self.cache.borrow_mut();
207        let Some(latest) = cache.first_mut() else {
208            return false;
209        };
210        // Its style, equal to the source's, gives the alignment.
211        if (latest.text_generation.get(), latest.font_scale_fingerprint) == stamp
212            && let Some(prepared) = Rc::get_mut(&mut latest.layout)
213            && prepared.try_realign(widths, prepared.visual_style.paragraph_style.text_align)
214        {
215            latest.min_width = widths.0;
216            latest.max_widths = prepared.max_widths_kept(widths.0);
217            return true;
218        }
219        let Some(index) = cache.iter().position(|entry| entry.keeps(widths, stamp)) else {
220            return false;
221        };
222        cache[..=index].rotate_right(1);
223        true
224    }
225
226    // Prepares a layout at `widths` that no cached one keeps, which the
227    // cache then holds first. Out of line: most reads take a cached layout.
228    #[inline(never)]
229    fn prepare(&self, widths: (f32, f32), stamp: (u64, u32)) {
230        let source = self.source.borrow();
231        let frame = source.frame(widths);
232        let align = source.style.paragraph_style.text_align;
233        // A layout at new widths takes the allocation of the one the full
234        // cache lets go, as a width that animates asks for one every frame.
235        let spare = {
236            let mut cache = self.cache.borrow_mut();
237            (cache.len() >= PREPARED_LAYOUT_CACHE_CAPACITY)
238                .then(|| cache.pop())
239                .flatten()
240                .map(|entry| entry.layout)
241        };
242        let prepared = {
243            // The cache holds layouts of this source only: the latest one, on
244            // the fonts of now, takes the widths when it breaks into the same
245            // lines there, as a width animates, or gives its glyphs to the new
246            // one.
247            let cache = self.cache.borrow();
248            let latest = cache
249                .first()
250                .filter(|entry| {
251                    (entry.text_generation.get(), entry.font_scale_fingerprint) == stamp
252                })
253                .map(|entry| &entry.layout);
254            match latest.filter(|latest| latest.layout.holds(widths.0, widths.1)) {
255                Some(latest) => latest.at_widths(widths, align, spare),
256                None => {
257                    let text = crate::text::measure::SharedText {
258                        text: &source.text,
259                        style: &source.style,
260                        style_hash: source.style_hash.get(),
261                    };
262                    let shaped = latest.map(|latest| &latest.layout);
263                    crate::text::measure::prepare_shared(&text, frame, source.admission, shaped)
264                }
265            }
266        };
267        if Arc::ptr_eq(&prepared.visual_style, &source.style) {
268            let _ = prepared.visual_style_hash.set(source.style_hash);
269        }
270        drop(source);
271
272        let mut cache = self.cache.borrow_mut();
273        cache.insert(
274            0,
275            TextPreparedLayoutCacheEntry::new(widths.0, stamp, prepared),
276        );
277        cache.truncate(PREPARED_LAYOUT_CACHE_CAPACITY);
278    }
279
280    /// The size of the layout the cache holds for `widths` and the max widths
281    /// that keep it; `None` when the cache holds none.
282    fn cached_widths(&self, widths: (f32, f32)) -> Option<(Size, cranpose_ui_layout::BoundRange)> {
283        let stamp = crate::render_state::text_layout_stamp();
284        let cache = self.cache.borrow();
285        let entry = cache.iter().find(|entry| entry.keeps(widths, stamp))?;
286        Some((entry.layout.size(), entry.max_widths))
287    }
288
289    /// The widest word of the text: see [`crate::text::measure::min_intrinsic_text_width`].
290    fn min_intrinsic_width(&self) -> f32 {
291        let source = self.source.borrow();
292        crate::text::measure::min_intrinsic_text_width(&source.text, &source.style)
293    }
294
295    fn measure_text_content(&self, widths: (f32, f32)) -> Size {
296        self.prepared(widths).size()
297    }
298
299    fn measure_layout(&self, widths: (f32, f32)) -> (Size, cranpose_ui_layout::AlignmentLines) {
300        self.measured_widths.set(Some(widths));
301        let prepared = self.prepared(widths);
302        (prepared.size(), prepared.alignment_lines())
303    }
304
305    fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
306        self.measured_widths
307            .get()
308            .map(|widths| Rc::clone(&self.prepared(widths)))
309    }
310}
311
312impl TextPreparedLayoutHandle {
313    fn new(owner: Rc<TextPreparedLayoutOwner>) -> Self {
314        Self { owner }
315    }
316
317    pub(crate) fn annotated_text(&self) -> Ref<'_, Rc<AnnotatedString>> {
318        self.owner.annotated_text()
319    }
320
321    pub(crate) fn style(&self) -> Ref<'_, TextStyle> {
322        self.owner.style()
323    }
324
325    pub(crate) fn options(&self) -> TextLayoutOptions {
326        self.owner.options()
327    }
328
329    pub(crate) fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
330        self.owner.measured_layout()
331    }
332}
333
334impl TextModifierNode {
335    /// A text node sized on `density`'s device pixel grid.
336    pub fn new(
337        text: Rc<AnnotatedString>,
338        style: impl Into<Arc<TextStyle>>,
339        options: TextLayoutOptions,
340        density: Density,
341    ) -> Self {
342        Self {
343            layout: Rc::new(TextPreparedLayoutOwner::new(text, style.into(), options)),
344            density,
345            state: NodeState::new(),
346        }
347    }
348
349    /// The text's size rounded up to whole device pixels, as Compose sizes a
350    /// text node (`TextLayoutResult.size` is the paragraph's size, `ceil`ed),
351    /// so whatever follows it starts on the pixel grid.
352    fn pixel_size(&self, size: Size) -> Size {
353        Size {
354            width: self.density.ceil(size.width),
355            height: self.density.ceil(size.height),
356        }
357    }
358
359    /// Returns the plain text of the node.
360    pub fn text(&self) -> Ref<'_, str> {
361        Ref::map(self.layout.annotated_text(), |text| text.text.as_str())
362    }
363
364    /// Returns the node's text with its span styles and annotations.
365    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
366        Rc::clone(&self.layout.annotated_text())
367    }
368
369    /// Returns the style the text lays out with.
370    pub fn style(&self) -> Ref<'_, TextStyle> {
371        self.layout.style()
372    }
373
374    /// Returns the layout options: overflow, soft wrap, and minimum and maximum lines.
375    pub fn options(&self) -> TextLayoutOptions {
376        self.layout.options()
377    }
378
379    fn measure_text_content(&self, widths: (f32, f32)) -> Size {
380        self.layout.measure_text_content(widths)
381    }
382
383    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
384        TextPreparedLayoutHandle::new(Rc::clone(&self.layout))
385    }
386}
387
388impl DelegatableNode for TextModifierNode {
389    fn node_state(&self) -> &NodeState {
390        &self.state
391    }
392}
393
394impl ModifierNode for TextModifierNode {
395    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
396        context.invalidate(InvalidationKind::Layout);
397        context.invalidate(InvalidationKind::Draw);
398        context.invalidate(InvalidationKind::Semantics);
399    }
400
401    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
402        Some(self)
403    }
404
405    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
406        Some(self)
407    }
408
409    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
410        Some(self)
411    }
412
413    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
414        Some(self)
415    }
416
417    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
418        Some(self)
419    }
420
421    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
422        Some(self)
423    }
424}
425
426impl LayoutModifierNode for TextModifierNode {
427    /// A text holds while its layout does and the bounds keep its size: it
428    /// measures nothing it wraps.
429    fn measure_hold(
430        &self,
431        _density: f32,
432        constraints: Constraints,
433        size: Size,
434        _wrapped: cranpose_ui_layout::WrappedHold,
435    ) -> Option<cranpose_ui_layout::ConstraintsHold> {
436        // A text changed in place since its last hold, as a ticker's is,
437        // measures again next frame whatever the hold.
438        if self.layout.changed.take() {
439            return None;
440        }
441        let (text_size, max_widths) = self.layout.cached_widths(text_widths(constraints))?;
442        let text_size = self.pixel_size(text_size);
443        if size != text_size {
444            return None;
445        }
446        Some(cranpose_ui_layout::ConstraintsHold {
447            width: cranpose_ui_layout::AxisHold {
448                min: cranpose_ui_layout::BoundRange::exactly(constraints.min_width),
449                max: cranpose_ui_layout::BoundRange::from(text_size.width).intersect(max_widths)?,
450            },
451            height: cranpose_ui_layout::AxisHold::sized(text_size.height),
452        })
453    }
454
455    fn measure(
456        &self,
457        _context: &mut dyn ModifierNodeContext,
458        _measurable: &dyn Measurable,
459        constraints: Constraints,
460    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
461        let (text_size, alignment_lines) = self.layout.measure_layout(text_widths(constraints));
462        let text_size = self.pixel_size(text_size);
463
464        let width = text_size
465            .width
466            .clamp(constraints.min_width, constraints.max_width);
467        let height = text_size
468            .height
469            .clamp(constraints.min_height, constraints.max_height);
470
471        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
472            .with_alignment_lines(alignment_lines)
473    }
474
475    fn min_intrinsic_width(
476        &self,
477        _measurable: &dyn Measurable,
478        _height: f32,
479        _density: f32,
480    ) -> f32 {
481        self.density.ceil(self.layout.min_intrinsic_width())
482    }
483
484    fn max_intrinsic_width(
485        &self,
486        _measurable: &dyn Measurable,
487        _height: f32,
488        _density: f32,
489    ) -> f32 {
490        self.pixel_size(self.measure_text_content((0.0, f32::INFINITY)))
491            .width
492    }
493
494    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
495        self.pixel_size(self.measure_text_content((0.0, intrinsic_width(width))))
496            .height
497    }
498
499    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
500        self.pixel_size(self.measure_text_content((0.0, intrinsic_width(width))))
501            .height
502    }
503}
504
505// The widths a text lays out between under `constraints`: Compose's
506// `finalConstraints` read the min and the max width.
507fn text_widths(constraints: Constraints) -> (f32, f32) {
508    (constraints.min_width.max(0.0), constraints.max_width)
509}
510
511// The max width an intrinsic height is asked at; a width that is not positive
512// and finite is no limit.
513fn intrinsic_width(width: f32) -> f32 {
514    if width.is_finite() && width > 0.0 {
515        width
516    } else {
517        f32::INFINITY
518    }
519}
520
521impl DrawModifierNode for TextModifierNode {}
522
523impl SemanticsNode for TextModifierNode {
524    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
525        config
526            .content_description
527            .get_or_insert_with(|| self.text().to_string());
528    }
529
530    fn reach(&self) -> cranpose_foundation::SemanticsReach {
531        cranpose_foundation::SemanticsReach::default()
532    }
533}
534
535/// Element that creates and updates TextModifierNode instances.
536///
537/// This follows the modifier element pattern where the element is responsible for:
538/// - Creating new nodes (via `create`)
539/// - Updating existing nodes when properties change (via `update`)
540/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
541///
542/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
543#[derive(Debug, Clone)]
544pub struct TextModifierElement {
545    text: Rc<AnnotatedString>,
546    /// One copy for every text composed with an equal style: see
547    /// [`shared_text_style`].
548    style: Arc<TextStyle>,
549    options: TextLayoutOptions,
550    density: Density,
551}
552
553impl TextModifierElement {
554    /// A text laid out on `density`'s device pixel grid, the composition's
555    /// [`crate::density::density`] where a `Text` is composed.
556    pub fn new(
557        text: Rc<AnnotatedString>,
558        style: TextStyle,
559        options: TextLayoutOptions,
560        density: Density,
561    ) -> Self {
562        Self::with_shared_style(text, shared_text_style(style), options, density)
563    }
564
565    /// [`Self::new`] for a style [`shared_text_style`] already shared.
566    pub(crate) fn with_shared_style(
567        text: Rc<AnnotatedString>,
568        style: Arc<TextStyle>,
569        options: TextLayoutOptions,
570        density: Density,
571    ) -> Self {
572        Self {
573            text,
574            style,
575            options: options.normalized(),
576            density,
577        }
578    }
579}
580
581impl PartialEq for TextModifierElement {
582    fn eq(&self, other: &Self) -> bool {
583        self.text == other.text
584            && (Arc::ptr_eq(&self.style, &other.style) || self.style == other.style)
585            && self.options == other.options
586            && self.density == other.density
587    }
588}
589
590/// How many recent text styles [`shared_text_style`] keeps.
591const SHARED_TEXT_STYLES: usize = 16;
592
593// The text styles recently composed, which [`shared_text_style`] shares.
594pub(crate) type RecentTextStyles = RecentValues<Arc<TextStyle>, SHARED_TEXT_STYLES>;
595
596/// `style` shared with the texts recently composed with an equal one, so a
597/// screen of texts in a few styles keeps a few copies, not one a text, and
598/// a node updated to an element of the same style compares pointers. A
599/// style none of the kept [`SHARED_TEXT_STYLES`] equals gets a copy of its
600/// own, kept first; the last kept one goes.
601pub(crate) fn shared_text_style(style: TextStyle) -> Arc<TextStyle> {
602    crate::thread_state::with(|state| {
603        let mut recent = state.recent_text_styles.borrow_mut();
604        if let Some(kept) = recent.find(|kept| **kept == style) {
605            return Arc::clone(kept);
606        }
607        Arc::clone(recent.keep(Arc::new(style)))
608    })
609}
610
611impl Hash for TextModifierElement {
612    fn hash<H: Hasher>(&self, state: &mut H) {
613        self.text.render_hash().hash(state);
614        self.options.hash(state);
615        self.density.density().to_bits().hash(state);
616    }
617}
618
619impl ModifierNodeElement for TextModifierElement {
620    type Node = TextModifierNode;
621
622    fn create(&self) -> Self::Node {
623        TextModifierNode::new(
624            Rc::clone(&self.text),
625            Arc::clone(&self.style),
626            self.options,
627            self.density,
628        )
629    }
630
631    fn update(&self, node: &mut Self::Node) {
632        node.density = self.density;
633        node.layout.update(&self.text, &self.style, self.options);
634    }
635
636    fn capabilities(&self) -> NodeCapabilities {
637        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
638    }
639}
640
641#[cfg(test)]
642#[path = "tests/text_modifier_node_tests.rs"]
643mod tests;