Table spans - #553
Draft
teunbrand wants to merge 8 commits into
Draft
Table spans#553teunbrand wants to merge 8 commits into
teunbrand wants to merge 8 commits into
Conversation
SPAN groups columns under one spanner: `SPAN label ACROSS col, ... [SETTING ...]`, where label is a string (possibly empty) or NULL (suppress the cell, keep the grouping) — mirrors label_assignment's own string/NULL value shape. tabulate_statement now takes a repeated tab_clause (LABEL and SPAN, any order), the same "any order, repeated" shape viz_clause gives VISUALISE's own clauses. Table gains a `spans: Vec<Spanner>` field and Spanner::validate_settings, built the same way a GeomTrait declares default_params() — a static name/default/constraint list validated through the existing validate_parameter — so SPAN's SETTING keys (gather, level) get the same type checking layer settings already do. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires SPAN into table resolution: build_cells validates each spanner's settings, reorders columns so gather-enabled spanners become contiguous (gt's tab_spanner(gather = TRUE) default), assigns each spanner a header row (explicit SETTING level pins directly, others greedily bump past any column-set intersection, then levels are compacted), and RLE-builds one TableCell per contiguous run of a spanner's columns — fragmenting a non-contiguous spanner into several cells. NULL-labeled spanners are filtered out before level assignment so they group columns without consuming a header row. Spanner-specific resolution (reordering, level assignment, cell construction) lives in the new sibling module execute/table_spanner.rs rather than growing table.rs further, mirroring how Plot's own resolution logic is split across schema.rs/casting.rs/layer.rs/scale.rs/position.rs/ cte.rs. validate() now also runs Spanner::validate_settings per SPAN clause, the same way layer SETTINGs are validated, so a bad SPAN setting is caught before execution. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
write_table now buckets cells by is_header() into header/body row groups, wrapping them in <thead>/<tbody> (each omitted when empty), and renders each row through the new render_row: cells sharing a top walk every column position, emitting a colspan attribute via TableCell::width() and filling any gap (a spanner not reaching every column, or one fragmented by another spanner's occupancy) with a synthesized empty cell of the same kind, so a row always reaches the table's full column count. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
process_tab_clause was overwriting table.labels on every LABEL clause instead of merging, unlike the VISUALISE-side handler for the same node — a second LABEL clause silently dropped every override from the first. create_column_labels now also returns no cells at all when every column's label is empty (only reachable via LABEL col => NULL, or => '', on every column, since an unlabeled column keeps its non-empty name), so a wholly suppressed label row is omitted entirely rather than rendered as a row of blank header cells. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New doc/syntax/clause/tabulate.qmd covers TABULATE's FROM/LABEL/SPAN clauses, wired into the navbar and syntax sidebar in _quarto.yml. ggsql.xml gains TabulateClause and SpanClause highlighting contexts, mirroring how DRAW/PLACE/SCALE/PROJECT/FACET/LABEL/VISUALISE already switch contexts on their own keyword. TABULATE is reachable from every existing context (a new TABULATE statement can start after any VISUALISE clause), but SPAN is only reachable from TabulateClause, SpanClause and LabelClause, since it's a TABULATE-only clause and TABULATE is mutually exclusive with VISUALISE within one statement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
render_cell now emits a rowspan attribute alongside colspan, and render_row takes the set of columns a rowspan from an earlier row already occupies, skipping them (no cell, no filler) rather than double-rendering. occupied_columns_per_row computes that set for every row in one pass over cells. write_table also now checks that every header cell sits above every body cell, a requirement of splitting output into separate <thead>/<tbody> blocks that isn't a general TableCell invariant. Nothing in the resolution pipeline produces a multi-row TableCell yet — this is writer-side groundwork, exercised directly with hand-built cells, ahead of compose_header actually merging a spanner-less column's label into a rowspan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
compose_header now grows a column's label cell upward into every consecutive spanner row above it that has no spanner covering that column, stopping at the first row that does — a rowspan on the label instead of a separate blank filler cell for that gap. Deliberately diverges from gt: gt only ever stretches into the single row immediately above the labels, even when rows further up are also empty for that column. Verified directly against gt's own output. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
assign_spanner_levels now matches gt's real resolve_spanner_level() rule: a spanner's level is one more than the highest level of any already-assigned spanner it shares a column with, rather than the lowest level free of overlap. Confirmed against gt's own source; it can use more header rows than strictly necessary for a chain of pairwise-but-not-all conflicting spanners, which is deliberate parity with gt, not a compaction gap. New SETTING id => 'foo' on a SPAN clause lets a later SPAN's ACROSS list reference an earlier spanner by id, folding its columns in alongside any literal ones (SPAN 'H1' ACROSS q1, apr after SPAN 'Q1' ... SETTING id => 'q1'). Table::resolve_spanner_ids expands these (only backward references resolve; a forward reference or typo falls through to the existing unknown-column error) and rejects a duplicate id; it lives on Table rather than in execute::table_spanner so validate() can call it without depending on execute. A separate check, folded into create_spanners since it needs the real column list, rejects an id that collides with an actual column name. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
teunbrand
added this pull request to stack #552
September 17, 2026 09:46
Collaborator
Author
|
Re: label stretching Behaviour deviates in this PR from gt's behaviour, because I understand why column label cells are stretched, but not why they are only stretched for 1 extra row, and not all empty spanner area (why column library(gt)
df <- data.frame(x = 0, a = 1, b = 2, c = 3, d = 4, e = 5)
tb <- df |> gt() |>
tab_spanner("X", c(a, b)) |>
tab_spanner("Y", c(b, c)) |>
tab_spanner("Z", c(c, d)) |>
tab_style(cell_borders(), locations = cells_column_labels())
tb
Created on 2026-09-17 with reprex v2.1.1 |
Collaborator
Author
|
Another idea I had, but not implemented here is maybe we could do this: So we could rename the spanned columns. Currently we have no grammar precedent for using |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

This PR stacks on top of #551.
The user-facing part of this is that it introduces the
SPANclause which can be used to create spanners.This deliberately deviates from the original spec due to objections mentioned here: rich-iannone/gtsql#1.
Spanners appear simple on the outside but actually have a bunch of associated logic:
Spanners span multiple columns, so table cells now can have >1 width which the html writer encodes in the
colspanattribute. Because column labels can be stretched, table cells can have >1 height, which the html writer encodes in therowspanattribute. Because the html writer renders the cells row-by-row, there is some bookkeeping going on to keep track of row-spanned cells. Together, I think this completes the layout logic of the writer.