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