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, TextOptions, 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;
19
20#[derive(Clone)]
21pub struct DynamicTextSource(Rc<dyn Fn() -> Rc<crate::text::AnnotatedString>>);
22
23impl DynamicTextSource {
24    pub fn new<F>(resolver: F) -> Self
25    where
26        F: Fn() -> Rc<crate::text::AnnotatedString> + 'static,
27    {
28        Self(Rc::new(resolver))
29    }
30
31    fn resolve(&self) -> Rc<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)]
43pub enum TextSource {
44    Static(Rc<crate::text::AnnotatedString>),
45    Dynamic(DynamicTextSource),
46}
47
48impl TextSource {
49    fn resolve(&self) -> Rc<crate::text::AnnotatedString> {
50        match self {
51            TextSource::Static(text) => text.clone(),
52            TextSource::Dynamic(dynamic) => dynamic.resolve(),
53        }
54    }
55}
56
57#[doc(hidden)]
58pub trait IntoTextSource {
59    fn into_text_source(self) -> TextSource;
60}
61
62impl IntoTextSource for String {
63    fn into_text_source(self) -> TextSource {
64        TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
65    }
66}
67
68impl IntoTextSource for &str {
69    fn into_text_source(self) -> TextSource {
70        TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
71    }
72}
73
74impl IntoTextSource for crate::text::AnnotatedString {
75    fn into_text_source(self) -> TextSource {
76        TextSource::Static(Rc::new(self))
77    }
78}
79
80impl IntoTextSource for Rc<crate::text::AnnotatedString> {
81    fn into_text_source(self) -> TextSource {
82        TextSource::Static(self)
83    }
84}
85
86impl<T> IntoTextSource for State<T>
87where
88    T: ToString + Clone + 'static,
89{
90    fn into_text_source(self) -> TextSource {
91        let state = self;
92        TextSource::Dynamic(DynamicTextSource::new(move || {
93            Rc::new(crate::text::AnnotatedString::from(
94                state.value().to_string(),
95            ))
96        }))
97    }
98}
99
100impl<T> IntoTextSource for MutableState<T>
101where
102    T: ToString + Clone + 'static,
103{
104    fn into_text_source(self) -> TextSource {
105        let state = self;
106        TextSource::Dynamic(DynamicTextSource::new(move || {
107            Rc::new(crate::text::AnnotatedString::from(
108                state.value().to_string(),
109            ))
110        }))
111    }
112}
113
114impl<F> IntoTextSource for F
115where
116    F: Fn() -> String + 'static,
117{
118    fn into_text_source(self) -> TextSource {
119        TextSource::Dynamic(DynamicTextSource::new(move || {
120            Rc::new(crate::text::AnnotatedString::from(self()))
121        }))
122    }
123}
124
125impl IntoTextSource for DynamicTextSource {
126    fn into_text_source(self) -> TextSource {
127        TextSource::Dynamic(self)
128    }
129}
130
131/// High-level element that displays text.
132///
133/// # When to use
134/// Use this widget to display read-only text on the screen. For editable text,
135/// use [`BasicTextField`](crate::widgets::BasicTextField).
136///
137/// # Arguments
138///
139/// * `value` - The string to display. Can be a `&str`, `String`, or `State<String>`.
140/// * `modifier` - Modifiers to apply (e.g., padding, background, layout instructions).
141/// * `style` - Text styling (color, font size).
142///
143/// # Example
144///
145/// ```rust,ignore
146/// Text("Hello World", Modifier::padding(16.0), TextStyle::default());
147/// ```
148fn compose_basic_text_group(
149    text: TextSource,
150    modifier: Modifier,
151    style: TextStyle,
152    options: TextLayoutOptions,
153) -> NodeId {
154    let current = text.resolve();
155
156    let options = options.normalized();
157
158    // Create a text modifier element that will add TextModifierNode to the chain
159    // TextModifierNode handles measurement, drawing, and semantics
160    let text_element = modifier_element(TextModifierElement::new(current, style, options));
161    let final_modifier = Modifier::from_parts(vec![text_element]);
162    let combined_modifier = modifier.then(final_modifier);
163
164    // text_modifier is inclusive of layout effects
165    Layout(
166        combined_modifier,
167        EmptyMeasurePolicy,
168        || {}, // No children
169    )
170}
171
172#[composable]
173pub fn BasicTextWithOptions<S>(
174    text: S,
175    modifier: Modifier,
176    style: TextStyle,
177    options: TextLayoutOptions,
178) -> NodeId
179where
180    S: IntoTextSource + Clone + PartialEq + 'static,
181{
182    compose_basic_text_group(text.into_text_source(), modifier, style, options)
183}
184
185#[composable]
186pub fn BasicText<S>(
187    text: S,
188    modifier: Modifier,
189    style: TextStyle,
190    overflow: TextOverflow,
191    soft_wrap: bool,
192    max_lines: usize,
193    min_lines: usize,
194) -> NodeId
195where
196    S: IntoTextSource + Clone + PartialEq + 'static,
197{
198    BasicTextWithOptions(
199        text,
200        modifier,
201        style,
202        TextLayoutOptions {
203            overflow,
204            soft_wrap,
205            max_lines,
206            min_lines,
207        },
208    )
209}
210
211#[composable]
212pub fn TextWithOptions<S>(
213    value: S,
214    modifier: Modifier,
215    style: TextStyle,
216    options: TextOptions,
217) -> NodeId
218where
219    S: IntoTextSource + Clone + PartialEq + 'static,
220{
221    BasicTextWithOptions(value, modifier, style, TextLayoutOptions::from(options))
222}
223
224#[composable]
225pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
226where
227    S: IntoTextSource + Clone + PartialEq + 'static,
228{
229    TextWithOptions(value, modifier, style, TextOptions::default())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::run_test_composition;
236    use cranpose_core::{location_key, Composition, MemoryApplier};
237    use std::cell::Cell;
238    use std::rc::Rc;
239
240    #[test]
241    fn basic_text_creates_node() {
242        let composition = run_test_composition(|| {
243            BasicTextWithOptions(
244                "Hello",
245                Modifier::empty(),
246                TextStyle::default(),
247                TextLayoutOptions::default(),
248            );
249        });
250
251        assert!(composition.root().is_some());
252    }
253
254    #[test]
255    fn text_with_options_creates_node() {
256        let composition = run_test_composition(|| {
257            TextWithOptions(
258                "Hello",
259                Modifier::empty(),
260                TextStyle::default(),
261                TextOptions {
262                    overflow: TextOverflow::Ellipsis,
263                    soft_wrap: false,
264                    max_lines: Some(1),
265                    ..TextOptions::default()
266                },
267            );
268        });
269
270        assert!(composition.root().is_some());
271    }
272
273    #[test]
274    fn basic_text_recomposes_when_dynamic_source_changes() {
275        let mut composition = Composition::new(MemoryApplier::new());
276        let runtime = composition.runtime_handle();
277        let state = MutableState::with_runtime("Hello".to_string(), runtime);
278        let resolutions = Rc::new(Cell::new(0));
279
280        composition
281            .render(location_key(file!(), line!(), column!()), {
282                let text_state = state;
283                let resolutions = Rc::clone(&resolutions);
284                move || {
285                    let text_state = text_state;
286                    let resolutions = Rc::clone(&resolutions);
287                    BasicText(
288                        DynamicTextSource::new(move || {
289                            resolutions.set(resolutions.get() + 1);
290                            Rc::new(crate::text::AnnotatedString::from(text_state.value()))
291                        }),
292                        Modifier::empty(),
293                        TextStyle::default(),
294                        TextOverflow::Clip,
295                        true,
296                        usize::MAX,
297                        1,
298                    );
299                }
300            })
301            .expect("initial text render");
302
303        assert_eq!(resolutions.get(), 1);
304
305        state.set_value("World".to_string());
306        composition
307            .process_invalid_scopes()
308            .expect("dynamic text recomposition");
309
310        assert_eq!(resolutions.get(), 2);
311    }
312}