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