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