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(""); + 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(""); } + 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("
+ /// 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("
When
Thu Oct 8
"); + 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
NameIdleDoing