Skip to content

Commit f1348bb

Browse files
Several titles in a rule or box edge (#57)
* Let a rule or box edge hold several titles Rule.Titles and Frame.Titles add EdgeTitles beside the first title, each left, centre or right; Frame.BottomTitles puts titles in the bottom edge. Titles that do not fit are left out centre first, then right, then left. Terminal, screen reader, HTML and JSON all carry them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nz2RWtEgTogzgVRxwBSMAA * Leave out edge titles by priority EdgeTitle.Priority works as TableColumn.Priority does: the highest number goes first. Unset, a title takes its side's (left 1, right 2, centre 3), which keeps the order the titles were dropped in before. JSON keeps it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nz2RWtEgTogzgVRxwBSMAA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a3d341 commit f1348bb

9 files changed

Lines changed: 533 additions & 29 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
## Unreleased
1010

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

1323
### Changed

‎MarkupString.Html/Emitters/LayoutHtml.cs‎

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,38 +79,105 @@ private static void Frame(Frame frame, HtmlLayoutWriter html)
7979
html.Write("ch\"");
8080
}
8181
html.Write(">");
82-
if (frame.Title is { Length: > 0 } title)
82+
var top = Titles(frame.Title, frame.TitleAlignment, frame.Titles);
83+
if (top.Count > 1)
84+
{
85+
// Several titles share the top edge: the legend spans it and draws the line between them.
86+
html.Write("<legend class=\"ms-box-title ms-titles ms-border-");
87+
html.Write(Css(BorderOf(frame.Border, html).Name));
88+
html.Write("\">");
89+
EdgeTitles(top, html);
90+
html.Write("</legend>");
91+
}
92+
else if (top.Count == 1)
8393
{
8494
html.Write("<legend class=\"ms-box-title\"");
85-
Align(frame.TitleAlignment, html);
95+
Align(top[0].Side, html);
8696
html.Write(">");
87-
html.Text(title);
97+
html.Text(top[0].Text);
8898
html.Write("</legend>");
8999
}
90100
foreach (var child in frame.Body is Stack { Children.IsDefault: false } body ? body.Children : [frame.Body])
91101
{
92102
if (child is Rule divider) Rule(divider, "ms-divider", divider.Border ?? frame.Border, html);
93103
else html.Block(child);
94104
}
105+
var bottom = Titles(null, Alignment.Center, frame.BottomTitles);
106+
if (bottom.Count > 0)
107+
{
108+
// The bottom edge, drawn as a line with its titles in place of the box's own bottom border.
109+
html.Write("<div class=\"ms-box-bottom ms-titles ms-border-");
110+
html.Write(Css(BorderOf(frame.Border, html).Name));
111+
html.Write("\">");
112+
EdgeTitles(bottom, html);
113+
html.Write("</div>");
114+
}
95115
html.Write("</fieldset>");
96116
}
97117

118+
/// <summary><paramref name="title"/> at <paramref name="alignment"/>, then <paramref name="more"/>; the empty ones left out.</summary>
119+
private static List<EdgeTitle> Titles(MarkupText? title, Alignment alignment, ImmutableArray<EdgeTitle> more)
120+
{
121+
var titles = new List<EdgeTitle>();
122+
if (title is { Length: > 0 }) titles.Add(new EdgeTitle(title, alignment));
123+
titles.AddRange((more.IsDefault ? [] : more).Where(extra => extra is { Text.Length: > 0 }));
124+
return titles;
125+
}
126+
127+
private static Alignment Side(Alignment side) => side is Alignment.Left or Alignment.Right ? side : Alignment.Center;
128+
129+
/// <summary>
130+
/// The titles in place along an edge: left ones against the start, middle ones centred, right ones against the end,
131+
/// a short line between neighbours on a side and a stretching line between the sides. A terminal leaves out what does
132+
/// not fit; a page wraps it instead.
133+
/// </summary>
134+
private static void EdgeTitles(List<EdgeTitle> titles, HtmlLayoutWriter html)
135+
{
136+
var groups = new[] { Alignment.Left, Alignment.Center, Alignment.Right }
137+
.Select(side => (Side: side, Titles: titles.Where(title => Side(title.Side) == side).ToList()))
138+
.Where(group => group.Titles.Count > 0)
139+
.ToList();
140+
html.Write(groups[0].Side == Alignment.Left ? "<span class=\"ms-line ms-end\"></span>" : "<span class=\"ms-line\"></span>");
141+
for (var g = 0; g < groups.Count; g++)
142+
{
143+
if (g > 0) html.Write("<span class=\"ms-line\"></span>");
144+
for (var t = 0; t < groups[g].Titles.Count; t++)
145+
{
146+
if (t > 0) html.Write("<span class=\"ms-line ms-end\"></span>");
147+
html.Write("<span class=\"ms-rule-title\">");
148+
html.Text(groups[g].Titles[t].Text);
149+
html.Write("</span>");
150+
}
151+
}
152+
html.Write(groups[^1].Side == Alignment.Right ? "<span class=\"ms-line ms-end\"></span>" : "<span class=\"ms-line\"></span>");
153+
}
154+
98155
private static BorderStyle BorderOf(BorderStyle? border, HtmlLayoutWriter html) => border ?? html.Context.Theme.Border ?? BorderStyle.Single;
99156

100157
private static void Rule(Rule rule, string kind, BorderStyle? border, HtmlLayoutWriter html)
101158
{
159+
var titles = Titles(rule.Title, rule.TitleAlignment, rule.Titles);
102160
html.Write("<div class=\"");
103161
html.Write(kind);
162+
if (titles.Count > 1) html.Write(" ms-titles");
104163
html.Write(" ms-border-");
105164
html.Write(Css(BorderOf(border, html).Name));
106165
html.Write("\" role=\"separator\"");
107-
Align(rule.TitleAlignment, html);
108-
html.Write(">");
109-
if (rule.Title is { Length: > 0 } title)
166+
if (titles.Count > 1)
110167
{
111-
html.Write("<span class=\"ms-rule-title\">");
112-
html.Text(title);
113-
html.Write("</span>");
168+
html.Write(">");
169+
EdgeTitles(titles, html);
170+
}
171+
else
172+
{
173+
Align(titles.Count == 1 ? titles[0].Side : rule.TitleAlignment, html);
174+
html.Write(">");
175+
if (titles.Count == 1)
176+
{
177+
html.Write("<span class=\"ms-rule-title\">");
178+
html.Text(titles[0].Text);
179+
html.Write("</span>");
180+
}
114181
}
115182
html.Write("</div>");
116183
}

‎MarkupString.Html/LayoutCss.cs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ public static class LayoutCss
3636
".ms-rule[data-align=\"left\"]::before, .ms-divider[data-align=\"left\"]::before { flex: 0 0 1ch; }\n" +
3737
".ms-rule[data-align=\"right\"]::after, .ms-divider[data-align=\"right\"]::after { flex: 0 0 1ch; }\n" +
3838
".ms-divider { margin: 0 calc(-1 * var(--ms-pad, 1ch)); }\n" +
39+
".ms-titles::before, .ms-titles::after { content: none; }\n" +
40+
".ms-line { flex: 1 1 0; min-width: 1ch; border-top: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); }\n" +
41+
".ms-line.ms-end { flex: 0 0 1ch; }\n" +
42+
".ms-border-double.ms-titles > .ms-line { border-top: 3px double var(--ms-border, var(--ms-border-default, currentColor)); }\n" +
43+
".ms-border-none.ms-titles > .ms-line { border-top: none; }\n" +
44+
".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" +
45+
".ms-box:has(> .ms-box-bottom) { border-bottom: none; }\n" +
46+
".ms-box-bottom { display: flex; align-items: center; gap: 1ch; flex-wrap: wrap; margin: 0 calc(-1 * var(--ms-pad, 1ch)); }\n" +
3947
".ms-box.ms-border-none > .ms-divider { margin: 0; }\n" +
4048
".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" +
4149
".ms-border-none.ms-rule::before, .ms-border-none.ms-rule::after { border-top: none; }\n" +
@@ -84,7 +92,7 @@ public static class LayoutCss
8492
".ms-grid > li { break-inside: avoid; white-space: pre-wrap; overflow-wrap: anywhere; }\n" +
8593
".ms-pre { margin: 0; font: inherit; white-space: pre; overflow-x: auto; }\n" +
8694
".ms-shaded { -webkit-background-clip: text; background-clip: text; color: transparent; }\n" +
87-
".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" +
95+
".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" +
8896
".ms-table-wrap { max-width: 100%; overflow-x: auto; }\n" +
8997
".ms-table { border-collapse: collapse; }\n" +
9098
".ms-table.ms-fill { width: 100%; }\n" +
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
using MarkupString.Ansi;
2+
using MarkupString.Html;
3+
using MarkupString.Layout;
4+
5+
namespace MarkupString.Tests.Layout;
6+
7+
/// <summary>Several titles in one line or box edge: each at its side, the ones that do not fit left out in order.</summary>
8+
public class EdgeTitleTests
9+
{
10+
private static readonly MarkupRegistry Registry = MarkupRegistry.Empty.WithAnsi().WithHtml();
11+
12+
private static MarkupText P(string text) => MarkupText.Plain(text);
13+
14+
private static string Line(Block block, int width) => BlockLayout.Build(block, width).ToPlainText();
15+
16+
private static Rule LeftMiddleRight() => new(P("A"))
17+
{
18+
Border = BorderStyle.Mush,
19+
TitleAlignment = Alignment.Left,
20+
Titles = [new EdgeTitle(P("B")), new EdgeTitle(P("C"), Alignment.Right)],
21+
};
22+
23+
private static Rule PoseHeader() => new(P("Wren"))
24+
{
25+
Border = BorderStyle.Mush,
26+
TitleAlignment = Alignment.Left,
27+
Titles = [new EdgeTitle(P("Scene 3"), Alignment.Right), new EdgeTitle(P("21:04"), Alignment.Right)],
28+
};
29+
30+
[Test]
31+
public async Task OneLeft_OneInTheMiddle_OneRight()
32+
=> await Assert.That(Line(LeftMiddleRight(), 30)).IsEqualTo("=< A >=======< B >======< C >=");
33+
34+
[Test]
35+
public async Task TwoOnTheRight_SitInOrder_ACellApart()
36+
=> await Assert.That(Line(PoseHeader(), 40)).IsEqualTo("=< Wren >=========< Scene 3 >=< 21:04 >=");
37+
38+
[Test]
39+
public async Task TooNarrow_DropsTheRightTitleFarthestFromTheEnd()
40+
=> await Assert.That(Line(PoseHeader(), 31)).IsEqualTo("=< Wren >============< 21:04 >=");
41+
42+
[Test]
43+
public async Task TooNarrow_DropsTheMiddleFirst()
44+
=> await Assert.That(Line(LeftMiddleRight(), 18)).IsEqualTo("=< A >======< C >=");
45+
46+
[Test]
47+
public async Task TheMiddleTitle_MovesOver_ToKeepACellClear()
48+
{
49+
var rule = new Rule(P("Long left title"))
50+
{
51+
Border = BorderStyle.Mush,
52+
TitleAlignment = Alignment.Left,
53+
Titles = [new EdgeTitle(P("M"))],
54+
};
55+
56+
await Assert.That(Line(rule, 30)).IsEqualTo("=< Long left title >=< M >====");
57+
}
58+
59+
[Test]
60+
public async Task OneTitleLeft_IsDrawnAsASingleTitleAlwaysWas()
61+
{
62+
var rule = new Rule { Border = BorderStyle.Mush, Titles = [new EdgeTitle(P("T"), Alignment.Right)] };
63+
64+
await Assert.That(Line(rule, 14)).IsEqualTo("========< T >=");
65+
}
66+
67+
[Test]
68+
public async Task ABox_TakesTitlesInItsBottomEdge()
69+
{
70+
var box = new Frame(new TextBlock(P("x")))
71+
{
72+
Border = BorderStyle.Mush,
73+
BottomTitles = [new EdgeTitle(P("1/3"), Alignment.Right)],
74+
};
75+
76+
await Assert.That(Line(box, 12).Split('\n')[^1]).IsEqualTo("+==< 1/3 >=+");
77+
}
78+
79+
[Test]
80+
public async Task ABox_TakesSeveralTitlesInItsTopEdge()
81+
{
82+
var box = new Frame(new TextBlock(P("x")))
83+
{
84+
Border = BorderStyle.Mush,
85+
Title = P("Finger"),
86+
TitleAlignment = Alignment.Left,
87+
Titles = [new EdgeTitle(P("On"), Alignment.Right)],
88+
};
89+
90+
await Assert.That(Line(box, 24).Split('\n')[0]).IsEqualTo("+=< Finger >====< On >=+");
91+
}
92+
93+
[Test]
94+
public async Task AScreenReader_HearsTheTitlesLeftToRight()
95+
{
96+
var rule = new Rule(P("C")) { TitleAlignment = Alignment.Right, Titles = [new EdgeTitle(P("A"), Alignment.Left), new EdgeTitle(P("B"))] };
97+
98+
var lines = BlockLayout.Lines(rule, 40, new LayoutContext { Linear = true });
99+
100+
await Assert.That(lines.Select(line => line.ToPlainText())).IsEquivalentTo(new[] { "A, B, C" });
101+
}
102+
103+
[Test]
104+
public async Task TheSerializer_KeepsEveryTitle()
105+
{
106+
var text = BlockLayout.Build(LeftMiddleRight(), 30, fluid: true);
107+
108+
var json = MarkupTextSerializer.Serialize(text, Registry);
109+
var read = MarkupTextSerializer.Deserialize(json, Registry);
110+
111+
await Assert.That(json).Contains("\"tt\"");
112+
await Assert.That(MarkupTextSerializer.Serialize(read, Registry)).IsEqualTo(json);
113+
await Assert.That(BlockLayout.Relayout(read, 18, LayoutContext.Default).ToPlainText()).IsEqualTo("=< A >======< C >=");
114+
}
115+
116+
[Test]
117+
public async Task TheSerializer_KeepsABoxsBottomTitles()
118+
{
119+
var box = new Frame(new TextBlock(P("x"))) { Border = BorderStyle.Mush, BottomTitles = [new EdgeTitle(P("1/3"), Alignment.Right)] };
120+
121+
var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(BlockLayout.Build(box, 12), Registry), Registry);
122+
123+
await Assert.That(BlockLayout.Relayout(read, 12, LayoutContext.Default).ToPlainText().Split('\n')[^1]).IsEqualTo("+==< 1/3 >=+");
124+
}
125+
126+
[Test]
127+
public async Task InHtml_TheLineRunsBetweenTheTitles()
128+
=> await Assert.That(BlockLayout.Build(LeftMiddleRight(), 30).Render(MarkupFormat.Html, Registry)).Contains(
129+
"<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>" +
130+
"<span class=\"ms-line\"></span><span class=\"ms-rule-title\">B</span><span class=\"ms-line\"></span><span class=\"ms-rule-title\">C</span>" +
131+
"<span class=\"ms-line ms-end\"></span></div>");
132+
133+
[Test]
134+
public async Task InHtml_ABoxsBottomTitlesDrawItsBottomEdge()
135+
{
136+
var box = new Frame(new TextBlock(P("x"))) { Border = BorderStyle.Mush, BottomTitles = [new EdgeTitle(P("1/3"), Alignment.Right)] };
137+
138+
await Assert.That(BlockLayout.Build(box, 12).Render(MarkupFormat.Html, Registry)).Contains(
139+
"<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>");
140+
}
141+
142+
[Test]
143+
public async Task APriority_KeepsATitleTheSideWouldDrop()
144+
{
145+
var rule = new Rule
146+
{
147+
Border = BorderStyle.Mush,
148+
Titles =
149+
[
150+
new EdgeTitle(P("Wren"), Alignment.Left) { Priority = 2 },
151+
new EdgeTitle(P("Pose 17")) { Priority = 1 },
152+
new EdgeTitle(P("Scene 3"), Alignment.Right),
153+
],
154+
};
155+
156+
await Assert.That(Line(rule, 40)).IsEqualTo("=< Wren >======< Pose 17 >==< Scene 3 >=");
157+
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");
158+
await Assert.That(Line(rule, 16)).IsEqualTo("===< Pose 17 >==");
159+
}
160+
161+
[Test]
162+
public async Task TheSerializer_KeepsAPriority()
163+
{
164+
var rule = new Rule { Border = BorderStyle.Mush, Titles = [new EdgeTitle(P("A"), Alignment.Left) { Priority = 2 }, new EdgeTitle(P("B")) { Priority = 1 }] };
165+
166+
var json = MarkupTextSerializer.Serialize(BlockLayout.Build(rule, 20, fluid: true), Registry);
167+
var read = MarkupTextSerializer.Deserialize(json, Registry);
168+
169+
await Assert.That(json).Contains("\"p\":2");
170+
await Assert.That(BlockLayout.Relayout(read, 6, LayoutContext.Default).ToPlainText()).IsEqualTo("=< B >");
171+
}
172+
}

0 commit comments

Comments
 (0)