From d32d6e08f46a02adb7e7152111cbbc421184fade Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:59:49 +0000 Subject: [PATCH 1/2] 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) Claude-Session: https://claude.ai/code/session_01Nz2RWtEgTogzgVRxwBSMAA --- CHANGELOG.md | 9 ++ MarkupString.Html/Emitters/LayoutHtml.cs | 85 ++++++++++-- MarkupString.Html/LayoutCss.cs | 10 +- MarkupString.Tests/Layout/EdgeTitleTests.cs | 141 ++++++++++++++++++++ MarkupString/Layout/Blocks/BlockText.cs | 126 ++++++++++++++++- MarkupString/Layout/Blocks/Frame.cs | 49 +++++-- MarkupString/Layout/Blocks/LayoutJson.cs | 21 ++- MarkupString/PublicAPI.Unshipped.txt | 20 +++ docs/layout.md | 34 ++++- 9 files changed, 466 insertions(+), 29 deletions(-) create mode 100644 MarkupString.Tests/Layout/EdgeTitleTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 65307da..7bdebc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ 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 in a fixed order (centre, then right, then left), 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 diff --git a/MarkupString.Html/Emitters/LayoutHtml.cs b/MarkupString.Html/Emitters/LayoutHtml.cs index fcaecc4..999cf4d 100644 --- a/MarkupString.Html/Emitters/LayoutHtml.cs +++ b/MarkupString.Html/Emitters/LayoutHtml.cs @@ -79,12 +79,22 @@ 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(""); + EdgeTitles(top, html); + html.Write(""); + } + else if (top.Count == 1) { html.Write(""); - html.Text(title); + html.Text(top[0].Text); html.Write(""); } foreach (var child in frame.Body is Stack { Children.IsDefault: false } body ? body.Children : [frame.Body]) @@ -92,25 +102,82 @@ private static void Frame(Frame frame, HtmlLayoutWriter html) 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("
"); + EdgeTitles(bottom, html); + html.Write("
"); + } html.Write(""); } + /// at , then ; the empty ones left out. + private static List Titles(MarkupText? title, Alignment alignment, ImmutableArray more) + { + var titles = new List(); + 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; + + /// + /// 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. + /// + private static void EdgeTitles(List 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 ? "" : ""); + for (var g = 0; g < groups.Count; g++) + { + if (g > 0) html.Write(""); + for (var t = 0; t < groups[g].Titles.Count; t++) + { + if (t > 0) html.Write(""); + html.Write(""); + html.Text(groups[g].Titles[t].Text); + html.Write(""); + } + } + html.Write(groups[^1].Side == Alignment.Right ? "" : ""); + } + 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("
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(""); - html.Text(title); - html.Write(""); + html.Write(">"); + EdgeTitles(titles, html); + } + else + { + Align(titles.Count == 1 ? titles[0].Side : rule.TitleAlignment, html); + html.Write(">"); + if (titles.Count == 1) + { + html.Write(""); + html.Text(titles[0].Text); + html.Write(""); + } } html.Write("
"); } diff --git a/MarkupString.Html/LayoutCss.cs b/MarkupString.Html/LayoutCss.cs index 935988d..87392c3 100644 --- a/MarkupString.Html/LayoutCss.cs +++ b/MarkupString.Html/LayoutCss.cs @@ -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" + @@ -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" + diff --git a/MarkupString.Tests/Layout/EdgeTitleTests.cs b/MarkupString.Tests/Layout/EdgeTitleTests.cs new file mode 100644 index 0000000..c2d39ed --- /dev/null +++ b/MarkupString.Tests/Layout/EdgeTitleTests.cs @@ -0,0 +1,141 @@ +using MarkupString.Ansi; +using MarkupString.Html; +using MarkupString.Layout; + +namespace MarkupString.Tests.Layout; + +/// Several titles in one line or box edge: each at its side, the ones that do not fit left out in order. +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( + "
A" + + "BC" + + "
"); + + [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( + "
1/3
"); + } +} diff --git a/MarkupString/Layout/Blocks/BlockText.cs b/MarkupString/Layout/Blocks/BlockText.cs index ab94208..a462219 100644 --- a/MarkupString/Layout/Blocks/BlockText.cs +++ b/MarkupString/Layout/Blocks/BlockText.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; + namespace MarkupString.Layout; /// The line arithmetic the built-in blocks share. @@ -24,15 +26,129 @@ public static void AddRange(this IList lines, IEnumerableA line across : two ends, a fill between, a title set into it. + /// at , then , leaving out the empty ones. + public static IReadOnlyList Titles(MarkupText? title, Alignment alignment, ImmutableArray more) + { + var titles = new List(); + if (title is { Length: > 0 }) titles.Add(new EdgeTitle(title, alignment)); + foreach (var extra in more.IsDefault ? [] : more) + if (extra is { Text.Length: > 0 }) titles.Add(extra); + return titles; + } + + /// in the theme's title colour. + public static IReadOnlyList Painted(LayoutContext context, IReadOnlyList titles) => + [.. titles.Select(title => title with { Text = context.Paint(theme => theme.TitleColor, title.Text) })]; + + /// The titles as they are read, left to right, a comma between; null when there are none. + public static MarkupText? Read(IReadOnlyList titles) => + titles.Count == 0 + ? null + : MarkupText.Join(MarkupText.Plain(", "), Ordered(titles).Select(title => title.Text)); + + /// Left, middle, right; in the order given within each. + private static IEnumerable Ordered(IEnumerable titles) => titles.OrderBy(title => Side(title.Side) switch + { + Alignment.Left => 0, + Alignment.Center => 1, + _ => 2, + }); + + private static Alignment Side(Alignment side) => side is Alignment.Left or Alignment.Right ? side : Alignment.Center; + + /// A line across : two ends, a fill between, set into it. + /// + /// Titles on one side sit in order, a cell of the fill apart, the first on the left a cell in from the end and the + /// last on the right likewise; the middle ones are centred, moved over only to keep a cell clear of the others. + /// Those that do not fit are left out, the middle first, then the right, then the left, on each side the one + /// farthest from its end first; one alone that does not fit is cut, as a single title always was. + /// public static MarkupText Edge( - MarkupText left, MarkupText fill, MarkupText right, MarkupText? title, Alignment alignment, BorderStyle style, int width) + MarkupText left, MarkupText fill, MarkupText right, IReadOnlyList titles, BorderStyle style, int width) { var inner = Math.Max(0, width - left.DisplayWidth - right.DisplayWidth); - var middle = title is { Length: > 0 } - ? MarkupText.Concat([style.TitleOpen, title, style.TitleClose]) - : MarkupText.Empty; fill = fill.Length == 0 ? MarkupText.Space : fill; + var set = titles + .Where(title => title.Text.Length > 0) + .Select(title => (Side: Side(title.Side), Text: MarkupText.Concat([style.TitleOpen, title.Text, style.TitleClose]))) + .ToList(); + while (set.Count > 1 && Needed(set) > inner) set.RemoveAt(Dropped(set)); + if (set.Count < 2) return Edge(left, fill, right, set.Count == 0 ? null : set[0].Text, set.Count == 0 ? Alignment.Center : set[0].Side, inner); + + var placed = new List<(int Start, MarkupText Text)>(); + var lefts = set.Where(title => title.Side == Alignment.Left).Select(title => title.Text).ToList(); + var middles = set.Where(title => title.Side == Alignment.Center).Select(title => title.Text).ToList(); + var rights = set.Where(title => title.Side == Alignment.Right).Select(title => title.Text).ToList(); + + var from = 1; + foreach (var text in lefts) + { + placed.Add((from, text)); + from += text.DisplayWidth + 1; + } + var rightStart = inner - 1 - Span(rights); + var at = rightStart; + foreach (var text in rights) + { + placed.Add((at, text)); + at += text.DisplayWidth + 1; + } + if (middles.Count > 0) + { + var span = Span(middles); + var low = lefts.Count > 0 ? from : 0; + var high = (rights.Count > 0 ? rightStart - 1 : inner) - span; + var start = Math.Clamp((inner - span + 1) / 2, low, Math.Max(low, high)); + foreach (var text in middles) + { + placed.Add((start, text)); + start += text.DisplayWidth + 1; + } + } + + // The fill is one pattern along the whole edge, so it reads on unbroken between the titles. + var edge = Run(fill, inner); + var parts = new List { left }; + var cell = 0; + foreach (var (start, text) in placed.OrderBy(title => title.Start)) + { + parts.Add(edge.Substring(DisplayWidth.IndexAtWidth(edge.Text, cell), DisplayWidth.IndexAtWidth(edge.Text, start) - DisplayWidth.IndexAtWidth(edge.Text, cell))); + parts.Add(text); + cell = start + text.DisplayWidth; + } + parts.Add(edge.Substring(DisplayWidth.IndexAtWidth(edge.Text, cell))); + parts.Add(right); + return MarkupText.Concat(parts); + } + + /// Cells take in a row, a cell apart. + private static int Span(List texts) => texts.Count == 0 ? 0 : texts.Sum(text => text.DisplayWidth) + texts.Count - 1; + + /// The fewest cells the titles fit in: each side a cell in from its end, a cell of fill between neighbours. + private static int Needed(List<(Alignment Side, MarkupText Text)> set) + { + var groups = new[] { Alignment.Left, Alignment.Center, Alignment.Right } + .Select(side => set.Where(title => title.Side == side).Select(title => title.Text).ToList()) + .Where(group => group.Count > 0) + .ToList(); + var ends = (set.Any(title => title.Side == Alignment.Left) ? 1 : 0) + (set.Any(title => title.Side == Alignment.Right) ? 1 : 0); + // Middle titles alone still keep a cell of fill, as a lone centred title does. + if (ends == 0) ends = 1; + return groups.Sum(Span) + groups.Count - 1 + ends; + } + + /// The title to leave out next: the last in the middle, else the first on the right, else the last on the left. + private static int Dropped(List<(Alignment Side, MarkupText Text)> set) + { + if (set.FindLastIndex(title => title.Side == Alignment.Center) is var middle and >= 0) return middle; + if (set.FindIndex(title => title.Side == Alignment.Right) is var right and >= 0) return right; + return set.FindLastIndex(title => title.Side == Alignment.Left); + } + + /// A line of cells between two ends, one title set into it. + private static MarkupText Edge(MarkupText left, MarkupText fill, MarkupText right, MarkupText? middle, Alignment alignment, int inner) + { + middle ??= MarkupText.Empty; var aside = alignment is Alignment.Left or Alignment.Right; if (middle.Length > 0 && middle.DisplayWidth + (aside ? 2 : 1) <= inner) { diff --git a/MarkupString/Layout/Blocks/Frame.cs b/MarkupString/Layout/Blocks/Frame.cs index 59355a8..dda91a7 100644 --- a/MarkupString/Layout/Blocks/Frame.cs +++ b/MarkupString/Layout/Blocks/Frame.cs @@ -1,7 +1,19 @@ +using System.Collections.Immutable; + namespace MarkupString.Layout; -/// A line across the width, with an optional title set into it. +/// A title set into a line or a frame's edge: against its left end, its right end, or in its middle. +/// The title. +/// , , or the middle for any other value. +public sealed record EdgeTitle(MarkupText Text, Alignment Side = Alignment.Center); + +/// A line across the width, with titles set into it. /// The title, or none. +/// +/// add more, each at its own side: one left, one in the middle and one right, or two on the right. +/// Titles on one side sit in the order given, a cell of the line apart. When they do not all fit, the middle ones go +/// first, then the right, then the left, and on a side the one farthest from its end goes first. +/// public sealed record Rule(MarkupText? Title = null) : Block { /// Whose horizontal edge and title brackets the line is drawn with; unset, the frame's around it or the theme's. @@ -10,18 +22,23 @@ public sealed record Rule(MarkupText? Title = null) : Block /// Where the title sits along the line. public Alignment TitleAlignment { get; init; } = Alignment.Center; + /// More titles, after , each at its own side. + public ImmutableArray Titles { get; init; } = []; + + /// at , then ; the empty ones left out. + internal IReadOnlyList EdgeTitles => BlockText.Titles(Title, TitleAlignment, Titles); + /// public override void Draw(LayoutContext context, int width, IList lines) { var style = context.Paint(context.Border(Border)); - var title = Title is null ? null : context.Paint(theme => theme.TitleColor, Title); - lines.Add(BlockText.Edge(MarkupText.Empty, style.Top, MarkupText.Empty, title, TitleAlignment, style, width)); + lines.Add(BlockText.Edge(MarkupText.Empty, style.Top, MarkupText.Empty, BlockText.Painted(context, EdgeTitles), style, width)); } /// public override void DrawLinear(LayoutContext context, int width, IList lines) { - if (Title is { Length: > 0 } title) lines.Add(title); + if (BlockText.Read(EdgeTitles) is { } titles) lines.Add(titles); } } @@ -39,6 +56,12 @@ public sealed record Frame(Block Body) : Block /// Where the title sits along the edge. public Alignment TitleAlignment { get; init; } = Alignment.Center; + /// More titles in the top edge, after , each at its own side, as a 's. + public ImmutableArray Titles { get; init; } = []; + + /// Titles in the bottom edge, each at its own side, as a 's: a page count, a footnote. + public ImmutableArray BottomTitles { get; init; } = []; + /// Spaces between each side and the body. public int Padding { get; init; } = 1; @@ -52,18 +75,20 @@ public override void Draw(LayoutContext context, int width, IList li var padding = BlockText.Blank(Padding); var inner = Math.Max(1, width - style.Left.DisplayWidth - style.Right.DisplayWidth - padding.DisplayWidth * 2); - if (!none || Title is { Length: > 0 }) - lines.Add(BlockText.Edge(style.TopLeft, style.Top, style.TopRight, Title is null ? null : context.Paint(theme => theme.TitleColor, Title), TitleAlignment, style, width)); + var top = BlockText.Titles(Title, TitleAlignment, Titles); + var bottom = BlockText.Titles(null, Alignment.Center, BottomTitles); + if (!none || top.Count > 0) + lines.Add(BlockText.Edge(style.TopLeft, style.Top, style.TopRight, BlockText.Painted(context, top), style, width)); var body = new List(); foreach (var child in Parts) { if (child is Rule rule) { - if (none && rule.Title is not { Length: > 0 }) continue; + var titles = rule.EdgeTitles; + if (none && titles.Count == 0) continue; var ruleStyle = context.Paint(context.Border(rule.Border ?? Border)); - var ruleTitle = rule.Title is null ? null : context.Paint(theme => theme.TitleColor, rule.Title); - lines.Add(BlockText.Edge(style.TeeLeft, ruleStyle.Top, style.TeeRight, ruleTitle, rule.TitleAlignment, ruleStyle, width)); + lines.Add(BlockText.Edge(style.TeeLeft, ruleStyle.Top, style.TeeRight, BlockText.Painted(context, titles), ruleStyle, width)); continue; } @@ -73,13 +98,15 @@ public override void Draw(LayoutContext context, int width, IList li lines.Add(MarkupText.Concat([style.Left, padding, BlockText.Fit(line, inner), padding, style.Right])); } - if (!none) lines.Add(BlockText.Edge(style.BottomLeft, style.Bottom, style.BottomRight, null, Alignment.Center, style, width)); + if (!none || bottom.Count > 0) + lines.Add(BlockText.Edge(style.BottomLeft, style.Bottom, style.BottomRight, BlockText.Painted(context, bottom), style, width)); } /// public override void DrawLinear(LayoutContext context, int width, IList lines) { - if (Title is { Length: > 0 } title) lines.Add(title); + if (BlockText.Read(BlockText.Titles(Title, TitleAlignment, Titles)) is { } top) lines.Add(top); foreach (var child in Parts) context.Draw(child, width, lines); + if (BlockText.Read(BlockText.Titles(null, Alignment.Center, BottomTitles)) is { } bottom) lines.Add(bottom); } } diff --git a/MarkupString/Layout/Blocks/LayoutJson.cs b/MarkupString/Layout/Blocks/LayoutJson.cs index 20a385d..ca92d26 100644 --- a/MarkupString/Layout/Blocks/LayoutJson.cs +++ b/MarkupString/Layout/Blocks/LayoutJson.cs @@ -60,8 +60,9 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry) w.Text("ti", b.Title); w.Border("bs", b.Border); w.Enum("ta", b.TitleAlignment, Alignment.Center); + WriteTitles(w, "tt", b.Titles); }, - r => new Rule(r.Text("ti")) { Border = r.Border("bs"), TitleAlignment = r.Enum("ta", Alignment.Center) }), + r => new Rule(r.Text("ti")) { Border = r.Border("bs"), TitleAlignment = r.Enum("ta", Alignment.Center), Titles = ReadTitles(r, "tt") }), BlockCodec.Create("frame", (b, w) => { @@ -70,6 +71,8 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry) w.Text("ti", b.Title); w.Enum("ta", b.TitleAlignment, Alignment.Center); w.Int("p", b.Padding, 1); + WriteTitles(w, "tt", b.Titles); + WriteTitles(w, "bt", b.BottomTitles); }, r => new Frame(r.Block("b") ?? new Stack([])) { @@ -77,6 +80,8 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry) Title = r.Text("ti"), TitleAlignment = r.Enum("ta", Alignment.Center), Padding = r.Int("p", 1, 0, 64), + Titles = ReadTitles(r, "tt"), + BottomTitles = ReadTitles(r, "bt"), }), BlockCodec.Create("flex", (b, w) => @@ -285,5 +290,19 @@ private static void WriteTreeItems(BlockWriter writer, System.Collections.Immuta private static System.Collections.Immutable.ImmutableArray ReadTreeItems(BlockReader reader) => reader.Array("it", item => new TreeItem(item.Block("c") ?? new Stack([]), ReadTreeItems(item))); + /// Titles beside a rule's or a frame's own, each with its side; nothing when there are none. + private static void WriteTitles(BlockWriter writer, string name, System.Collections.Immutable.ImmutableArray titles) + { + if (titles.IsDefaultOrEmpty) return; + writer.Array(name, titles, (title, tw) => + { + tw.Text("t", title.Text); + tw.Enum("s", title.Side, Alignment.Center); + }); + } + + private static System.Collections.Immutable.ImmutableArray ReadTitles(BlockReader reader, string name) => + reader.Array(name, title => new EdgeTitle(title.Text("t") ?? MarkupText.Empty, title.Enum("s", Alignment.Center))); + private static string Name(Alignment alignment) => alignment.ToString().ToLowerInvariant(); } diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 7dc5c58..2426369 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -1 +1,21 @@ #nullable enable +MarkupString.Layout.EdgeTitle +MarkupString.Layout.EdgeTitle.$() -> MarkupString.Layout.EdgeTitle! +MarkupString.Layout.EdgeTitle.Deconstruct(out MarkupString.MarkupText! Text, out MarkupString.Layout.Alignment Side) -> void +MarkupString.Layout.EdgeTitle.EdgeTitle(MarkupString.MarkupText! Text, MarkupString.Layout.Alignment Side = MarkupString.Layout.Alignment.Center) -> void +MarkupString.Layout.EdgeTitle.Equals(MarkupString.Layout.EdgeTitle? other) -> bool +MarkupString.Layout.EdgeTitle.Side.get -> MarkupString.Layout.Alignment +MarkupString.Layout.EdgeTitle.Side.init -> void +MarkupString.Layout.EdgeTitle.Text.get -> MarkupString.MarkupText! +MarkupString.Layout.EdgeTitle.Text.init -> void +MarkupString.Layout.Frame.BottomTitles.get -> System.Collections.Immutable.ImmutableArray +MarkupString.Layout.Frame.BottomTitles.init -> void +MarkupString.Layout.Frame.Titles.get -> System.Collections.Immutable.ImmutableArray +MarkupString.Layout.Frame.Titles.init -> void +MarkupString.Layout.Rule.Titles.get -> System.Collections.Immutable.ImmutableArray +MarkupString.Layout.Rule.Titles.init -> void +override MarkupString.Layout.EdgeTitle.Equals(object? obj) -> bool +override MarkupString.Layout.EdgeTitle.GetHashCode() -> int +override MarkupString.Layout.EdgeTitle.ToString() -> string! +static MarkupString.Layout.EdgeTitle.operator !=(MarkupString.Layout.EdgeTitle? left, MarkupString.Layout.EdgeTitle? right) -> bool +static MarkupString.Layout.EdgeTitle.operator ==(MarkupString.Layout.EdgeTitle? left, MarkupString.Layout.EdgeTitle? right) -> bool diff --git a/docs/layout.md b/docs/layout.md index 72aff62..6f001a6 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -290,6 +290,36 @@ draws the same tree as a `
` with a legend, a divider, and a flex row w 35 and 36 `ch` and wrap onto rows of their own on a narrower page. Include `LayoutCss.Fixed`, or your own copy of its rules, on the page. +### Several titles in one edge + +A `Rule` or a `Frame`'s top edge holds more than one title. `Title` and `TitleAlignment` place the +first; `Titles` adds more, each an `EdgeTitle` with its own side. A `Frame`'s `BottomTitles` go in its +bottom edge, which is then drawn even for a borderless frame. + +```csharp +var header = new Rule(MarkupText.Plain("Wren")) +{ + Border = BorderStyle.Mush, + TitleAlignment = Alignment.Left, + Titles = + [ + new EdgeTitle(MarkupText.Plain("Scene 3"), Alignment.Right), + new EdgeTitle(MarkupText.Plain("21:04"), Alignment.Right), + ], +}; +``` + +``` +=< Wren >=========< Scene 3 >=< 21:04 >= +``` + +Titles on one side sit in order, a cell of line apart. A centre title stays centred unless that would +crowd a side's titles, and then moves over to keep a cell clear. When the line is too narrow for all +of them, titles are left out one at a time: the last centre title first, then the right title farthest +from the end, then the last left one. At 31 cells the line above is `=< Wren >============< 21:04 >=`. +A screen reader hears the titles left to right, joined by commas. In HTML each title is a segment of +a flex row with the line drawn between them. + ### How it fits together - **A block draws itself.** Every block derives from `Block` and draws its own lines at the width it @@ -309,8 +339,8 @@ your own copy of its rules, on the page. | Block | Terminal | HTML | |---|---|---| -| `Frame` (`.Bordered(title, border)`) | the frame, its title set into the top edge | `
` and `` | -| `Rule` | a line of the border's top edge with the title in it; inside a frame, a divider meeting the sides | a line drawn in CSS | +| `Frame` (`.Bordered(title, border)`) | the frame, its titles set into the top and bottom edges | `
` and `` | +| `Rule` | a line of the border's top edge with its titles in it; inside a frame, a divider meeting the sides | a line drawn in CSS | | `Flex` | items side by side at widths shared from their `Sized` bases, stacked when one would fall under its `Min` | a wrapping flex row | | `Figure` | the text art, with `Beside` flowing round it; MXP and Pueblo write the picture on its first row and keep its cells blank | an `` floated beside it | | `Fields` | labels in one column, values lined up in the next, a long value wrapping under itself | a `
` laid out as a two-column grid | From 0b9c64c1f12c286b4fb120bb5fc6fc06df2adbc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:04:10 +0000 Subject: [PATCH 2/2] 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) Claude-Session: https://claude.ai/code/session_01Nz2RWtEgTogzgVRxwBSMAA --- CHANGELOG.md | 3 +- MarkupString.Tests/Layout/EdgeTitleTests.cs | 31 ++++++++++++++++++ MarkupString/Layout/Blocks/BlockText.cs | 36 +++++++++++++++------ MarkupString/Layout/Blocks/Frame.cs | 9 +++++- MarkupString/Layout/Blocks/LayoutJson.cs | 6 +++- MarkupString/PublicAPI.Unshipped.txt | 2 ++ docs/layout.md | 8 +++-- 7 files changed, 81 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bdebc8..7bb3cfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **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 in a fixed order (centre, then right, then left), a screen + 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. diff --git a/MarkupString.Tests/Layout/EdgeTitleTests.cs b/MarkupString.Tests/Layout/EdgeTitleTests.cs index c2d39ed..f95fd67 100644 --- a/MarkupString.Tests/Layout/EdgeTitleTests.cs +++ b/MarkupString.Tests/Layout/EdgeTitleTests.cs @@ -138,4 +138,35 @@ public async Task InHtml_ABoxsBottomTitlesDrawItsBottomEdge() await Assert.That(BlockLayout.Build(box, 12).Render(MarkupFormat.Html, Registry)).Contains( "
1/3
"); } + + [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 >"); + } } diff --git a/MarkupString/Layout/Blocks/BlockText.cs b/MarkupString/Layout/Blocks/BlockText.cs index a462219..3dd0403 100644 --- a/MarkupString/Layout/Blocks/BlockText.cs +++ b/MarkupString/Layout/Blocks/BlockText.cs @@ -60,8 +60,9 @@ public static IReadOnlyList Painted(LayoutContext context, IReadOnlyL /// /// Titles on one side sit in order, a cell of the fill apart, the first on the left a cell in from the end and the /// last on the right likewise; the middle ones are centred, moved over only to keep a cell clear of the others. - /// Those that do not fit are left out, the middle first, then the right, then the left, on each side the one - /// farthest from its end first; one alone that does not fit is cut, as a single title always was. + /// Those that do not fit are left out by , highest first; among equals the middle + /// goes first, then the right, then the left, on each side the one farthest from its end first. One alone that does + /// not fit is cut, as a single title always was. /// public static MarkupText Edge( MarkupText left, MarkupText fill, MarkupText right, IReadOnlyList titles, BorderStyle style, int width) @@ -70,7 +71,7 @@ public static MarkupText Edge( fill = fill.Length == 0 ? MarkupText.Space : fill; var set = titles .Where(title => title.Text.Length > 0) - .Select(title => (Side: Side(title.Side), Text: MarkupText.Concat([style.TitleOpen, title.Text, style.TitleClose]))) + .Select(title => (Side: Side(title.Side), Text: MarkupText.Concat([style.TitleOpen, title.Text, style.TitleClose]), Priority: Priority(title))) .ToList(); while (set.Count > 1 && Needed(set) > inner) set.RemoveAt(Dropped(set)); if (set.Count < 2) return Edge(left, fill, right, set.Count == 0 ? null : set[0].Text, set.Count == 0 ? Alignment.Center : set[0].Side, inner); @@ -125,7 +126,7 @@ public static MarkupText Edge( private static int Span(List texts) => texts.Count == 0 ? 0 : texts.Sum(text => text.DisplayWidth) + texts.Count - 1; /// The fewest cells the titles fit in: each side a cell in from its end, a cell of fill between neighbours. - private static int Needed(List<(Alignment Side, MarkupText Text)> set) + private static int Needed(List<(Alignment Side, MarkupText Text, int Priority)> set) { var groups = new[] { Alignment.Left, Alignment.Center, Alignment.Right } .Select(side => set.Where(title => title.Side == side).Select(title => title.Text).ToList()) @@ -137,12 +138,29 @@ private static int Needed(List<(Alignment Side, MarkupText Text)> set) return groups.Sum(Span) + groups.Count - 1 + ends; } - /// The title to leave out next: the last in the middle, else the first on the right, else the last on the left. - private static int Dropped(List<(Alignment Side, MarkupText Text)> set) + /// A title's priority: its own, else 1 on the left, 2 on the right, 3 in the middle. + private static int Priority(EdgeTitle title) => title.Priority ?? Side(title.Side) switch { - if (set.FindLastIndex(title => title.Side == Alignment.Center) is var middle and >= 0) return middle; - if (set.FindIndex(title => title.Side == Alignment.Right) is var right and >= 0) return right; - return set.FindLastIndex(title => title.Side == Alignment.Left); + Alignment.Left => 1, + Alignment.Right => 2, + _ => 3, + }; + + /// + /// The title to leave out next: the highest priority; among equals the middle, then the right, then the left, and + /// on a side the one farthest from its end (the last in the middle or on the left, the first on the right). + /// + private static int Dropped(List<(Alignment Side, MarkupText Text, int Priority)> set) + { + var highest = set.Max(title => title.Priority); + foreach (var side in new[] { Alignment.Center, Alignment.Right, Alignment.Left }) + { + var index = side == Alignment.Right + ? set.FindIndex(title => title.Priority == highest && title.Side == side) + : set.FindLastIndex(title => title.Priority == highest && title.Side == side); + if (index >= 0) return index; + } + return set.Count - 1; } /// A line of cells between two ends, one title set into it. diff --git a/MarkupString/Layout/Blocks/Frame.cs b/MarkupString/Layout/Blocks/Frame.cs index dda91a7..f0aab59 100644 --- a/MarkupString/Layout/Blocks/Frame.cs +++ b/MarkupString/Layout/Blocks/Frame.cs @@ -5,7 +5,14 @@ namespace MarkupString.Layout; /// A title set into a line or a frame's edge: against its left end, its right end, or in its middle. /// The title. /// , , or the middle for any other value. -public sealed record EdgeTitle(MarkupText Text, Alignment Side = Alignment.Center); +public sealed record EdgeTitle(MarkupText Text, Alignment Side = Alignment.Center) +{ + /// + /// How important it is, as a is: when the titles do not fit, the highest number is + /// left out first. Unset, it follows the side: 1 on the left, 2 on the right, 3 in the middle. + /// + public int? Priority { get; init; } +} /// A line across the width, with titles set into it. /// The title, or none. diff --git a/MarkupString/Layout/Blocks/LayoutJson.cs b/MarkupString/Layout/Blocks/LayoutJson.cs index ca92d26..d5eea7c 100644 --- a/MarkupString/Layout/Blocks/LayoutJson.cs +++ b/MarkupString/Layout/Blocks/LayoutJson.cs @@ -298,11 +298,15 @@ private static void WriteTitles(BlockWriter writer, string name, System.Collecti { tw.Text("t", title.Text); tw.Enum("s", title.Side, Alignment.Center); + if (title.Priority is { } priority) tw.Int("p", priority, int.MinValue); }); } private static System.Collections.Immutable.ImmutableArray ReadTitles(BlockReader reader, string name) => - reader.Array(name, title => new EdgeTitle(title.Text("t") ?? MarkupText.Empty, title.Enum("s", Alignment.Center))); + reader.Array(name, title => new EdgeTitle(title.Text("t") ?? MarkupText.Empty, title.Enum("s", Alignment.Center)) + { + Priority = title.Int("p") is { } priority ? Math.Clamp(priority, 1, 1000) : null, + }); private static string Name(Alignment alignment) => alignment.ToString().ToLowerInvariant(); } diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 2426369..a8c0ae1 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -19,3 +19,5 @@ override MarkupString.Layout.EdgeTitle.GetHashCode() -> int override MarkupString.Layout.EdgeTitle.ToString() -> string! static MarkupString.Layout.EdgeTitle.operator !=(MarkupString.Layout.EdgeTitle? left, MarkupString.Layout.EdgeTitle? right) -> bool static MarkupString.Layout.EdgeTitle.operator ==(MarkupString.Layout.EdgeTitle? left, MarkupString.Layout.EdgeTitle? right) -> bool +MarkupString.Layout.EdgeTitle.Priority.get -> int? +MarkupString.Layout.EdgeTitle.Priority.init -> void diff --git a/docs/layout.md b/docs/layout.md index 6f001a6..2869a71 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -315,8 +315,12 @@ var header = new Rule(MarkupText.Plain("Wren")) Titles on one side sit in order, a cell of line apart. A centre title stays centred unless that would crowd a side's titles, and then moves over to keep a cell clear. When the line is too narrow for all -of them, titles are left out one at a time: the last centre title first, then the right title farthest -from the end, then the last left one. At 31 cells the line above is `=< Wren >============< 21:04 >=`. +of them, titles are left out one at a time by `Priority`, the highest number first, as a table leaves +out its columns. A title with no priority of its own takes its side's: 1 on the left, 2 on the right, +3 in the middle. Among equals the middle goes first, then the right, then the left, and on each side +the title farthest from its end. At 31 cells the line above is `=< Wren >============< 21:04 >=`; give +`Scene 3` a `Priority` of 1 and `21:04` goes instead. To set the first title's priority, leave `Title` +empty and put every title in `Titles`. A screen reader hears the titles left to right, joined by commas. In HTML each title is a segment of a flex row with the line drawn between them.