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