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::{LayoutEnvironment, 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 environment = LayoutEnvironment::current();
159    let text_element = modifier_element(TextModifierElement::with_shared_style(
160        current,
161        style,
162        options,
163        environment.density,
164    ));
165    let final_modifier = Modifier::from_parts(&[text_element]);
166    let combined_modifier = modifier.then(final_modifier);
167
168    compose_empty_layout(combined_modifier, environment)
169}
170
171/// The composable every text widget calls, named `Text` for the source
172/// traces inspection shows.
173mod shared_style {
174    use std::sync::Arc;
175
176    use cranpose_core::NodeId;
177
178    use super::{IntoTextSource, compose_basic_text_group};
179    use crate::{
180        composable,
181        modifier::Modifier,
182        text::{TextLayoutOptions, TextStyle},
183    };
184
185    /// Its stored parameters hold the style
186    /// [`crate::text_modifier_node::shared_text_style`] shares with equal
187    /// ones, eight bytes where a style takes 320, and compare it by pointer.
188    #[composable]
189    pub(super) fn Text<S>(
190        text: S,
191        modifier: Modifier,
192        style: Arc<TextStyle>,
193        options: TextLayoutOptions,
194    ) -> NodeId
195    where
196        S: IntoTextSource + Clone + PartialEq + 'static,
197    {
198        compose_basic_text_group(text.into_text_source(), modifier, style, options)
199    }
200}
201
202/// Displays `text` in `style` with the given layout `options`. Compose's `BasicText`.
203#[track_caller]
204#[expect(non_snake_case)]
205pub fn BasicTextWithOptions<S>(
206    text: S,
207    modifier: Modifier,
208    style: TextStyle,
209    options: TextLayoutOptions,
210) -> NodeId
211where
212    S: IntoTextSource + Clone + PartialEq + 'static,
213{
214    shared_style::Text(text, modifier, shared_text_style(style), options)
215}
216
217/// Displays `text` in `style` with the given overflow, wrapping and line limits. Compose's
218/// `BasicText`.
219#[track_caller]
220#[expect(non_snake_case)]
221pub fn BasicText<S>(
222    text: S,
223    modifier: Modifier,
224    style: TextStyle,
225    overflow: TextOverflow,
226    soft_wrap: bool,
227    max_lines: usize,
228    min_lines: usize,
229) -> NodeId
230where
231    S: IntoTextSource + Clone + PartialEq + 'static,
232{
233    shared_style::Text(
234        text,
235        modifier,
236        shared_text_style(style),
237        TextLayoutOptions {
238            overflow,
239            soft_wrap,
240            max_lines,
241            min_lines,
242        },
243    )
244}
245
246/// Displays `value` in `style` with the given text `options`. Compose's `Text`.
247#[track_caller]
248#[expect(non_snake_case)]
249pub fn TextWithOptions<S>(
250    value: S,
251    modifier: Modifier,
252    style: TextStyle,
253    options: TextOptions,
254) -> NodeId
255where
256    S: IntoTextSource + Clone + PartialEq + 'static,
257{
258    shared_style::Text(
259        value,
260        modifier,
261        shared_text_style(style),
262        TextLayoutOptions::from(options),
263    )
264}
265
266/// Displays plain or annotated text with the supplied style. The modifier
267/// controls layout, appearance and input around the text.
268///
269/// # Example
270///
271/// ```rust
272/// use cranpose_ui::*;
273///
274/// #[composable]
275/// fn Greeting() {
276///     Text(
277///         "Hello",
278///         Modifier::empty().padding(16.0),
279///         TextStyle::default(),
280///     );
281/// }
282/// ```
283#[track_caller]
284#[expect(non_snake_case)]
285pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
286where
287    S: IntoTextSource + Clone + PartialEq + 'static,
288{
289    shared_style::Text(
290        value,
291        modifier,
292        shared_text_style(style),
293        TextLayoutOptions::from(TextOptions::default()),
294    )
295}
296
297#[cfg(test)]
298#[path = "tests/text_tests.rs"]
299mod tests;