Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<colgroup>`
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
Expand Down
32 changes: 31 additions & 1 deletion MarkupString.Html/Emitters/LayoutHtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<div class=\"ms-table-wrap\"><table class=\"ms-table");
if (table.Striped) html.Write(" ms-striped");
html.Write("\"><thead><tr>");
if (fills) html.Write(" ms-fill");
html.Write("\">");
if (fills) Proportions(table.ColumnWidths(html.Context, html.Width), html);
html.Write("<thead><tr>");
foreach (var column in columns)
{
html.Write("<th scope=\"col\"");
Expand Down Expand Up @@ -466,6 +470,32 @@ private static void Cell(TableColumn column, HtmlLayoutWriter html)
}
}

/// <summary>
/// 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, <see cref="LayoutCss"/> lets
/// the rest share the width as the browser sees fit, so a hidden column's share is not left empty.
/// </summary>
private static void Proportions(ImmutableArray<int> widths, HtmlLayoutWriter html)
{
var total = widths.Sum();
if (total == 0) return;
html.Write("<colgroup>");
foreach (var width in widths)
{
if (width == 0)
{
html.Write("<col>");
continue;
}
html.Write("<col style=\"width:");
html.Write(Number((int)Math.Round(width * 100.0 / total)));
html.Write("%\">");
}
html.Write("</colgroup>");
}

/// <summary>
/// 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.
Expand Down
3 changes: 2 additions & 1 deletion MarkupString.Html/LayoutCss.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
62 changes: 62 additions & 0 deletions MarkupString.Tests/Layout/WidgetLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,68 @@ await Assert.That(BlockLayout.Build(table, 60).Render(MarkupFormat.Html, Registr
.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>");
}

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("<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>");
await Assert.That(BlockLayout.Build(Who(), 60).Render(MarkupFormat.Html, Registry)).DoesNotContain("<colgroup>")
.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());
}

/// <summary>
/// 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.
Expand Down
2 changes: 2 additions & 0 deletions MarkupString/Layout/Blocks/LayoutJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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")))
{
Expand Down
40 changes: 38 additions & 2 deletions MarkupString/Layout/Blocks/Table.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace MarkupString.Layout;
/// <summary>
/// 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 <see cref="TableColumn.Grow"/> share what is left, so the table fills it.
/// </summary>
/// <param name="Columns">The columns, in order.</param>
/// <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>
Expand Down Expand Up @@ -93,6 +94,35 @@ private void DrawCards(LayoutContext context, int width, IList<MarkupText> lines
}
}

/// <summary>
/// The width each column is drawn at in <paramref name="width"/> 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.
/// </summary>
public ImmutableArray<int> 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] : [];
}

/// <summary>
/// <paramref name="spare"/> cells shared among the shown columns that grow, by their
/// <see cref="TableColumn.Grow"/> shares; the cells a share rounds away go to the first of them.
/// None grows past its <see cref="TableColumn.Max"/>.
/// </summary>
private int[] Spread(int[] widths, List<int> 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;
}

/// <summary>
/// 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
Expand Down Expand Up @@ -124,7 +154,7 @@ private void DrawCards(LayoutContext context, int width, IList<MarkupText> 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;
Expand Down Expand Up @@ -152,4 +182,10 @@ public sealed record TableColumn(MarkupText Header)

/// <summary>Whether its cells may wrap onto more lines; a column that may not is shown whole or left out.</summary>
public bool Wrap { get; init; } = true;

/// <summary>
/// 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.
/// </summary>
public int Grow { get; init; }
}
3 changes: 3 additions & 0 deletions MarkupString/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
#nullable enable
MarkupString.Layout.Table.ColumnWidths(MarkupString.Layout.LayoutContext! context, int width) -> System.Collections.Immutable.ImmutableArray<int>
MarkupString.Layout.TableColumn.Grow.get -> int
MarkupString.Layout.TableColumn.Grow.init -> void
Loading