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