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: std::sync::Arc<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: std::sync::Arc<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, font_scale_fingerprint) = crate::render_state::text_layout_stamp();
115
116        {
117            let mut cache = self.cache.borrow_mut();
118            if let Some(index) = cache.iter().position(|entry| {
119                entry.widths.hold(normalized_max_width)
120                    && entry.text_generation == text_generation
121                    && entry.font_scale_fingerprint == font_scale_fingerprint
122            }) {
123                cache[..=index].rotate_right(1);
124                return read(&cache[0].layout);
125            }
126        }
127
128        let prepared = crate::text::prepare_text_layout_for_node(
129            self.node_id(),
130            &self.text,
131            &self.style,
132            self.options,
133            normalized_max_width,
134        );
135        let widths = crate::text::measure::PreparedWidths::of(
136            self.text.as_ref(),
137            self.options,
138            normalized_max_width,
139            &prepared,
140        );
141
142        let mut cache = self.cache.borrow_mut();
143        cache.insert(
144            0,
145            TextPreparedLayoutCacheEntry {
146                widths,
147                text_generation,
148                font_scale_fingerprint,
149                layout: prepared,
150            },
151        );
152        cache.truncate(PREPARED_LAYOUT_CACHE_CAPACITY);
153        read(&cache[0].layout)
154    }
155
156    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
157        self.with_prepared(max_width, |prepared| Size {
158            width: prepared.metrics.width,
159            height: prepared.metrics.height,
160        })
161    }
162
163    fn measure_layout(&self, max_width: Option<f32>) -> (Size, cranpose_ui_layout::AlignmentLines) {
164        self.measured_max_width.set(Some(max_width));
165        self.with_prepared(max_width, |prepared| {
166            (
167                Size::new(prepared.metrics.width, prepared.metrics.height),
168                prepared.alignment_lines,
169            )
170        })
171    }
172
173    fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
174        self.measured_max_width
175            .get()
176            .map(|max_width| self.with_prepared(max_width, Rc::clone))
177    }
178}
179
180impl TextPreparedLayoutHandle {
181    fn new(owner: Rc<TextPreparedLayoutOwner>) -> Self {
182        Self { owner }
183    }
184
185    pub(crate) fn annotated_text(&self) -> &Rc<AnnotatedString> {
186        &self.owner.text
187    }
188
189    pub(crate) fn style(&self) -> &TextStyle {
190        self.owner.style()
191    }
192
193    pub(crate) fn options(&self) -> TextLayoutOptions {
194        self.owner.options()
195    }
196
197    pub(crate) fn measured_layout(&self) -> Option<Rc<crate::text::PreparedTextLayout>> {
198        self.owner.measured_layout()
199    }
200}
201
202impl TextModifierNode {
203    /// A text node sized on `density`'s device pixel grid.
204    pub fn new(
205        text: Rc<AnnotatedString>,
206        style: TextStyle,
207        options: TextLayoutOptions,
208        density: Density,
209    ) -> Self {
210        Self {
211            layout: Rc::new(TextPreparedLayoutOwner::new(
212                text,
213                std::sync::Arc::new(style),
214                options,
215                None,
216                None,
217            )),
218            density,
219            state: NodeState::new(),
220        }
221    }
222
223    /// The text's size rounded up to whole device pixels, as Compose sizes a
224    /// text node (`TextLayoutResult.size` is the paragraph's size, `ceil`ed),
225    /// so whatever follows it starts on the pixel grid.
226    fn pixel_size(&self, size: Size) -> Size {
227        Size {
228            width: self.density.ceil(size.width),
229            height: self.density.ceil(size.height),
230        }
231    }
232
233    pub fn text(&self) -> &str {
234        self.layout.text()
235    }
236
237    pub fn annotated_text(&self) -> Rc<AnnotatedString> {
238        self.layout.annotated_text()
239    }
240
241    pub fn annotated_string(&self) -> AnnotatedString {
242        self.layout.annotated_string()
243    }
244
245    pub fn style(&self) -> &TextStyle {
246        self.layout.style()
247    }
248
249    pub fn options(&self) -> TextLayoutOptions {
250        self.layout.options()
251    }
252
253    fn measure_text_content(&self, max_width: Option<f32>) -> Size {
254        self.layout.measure_text_content(max_width)
255    }
256
257    pub(crate) fn prepared_layout_handle(&self) -> TextPreparedLayoutHandle {
258        TextPreparedLayoutHandle::new(self.layout.clone())
259    }
260}
261
262impl DelegatableNode for TextModifierNode {
263    fn node_state(&self) -> &NodeState {
264        &self.state
265    }
266}
267
268impl ModifierNode for TextModifierNode {
269    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
270        self.layout.set_node_id(context.node_id());
271        context.invalidate(InvalidationKind::Layout);
272        context.invalidate(InvalidationKind::Draw);
273        context.invalidate(InvalidationKind::Semantics);
274    }
275
276    fn on_detach(&mut self) {
277        self.layout.set_node_id(None);
278    }
279
280    fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
281        Some(self)
282    }
283
284    fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
285        Some(self)
286    }
287
288    fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
289        Some(self)
290    }
291
292    fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
293        Some(self)
294    }
295
296    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
297        Some(self)
298    }
299
300    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
301        Some(self)
302    }
303}
304
305impl LayoutModifierNode for TextModifierNode {
306    fn measure(
307        &self,
308        _context: &mut dyn ModifierNodeContext,
309        _measurable: &dyn Measurable,
310        constraints: Constraints,
311    ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
312        let max_width = constraints
313            .max_width
314            .is_finite()
315            .then_some(constraints.max_width);
316        let (text_size, alignment_lines) = self.layout.measure_layout(max_width);
317        let text_size = self.pixel_size(text_size);
318
319        let width = text_size
320            .width
321            .clamp(constraints.min_width, constraints.max_width);
322        let height = text_size
323            .height
324            .clamp(constraints.min_height, constraints.max_height);
325
326        cranpose_ui_layout::LayoutModifierMeasureResult::with_size(Size { width, height })
327            .with_alignment_lines(alignment_lines)
328    }
329
330    fn min_intrinsic_width(
331        &self,
332        _measurable: &dyn Measurable,
333        _height: f32,
334        _density: f32,
335    ) -> f32 {
336        self.pixel_size(self.measure_text_content(None)).width
337    }
338
339    fn max_intrinsic_width(
340        &self,
341        _measurable: &dyn Measurable,
342        _height: f32,
343        _density: f32,
344    ) -> f32 {
345        self.pixel_size(self.measure_text_content(None)).width
346    }
347
348    fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
349        self.pixel_size(
350            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
351        )
352        .height
353    }
354
355    fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
356        self.pixel_size(
357            self.measure_text_content(Some(width).filter(|w| w.is_finite() && *w > 0.0)),
358        )
359        .height
360    }
361}
362
363impl DrawModifierNode for TextModifierNode {}
364
365impl SemanticsNode for TextModifierNode {
366    fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
367        config
368            .content_description
369            .get_or_insert_with(|| self.text().to_string());
370    }
371
372    fn reach(&self) -> cranpose_foundation::SemanticsReach {
373        cranpose_foundation::SemanticsReach::default()
374    }
375}
376
377/// Element that creates and updates TextModifierNode instances.
378///
379/// This follows the modifier element pattern where the element is responsible for:
380/// - Creating new nodes (via `create`)
381/// - Updating existing nodes when properties change (via `update`)
382/// - Declaring capabilities (LAYOUT | DRAW | SEMANTICS)
383///
384/// Matches Jetpack Compose: `TextStringSimpleElement` in BasicText.kt
385#[derive(Debug, Clone, PartialEq)]
386pub struct TextModifierElement {
387    text: Rc<AnnotatedString>,
388    style: TextStyle,
389    options: TextLayoutOptions,
390    density: Density,
391}
392
393impl TextModifierElement {
394    /// A text laid out on `density`'s device pixel grid, the composition's
395    /// [`crate::density::density`] where a `Text` is composed.
396    pub fn new(
397        text: Rc<AnnotatedString>,
398        style: TextStyle,
399        options: TextLayoutOptions,
400        density: Density,
401    ) -> Self {
402        Self {
403            text,
404            style,
405            options: options.normalized(),
406            density,
407        }
408    }
409}
410
411impl Hash for TextModifierElement {
412    fn hash<H: Hasher>(&self, state: &mut H) {
413        self.text.render_hash().hash(state);
414        self.style.render_hash().hash(state);
415        self.options.hash(state);
416        self.density.density().to_bits().hash(state);
417    }
418}
419
420impl ModifierNodeElement for TextModifierElement {
421    type Node = TextModifierNode;
422
423    fn create(&self) -> Self::Node {
424        TextModifierNode::new(
425            self.text.clone(),
426            self.style.clone(),
427            self.options,
428            self.density,
429        )
430    }
431
432    fn update(&self, node: &mut Self::Node) {
433        node.density = self.density;
434        let current = node.layout.as_ref();
435        let same_style = *current.style == self.style;
436        if current.text != self.text || !same_style || current.options != self.options {
437            // A text that changed in the same style, as a ticker's does,
438            // keeps sharing the style it had.
439            let style = if same_style {
440                std::sync::Arc::clone(&current.style)
441            } else {
442                std::sync::Arc::new(self.style.clone())
443            };
444            let owner = TextPreparedLayoutOwner::new(
445                self.text.clone(),
446                style,
447                self.options,
448                current.node_id(),
449                current.measured_max_width.get(),
450            );
451            // A layout no handle still reads keeps its allocation.
452            match Rc::get_mut(&mut node.layout) {
453                Some(layout) => *layout = owner,
454                None => node.layout = Rc::new(owner),
455            }
456        }
457    }
458
459    fn capabilities(&self) -> NodeCapabilities {
460        NodeCapabilities::LAYOUT | NodeCapabilities::DRAW | NodeCapabilities::SEMANTICS
461    }
462}
463
464#[cfg(test)]
465#[path = "tests/text_modifier_node_tests.rs"]
466mod tests;