Skip to main content

cranpose_ui/
text_modifier_node.rs

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