1use std::sync::Arc;
7
8use parking_lot::Mutex;
9use tessera_ui::{
10 __private::configure_layout_node, AccessibilityActionHandler, AccessibilityNode, Color,
11 ComputedData, Dp, LayoutResult, MeasurementError, Modifier, Px, PxPosition,
12 SemanticsModifierNode, accesskit::Role, layout::MeasureScope,
13 modifier::ModifierCapabilityExt as _, remember, tessera, use_context,
14};
15
16use crate::{
17 pipelines::text::{
18 command::{TextCommand, TextConstraint},
19 pipeline::{TextData, TextFormat, TextMeasureInfo},
20 },
21 theme::{ContentColor, MaterialTheme, TextStyle},
22};
23
24pub use crate::pipelines::text::pipeline::{read_font_system, write_font_system};
25
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
28pub struct TextContent(Arc<str>);
29
30impl<T: Into<String>> From<T> for TextContent {
31 fn from(value: T) -> Self {
32 Self(value.into().into())
33 }
34}
35
36#[tessera(tessera_ui, layout)]
81pub fn text(
82 #[prop(into)] content: TextContent,
83 modifier: Option<Modifier>,
84 color: Option<Color>,
85 style: Option<TextStyle>,
86 size: Option<Dp>,
87 line_height: Option<Dp>,
88 single_line: Option<bool>,
89 #[prop(into)] accessibility_label: Option<String>,
90 #[prop(into)] accessibility_description: Option<String>,
91) {
92 let modifier = modifier.unwrap_or_default();
93 let theme = if style.is_none() || color.is_none() {
94 use_context::<MaterialTheme>()
95 } else {
96 None
97 };
98 let resolved_style = if let Some(style) = style {
99 style
100 } else if let Some(inherited) = use_context::<TextStyle>() {
101 inherited.get()
102 } else {
103 theme
104 .map(|theme| theme.with(|theme| theme.typography.body_large))
105 .unwrap_or_default()
106 };
107 let color = if let Some(color) = color {
108 color
109 } else if let Some(inherited) = use_context::<ContentColor>() {
110 inherited.get().current
111 } else {
112 theme
113 .map(|theme| theme.with(|theme| theme.color_scheme.on_surface))
114 .unwrap_or_else(|| ContentColor::default().current)
115 };
116 let size = size.unwrap_or(resolved_style.font_size);
117 let TextContent(content) = content;
118 let semantics = TextSemantics {
119 content: content.clone(),
120 label: accessibility_label,
121 description: accessibility_description,
122 };
123 let line_height = line_height
124 .or(resolved_style.line_height)
125 .unwrap_or(Dp(size.0 * 1.2));
126
127 let shaped = remember(|| Arc::new(Mutex::new(None))).get();
129 configure_layout_node(
130 TextLayout {
131 text: content,
132 single_line: single_line.unwrap_or(false),
133 style: TextStyle {
134 font_size: size,
135 line_height: Some(line_height),
136 ..resolved_style
137 },
138 shaped: shaped.clone(),
139 },
140 TextDraw { color, shaped },
141 modifier.push_semantics(semantics),
142 );
143}
144
145#[derive(Clone)]
146struct TextSemantics {
147 content: Arc<str>,
148 label: Option<String>,
149 description: Option<String>,
150}
151
152impl SemanticsModifierNode for TextSemantics {
153 fn apply(
154 &self,
155 accessibility: &mut AccessibilityNode,
156 _: &mut Option<AccessibilityActionHandler>,
157 ) {
158 if accessibility.role.is_none() {
159 accessibility.role = Some(Role::Label);
160 }
161 if let Some(label) = &self.label {
162 accessibility.label = Some(label.clone());
163 } else if accessibility.label.is_none() && !self.content.is_empty() {
164 accessibility.label = Some(self.content.to_string());
165 }
166 if let Some(description) = &self.description {
167 accessibility.description = Some(description.clone());
168 }
169 }
170}
171
172#[derive(Clone)]
173struct TextLayout {
174 text: Arc<str>,
175 single_line: bool,
176 style: TextStyle,
177 shaped: Arc<Mutex<Option<CachedText>>>,
178}
179
180struct CachedText {
181 fonts: Option<u64>,
182 constraint: TextConstraint,
183 data: TextData,
184}
185
186impl PartialEq for TextLayout {
187 fn eq(&self, other: &Self) -> bool {
188 tessera_ui::LayoutModifierNode::measure_eq(self, other)
189 }
190}
191
192impl TextLayout {
193 fn measure_text(&self, constraint: TextConstraint) -> TextMeasureInfo {
194 let fonts = crate::fonts::prepare(&self.text, self.style);
195 let format = TextFormat::from(self.style);
196 let mut cache = self.shaped.lock();
197 match cache.as_mut() {
198 Some(cached)
199 if cached.fonts == fonts
200 && cached.data.matches_text(&self.text, format)
201 && cached.constraint.single_line == constraint.single_line =>
202 {
203 if cached.constraint != constraint {
204 cached.data.relayout(&constraint);
205 cached.constraint = constraint;
206 }
207 }
208 _ => {
209 *cache = Some(CachedText {
210 fonts,
211 constraint: constraint.clone(),
212 data: TextData::shape_prepared(
213 self.text.clone(),
214 Color::WHITE,
215 format,
216 constraint,
217 ),
218 });
219 }
220 }
221 cache
222 .as_ref()
223 .expect("text must be shaped")
224 .data
225 .measure_info()
226 }
227}
228
229impl tessera_ui::LayoutModifierNode for TextLayout {
230 fn measure_eq(&self, other: &Self) -> bool {
231 self.text == other.text
232 && self.style == other.style
233 && self.single_line == other.single_line
234 }
235
236 fn placement_eq(&self, other: &Self) -> bool {
237 self.measure_eq(other)
238 }
239
240 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
241 let max_width = input.parent_constraint().width().resolve_max();
242 let max_height = input.parent_constraint().height().resolve_max();
243
244 let info = self.measure_text(TextConstraint {
245 max_width: max_width.map(|px: Px| px.to_f32()),
246 max_height: max_height.map(|px: Px| px.to_f32()),
247 single_line: self.single_line,
248 });
249
250 Ok(LayoutResult::new(ComputedData {
251 width: input.parent_constraint().width().clamp(info.size[0].into()),
252 height: input
253 .parent_constraint()
254 .height()
255 .clamp(info.size[1].into()),
256 }))
257 }
258}
259
260#[derive(Clone)]
261struct TextDraw {
262 color: Color,
263 shaped: Arc<Mutex<Option<CachedText>>>,
264}
265
266impl PartialEq for TextDraw {
267 fn eq(&self, other: &Self) -> bool {
268 self.color == other.color && Arc::ptr_eq(&self.shaped, &other.shaped)
269 }
270}
271
272impl tessera_ui::DrawModifierNode for TextDraw {
273 fn draw(
274 &self,
275 ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
276 content: &mut dyn tessera_ui::DrawModifierContent,
277 ) {
278 let input = &mut *ctx.render_input;
279 let Some(mut text_data) = self
280 .shaped
281 .lock()
282 .as_ref()
283 .filter(|cached| cached.fonts.is_some())
284 .map(|cached| cached.data.clone())
285 else {
286 return;
287 };
288 text_data.set_color(self.color);
289
290 let drawable = TextCommand {
291 data: text_data,
292 offset: PxPosition::ZERO,
293 };
294 input
295 .metadata_mut()
296 .fragment_mut()
297 .push_draw_command(drawable);
298 content.draw(input);
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use tessera_ui::{
305 Context, State, WorkPhase, provide_context,
306 testing::{TestProbe, layout_test, pump_layout_test_session, start_layout_test_session},
307 };
308
309 use crate::{
310 boxed::boxed,
311 column::column,
312 modifier::{ModifierExt as _, SemanticsArgs},
313 row::row,
314 theme::material_theme,
315 };
316
317 use super::*;
318
319 #[tessera]
320 fn weighted_labels_fixture() {
321 boxed().children(|| {
322 row()
323 .modifier(Modifier::new().width(Dp(300.0)))
324 .children(|| {
325 for index in 0..3 {
326 text("Cell").modifier(Modifier::new().weight(1.0).semantics(
327 SemanticsArgs {
328 test_tag: Some(format!("weighted-label-{index}")),
329 ..Default::default()
330 },
331 ));
332 }
333 });
334 });
335 }
336
337 #[test]
338 fn weighted_text_respects_allocated_column_width() {
339 let runtime = tessera_ui::RuntimeSession::new();
340 let _scope = runtime.enter();
341 crate::test_utils::run_isolated_test(
342 "text::tests::weighted_text_respects_allocated_column_width",
343 || {
344 tessera_ui::dp::set_scale_factor(1.0);
345 let snapshot = layout_test(|| {
346 weighted_labels_fixture();
347 })
348 .viewport_px(400, 100)
349 .run();
350 for index in 0..3 {
351 let bounds = snapshot.node(&format!("weighted-label-{index}")).bounds();
352 assert_eq!(bounds.width, Px(100));
353 assert_eq!(bounds.x, Px(index * 100));
354 }
355 },
356 );
357 }
358
359 #[tessera]
360 fn reflow_fixture(observed: TestProbe<Option<State<Dp>>>) {
361 let width = remember(|| Dp(120.0));
362 observed.set(Some(width));
363 material_theme().child(move || {
364 column().children(move || {
365 boxed()
366 .modifier(Modifier::new().width(width.get()).semantics(SemanticsArgs {
367 test_tag: Some("wrapped-text".into()),
368 ..Default::default()
369 }))
370 .children(|| {
371 text("Material labels stay readable when their containers resize.");
372 });
373 });
374 });
375 }
376
377 #[test]
378 fn density_changes_remeasure_retained_content_without_state_writes() {
379 let runtime = tessera_ui::RuntimeSession::new();
380 let _scope = runtime.enter();
381 crate::test_utils::run_isolated_test(
382 "text::tests::density_changes_remeasure_retained_content_without_state_writes",
383 || {
384 tessera_ui::dp::set_scale_factor(1.0);
385 let observed = TestProbe::default();
386 let fixture_observed = observed.clone();
387 let mut session = start_layout_test_session(
388 layout_test(move || {
389 reflow_fixture(fixture_observed.clone());
390 })
391 .viewport_px(500, 400),
392 );
393 let original = pump_layout_test_session(&mut session, 0)
394 .node("wrapped-text")
395 .bounds();
396 let width = observed.get().expect("fixture state");
397 tessera_ui::dp::set_scale_factor(1.5);
398 let scaled = pump_layout_test_session(&mut session, 16_666_667)
399 .node("wrapped-text")
400 .bounds();
401 assert_eq!(scaled.width, Px::new(180));
402 assert!(scaled.height > original.height);
403 assert!(observed.get() == Some(width));
404 assert_eq!(width.get(), Dp(120.0));
405 tessera_ui::dp::set_scale_factor(1.0);
406 let restored = pump_layout_test_session(&mut session, 33_333_333)
407 .node("wrapped-text")
408 .bounds();
409 assert_eq!(restored, original);
410 },
411 );
412 }
413
414 #[tessera]
415 fn color_fixture(observed: TestProbe<Option<State<tessera_ui::Color>>>) {
416 let color = remember(|| tessera_ui::Color::BLACK);
417 observed.set(Some(color));
418 boxed()
419 .modifier(Modifier::new().semantics(SemanticsArgs {
420 test_tag: Some("color-text".into()),
421 ..Default::default()
422 }))
423 .children(move || {
424 text("Color cache").color(color.get());
425 });
426 }
427
428 #[test]
429 fn text_color_updates_retain_measurements_and_window_geometry() {
430 let observed = TestProbe::default();
431 let fixture_observed = observed.clone();
432 let mut session = start_layout_test_session(
433 layout_test(move || {
434 color_fixture(fixture_observed.clone());
435 })
436 .viewport_px(240, 100),
437 );
438 let original = pump_layout_test_session(&mut session, 0)
439 .node("color-text")
440 .bounds();
441 observed.get().unwrap().set(tessera_ui::Color::WHITE);
442 let changed = pump_layout_test_session(&mut session, 16_666_667)
443 .node("color-text")
444 .bounds();
445 assert_eq!(changed, original);
446 let diagnostics = session.diagnostics().unwrap();
447 assert_eq!(
448 session.work().phase(tessera_ui::WorkPhase::Measure).count(),
449 0
450 );
451 assert_eq!(diagnostics.geometry_nodes_updated, 0);
452 }
453
454 #[tessera]
455 fn inherited_defaults_fixture(
456 observed: TestProbe<Option<(Context<TextStyle>, Context<ContentColor>)>>,
457 ) {
458 provide_context(TextStyle::default, move || {
459 provide_context(ContentColor::default, move || {
460 observed.set(Some((
461 use_context::<TextStyle>().unwrap(),
462 use_context::<ContentColor>().unwrap(),
463 )));
464 text("Inherited");
465 text("Explicit")
466 .style(TextStyle::default())
467 .color(Color::BLACK);
468 });
469 });
470 }
471
472 #[test]
473 fn explicit_text_defaults_ignore_inherited_updates() {
474 let observed = TestProbe::default();
475 let mut session = start_layout_test_session(layout_test(|| {
476 inherited_defaults_fixture(observed.clone());
477 }));
478 pump_layout_test_session(&mut session, 0);
479 assert_eq!(session.work().count("text", WorkPhase::Build), 2);
480 let (style, color) = observed.get().unwrap();
481
482 style.with_mut(|style| style.font_size = Dp(30.0));
483 pump_layout_test_session(&mut session, 16_666_667);
484 assert_eq!(session.work().count("text", WorkPhase::Build), 1);
485
486 color.set(ContentColor {
487 current: Color::WHITE,
488 });
489 pump_layout_test_session(&mut session, 33_333_334);
490 assert_eq!(session.work().count("text", WorkPhase::Build), 1);
491 }
492
493 #[test]
494 fn retained_text_reflows_when_its_parent_changes_width() {
495 let observed = TestProbe::default();
496 let fixture_observed = observed.clone();
497 let mut session = start_layout_test_session(
498 layout_test(move || {
499 reflow_fixture(fixture_observed.clone());
500 })
501 .viewport_px(500, 400),
502 );
503 let narrow = pump_layout_test_session(&mut session, 0)
504 .node("wrapped-text")
505 .bounds();
506 observed.get().expect("fixture state").set(Dp(280.0));
507 let wide = pump_layout_test_session(&mut session, 16_666_667)
508 .node("wrapped-text")
509 .bounds();
510 assert_eq!(narrow.width, Px::new(120));
511 assert_eq!(wide.width, Px::new(280));
512 assert!(wide.height < narrow.height);
513 observed.get().expect("fixture state").set(Dp(120.0));
514 let restored = pump_layout_test_session(&mut session, 33_333_333)
515 .node("wrapped-text")
516 .bounds();
517 assert_eq!(restored.height, narrow.height);
518 }
519}
520
521#[cfg(all(test, not(target_family = "wasm")))]
522#[path = "text/drawing_tests.rs"]
523mod drawing_tests;
524
525#[cfg(all(test, not(target_family = "wasm")))]
526#[path = "text/semantics_tests.rs"]
527mod semantics_tests;