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