Skip to main content

cranpose_ui/widgets/
text.rs

1//! Text widget implementation
2//!
3//! This implementation follows Jetpack Compose's BasicText architecture where text content
4//! is implemented as a modifier node rather than as a measure policy. This properly separates
5//! concerns: MeasurePolicy handles child layout, while TextModifierNode handles text content
6//! measurement, drawing, and semantics.
7
8#![allow(non_snake_case)]
9
10use crate::composable;
11use crate::layout::policies::EmptyMeasurePolicy;
12use crate::modifier::Modifier;
13use crate::text::{TextLayoutOptions, TextOverflow, TextStyle};
14use crate::text_modifier_node::TextModifierElement;
15use crate::widgets::Layout;
16use cranpose_core::{MutableState, NodeId, State};
17use cranpose_foundation::modifier_element;
18use std::rc::Rc; // Added Rc import
19
20#[derive(Clone)]
21pub struct DynamicTextSource(Rc<dyn Fn() -> crate::text::AnnotatedString>);
22
23impl DynamicTextSource {
24    pub fn new<F>(resolver: F) -> Self
25    where
26        F: Fn() -> crate::text::AnnotatedString + 'static,
27    {
28        Self(Rc::new(resolver))
29    }
30
31    fn resolve(&self) -> crate::text::AnnotatedString {
32        (self.0)()
33    }
34}
35
36impl PartialEq for DynamicTextSource {
37    fn eq(&self, other: &Self) -> bool {
38        Rc::ptr_eq(&self.0, &other.0)
39    }
40}
41
42#[derive(Clone, PartialEq)]
43enum TextSource {
44    Static(crate::text::AnnotatedString),
45    Dynamic(DynamicTextSource),
46}
47
48impl TextSource {
49    fn resolve(&self) -> crate::text::AnnotatedString {
50        match self {
51            TextSource::Static(text) => text.clone(),
52            TextSource::Dynamic(dynamic) => dynamic.resolve(),
53        }
54    }
55}
56
57trait IntoTextSource {
58    fn into_text_source(self) -> TextSource;
59}
60
61impl IntoTextSource for String {
62    fn into_text_source(self) -> TextSource {
63        TextSource::Static(crate::text::AnnotatedString::from(self))
64    }
65}
66
67impl IntoTextSource for &str {
68    fn into_text_source(self) -> TextSource {
69        TextSource::Static(crate::text::AnnotatedString::from(self))
70    }
71}
72
73impl IntoTextSource for crate::text::AnnotatedString {
74    fn into_text_source(self) -> TextSource {
75        TextSource::Static(self)
76    }
77}
78
79impl<T> IntoTextSource for State<T>
80where
81    T: ToString + Clone + 'static,
82{
83    fn into_text_source(self) -> TextSource {
84        let state = self;
85        TextSource::Dynamic(DynamicTextSource::new(move || {
86            crate::text::AnnotatedString::from(state.value().to_string())
87        }))
88    }
89}
90
91impl<T> IntoTextSource for MutableState<T>
92where
93    T: ToString + Clone + 'static,
94{
95    fn into_text_source(self) -> TextSource {
96        let state = self;
97        TextSource::Dynamic(DynamicTextSource::new(move || {
98            crate::text::AnnotatedString::from(state.value().to_string())
99        }))
100    }
101}
102
103impl<F> IntoTextSource for F
104where
105    F: Fn() -> String + 'static,
106{
107    fn into_text_source(self) -> TextSource {
108        TextSource::Dynamic(DynamicTextSource::new(move || {
109            crate::text::AnnotatedString::from(self())
110        }))
111    }
112}
113
114impl IntoTextSource for DynamicTextSource {
115    fn into_text_source(self) -> TextSource {
116        TextSource::Dynamic(self)
117    }
118}
119
120/// High-level element that displays text.
121///
122/// # When to use
123/// Use this widget to display read-only text on the screen. For editable text,
124/// use [`BasicTextField`](crate::widgets::BasicTextField).
125///
126/// # Arguments
127///
128/// * `value` - The string to display. Can be a `&str`, `String`, or `State<String>`.
129/// * `modifier` - Modifiers to apply (e.g., padding, background, layout instructions).
130/// * `style` - Text styling (color, font size).
131///
132/// # Example
133///
134/// ```rust,ignore
135/// Text("Hello World", Modifier::padding(16.0), TextStyle::default());
136/// ```
137#[composable]
138pub fn BasicText<S>(
139    text: S,
140    modifier: Modifier,
141    style: TextStyle,
142    overflow: TextOverflow,
143    soft_wrap: bool,
144    max_lines: usize,
145    min_lines: usize,
146) -> NodeId
147where
148    S: IntoTextSource + Clone + PartialEq + 'static,
149{
150    let current = text.into_text_source().resolve();
151
152    let options = TextLayoutOptions {
153        overflow,
154        soft_wrap,
155        max_lines,
156        min_lines,
157    }
158    .normalized();
159
160    // Create a text modifier element that will add TextModifierNode to the chain
161    // TextModifierNode handles measurement, drawing, and semantics
162    let text_element = modifier_element(TextModifierElement::new(current, style, options));
163    let final_modifier = Modifier::from_parts(vec![text_element]);
164    let combined_modifier = modifier.then(final_modifier);
165
166    // text_modifier is inclusive of layout effects
167    Layout(
168        combined_modifier,
169        EmptyMeasurePolicy,
170        || {}, // No children
171    )
172}
173
174#[composable]
175pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
176where
177    S: IntoTextSource + Clone + PartialEq + 'static,
178{
179    BasicText(
180        value,
181        modifier,
182        style,
183        TextOverflow::Clip,
184        true,
185        usize::MAX,
186        1,
187    )
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::run_test_composition;
194
195    #[test]
196    fn basic_text_creates_node() {
197        let composition = run_test_composition(|| {
198            BasicText(
199                "Hello",
200                Modifier::empty(),
201                TextStyle::default(),
202                TextOverflow::Clip,
203                true,
204                usize::MAX,
205                1,
206            );
207        });
208
209        assert!(composition.root().is_some());
210    }
211}