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    modifier::Modifier,
16    text::{TextLayoutOptions, TextOptions, TextOverflow, TextStyle},
17    text_modifier_node::TextModifierElement,
18    widgets::layout::compose_empty_layout,
19};
20
21#[derive(Clone)]
22pub struct DynamicTextSource(Rc<dyn Fn() -> Rc<crate::text::AnnotatedString>>);
23
24impl DynamicTextSource {
25    pub fn new<F>(resolver: F) -> Self
26    where
27        F: Fn() -> Rc<crate::text::AnnotatedString> + 'static,
28    {
29        Self(Rc::new(resolver))
30    }
31
32    fn resolve(&self) -> Rc<crate::text::AnnotatedString> {
33        (self.0)()
34    }
35}
36
37impl PartialEq for DynamicTextSource {
38    fn eq(&self, other: &Self) -> bool {
39        Rc::ptr_eq(&self.0, &other.0)
40    }
41}
42
43#[derive(Clone, PartialEq)]
44pub enum TextSource {
45    Static(Rc<crate::text::AnnotatedString>),
46    Dynamic(DynamicTextSource),
47}
48
49impl TextSource {
50    fn resolve(&self) -> Rc<crate::text::AnnotatedString> {
51        match self {
52            TextSource::Static(text) => text.clone(),
53            TextSource::Dynamic(dynamic) => dynamic.resolve(),
54        }
55    }
56}
57
58#[doc(hidden)]
59pub trait IntoTextSource {
60    fn into_text_source(self) -> TextSource;
61}
62
63impl IntoTextSource for String {
64    fn into_text_source(self) -> TextSource {
65        TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
66    }
67}
68
69impl IntoTextSource for &str {
70    fn into_text_source(self) -> TextSource {
71        TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
72    }
73}
74
75impl IntoTextSource for crate::text::AnnotatedString {
76    fn into_text_source(self) -> TextSource {
77        TextSource::Static(Rc::new(self))
78    }
79}
80
81impl IntoTextSource for Rc<crate::text::AnnotatedString> {
82    fn into_text_source(self) -> TextSource {
83        TextSource::Static(self)
84    }
85}
86
87impl<T> IntoTextSource for State<T>
88where
89    T: ToString + Clone + 'static,
90{
91    fn into_text_source(self) -> TextSource {
92        let state = self;
93        TextSource::Dynamic(DynamicTextSource::new(move || {
94            Rc::new(crate::text::AnnotatedString::from(
95                state.value().to_string(),
96            ))
97        }))
98    }
99}
100
101impl<T> IntoTextSource for MutableState<T>
102where
103    T: ToString + Clone + 'static,
104{
105    fn into_text_source(self) -> TextSource {
106        let state = self;
107        TextSource::Dynamic(DynamicTextSource::new(move || {
108            Rc::new(crate::text::AnnotatedString::from(
109                state.value().to_string(),
110            ))
111        }))
112    }
113}
114
115impl<F> IntoTextSource for F
116where
117    F: Fn() -> String + 'static,
118{
119    fn into_text_source(self) -> TextSource {
120        TextSource::Dynamic(DynamicTextSource::new(move || {
121            Rc::new(crate::text::AnnotatedString::from(self()))
122        }))
123    }
124}
125
126impl IntoTextSource for DynamicTextSource {
127    fn into_text_source(self) -> TextSource {
128        TextSource::Dynamic(self)
129    }
130}
131
132fn compose_basic_text_group(
133    text: TextSource,
134    modifier: Modifier,
135    style: TextStyle,
136    options: TextLayoutOptions,
137) -> NodeId {
138    #[cfg(feature = "localization")]
139    let style = crate::localization::apply_text_locale(style);
140    let current = text.resolve();
141
142    let options = options.normalized();
143
144    let modifier = match crate::widgets::local_selection_registrar().current() {
145        Some(registrar) => modifier.then(crate::widgets::selection_container::selectable_text(
146            registrar,
147            Rc::clone(&current),
148            style.clone(),
149            options,
150        )),
151        None => modifier,
152    };
153    let density = crate::density::density();
154    let text_element = modifier_element(TextModifierElement::new(current, style, options, density));
155    let final_modifier = Modifier::from_parts(&[text_element]);
156    let combined_modifier = modifier.then(final_modifier);
157
158    compose_empty_layout(combined_modifier, density)
159}
160
161#[composable]
162pub fn BasicTextWithOptions<S>(
163    text: S,
164    modifier: Modifier,
165    style: TextStyle,
166    options: TextLayoutOptions,
167) -> NodeId
168where
169    S: IntoTextSource + Clone + PartialEq + 'static,
170{
171    compose_basic_text_group(text.into_text_source(), modifier, style, options)
172}
173
174#[composable]
175pub fn BasicText<S>(
176    text: S,
177    modifier: Modifier,
178    style: TextStyle,
179    overflow: TextOverflow,
180    soft_wrap: bool,
181    max_lines: usize,
182    min_lines: usize,
183) -> NodeId
184where
185    S: IntoTextSource + Clone + PartialEq + 'static,
186{
187    compose_basic_text_group(
188        text.into_text_source(),
189        modifier,
190        style,
191        TextLayoutOptions {
192            overflow,
193            soft_wrap,
194            max_lines,
195            min_lines,
196        },
197    )
198}
199
200#[composable]
201pub fn TextWithOptions<S>(
202    value: S,
203    modifier: Modifier,
204    style: TextStyle,
205    options: TextOptions,
206) -> NodeId
207where
208    S: IntoTextSource + Clone + PartialEq + 'static,
209{
210    compose_basic_text_group(
211        value.into_text_source(),
212        modifier,
213        style,
214        TextLayoutOptions::from(options),
215    )
216}
217
218/// Displays plain or annotated text with the supplied style. The modifier
219/// controls layout, appearance and input around the text.
220///
221/// # Example
222///
223/// ```rust
224/// use cranpose_ui::*;
225///
226/// #[composable]
227/// fn Greeting() {
228///     Text(
229///         "Hello",
230///         Modifier::empty().padding(16.0),
231///         TextStyle::default(),
232///     );
233/// }
234/// ```
235#[composable]
236pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
237where
238    S: IntoTextSource + Clone + PartialEq + 'static,
239{
240    compose_basic_text_group(
241        value.into_text_source(),
242        modifier,
243        style,
244        TextLayoutOptions::from(TextOptions::default()),
245    )
246}
247
248#[cfg(test)]
249#[path = "tests/text_tests.rs"]
250mod tests;