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, InvalidationKind, LayoutModifierNode,
9    Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement, NodeCapabilities,
10    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    widths: crate::text::measure::PreparedWidths,
35    text_generation: u64,
36    font_scale_fingerprint: u32,
37    layout: Rc<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    measured_max_width: Cell<Option<Option<f32>>>,
47    cache: RefCell<Vec<TextPreparedLayoutCacheEntry>>,
48}
49
50#[derive(Clone, Debug)]
51pub(crate) struct TextPreparedLayoutHandle {
52    owner: Rc<TextPreparedLayoutOwner>,
53}
54
55impl TextPreparedLayoutOwner {
56    fn new(
57        text: Rc<AnnotatedString>,
58        style: TextStyle,
59        options: TextLayoutOptions,
60        node_id: Option<cranpose_core::NodeId>,
61        measured_max_width: Option<Option<f32>>,
62    ) -> Self {
63        Self {
64            text,
65            style,
66            options: options.normalized(),
67            node_id: Cell::new(node_id),
68            measured_max_width: Cell::new(measured_max_width),
69            cache: RefCell::new(Vec::new()),
70        }
71    }
72
73    fn text(&self) -> &str {
74        self.text.text.as_str()
75    }
76
77    fn annotated_text(&self) -> Rc<AnnotatedString> {
78        self.text.clone()
79    }
80
81    fn annotated_string(&self) -> AnnotatedString {
82        (*self.text).clone()
83    }
84
85    fn style(&self) -> &TextStyle {
86        &self.style
87    }
88
89    fn options(&self) -> TextLayoutOptions {
90        self.options
91    }
92
93    fn node_id(&self) -> Option<cranpose_core::NodeId> {
94        self.node_id.get()
95    }
96
97    fn set_node_id(&self, node_id: Option<cranpose_core::NodeId>) {
98        if self.node_id.replace(node_id) != node_id {
99            self.cache.borrow_mut().clear();
100        }
101    }
102
103    /// The layout at `max_width`, shared with the cache: measuring reads only
104    /// its size, and copying the whole layout on every measure cost a grid of
105    /// wrapping labels more than wrapping them.
106    fn prepare(&self, max_width: Option<f32>) -> Rc<crate::text::PreparedTextLayout> {
107        let normalized_max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
108        let text_generation = crate::text::measure::current_text_generation();
109        let font_scale_fingerprint = crate::current_font_scale_curve().fingerprint();
110
111        {
112            let mut cache = self.cache.borrow_mut();
113            if let Some(index) = cache.iter().position(|entry| {
114                entry.widths.hold(normalized_max_width)
115                    && entry.text_generation == text_generation
116                    && entry.font_scale_fingerprint == font_scale_fingerprint
117            }) {
118                let prepared = Rc::clone(&cache[index].layout);
119                cache[..=index].rotate_right(1);
120                return prepared;
121            }
122        }
123
124        let prepared = crate::text::prepare_text_layout_for_node(
125            self.node_id(),
126            self.text.as_ref(),
127            &self.style,
128            self.options,
129            normalized_max_width,
130        );
131
132        let mut cache = self.cache.borrow_mut();
133        cache.insert(
134            0,
135            TextPreparedLayoutCacheEntry {
136                widths: crate::text::measure::PreparedWidths::of(
137                    self.text.as_ref(),
138                    self.options,
139                    normalized_max_width,
140                    &prepared,
141                ),
142                text_generation,
143                font_scale_fingerprint,
144                layout: Rc::clone(&prepared),
145            },
146        );
147        cache.truncate(PREPARED_LAYOUT_CACHE_CAPACITY);
148        prepared
149    }
150
151    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
152        let prepared = self.prepare(max_width);
153        Size {
154            width: prepared.metrics.width,
155            height: prepared.metrics.height,
156        }
157    }
158
159    fn measure_layout(&self, max_width: Option<f32>) -> Size {
160        self.measured_max_width.set(Some(max_width));
161        self.measure_text_content(max_width)
162    }
163
164    fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
165        self.measured_max_width
166            .get()
167            .map(|max_width| self.prepare(max_width))
168    }
169}
170
171impl TextPreparedLayoutHandle {
172    fn new(owner: Rc<TextPreparedLayoutOwner>) -> Self {
173        Self { owner }
174    }
175
176    pub(crate) fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
177        self.owner.measured_layout()
178    }
179}
180
181impl TextModifierNode {
182    pub fn new(text: Rc<AnnotatedString>, style: TextStyle, options: TextLayoutOptions) -> Self {
183        Self {
184            layout: Rc::new(TextPreparedLayoutOwner::new(
185                text, style, options, None, None,
186            )),
187            state: NodeState::new(),
188        }
189    }
190
191    pub fn text(&self) -> &str {
192        self.layout.text()
193    }
194
195    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
196        self.layout.annotated_text()
197    }
198
199    pub fn annotated_string(&self) -> AnnotatedString {
200        self.layout.annotated_string()
201    }
202
203    pub fn style(&self) -> &TextStyle {
204        self.layout.style()
205    }
206
207    pub fn options(&self) -> TextLayoutOptions {
208        self.layout.options()
209    }
210
211    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
212        self.layout.measure_text_content(max_width)
213    }
214
215    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
216        TextPreparedLayoutHandle::new(self.layout.clone())
217    }
218}
219
220impl DelegatableNode for TextModifierNode {
221    fn node_state(&self) -> &NodeState {
222        &self.state
223    }
224}
225
226impl ModifierNode for TextModifierNode {
227    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
228        self.layout.set_node_id(context.node_id());
229        context.invalidate(InvalidationKind::Layout);
230        context.invalidate(InvalidationKind::Draw);
231        context.invalidate(InvalidationKind::Semantics);
232    }
233
234    fn on_detach(&mut self) {
235        self.layout.set_node_id(None);
236    }
237
238    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
239        Some(self)
240    }
241
242    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
243        Some(self)
244    }
245
246    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
247        Some(self)
248    }
249
250    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
251        Some(self)
252    }
253
254    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
255        Some(self)
256    }
257
258    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
259        Some(self)
260    }
261}
262
263impl LayoutModifierNode for TextModifierNode {
264    fn measure(
265        &self,
266        _context: &mut dyn ModifierNodeContext,
267        _measurable: &dyn Measurable,
268        constraints: Constraints,
269    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
270        let max_width = constraints
271            .max_width
272            .is_finite()
273            .then_some(constraints.max_width);
274        let text_size = self.layout.measure_layout(max_width);
275
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        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
284    }
285
286    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
287        self.measure_text_content(None).width
288    }
289
290    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
291        self.measure_text_content(None).width
292    }
293
294    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
295        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
296            .height
297    }
298
299    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
300        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
301            .height
302    }
303}
304
305impl DrawModifierNode for TextModifierNode {}
306
307impl SemanticsNode for TextModifierNode {
308    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
309        config.content_description = Some(self.text().to_string());
310    }
311
312    fn reach(&self) -> cranpose_foundation::SemanticsReach {
313        cranpose_foundation::SemanticsReach::default()
314    }
315}
316
317/// Element that creates and updates TextModifierNode instances.
318///
319/// This follows the modifier element pattern where the element is responsible for:
320/// - Creating new nodes (via `create`)
321/// - Updating existing nodes when properties change (via `update`)
322/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
323///
324/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
325#[derive(Debug, Clone, PartialEq)]
326pub struct TextModifierElement {
327    text: Rc<AnnotatedString>,
328    style: TextStyle,
329    options: TextLayoutOptions,
330}
331
332impl TextModifierElement {
333    pub fn new(text: Rc<AnnotatedString>, style: TextStyle, options: TextLayoutOptions) -> Self {
334        Self {
335            text,
336            style,
337            options: options.normalized(),
338        }
339    }
340}
341
342impl Hash for TextModifierElement {
343    fn hash<H: Hasher>(&self, state: &mut H) {
344        self.text.render_hash().hash(state);
345        self.style.render_hash().hash(state);
346        self.options.hash(state);
347    }
348}
349
350impl ModifierNodeElement for TextModifierElement {
351    type Node = TextModifierNode;
352
353    fn create(&self) -> Self::Node {
354        TextModifierNode::new(self.text.clone(), self.style.clone(), self.options)
355    }
356
357    fn update(&self, node: &mut Self::Node) {
358        let current = node.layout.as_ref();
359        if current.text != self.text
360            || current.style != self.style
361            || current.options != self.options
362        {
363            node.layout = Rc::new(TextPreparedLayoutOwner::new(
364                self.text.clone(),
365                self.style.clone(),
366                self.options,
367                current.node_id(),
368                current.measured_max_width.get(),
369            ));
370        }
371    }
372
373    fn capabilities(&self) -> NodeCapabilities {
374        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
375    }
376}
377
378#[cfg(test)]
379#[path = "tests/text_modifier_node_tests.rs"]
380mod tests;