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;
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)]
43enum 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
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(Rc::new(crate::text::AnnotatedString::from(self)))
64    }
65}
66
67impl IntoTextSource for &str {
68    fn into_text_source(self) -> TextSource {
69        TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
70    }
71}
72
73impl IntoTextSource for crate::text::AnnotatedString {
74    fn into_text_source(self) -> TextSource {
75        TextSource::Static(Rc::new(self))
76    }
77}
78
79impl IntoTextSource for Rc<crate::text::AnnotatedString> {
80    fn into_text_source(self) -> TextSource {
81        TextSource::Static(self)
82    }
83}
84
85impl<T> IntoTextSource for State<T>
86where
87    T: ToString + Clone + 'static,
88{
89    fn into_text_source(self) -> TextSource {
90        let state = self;
91        TextSource::Dynamic(DynamicTextSource::new(move || {
92            Rc::new(crate::text::AnnotatedString::from(
93                state.value().to_string(),
94            ))
95        }))
96    }
97}
98
99impl<T> IntoTextSource for MutableState<T>
100where
101    T: ToString + Clone + 'static,
102{
103    fn into_text_source(self) -> TextSource {
104        let state = self;
105        TextSource::Dynamic(DynamicTextSource::new(move || {
106            Rc::new(crate::text::AnnotatedString::from(
107                state.value().to_string(),
108            ))
109        }))
110    }
111}
112
113impl<F> IntoTextSource for F
114where
115    F: Fn() -> String + 'static,
116{
117    fn into_text_source(self) -> TextSource {
118        TextSource::Dynamic(DynamicTextSource::new(move || {
119            Rc::new(crate::text::AnnotatedString::from(self()))
120        }))
121    }
122}
123
124impl IntoTextSource for DynamicTextSource {
125    fn into_text_source(self) -> TextSource {
126        TextSource::Dynamic(self)
127    }
128}
129
130/// High-level element that displays text.
131///
132/// # When to use
133/// Use this widget to display read-only text on the screen. For editable text,
134/// use [`BasicTextField`](crate::widgets::BasicTextField).
135///
136/// # Arguments
137///
138/// * `value` - The string to display. Can be a `&str`, `String`, or `State<String>`.
139/// * `modifier` - Modifiers to apply (e.g., padding, background, layout instructions).
140/// * `style` - Text styling (color, font size).
141///
142/// # Example
143///
144/// ```rust,ignore
145/// Text("Hello World", Modifier::padding(16.0), TextStyle::default());
146/// ```
147#[composable]
148pub fn BasicText<S>(
149    text: S,
150    modifier: Modifier,
151    style: TextStyle,
152    overflow: TextOverflow,
153    soft_wrap: bool,
154    max_lines: usize,
155    min_lines: usize,
156) -> NodeId
157where
158    S: IntoTextSource + Clone + PartialEq + 'static,
159{
160    let current = text.into_text_source().resolve();
161
162    let options = TextLayoutOptions {
163        overflow,
164        soft_wrap,
165        max_lines,
166        min_lines,
167    }
168    .normalized();
169
170    // Create a text modifier element that will add TextModifierNode to the chain
171    // TextModifierNode handles measurement, drawing, and semantics
172    let text_element = modifier_element(TextModifierElement::new(current, style, options));
173    let final_modifier = Modifier::from_parts(vec![text_element]);
174    let combined_modifier = modifier.then(final_modifier);
175
176    // text_modifier is inclusive of layout effects
177    Layout(
178        combined_modifier,
179        EmptyMeasurePolicy,
180        || {}, // No children
181    )
182}
183
184#[composable]
185pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
186where
187    S: IntoTextSource + Clone + PartialEq + 'static,
188{
189    BasicText(
190        value,
191        modifier,
192        style,
193        TextOverflow::Clip,
194        true,
195        usize::MAX,
196        1,
197    )
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::run_test_composition;
204
205    #[test]
206    fn basic_text_creates_node() {
207        let composition = run_test_composition(|| {
208            BasicText(
209                "Hello",
210                Modifier::empty(),
211                TextStyle::default(),
212                TextOverflow::Clip,
213                true,
214                usize::MAX,
215                1,
216            );
217        });
218
219        assert!(composition.root().is_some());
220    }
221}