Skip to content

Implement CSS table layout (Display::Table) - #1094

Open
kane50613 wants to merge 8 commits into
DioxusLabs:mainfrom
kane50613:main
Open

Implement CSS table layout (Display::Table)#1094
kane50613 wants to merge 8 commits into
DioxusLabs:mainfrom
kane50613:main

Conversation

@kane50613

@kane50613 kane50613 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Objective

Works towards #467

Adds table layout (CSS 2.1 §17) behind a default-on table_layout feature.

New display variants Table/TableRowGroup/TableRow/TableCell, plus table_layout, border_spacing, colspan, and rowspan style properties. Covers automatic and fixed layout, spans, border-spacing, RTL, and row baseline alignment.

Cells are block containers and dispatch to compute_block_layout. A cell reads its align_content through BlockContainerStyle. LayoutTableContainer extends LayoutBlockContainer + TraverseTree, because table layout reads past its own children into the rows and cells below them. Cell baseline shifts pass through a new LayoutInput::content_offset_y field, which block layout applies at its content cursor and which participates in the cache key.

css-tables-3 leaves width distribution and row height distribution underspecified, so both are ported from Blink rather than written from the spec text alone. Column widths use the four-guess distribution algorithm of css-tables-3 §3.9. Row heights follow Blink's priority order: percentage rows first, then rows a rowspan starts in, then unconstrained rows, then empty rows, then every non-empty row in proportion to its height.

Anonymous box fixup (css-tables-3 §3) and border-collapse resolution are out of scope. They belong to the tree builder. The module docs explain both.

Context

Generated by Claude and reviewed by me.

95 fixtures produce 380 tests. Chrome generates the expectations. The table_layout-only build passes locally.

Here are some screenshots tested with blitz:

table-percent table-spans

Feedback wanted

  • Cell baseline shifts pass through a new LayoutInput::content_offset_y field, consumed by block layout and included in the cache key. This mirrors Blink's ConstraintSpace approach, but it does widen a shared public struct. Is that acceptable, or is there a preferred mechanism?
  • Putting content_offset_y in CacheKey costs 80 bytes per node. CacheKey grows from 16 to 24 bytes, and Cache from 368 to 448, since each node holds one final-layout entry and nine measure entries. The field is only ever non-zero under RunMode::PerformLayout. For callers inside the crate, the comparison it adds to the ComputeSize lookup is therefore always true. Should it move out of CacheKey and sit on the final-layout entry alone?
  • A ComputeSize cache hit returns LayoutOutput::from_outer_size, which drops baselines. Table row sizing reads first_baselines.y, so a cell takes a different baseline path when its measurement comes from the cache. This predates the PR, but table layout is the first algorithm to depend on it.
  • Anonymous box fixup (css-tables-3 §3) and border-collapse resolution are treated as the tree builder's responsibility and documented in the module docs. Should either live in Taffy instead?
  • table_layout is currently in the default feature set. Keep it there, or opt-in?
  • Is this enough to consider it done, with captions, <col>/<colgroup>, and the border-collapse contract tracked as follow-up issues? Or should it stay open as the tracking issue?

@kane50613
kane50613 force-pushed the main branch 2 times, most recently from 153eb18 to 7e28f10 Compare August 11, 2026 08:11
@kane50613
kane50613 marked this pull request as draft August 12, 2026 07:36
@kane50613
kane50613 marked this pull request as ready for review August 12, 2026 08:23

@nicoburns nicoburns left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a review of everything in this PR that is NOT the actual algorithm in compute/table.rs.

In general, this looks about the right shape.

However, I'm not overjoyed about about this adding to the cache key. Especially as I think this means that every table cell will need to be relayouted if it's alignment in the parent changes. I would be interested to explore designs that avoid this. Although it may end up being that this is the best design.

AI is suggesting something like an additional field on LayoutOutput instead. Which doesn't look super-clean either, but perhaps would have better performance characteristics?

table_layout is currently in the default feature set. Keep it there, or opt-in?

Yes, we use that convention in Taffy because it makes development easier (e.g. code is enabled for Rust Analyzer). Consumers can always disable features they don't want.

Here are some screenshots tested with blitz:

Does this mean you have a Blitz branch that uses this sitting around somewhere? Have you run the table WPT tests against it?

Is this enough to consider it done, with captions, /, and the border-collapse contract tracked as follow-up issues?

I don't yet have a good read on how complete this is. I might be wrong, but I believe TableWrapper / captions may just be a block layout. <col>/<colgroup> feels like a more core part of the algorithm to me. When I did CSS Grid I had a great big todo list: #204. Something along those lines for this might be a good idea to track progress.

Putting content_offset_y in CacheKey costs 80 bytes per node. CacheKey grows from 16 to 24 bytes, and Cache from 368 to 448, since each node holds one final-layout entry and nine measure entries. The field is only ever non-zero under RunMode::PerformLayout. For callers inside the crate, the comparison it adds to the ComputeSize lookup is therefore always true. Should it move out of CacheKey and sit on the final-layout entry alone?

If we stick with the cache key approach, then I think using separate CacheKey types for the final layout cache would make sense here.

A ComputeSize cache hit returns LayoutOutput::from_outer_size, which drops baselines. Table row sizing reads first_baselines.y, so a cell takes a different baseline path when its measurement comes from the cache. This predates the PR, but table layout is the first algorithm to depend on it.

All of the other algorithms have a constraint where they do a PerformLayout if they need baselines. In general, most of the algorithms don't return useful/correct baselines unless you do PerformLayout. Baselines are a bit awkward in that they require more computation than size computation but less than a full layout. It's less critical for other Taffy layout modes because use of baseline alignment is relatively rare there (unlike table where it's the default). I'm not sure what the best solution to this is.

Anonymous box fixup (css-tables-3 §3) and border-collapse resolution are treated as the tree builder's responsibility and documented in the module docs. Should either live in Taffy instead?

Anonymous box fixup being the tree builder's responsibility I definitely agree with (we may eventually want to add this phase to TaffyTree, but for now leaving it unimplemented in Taffy is correct). Border collapse resolution I'm less sure about. It feels like that might be better in the "layout" phase than in "construction"?

Comment thread src/style/table.rs
/// `display: table-row-group` (thead, tbody, tfoot)
RowGroup,
/// Any other display value
Other,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should perhaps be None? What do you think?

Comment thread src/style/table.rs Outdated
/// The item's role within the table, derived from its `display` value
#[inline(always)]
fn table_role(&self) -> TableRole {
TableRole::Cell

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should Other/None perhaps be the default?

Comment thread src/tree/taffy_tree.rs
Comment on lines +334 to +335
#[cfg(feature = "table_layout")]
(Display::TableCell | Display::TableRow | Display::TableRowGroup, true) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps Row and RowGroup should panic? Given that it's a "should never be encountered case". (open to discussion on this)

Comment thread src/tree/traits.rs Outdated
/// Table layout reaches past its own children into the rows and cells below them, so the tree
/// must also be traversable to any depth. A cell is a block container, and reads its
/// `align_content` through [`LayoutBlockContainer`].
pub trait LayoutTableContainer: LayoutBlockContainer + TraverseTree {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this extend LayoutBlockContainer rather than LayoutPartialTree. This isn't necessarily wrong, but my instinct is that we should just add more methods (duplicate if necessary) unless it's a lot of methods we're inheriting here.

Works towards DioxusLabs#467. Adds automatic and fixed table layout (CSS 2.1 §17)
behind a `table_layout` feature flag, with 308 Chrome-generated
snapshot tests from 77 new fixtures.
Port the css-tables-3 §3.9 width distribution algorithm and the row height
priority order Blink applies. Both replace the linear interpolation and even
splits that stood in for them.

Merge column constraints by the spec's rule, so a wrappable cell no longer
widens a column with a specified width. Account for percentage columns in
intrinsic table widths.

Keep a rowspan inside its row group. Lend its ascent to the row it starts in,
and spread its excess height over the rows it crosses in proportion to their
heights. Apply a row group's specified height, and resolve percentage row
heights against the table.

Add 18 fixtures with expectations generated from Chrome. Drop the parallel
per-cell vectors, the quadratic order rescans, and the duplicate
`align_content` getter.
The default style is not a table cell, and the `Style` impl already maps every non-table display to `Other`. Returning `Cell` made an unrecognised child silently act as one.
A row, row group, or cell with `display: none` was placed into the grid, where it took up a slot and grew the table. Chrome drops it entirely, so leave it out of the grid and clear its layout the way the block and grid algorithms do.
Table layout read one method off `LayoutBlockContainer`, a cell's `align_content`. Move that method onto `TableItemStyle` so an embedder does not have to implement the whole block container trait to lay out tables.
Table cells now contribute to the table's scrollable overflow rect the way grid and block items do, measured from the padding-box origin and mirrored for RTL.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants