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