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::{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**: Renders the text (placeholder for now)
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    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
171        self.owner.measure_text_content(max_width)
172    }
173}
174
175impl TextModifierNode {
176    pub fn new(text: Rc<AnnotatedString>, style: TextStyle, options: TextLayoutOptions) -> Self {
177        Self {
178            layout: Rc::new(TextPreparedLayoutOwner::new(text, style, options, None)),
179            state: NodeState::new(),
180        }
181    }
182
183    pub fn text(&self) -> &str {
184        self.layout.text()
185    }
186
187    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
188        self.layout.annotated_text()
189    }
190
191    pub fn annotated_string(&self) -> AnnotatedString {
192        self.layout.annotated_string()
193    }
194
195    pub fn style(&self) -> &TextStyle {
196        self.layout.style()
197    }
198
199    pub fn options(&self) -> TextLayoutOptions {
200        self.layout.options()
201    }
202
203    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
204        self.layout.measure_text_content(max_width)
205    }
206
207    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
208        TextPreparedLayoutHandle::new(self.layout.clone())
209    }
210}
211
212impl DelegatableNode for TextModifierNode {
213    fn node_state(&self) -> &NodeState {
214        &self.state
215    }
216}
217
218impl ModifierNode for TextModifierNode {
219    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
220        self.layout.set_node_id(context.node_id());
221        // Invalidate layout and draw when text node is attached
222        context.invalidate(InvalidationKind::Layout);
223        context.invalidate(InvalidationKind::Draw);
224        context.invalidate(InvalidationKind::Semantics);
225    }
226
227    fn on_detach(&mut self) {
228        self.layout.set_node_id(None);
229    }
230
231    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
232        Some(self)
233    }
234
235    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
236        Some(self)
237    }
238
239    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
240        Some(self)
241    }
242
243    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
244        Some(self)
245    }
246
247    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
248        Some(self)
249    }
250
251    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
252        Some(self)
253    }
254}
255
256impl LayoutModifierNode for TextModifierNode {
257    fn measure(
258        &self,
259        _context: &mut dyn ModifierNodeContext,
260        _measurable: &dyn Measurable,
261        constraints: Constraints,
262    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
263        // Measure the text content
264        let max_width = constraints
265            .max_width
266            .is_finite()
267            .then_some(constraints.max_width);
268        let text_size = self.measure_text_content(max_width);
269
270        // Constrain text size to the provided constraints
271        let width = text_size
272            .width
273            .clamp(constraints.min_width, constraints.max_width);
274        let height = text_size
275            .height
276            .clamp(constraints.min_height, constraints.max_height);
277
278        // Text is a leaf node - return the text size directly with no offset
279        // We don't call measurable.measure() because there's no wrapped content
280        // (Text uses EmptyMeasurePolicy which has no children)
281        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
282    }
283
284    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
285        self.measure_text_content(None).width
286    }
287
288    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
289        self.measure_text_content(None).width
290    }
291
292    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
293        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
294            .height
295    }
296
297    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
298        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
299            .height
300    }
301
302    fn create_measurement_proxy(&self) -> Option<Box<dyn MeasurementProxy>> {
303        Some(Box::new(TextMeasurementProxy {
304            layout: self.prepared_layout_handle(),
305        }))
306    }
307}
308
309/// Measurement proxy for TextModifierNode that snapshots live state.
310///
311/// Phase 2: Instead of reconstructing nodes via `TextModifierNode::new()`, this proxy
312/// directly implements measurement logic using the snapshotted text content.
313struct TextMeasurementProxy {
314    layout: TextPreparedLayoutHandle,
315}
316
317impl TextMeasurementProxy {
318    /// Measure the text content dimensions.
319    /// Matches TextModifierNode::measure_text_content() logic.
320    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
321        self.layout.measure_text_content(max_width)
322    }
323}
324
325impl MeasurementProxy for TextMeasurementProxy {
326    fn measure_proxy(
327        &self,
328        _context: &mut dyn ModifierNodeContext,
329        _measurable: &dyn Measurable,
330        constraints: Constraints,
331    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
332        // Directly implement text measurement logic (no node reconstruction)
333        let max_width = constraints
334            .max_width
335            .is_finite()
336            .then_some(constraints.max_width);
337        let text_size = self.measure_text_content(max_width);
338
339        // Constrain text size to the provided constraints
340        let width = text_size
341            .width
342            .clamp(constraints.min_width, constraints.max_width);
343        let height = text_size
344            .height
345            .clamp(constraints.min_height, constraints.max_height);
346
347        // Text is a leaf node - return the text size directly with no offset
348        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
349    }
350
351    fn min_intrinsic_width_proxy(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
352        self.measure_text_content(None).width
353    }
354
355    fn max_intrinsic_width_proxy(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
356        self.measure_text_content(None).width
357    }
358
359    fn min_intrinsic_height_proxy(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
360        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
361            .height
362    }
363
364    fn max_intrinsic_height_proxy(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
365        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
366            .height
367    }
368}
369
370impl DrawModifierNode for TextModifierNode {
371    fn draw(&self, _draw_scope: &mut dyn DrawScope) {
372        // In a full implementation, this would:
373        // 1. Get the text paragraph layout cache
374        // 2. Paint the text using draw_scope canvas
375        //
376        // For now, this is a placeholder. The actual rendering will be handled
377        // by the renderer which can read text from the modifier chain.
378        //
379        // Future: Implement actual text drawing here using DrawScope
380    }
381}
382
383impl SemanticsNode for TextModifierNode {
384    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
385        // Provide text content for accessibility
386        config.content_description = Some(self.text().to_string());
387    }
388}
389
390/// Element that creates and updates TextModifierNode instances.
391///
392/// This follows the modifier element pattern where the element is responsible for:
393/// - Creating new nodes (via `create`)
394/// - Updating existing nodes when properties change (via `update`)
395/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
396///
397/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
398#[derive(Debug, Clone, PartialEq)]
399pub struct TextModifierElement {
400    text: Rc<AnnotatedString>,
401    style: TextStyle,
402    options: TextLayoutOptions,
403}
404
405impl TextModifierElement {
406    pub fn new(text: Rc<AnnotatedString>, style: TextStyle, options: TextLayoutOptions) -> Self {
407        Self {
408            text,
409            style,
410            options: options.normalized(),
411        }
412    }
413}
414
415fn hash_f32_bits<H: Hasher>(value: f32, state: &mut H) {
416    value.to_bits().hash(state);
417}
418
419fn hash_text_unit<H: Hasher>(unit: crate::text::TextUnit, state: &mut H) {
420    match unit {
421        crate::text::TextUnit::Unspecified => 0u8.hash(state),
422        crate::text::TextUnit::Sp(value) => {
423            1u8.hash(state);
424            hash_f32_bits(value, state);
425        }
426        crate::text::TextUnit::Em(value) => {
427            2u8.hash(state);
428            hash_f32_bits(value, state);
429        }
430    }
431}
432
433fn hash_color<H: Hasher>(color: crate::modifier::Color, state: &mut H) {
434    hash_f32_bits(color.0, state);
435    hash_f32_bits(color.1, state);
436    hash_f32_bits(color.2, state);
437    hash_f32_bits(color.3, state);
438}
439
440fn hash_option_color<H: Hasher>(color: &Option<crate::modifier::Color>, state: &mut H) {
441    match color {
442        Some(color) => {
443            1u8.hash(state);
444            hash_color(*color, state);
445        }
446        None => 0u8.hash(state),
447    }
448}
449
450fn hash_brush<H: Hasher>(brush: &crate::modifier::Brush, state: &mut H) {
451    match brush {
452        crate::modifier::Brush::Solid(color) => {
453            0u8.hash(state);
454            hash_color(*color, state);
455        }
456        crate::modifier::Brush::LinearGradient {
457            colors,
458            stops,
459            start,
460            end,
461            tile_mode,
462        } => {
463            1u8.hash(state);
464            colors.len().hash(state);
465            for color in colors {
466                hash_color(*color, state);
467            }
468            match stops {
469                Some(stops) => {
470                    1u8.hash(state);
471                    stops.len().hash(state);
472                    for stop in stops {
473                        hash_f32_bits(*stop, state);
474                    }
475                }
476                None => 0u8.hash(state),
477            }
478            hash_f32_bits(start.x, state);
479            hash_f32_bits(start.y, state);
480            hash_f32_bits(end.x, state);
481            hash_f32_bits(end.y, state);
482            tile_mode.hash(state);
483        }
484        crate::modifier::Brush::RadialGradient {
485            colors,
486            stops,
487            center,
488            radius,
489            tile_mode,
490        } => {
491            2u8.hash(state);
492            colors.len().hash(state);
493            for color in colors {
494                hash_color(*color, state);
495            }
496            match stops {
497                Some(stops) => {
498                    1u8.hash(state);
499                    stops.len().hash(state);
500                    for stop in stops {
501                        hash_f32_bits(*stop, state);
502                    }
503                }
504                None => 0u8.hash(state),
505            }
506            hash_f32_bits(center.x, state);
507            hash_f32_bits(center.y, state);
508            hash_f32_bits(*radius, state);
509            tile_mode.hash(state);
510        }
511        crate::modifier::Brush::SweepGradient {
512            colors,
513            stops,
514            center,
515        } => {
516            3u8.hash(state);
517            colors.len().hash(state);
518            for color in colors {
519                hash_color(*color, state);
520            }
521            match stops {
522                Some(stops) => {
523                    1u8.hash(state);
524                    stops.len().hash(state);
525                    for stop in stops {
526                        hash_f32_bits(*stop, state);
527                    }
528                }
529                None => 0u8.hash(state),
530            }
531            hash_f32_bits(center.x, state);
532            hash_f32_bits(center.y, state);
533        }
534    }
535}
536
537fn hash_option_brush<H: Hasher>(brush: &Option<crate::modifier::Brush>, state: &mut H) {
538    match brush {
539        Some(brush) => {
540            1u8.hash(state);
541            hash_brush(brush, state);
542        }
543        None => 0u8.hash(state),
544    }
545}
546
547fn hash_option_alpha<H: Hasher>(alpha: &Option<f32>, state: &mut H) {
548    match alpha {
549        Some(alpha) => {
550            1u8.hash(state);
551            hash_f32_bits(*alpha, state);
552        }
553        None => 0u8.hash(state),
554    }
555}
556
557fn hash_option_baseline_shift<H: Hasher>(
558    baseline_shift: &Option<crate::text::BaselineShift>,
559    state: &mut H,
560) {
561    match baseline_shift {
562        Some(shift) => {
563            1u8.hash(state);
564            hash_f32_bits(shift.0, state);
565        }
566        None => 0u8.hash(state),
567    }
568}
569
570fn hash_option_text_geometric_transform<H: Hasher>(
571    transform: &Option<crate::text::TextGeometricTransform>,
572    state: &mut H,
573) {
574    match transform {
575        Some(transform) => {
576            1u8.hash(state);
577            hash_f32_bits(transform.scale_x, state);
578            hash_f32_bits(transform.skew_x, state);
579        }
580        None => 0u8.hash(state),
581    }
582}
583
584fn hash_option_shadow<H: Hasher>(shadow: &Option<crate::text::Shadow>, state: &mut H) {
585    match shadow {
586        Some(shadow) => {
587            1u8.hash(state);
588            hash_color(shadow.color, state);
589            hash_f32_bits(shadow.offset.x, state);
590            hash_f32_bits(shadow.offset.y, state);
591            hash_f32_bits(shadow.blur_radius, state);
592        }
593        None => 0u8.hash(state),
594    }
595}
596
597fn hash_option_text_indent<H: Hasher>(indent: &Option<crate::text::TextIndent>, state: &mut H) {
598    match indent {
599        Some(indent) => {
600            1u8.hash(state);
601            hash_text_unit(indent.first_line, state);
602            hash_text_unit(indent.rest_line, state);
603        }
604        None => 0u8.hash(state),
605    }
606}
607
608fn hash_option_text_draw_style<H: Hasher>(
609    draw_style: &Option<crate::text::TextDrawStyle>,
610    state: &mut H,
611) {
612    match draw_style {
613        Some(crate::text::TextDrawStyle::Fill) => {
614            1u8.hash(state);
615            0u8.hash(state);
616        }
617        Some(crate::text::TextDrawStyle::Stroke { width }) => {
618            1u8.hash(state);
619            1u8.hash(state);
620            hash_f32_bits(*width, state);
621        }
622        None => 0u8.hash(state),
623    }
624}
625
626fn hash_text_style<H: Hasher>(style: &TextStyle, state: &mut H) {
627    let span = &style.span_style;
628    let paragraph = &style.paragraph_style;
629
630    hash_option_color(&span.color, state);
631    hash_option_brush(&span.brush, state);
632    hash_option_alpha(&span.alpha, state);
633    hash_text_unit(span.font_size, state);
634    span.font_weight.hash(state);
635    span.font_style.hash(state);
636    span.font_synthesis.hash(state);
637    span.font_family.hash(state);
638    span.font_feature_settings.hash(state);
639    hash_text_unit(span.letter_spacing, state);
640    hash_option_baseline_shift(&span.baseline_shift, state);
641    hash_option_text_geometric_transform(&span.text_geometric_transform, state);
642    span.locale_list.hash(state);
643    hash_option_color(&span.background, state);
644    span.text_decoration.hash(state);
645    hash_option_shadow(&span.shadow, state);
646    span.platform_style.hash(state);
647    hash_option_text_draw_style(&span.draw_style, state);
648
649    paragraph.text_align.hash(state);
650    paragraph.text_direction.hash(state);
651    hash_text_unit(paragraph.line_height, state);
652    hash_option_text_indent(&paragraph.text_indent, state);
653    paragraph.platform_style.hash(state);
654    paragraph.line_height_style.hash(state);
655    paragraph.line_break.hash(state);
656    paragraph.hyphens.hash(state);
657    paragraph.text_motion.hash(state);
658}
659
660impl Hash for TextModifierElement {
661    fn hash<H: Hasher>(&self, state: &mut H) {
662        self.text.text.hash(state);
663        hash_text_style(&self.style, state);
664        self.options.hash(state);
665    }
666}
667
668impl ModifierNodeElement for TextModifierElement {
669    type Node = TextModifierNode;
670
671    fn create(&self) -> Self::Node {
672        TextModifierNode::new(self.text.clone(), self.style.clone(), self.options)
673    }
674
675    fn update(&self, node: &mut Self::Node) {
676        let current = node.layout.as_ref();
677        if current.text != self.text
678            || current.style != self.style
679            || current.options != self.options
680        {
681            node.layout = Rc::new(TextPreparedLayoutOwner::new(
682                self.text.clone(),
683                self.style.clone(),
684                self.options,
685                current.node_id(),
686            ));
687            // Text/Style changed - need to invalidate layout, draw, and semantics
688            // Note: In the full implementation, we would call context.invalidate here
689            // but update() doesn't currently have access to context.
690            // The invalidation will happen on the next recomposition when the node
691            // is reconciled.
692        }
693    }
694
695    fn capabilities(&self) -> NodeCapabilities {
696        // Text nodes participate in layout, drawing, and semantics
697        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use crate::text::TextUnit;
705    use crate::text_layout_result::TextLayoutResult;
706    use cranpose_core::NodeId;
707    use cranpose_foundation::BasicModifierNodeContext;
708    use std::collections::hash_map::DefaultHasher;
709    use std::sync::mpsc;
710
711    fn hash_of(element: &TextModifierElement) -> u64 {
712        let mut hasher = DefaultHasher::new();
713        element.hash(&mut hasher);
714        hasher.finish()
715    }
716
717    struct RecordingPreparedLayoutMeasurer {
718        recorded: std::rc::Rc<std::cell::RefCell<Vec<Option<NodeId>>>>,
719    }
720
721    impl crate::text::TextMeasurer for RecordingPreparedLayoutMeasurer {
722        fn measure(
723            &self,
724            _text: &crate::text::AnnotatedString,
725            _style: &TextStyle,
726        ) -> crate::text::TextMetrics {
727            crate::text::TextMetrics {
728                width: 12.0,
729                height: 18.0,
730                line_height: 18.0,
731                line_count: 1,
732            }
733        }
734
735        fn prepare_with_options_for_node(
736            &self,
737            node_id: Option<NodeId>,
738            text: &crate::text::AnnotatedString,
739            _style: &TextStyle,
740            _options: TextLayoutOptions,
741            _max_width: Option<f32>,
742        ) -> crate::text::PreparedTextLayout {
743            self.recorded.borrow_mut().push(node_id);
744            crate::text::PreparedTextLayout {
745                text: text.clone(),
746                metrics: crate::text::TextMetrics {
747                    width: 12.0,
748                    height: 18.0,
749                    line_height: 18.0,
750                    line_count: 1,
751                },
752                did_overflow: false,
753            }
754        }
755
756        fn get_offset_for_position(
757            &self,
758            _text: &crate::text::AnnotatedString,
759            _style: &TextStyle,
760            _x: f32,
761            _y: f32,
762        ) -> usize {
763            0
764        }
765
766        fn get_cursor_x_for_offset(
767            &self,
768            _text: &crate::text::AnnotatedString,
769            _style: &TextStyle,
770            _offset: usize,
771        ) -> f32 {
772            0.0
773        }
774
775        fn layout(
776            &self,
777            _text: &crate::text::AnnotatedString,
778            _style: &TextStyle,
779        ) -> TextLayoutResult {
780            panic!("layout is not used in this test");
781        }
782    }
783
784    #[test]
785    fn hash_changes_when_style_changes() {
786        let text = Rc::new(AnnotatedString::from("Hello"));
787        let element_a = TextModifierElement::new(
788            text.clone(),
789            TextStyle::default(),
790            TextLayoutOptions::default(),
791        );
792        let style_b = TextStyle {
793            span_style: crate::text::SpanStyle {
794                font_size: TextUnit::Sp(18.0),
795                ..Default::default()
796            },
797            ..Default::default()
798        };
799        let element_b = TextModifierElement::new(text, style_b, TextLayoutOptions::default());
800
801        assert_ne!(element_a, element_b);
802        assert_ne!(hash_of(&element_a), hash_of(&element_b));
803    }
804
805    #[test]
806    fn hash_matches_for_equal_elements() {
807        let style = TextStyle {
808            span_style: crate::text::SpanStyle {
809                font_size: TextUnit::Sp(14.0),
810                letter_spacing: TextUnit::Em(0.1),
811                ..Default::default()
812            },
813            ..Default::default()
814        };
815        let options = TextLayoutOptions::default();
816        let text = Rc::new(AnnotatedString::from("Hash me"));
817        let element_a = TextModifierElement::new(text.clone(), style.clone(), options);
818        let element_b = TextModifierElement::new(text, style, options);
819
820        assert_eq!(element_a, element_b);
821        assert_eq!(hash_of(&element_a), hash_of(&element_b));
822    }
823
824    #[test]
825    fn measure_uses_attached_node_identity() {
826        let (tx, rx) = mpsc::channel();
827
828        std::thread::spawn(move || {
829            let recorded = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
830            crate::text::set_text_measurer(RecordingPreparedLayoutMeasurer {
831                recorded: recorded.clone(),
832            });
833
834            let mut node = TextModifierNode::new(
835                Rc::new(AnnotatedString::from("identity")),
836                TextStyle::default(),
837                TextLayoutOptions::default(),
838            );
839            let mut context = BasicModifierNodeContext::new();
840            context.set_node_id(Some(77));
841            node.on_attach(&mut context);
842
843            let size = node.measure_text_content(Some(96.0));
844            tx.send((recorded.borrow().clone(), size.width, size.height))
845                .expect("send measurement result");
846        });
847
848        let (recorded, width, height) = rx.recv().expect("receive measurement result");
849        assert_eq!(recorded, vec![Some(77)]);
850        assert_eq!(width, 12.0);
851        assert_eq!(height, 18.0);
852    }
853
854    #[test]
855    fn prepared_layout_cache_reuses_node_snapshot() {
856        let (tx, rx) = mpsc::channel();
857
858        std::thread::spawn(move || {
859            let recorded = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
860            crate::text::set_text_measurer(RecordingPreparedLayoutMeasurer {
861                recorded: recorded.clone(),
862            });
863
864            let mut node = TextModifierNode::new(
865                Rc::new(AnnotatedString::from("reuse")),
866                TextStyle::default(),
867                TextLayoutOptions::default(),
868            );
869            let mut context = BasicModifierNodeContext::new();
870            context.set_node_id(Some(88));
871            node.on_attach(&mut context);
872
873            let measured = node.measure_text_content(Some(120.0));
874            let prepared = node.prepared_layout_handle().prepare(Some(120.0));
875            tx.send((
876                recorded.borrow().clone(),
877                measured.width,
878                measured.height,
879                prepared.metrics.width,
880                prepared.metrics.height,
881            ))
882            .expect("send cached layout result");
883        });
884
885        let (recorded, measured_width, measured_height, prepared_width, prepared_height) =
886            rx.recv().expect("receive cached layout result");
887        assert_eq!(recorded, vec![Some(88)]);
888        assert_eq!(measured_width, prepared_width);
889        assert_eq!(measured_height, prepared_height);
890    }
891}