diff --git a/CHANGELOG.md b/CHANGELOG.md
index eacd61a..4eb493c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,16 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## Unreleased
+
+### Added
+
+- **Tables that fill their width.** `TableColumn.Grow` gives a column a share of the width a table has
+ left once every column fits; a table with a growing column fills its width, and its heading rule
+ with it. HTML writes such a table as `ms-fill` (`width: 100%` in `LayoutCss`) with a `
`
+ giving each column its share of the width as the text layout draws it
+ (`Table.ColumnWidths`), so a narrower page squeezes every column alike. The serializer keeps it (`gr`).
+
## 2.10.0 — 2026-10-07
### Added
diff --git a/MarkupString.Html/Emitters/LayoutHtml.cs b/MarkupString.Html/Emitters/LayoutHtml.cs
index 8d25d2c..4169b22 100644
--- a/MarkupString.Html/Emitters/LayoutHtml.cs
+++ b/MarkupString.Html/Emitters/LayoutHtml.cs
@@ -412,9 +412,13 @@ private static void Grid(Grid grid, HtmlLayoutWriter html)
private static void Table(Table table, HtmlLayoutWriter html)
{
var columns = table.Columns.IsDefault ? [] : table.Columns;
+ var fills = columns.Any(column => column.Grow > 0);
html.Write("");
+ if (fills) html.Write(" ms-fill");
+ html.Write("\">");
+ if (fills) Proportions(table.ColumnWidths(html.Context, html.Width), html);
+ html.Write("");
foreach (var column in columns)
{
html.Write("
+ /// A filling table's columns as shares of the page's width, in the proportions the text layout
+ /// draws them at, so the growing columns take the room and the rest keep theirs; a narrower page
+ /// squeezes them all alike. A column the text layout leaves out, or a table it draws as cards,
+ /// gets no share. Where a narrow page hides the less important columns, lets
+ /// the rest share the width as the browser sees fit, so a hidden column's share is not left empty.
+ ///
+ private static void Proportions(ImmutableArray widths, HtmlLayoutWriter html)
+ {
+ var total = widths.Sum();
+ if (total == 0) return;
+ html.Write("");
+ foreach (var width in widths)
+ {
+ if (width == 0)
+ {
+ html.Write("");
+ continue;
+ }
+ html.Write("");
+ }
+ html.Write("");
+ }
+
///
/// A shaded block: its text clipped to the gradient, and its borders and rules drawn in it. A
/// browser has no way to run a gradient along characters or words, so those run across.
diff --git a/MarkupString.Html/LayoutCss.cs b/MarkupString.Html/LayoutCss.cs
index b474946..bad9b0c 100644
--- a/MarkupString.Html/LayoutCss.cs
+++ b/MarkupString.Html/LayoutCss.cs
@@ -82,10 +82,11 @@ public static class LayoutCss
".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" +
".ms-table-wrap { max-width: 100%; overflow-x: auto; }\n" +
".ms-table { border-collapse: collapse; }\n" +
+ ".ms-table.ms-fill { width: 100%; }\n" +
".ms-table th, .ms-table td { padding: 0 1ch; vertical-align: top; text-align: left; }\n" +
".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" +
".ms-table.ms-striped > tbody > tr:nth-child(even) { background: var(--ms-stripe, var(--ms-stripe-default, rgba(127, 127, 127, 0.12))); }\n" +
".ms-table .ms-nowrap, .ms-table .ms-nowrap .ms-text { white-space: pre; overflow-wrap: normal; }\n" +
- "@media (max-width: 48em) { .ms-table .ms-p3 { display: none; } }\n" +
+ "@media (max-width: 48em) { .ms-table .ms-p3 { display: none; } .ms-table.ms-fill > colgroup > col { width: auto !important; } }\n" +
"@media (max-width: 32em) { .ms-table .ms-p2 { display: none; } }\n";
}
diff --git a/MarkupString.Tests/Layout/WidgetLayoutTests.cs b/MarkupString.Tests/Layout/WidgetLayoutTests.cs
index a94b45d..9259463 100644
--- a/MarkupString.Tests/Layout/WidgetLayoutTests.cs
+++ b/MarkupString.Tests/Layout/WidgetLayoutTests.cs
@@ -196,6 +196,68 @@ await Assert.That(BlockLayout.Build(table, 60).Render(MarkupFormat.Html, Registr
.Contains("| When | |
Thu Oct 8 | ");
}
+ private static Table Growing(int nameShare, int doingShare) => Who() with
+ {
+ Columns =
+ [
+ new TableColumn(P("Name")) { Min = 6, Grow = nameShare },
+ new TableColumn(P("Idle")) { Alignment = Alignment.Right, Priority = 2, Wrap = false },
+ new TableColumn(P("Doing")) { Min = 8, Priority = 3, Grow = doingShare },
+ ],
+ };
+
+ [Test]
+ public async Task Table_AColumnThatGrows_FillsTheWidth()
+ {
+ var lines = BlockLayout.Build(Growing(0, 1), 60).ToPlainText().Split('\n');
+
+ await Assert.That(lines[1]).IsEqualTo(new string('-', 60)).Because("the heading rule spans the table, which now spans its width");
+ await Assert.That(lines[0].TrimEnd()).IsEqualTo("Name Idle Doing");
+ await Assert.That(lines[3].TrimEnd()).IsEqualTo("Raya 5m Writing a scene in the garden");
+ }
+
+ [Test]
+ public async Task Table_GrowingColumns_ShareTheSpareWidthByShare()
+ // 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.
+ => await Assert.That(Lines(Growing(1, 3), 60)[0]).IsEqualTo("Name" + new string(' ', 9) + "Idle Doing");
+
+ [Test]
+ public async Task Table_AGrowingColumn_StopsAtItsMax()
+ {
+ var table = Growing(0, 1) with { Columns = [.. Growing(0, 1).Columns.SetItem(2, Growing(0, 1).Columns[2] with { Max = 35 })] };
+
+ await Assert.That(Lines(table, 60)[1]).IsEqualTo(new string('-', 49)).Because("Doing grows from 29 to its 35 and no further");
+ }
+
+ [Test]
+ public async Task Table_TooWide_GrowsNothing()
+ => await Assert.That(Lines(Growing(1, 3), 30)).IsEquivalentTo(Lines(Who(), 30));
+
+ [Test]
+ public async Task Table_InHtml_AGrowingTableFillsThePage()
+ {
+ var html = BlockLayout.Build(Growing(1, 3), 60).Render(MarkupFormat.Html, Registry);
+
+ // Drawn as text at 60 the columns are 11, 4 and 41 cells; the page gets them as shares of 56.
+ await Assert.That(html).Contains("| Name | Idle | Doing | ");
+ await Assert.That(BlockLayout.Build(Who(), 60).Render(MarkupFormat.Html, Registry)).DoesNotContain("")
+ .Because("a table that does not fill is as wide as its cells");
+ await Assert.That(LayoutCss.Fixed).Contains(".ms-table.ms-fill { width: 100%; }");
+ await Assert.That(LayoutCss.Fixed).Contains(".ms-table.ms-fill > colgroup > col { width: auto !important; }")
+ .Because("on a page narrow enough to hide columns, their shares are given up");
+ }
+
+ [Test]
+ public async Task Table_Grow_SurvivesTheSerializer()
+ {
+ var text = BlockLayout.Build(Growing(1, 3), 60, fluid: true);
+
+ var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(text, Registry), Registry);
+
+ await Assert.That(BlockLayout.Relayout(read, 70, LayoutContext.Default).ToPlainText())
+ .IsEqualTo(BlockLayout.Build(Growing(1, 3), 70).ToPlainText());
+ }
+
///
/// The line under the headings spans the columns as drawn, gaps and separators included, whatever
/// the mode, never less than the widest row; a box round a narrower table is wider than its rule.
diff --git a/MarkupString/Layout/Blocks/LayoutJson.cs b/MarkupString/Layout/Blocks/LayoutJson.cs
index 68b338e..20a385d 100644
--- a/MarkupString/Layout/Blocks/LayoutJson.cs
+++ b/MarkupString/Layout/Blocks/LayoutJson.cs
@@ -214,6 +214,7 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
cw.Int("mx", column.Max);
cw.Int("p", column.Priority, 1);
cw.Bool("nw", !column.Wrap);
+ cw.Int("gr", column.Grow);
});
w.Array("rows", b.Rows.IsDefault ? [] : b.Rows, (row, rw) => rw.Blocks("c", row.IsDefault ? [] : row));
w.Int("g", b.Gap, 2);
@@ -229,6 +230,7 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
Max = cr.Int("mx", 0, 0, 4096),
Priority = cr.Int("p", 1, 1, 1000),
Wrap = !cr.Bool("nw"),
+ Grow = cr.Int("gr", 0, 0, 1000),
}),
r.Array("rows", rr => rr.Blocks("c")))
{
diff --git a/MarkupString/Layout/Blocks/Table.cs b/MarkupString/Layout/Blocks/Table.cs
index 40d519b..39d24e1 100644
--- a/MarkupString/Layout/Blocks/Table.cs
+++ b/MarkupString/Layout/Blocks/Table.cs
@@ -5,7 +5,8 @@ namespace MarkupString.Layout;
///
/// Rows under column headings. Columns grow to fit their widest cell; when the table is too wide, the
/// columns that may wrap give way first, then the least important columns are left out, and when
-/// even one column will not fit each row becomes a card of labelled values.
+/// even one column will not fit each row becomes a card of labelled values. When it is narrower than
+/// its width, the columns that share what is left, so the table fills it.
///
/// The columns, in order.
/// 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.
@@ -93,6 +94,35 @@ private void DrawCards(LayoutContext context, int width, IList lines
}
}
+ ///
+ /// The width each column is drawn at in cells, zero for one left out; empty
+ /// when the rows are drawn as cards instead. A format that lays the table out itself, such as HTML,
+ /// reads its proportions here.
+ ///
+ public ImmutableArray ColumnWidths(LayoutContext context, int width)
+ {
+ if (Columns.IsDefaultOrEmpty) return [];
+ var divider = Separator is { } drawn ? context.Glyph(drawn, " | ").DisplayWidth : Gap;
+ return Widths(context, width, divider) is { } widths ? [.. widths] : [];
+ }
+
+ ///
+ /// cells shared among the shown columns that grow, by their
+ /// shares; the cells a share rounds away go to the first of them.
+ /// None grows past its .
+ ///
+ private int[] Spread(int[] widths, List shown, int spare)
+ {
+ var growing = shown.Where(c => Columns[c].Grow > 0).ToList();
+ var shares = growing.Sum(c => Columns[c].Grow);
+ if (spare <= 0 || shares == 0) return widths;
+ var added = growing.ToDictionary(c => c, c => spare * Columns[c].Grow / shares);
+ added[growing[0]] += spare - added.Values.Sum();
+ foreach (var c in growing)
+ widths[c] = Columns[c].Max > 0 ? Math.Min(widths[c] + added[c], Math.Max(widths[c], Columns[c].Max)) : widths[c] + added[c];
+ return widths;
+ }
+
///
/// The width of each column, zero for one left out, or null when not even one column fits. Each
/// 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 lines
widths[widest]--;
overflow--;
}
- if (overflow <= 0) return widths;
+ if (overflow <= 0) return Spread(widths, active, -overflow);
// Leave out the least important column, the rightmost of those tied.
if (active.Count == 1) return null;
@@ -152,4 +182,10 @@ public sealed record TableColumn(MarkupText Header)
/// Whether its cells may wrap onto more lines; a column that may not is shown whole or left out.
public bool Wrap { get; init; } = true;
+
+ ///
+ /// Its share of the width the table has left over once every column fits, or zero for none. A
+ /// table with a column that grows fills its width; one without stays as wide as its cells.
+ ///
+ public int Grow { get; init; }
}
diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt
index 7dc5c58..f135526 100644
--- a/MarkupString/PublicAPI.Unshipped.txt
+++ b/MarkupString/PublicAPI.Unshipped.txt
@@ -1 +1,4 @@
#nullable enable
+MarkupString.Layout.Table.ColumnWidths(MarkupString.Layout.LayoutContext! context, int width) -> System.Collections.Immutable.ImmutableArray
+MarkupString.Layout.TableColumn.Grow.get -> int
+MarkupString.Layout.TableColumn.Grow.init -> void
|---|