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::compose_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
133fn compose_basic_text_group(
134    text: TextSource,
135    modifier: Modifier,
136    style: TextStyle,
137    options: TextLayoutOptions,
138) -> NodeId {
139    let current = text.resolve();
140
141    let options = options.normalized();
142
143    let modifier = match crate::widgets::local_selection_registrar().current() {
144        Some(registrar) => modifier.then(crate::widgets::selection_container::selectable_text(
145            registrar,
146            Rc::clone(&current),
147            style.clone(),
148            options,
149        )),
150        None => modifier,
151    };
152    let text_element = modifier_element(TextModifierElement::new(current, style, options));
153    let final_modifier = Modifier::from_parts(vec![text_element]);
154    let combined_modifier = modifier.then(final_modifier);
155
156    compose_layout(combined_modifier, EmptyMeasurePolicy, || {})
157}
158
159#[composable]
160pub fn BasicTextWithOptions<S>(
161    text: S,
162    modifier: Modifier,
163    style: TextStyle,
164    options: TextLayoutOptions,
165) -> NodeId
166where
167    S: IntoTextSource + Clone + PartialEq + 'static,
168{
169    compose_basic_text_group(text.into_text_source(), modifier, style, options)
170}
171
172#[composable]
173pub fn BasicText<S>(
174    text: S,
175    modifier: Modifier,
176    style: TextStyle,
177    overflow: TextOverflow,
178    soft_wrap: bool,
179    max_lines: usize,
180    min_lines: usize,
181) -> NodeId
182where
183    S: IntoTextSource + Clone + PartialEq + 'static,
184{
185    compose_basic_text_group(
186        text.into_text_source(),
187        modifier,
188        style,
189        TextLayoutOptions {
190            overflow,
191            soft_wrap,
192            max_lines,
193            min_lines,
194        },
195    )
196}
197
198#[composable]
199pub fn TextWithOptions<S>(
200    value: S,
201    modifier: Modifier,
202    style: TextStyle,
203    options: TextOptions,
204) -> NodeId
205where
206    S: IntoTextSource + Clone + PartialEq + 'static,
207{
208    compose_basic_text_group(
209        value.into_text_source(),
210        modifier,
211        style,
212        TextLayoutOptions::from(options),
213    )
214}
215
216/// High-level element that displays text.
217///
218/// # When to use
219/// Use this widget to display read-only text on the screen. For editable text,
220/// use [`BasicTextField`](crate::widgets::BasicTextField).
221///
222/// # Arguments
223///
224/// * `value` - The string to display. Can be a `&str`, `String`, or `State<String>`.
225/// * `modifier` - Modifiers to apply (e.g., padding, background, layout instructions).
226/// * `style` - Text styling (color, font size).
227///
228/// # Example
229///
230/// ```rust,ignore
231/// Text("Hello World", Modifier::padding(16.0), TextStyle::default());
232/// ```
233#[composable]
234pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
235where
236    S: IntoTextSource + Clone + PartialEq + 'static,
237{
238    compose_basic_text_group(
239        value.into_text_source(),
240        modifier,
241        style,
242        TextLayoutOptions::from(TextOptions::default()),
243    )
244}
245
246#[cfg(test)]
247#[path = "tests/text_tests.rs"]
248mod tests;