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("");
+ }
+ else if (top.Count == 1)
{
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("
");
+ }
+
+ [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 `