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::{
14    density::Density,
15    text::{AnnotatedString, TextLayoutOptions, TextStyle},
16};
17
18/// Node that stores text content and handles measurement, drawing, and semantics.
19///
20/// This node implements three capabilities:
21/// - **Layout**: Measures text and returns appropriate size
22/// - **Draw**: Supplies prepared text state consumed by scene building
23/// - **Semantics**: Provides text content for accessibility
24///
25/// Matches Jetpack Compose: `TextStringSimpleNode` in
26/// `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt`
27#[derive(Debug)]
28pub struct TextModifierNode {
29    layout: Rc<TextPreparedLayoutOwner>,
30    density: Density,
31    state: NodeState,
32}
33
34const PREPARED_LAYOUT_CACHE_CAPACITY: usize = 4;
35
36#[derive(Clone, Debug)]
37struct TextPreparedLayoutCacheEntry {
38    widths: crate::text::measure::PreparedWidths,
39    text_generation: u64,
40    font_scale_fingerprint: u32,
41    layout: Rc<crate::text::PreparedTextLayout>,
42}
43
44#[derive(Debug)]
45struct TextPreparedLayoutOwner {
46    text: Rc<AnnotatedString>,
47    style: TextStyle,
48    options: TextLayoutOptions,
49    node_id: Cell<Option<cranpose_core::NodeId>>,
50    measured_max_width: Cell<Option<Option<f32>>>,
51    cache: RefCell<Vec<TextPreparedLayoutCacheEntry>>,
52}
53
54#[derive(Clone, Debug)]
55pub(crate) struct TextPreparedLayoutHandle {
56    owner: Rc<TextPreparedLayoutOwner>,
57}
58
59impl TextPreparedLayoutOwner {
60    fn new(
61        text: Rc<AnnotatedString>,
62        style: TextStyle,
63        options: TextLayoutOptions,
64        node_id: Option<cranpose_core::NodeId>,
65        measured_max_width: Option<Option<f32>>,
66    ) -> Self {
67        Self {
68            text,
69            style,
70            options: options.normalized(),
71            node_id: Cell::new(node_id),
72            measured_max_width: Cell::new(measured_max_width),
73            cache: RefCell::new(Vec::new()),
74        }
75    }
76
77    fn text(&self) -> &str {
78        self.text.text.as_str()
79    }
80
81    fn annotated_text(&self) -> Rc<AnnotatedString> {
82        self.text.clone()
83    }
84
85    fn annotated_string(&self) -> AnnotatedString {
86        (*self.text).clone()
87    }
88
89    fn style(&self) -> &TextStyle {
90        &self.style
91    }
92
93    fn options(&self) -> TextLayoutOptions {
94        self.options
95    }
96
97    fn node_id(&self) -> Option<cranpose_core::NodeId> {
98        self.node_id.get()
99    }
100
101    fn set_node_id(&self, node_id: Option<cranpose_core::NodeId>) {
102        if self.node_id.replace(node_id) != node_id {
103            self.cache.borrow_mut().clear();
104        }
105    }
106
107    /// The layout at `max_width`, shared with the cache: measuring reads only
108    /// its size, and copying the whole layout on every measure cost a grid of
109    /// wrapping labels more than wrapping them.
110    fn prepare(&self, max_width: Option<f32>) -> Rc<crate::text::PreparedTextLayout> {
111        let normalized_max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
112        let text_generation = crate::text::measure::current_text_generation();
113        let font_scale_fingerprint = crate::current_font_scale_curve().fingerprint();
114
115        {
116            let mut cache = self.cache.borrow_mut();
117            if let Some(index) = cache.iter().position(|entry| {
118                entry.widths.hold(normalized_max_width)
119                    && entry.text_generation == text_generation
120                    && entry.font_scale_fingerprint == font_scale_fingerprint
121            }) {
122                let prepared = Rc::clone(&cache[index].layout);
123                cache[..=index].rotate_right(1);
124                return prepared;
125            }
126        }
127
128        let prepared = crate::text::prepare_text_layout_for_node(
129            self.node_id(),
130            self.text.as_ref(),
131            &self.style,
132            self.options,
133            normalized_max_width,
134        );
135
136        let mut cache = self.cache.borrow_mut();
137        cache.insert(
138            0,
139            TextPreparedLayoutCacheEntry {
140                widths: crate::text::measure::PreparedWidths::of(
141                    self.text.as_ref(),
142                    self.options,
143                    normalized_max_width,
144                    &prepared,
145                ),
146                text_generation,
147                font_scale_fingerprint,
148                layout: Rc::clone(&prepared),
149            },
150        );
151        cache.truncate(PREPARED_LAYOUT_CACHE_CAPACITY);
152        prepared
153    }
154
155    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
156        let prepared = self.prepare(max_width);
157        Size {
158            width: prepared.metrics.width,
159            height: prepared.metrics.height,
160        }
161    }
162
163    fn measure_layout(&self, max_width: Option<f32>) -> Size {
164        self.measured_max_width.set(Some(max_width));
165        self.measure_text_content(max_width)
166    }
167
168    fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
169        self.measured_max_width
170            .get()
171            .map(|max_width| self.prepare(max_width))
172    }
173}
174
175impl TextPreparedLayoutHandle {
176    fn new(owner: Rc<TextPreparedLayoutOwner>) -> Self {
177        Self { owner }
178    }
179
180    pub(crate) fn annotated_text(&self) -> &Rc<AnnotatedString> {
181        &self.owner.text
182    }
183
184    pub(crate) fn style(&self) -> &TextStyle {
185        self.owner.style()
186    }
187
188    pub(crate) fn options(&self) -> TextLayoutOptions {
189        self.owner.options()
190    }
191
192    pub(crate) fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
193        self.owner.measured_layout()
194    }
195}
196
197impl TextModifierNode {
198    /// A text node sized on `density`'s device pixel grid.
199    pub fn new(
200        text: Rc<AnnotatedString>,
201        style: TextStyle,
202        options: TextLayoutOptions,
203        density: Density,
204    ) -> Self {
205        Self {
206            layout: Rc::new(TextPreparedLayoutOwner::new(
207                text, style, options, None, None,
208            )),
209            density,
210            state: NodeState::new(),
211        }
212    }
213
214    /// The text's size rounded up to whole device pixels, as Compose sizes a
215    /// text node (`TextLayoutResult.size` is the paragraph's size, `ceil`ed),
216    /// so whatever follows it starts on the pixel grid.
217    fn pixel_size(&self, size: Size) -> Size {
218        Size {
219            width: self.density.ceil(size.width),
220            height: self.density.ceil(size.height),
221        }
222    }
223
224    pub fn text(&self) -> &str {
225        self.layout.text()
226    }
227
228    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
229        self.layout.annotated_text()
230    }
231
232    pub fn annotated_string(&self) -> AnnotatedString {
233        self.layout.annotated_string()
234    }
235
236    pub fn style(&self) -> &TextStyle {
237        self.layout.style()
238    }
239
240    pub fn options(&self) -> TextLayoutOptions {
241        self.layout.options()
242    }
243
244    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
245        self.layout.measure_text_content(max_width)
246    }
247
248    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
249        TextPreparedLayoutHandle::new(self.layout.clone())
250    }
251}
252
253impl DelegatableNode for TextModifierNode {
254    fn node_state(&self) -> &NodeState {
255        &self.state
256    }
257}
258
259impl ModifierNode for TextModifierNode {
260    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
261        self.layout.set_node_id(context.node_id());
262        context.invalidate(InvalidationKind::Layout);
263        context.invalidate(InvalidationKind::Draw);
264        context.invalidate(InvalidationKind::Semantics);
265    }
266
267    fn on_detach(&mut self) {
268        self.layout.set_node_id(None);
269    }
270
271    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
272        Some(self)
273    }
274
275    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
276        Some(self)
277    }
278
279    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
280        Some(self)
281    }
282
283    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
284        Some(self)
285    }
286
287    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
288        Some(self)
289    }
290
291    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
292        Some(self)
293    }
294}
295
296impl LayoutModifierNode for TextModifierNode {
297    fn measure(
298        &self,
299        _context: &mut dyn ModifierNodeContext,
300        _measurable: &dyn Measurable,
301        constraints: Constraints,
302    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
303        let max_width = constraints
304            .max_width
305            .is_finite()
306            .then_some(constraints.max_width);
307        let text_size = self.pixel_size(self.layout.measure_layout(max_width));
308
309        let width = text_size
310            .width
311            .clamp(constraints.min_width, constraints.max_width);
312        let height = text_size
313            .height
314            .clamp(constraints.min_height, constraints.max_height);
315
316        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
317    }
318
319    fn min_intrinsic_width(
320        &self,
321        _measurable: &dyn Measurable,
322        _height: f32,
323        _density: f32,
324    ) -> f32 {
325        self.pixel_size(self.measure_text_content(None)).width
326    }
327
328    fn max_intrinsic_width(
329        &self,
330        _measurable: &dyn Measurable,
331        _height: f32,
332        _density: f32,
333    ) -> f32 {
334        self.pixel_size(self.measure_text_content(None)).width
335    }
336
337    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
338        self.pixel_size(
339            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
340        )
341        .height
342    }
343
344    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
345        self.pixel_size(
346            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
347        )
348        .height
349    }
350}
351
352impl DrawModifierNode for TextModifierNode {}
353
354impl SemanticsNode for TextModifierNode {
355    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
356        config.content_description = Some(self.text().to_string());
357    }
358
359    fn reach(&self) -> cranpose_foundation::SemanticsReach {
360        cranpose_foundation::SemanticsReach::default()
361    }
362}
363
364/// Element that creates and updates TextModifierNode instances.
365///
366/// This follows the modifier element pattern where the element is responsible for:
367/// - Creating new nodes (via `create`)
368/// - Updating existing nodes when properties change (via `update`)
369/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
370///
371/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
372#[derive(Debug, Clone, PartialEq)]
373pub struct TextModifierElement {
374    text: Rc<AnnotatedString>,
375    style: TextStyle,
376    options: TextLayoutOptions,
377    density: Density,
378}
379
380impl TextModifierElement {
381    /// A text laid out on `density`'s device pixel grid, the composition's
382    /// [`crate::density::density`] where a `Text` is composed.
383    pub fn new(
384        text: Rc<AnnotatedString>,
385        style: TextStyle,
386        options: TextLayoutOptions,
387        density: Density,
388    ) -> Self {
389        Self {
390            text,
391            style,
392            options: options.normalized(),
393            density,
394        }
395    }
396}
397
398impl Hash for TextModifierElement {
399    fn hash<H: Hasher>(&self, state: &mut H) {
400        self.text.render_hash().hash(state);
401        self.style.render_hash().hash(state);
402        self.options.hash(state);
403        self.density.density().to_bits().hash(state);
404    }
405}
406
407impl ModifierNodeElement for TextModifierElement {
408    type Node = TextModifierNode;
409
410    fn create(&self) -> Self::Node {
411        TextModifierNode::new(
412            self.text.clone(),
413            self.style.clone(),
414            self.options,
415            self.density,
416        )
417    }
418
419    fn update(&self, node: &mut Self::Node) {
420        node.density = self.density;
421        let current = node.layout.as_ref();
422        if current.text != self.text
423            || current.style != self.style
424            || current.options != self.options
425        {
426            node.layout = Rc::new(TextPreparedLayoutOwner::new(
427                self.text.clone(),
428                self.style.clone(),
429                self.options,
430                current.node_id(),
431                current.measured_max_width.get(),
432            ));
433        }
434    }
435
436    fn capabilities(&self) -> NodeCapabilities {
437        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
438    }
439}
440
441#[cfg(test)]
442#[path = "tests/text_modifier_node_tests.rs"]
443mod tests;