Skip to main content

tessera_components/
text_table.rs

1//! Text tables with content-sized columns and merged cells.
2//!
3//! ## Usage
4//!
5//! Compare values with accessible headers and merged cells.
6
7use std::sync::Arc;
8
9use parking_lot::Mutex;
10use tessera_ui::{
11    AxisConstraint, Color, ComputedData, Constraint, Dp, LayoutModifierNode, LayoutResult,
12    MeasurementError, Modifier, Px, PxPosition,
13    accesskit::Role,
14    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
15    remember, remember_with_key, tessera, use_context,
16};
17use unicode_linebreak::linebreaks;
18
19use crate::{
20    alignment::Alignment,
21    boxed::boxed,
22    modifier::{ModifierExt as _, Padding, SemanticsArgs},
23    pipelines::text::{command::TextConstraint, pipeline::TextData},
24    spacer::spacer,
25    text::text,
26    theme::{MaterialTheme, TextStyle},
27};
28
29const MAX_GRID_POSITIONS: usize = 1_048_576;
30
31/// The meaning of a text table cell.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
33pub enum TextTableHeading {
34    /// An ordinary data cell.
35    #[default]
36    None,
37    /// A heading for the column or columns covered by the cell.
38    Column,
39    /// A heading for the row or rows covered by the cell.
40    Row,
41}
42
43/// Text and grid coordinates for a table cell.
44///
45/// Indices start at zero. Spans must be positive and cells must not overlap.
46#[derive(Clone, Debug, PartialEq, Eq, Hash)]
47pub struct TextTableCell {
48    /// The displayed text, including any explicit line breaks.
49    pub text: String,
50    /// The first covered row.
51    pub row: usize,
52    /// The first covered column.
53    pub column: usize,
54    /// The number of covered rows.
55    pub row_span: usize,
56    /// The number of covered columns.
57    pub column_span: usize,
58    /// Whether the cell names rows or columns.
59    pub heading: TextTableHeading,
60}
61
62impl Default for TextTableCell {
63    fn default() -> Self {
64        Self {
65            text: String::new(),
66            row: 0,
67            column: 0,
68            row_span: 1,
69            column_span: 1,
70            heading: TextTableHeading::None,
71        }
72    }
73}
74
75#[derive(Clone, Copy, PartialEq, Eq, Hash)]
76struct ShapingStyleKey {
77    font_size: u64,
78    line_height: Option<u64>,
79    font_weight: u16,
80    letter_spacing: u64,
81    font_family: Option<&'static str>,
82}
83
84impl From<TextStyle> for ShapingStyleKey {
85    fn from(style: TextStyle) -> Self {
86        Self {
87            font_size: style.font_size.0.to_bits(),
88            line_height: style.line_height.map(|height| height.0.to_bits()),
89            font_weight: style.font_weight,
90            letter_spacing: style.letter_spacing.0.to_bits(),
91            font_family: style.font_family,
92        }
93    }
94}
95
96/// # text_table
97///
98/// Compare text with content-sized columns and accessible merged cells.
99///
100/// ## Usage
101///
102/// Compare specifications or settings with row and column headers.
103///
104/// ## Parameters
105///
106/// - `cells` — text, zero-based grid coordinates, spans and heading roles
107/// - `columns` — column count; omitted values infer it from the cells
108/// - `modifier` — table sizing and node behavior
109/// - `style`, `header_style` — data and heading typography
110/// - `cell_padding` — content insets, defaulting to 24dp horizontally and 16dp
111///   vertically
112/// - `color`, `header_color` — body and heading text colors; headings use the
113///   body color when no heading color is supplied
114/// - `header_background`, `divider_color` — heading fill and row-rule colors
115///
116/// Columns adapt to measured text. Narrow columns use the text component's
117/// wrapping behavior. Physical-pixel rounding preserves the total allocated
118/// width without gaps. Cells are read in row and column order. Grids cannot
119/// exceed 1,048,576 positions.
120///
121/// ## Examples
122///
123/// ```
124/// use tessera_components::text_table::{TextTableCell, TextTableHeading, text_table};
125/// #[tessera_ui::tessera]
126/// fn comparison() {
127///     let heading = TextTableCell {
128///         text: "Size".into(),
129///         heading: TextTableHeading::Column,
130///         ..Default::default()
131///     };
132///     let value = TextTableCell {
133///         text: "Medium".into(),
134///         row: 1,
135///         ..Default::default()
136///     };
137///     text_table(vec![heading, value]).columns(1);
138/// }
139/// # tessera_ui::testing::layout_test(comparison).run();
140/// ```
141#[tessera]
142pub fn text_table(
143    cells: Vec<TextTableCell>,
144    columns: Option<usize>,
145    modifier: Option<Modifier>,
146    style: Option<TextStyle>,
147    header_style: Option<TextStyle>,
148    cell_padding: Option<Padding>,
149    color: Option<Color>,
150    header_color: Option<Color>,
151    header_background: Option<Color>,
152    divider_color: Option<Color>,
153) {
154    let theme = use_context::<MaterialTheme>()
155        .map(|theme| theme.get())
156        .unwrap_or_default();
157    let style = style.unwrap_or(theme.typography.body_large);
158    let header_style = header_style.unwrap_or(TextStyle {
159        font_weight: 500,
160        ..style
161    });
162    let padding = cell_padding.unwrap_or_else(|| Padding::symmetric(Dp(24.0), Dp(16.0)));
163    let color = color.unwrap_or(theme.color_scheme.on_surface);
164    let header_color = header_color.unwrap_or(color);
165    let header_background = header_background.unwrap_or(theme.color_scheme.surface_container_low);
166    let divider_color = divider_color.unwrap_or(theme.color_scheme.outline_variant);
167    let mut cells = cells;
168    cells.sort_by_key(|cell| (cell.row, cell.column));
169    let columns = columns.unwrap_or_else(|| {
170        cells
171            .iter()
172            .map(|cell| {
173                cell.column
174                    .checked_add(cell.column_span)
175                    .expect("Table column overflow")
176            })
177            .max()
178            .unwrap_or(0)
179    });
180    let rows = validate(&cells, columns);
181    let metrics_key = (
182        cells.clone(),
183        ShapingStyleKey::from(style),
184        ShapingStyleKey::from(header_style),
185        [
186            padding.left.0.to_bits(),
187            padding.top.0.to_bits(),
188            padding.right.0.to_bits(),
189            padding.bottom.0.to_bits(),
190        ],
191        Dp(1.0).to_pixels_f32().to_bits(),
192    );
193    let intrinsic = remember_with_key(metrics_key, move || {
194        measure_intrinsics(&cells, style, header_style, padding)
195    })
196    .get();
197    let cache = remember(|| Arc::new(Mutex::new(None::<TrackLayout>))).get();
198    let cells = intrinsic.cells.clone();
199    let modifier = modifier.unwrap_or_default().semantics(SemanticsArgs {
200        role: Some(Role::Table),
201        merge_descendants: true,
202        collection_info: Some((rows, columns, false)),
203        ..Default::default()
204    });
205    layout()
206        .modifier(modifier)
207        .policy(TableLayout {
208            intrinsic,
209            rows,
210            columns,
211            cache,
212        })
213        .child(move || {
214            for row in 0..rows {
215                table_row(
216                    cells
217                        .iter()
218                        .filter(|cell| cell.row == row)
219                        .cloned()
220                        .collect(),
221                    row,
222                    columns,
223                    style,
224                    header_style,
225                    padding,
226                    color,
227                    header_color,
228                    header_background,
229                    divider_color,
230                );
231            }
232        });
233}
234
235#[tessera]
236fn table_row(
237    cells: Vec<TextTableCell>,
238    row: usize,
239    columns: usize,
240    style: TextStyle,
241    header_style: TextStyle,
242    padding: Padding,
243    color: Color,
244    header_color: Color,
245    header_background: Color,
246    divider_color: Color,
247) {
248    Modifier::new()
249        .semantics(SemanticsArgs {
250            role: Some(Role::Row),
251            merge_descendants: true,
252            collection_item_info: Some((row, 1, 0, columns, false)),
253            ..Default::default()
254        })
255        .attach();
256    for cell in cells {
257        let heading = cell.heading != TextTableHeading::None;
258        let modifier = Modifier::new().semantics(SemanticsArgs {
259            role: Some(match cell.heading {
260                TextTableHeading::None => Role::Cell,
261                TextTableHeading::Column => Role::ColumnHeader,
262                TextTableHeading::Row => Role::RowHeader,
263            }),
264            label: Some(cell.text.clone()),
265            merge_descendants: false,
266            collection_item_info: Some((
267                cell.row,
268                cell.row_span,
269                cell.column,
270                cell.column_span,
271                heading,
272            )),
273            ..Default::default()
274        });
275        let modifier = if heading {
276            modifier.background(header_background)
277        } else {
278            modifier
279        };
280        boxed()
281            .alignment(Alignment::CenterStart)
282            .modifier(modifier)
283            .children(move || {
284                text(cell.text.clone())
285                    .style(if heading { header_style } else { style })
286                    .color(if heading { header_color } else { color })
287                    .modifier(Modifier::new().padding(padding));
288                spacer().modifier(
289                    Modifier::new()
290                        .fill_max_width()
291                        .height(Dp(1.0))
292                        .background(divider_color)
293                        .align(Alignment::BottomStart),
294                );
295            });
296    }
297}
298
299fn validate(cells: &[TextTableCell], columns: usize) -> usize {
300    if cells.is_empty() {
301        return 0;
302    }
303    assert!(columns > 0, "A nonempty table requires columns");
304    let rows = cells
305        .iter()
306        .map(|cell| {
307            assert!(
308                cell.row_span > 0 && cell.column_span > 0,
309                "Table spans must be positive"
310            );
311            let end = cell
312                .column
313                .checked_add(cell.column_span)
314                .expect("Table column overflow");
315            assert!(end <= columns, "A table cell exceeds the column count");
316            cell.row
317                .checked_add(cell.row_span)
318                .expect("Table row overflow")
319        })
320        .max()
321        .unwrap_or(0);
322    let count = rows
323        .checked_mul(columns)
324        .filter(|count| *count <= MAX_GRID_POSITIONS)
325        .expect("Table exceeds the supported grid allocation");
326    let mut occupied = vec![false; count];
327    for cell in cells {
328        for row in cell.row..cell.row + cell.row_span {
329            for column in cell.column..cell.column + cell.column_span {
330                let slot = &mut occupied[row * columns + column];
331                assert!(!*slot, "Table cells overlap");
332                *slot = true;
333            }
334        }
335    }
336    rows
337}
338
339#[derive(Clone, PartialEq)]
340struct Intrinsics {
341    cells: Vec<TextTableCell>,
342    widths: Vec<(i32, i32)>,
343}
344
345fn shaped_width(content: &str, style: TextStyle) -> i32 {
346    let width = TextData::shape(
347        content.to_owned(),
348        Color::BLACK,
349        style,
350        TextConstraint::default(),
351    )
352    .measure_info()
353    .size[0];
354    i32::try_from(width).expect("Table text exceeds the supported physical width")
355}
356
357fn measure_intrinsics(
358    cells: &[TextTableCell],
359    style: TextStyle,
360    header_style: TextStyle,
361    padding: Padding,
362) -> Intrinsics {
363    let horizontal: Px = padding.left.into();
364    let right: Px = padding.right.into();
365    let padding_width = horizontal.0 + right.0;
366    let widths = cells
367        .iter()
368        .map(|cell| {
369            let style = if cell.heading == TextTableHeading::None {
370                style
371            } else {
372                header_style
373            };
374            let maximum = shaped_width(&cell.text, style) + padding_width;
375            let mut start = 0;
376            let mut minimum = 0;
377            for (end, _) in linebreaks(&cell.text) {
378                let segment = cell.text[start..end].trim_end_matches(char::is_whitespace);
379                minimum = minimum.max(shaped_width(segment, style));
380                start = end;
381            }
382            (
383                minimum + padding_width,
384                maximum.max(minimum + padding_width),
385            )
386        })
387        .collect();
388    Intrinsics {
389        cells: cells.to_vec(),
390        widths,
391    }
392}
393
394#[derive(Clone)]
395struct TableLayout {
396    intrinsic: Intrinsics,
397    rows: usize,
398    columns: usize,
399    cache: Arc<Mutex<Option<TrackLayout>>>,
400}
401impl PartialEq for TableLayout {
402    fn eq(&self, other: &Self) -> bool {
403        self.intrinsic == other.intrinsic
404            && self.rows == other.rows
405            && self.columns == other.columns
406    }
407}
408
409#[derive(Clone)]
410struct TrackLayout {
411    size: ComputedData,
412    positions: Vec<PxPosition>,
413    sizes: Vec<ComputedData>,
414}
415
416impl LayoutModifierNode for TableLayout {
417    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
418        let children = input.children();
419        assert_eq!(
420            children.len(),
421            self.intrinsic.cells.len(),
422            "Table cells must create one layout child each"
423        );
424        let parent = *input.parent_constraint().as_ref();
425        if children.is_empty() {
426            let size = ComputedData {
427                width: parent.width.clamp(Px::ZERO),
428                height: parent.height.clamp(Px::ZERO),
429            };
430            *self.cache.lock() = Some(TrackLayout {
431                size,
432                positions: Vec::new(),
433                sizes: Vec::new(),
434            });
435            return Ok(LayoutResult::new(size));
436        }
437        let mut minimum = vec![0; self.columns];
438        let mut maximum = vec![0; self.columns];
439        let mut order = (0..children.len()).collect::<Vec<_>>();
440        order.sort_by_key(|&index| self.intrinsic.cells[index].column_span);
441        for index in order {
442            let cell = &self.intrinsic.cells[index];
443            let range = cell.column..cell.column + cell.column_span;
444            grow_span(&mut minimum[range.clone()], self.intrinsic.widths[index].0);
445            grow_span(&mut maximum[range], self.intrinsic.widths[index].1);
446        }
447        for (maximum, minimum) in maximum.iter_mut().zip(&minimum) {
448            *maximum = (*maximum).max(*minimum);
449        }
450        let desired = maximum.iter().sum::<i32>();
451        let width = parent.width.clamp(Px(desired));
452        let columns = fit_columns(width.0, &minimum, &maximum);
453        let x = prefix(&columns);
454        let mut rows = vec![0; self.rows];
455        let mut heights = vec![0; children.len()];
456        for (index, child) in children.iter().enumerate() {
457            let cell = &self.intrinsic.cells[index];
458            let cell_width = x[cell.column + cell.column_span] - x[cell.column];
459            let measured = child.probe_intrinsic(
460                input,
461                &Constraint::new(AxisConstraint::exact(Px(cell_width)), AxisConstraint::NONE),
462            )?;
463            heights[index] = measured.height.0;
464            if cell.row_span == 1 {
465                rows[cell.row] = rows[cell.row].max(measured.height.0);
466            }
467        }
468        let mut order = (0..children.len()).collect::<Vec<_>>();
469        order.sort_by_key(|&index| self.intrinsic.cells[index].row_span);
470        for index in order {
471            let cell = &self.intrinsic.cells[index];
472            grow_span(
473                &mut rows[cell.row..cell.row + cell.row_span],
474                heights[index],
475            );
476        }
477        let y = prefix(&rows);
478        let size = ComputedData {
479            width,
480            height: parent.height.clamp(Px(*y.last().unwrap_or(&0))),
481        };
482        let mut result = LayoutResult::new(size);
483        let mut positions = Vec::with_capacity(children.len());
484        let mut sizes = Vec::with_capacity(children.len());
485        for (index, child) in children.iter().enumerate() {
486            let cell = &self.intrinsic.cells[index];
487            let constraint = Constraint::new(
488                AxisConstraint::exact(Px(x[cell.column + cell.column_span] - x[cell.column])),
489                AxisConstraint::exact(Px(y[cell.row + cell.row_span] - y[cell.row])),
490            );
491            let measured = child.measure(input, &constraint)?;
492            let position = PxPosition::new(Px(x[cell.column]), Px(y[cell.row]));
493            result.place_child(*child, position);
494            positions.push(position);
495            sizes.push(measured.size());
496        }
497        *self.cache.lock() = Some(TrackLayout {
498            size,
499            positions,
500            sizes,
501        });
502        Ok(result)
503    }
504
505    fn place_children(
506        &self,
507        scope: &PlacementScope<'_>,
508        _: &PlacementResult,
509    ) -> Option<PlacementResult> {
510        let tracks = self.cache.lock();
511        let tracks = tracks.as_ref()?;
512        let children = scope.children();
513        if tracks.size != scope.size()
514            || children.len() != tracks.positions.len()
515            || children
516                .iter()
517                .zip(&tracks.sizes)
518                .any(|(child, size)| child.size() != *size)
519        {
520            return None;
521        }
522        let mut result = LayoutResult::new(scope.size());
523        for (child, position) in children.iter().zip(&tracks.positions) {
524            result.place_child(*child, *position);
525        }
526        Some(result.into_placement_result())
527    }
528}
529
530fn prefix(values: &[i32]) -> Vec<i32> {
531    std::iter::once(0)
532        .chain(values.iter().scan(0, |sum, value| {
533            *sum += value;
534            Some(*sum)
535        }))
536        .collect()
537}
538
539fn distribute(total: i32, weights: &[i32]) -> Vec<i32> {
540    if weights.is_empty() {
541        return Vec::new();
542    }
543    let denominator = weights
544        .iter()
545        .map(|value| (*value).max(0) as i64)
546        .sum::<i64>();
547    let denominator = if denominator == 0 {
548        weights.len() as i64
549    } else {
550        denominator
551    };
552    let equal = weights.iter().all(|weight| *weight == 0);
553    let mut remainders = Vec::with_capacity(weights.len());
554    let mut values = weights
555        .iter()
556        .enumerate()
557        .map(|(index, weight)| {
558            let numerator = i64::from(total.max(0))
559                * if equal {
560                    1
561                } else {
562                    i64::from((*weight).max(0))
563                };
564            remainders.push((numerator % denominator, index));
565            (numerator / denominator) as i32
566        })
567        .collect::<Vec<_>>();
568    remainders.sort_by_key(|&(remainder, index)| (std::cmp::Reverse(remainder), index));
569    let residual = total.max(0) - values.iter().sum::<i32>();
570    for &(_, index) in remainders.iter().take(residual as usize) {
571        values[index] += 1;
572    }
573    values
574}
575
576fn grow_span(values: &mut [i32], required: i32) {
577    let deficit = (required - values.iter().sum::<i32>()).max(0);
578    let extra = distribute(deficit, &vec![1; values.len()]);
579    for (value, extra) in values.iter_mut().zip(extra) {
580        *value += extra;
581    }
582}
583
584fn fit_columns(width: i32, minimum: &[i32], maximum: &[i32]) -> Vec<i32> {
585    let min_total = minimum.iter().sum::<i32>();
586    let max_total = maximum.iter().sum::<i32>();
587    if width < min_total {
588        return distribute(width, minimum);
589    }
590    let weights = if width < max_total {
591        maximum
592            .iter()
593            .zip(minimum)
594            .map(|(max, min)| max - min)
595            .collect::<Vec<_>>()
596    } else {
597        maximum.to_vec()
598    };
599    let base = if width < max_total { minimum } else { maximum };
600    let extra = distribute(width - base.iter().sum::<i32>(), &weights);
601    base.iter()
602        .zip(extra)
603        .map(|(base, extra)| base + extra)
604        .collect()
605}
606
607#[cfg(test)]
608#[path = "text_table_tests.rs"]
609mod tests;