1#![allow(non_snake_case)]
9
10use std::rc::Rc;
11
12use cranpose_core::{MutableState, NodeId, State};
13use cranpose_foundation::modifier_element;
14
15use crate::{
16 composable,
17 layout::policies::EmptyMeasurePolicy,
18 modifier::Modifier,
19 text::{TextLayoutOptions, TextOptions, TextOverflow, TextStyle},
20 text_modifier_node::TextModifierElement,
21 widgets::Layout,
22};
23
24#[derive(Clone)]
25pub struct DynamicTextSource(Rc<dyn Fn() -> Rc<crate::text::AnnotatedString>>);
26
27impl DynamicTextSource {
28 pub fn new<F>(resolver: F) -> Self
29 where
30 F: Fn() -> Rc<crate::text::AnnotatedString> + 'static,
31 {
32 Self(Rc::new(resolver))
33 }
34
35 fn resolve(&self) -> Rc<crate::text::AnnotatedString> {
36 (self.0)()
37 }
38}
39
40impl PartialEq for DynamicTextSource {
41 fn eq(&self, other: &Self) -> bool {
42 Rc::ptr_eq(&self.0, &other.0)
43 }
44}
45
46#[derive(Clone, PartialEq)]
47pub enum TextSource {
48 Static(Rc<crate::text::AnnotatedString>),
49 Dynamic(DynamicTextSource),
50}
51
52impl TextSource {
53 fn resolve(&self) -> Rc<crate::text::AnnotatedString> {
54 match self {
55 TextSource::Static(text) => text.clone(),
56 TextSource::Dynamic(dynamic) => dynamic.resolve(),
57 }
58 }
59}
60
61#[doc(hidden)]
62pub trait IntoTextSource {
63 fn into_text_source(self) -> TextSource;
64}
65
66impl IntoTextSource for String {
67 fn into_text_source(self) -> TextSource {
68 TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
69 }
70}
71
72impl IntoTextSource for &str {
73 fn into_text_source(self) -> TextSource {
74 TextSource::Static(Rc::new(crate::text::AnnotatedString::from(self)))
75 }
76}
77
78impl IntoTextSource for crate::text::AnnotatedString {
79 fn into_text_source(self) -> TextSource {
80 TextSource::Static(Rc::new(self))
81 }
82}
83
84impl IntoTextSource for Rc<crate::text::AnnotatedString> {
85 fn into_text_source(self) -> TextSource {
86 TextSource::Static(self)
87 }
88}
89
90impl<T> IntoTextSource for State<T>
91where
92 T: ToString + Clone + 'static,
93{
94 fn into_text_source(self) -> TextSource {
95 let state = self;
96 TextSource::Dynamic(DynamicTextSource::new(move || {
97 Rc::new(crate::text::AnnotatedString::from(
98 state.value().to_string(),
99 ))
100 }))
101 }
102}
103
104impl<T> IntoTextSource for MutableState<T>
105where
106 T: ToString + Clone + 'static,
107{
108 fn into_text_source(self) -> TextSource {
109 let state = self;
110 TextSource::Dynamic(DynamicTextSource::new(move || {
111 Rc::new(crate::text::AnnotatedString::from(
112 state.value().to_string(),
113 ))
114 }))
115 }
116}
117
118impl<F> IntoTextSource for F
119where
120 F: Fn() -> String + 'static,
121{
122 fn into_text_source(self) -> TextSource {
123 TextSource::Dynamic(DynamicTextSource::new(move || {
124 Rc::new(crate::text::AnnotatedString::from(self()))
125 }))
126 }
127}
128
129impl IntoTextSource for DynamicTextSource {
130 fn into_text_source(self) -> TextSource {
131 TextSource::Dynamic(self)
132 }
133}
134
135fn compose_basic_text_group(
153 text: TextSource,
154 modifier: Modifier,
155 style: TextStyle,
156 options: TextLayoutOptions,
157) -> NodeId {
158 let current = text.resolve();
159
160 let options = options.normalized();
161
162 let text_element = modifier_element(TextModifierElement::new(current, style, options));
165 let final_modifier = Modifier::from_parts(vec![text_element]);
166 let combined_modifier = modifier.then(final_modifier);
167
168 Layout(
170 combined_modifier,
171 EmptyMeasurePolicy,
172 || {}, )
174}
175
176#[composable]
177pub fn BasicTextWithOptions<S>(
178 text: S,
179 modifier: Modifier,
180 style: TextStyle,
181 options: TextLayoutOptions,
182) -> NodeId
183where
184 S: IntoTextSource + Clone + PartialEq + 'static,
185{
186 compose_basic_text_group(text.into_text_source(), modifier, style, options)
187}
188
189#[composable]
190pub fn BasicText<S>(
191 text: S,
192 modifier: Modifier,
193 style: TextStyle,
194 overflow: TextOverflow,
195 soft_wrap: bool,
196 max_lines: usize,
197 min_lines: usize,
198) -> NodeId
199where
200 S: IntoTextSource + Clone + PartialEq + 'static,
201{
202 BasicTextWithOptions(
203 text,
204 modifier,
205 style,
206 TextLayoutOptions {
207 overflow,
208 soft_wrap,
209 max_lines,
210 min_lines,
211 },
212 )
213}
214
215#[composable]
216pub fn TextWithOptions<S>(
217 value: S,
218 modifier: Modifier,
219 style: TextStyle,
220 options: TextOptions,
221) -> NodeId
222where
223 S: IntoTextSource + Clone + PartialEq + 'static,
224{
225 BasicTextWithOptions(value, modifier, style, TextLayoutOptions::from(options))
226}
227
228#[composable]
229pub fn Text<S>(value: S, modifier: Modifier, style: TextStyle) -> NodeId
230where
231 S: IntoTextSource + Clone + PartialEq + 'static,
232{
233 TextWithOptions(value, modifier, style, TextOptions::default())
234}
235
236#[cfg(test)]
237mod tests {
238 use std::{cell::Cell, rc::Rc};
239
240 use cranpose_core::{location_key, Composition, MemoryApplier};
241
242 use super::*;
243 use crate::run_test_composition;
244
245 #[test]
246 fn basic_text_creates_node() {
247 let _app_context = crate::render_state::app_context_test_scope();
248 let composition = run_test_composition(|| {
249 BasicTextWithOptions(
250 "Hello",
251 Modifier::empty(),
252 TextStyle::default(),
253 TextLayoutOptions::default(),
254 );
255 });
256
257 assert!(composition.root().is_some());
258 }
259
260 #[test]
261 fn text_with_options_creates_node() {
262 let _app_context = crate::render_state::app_context_test_scope();
263 let composition = run_test_composition(|| {
264 TextWithOptions(
265 "Hello",
266 Modifier::empty(),
267 TextStyle::default(),
268 TextOptions {
269 overflow: TextOverflow::Ellipsis,
270 soft_wrap: false,
271 max_lines: Some(1),
272 ..TextOptions::default()
273 },
274 );
275 });
276
277 assert!(composition.root().is_some());
278 }
279
280 #[test]
281 fn basic_text_recomposes_when_dynamic_source_changes() {
282 let _app_context = crate::render_state::app_context_test_scope();
283 let mut composition = Composition::new(MemoryApplier::new());
284 let runtime = composition.runtime_handle();
285 let state = MutableState::with_runtime("Hello".to_string(), runtime);
286 let resolutions = Rc::new(Cell::new(0));
287
288 composition
289 .render(location_key(file!(), line!(), column!()), {
290 let text_state = state;
291 let resolutions = Rc::clone(&resolutions);
292 move || {
293 let text_state = text_state;
294 let resolutions = Rc::clone(&resolutions);
295 BasicText(
296 DynamicTextSource::new(move || {
297 resolutions.set(resolutions.get() + 1);
298 Rc::new(crate::text::AnnotatedString::from(text_state.value()))
299 }),
300 Modifier::empty(),
301 TextStyle::default(),
302 TextOverflow::Clip,
303 true,
304 usize::MAX,
305 1,
306 );
307 }
308 })
309 .expect("initial text render");
310
311 assert_eq!(resolutions.get(), 1);
312
313 state.set_value("World".to_string());
314 composition
315 .process_invalid_scopes()
316 .expect("dynamic text recomposition");
317
318 assert_eq!(resolutions.get(), 2);
319 }
320}