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