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/// Text produced by a callback that runs each time the text composes, so state read in the callback
21/// recomposes the text.
22#[derive(Clone)]
23pub struct DynamicTextSource(Rc<dyn Fn() -> Rc<crate::text::AnnotatedString>>);
24
25impl DynamicTextSource {
26    /// Creates a source whose text `resolver` returns.
27    pub fn new<F>(resolver: F) -> Self
28    where
29        F: Fn() -> Rc<crate::text::AnnotatedString> + 'static,
30    {
31        Self(Rc::new(resolver))
32    }
33
34    fn resolve(&self) -> Rc<crate::text::AnnotatedString> {
35        (self.0)()
36    }
37}
38
39impl PartialEq for DynamicTextSource {
40    fn eq(&self, other: &Self) -> bool {
41        Rc::ptr_eq(&self.0, &other.0)
42    }
43}
44
45/// Where a text widget gets its text.
46#[derive(Clone, PartialEq)]
47pub enum TextSource {
48    /// A fixed text.
49    Static(Rc<crate::text::AnnotatedString>),
50    /// A text produced by a callback each time the widget composes.
51    Dynamic(DynamicTextSource),
52}
53
54impl TextSource {
55    fn resolve(&self) -> Rc<crate::text::AnnotatedString> {
56        match self {
57            TextSource::Static(text) => text.clone(),
58            TextSource::Dynamic(dynamic) => dynamic.resolve(),
59        }
60    }
61}
62
63#[doc(hidden)]
64pub trait IntoTextSource {
65    fn into_text_source(self) -> TextSource;
66}
67
68impl IntoTextSource for String {
69    fn into_text_source(self) -> TextSource {
70        TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
71    }
72}
73
74impl IntoTextSource for &str {
75    fn into_text_source(self) -> TextSource {
76        TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
77    }
78}
79
80impl IntoTextSource for crate::text::AnnotatedString {
81    fn into_text_source(self) -> TextSource {
82        TextSource::Static(Rc::new(self))
83    }
84}
85
86impl IntoTextSource for Rc<crate::text::AnnotatedString> {
87    fn into_text_source(self) -> TextSource {
88        TextSource::Static(self)
89    }
90}
91
92impl<T> IntoTextSource for State<T>
93where
94    T: ToString + Clone + 'static,
95{
96    fn into_text_source(self) -> TextSource {
97        let state = self;
98        TextSource::Dynamic(DynamicTextSource::new(move || {
99            Rc::new(crate::text::AnnotatedString::from(
100                state.value().to_string(),
101            ))
102        }))
103    }
104}
105
106impl<T> IntoTextSource for MutableState<T>
107where
108    T: ToString + Clone + 'static,
109{
110    fn into_text_source(self) -> TextSource {
111        let state = self;
112        TextSource::Dynamic(DynamicTextSource::new(move || {
113            Rc::new(crate::text::AnnotatedString::from(
114                state.value().to_string(),
115            ))
116        }))
117    }
118}
119
120impl<F> IntoTextSource for F
121where
122    F: Fn() -> String + 'static,
123{
124    fn into_text_source(self) -> TextSource {
125        TextSource::Dynamic(DynamicTextSource::new(move || {
126            Rc::new(crate::text::AnnotatedString::from(self()))
127        }))
128    }
129}
130
131impl IntoTextSource for DynamicTextSource {
132    fn into_text_source(self) -> TextSource {
133        TextSource::Dynamic(self)
134    }
135}
136
137fn compose_basic_text_group(
138    text: TextSource,
139    modifier: Modifier,
140    style: Arc<TextStyle>,
141    options: TextLayoutOptions,
142) -> NodeId {
143    #[cfg(feature = "localization")]
144    let style = crate::localization::apply_shared_text_locale(style);
145    let current = text.resolve();
146
147    let options = options.normalized();
148
149    let modifier = match crate::widgets::local_selection_registrar().current() {
150        Some(registrar) => modifier.then(crate::widgets::selection_container::selectable_text(
151            registrar,
152            Rc::clone(&current),
153            TextStyle::clone(&style),
154            options,
155        )),
156        None => modifier,
157    };
158    let density = crate::density::density();
159    let text_element = modifier_element(TextModifierElement::with_shared_style(
160        current, style, options, density,
161    ));
162    let final_modifier = Modifier::from_parts(&[text_element]);
163    let combined_modifier = modifier.then(final_modifier);
164
165    compose_empty_layout(combined_modifier, density)
166}
167
168/// The composable every text widget calls, named `Text` for the source
169/// traces inspection shows.
170mod shared_style {
171    use std::sync::Arc;
172
173    use cranpose_core::NodeId;
174
175    use super::{IntoTextSource, compose_basic_text_group};
176    use crate::{
177        composable,
178        modifier::Modifier,
179        text::{TextLayoutOptions, TextStyle},
180    };
181
182    /// Its stored parameters hold the style
183    /// [`crate::text_modifier_node::shared_text_style`] shares with equal
184    /// ones, eight bytes where a style takes 320, and compare it by pointer.
185    #[composable]
186    pub(super) fn Text<S>(
187        text: S,
188        modifier: Modifier,
189        style: Arc<TextStyle>,
190        options: TextLayoutOptions,
191    ) -> NodeId
192    where
193        S: IntoTextSource + Clone + PartialEq + 'static,
194    {
195        compose_basic_text_group(text.into_text_source(), modifier, style, options)
196    }
197}
198
199/// Displays `text` in `style` with the given layout `options`. Compose's `BasicText`.
200#[track_caller]
201#[expect(non_snake_case)]
202pub fn BasicTextWithOptions<S>(
203    text: S,
204    modifier: Modifier,
205    style: TextStyle,
206    options: TextLayoutOptions,
207) -> NodeId
208where
209    S: IntoTextSource + Clone + PartialEq + 'static,
210{
211    shared_style::Text(text, modifier, shared_text_style(style), options)
212}
213
214/// Displays `text` in `style` with the given overflow, wrapping and line limits. Compose's
215/// `BasicText`.
216#[track_caller]
217#[expect(non_snake_case)]
218pub fn BasicText<S>(
219    text: S,
220    modifier: Modifier,
221    style: TextStyle,
222    overflow: TextOverflow,
223    soft_wrap: bool,
224    max_lines: usize,
225    min_lines: usize,
226) -> NodeId
227where
228    S: IntoTextSource + Clone + PartialEq + 'static,
229{
230    shared_style::Text(
231        text,
232        modifier,
233        shared_text_style(style),
234        TextLayoutOptions {
235            overflow,
236            soft_wrap,
237            max_lines,
238            min_lines,
239        },
240    )
241}
242
243/// Displays `value` in `style` with the given text `options`. Compose's `Text`.
244#[track_caller]
245#[expect(non_snake_case)]
246pub fn TextWithOptions<S>(
247    value: S,
248    modifier: Modifier,
249    style: TextStyle,
250    options: TextOptions,
251) -> NodeId
252where
253    S: IntoTextSource + Clone + PartialEq + 'static,
254{
255    shared_style::Text(
256        value,
257        modifier,
258        shared_text_style(style),
259        TextLayoutOptions::from(options),
260    )
261}
262
263/// Displays plain or annotated text with the supplied style. The modifier
264/// controls layout, appearance and input around the text.
265///
266/// # Example
267///
268/// ```rust
269/// use cranpose_ui::*;
270///
271/// #[composable]
272/// fn Greeting() {
273///     Text(
274///         "Hello",
275///         Modifier::empty().padding(16.0),
276///         TextStyle::default(),
277///     );
278/// }
279/// ```
280#[track_caller]
281#[expect(non_snake_case)]
282pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
283where
284    S: IntoTextSource + Clone + PartialEq + 'static,
285{
286    shared_style::Text(
287        value,
288        modifier,
289        shared_text_style(style),
290        TextLayoutOptions::from(TextOptions::default()),
291    )
292}
293
294#[cfg(test)]
295#[path = "tests/text_tests.rs"]
296mod tests;