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