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