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 @@ -8,6 +8,16 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- **A rule or box edge holds several titles.** `Rule.Titles` and `Frame.Titles` add titles beside the
first, each an `EdgeTitle` placed left, centre or right, so one line carries
`=< Wren >=========< Scene 3 >=< 21:04 >=`. `Frame.BottomTitles` puts titles in a box's bottom
edge. Titles that do not fit are left out by `EdgeTitle.Priority`, highest first, as a table's
columns are; unset, a title takes its side's (left 1, right 2, centre 3). A screen
reader hears them left to right, HTML draws them as a flex row, and the JSON codec keeps them
(`tt`, `bt`). `BlockText.Edge` takes a list of titles for blocks of your own.

## 2.15.5 — 2026-10-09

### Changed
Expand Down
85 changes: 76 additions & 9 deletions MarkupString.Html/Emitters/LayoutHtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,38 +79,105 @@ private static void Frame(Frame frame, HtmlLayoutWriter html)
html.Write("ch\"");
}
html.Write(">");
if (frame.Title is { Length: > 0 } title)
var top = Titles(frame.Title, frame.TitleAlignment, frame.Titles);
if (top.Count > 1)
{
// Several titles share the top edge: the legend spans it and draws the line between them.
html.Write("<legend class=\"ms-box-title ms-titles ms-border-");
html.Write(Css(BorderOf(frame.Border, html).Name));
html.Write("\">");
EdgeTitles(top, html);
html.Write("</legend>");
}
else if (top.Count == 1)
{
html.Write("<legend class=\"ms-box-title\"");
Align(frame.TitleAlignment, html);
Align(top[0].Side, html);
html.Write(">");
html.Text(title);
html.Text(top[0].Text);
html.Write("</legend>");
}
foreach (var child in frame.Body is Stack { Children.IsDefault: false } body ? body.Children : [frame.Body])
{
if (child is Rule divider) Rule(divider, "ms-divider", divider.Border ?? frame.Border, html);
else html.Block(child);
}
var bottom = Titles(null, Alignment.Center, frame.BottomTitles);
if (bottom.Count > 0)
{
// The bottom edge, drawn as a line with its titles in place of the box's own bottom border.
html.Write("<div class=\"ms-box-bottom ms-titles ms-border-");
html.Write(Css(BorderOf(frame.Border, html).Name));
html.Write("\">");
EdgeTitles(bottom, html);
html.Write("</div>");
}
html.Write("</fieldset>");
}

/// <summary><paramref name="title"/> at <paramref name="alignment"/>, then <paramref name="more"/>; the empty ones left out.</summary>
private static List<EdgeTitle> Titles(MarkupText? title, Alignment alignment, ImmutableArray<EdgeTitle> more)
{
var titles = new List<EdgeTitle>();
if (title is { Length: > 0 }) titles.Add(new EdgeTitle(title, alignment));
titles.AddRange((more.IsDefault ? [] : more).Where(extra => extra is { Text.Length: > 0 }));
return titles;
}

private static Alignment Side(Alignment side) => side is Alignment.Left or Alignment.Right ? side : Alignment.Center;

/// <summary>
/// The titles in place along an edge: left ones against the start, middle ones centred, right ones against the end,
/// a short line between neighbours on a side and a stretching line between the sides. A terminal leaves out what does
/// not fit; a page wraps it instead.
/// </summary>
private static void EdgeTitles(List<EdgeTitle> titles, HtmlLayoutWriter html)
{
var groups = new[] { Alignment.Left, Alignment.Center, Alignment.Right }
.Select(side => (Side: side, Titles: titles.Where(title => Side(title.Side) == side).ToList()))
.Where(group => group.Titles.Count > 0)
.ToList();
html.Write(groups[0].Side == Alignment.Left ? "<span class=\"ms-line ms-end\"></span>" : "<span class=\"ms-line\"></span>");
for (var g = 0; g < groups.Count; g++)
{
if (g > 0) html.Write("<span class=\"ms-line\"></span>");
for (var t = 0; t < groups[g].Titles.Count; t++)
{
if (t > 0) html.Write("<span class=\"ms-line ms-end\"></span>");
html.Write("<span class=\"ms-rule-title\">");
html.Text(groups[g].Titles[t].Text);
html.Write("</span>");
}
}
html.Write(groups[^1].Side == Alignment.Right ? "<span class=\"ms-line ms-end\"></span>" : "<span class=\"ms-line\"></span>");
}

private static BorderStyle BorderOf(BorderStyle? border, HtmlLayoutWriter html) => border ?? html.Context.Theme.Border ?? BorderStyle.Single;

private static void Rule(Rule rule, string kind, BorderStyle? border, HtmlLayoutWriter html)
{
var titles = Titles(rule.Title, rule.TitleAlignment, rule.Titles);
html.Write("<div class=\"");
html.Write(kind);
if (titles.Count > 1) html.Write(" ms-titles");
html.Write(" ms-border-");
html.Write(Css(BorderOf(border, html).Name));
html.Write("\" role=\"separator\"");
Align(rule.TitleAlignment, html);
html.Write(">");
if (rule.Title is { Length: > 0 } title)
if (titles.Count > 1)
{
html.Write("<span class=\"ms-rule-title\">");
html.Text(title);
html.Write("</span>");
html.Write(">");
EdgeTitles(titles, html);
}
else
{
Align(titles.Count == 1 ? titles[0].Side : rule.TitleAlignment, html);
html.Write(">");
if (titles.Count == 1)
{
html.Write("<span class=\"ms-rule-title\">");
html.Text(titles[0].Text);
html.Write("</span>");
}
}
html.Write("</div>");
}
Expand Down
10 changes: 9 additions & 1 deletion MarkupString.Html/LayoutCss.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ public static class LayoutCss
".ms-rule[data-align=\"left\"]::before, .ms-divider[data-align=\"left\"]::before { flex: 0 0 1ch; }\n" +
".ms-rule[data-align=\"right\"]::after, .ms-divider[data-align=\"right\"]::after { flex: 0 0 1ch; }\n" +
".ms-divider { margin: 0 calc(-1 * var(--ms-pad, 1ch)); }\n" +
".ms-titles::before, .ms-titles::after { content: none; }\n" +
".ms-line { flex: 1 1 0; min-width: 1ch; border-top: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); }\n" +
".ms-line.ms-end { flex: 0 0 1ch; }\n" +
".ms-border-double.ms-titles > .ms-line { border-top: 3px double var(--ms-border, var(--ms-border-default, currentColor)); }\n" +
".ms-border-none.ms-titles > .ms-line { border-top: none; }\n" +
".ms-box-title.ms-titles { display: flex; align-items: center; gap: 1ch; flex-wrap: wrap; padding: 0; margin-inline: calc(-1 * var(--ms-pad)); width: calc(100% + 2 * var(--ms-pad)); box-sizing: border-box; }\n" +
".ms-box:has(> .ms-box-bottom) { border-bottom: none; }\n" +
".ms-box-bottom { display: flex; align-items: center; gap: 1ch; flex-wrap: wrap; margin: 0 calc(-1 * var(--ms-pad, 1ch)); }\n" +
".ms-box.ms-border-none > .ms-divider { margin: 0; }\n" +
".ms-border-double.ms-rule::before, .ms-border-double.ms-rule::after { border-top: 3px double var(--ms-border, var(--ms-border-default, currentColor)); }\n" +
".ms-border-none.ms-rule::before, .ms-border-none.ms-rule::after { border-top: none; }\n" +
Expand Down Expand Up @@ -84,7 +92,7 @@ public static class LayoutCss
".ms-grid > li { break-inside: avoid; white-space: pre-wrap; overflow-wrap: anywhere; }\n" +
".ms-pre { margin: 0; font: inherit; white-space: pre; overflow-x: auto; }\n" +
".ms-shaded { -webkit-background-clip: text; background-clip: text; color: transparent; }\n" +
".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-shaded :is(.ms-box, .ms-gauge-bar), .ms-shaded :is(.ms-rule, .ms-divider)::before, .ms-shaded :is(.ms-rule, .ms-divider)::after, .ms-shaded .ms-line { 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" +
Expand Down
172 changes: 172 additions & 0 deletions MarkupString.Tests/Layout/EdgeTitleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using MarkupString.Ansi;
using MarkupString.Html;
using MarkupString.Layout;

namespace MarkupString.Tests.Layout;

/// <summary>Several titles in one line or box edge: each at its side, the ones that do not fit left out in order.</summary>
public class EdgeTitleTests
{
private static readonly MarkupRegistry Registry = MarkupRegistry.Empty.WithAnsi().WithHtml();

private static MarkupText P(string text) => MarkupText.Plain(text);

private static string Line(Block block, int width) => BlockLayout.Build(block, width).ToPlainText();

private static Rule LeftMiddleRight() => new(P("A"))
{
Border = BorderStyle.Mush,
TitleAlignment = Alignment.Left,
Titles = [new EdgeTitle(P("B")), new EdgeTitle(P("C"), Alignment.Right)],
};

private static Rule PoseHeader() => new(P("Wren"))
{
Border = BorderStyle.Mush,
TitleAlignment = Alignment.Left,
Titles = [new EdgeTitle(P("Scene 3"), Alignment.Right), new EdgeTitle(P("21:04"), Alignment.Right)],
};

[Test]
public async Task OneLeft_OneInTheMiddle_OneRight()
=> await Assert.That(Line(LeftMiddleRight(), 30)).IsEqualTo("=< A >=======< B >======< C >=");

[Test]
public async Task TwoOnTheRight_SitInOrder_ACellApart()
=> await Assert.That(Line(PoseHeader(), 40)).IsEqualTo("=< Wren >=========< Scene 3 >=< 21:04 >=");

[Test]
public async Task TooNarrow_DropsTheRightTitleFarthestFromTheEnd()
=> await Assert.That(Line(PoseHeader(), 31)).IsEqualTo("=< Wren >============< 21:04 >=");

[Test]
public async Task TooNarrow_DropsTheMiddleFirst()
=> await Assert.That(Line(LeftMiddleRight(), 18)).IsEqualTo("=< A >======< C >=");

[Test]
public async Task TheMiddleTitle_MovesOver_ToKeepACellClear()
{
var rule = new Rule(P("Long left title"))
{
Border = BorderStyle.Mush,
TitleAlignment = Alignment.Left,
Titles = [new EdgeTitle(P("M"))],
};

await Assert.That(Line(rule, 30)).IsEqualTo("=< Long left title >=< M >====");
}

[Test]
public async Task OneTitleLeft_IsDrawnAsASingleTitleAlwaysWas()
{
var rule = new Rule { Border = BorderStyle.Mush, Titles = [new EdgeTitle(P("T"), Alignment.Right)] };

await Assert.That(Line(rule, 14)).IsEqualTo("========< T >=");
}

[Test]
public async Task ABox_TakesTitlesInItsBottomEdge()
{
var box = new Frame(new TextBlock(P("x")))
{
Border = BorderStyle.Mush,
BottomTitles = [new EdgeTitle(P("1/3"), Alignment.Right)],
};

await Assert.That(Line(box, 12).Split('\n')[^1]).IsEqualTo("+==< 1/3 >=+");
}

[Test]
public async Task ABox_TakesSeveralTitlesInItsTopEdge()
{
var box = new Frame(new TextBlock(P("x")))
{
Border = BorderStyle.Mush,
Title = P("Finger"),
TitleAlignment = Alignment.Left,
Titles = [new EdgeTitle(P("On"), Alignment.Right)],
};

await Assert.That(Line(box, 24).Split('\n')[0]).IsEqualTo("+=< Finger >====< On >=+");
}

[Test]
public async Task AScreenReader_HearsTheTitlesLeftToRight()
{
var rule = new Rule(P("C")) { TitleAlignment = Alignment.Right, Titles = [new EdgeTitle(P("A"), Alignment.Left), new EdgeTitle(P("B"))] };

var lines = BlockLayout.Lines(rule, 40, new LayoutContext { Linear = true });

await Assert.That(lines.Select(line => line.ToPlainText())).IsEquivalentTo(new[] { "A, B, C" });
}

[Test]
public async Task TheSerializer_KeepsEveryTitle()
{
var text = BlockLayout.Build(LeftMiddleRight(), 30, fluid: true);

var json = MarkupTextSerializer.Serialize(text, Registry);
var read = MarkupTextSerializer.Deserialize(json, Registry);

await Assert.That(json).Contains("\"tt\"");
await Assert.That(MarkupTextSerializer.Serialize(read, Registry)).IsEqualTo(json);
await Assert.That(BlockLayout.Relayout(read, 18, LayoutContext.Default).ToPlainText()).IsEqualTo("=< A >======< C >=");
}

[Test]
public async Task TheSerializer_KeepsABoxsBottomTitles()
{
var box = new Frame(new TextBlock(P("x"))) { Border = BorderStyle.Mush, BottomTitles = [new EdgeTitle(P("1/3"), Alignment.Right)] };

var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(BlockLayout.Build(box, 12), Registry), Registry);

await Assert.That(BlockLayout.Relayout(read, 12, LayoutContext.Default).ToPlainText().Split('\n')[^1]).IsEqualTo("+==< 1/3 >=+");
}

[Test]
public async Task InHtml_TheLineRunsBetweenTheTitles()
=> await Assert.That(BlockLayout.Build(LeftMiddleRight(), 30).Render(MarkupFormat.Html, Registry)).Contains(
"<div class=\"ms-rule ms-titles ms-border-mush\" role=\"separator\"><span class=\"ms-line ms-end\"></span><span class=\"ms-rule-title\">A</span>" +
"<span class=\"ms-line\"></span><span class=\"ms-rule-title\">B</span><span class=\"ms-line\"></span><span class=\"ms-rule-title\">C</span>" +
"<span class=\"ms-line ms-end\"></span></div>");

[Test]
public async Task InHtml_ABoxsBottomTitlesDrawItsBottomEdge()
{
var box = new Frame(new TextBlock(P("x"))) { Border = BorderStyle.Mush, BottomTitles = [new EdgeTitle(P("1/3"), Alignment.Right)] };

await Assert.That(BlockLayout.Build(box, 12).Render(MarkupFormat.Html, Registry)).Contains(
"<div class=\"ms-box-bottom ms-titles ms-border-mush\"><span class=\"ms-line\"></span><span class=\"ms-rule-title\">1/3</span><span class=\"ms-line ms-end\"></span></div></fieldset>");
}

[Test]
public async Task APriority_KeepsATitleTheSideWouldDrop()
{
var rule = new Rule
{
Border = BorderStyle.Mush,
Titles =
[
new EdgeTitle(P("Wren"), Alignment.Left) { Priority = 2 },
new EdgeTitle(P("Pose 17")) { Priority = 1 },
new EdgeTitle(P("Scene 3"), Alignment.Right),
],
};

await Assert.That(Line(rule, 40)).IsEqualTo("=< Wren >======< Pose 17 >==< Scene 3 >=");
await Assert.That(Line(rule, 26)).IsEqualTo("=< Wren >=< Pose 17 >=====").Because("the right title is 2 by its side, and among equals the right goes before the left");
await Assert.That(Line(rule, 16)).IsEqualTo("===< Pose 17 >==");
}

[Test]
public async Task TheSerializer_KeepsAPriority()
{
var rule = new Rule { Border = BorderStyle.Mush, Titles = [new EdgeTitle(P("A"), Alignment.Left) { Priority = 2 }, new EdgeTitle(P("B")) { Priority = 1 }] };

var json = MarkupTextSerializer.Serialize(BlockLayout.Build(rule, 20, fluid: true), Registry);
var read = MarkupTextSerializer.Deserialize(json, Registry);

await Assert.That(json).Contains("\"p\":2");
await Assert.That(BlockLayout.Relayout(read, 6, LayoutContext.Default).ToPlainText()).IsEqualTo("=< B >");
}
}
Loading
Loading