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};
12
13use crate::{
14    density::Density,
15    text::{AnnotatedString, TextLayoutOptions, TextStyle},
16};
17
18/// Node that stores text content and handles measurement, drawing, and semantics.
19///
20/// This node implements three capabilities:
21/// - **Layout**: Measures text and returns appropriate size
22/// - **Draw**: Supplies prepared text state consumed by scene building
23/// - **Semantics**: Provides text content for accessibility
24///
25/// Matches Jetpack Compose: `TextStringSimpleNode` in
26/// `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt`
27#[derive(Debug)]
28pub struct TextModifierNode {
29    layout: Rc<TextPreparedLayoutOwner>,
30    density: Density,
31    state: NodeState,
32}
33
34const PREPARED_LAYOUT_CACHE_CAPACITY: usize = 4;
35
36#[derive(Clone, Debug)]
37struct TextPreparedLayoutCacheEntry {
38    widths: crate::text::measure::PreparedWidths,
39    text_generation: u64,
40    font_scale_fingerprint: u32,
41    layout: Rc<crate::text::PreparedTextLayout>,
42}
43
44#[derive(Debug)]
45struct TextPreparedLayoutOwner {
46    text: Rc<AnnotatedString>,
47    style: TextStyle,
48    options: TextLayoutOptions,
49    node_id: Cell<Option<cranpose_core::NodeId>>,
50    measured_max_width: Cell<Option<Option<f32>>>,
51    cache: RefCell<Vec<TextPreparedLayoutCacheEntry>>,
52}
53
54#[derive(Clone, Debug)]
55pub(crate) struct TextPreparedLayoutHandle {
56    owner: Rc<TextPreparedLayoutOwner>,
57}
58
59impl TextPreparedLayoutOwner {
60    fn new(
61        text: Rc<AnnotatedString>,
62        style: TextStyle,
63        options: TextLayoutOptions,
64        node_id: Option<cranpose_core::NodeId>,
65        measured_max_width: Option<Option<f32>>,
66    ) -> Self {
67        Self {
68            text,
69            style,
70            options: options.normalized(),
71            node_id: Cell::new(node_id),
72            measured_max_width: Cell::new(measured_max_width),
73            cache: RefCell::new(Vec::new()),
74        }
75    }
76
77    fn text(&self) -> &str {
78        self.text.text.as_str()
79    }
80
81    fn annotated_text(&self) -> Rc<AnnotatedString> {
82        self.text.clone()
83    }
84
85    fn annotated_string(&self) -> AnnotatedString {
86        (*self.text).clone()
87    }
88
89    fn style(&self) -> &TextStyle {
90        &self.style
91    }
92
93    fn options(&self) -> TextLayoutOptions {
94        self.options
95    }
96
97    fn node_id(&self) -> Option<cranpose_core::NodeId> {
98        self.node_id.get()
99    }
100
101    fn set_node_id(&self, node_id: Option<cranpose_core::NodeId>) {
102        if self.node_id.replace(node_id) != node_id {
103            self.cache.borrow_mut().clear();
104        }
105    }
106
107    /// The layout at `max_width`, shared with the cache: measuring reads only
108    /// its size, and copying the whole layout on every measure cost a grid of
109    /// wrapping labels more than wrapping them.
110    fn prepare(&self, max_width: Option<f32>) -> Rc<crate::text::PreparedTextLayout> {
111        let normalized_max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
112        let text_generation = crate::text::measure::current_text_generation();
113        let font_scale_fingerprint = crate::current_font_scale_curve().fingerprint();
114
115        {
116            let mut cache = self.cache.borrow_mut();
117            if let Some(index) = cache.iter().position(|entry| {
118                entry.widths.hold(normalized_max_width)
119                    && entry.text_generation == text_generation
120                    && entry.font_scale_fingerprint == font_scale_fingerprint
121            }) {
122                let prepared = Rc::clone(&cache[index].layout);
123                cache[..=index].rotate_right(1);
124                return prepared;
125            }
126        }
127
128        let prepared = crate::text::prepare_text_layout_for_node(
129            self.node_id(),
130            self.text.as_ref(),
131            &self.style,
132            self.options,
133            normalized_max_width,
134        );
135
136        let mut cache = self.cache.borrow_mut();
137        cache.insert(
138            0,
139            TextPreparedLayoutCacheEntry {
140                widths: crate::text::measure::PreparedWidths::of(
141                    self.text.as_ref(),
142                    self.options,
143                    normalized_max_width,
144                    &prepared,
145                ),
146                text_generation,
147                font_scale_fingerprint,
148                layout: Rc::clone(&prepared),
149            },
150        );
151        cache.truncate(PREPARED_LAYOUT_CACHE_CAPACITY);
152        prepared
153    }
154
155    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
156        let prepared = self.prepare(max_width);
157        Size {
158            width: prepared.metrics.width,
159            height: prepared.metrics.height,
160        }
161    }
162
163    fn measure_layout(&self, max_width: Option<f32>) -> Size {
164        self.measured_max_width.set(Some(max_width));
165        self.measure_text_content(max_width)
166    }
167
168    fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
169        self.measured_max_width
170            .get()
171            .map(|max_width| self.prepare(max_width))
172    }
173}
174
175impl TextPreparedLayoutHandle {
176    fn new(owner: Rc<TextPreparedLayoutOwner>) -> Self {
177        Self { owner }
178    }
179
180    pub(crate) fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
181        self.owner.measured_layout()
182    }
183}
184
185impl TextModifierNode {
186    /// A text node sized on `density`'s device pixel grid.
187    pub fn new(
188        text: Rc<AnnotatedString>,
189        style: TextStyle,
190        options: TextLayoutOptions,
191        density: Density,
192    ) -> Self {
193        Self {
194            layout: Rc::new(TextPreparedLayoutOwner::new(
195                text, style, options, None, None,
196            )),
197            density,
198            state: NodeState::new(),
199        }
200    }
201
202    /// The text's size rounded up to whole device pixels, as Compose sizes a
203    /// text node (`TextLayoutResult.size` is the paragraph's size, `ceil`ed),
204    /// so whatever follows it starts on the pixel grid.
205    fn pixel_size(&self, size: Size) -> Size {
206        Size {
207            width: self.density.ceil(size.width),
208            height: self.density.ceil(size.height),
209        }
210    }
211
212    pub fn text(&self) -> &str {
213        self.layout.text()
214    }
215
216    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
217        self.layout.annotated_text()
218    }
219
220    pub fn annotated_string(&self) -> AnnotatedString {
221        self.layout.annotated_string()
222    }
223
224    pub fn style(&self) -> &TextStyle {
225        self.layout.style()
226    }
227
228    pub fn options(&self) -> TextLayoutOptions {
229        self.layout.options()
230    }
231
232    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
233        self.layout.measure_text_content(max_width)
234    }
235
236    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
237        TextPreparedLayoutHandle::new(self.layout.clone())
238    }
239}
240
241impl DelegatableNode for TextModifierNode {
242    fn node_state(&self) -> &NodeState {
243        &self.state
244    }
245}
246
247impl ModifierNode for TextModifierNode {
248    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
249        self.layout.set_node_id(context.node_id());
250        context.invalidate(InvalidationKind::Layout);
251        context.invalidate(InvalidationKind::Draw);
252        context.invalidate(InvalidationKind::Semantics);
253    }
254
255    fn on_detach(&mut self) {
256        self.layout.set_node_id(None);
257    }
258
259    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
260        Some(self)
261    }
262
263    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
264        Some(self)
265    }
266
267    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
268        Some(self)
269    }
270
271    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
272        Some(self)
273    }
274
275    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
276        Some(self)
277    }
278
279    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
280        Some(self)
281    }
282}
283
284impl LayoutModifierNode for TextModifierNode {
285    fn measure(
286        &self,
287        _context: &mut dyn ModifierNodeContext,
288        _measurable: &dyn Measurable,
289        constraints: Constraints,
290    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
291        let max_width = constraints
292            .max_width
293            .is_finite()
294            .then_some(constraints.max_width);
295        let text_size = self.pixel_size(self.layout.measure_layout(max_width));
296
297        let width = text_size
298            .width
299            .clamp(constraints.min_width, constraints.max_width);
300        let height = text_size
301            .height
302            .clamp(constraints.min_height, constraints.max_height);
303
304        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
305    }
306
307    fn min_intrinsic_width(
308        &self,
309        _measurable: &dyn Measurable,
310        _height: f32,
311        _density: f32,
312    ) -> f32 {
313        self.pixel_size(self.measure_text_content(None)).width
314    }
315
316    fn max_intrinsic_width(
317        &self,
318        _measurable: &dyn Measurable,
319        _height: f32,
320        _density: f32,
321    ) -> f32 {
322        self.pixel_size(self.measure_text_content(None)).width
323    }
324
325    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
326        self.pixel_size(
327            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
328        )
329        .height
330    }
331
332    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
333        self.pixel_size(
334            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
335        )
336        .height
337    }
338}
339
340impl DrawModifierNode for TextModifierNode {}
341
342impl SemanticsNode for TextModifierNode {
343    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
344        config.content_description = Some(self.text().to_string());
345    }
346
347    fn reach(&self) -> cranpose_foundation::SemanticsReach {
348        cranpose_foundation::SemanticsReach::default()
349    }
350}
351
352/// Element that creates and updates TextModifierNode instances.
353///
354/// This follows the modifier element pattern where the element is responsible for:
355/// - Creating new nodes (via `create`)
356/// - Updating existing nodes when properties change (via `update`)
357/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
358///
359/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
360#[derive(Debug, Clone, PartialEq)]
361pub struct TextModifierElement {
362    text: Rc<AnnotatedString>,
363    style: TextStyle,
364    options: TextLayoutOptions,
365    density: Density,
366}
367
368impl TextModifierElement {
369    /// A text laid out on `density`'s device pixel grid, the composition's
370    /// [`crate::density::density`] where a `Text` is composed.
371    pub fn new(
372        text: Rc<AnnotatedString>,
373        style: TextStyle,
374        options: TextLayoutOptions,
375        density: Density,
376    ) -> Self {
377        Self {
378            text,
379            style,
380            options: options.normalized(),
381            density,
382        }
383    }
384}
385
386impl Hash for TextModifierElement {
387    fn hash<H: Hasher>(&self, state: &mut H) {
388        self.text.render_hash().hash(state);
389        self.style.render_hash().hash(state);
390        self.options.hash(state);
391        self.density.density().to_bits().hash(state);
392    }
393}
394
395impl ModifierNodeElement for TextModifierElement {
396    type Node = TextModifierNode;
397
398    fn create(&self) -> Self::Node {
399        TextModifierNode::new(
400            self.text.clone(),
401            self.style.clone(),
402            self.options,
403            self.density,
404        )
405    }
406
407    fn update(&self, node: &mut Self::Node) {
408        node.density = self.density;
409        let current = node.layout.as_ref();
410        if current.text != self.text
411            || current.style != self.style
412            || current.options != self.options
413        {
414            node.layout = Rc::new(TextPreparedLayoutOwner::new(
415                self.text.clone(),
416                self.style.clone(),
417                self.options,
418                current.node_id(),
419                current.measured_max_width.get(),
420            ));
421        }
422    }
423
424    fn capabilities(&self) -> NodeCapabilities {
425        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
426    }
427}
428
429#[cfg(test)]
430#[path = "tests/text_modifier_node_tests.rs"]
431mod tests;