Skip to main content

cranpose_ui/
text_modifier_node.rs

1//! Text modifier node implementation following Jetpack Compose's TextStringSimpleNode architecture.
2//!
3//! This module implements text content as a modifier node rather than as a measure policy,
4//! matching the Jetpack Compose pattern where text is treated as visual content (like background)
5//! rather than as a layout strategy.
6//!
7//! # Architecture
8//!
9//! In Jetpack Compose, `BasicText` uses:
10//! ```kotlin
11//! Layout(modifier.then(TextStringSimpleElement(...)), EmptyMeasurePolicy)
12//! ```
13//!
14//! Where `TextStringSimpleNode` implements:
15//! - `LayoutModifierNode` - handles text measurement
16//! - `DrawModifierNode` - handles text drawing
17//! - `SemanticsModifierNode` - provides text content for accessibility
18//!
19//! This follows the principle that `MeasurePolicy` is for child layout, while modifier nodes
20//! handle content rendering and measurement.
21
22use crate::text::{AnnotatedString, TextLayoutOptions, TextStyle};
23use cranpose_foundation::{
24    Constraints, DelegatableNode, DrawModifierNode, DrawScope, InvalidationKind,
25    LayoutModifierNode, Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement,
26    NodeCapabilities, NodeState, SemanticsConfiguration, SemanticsNode, Size,
27};
28use std::cell::{Cell, RefCell};
29use std::hash::{Hash, Hasher};
30use std::rc::Rc;
31
32/// Node that stores text content and handles measurement, drawing, and semantics.
33///
34/// This node implements three capabilities:
35/// - **Layout**: Measures text and returns appropriate size
36/// - **Draw**: Supplies prepared text state consumed by scene building
37/// - **Semantics**: Provides text content for accessibility
38///
39/// Matches Jetpack Compose: `TextStringSimpleNode` in
40/// `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt`
41#[derive(Debug)]
42pub struct TextModifierNode {
43    layout: Rc<TextPreparedLayoutOwner>,
44    state: NodeState,
45}
46
47const PREPARED_LAYOUT_CACHE_CAPACITY: usize = 4;
48
49#[derive(Clone, Debug)]
50struct TextPreparedLayoutCacheEntry {
51    max_width_bits: Option<u32>,
52    layout: crate::text::PreparedTextLayout,
53}
54
55#[derive(Debug)]
56struct TextPreparedLayoutOwner {
57    text: Rc<AnnotatedString>,
58    style: TextStyle,
59    options: TextLayoutOptions,
60    node_id: Cell<Option<cranpose_core::NodeId>>,
61    cache: RefCell<Vec<TextPreparedLayoutCacheEntry>>,
62}
63
64#[derive(Clone, Debug)]
65pub(crate) struct TextPreparedLayoutHandle {
66    owner: Rc<TextPreparedLayoutOwner>,
67}
68
69impl TextPreparedLayoutOwner {
70    fn new(
71        text: Rc<AnnotatedString>,
72        style: TextStyle,
73        options: TextLayoutOptions,
74        node_id: Option<cranpose_core::NodeId>,
75    ) -> Self {
76        Self {
77            text,
78            style,
79            options: options.normalized(),
80            node_id: Cell::new(node_id),
81            cache: RefCell::new(Vec::new()),
82        }
83    }
84
85    fn text(&self) -> &str {
86        self.text.text.as_str()
87    }
88
89    fn annotated_text(&self) -> Rc<AnnotatedString> {
90        self.text.clone()
91    }
92
93    fn annotated_string(&self) -> AnnotatedString {
94        (*self.text).clone()
95    }
96
97    fn style(&self) -> &TextStyle {
98        &self.style
99    }
100
101    fn options(&self) -> TextLayoutOptions {
102        self.options
103    }
104
105    fn node_id(&self) -> Option<cranpose_core::NodeId> {
106        self.node_id.get()
107    }
108
109    fn set_node_id(&self, node_id: Option<cranpose_core::NodeId>) {
110        if self.node_id.replace(node_id) != node_id {
111            self.cache.borrow_mut().clear();
112        }
113    }
114
115    fn prepare(&self, max_width: Option<f32>) -> crate::text::PreparedTextLayout {
116        let normalized_max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
117        let max_width_bits = normalized_max_width.map(f32::to_bits);
118
119        {
120            let mut cache = self.cache.borrow_mut();
121            if let Some(index) = cache
122                .iter()
123                .position(|entry| entry.max_width_bits == max_width_bits)
124            {
125                let entry = cache.remove(index);
126                let prepared = entry.layout.clone();
127                cache.insert(0, entry);
128                return prepared;
129            }
130        }
131
132        let prepared = crate::text::prepare_text_layout_for_node(
133            self.node_id(),
134            self.text.as_ref(),
135            &self.style,
136            self.options,
137            normalized_max_width,
138        );
139
140        let mut cache = self.cache.borrow_mut();
141        cache.insert(
142            0,
143            TextPreparedLayoutCacheEntry {
144                max_width_bits,
145                layout: prepared.clone(),
146            },
147        );
148        cache.truncate(PREPARED_LAYOUT_CACHE_CAPACITY);
149        prepared
150    }
151
152    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
153        let prepared = self.prepare(max_width);
154        Size {
155            width: prepared.metrics.width,
156            height: prepared.metrics.height,
157        }
158    }
159}
160
161impl TextPreparedLayoutHandle {
162    fn new(owner: Rc<TextPreparedLayoutOwner>) -> Self {
163        Self { owner }
164    }
165
166    pub(crate) fn prepare(&self, max_width: Option<f32>) -> crate::text::PreparedTextLayout {
167        self.owner.prepare(max_width)
168    }
169}
170
171impl TextModifierNode {
172    pub fn new(text: Rc<AnnotatedString>, style: TextStyle, options: TextLayoutOptions) -> Self {
173        Self {
174            layout: Rc::new(TextPreparedLayoutOwner::new(text, style, options, None)),
175            state: NodeState::new(),
176        }
177    }
178
179    pub fn text(&self) -> &str {
180        self.layout.text()
181    }
182
183    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
184        self.layout.annotated_text()
185    }
186
187    pub fn annotated_string(&self) -> AnnotatedString {
188        self.layout.annotated_string()
189    }
190
191    pub fn style(&self) -> &TextStyle {
192        self.layout.style()
193    }
194
195    pub fn options(&self) -> TextLayoutOptions {
196        self.layout.options()
197    }
198
199    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
200        self.layout.measure_text_content(max_width)
201    }
202
203    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
204        TextPreparedLayoutHandle::new(self.layout.clone())
205    }
206}
207
208impl DelegatableNode for TextModifierNode {
209    fn node_state(&self) -> &NodeState {
210        &self.state
211    }
212}
213
214impl ModifierNode for TextModifierNode {
215    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
216        self.layout.set_node_id(context.node_id());
217        // Invalidate layout and draw when text node is attached
218        context.invalidate(InvalidationKind::Layout);
219        context.invalidate(InvalidationKind::Draw);
220        context.invalidate(InvalidationKind::Semantics);
221    }
222
223    fn on_detach(&mut self) {
224        self.layout.set_node_id(None);
225    }
226
227    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
228        Some(self)
229    }
230
231    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
232        Some(self)
233    }
234
235    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
236        Some(self)
237    }
238
239    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
240        Some(self)
241    }
242
243    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
244        Some(self)
245    }
246
247    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
248        Some(self)
249    }
250}
251
252impl LayoutModifierNode for TextModifierNode {
253    fn measure(
254        &self,
255        _context: &mut dyn ModifierNodeContext,
256        _measurable: &dyn Measurable,
257        constraints: Constraints,
258    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
259        // Measure the text content
260        let max_width = constraints
261            .max_width
262            .is_finite()
263            .then_some(constraints.max_width);
264        let text_size = self.measure_text_content(max_width);
265
266        // Constrain text size to the provided constraints
267        let width = text_size
268            .width
269            .clamp(constraints.min_width, constraints.max_width);
270        let height = text_size
271            .height
272            .clamp(constraints.min_height, constraints.max_height);
273
274        // Text is a leaf node - return the text size directly with no offset
275        // We don't call measurable.measure() because there's no wrapped content
276        // (Text uses EmptyMeasurePolicy which has no children)
277        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
278    }
279
280    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
281        self.measure_text_content(None).width
282    }
283
284    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
285        self.measure_text_content(None).width
286    }
287
288    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
289        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
290            .height
291    }
292
293    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
294        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
295            .height
296    }
297}
298
299impl DrawModifierNode for TextModifierNode {
300    fn draw(&self, _draw_scope: &mut dyn DrawScope) {
301        // Text drawing is emitted by the scene builder from the retained node
302        // state, so the modifier draw hook remains side-effect free.
303    }
304}
305
306impl SemanticsNode for TextModifierNode {
307    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
308        // Provide text content for accessibility
309        config.content_description = Some(self.text().to_string());
310    }
311}
312
313/// Element that creates and updates TextModifierNode instances.
314///
315/// This follows the modifier element pattern where the element is responsible for:
316/// - Creating new nodes (via `create`)
317/// - Updating existing nodes when properties change (via `update`)
318/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
319///
320/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
321#[derive(Debug, Clone, PartialEq)]
322pub struct TextModifierElement {
323    text: Rc<AnnotatedString>,
324    style: TextStyle,
325    options: TextLayoutOptions,
326}
327
328impl TextModifierElement {
329    pub fn new(text: Rc<AnnotatedString>, style: TextStyle, options: TextLayoutOptions) -> Self {
330        Self {
331            text,
332            style,
333            options: options.normalized(),
334        }
335    }
336}
337
338impl Hash for TextModifierElement {
339    fn hash<H: Hasher>(&self, state: &mut H) {
340        self.text.render_hash().hash(state);
341        self.style.render_hash().hash(state);
342        self.options.hash(state);
343    }
344}
345
346impl ModifierNodeElement for TextModifierElement {
347    type Node = TextModifierNode;
348
349    fn create(&self) -> Self::Node {
350        TextModifierNode::new(self.text.clone(), self.style.clone(), self.options)
351    }
352
353    fn update(&self, node: &mut Self::Node) {
354        let current = node.layout.as_ref();
355        if current.text != self.text
356            || current.style != self.style
357            || current.options != self.options
358        {
359            node.layout = Rc::new(TextPreparedLayoutOwner::new(
360                self.text.clone(),
361                self.style.clone(),
362                self.options,
363                current.node_id(),
364            ));
365        }
366    }
367
368    fn capabilities(&self) -> NodeCapabilities {
369        // Text nodes participate in layout, drawing, and semantics
370        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::text::TextUnit;
378    use crate::text_layout_result::TextLayoutResult;
379    use cranpose_core::NodeId;
380    use cranpose_foundation::BasicModifierNodeContext;
381    use std::collections::hash_map::DefaultHasher;
382    use std::sync::mpsc;
383
384    fn hash_of(element: &TextModifierElement) -> u64 {
385        let mut hasher = DefaultHasher::new();
386        element.hash(&mut hasher);
387        hasher.finish()
388    }
389
390    struct RecordingPreparedLayoutMeasurer {
391        recorded: std::rc::Rc<std::cell::RefCell<Vec<Option<NodeId>>>>,
392    }
393
394    impl crate::text::TextMeasurer for RecordingPreparedLayoutMeasurer {
395        fn measure(
396            &self,
397            _text: &crate::text::AnnotatedString,
398            _style: &TextStyle,
399        ) -> crate::text::TextMetrics {
400            crate::text::TextMetrics {
401                width: 12.0,
402                height: 18.0,
403                line_height: 18.0,
404                line_count: 1,
405            }
406        }
407
408        fn prepare_with_options_for_node(
409            &self,
410            node_id: Option<NodeId>,
411            text: &crate::text::AnnotatedString,
412            _style: &TextStyle,
413            _options: TextLayoutOptions,
414            _max_width: Option<f32>,
415        ) -> crate::text::PreparedTextLayout {
416            self.recorded.borrow_mut().push(node_id);
417            crate::text::PreparedTextLayout {
418                text: text.clone(),
419                visual_style: TextStyle::default(),
420                metrics: crate::text::TextMetrics {
421                    width: 12.0,
422                    height: 18.0,
423                    line_height: 18.0,
424                    line_count: 1,
425                },
426                did_overflow: false,
427            }
428        }
429
430        fn get_offset_for_position(
431            &self,
432            _text: &crate::text::AnnotatedString,
433            _style: &TextStyle,
434            _x: f32,
435            _y: f32,
436        ) -> usize {
437            0
438        }
439
440        fn get_cursor_x_for_offset(
441            &self,
442            _text: &crate::text::AnnotatedString,
443            _style: &TextStyle,
444            _offset: usize,
445        ) -> f32 {
446            0.0
447        }
448
449        fn layout(
450            &self,
451            _text: &crate::text::AnnotatedString,
452            _style: &TextStyle,
453        ) -> TextLayoutResult {
454            panic!("layout is not used in this test");
455        }
456    }
457
458    #[test]
459    fn hash_changes_when_style_changes() {
460        let text = Rc::new(AnnotatedString::from("Hello"));
461        let element_a = TextModifierElement::new(
462            text.clone(),
463            TextStyle::default(),
464            TextLayoutOptions::default(),
465        );
466        let style_b = TextStyle {
467            span_style: crate::text::SpanStyle {
468                font_size: TextUnit::Sp(18.0),
469                ..Default::default()
470            },
471            ..Default::default()
472        };
473        let element_b = TextModifierElement::new(text, style_b, TextLayoutOptions::default());
474
475        assert_ne!(element_a, element_b);
476        assert_ne!(hash_of(&element_a), hash_of(&element_b));
477    }
478
479    #[test]
480    fn hash_matches_for_equal_elements() {
481        let style = TextStyle {
482            span_style: crate::text::SpanStyle {
483                font_size: TextUnit::Sp(14.0),
484                letter_spacing: TextUnit::Em(0.1),
485                ..Default::default()
486            },
487            ..Default::default()
488        };
489        let options = TextLayoutOptions::default();
490        let text = Rc::new(AnnotatedString::from("Hash me"));
491        let element_a = TextModifierElement::new(text.clone(), style.clone(), options);
492        let element_b = TextModifierElement::new(text, style, options);
493
494        assert_eq!(element_a, element_b);
495        assert_eq!(hash_of(&element_a), hash_of(&element_b));
496    }
497
498    #[test]
499    fn measure_uses_attached_node_identity() {
500        let (tx, rx) = mpsc::channel();
501
502        std::thread::spawn(move || {
503            let recorded = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
504            let app_context = crate::AppContext::new();
505            app_context.enter(|| {
506                crate::text::set_text_measurer(RecordingPreparedLayoutMeasurer {
507                    recorded: recorded.clone(),
508                });
509
510                let mut node = TextModifierNode::new(
511                    Rc::new(AnnotatedString::from("identity")),
512                    TextStyle::default(),
513                    TextLayoutOptions::default(),
514                );
515                let mut context = BasicModifierNodeContext::new();
516                context.set_node_id(Some(77));
517                node.on_attach(&mut context);
518
519                let size = node.measure_text_content(Some(96.0));
520                tx.send((recorded.borrow().clone(), size.width, size.height))
521                    .expect("send measurement result");
522            });
523        });
524
525        let (recorded, width, height) = rx.recv().expect("receive measurement result");
526        assert_eq!(recorded, vec![Some(77)]);
527        assert_eq!(width, 12.0);
528        assert_eq!(height, 18.0);
529    }
530
531    #[test]
532    fn prepared_layout_cache_reuses_node_snapshot() {
533        let (tx, rx) = mpsc::channel();
534
535        std::thread::spawn(move || {
536            let recorded = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
537            let app_context = crate::AppContext::new();
538            app_context.enter(|| {
539                crate::text::set_text_measurer(RecordingPreparedLayoutMeasurer {
540                    recorded: recorded.clone(),
541                });
542
543                let mut node = TextModifierNode::new(
544                    Rc::new(AnnotatedString::from("reuse")),
545                    TextStyle::default(),
546                    TextLayoutOptions::default(),
547                );
548                let mut context = BasicModifierNodeContext::new();
549                context.set_node_id(Some(88));
550                node.on_attach(&mut context);
551
552                let measured = node.measure_text_content(Some(120.0));
553                let prepared = node.prepared_layout_handle().prepare(Some(120.0));
554                tx.send((
555                    recorded.borrow().clone(),
556                    measured.width,
557                    measured.height,
558                    prepared.metrics.width,
559                    prepared.metrics.height,
560                ))
561                .expect("send cached layout result");
562            });
563        });
564
565        let (recorded, measured_width, measured_height, prepared_width, prepared_height) =
566            rx.recv().expect("receive cached layout result");
567        assert_eq!(recorded, vec![Some(88)]);
568        assert_eq!(measured_width, prepared_width);
569        assert_eq!(measured_height, prepared_height);
570    }
571
572    #[test]
573    fn semantics_uses_source_text_for_scaled_overflow() {
574        let node = TextModifierNode::new(
575            Rc::new(AnnotatedString::from("Save Cranpose WebP")),
576            TextStyle::default(),
577            TextLayoutOptions {
578                overflow: crate::text::TextOverflow::ScaleDown {
579                    min_font_size_sp: 9.0,
580                },
581                soft_wrap: false,
582                max_lines: 1,
583                min_lines: 1,
584            },
585        );
586        let mut config = SemanticsConfiguration::default();
587
588        node.merge_semantics(&mut config);
589
590        assert_eq!(
591            config.content_description.as_deref(),
592            Some("Save Cranpose WebP")
593        );
594    }
595}