Skip to content

Table spans - #553

Draft
teunbrand wants to merge 8 commits into
table_labelsfrom
table_spans
Draft

teunbrand wants to merge 8 commits into
table_labelsfrom
table_spans

Conversation

@teunbrand

@teunbrand teunbrand commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

This PR stacks on top of #551.

The user-facing part of this is that it introduces the SPAN clause which can be used to create spanners.
This deliberately deviates from the original spec due to objections mentioned here: rich-iannone/gtsql#1.

TABULATE FROM ggsql:penguins
  SPAN 'Bill' ACROSS bill_dep, bill_len

Spanners appear simple on the outside but actually have a bunch of associated logic:

  • It reorders columns to gather spanned columns together when possible
  • When multiple spanners are declared, they each need to have levels so they do not collide
  • We need to account for fragmented (disjoint) spanners that have >1 start and end column (when column order doesn't allow for contiguous spanners)
  • The column labels need to be stretched into empty spanner territory.
  • There is some bookkeeping around spanner IDs

Spanners span multiple columns, so table cells now can have >1 width which the html writer encodes in the colspan attribute. Because column labels can be stretched, table cells can have >1 height, which the html writer encodes in the rowspan attribute. 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.

teunbrand and others added 8 commits September 16, 2026 12:45
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
teunbrand added this pull request to stack #552 September 17, 2026 09:46
@teunbrand

Copy link
Copy Markdown
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 d doesn't reach up to spanner Z for example).

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
image

Created on 2026-09-17 with reprex v2.1.1

@teunbrand

teunbrand commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another idea I had, but not implemented here is maybe we could do this:

TABULATE FROM ggsql:penguins
  SPAN 'Bill' ACROSS bill_len AS 'Length', bill_dep AS 'Depth'

So we could rename the spanned columns. Currently we have no grammar precedent for using identifier - AS keyword - string anywhere, so I didn't include this.

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.

1 participant