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    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    fn prepare(&self, max_width: Option<f32>) -> crate::text::PreparedTextLayout {
104        let normalized_max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
105        let max_width_bits = normalized_max_width.map(f32::to_bits);
106        let text_generation = crate::text::measure::current_text_generation();
107        let font_scale_fingerprint = crate::current_font_scale_curve().fingerprint();
108
109        {
110            let mut cache = self.cache.borrow_mut();
111            if let Some(index) = cache.iter().position(|entry| {
112                entry.max_width_bits == max_width_bits
113                    && entry.text_generation == text_generation
114                    && entry.font_scale_fingerprint == font_scale_fingerprint
115            }) {
116                let entry = cache.remove(index);
117                let prepared = entry.layout.clone();
118                cache.insert(0, entry);
119                return prepared;
120            }
121        }
122
123        let prepared = crate::text::prepare_text_layout_for_node(
124            self.node_id(),
125            self.text.as_ref(),
126            &self.style,
127            self.options,
128            normalized_max_width,
129        );
130
131        let mut cache = self.cache.borrow_mut();
132        cache.insert(
133            0,
134            TextPreparedLayoutCacheEntry {
135                max_width_bits,
136                text_generation,
137                font_scale_fingerprint,
138                layout: prepared.clone(),
139            },
140        );
141        cache.truncate(PREPARED_LAYOUT_CACHE_CAPACITY);
142        prepared
143    }
144
145    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
146        let prepared = self.prepare(max_width);
147        Size {
148            width: prepared.metrics.width,
149            height: prepared.metrics.height,
150        }
151    }
152
153    fn measure_layout(&self, max_width: Option<f32>) -> Size {
154        self.measured_max_width.set(Some(max_width));
155        self.measure_text_content(max_width)
156    }
157
158    fn measured_layout(&self) -> Option<crate::text::PreparedTextLayout> {
159        self.measured_max_width
160            .get()
161            .map(|max_width| self.prepare(max_width))
162    }
163}
164
165impl TextPreparedLayoutHandle {
166    fn new(owner: Rc<TextPreparedLayoutOwner>) -> Self {
167        Self { owner }
168    }
169
170    pub(crate) fn measured_layout(&self) -> Option<crate::text::PreparedTextLayout> {
171        self.owner.measured_layout()
172    }
173}
174
175impl TextModifierNode {
176    pub fn new(text: Rc<AnnotatedString>, style: TextStyle, options: TextLayoutOptions) -> Self {
177        Self {
178            layout: Rc::new(TextPreparedLayoutOwner::new(
179                text, style, options, None, None,
180            )),
181            state: NodeState::new(),
182        }
183    }
184
185    pub fn text(&self) -> &str {
186        self.layout.text()
187    }
188
189    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
190        self.layout.annotated_text()
191    }
192
193    pub fn annotated_string(&self) -> AnnotatedString {
194        self.layout.annotated_string()
195    }
196
197    pub fn style(&self) -> &TextStyle {
198        self.layout.style()
199    }
200
201    pub fn options(&self) -> TextLayoutOptions {
202        self.layout.options()
203    }
204
205    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
206        self.layout.measure_text_content(max_width)
207    }
208
209    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
210        TextPreparedLayoutHandle::new(self.layout.clone())
211    }
212}
213
214impl DelegatableNode for TextModifierNode {
215    fn node_state(&self) -> &NodeState {
216        &self.state
217    }
218}
219
220impl ModifierNode for TextModifierNode {
221    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
222        self.layout.set_node_id(context.node_id());
223        context.invalidate(InvalidationKind::Layout);
224        context.invalidate(InvalidationKind::Draw);
225        context.invalidate(InvalidationKind::Semantics);
226    }
227
228    fn on_detach(&mut self) {
229        self.layout.set_node_id(None);
230    }
231
232    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
233        Some(self)
234    }
235
236    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
237        Some(self)
238    }
239
240    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
241        Some(self)
242    }
243
244    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
245        Some(self)
246    }
247
248    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
249        Some(self)
250    }
251
252    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
253        Some(self)
254    }
255}
256
257impl LayoutModifierNode for TextModifierNode {
258    fn measure(
259        &self,
260        _context: &mut dyn ModifierNodeContext,
261        _measurable: &dyn Measurable,
262        constraints: Constraints,
263    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
264        let max_width = constraints
265            .max_width
266            .is_finite()
267            .then_some(constraints.max_width);
268        let text_size = self.layout.measure_layout(max_width);
269
270        let width = text_size
271            .width
272            .clamp(constraints.min_width, constraints.max_width);
273        let height = text_size
274            .height
275            .clamp(constraints.min_height, constraints.max_height);
276
277        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
278    }
279
280    fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
281        self.measure_text_content(None).width
282    }
283
284    fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
285        self.measure_text_content(None).width
286    }
287
288    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
289        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
290            .height
291    }
292
293    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, _width: f32) -> f32 {
294        self.measure_text_content(Some(_width).filter(|w| w.is_finite() && *w > 0.0))
295            .height
296    }
297}
298
299impl DrawModifierNode for TextModifierNode {
300    fn draw(&self, _draw_scope: &mut dyn DrawScope) {}
301}
302
303impl SemanticsNode for TextModifierNode {
304    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
305        config.content_description = Some(self.text().to_string());
306    }
307}
308
309/// Element that creates and updates TextModifierNode instances.
310///
311/// This follows the modifier element pattern where the element is responsible for:
312/// - Creating new nodes (via `create`)
313/// - Updating existing nodes when properties change (via `update`)
314/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
315///
316/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
317#[derive(Debug, Clone, PartialEq)]
318pub struct TextModifierElement {
319    text: Rc<AnnotatedString>,
320    style: TextStyle,
321    options: TextLayoutOptions,
322}
323
324impl TextModifierElement {
325    pub fn new(text: Rc<AnnotatedString>, style: TextStyle, options: TextLayoutOptions) -> Self {
326        Self {
327            text,
328            style,
329            options: options.normalized(),
330        }
331    }
332}
333
334impl Hash for TextModifierElement {
335    fn hash<H: Hasher>(&self, state: &mut H) {
336        self.text.render_hash().hash(state);
337        self.style.render_hash().hash(state);
338        self.options.hash(state);
339    }
340}
341
342impl ModifierNodeElement for TextModifierElement {
343    type Node = TextModifierNode;
344
345    fn create(&self) -> Self::Node {
346        TextModifierNode::new(self.text.clone(), self.style.clone(), self.options)
347    }
348
349    fn update(&self, node: &mut Self::Node) {
350        let current = node.layout.as_ref();
351        if current.text != self.text
352            || current.style != self.style
353            || current.options != self.options
354        {
355            node.layout = Rc::new(TextPreparedLayoutOwner::new(
356                self.text.clone(),
357                self.style.clone(),
358                self.options,
359                current.node_id(),
360                current.measured_max_width.get(),
361            ));
362        }
363    }
364
365    fn capabilities(&self) -> NodeCapabilities {
366        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
367    }
368}
369
370#[cfg(test)]
371#[path = "tests/text_modifier_node_tests.rs"]
372mod tests;