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