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, sync::Arc};
9
10use cranpose_core::{MutableState, NodeId, State};
11use cranpose_foundation::modifier_element;
12
13use crate::{
14    modifier::Modifier,
15    text::{TextLayoutOptions, TextOptions, TextOverflow, TextStyle},
16    text_modifier_node::{TextModifierElement, shared_text_style},
17    widgets::layout::compose_empty_layout,
18};
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)]
43pub enum 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
57#[doc(hidden)]
58pub trait IntoTextSource {
59    fn into_text_source(self) -> TextSource;
60}
61
62impl IntoTextSource for String {
63    fn into_text_source(self) -> TextSource {
64        TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
65    }
66}
67
68impl IntoTextSource for &str {
69    fn into_text_source(self) -> TextSource {
70        TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
71    }
72}
73
74impl IntoTextSource for crate::text::AnnotatedString {
75    fn into_text_source(self) -> TextSource {
76        TextSource::Static(Rc::new(self))
77    }
78}
79
80impl IntoTextSource for Rc<crate::text::AnnotatedString> {
81    fn into_text_source(self) -> TextSource {
82        TextSource::Static(self)
83    }
84}
85
86impl<T> IntoTextSource for State<T>
87where
88    T: ToString + Clone + 'static,
89{
90    fn into_text_source(self) -> TextSource {
91        let state = self;
92        TextSource::Dynamic(DynamicTextSource::new(move || {
93            Rc::new(crate::text::AnnotatedString::from(
94                state.value().to_string(),
95            ))
96        }))
97    }
98}
99
100impl<T> IntoTextSource for MutableState<T>
101where
102    T: ToString + Clone + 'static,
103{
104    fn into_text_source(self) -> TextSource {
105        let state = self;
106        TextSource::Dynamic(DynamicTextSource::new(move || {
107            Rc::new(crate::text::AnnotatedString::from(
108                state.value().to_string(),
109            ))
110        }))
111    }
112}
113
114impl<F> IntoTextSource for F
115where
116    F: Fn() -> String + 'static,
117{
118    fn into_text_source(self) -> TextSource {
119        TextSource::Dynamic(DynamicTextSource::new(move || {
120            Rc::new(crate::text::AnnotatedString::from(self()))
121        }))
122    }
123}
124
125impl IntoTextSource for DynamicTextSource {
126    fn into_text_source(self) -> TextSource {
127        TextSource::Dynamic(self)
128    }
129}
130
131fn compose_basic_text_group(
132    text: TextSource,
133    modifier: Modifier,
134    style: Arc<TextStyle>,
135    options: TextLayoutOptions,
136) -> NodeId {
137    #[cfg(feature = "localization")]
138    let style = crate::localization::apply_shared_text_locale(style);
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            TextStyle::clone(&style),
148            options,
149        )),
150        None => modifier,
151    };
152    let density = crate::density::density();
153    let text_element = modifier_element(TextModifierElement::with_shared_style(
154        current, style, options, density,
155    ));
156    let final_modifier = Modifier::from_parts(&[text_element]);
157    let combined_modifier = modifier.then(final_modifier);
158
159    compose_empty_layout(combined_modifier, density)
160}
161
162/// The composable every text widget calls, named `Text` for the source
163/// traces inspection shows.
164mod shared_style {
165    use std::sync::Arc;
166
167    use cranpose_core::NodeId;
168
169    use super::{IntoTextSource, compose_basic_text_group};
170    use crate::{
171        composable,
172        modifier::Modifier,
173        text::{TextLayoutOptions, TextStyle},
174    };
175
176    /// Its stored parameters hold the style
177    /// [`crate::text_modifier_node::shared_text_style`] shares with equal
178    /// ones, eight bytes where a style takes 320, and compare it by pointer.
179    #[composable]
180    pub(super) fn Text<S>(
181        text: S,
182        modifier: Modifier,
183        style: Arc<TextStyle>,
184        options: TextLayoutOptions,
185    ) -> NodeId
186    where
187        S: IntoTextSource + Clone + PartialEq + 'static,
188    {
189        compose_basic_text_group(text.into_text_source(), modifier, style, options)
190    }
191}
192
193#[track_caller]
194#[expect(non_snake_case)]
195pub fn BasicTextWithOptions<S>(
196    text: S,
197    modifier: Modifier,
198    style: TextStyle,
199    options: TextLayoutOptions,
200) -> NodeId
201where
202    S: IntoTextSource + Clone + PartialEq + 'static,
203{
204    shared_style::Text(text, modifier, shared_text_style(style), options)
205}
206
207#[track_caller]
208#[expect(non_snake_case)]
209pub fn BasicText<S>(
210    text: S,
211    modifier: Modifier,
212    style: TextStyle,
213    overflow: TextOverflow,
214    soft_wrap: bool,
215    max_lines: usize,
216    min_lines: usize,
217) -> NodeId
218where
219    S: IntoTextSource + Clone + PartialEq + 'static,
220{
221    shared_style::Text(
222        text,
223        modifier,
224        shared_text_style(style),
225        TextLayoutOptions {
226            overflow,
227            soft_wrap,
228            max_lines,
229            min_lines,
230        },
231    )
232}
233
234#[track_caller]
235#[expect(non_snake_case)]
236pub fn TextWithOptions<S>(
237    value: S,
238    modifier: Modifier,
239    style: TextStyle,
240    options: TextOptions,
241) -> NodeId
242where
243    S: IntoTextSource + Clone + PartialEq + 'static,
244{
245    shared_style::Text(
246        value,
247        modifier,
248        shared_text_style(style),
249        TextLayoutOptions::from(options),
250    )
251}
252
253/// Displays plain or annotated text with the supplied style. The modifier
254/// controls layout, appearance and input around the text.
255///
256/// # Example
257///
258/// ```rust
259/// use cranpose_ui::*;
260///
261/// #[composable]
262/// fn Greeting() {
263///     Text(
264///         "Hello",
265///         Modifier::empty().padding(16.0),
266///         TextStyle::default(),
267///     );
268/// }
269/// ```
270#[track_caller]
271#[expect(non_snake_case)]
272pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
273where
274    S: IntoTextSource + Clone + PartialEq + 'static,
275{
276    shared_style::Text(
277        value,
278        modifier,
279        shared_text_style(style),
280        TextLayoutOptions::from(TextOptions::default()),
281    )
282}
283
284#[cfg(test)]
285#[path = "tests/text_tests.rs"]
286mod tests;