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, MeasurementProxy, ModifierNode, ModifierNodeContext,
26    ModifierNodeElement, NodeCapabilities, NodeState, SemanticsConfiguration, SemanticsNode, Size,
27};
28use std::cell::RefCell;
29use std::hash::{Hash, Hasher};
30
31/// Node that stores text content and handles measurement, drawing, and semantics.
32///
33/// This node implements three capabilities:
34/// - **Layout**: Measures text and returns appropriate size
35/// - **Draw**: Renders the text (placeholder for now)
36/// - **Semantics**: Provides text content for accessibility
37///
38/// Matches Jetpack Compose: `TextStringSimpleNode` in
39/// `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt`
40#[derive(Debug)]
41pub struct TextModifierNode {
42    text: AnnotatedString,
43    style: TextStyle,
44    options: TextLayoutOptions,
45    measure_cache: RefCell<Option<TextMeasureCacheEntry>>,
46    state: NodeState,
47}
48
49#[derive(Clone, Copy, Debug)]
50struct TextMeasureCacheEntry {
51    max_width_bits: Option<u32>,
52    size: Size,
53}
54
55impl TextModifierNode {
56    pub fn new(text: AnnotatedString, style: TextStyle, options: TextLayoutOptions) -> Self {
57        Self {
58            text,
59            style,
60            options: options.normalized(),
61            measure_cache: RefCell::new(None),
62            state: NodeState::new(),
63        }
64    }
65
66    pub fn text(&self) -> &str {
67        &self.text.text
68    }
69
70    pub fn annotated_string(&self) -> AnnotatedString {
71        self.text.clone()
72    }
73
74    pub fn style(&self) -> &TextStyle {
75        &self.style
76    }
77
78    pub fn options(&self) -> TextLayoutOptions {
79        self.options
80    }
81
82    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
83        let cache_key = max_width.map(f32::to_bits);
84        if let Some(cache) = self.measure_cache.borrow().as_ref() {
85            if cache.max_width_bits == cache_key {
86                return cache.size;
87            }
88        }
89
90        let metrics = crate::text::measure_text_with_options(
91            &self.text,
92            &self.style,
93            self.options,
94            max_width,
95        );
96        let size = Size {
97            width: metrics.width,
98            height: metrics.height,
99        };
100        self.measure_cache
101            .borrow_mut()
102            .replace(TextMeasureCacheEntry {
103                max_width_bits: cache_key,
104                size,
105            });
106        size
107    }
108}
109
110impl DelegatableNode for TextModifierNode {
111    fn node_state(&self) -> &NodeState {
112        &self.state
113    }
114}
115
116impl ModifierNode for TextModifierNode {
117    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
118        // Invalidate layout and draw when text node is attached
119        context.invalidate(InvalidationKind::Layout);
120        context.invalidate(InvalidationKind::Draw);
121        context.invalidate(InvalidationKind::Semantics);
122    }
123
124    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
125        Some(self)
126    }
127
128    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
129        Some(self)
130    }
131
132    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
133        Some(self)
134    }
135
136    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
137        Some(self)
138    }
139
140    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
141        Some(self)
142    }
143
144    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
145        Some(self)
146    }
147}
148
149impl LayoutModifierNode for TextModifierNode {
150    fn measure(
151        &self,
152        _context: &mut dyn ModifierNodeContext,
153        _measurable: &dyn Measurable,
154        constraints: Constraints,
155    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
156        // Measure the text content
157        let max_width = constraints
158            .max_width
159            .is_finite()
160            .then_some(constraints.max_width);
161        let text_size = self.measure_text_content(max_width);
162
163        // Constrain text size to the provided constraints
164        let width = text_size
165            .width
166            .clamp(constraints.min_width, constraints.max_width);
167        let height = text_size
168            .height
169            .clamp(constraints.min_height, constraints.max_height);
170
171        // Text is a leaf node - return the text size directly with no offset
172        // We don't call measurable.measure() because there's no wrapped content
173        // (Text uses EmptyMeasurePolicy which has no children)
174        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
175    }
176
177    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
178        self.measure_text_content(None).width
179    }
180
181    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
182        self.measure_text_content(None).width
183    }
184
185    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
186        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
187            .height
188    }
189
190    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
191        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
192            .height
193    }
194
195    fn create_measurement_proxy(&self) -> Option<Box<dyn MeasurementProxy>> {
196        Some(Box::new(TextMeasurementProxy {
197            text: self.text.clone(),
198            style: self.style.clone(),
199            options: self.options,
200        }))
201    }
202}
203
204/// Measurement proxy for TextModifierNode that snapshots live state.
205///
206/// Phase 2: Instead of reconstructing nodes via `TextModifierNode::new()`, this proxy
207/// directly implements measurement logic using the snapshotted text content.
208struct TextMeasurementProxy {
209    text: AnnotatedString,
210    style: TextStyle,
211    options: TextLayoutOptions,
212}
213
214impl TextMeasurementProxy {
215    /// Measure the text content dimensions.
216    /// Matches TextModifierNode::measure_text_content() logic.
217    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
218        let metrics = crate::text::measure_text_with_options(
219            &self.text,
220            &self.style,
221            self.options,
222            max_width,
223        );
224        Size {
225            width: metrics.width,
226            height: metrics.height,
227        }
228    }
229}
230
231impl MeasurementProxy for TextMeasurementProxy {
232    fn measure_proxy(
233        &self,
234        _context: &mut dyn ModifierNodeContext,
235        _measurable: &dyn Measurable,
236        constraints: Constraints,
237    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
238        // Directly implement text measurement logic (no node reconstruction)
239        let max_width = constraints
240            .max_width
241            .is_finite()
242            .then_some(constraints.max_width);
243        let text_size = self.measure_text_content(max_width);
244
245        // Constrain text size to the provided constraints
246        let width = text_size
247            .width
248            .clamp(constraints.min_width, constraints.max_width);
249        let height = text_size
250            .height
251            .clamp(constraints.min_height, constraints.max_height);
252
253        // Text is a leaf node - return the text size directly with no offset
254        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
255    }
256
257    fn min_intrinsic_width_proxy(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
258        self.measure_text_content(None).width
259    }
260
261    fn max_intrinsic_width_proxy(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
262        self.measure_text_content(None).width
263    }
264
265    fn min_intrinsic_height_proxy(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
266        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
267            .height
268    }
269
270    fn max_intrinsic_height_proxy(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
271        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
272            .height
273    }
274}
275
276impl DrawModifierNode for TextModifierNode {
277    fn draw(&self, _draw_scope: &mut dyn DrawScope) {
278        // In a full implementation, this would:
279        // 1. Get the text paragraph layout cache
280        // 2. Paint the text using draw_scope canvas
281        //
282        // For now, this is a placeholder. The actual rendering will be handled
283        // by the renderer which can read text from the modifier chain.
284        //
285        // Future: Implement actual text drawing here using DrawScope
286    }
287}
288
289impl SemanticsNode for TextModifierNode {
290    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
291        // Provide text content for accessibility
292        config.content_description = Some(self.text.text.clone());
293    }
294}
295
296/// Element that creates and updates TextModifierNode instances.
297///
298/// This follows the modifier element pattern where the element is responsible for:
299/// - Creating new nodes (via `create`)
300/// - Updating existing nodes when properties change (via `update`)
301/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
302///
303/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
304#[derive(Debug, Clone, PartialEq)]
305pub struct TextModifierElement {
306    text: AnnotatedString,
307    style: TextStyle,
308    options: TextLayoutOptions,
309}
310
311impl TextModifierElement {
312    pub fn new(text: AnnotatedString, style: TextStyle, options: TextLayoutOptions) -> Self {
313        Self {
314            text,
315            style,
316            options: options.normalized(),
317        }
318    }
319}
320
321fn hash_f32_bits<H: Hasher>(value: f32, state: &mut H) {
322    value.to_bits().hash(state);
323}
324
325fn hash_text_unit<H: Hasher>(unit: crate::text::TextUnit, state: &mut H) {
326    match unit {
327        crate::text::TextUnit::Unspecified => 0u8.hash(state),
328        crate::text::TextUnit::Sp(value) => {
329            1u8.hash(state);
330            hash_f32_bits(value, state);
331        }
332        crate::text::TextUnit::Em(value) => {
333            2u8.hash(state);
334            hash_f32_bits(value, state);
335        }
336    }
337}
338
339fn hash_color<H: Hasher>(color: crate::modifier::Color, state: &mut H) {
340    hash_f32_bits(color.0, state);
341    hash_f32_bits(color.1, state);
342    hash_f32_bits(color.2, state);
343    hash_f32_bits(color.3, state);
344}
345
346fn hash_option_color<H: Hasher>(color: &Option<crate::modifier::Color>, state: &mut H) {
347    match color {
348        Some(color) => {
349            1u8.hash(state);
350            hash_color(*color, state);
351        }
352        None => 0u8.hash(state),
353    }
354}
355
356fn hash_brush<H: Hasher>(brush: &crate::modifier::Brush, state: &mut H) {
357    match brush {
358        crate::modifier::Brush::Solid(color) => {
359            0u8.hash(state);
360            hash_color(*color, state);
361        }
362        crate::modifier::Brush::LinearGradient {
363            colors,
364            stops,
365            start,
366            end,
367            tile_mode,
368        } => {
369            1u8.hash(state);
370            colors.len().hash(state);
371            for color in colors {
372                hash_color(*color, state);
373            }
374            match stops {
375                Some(stops) => {
376                    1u8.hash(state);
377                    stops.len().hash(state);
378                    for stop in stops {
379                        hash_f32_bits(*stop, state);
380                    }
381                }
382                None => 0u8.hash(state),
383            }
384            hash_f32_bits(start.x, state);
385            hash_f32_bits(start.y, state);
386            hash_f32_bits(end.x, state);
387            hash_f32_bits(end.y, state);
388            tile_mode.hash(state);
389        }
390        crate::modifier::Brush::RadialGradient {
391            colors,
392            stops,
393            center,
394            radius,
395            tile_mode,
396        } => {
397            2u8.hash(state);
398            colors.len().hash(state);
399            for color in colors {
400                hash_color(*color, state);
401            }
402            match stops {
403                Some(stops) => {
404                    1u8.hash(state);
405                    stops.len().hash(state);
406                    for stop in stops {
407                        hash_f32_bits(*stop, state);
408                    }
409                }
410                None => 0u8.hash(state),
411            }
412            hash_f32_bits(center.x, state);
413            hash_f32_bits(center.y, state);
414            hash_f32_bits(*radius, state);
415            tile_mode.hash(state);
416        }
417        crate::modifier::Brush::SweepGradient {
418            colors,
419            stops,
420            center,
421        } => {
422            3u8.hash(state);
423            colors.len().hash(state);
424            for color in colors {
425                hash_color(*color, state);
426            }
427            match stops {
428                Some(stops) => {
429                    1u8.hash(state);
430                    stops.len().hash(state);
431                    for stop in stops {
432                        hash_f32_bits(*stop, state);
433                    }
434                }
435                None => 0u8.hash(state),
436            }
437            hash_f32_bits(center.x, state);
438            hash_f32_bits(center.y, state);
439        }
440    }
441}
442
443fn hash_option_brush<H: Hasher>(brush: &Option<crate::modifier::Brush>, state: &mut H) {
444    match brush {
445        Some(brush) => {
446            1u8.hash(state);
447            hash_brush(brush, state);
448        }
449        None => 0u8.hash(state),
450    }
451}
452
453fn hash_option_alpha<H: Hasher>(alpha: &Option<f32>, state: &mut H) {
454    match alpha {
455        Some(alpha) => {
456            1u8.hash(state);
457            hash_f32_bits(*alpha, state);
458        }
459        None => 0u8.hash(state),
460    }
461}
462
463fn hash_option_baseline_shift<H: Hasher>(
464    baseline_shift: &Option<crate::text::BaselineShift>,
465    state: &mut H,
466) {
467    match baseline_shift {
468        Some(shift) => {
469            1u8.hash(state);
470            hash_f32_bits(shift.0, state);
471        }
472        None => 0u8.hash(state),
473    }
474}
475
476fn hash_option_text_geometric_transform<H: Hasher>(
477    transform: &Option<crate::text::TextGeometricTransform>,
478    state: &mut H,
479) {
480    match transform {
481        Some(transform) => {
482            1u8.hash(state);
483            hash_f32_bits(transform.scale_x, state);
484            hash_f32_bits(transform.skew_x, state);
485        }
486        None => 0u8.hash(state),
487    }
488}
489
490fn hash_option_shadow<H: Hasher>(shadow: &Option<crate::text::Shadow>, state: &mut H) {
491    match shadow {
492        Some(shadow) => {
493            1u8.hash(state);
494            hash_color(shadow.color, state);
495            hash_f32_bits(shadow.offset.x, state);
496            hash_f32_bits(shadow.offset.y, state);
497            hash_f32_bits(shadow.blur_radius, state);
498        }
499        None => 0u8.hash(state),
500    }
501}
502
503fn hash_option_text_indent<H: Hasher>(indent: &Option<crate::text::TextIndent>, state: &mut H) {
504    match indent {
505        Some(indent) => {
506            1u8.hash(state);
507            hash_text_unit(indent.first_line, state);
508            hash_text_unit(indent.rest_line, state);
509        }
510        None => 0u8.hash(state),
511    }
512}
513
514fn hash_option_text_draw_style<H: Hasher>(
515    draw_style: &Option<crate::text::TextDrawStyle>,
516    state: &mut H,
517) {
518    match draw_style {
519        Some(crate::text::TextDrawStyle::Fill) => {
520            1u8.hash(state);
521            0u8.hash(state);
522        }
523        Some(crate::text::TextDrawStyle::Stroke { width }) => {
524            1u8.hash(state);
525            1u8.hash(state);
526            hash_f32_bits(*width, state);
527        }
528        None => 0u8.hash(state),
529    }
530}
531
532fn hash_text_style<H: Hasher>(style: &TextStyle, state: &mut H) {
533    let span = &style.span_style;
534    let paragraph = &style.paragraph_style;
535
536    hash_option_color(&span.color, state);
537    hash_option_brush(&span.brush, state);
538    hash_option_alpha(&span.alpha, state);
539    hash_text_unit(span.font_size, state);
540    span.font_weight.hash(state);
541    span.font_style.hash(state);
542    span.font_synthesis.hash(state);
543    span.font_family.hash(state);
544    span.font_feature_settings.hash(state);
545    hash_text_unit(span.letter_spacing, state);
546    hash_option_baseline_shift(&span.baseline_shift, state);
547    hash_option_text_geometric_transform(&span.text_geometric_transform, state);
548    span.locale_list.hash(state);
549    hash_option_color(&span.background, state);
550    span.text_decoration.hash(state);
551    hash_option_shadow(&span.shadow, state);
552    span.platform_style.hash(state);
553    hash_option_text_draw_style(&span.draw_style, state);
554
555    paragraph.text_align.hash(state);
556    paragraph.text_direction.hash(state);
557    hash_text_unit(paragraph.line_height, state);
558    hash_option_text_indent(&paragraph.text_indent, state);
559    paragraph.platform_style.hash(state);
560    paragraph.line_height_style.hash(state);
561    paragraph.line_break.hash(state);
562    paragraph.hyphens.hash(state);
563    paragraph.text_motion.hash(state);
564}
565
566impl Hash for TextModifierElement {
567    fn hash<H: Hasher>(&self, state: &mut H) {
568        self.text.text.hash(state);
569        hash_text_style(&self.style, state);
570        self.options.hash(state);
571    }
572}
573
574impl ModifierNodeElement for TextModifierElement {
575    type Node = TextModifierNode;
576
577    fn create(&self) -> Self::Node {
578        TextModifierNode::new(self.text.clone(), self.style.clone(), self.options)
579    }
580
581    fn update(&self, node: &mut Self::Node) {
582        let mut changed = false;
583        if node.text != self.text {
584            node.text = self.text.clone();
585            changed = true;
586        }
587        if node.style != self.style {
588            node.style = self.style.clone();
589            changed = true;
590        }
591        if node.options != self.options {
592            node.options = self.options;
593            changed = true;
594        }
595
596        if changed {
597            node.measure_cache.borrow_mut().take();
598            // Text/Style changed - need to invalidate layout, draw, and semantics
599            // Note: In the full implementation, we would call context.invalidate here
600            // but update() doesn't currently have access to context.
601            // The invalidation will happen on the next recomposition when the node
602            // is reconciled.
603        }
604    }
605
606    fn capabilities(&self) -> NodeCapabilities {
607        // Text nodes participate in layout, drawing, and semantics
608        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::text::TextUnit;
616    use std::collections::hash_map::DefaultHasher;
617
618    fn hash_of(element: &TextModifierElement) -> u64 {
619        let mut hasher = DefaultHasher::new();
620        element.hash(&mut hasher);
621        hasher.finish()
622    }
623
624    #[test]
625    fn hash_changes_when_style_changes() {
626        let text = AnnotatedString::from("Hello");
627        let element_a = TextModifierElement::new(
628            text.clone(),
629            TextStyle::default(),
630            TextLayoutOptions::default(),
631        );
632        let style_b = TextStyle {
633            span_style: crate::text::SpanStyle {
634                font_size: TextUnit::Sp(18.0),
635                ..Default::default()
636            },
637            ..Default::default()
638        };
639        let element_b = TextModifierElement::new(text, style_b, TextLayoutOptions::default());
640
641        assert_ne!(element_a, element_b);
642        assert_ne!(hash_of(&element_a), hash_of(&element_b));
643    }
644
645    #[test]
646    fn hash_matches_for_equal_elements() {
647        let style = TextStyle {
648            span_style: crate::text::SpanStyle {
649                font_size: TextUnit::Sp(14.0),
650                letter_spacing: TextUnit::Em(0.1),
651                ..Default::default()
652            },
653            ..Default::default()
654        };
655        let options = TextLayoutOptions::default();
656        let text = AnnotatedString::from("Hash me");
657        let element_a = TextModifierElement::new(text.clone(), style.clone(), options);
658        let element_b = TextModifierElement::new(text, style, options);
659
660        assert_eq!(element_a, element_b);
661        assert_eq!(hash_of(&element_a), hash_of(&element_b));
662    }
663}