diff --git a/CHANGELOG.md b/CHANGELOG.md index 65307da..7bb3cfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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..f95fd67 --- /dev/null +++ b/MarkupString.Tests/Layout/EdgeTitleTests.cs @@ -0,0 +1,172 @@ +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
"); + } + + [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 ab94208..3dd0403 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,147 @@ 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 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, 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]), 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); + + 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, 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()) + .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; + } + + /// 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 + { + 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. + 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..f0aab59 100644 --- a/MarkupString/Layout/Blocks/Frame.cs +++ b/MarkupString/Layout/Blocks/Frame.cs @@ -1,7 +1,26 @@ +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) +{ + /// + /// 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. +/// +/// 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 +29,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 +63,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 +82,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 +105,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..d5eea7c 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,23 @@ 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); + 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)) + { + 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 7dc5c58..a8c0ae1 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -1 +1,23 @@ #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 +MarkupString.Layout.EdgeTitle.Priority.get -> int? +MarkupString.Layout.EdgeTitle.Priority.init -> void diff --git a/docs/layout.md b/docs/layout.md index 72aff62..2869a71 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -290,6 +290,40 @@ 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 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. + ### How it fits together - **A block draws itself.** Every block derives from `Block` and draws its own lines at the width it @@ -309,8 +343,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 |