1use 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,
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(
151 text: TextSource,
152 modifier: Modifier,
153 style: TextStyle,
154 options: TextLayoutOptions,
155) -> NodeId {
156 let current = text.resolve();
157
158 let options = options.normalized();
159
160 let text_element = modifier_element(TextModifierElement::new(current, style, options));
161 let final_modifier = Modifier::from_parts(vec![text_element]);
162 let combined_modifier = modifier.then(final_modifier);
163
164 Layout(combined_modifier, EmptyMeasurePolicy, || {})
165}
166
167#[composable]
168pub fn BasicTextWithOptions<S>(
169 text: S,
170 modifier: Modifier,
171 style: TextStyle,
172 options: TextLayoutOptions,
173) -> NodeId
174where
175 S: IntoTextSource + Clone + PartialEq + 'static,
176{
177 compose_basic_text_group(text.into_text_source(), modifier, style, options)
178}
179
180#[composable]
181pub fn BasicText<S>(
182 text: S,
183 modifier: Modifier,
184 style: TextStyle,
185 overflow: TextOverflow,
186 soft_wrap: bool,
187 max_lines: usize,
188 min_lines: usize,
189) -> NodeId
190where
191 S: IntoTextSource + Clone + PartialEq + 'static,
192{
193 BasicTextWithOptions(
194 text,
195 modifier,
196 style,
197 TextLayoutOptions {
198 overflow,
199 soft_wrap,
200 max_lines,
201 min_lines,
202 },
203 )
204}
205
206#[composable]
207pub fn TextWithOptions<S>(
208 value: S,
209 modifier: Modifier,
210 style: TextStyle,
211 options: TextOptions,
212) -> NodeId
213where
214 S: IntoTextSource + Clone + PartialEq + 'static,
215{
216 BasicTextWithOptions(value, modifier, style, TextLayoutOptions::from(options))
217}
218
219#[composable]
220pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
221where
222 S: IntoTextSource + Clone + PartialEq + 'static,
223{
224 TextWithOptions(value, modifier, style, TextOptions::default())
225}
226
227#[cfg(test)]
228mod tests {
229 use std::{cell::Cell, rc::Rc};
230
231 use cranpose_core::{Composition, MemoryApplier, location_key};
232
233 use super::*;
234 use crate::run_test_composition;
235
236 #[test]
237 fn basic_text_creates_node() {
238 let _app_context = crate::render_state::app_context_test_scope();
239 let composition = run_test_composition(|| {
240 BasicTextWithOptions(
241 "Hello",
242 Modifier::empty(),
243 TextStyle::default(),
244 TextLayoutOptions::default(),
245 );
246 });
247
248 assert!(composition.root().is_some());
249 }
250
251 #[test]
252 fn text_with_options_creates_node() {
253 let _app_context = crate::render_state::app_context_test_scope();
254 let composition = run_test_composition(|| {
255 TextWithOptions(
256 "Hello",
257 Modifier::empty(),
258 TextStyle::default(),
259 TextOptions {
260 overflow: TextOverflow::Ellipsis,
261 soft_wrap: false,
262 max_lines: Some(1),
263 ..TextOptions::default()
264 },
265 );
266 });
267
268 assert!(composition.root().is_some());
269 }
270
271 #[test]
272 fn basic_text_recomposes_when_dynamic_source_changes() {
273 let _app_context = crate::render_state::app_context_test_scope();
274 let mut composition = Composition::new(MemoryApplier::new());
275 let runtime = composition.runtime_handle();
276 let state = MutableState::with_runtime("Hello".to_string(), runtime);
277 let resolutions = Rc::new(Cell::new(0));
278
279 composition
280 .render(location_key(file!(), line!(), column!()), {
281 let text_state = state;
282 let resolutions = Rc::clone(&resolutions);
283 move || {
284 let text_state = text_state;
285 let resolutions = Rc::clone(&resolutions);
286 BasicText(
287 DynamicTextSource::new(move || {
288 resolutions.set(resolutions.get() + 1);
289 Rc::new(crate::text::AnnotatedString::from(text_state.value()))
290 }),
291 Modifier::empty(),
292 TextStyle::default(),
293 TextOverflow::Clip,
294 true,
295 usize::MAX,
296 1,
297 );
298 }
299 })
300 .expect("initial text render");
301
302 assert_eq!(resolutions.get(), 1);
303
304 state.set_value("World".to_string());
305 composition
306 .process_invalid_scopes()
307 .expect("dynamic text recomposition");
308
309 assert_eq!(resolutions.get(), 2);
310 }
311}