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::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};
29use std::rc::Rc;
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: Rc<str>,
43    style: TextStyle,
44    state: NodeState,
45}
46
47impl TextModifierNode {
48    pub fn new(text: Rc<str>, style: TextStyle) -> Self {
49        Self {
50            text,
51            style,
52            state: NodeState::new(),
53        }
54    }
55
56    pub fn text(&self) -> &str {
57        &self.text
58    }
59
60    pub fn text_arc(&self) -> Rc<str> {
61        self.text.clone()
62    }
63
64    pub fn style(&self) -> &TextStyle {
65        &self.style
66    }
67
68    fn measure_text_content(&self) -> Size {
69        let metrics = crate::text::measure_text(&self.text, &self.style);
70        Size {
71            width: metrics.width,
72            height: metrics.height,
73        }
74    }
75}
76
77impl DelegatableNode for TextModifierNode {
78    fn node_state(&self) -> &NodeState {
79        &self.state
80    }
81}
82
83impl ModifierNode for TextModifierNode {
84    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
85        // Invalidate layout and draw when text node is attached
86        context.invalidate(InvalidationKind::Layout);
87        context.invalidate(InvalidationKind::Draw);
88        context.invalidate(InvalidationKind::Semantics);
89    }
90
91    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
92        Some(self)
93    }
94
95    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
96        Some(self)
97    }
98
99    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
100        Some(self)
101    }
102
103    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
104        Some(self)
105    }
106
107    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
108        Some(self)
109    }
110
111    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
112        Some(self)
113    }
114}
115
116impl LayoutModifierNode for TextModifierNode {
117    fn measure(
118        &self,
119        _context: &mut dyn ModifierNodeContext,
120        _measurable: &dyn Measurable,
121        constraints: Constraints,
122    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
123        // Measure the text content
124        let text_size = self.measure_text_content();
125
126        // Constrain text size to the provided constraints
127        let width = text_size
128            .width
129            .clamp(constraints.min_width, constraints.max_width);
130        let height = text_size
131            .height
132            .clamp(constraints.min_height, constraints.max_height);
133
134        // Text is a leaf node - return the text size directly with no offset
135        // We don't call measurable.measure() because there's no wrapped content
136        // (Text uses EmptyMeasurePolicy which has no children)
137        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
138    }
139
140    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
141        self.measure_text_content().width
142    }
143
144    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
145        self.measure_text_content().width
146    }
147
148    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
149        self.measure_text_content().height
150    }
151
152    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
153        self.measure_text_content().height
154    }
155
156    fn create_measurement_proxy(&self) -> Option<Box<dyn MeasurementProxy>> {
157        Some(Box::new(TextMeasurementProxy {
158            text: self.text.clone(),
159            style: self.style.clone(), // Add style
160        }))
161    }
162}
163
164/// Measurement proxy for TextModifierNode that snapshots live state.
165///
166/// Phase 2: Instead of reconstructing nodes via `TextModifierNode::new()`, this proxy
167/// directly implements measurement logic using the snapshotted text content.
168struct TextMeasurementProxy {
169    text: Rc<str>,
170    style: TextStyle, // Add style
171}
172
173impl TextMeasurementProxy {
174    /// Measure the text content dimensions.
175    /// Matches TextModifierNode::measure_text_content() logic.
176    fn measure_text_content(&self) -> Size {
177        let metrics = crate::text::measure_text(&self.text, &self.style);
178        Size {
179            width: metrics.width,
180            height: metrics.height,
181        }
182    }
183}
184
185impl MeasurementProxy for TextMeasurementProxy {
186    fn measure_proxy(
187        &self,
188        _context: &mut dyn ModifierNodeContext,
189        _measurable: &dyn Measurable,
190        constraints: Constraints,
191    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
192        // Directly implement text measurement logic (no node reconstruction)
193        let text_size = self.measure_text_content();
194
195        // Constrain text size to the provided constraints
196        let width = text_size
197            .width
198            .clamp(constraints.min_width, constraints.max_width);
199        let height = text_size
200            .height
201            .clamp(constraints.min_height, constraints.max_height);
202
203        // Text is a leaf node - return the text size directly with no offset
204        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
205    }
206
207    fn min_intrinsic_width_proxy(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
208        self.measure_text_content().width
209    }
210
211    fn max_intrinsic_width_proxy(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
212        self.measure_text_content().width
213    }
214
215    fn min_intrinsic_height_proxy(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
216        self.measure_text_content().height
217    }
218
219    fn max_intrinsic_height_proxy(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
220        self.measure_text_content().height
221    }
222}
223
224impl DrawModifierNode for TextModifierNode {
225    fn draw(&self, _draw_scope: &mut dyn DrawScope) {
226        // In a full implementation, this would:
227        // 1. Get the text paragraph layout cache
228        // 2. Paint the text using draw_scope canvas
229        //
230        // For now, this is a placeholder. The actual rendering will be handled
231        // by the renderer which can read text from the modifier chain.
232        //
233        // Future: Implement actual text drawing here using DrawScope
234    }
235}
236
237impl SemanticsNode for TextModifierNode {
238    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
239        // Provide text content for accessibility
240        config.content_description = Some(self.text.to_string());
241    }
242}
243
244/// Element that creates and updates TextModifierNode instances.
245///
246/// This follows the modifier element pattern where the element is responsible for:
247/// - Creating new nodes (via `create`)
248/// - Updating existing nodes when properties change (via `update`)
249/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
250///
251/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
252#[derive(Debug, Clone, PartialEq)]
253pub struct TextModifierElement {
254    text: Rc<str>,
255    style: TextStyle,
256}
257
258impl TextModifierElement {
259    pub fn new(text: Rc<str>, style: TextStyle) -> Self {
260        Self { text, style }
261    }
262}
263
264fn hash_f32_bits<H: Hasher>(value: f32, state: &mut H) {
265    value.to_bits().hash(state);
266}
267
268fn hash_text_unit<H: Hasher>(unit: crate::text::TextUnit, state: &mut H) {
269    match unit {
270        crate::text::TextUnit::Unspecified => 0u8.hash(state),
271        crate::text::TextUnit::Sp(value) => {
272            1u8.hash(state);
273            hash_f32_bits(value, state);
274        }
275        crate::text::TextUnit::Em(value) => {
276            2u8.hash(state);
277            hash_f32_bits(value, state);
278        }
279    }
280}
281
282fn hash_color<H: Hasher>(color: crate::modifier::Color, state: &mut H) {
283    hash_f32_bits(color.0, state);
284    hash_f32_bits(color.1, state);
285    hash_f32_bits(color.2, state);
286    hash_f32_bits(color.3, state);
287}
288
289fn hash_option_color<H: Hasher>(color: &Option<crate::modifier::Color>, state: &mut H) {
290    match color {
291        Some(color) => {
292            1u8.hash(state);
293            hash_color(*color, state);
294        }
295        None => 0u8.hash(state),
296    }
297}
298
299fn hash_option_f32<H: Hasher>(value: Option<f32>, state: &mut H) {
300    match value {
301        Some(value) => {
302            1u8.hash(state);
303            hash_f32_bits(value, state);
304        }
305        None => 0u8.hash(state),
306    }
307}
308
309fn hash_option_shadow<H: Hasher>(shadow: &Option<crate::text::Shadow>, state: &mut H) {
310    match shadow {
311        Some(shadow) => {
312            1u8.hash(state);
313            hash_color(shadow.color, state);
314            hash_f32_bits(shadow.offset.x, state);
315            hash_f32_bits(shadow.offset.y, state);
316            hash_f32_bits(shadow.blur_radius, state);
317        }
318        None => 0u8.hash(state),
319    }
320}
321
322fn hash_option_text_indent<H: Hasher>(indent: &Option<crate::text::TextIndent>, state: &mut H) {
323    match indent {
324        Some(indent) => {
325            1u8.hash(state);
326            hash_text_unit(indent.first_line, state);
327            hash_text_unit(indent.rest_line, state);
328        }
329        None => 0u8.hash(state),
330    }
331}
332
333fn hash_text_style<H: Hasher>(style: &TextStyle, state: &mut H) {
334    hash_option_color(&style.color, state);
335    hash_text_unit(style.font_size, state);
336    style.font_weight.hash(state);
337    style.font_style.hash(state);
338    style.font_synthesis.hash(state);
339    style.font_family.hash(state);
340    style.font_feature_settings.hash(state);
341    hash_text_unit(style.letter_spacing, state);
342    hash_option_f32(style.baseline_shift, state);
343    style.text_geometric_transform.is_some().hash(state);
344    style.locale_list.is_some().hash(state);
345    hash_option_color(&style.background, state);
346    style.text_decoration.hash(state);
347    hash_option_shadow(&style.shadow, state);
348    style.text_align.hash(state);
349    style.text_direction.hash(state);
350    hash_text_unit(style.line_height, state);
351    hash_option_text_indent(&style.text_indent, state);
352}
353
354impl Hash for TextModifierElement {
355    fn hash<H: Hasher>(&self, state: &mut H) {
356        self.text.hash(state);
357        hash_text_style(&self.style, state);
358    }
359}
360
361impl ModifierNodeElement for TextModifierElement {
362    type Node = TextModifierNode;
363
364    fn create(&self) -> Self::Node {
365        TextModifierNode::new(self.text.clone(), self.style.clone())
366    }
367
368    fn update(&self, node: &mut Self::Node) {
369        let mut changed = false;
370        if node.text != self.text {
371            node.text = self.text.clone();
372            changed = true;
373        }
374        if node.style != self.style {
375            node.style = self.style.clone();
376            changed = true;
377        }
378
379        if changed {
380            // Text/Style changed - need to invalidate layout, draw, and semantics
381            // Note: In the full implementation, we would call context.invalidate here
382            // but update() doesn't currently have access to context.
383            // The invalidation will happen on the next recomposition when the node
384            // is reconciled.
385        }
386    }
387
388    fn capabilities(&self) -> NodeCapabilities {
389        // Text nodes participate in layout, drawing, and semantics
390        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::text::TextUnit;
398    use std::collections::hash_map::DefaultHasher;
399
400    fn hash_of(element: &TextModifierElement) -> u64 {
401        let mut hasher = DefaultHasher::new();
402        element.hash(&mut hasher);
403        hasher.finish()
404    }
405
406    #[test]
407    fn hash_changes_when_style_changes() {
408        let text = Rc::<str>::from("Hello");
409        let element_a = TextModifierElement::new(text.clone(), TextStyle::default());
410        let style_b = TextStyle {
411            font_size: TextUnit::Sp(18.0),
412            ..Default::default()
413        };
414        let element_b = TextModifierElement::new(text, style_b);
415
416        assert_ne!(element_a, element_b);
417        assert_ne!(hash_of(&element_a), hash_of(&element_b));
418    }
419
420    #[test]
421    fn hash_matches_for_equal_elements() {
422        let style = TextStyle {
423            font_size: TextUnit::Sp(14.0),
424            letter_spacing: TextUnit::Em(0.1),
425            ..Default::default()
426        };
427        let element_a = TextModifierElement::new(Rc::<str>::from("Hash me"), style.clone());
428        let element_b = TextModifierElement::new(Rc::<str>::from("Hash me"), style);
429
430        assert_eq!(element_a, element_b);
431        assert_eq!(hash_of(&element_a), hash_of(&element_b));
432    }
433}