Skip to content

Commit b4992d5

Browse files
Let a table column grow to fill the table's width (#35)
* Let a table column grow to fill the table's width A table was only as wide as its cells, so in a box or a screen whose other lines span the width its heading rule and columns stopped short. TableColumn.Grow gives a column a share of the width left over once every column fits; a table with one fills its width. HTML marks it ms-fill (width: 100%) and gives each growing heading its share as a percentage. A growing column stops at its Max, and the serializer keeps the share. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eAjtsQTFqWAeWQMc7ebNo * Give a filling table's columns their drawn proportions in HTML A percentage width on the growing heading made the browser squeeze every other column to its narrowest. The table now carries a colgroup with each column's share of the width as the text layout draws it (Table.ColumnWidths), and a page narrow enough to hide columns lets the rest share the width freely. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eAjtsQTFqWAeWQMc7ebNo --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f368d7 commit b4992d5

7 files changed

Lines changed: 148 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget
66
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
77
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

9+
## Unreleased
10+
11+
### Added
12+
13+
- **Tables that fill their width.** `TableColumn.Grow` gives a column a share of the width a table has
14+
left once every column fits; a table with a growing column fills its width, and its heading rule
15+
with it. HTML writes such a table as `ms-fill` (`width: 100%` in `LayoutCss`) with a `<colgroup>`
16+
giving each column its share of the width as the text layout draws it
17+
(`Table.ColumnWidths`), so a narrower page squeezes every column alike. The serializer keeps it (`gr`).
18+
919
## 2.10.0 — 2026-10-07
1020

1121
### Added

‎MarkupString.Html/Emitters/LayoutHtml.cs‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,9 +412,13 @@ private static void Grid(Grid grid, HtmlLayoutWriter html)
412412
private static void Table(Table table, HtmlLayoutWriter html)
413413
{
414414
var columns = table.Columns.IsDefault ? [] : table.Columns;
415+
var fills = columns.Any(column => column.Grow > 0);
415416
html.Write("<div class=\"ms-table-wrap\"><table class=\"ms-table");
416417
if (table.Striped) html.Write(" ms-striped");
417-
html.Write("\"><thead><tr>");
418+
if (fills) html.Write(" ms-fill");
419+
html.Write("\">");
420+
if (fills) Proportions(table.ColumnWidths(html.Context, html.Width), html);
421+
html.Write("<thead><tr>");
418422
foreach (var column in columns)
419423
{
420424
html.Write("<th scope=\"col\"");
@@ -466,6 +470,32 @@ private static void Cell(TableColumn column, HtmlLayoutWriter html)
466470
}
467471
}
468472

473+
/// <summary>
474+
/// A filling table's columns as shares of the page's width, in the proportions the text layout
475+
/// draws them at, so the growing columns take the room and the rest keep theirs; a narrower page
476+
/// squeezes them all alike. A column the text layout leaves out, or a table it draws as cards,
477+
/// gets no share. Where a narrow page hides the less important columns, <see cref="LayoutCss"/> lets
478+
/// the rest share the width as the browser sees fit, so a hidden column's share is not left empty.
479+
/// </summary>
480+
private static void Proportions(ImmutableArray<int> widths, HtmlLayoutWriter html)
481+
{
482+
var total = widths.Sum();
483+
if (total == 0) return;
484+
html.Write("<colgroup>");
485+
foreach (var width in widths)
486+
{
487+
if (width == 0)
488+
{
489+
html.Write("<col>");
490+
continue;
491+
}
492+
html.Write("<col style=\"width:");
493+
html.Write(Number((int)Math.Round(width * 100.0 / total)));
494+
html.Write("%\">");
495+
}
496+
html.Write("</colgroup>");
497+
}
498+
469499
/// <summary>
470500
/// A shaded block: its text clipped to the gradient, and its borders and rules drawn in it. A
471501
/// browser has no way to run a gradient along characters or words, so those run across.

‎MarkupString.Html/LayoutCss.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ public static class LayoutCss
8282
".ms-shaded :is(.ms-box, .ms-gauge-bar), .ms-shaded :is(.ms-rule, .ms-divider)::before, .ms-shaded :is(.ms-rule, .ms-divider)::after { border-image: var(--ms-shade) 1; }\n" +
8383
".ms-table-wrap { max-width: 100%; overflow-x: auto; }\n" +
8484
".ms-table { border-collapse: collapse; }\n" +
85+
".ms-table.ms-fill { width: 100%; }\n" +
8586
".ms-table th, .ms-table td { padding: 0 1ch; vertical-align: top; text-align: left; }\n" +
8687
".ms-table th { border-bottom: 1px solid var(--ms-header-rule, var(--ms-header-rule-default, currentColor)); color: var(--ms-heading, var(--ms-heading-default, inherit)); }\n" +
8788
".ms-table.ms-striped > tbody > tr:nth-child(even) { background: var(--ms-stripe, var(--ms-stripe-default, rgba(127, 127, 127, 0.12))); }\n" +
8889
".ms-table .ms-nowrap, .ms-table .ms-nowrap .ms-text { white-space: pre; overflow-wrap: normal; }\n" +
89-
"@media (max-width: 48em) { .ms-table .ms-p3 { display: none; } }\n" +
90+
"@media (max-width: 48em) { .ms-table .ms-p3 { display: none; } .ms-table.ms-fill > colgroup > col { width: auto !important; } }\n" +
9091
"@media (max-width: 32em) { .ms-table .ms-p2 { display: none; } }\n";
9192
}

‎MarkupString.Tests/Layout/WidgetLayoutTests.cs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,68 @@ await Assert.That(BlockLayout.Build(table, 60).Render(MarkupFormat.Html, Registr
196196
.Contains("<th scope=\"col\" class=\"ms-nowrap\">When</th></tr></thead><tbody><tr><td class=\"ms-nowrap\"><div class=\"ms-text\">Thu Oct 8</div></td>");
197197
}
198198

199+
private static Table Growing(int nameShare, int doingShare) => Who() with
200+
{
201+
Columns =
202+
[
203+
new TableColumn(P("Name")) { Min = 6, Grow = nameShare },
204+
new TableColumn(P("Idle")) { Alignment = Alignment.Right, Priority = 2, Wrap = false },
205+
new TableColumn(P("Doing")) { Min = 8, Priority = 3, Grow = doingShare },
206+
],
207+
};
208+
209+
[Test]
210+
public async Task Table_AColumnThatGrows_FillsTheWidth()
211+
{
212+
var lines = BlockLayout.Build(Growing(0, 1), 60).ToPlainText().Split('\n');
213+
214+
await Assert.That(lines[1]).IsEqualTo(new string('-', 60)).Because("the heading rule spans the table, which now spans its width");
215+
await Assert.That(lines[0].TrimEnd()).IsEqualTo("Name Idle Doing");
216+
await Assert.That(lines[3].TrimEnd()).IsEqualTo("Raya 5m Writing a scene in the garden");
217+
}
218+
219+
[Test]
220+
public async Task Table_GrowingColumns_ShareTheSpareWidthByShare()
221+
// 43 cells as drawn leaves 17 at 60: Name gets 17/4 = 4 and the cell the shares round away (11 wide, then the gap), Doing 12.
222+
=> await Assert.That(Lines(Growing(1, 3), 60)[0]).IsEqualTo("Name" + new string(' ', 9) + "Idle Doing");
223+
224+
[Test]
225+
public async Task Table_AGrowingColumn_StopsAtItsMax()
226+
{
227+
var table = Growing(0, 1) with { Columns = [.. Growing(0, 1).Columns.SetItem(2, Growing(0, 1).Columns[2] with { Max = 35 })] };
228+
229+
await Assert.That(Lines(table, 60)[1]).IsEqualTo(new string('-', 49)).Because("Doing grows from 29 to its 35 and no further");
230+
}
231+
232+
[Test]
233+
public async Task Table_TooWide_GrowsNothing()
234+
=> await Assert.That(Lines(Growing(1, 3), 30)).IsEquivalentTo(Lines(Who(), 30));
235+
236+
[Test]
237+
public async Task Table_InHtml_AGrowingTableFillsThePage()
238+
{
239+
var html = BlockLayout.Build(Growing(1, 3), 60).Render(MarkupFormat.Html, Registry);
240+
241+
// Drawn as text at 60 the columns are 11, 4 and 41 cells; the page gets them as shares of 56.
242+
await Assert.That(html).Contains("<table class=\"ms-table ms-fill\"><colgroup><col style=\"width:20%\"><col style=\"width:7%\"><col style=\"width:73%\"></colgroup><thead><tr><th scope=\"col\">Name</th><th scope=\"col\" class=\"ms-p2 ms-nowrap\" style=\"text-align:right\">Idle</th><th scope=\"col\" class=\"ms-p3\">Doing</th>");
243+
await Assert.That(BlockLayout.Build(Who(), 60).Render(MarkupFormat.Html, Registry)).DoesNotContain("<colgroup>")
244+
.Because("a table that does not fill is as wide as its cells");
245+
await Assert.That(LayoutCss.Fixed).Contains(".ms-table.ms-fill { width: 100%; }");
246+
await Assert.That(LayoutCss.Fixed).Contains(".ms-table.ms-fill > colgroup > col { width: auto !important; }")
247+
.Because("on a page narrow enough to hide columns, their shares are given up");
248+
}
249+
250+
[Test]
251+
public async Task Table_Grow_SurvivesTheSerializer()
252+
{
253+
var text = BlockLayout.Build(Growing(1, 3), 60, fluid: true);
254+
255+
var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(text, Registry), Registry);
256+
257+
await Assert.That(BlockLayout.Relayout(read, 70, LayoutContext.Default).ToPlainText())
258+
.IsEqualTo(BlockLayout.Build(Growing(1, 3), 70).ToPlainText());
259+
}
260+
199261
/// <summary>
200262
/// The line under the headings spans the columns as drawn, gaps and separators included, whatever
201263
/// the mode, never less than the widest row; a box round a narrower table is wider than its rule.

‎MarkupString/Layout/Blocks/LayoutJson.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
214214
cw.Int("mx", column.Max);
215215
cw.Int("p", column.Priority, 1);
216216
cw.Bool("nw", !column.Wrap);
217+
cw.Int("gr", column.Grow);
217218
});
218219
w.Array("rows", b.Rows.IsDefault ? [] : b.Rows, (row, rw) => rw.Blocks("c", row.IsDefault ? [] : row));
219220
w.Int("g", b.Gap, 2);
@@ -229,6 +230,7 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
229230
Max = cr.Int("mx", 0, 0, 4096),
230231
Priority = cr.Int("p", 1, 1, 1000),
231232
Wrap = !cr.Bool("nw"),
233+
Grow = cr.Int("gr", 0, 0, 1000),
232234
}),
233235
r.Array("rows", rr => rr.Blocks("c")))
234236
{

‎MarkupString/Layout/Blocks/Table.cs‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ namespace MarkupString.Layout;
55
/// <summary>
66
/// Rows under column headings. Columns grow to fit their widest cell; when the table is too wide, the
77
/// columns that may wrap give way first, then the least important columns are left out, and when
8-
/// even one column will not fit each row becomes a card of labelled values.
8+
/// even one column will not fit each row becomes a card of labelled values. When it is narrower than
9+
/// its width, the columns that <see cref="TableColumn.Grow"/> share what is left, so the table fills it.
910
/// </summary>
1011
/// <param name="Columns">The columns, in order.</param>
1112
/// <param name="Rows">The rows, a cell per column; a short row is padded with empty cells. A cell's text takes its column's alignment unless it sets its own.</param>
@@ -93,6 +94,35 @@ private void DrawCards(LayoutContext context, int width, IList<MarkupText> lines
9394
}
9495
}
9596

97+
/// <summary>
98+
/// The width each column is drawn at in <paramref name="width"/> cells, zero for one left out; empty
99+
/// when the rows are drawn as cards instead. A format that lays the table out itself, such as HTML,
100+
/// reads its proportions here.
101+
/// </summary>
102+
public ImmutableArray<int> ColumnWidths(LayoutContext context, int width)
103+
{
104+
if (Columns.IsDefaultOrEmpty) return [];
105+
var divider = Separator is { } drawn ? context.Glyph(drawn, " | ").DisplayWidth : Gap;
106+
return Widths(context, width, divider) is { } widths ? [.. widths] : [];
107+
}
108+
109+
/// <summary>
110+
/// <paramref name="spare"/> cells shared among the shown columns that grow, by their
111+
/// <see cref="TableColumn.Grow"/> shares; the cells a share rounds away go to the first of them.
112+
/// None grows past its <see cref="TableColumn.Max"/>.
113+
/// </summary>
114+
private int[] Spread(int[] widths, List<int> shown, int spare)
115+
{
116+
var growing = shown.Where(c => Columns[c].Grow > 0).ToList();
117+
var shares = growing.Sum(c => Columns[c].Grow);
118+
if (spare <= 0 || shares == 0) return widths;
119+
var added = growing.ToDictionary(c => c, c => spare * Columns[c].Grow / shares);
120+
added[growing[0]] += spare - added.Values.Sum();
121+
foreach (var c in growing)
122+
widths[c] = Columns[c].Max > 0 ? Math.Min(widths[c] + added[c], Math.Max(widths[c], Columns[c].Max)) : widths[c] + added[c];
123+
return widths;
124+
}
125+
96126
/// <summary>
97127
/// The width of each column, zero for one left out, or null when not even one column fits. Each
98128
/// column asks for its widest cell; too wide, the columns that wrap give way, widest first, down to
@@ -124,7 +154,7 @@ private void DrawCards(LayoutContext context, int width, IList<MarkupText> lines
124154
widths[widest]--;
125155
overflow--;
126156
}
127-
if (overflow <= 0) return widths;
157+
if (overflow <= 0) return Spread(widths, active, -overflow);
128158

129159
// Leave out the least important column, the rightmost of those tied.
130160
if (active.Count == 1) return null;
@@ -152,4 +182,10 @@ public sealed record TableColumn(MarkupText Header)
152182

153183
/// <summary>Whether its cells may wrap onto more lines; a column that may not is shown whole or left out.</summary>
154184
public bool Wrap { get; init; } = true;
185+
186+
/// <summary>
187+
/// Its share of the width the table has left over once every column fits, or zero for none. A
188+
/// table with a column that grows fills its width; one without stays as wide as its cells.
189+
/// </summary>
190+
public int Grow { get; init; }
155191
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
#nullable enable
2+
MarkupString.Layout.Table.ColumnWidths(MarkupString.Layout.LayoutContext! context, int width) -> System.Collections.Immutable.ImmutableArray<int>
3+
MarkupString.Layout.TableColumn.Grow.get -> int
4+
MarkupString.Layout.TableColumn.Grow.init -> void

0 commit comments

Comments
 (0)