From ccee6ba55cc41cd8b1c4155a5d60cbb1f8dd7ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:58:16 +0000 Subject: [PATCH 1/2] 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) Claude-Session: https://claude.ai/code/session_018eAjtsQTFqWAeWQMc7ebNo --- CHANGELOG.md | 9 +++ MarkupString.Html/Emitters/LayoutHtml.cs | 28 ++++++--- MarkupString.Html/LayoutCss.cs | 1 + .../Layout/WidgetLayoutTests.cs | 58 +++++++++++++++++++ MarkupString/Layout/Blocks/LayoutJson.cs | 2 + MarkupString/Layout/Blocks/Table.cs | 28 ++++++++- MarkupString/PublicAPI.Unshipped.txt | 2 + 7 files changed, 119 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f2fa0..cff7088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ 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`) and each growing + heading's share as a percentage width. The serializer keeps it (`gr`). + ## 2.9.0 — 2026-10-07 ### Added diff --git a/MarkupString.Html/Emitters/LayoutHtml.cs b/MarkupString.Html/Emitters/LayoutHtml.cs index 8d25d2c..8a0c8ff 100644 --- a/MarkupString.Html/Emitters/LayoutHtml.cs +++ b/MarkupString.Html/Emitters/LayoutHtml.cs @@ -412,13 +412,16 @@ private static void Grid(Grid grid, HtmlLayoutWriter html) private static void Table(Table table, HtmlLayoutWriter html) { var columns = table.Columns.IsDefault ? [] : table.Columns; + var shares = columns.Sum(column => column.Grow); html.Write("
0) html.Write(" ms-fill"); html.Write("\">"); foreach (var column in columns) { html.Write(""); @@ -430,7 +433,7 @@ private static void Table(Table table, HtmlLayoutWriter html) for (var c = 0; c < columns.Length; c++) { html.Write(""); if (!row.IsDefault && c < row.Length) html.Block(row[c]); html.Write(""); @@ -442,9 +445,10 @@ private static void Table(Table table, HtmlLayoutWriter html) /// /// A cell's alignment and its classes: ms-p2/ms-p3 for a column that may be left out on - /// a narrow page, ms-nowrap for one whose cells stay on one line. + /// a narrow page, ms-nowrap for one whose cells stay on one line; on a heading, the + /// of a filling table's width its column grows into. /// - private static void Cell(TableColumn column, HtmlLayoutWriter html) + private static void Cell(TableColumn column, HtmlLayoutWriter html, int percent) { if (column.Priority >= 2 || !column.Wrap) { @@ -458,12 +462,22 @@ private static void Cell(TableColumn column, HtmlLayoutWriter html) if (!column.Wrap) html.Write("ms-nowrap"); html.Write("\""); } - if (column.Alignment is Alignment.Right or Alignment.Center) + var aligned = column.Alignment is Alignment.Right or Alignment.Center; + if (!aligned && percent == 0) return; + html.Write(" style=\""); + if (aligned) { - html.Write(" style=\"text-align:"); + html.Write("text-align:"); html.Write(column.Alignment == Alignment.Right ? "right" : "center"); - html.Write("\""); } + if (percent > 0) + { + if (aligned) html.Write(";"); + html.Write("width:"); + html.Write(Number(percent)); + html.Write("%"); + } + html.Write("\""); } /// diff --git a/MarkupString.Html/LayoutCss.cs b/MarkupString.Html/LayoutCss.cs index b474946..420912f 100644 --- a/MarkupString.Html/LayoutCss.cs +++ b/MarkupString.Html/LayoutCss.cs @@ -82,6 +82,7 @@ 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" + diff --git a/MarkupString.Tests/Layout/WidgetLayoutTests.cs b/MarkupString.Tests/Layout/WidgetLayoutTests.cs index a94b45d..0893514 100644 --- a/MarkupString.Tests/Layout/WidgetLayoutTests.cs +++ b/MarkupString.Tests/Layout/WidgetLayoutTests.cs @@ -196,6 +196,64 @@ await Assert.That(BlockLayout.Build(table, 60).Render(MarkupFormat.Html, Registr .Contains(""); } + 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); + + await Assert.That(html).Contains("
0 && column.Grow > 0 ? column.Grow * 100 / shares : 0); html.Write(">"); html.Text(column.Header); html.Write("When
Thu Oct 8
"); + await Assert.That(html).Contains("").Because("the share is on the heading alone"); + await Assert.That(LayoutCss.Fixed).Contains(".ms-table.ms-fill { width: 100%; }"); + } + + [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..a04b065 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,23 @@ private void DrawCards(LayoutContext context, int width, IList lines } } + /// + /// 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 +142,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 +170,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 78426d9..3888ed1 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -946,3 +946,5 @@ MarkupString.ThemePalette.Harmony.init -> void MarkupString.ThemePalette.InMode(MarkupString.ThemeMode mode) -> MarkupString.ThemePalette! MarkupString.ThemePalette.Seed.get -> MarkupString.RgbColor? MarkupString.ThemePalette.Seed.init -> void +MarkupString.Layout.TableColumn.Grow.get -> int +MarkupString.Layout.TableColumn.Grow.init -> void From aae662942da4226dbeaa3a61ced74693ddffddfd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:19:46 +0000 Subject: [PATCH 2/2] 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) Claude-Session: https://claude.ai/code/session_018eAjtsQTFqWAeWQMc7ebNo --- CHANGELOG.md | 5 +- MarkupString.Html/Emitters/LayoutHtml.cs | 56 ++++++++++++------- MarkupString.Html/LayoutCss.cs | 2 +- .../Layout/WidgetLayoutTests.cs | 8 ++- MarkupString/Layout/Blocks/Table.cs | 12 ++++ MarkupString/PublicAPI.Unshipped.txt | 1 + 6 files changed, 59 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cff7088..c60bd57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **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`) and each growing - heading's share as a percentage width. The serializer keeps it (`gr`). + 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.9.0 — 2026-10-07 diff --git a/MarkupString.Html/Emitters/LayoutHtml.cs b/MarkupString.Html/Emitters/LayoutHtml.cs index 8a0c8ff..4169b22 100644 --- a/MarkupString.Html/Emitters/LayoutHtml.cs +++ b/MarkupString.Html/Emitters/LayoutHtml.cs @@ -412,16 +412,17 @@ private static void Grid(Grid grid, HtmlLayoutWriter html) private static void Table(Table table, HtmlLayoutWriter html) { var columns = table.Columns.IsDefault ? [] : table.Columns; - var shares = columns.Sum(column => column.Grow); + var fills = columns.Any(column => column.Grow > 0); html.Write("
NameIdleDoing
Mannaz
0) html.Write(" ms-fill"); - 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(""); @@ -433,7 +434,7 @@ private static void Table(Table table, HtmlLayoutWriter html) for (var c = 0; c < columns.Length; c++) { html.Write(""); if (!row.IsDefault && c < row.Length) html.Block(row[c]); html.Write(""); @@ -445,10 +446,9 @@ private static void Table(Table table, HtmlLayoutWriter html) /// /// A cell's alignment and its classes: ms-p2/ms-p3 for a column that may be left out on - /// a narrow page, ms-nowrap for one whose cells stay on one line; on a heading, the - /// of a filling table's width its column grows into. + /// a narrow page, ms-nowrap for one whose cells stay on one line. /// - private static void Cell(TableColumn column, HtmlLayoutWriter html, int percent) + private static void Cell(TableColumn column, HtmlLayoutWriter html) { if (column.Priority >= 2 || !column.Wrap) { @@ -462,22 +462,38 @@ private static void Cell(TableColumn column, HtmlLayoutWriter html, int percent) if (!column.Wrap) html.Write("ms-nowrap"); html.Write("\""); } - var aligned = column.Alignment is Alignment.Right or Alignment.Center; - if (!aligned && percent == 0) return; - html.Write(" style=\""); - if (aligned) + if (column.Alignment is Alignment.Right or Alignment.Center) { - html.Write("text-align:"); + html.Write(" style=\"text-align:"); html.Write(column.Alignment == Alignment.Right ? "right" : "center"); + html.Write("\""); } - if (percent > 0) + } + + /// + /// 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 (aligned) html.Write(";"); - html.Write("width:"); - html.Write(Number(percent)); - html.Write("%"); + if (width == 0) + { + html.Write(""); + continue; + } + html.Write(""); } - html.Write("\""); + html.Write(""); } /// diff --git a/MarkupString.Html/LayoutCss.cs b/MarkupString.Html/LayoutCss.cs index 420912f..bad9b0c 100644 --- a/MarkupString.Html/LayoutCss.cs +++ b/MarkupString.Html/LayoutCss.cs @@ -87,6 +87,6 @@ public static class LayoutCss ".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 0893514..9259463 100644 --- a/MarkupString.Tests/Layout/WidgetLayoutTests.cs +++ b/MarkupString.Tests/Layout/WidgetLayoutTests.cs @@ -238,9 +238,13 @@ public async Task Table_InHtml_AGrowingTableFillsThePage() { var html = BlockLayout.Build(Growing(1, 3), 60).Render(MarkupFormat.Html, Registry); - await Assert.That(html).Contains("
0 && column.Grow > 0 ? column.Grow * 100 / shares : 0); + Cell(column, html); html.Write(">"); html.Text(column.Header); html.Write("
"); - await Assert.That(html).Contains("").Because("the share is on the heading alone"); + // 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("
NameIdleDoing
Mannaz
"); + 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] diff --git a/MarkupString/Layout/Blocks/Table.cs b/MarkupString/Layout/Blocks/Table.cs index a04b065..39d24e1 100644 --- a/MarkupString/Layout/Blocks/Table.cs +++ b/MarkupString/Layout/Blocks/Table.cs @@ -94,6 +94,18 @@ 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. diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 3888ed1..7f088b6 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -948,3 +948,4 @@ MarkupString.ThemePalette.Seed.get -> MarkupString.RgbColor? MarkupString.ThemePalette.Seed.init -> void MarkupString.Layout.TableColumn.Grow.get -> int MarkupString.Layout.TableColumn.Grow.init -> void +MarkupString.Layout.Table.ColumnWidths(MarkupString.Layout.LayoutContext! context, int width) -> System.Collections.Immutable.ImmutableArray
NameIdleDoing