Skip to main content

tessera_components/
text.rs

1//! Single-style text rendering.
2//!
3//! ## Usage
4//!
5//! Display labels, headings, and other text content.
6use 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/// Shared string content accepted by [`text`].
27#[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/// # text
37///
38/// Renders a block of text with a single, uniform style.
39///
40/// ## Usage
41///
42/// Display simple text content. For more complex styling or editing, see other
43/// components.
44///
45/// ## Parameters
46///
47/// - `content` — required text content to display.
48/// - `modifier` — modifier chain applied to the text node; caller semantics
49///   remain intact. The default label is the content and the default role is a
50///   label, unless supplied by the modifier.
51/// - `color` — optional text color override.
52/// - `style` — optional text style override for typography presets.
53/// - `size` — optional font size override.
54/// - `line_height` — optional line height override.
55/// - `single_line` — keep text on one line and ellipsize overflow.
56/// - `accessibility_label` — optional label override, taking precedence over
57///   modifier semantics and displayed content.
58/// - `accessibility_description` — optional description override, taking
59///   precedence over modifier semantics.
60///
61/// ## Examples
62///
63/// ```
64/// use tessera_components::text::text;
65/// use tessera_ui::{Color, Dp, tessera};
66///
67/// #[tessera]
68/// fn demo() {
69///     text("Hello, world!")
70///         .color(Color::new(0.2, 0.5, 0.8, 1.0))
71///         .style(tessera_components::theme::TextStyle {
72///             font_size: Dp(32.0),
73///             line_height: Some(Dp(40.0)),
74///             ..Default::default()
75///         });
76/// }
77///
78/// # tessera_ui::testing::layout_test(demo).run();
79/// ```
80#[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    // A draw-only rebuild can reuse measurement and its glyph buffer.
128    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;