diff --git a/CHANGELOG.md b/CHANGELOG.md index cda39e9..3bbb88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added + +- **Text for a client without Unicode.** `AsciiFold` replaces each character an ASCII-only or + Latin-1 client cannot show with the nearest one it can (`·` to `*`, `—` to `-`, `é` to `e`, `?` for + the rest), keeping markup and, for the built-in stand-ins, each character's width. A game's own + stand-ins come first. `LayoutContext.Fold` folds the text inside every block before it is + measured, through the new `Block.MapText`, so tables stay aligned whatever the stand-ins' widths. + ## 2.16.0 — 2026-10-09 ### Added diff --git a/MarkupString.Tests/AsciiFoldTests.cs b/MarkupString.Tests/AsciiFoldTests.cs new file mode 100644 index 0000000..84f11f7 --- /dev/null +++ b/MarkupString.Tests/AsciiFoldTests.cs @@ -0,0 +1,120 @@ +using MarkupString.Ansi; +using MarkupString.Layout; + +namespace MarkupString.Tests; + +public class AsciiFoldTests +{ + private static readonly AnsiMarkup Red = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(255, 0, 0)); + + [Test] + [Arguments("Scene 3 · 21:04", "Scene 3 * 21:04")] + [Arguments("one — two – three", "one - two - three")] + [Arguments("“quoted” ‘single’", "\"quoted\" 'single'")] + [Arguments("café naïve Ångström", "cafe naive Angstrom")] + [Arguments("café", "cafe")] + [Arguments("a b", "a b")] + [Arguments("→ ← ×", "> < x")] + [Arguments("Straße Øre", "Strase Ore")] + [Arguments("┌─┐ ═ █", "+-+ = #")] + [Arguments("plain ascii\ttab\n", "plain ascii\ttab\n")] + public async Task Fold_ReplacesWithTheNearestAscii(string text, string expected) + => await Assert.That(AsciiFold.Default.Fold(text)).IsEqualTo(expected); + + [Test] + public async Task Fold_KeepsEachCharactersWidth() + { + // A wide character is two question marks, a fullwidth letter the letter and a space. + await Assert.That(AsciiFold.Default.Fold("日本")).IsEqualTo("????"); + await Assert.That(AsciiFold.Default.Fold("😀!")).IsEqualTo("??!"); + await Assert.That(AsciiFold.Default.Fold("AB")).IsEqualTo("A B "); + await Assert.That(AsciiFold.Default.Fold("👩‍👩‍👧")).IsEqualTo("??"); + } + + [Test] + public async Task Fold_ForLatin1_KeepsWhatLatin1Has() + { + var fold = new AsciiFold(latin1: true); + await Assert.That(fold.Fold("· café — ½")).IsEqualTo("· café - ½"); + await Assert.That(fold.Fold("café")).IsEqualTo("café"); + } + + [Test] + public async Task Translations_ComeBeforeTheBuiltInOnes() + { + var fold = new AsciiFold([new("·", " - "), new("…", "..."), new("❤️", "<3")]); + await Assert.That(fold.Fold("a·b…")).IsEqualTo("a - b..."); + await Assert.That(fold.Fold("I ❤️ it")).IsEqualTo("I <3 it"); + await Assert.That(new AsciiFold([new("·", "+")], latin1: true).Fold("a·b")).IsEqualTo("a+b"); + } + + [Test] + [Arguments("ab", "x")] + [Arguments("a", "x")] + [Arguments("", "x")] + [Arguments("·", "é")] + [Arguments("·", "\n")] + public async Task Translations_RejectAKeyOrValueThatCannotWork(string key, string value) + => await Assert.That(() => new AsciiFold([new(key, value)])).Throws(); + + [Test] + public async Task Fold_KeepsMarkupOnTheCharacterItReplaces() + { + var text = MarkupText.Concat([MarkupText.Plain("a"), MarkupText.Wrap(Red, "·…"), MarkupText.Plain("b")]); + var folded = new AsciiFold([new("…", "...")]).Fold(text); + + await Assert.That(folded.Text).IsEqualTo("a*...b"); + await Assert.That(folded.Runs.Length).IsEqualTo(1); + await Assert.That(folded.Runs[0]).IsEqualTo(new Run(1, 4, MarkupSet.Of(Red))); + } + + [Test] + public async Task Fold_LeavesAsciiTextAsItIs() + { + var text = MarkupText.Wrap(Red, "nothing to do"); + await Assert.That(AsciiFold.Default.Fold(text)).IsSameReferenceAs(text); + } + + [Test] + public async Task Fold_KeepsAPointsCarrier() + { + var text = MarkupText.Concat([MarkupText.Plain("·"), MarkupText.Sound("ping.wav")]); + var folded = AsciiFold.Default.Fold(text); + + await Assert.That(folded.Text).IsEqualTo("*" + MarkupText.PointCarrier); + await Assert.That(folded.Runs.Single().Start).IsEqualTo(1); + } + + [Test] + public async Task Layout_MeasuresTheFoldedText() + { + var table = new Table([new TableColumn(MarkupText.Plain("Scene")), new TableColumn(MarkupText.Plain("When"))], + [ + [MarkupText.Plain("Tea · Garden"), MarkupText.Plain("21:04")], + [MarkupText.Plain("Duel…"), MarkupText.Plain("now")], + ]); + var block = new Stack([new Rule(MarkupText.Plain("Wren · Scenes")), table]); + var context = new LayoutContext { AsciiOnly = true, Fold = new AsciiFold([new("·", " - "), new("…", "...")]) }; + + var lines = BlockLayout.Lines(block, 30, context).Select(line => line.ToPlainText()).ToArray(); + + await Assert.That(lines[0]).Contains("Wren - Scenes"); + await Assert.That(lines.Any(line => line.Contains("Tea - Garden"))).IsTrue(); + await Assert.That(lines.Any(line => line.Contains("Duel..."))).IsTrue(); + await Assert.That(lines.All(line => System.Text.Ascii.IsValid(line))).IsTrue(); + // The column after the folded text still starts at one place on every row. + var when = lines.Where(line => line.Contains("21:04") || line.Contains("now")).Select(line => line.IndexOf(line.Contains("21:04") ? "21:04" : "now")).Distinct(); + await Assert.That(when.Count()).IsEqualTo(1); + } + + [Test] + public async Task Relayout_KeepsTheTreeAsGiven() + { + var built = BlockLayout.Build(new Rule(MarkupText.Plain("a · b")), 20, fluid: true); + var relaid = BlockLayout.Relayout(built, 20, new LayoutContext { AsciiOnly = true, Fold = AsciiFold.Default }); + + await Assert.That(relaid.ToPlainText()).Contains("a * b"); + var layout = relaid.Runs.SelectMany(run => run.Markups).OfType().Single(); + await Assert.That(((Rule)layout.Root).Title!.ToPlainText()).IsEqualTo("a · b"); + } +} diff --git a/MarkupString/AsciiFold.cs b/MarkupString/AsciiFold.cs new file mode 100644 index 0000000..9117628 --- /dev/null +++ b/MarkupString/AsciiFold.cs @@ -0,0 +1,274 @@ +using System.Collections.Frozen; +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using MarkupString.Layout; + +namespace MarkupString; + +/// +/// Text for a reader whose client shows only ASCII, or only Latin-1: each character it cannot show +/// becomes the nearest one it can. A middle dot · becomes *, a dash -, a curly quote +/// a straight one, é an e, box drawing its nearest ASCII; anything else ?, once for +/// each cell it took. +/// +/// +/// +/// Every built-in stand-in is as wide as the character it replaces, so text padded into columns keeps +/// them. set by a game come first and may be longer or shorter; a layout +/// block folded through is measured after the fold, so it stays aligned +/// whatever they are. +/// +/// +/// Markup is kept: a replacement carries the markup of the character it replaces. A point +/// () keeps its carrier. +/// +/// +public sealed class AsciiFold +{ + /// The built-in stand-ins, for an ASCII-only reader. + public static AsciiFold Default { get; } = new(); + + /// Creates a fold. + /// + /// Stand-ins that come before the built-in ones: each key one character (one grapheme cluster) outside + /// ASCII, each value printable ASCII, empty to leave the character out. + /// + /// Whether the reader shows Latin-1 (ISO-8859-1), which keeps · and é. + /// A key is not one character outside ASCII, or a value is not printable ASCII. + public AsciiFold(IEnumerable>? translations = null, bool latin1 = false) + { + var checkedTranslations = new Dictionary(StringComparer.Ordinal); + foreach (var (key, value) in translations ?? []) + { + if (!IsOneCluster(key) || IsAscii(key)) + throw new ArgumentException($"'{key}' is not one character outside ASCII.", nameof(translations)); + if (value is null || !IsPrintableAscii(value)) + throw new ArgumentException($"The stand-in for '{key}' is not printable ASCII.", nameof(translations)); + checkedTranslations[key] = value; + } + Translations = checkedTranslations.ToFrozenDictionary(StringComparer.Ordinal); + Latin1 = latin1; + } + + /// The stand-ins that come before the built-in ones. + public IReadOnlyDictionary Translations { get; } + + /// Whether the reader shows Latin-1, so only what Latin-1 lacks is replaced. + public bool Latin1 { get; } + + /// in the characters the reader shows. + public string Fold(string text) + { + ArgumentNullException.ThrowIfNull(text); + if (IsAscii(text)) return text; + + var builder = new StringBuilder(text.Length); + var span = text.AsSpan(); + foreach (var range in Graphemes.Enumerate(span)) + { + var cluster = span[range]; + if (Translate(cluster) is { } replacement) builder.Append(replacement); + else builder.Append(cluster); + } + return builder.ToString(); + } + + /// in the characters the reader shows, its markup kept. + public MarkupText Fold(MarkupText text) + { + ArgumentNullException.ThrowIfNull(text); + var source = text.Text; + if (IsAscii(source)) return text; + + var points = PointRanges(text.Runs); + var span = source.AsSpan(); + var builder = new StringBuilder(source.Length); + // Where each index of the old text lands in the new one, so the runs can follow. + var map = new int[source.Length + 1]; + var changed = false; + foreach (var range in Graphemes.Enumerate(span)) + { + var (start, end) = (range.Start.Value, range.End.Value); + var cluster = span[start..end]; + var replacement = InPoint(points, start) ? null : Translate(cluster); + for (var i = start; i < end; i++) map[i] = builder.Length; + if (replacement is null) builder.Append(cluster); + else + { + builder.Append(replacement); + changed = true; + } + } + map[source.Length] = builder.Length; + if (!changed) return text; + + var runs = ImmutableArray.CreateBuilder(text.Runs.Length); + foreach (var run in text.Runs) + { + var start = map[run.Start]; + var end = map[run.End]; + if (end > start) runs.Add(new Run(start, end - start, run.Markups)); + } + return new MarkupText(builder.ToString(), runs.ToImmutable()); + } + + /// What becomes, or null to keep it. + private string? Translate(ReadOnlySpan cluster) + { + if (IsAscii(cluster)) return null; + + var key = cluster.ToString(); + if (Translations.TryGetValue(key, out var translated)) return translated; + if (Latin1) + { + if (IsLatin1(cluster)) return null; + // A letter written as a base and an accent may have one Latin-1 character for both. + if (Composed(cluster) is { } composed) return composed; + } + + if (BuiltIn(key) is { } builtIn) return builtIn; + + var width = DisplayWidth.Of(cluster); + // A letter with accents as the letter, a fullwidth one as the letter and a space, to keep its two cells. + if (Letters(cluster) is { Length: > 0 } letters) return letters.Length < width ? letters.PadRight(width) : letters; + return new string('?', width); + } + + /// + /// The ASCII letters is written with, its accents left out; null when it + /// holds anything else. Read from tables of its own rather than Unicode normalisation, which a + /// runtime without ICU does not do. + /// + private static string? Letters(ReadOnlySpan cluster) + { + var builder = new StringBuilder(cluster.Length); + foreach (var c in cluster) + { + if (c is >= ' ' and <= '~') builder.Append(c); + else if (c is >= '\u00C0' and <= '\u024F' && LatinLetters[c - '\u00C0'] is not '\0' and var letter) builder.Append(letter); + else if (c is >= '\uFF01' and <= '\uFF5E') builder.Append((char)(c - 0xFEE0)); + else if (CharUnicodeInfo.GetUnicodeCategory(c) is not (UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format)) return null; + } + return builder.ToString(); + } + + /// The one Latin-1 character a letter and a single accent make, or null. + private static string? Composed(ReadOnlySpan cluster) + { + if (cluster.Length != 2) return null; + for (var i = 0; i < Latin1Composed.Length; i += 3) + if (Latin1Composed[i] == cluster[0] && Latin1Composed[i + 1] == cluster[1]) return Latin1Composed[i + 2].ToString(); + return null; + } + + /// The built-in stand-in for , or null. + private static string? BuiltIn(string cluster) + { + if (Table.TryGetValue(cluster, out var value)) return value; + // A character followed by a variation selector (☆︎, ✔️) stands in as the character does. + var first = Rune.GetRuneAt(cluster, 0); + var rest = cluster.AsSpan(first.Utf16SequenceLength); + if (rest.Length > 0 && !IsVariationSelectors(rest)) return null; + + var c = first.Value; + if (c is >= 0x2500 and <= 0x257F && BorderStyle.AsciiFor((char)c) is { } line) return line.ToString(); + if (c is >= 0x2580 and <= 0x259F) return "#"; + return rest.Length > 0 && Table.TryGetValue(first.ToString(), out var bare) ? bare : null; + } + + private static readonly FrozenDictionary Table = new Dictionary(StringComparer.Ordinal) + { + // Dots and bullets. + ["·"] = "*", ["•"] = "*", ["∙"] = "*", ["⋅"] = "*", ["‧"] = "*", ["●"] = "*", ["◦"] = "o", ["○"] = "o", + ["▪"] = "*", ["■"] = "#", ["□"] = "#", ["◆"] = "*", ["◇"] = "*", ["★"] = "*", ["☆"] = "*", ["‣"] = ">", + ["…"] = ".", + // Dashes and minus. + ["‐"] = "-", ["‑"] = "-", ["‒"] = "-", ["–"] = "-", ["—"] = "-", ["―"] = "-", ["−"] = "-", ["⁃"] = "-", + // Quotes and primes. + ["‘"] = "'", ["’"] = "'", ["‚"] = "'", ["‛"] = "'", ["′"] = "'", ["‹"] = "<", ["›"] = ">", + ["“"] = "\"", ["”"] = "\"", ["„"] = "\"", ["‟"] = "\"", ["″"] = "\"", ["«"] = "\"", ["»"] = "\"", + // Spaces. + [" "] = " ", [" "] = " ", [" "] = " ", [" "] = " ", [" "] = " ", [" "] = " ", + [" "] = " ", [" "] = " ", [" "] = " ", + // Arrows. + ["←"] = "<", ["→"] = ">", ["↑"] = "^", ["↓"] = "v", ["⇐"] = "<", ["⇒"] = ">", ["⇑"] = "^", ["⇓"] = "v", + ["◀"] = "<", ["▶"] = ">", ["▲"] = "^", ["▼"] = "v", ["◂"] = "<", ["▸"] = ">", ["▴"] = "^", ["▾"] = "v", + // Signs. + ["×"] = "x", ["÷"] = "/", ["≤"] = "<", ["≥"] = ">", ["≈"] = "~", ["✓"] = "+", ["✔"] = "+", ["✗"] = "x", + ["✘"] = "x", ["¦"] = "|", ["¡"] = "!", ["¿"] = "?", ["°"] = "o", + // Letters with no decomposition. + ["ß"] = "s", ["æ"] = "a", ["Æ"] = "A", ["œ"] = "o", ["Œ"] = "O", ["ø"] = "o", ["Ø"] = "O", ["ł"] = "l", + ["Ł"] = "L", ["đ"] = "d", ["Đ"] = "D", ["ð"] = "d", ["Ð"] = "D", ["þ"] = "p", ["Þ"] = "P", ["ı"] = "i", + }.ToFrozenDictionary(StringComparer.Ordinal); + + /// The letter each of U+00C0 to U+024F is written on, or a null character for none. + private const string LatinLetters = + "AAAAAA\0CEEEEIIII\0NOOOOO\0\0UUUUY\0\0aaaaaa\0ceeeeiiii\0nooooo\0\0uuuuy\0y" + + "AaAaAaCcCcCcCcDd\0\0EeEeEeEeEeGgGgGgGgHh\0\0IiIiIiIiI\0\0\0JjKk\0LlLlLl\0" + + "\0\0\0NnNnNn\0\0\0OoOoOo\0\0RrRrRrSsSsSsSsTtTt\0\0UuUuUuUuUuUuWwYyYZzZzZz\0" + + "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0Oo\0\0\0\0\0\0\0\0\0\0\0\0\0Uu\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0" + + "\0\0\0\0\0\0\0\0\0\0\0\0\0AaIiOoUuUuUuUuUu\0AaAa\0\0\0\0GgKkOoOo\0\0j\0\0\0Gg\0\0NnAa\0\0\0\0" + + "AaAaEeEeIiIiOoOoRrRrUuUuSsTt\0\0Hh\0\0\0\0\0\0AaEeOoOoOoOoYy\0\0\0\0\0\0\0\0\0\0\0\0" + + "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; + + /// Each Latin-1 letter with an accent, as the letter, the accent, and the character the two make. + private const string Latin1Composed = + "A\u0300\u00C0A\u0301\u00C1A\u0302\u00C2A\u0303\u00C3A\u0308\u00C4A\u030A\u00C5C\u0327\u00C7E\u0300\u00C8E\u0301\u00C9E\u0302\u00CAE\u0308\u00CBI\u0300\u00CCI\u0301\u00CDI\u0302\u00CEI\u0308\u00CFN\u0303\u00D1" + + "O\u0300\u00D2O\u0301\u00D3O\u0302\u00D4O\u0303\u00D5O\u0308\u00D6U\u0300\u00D9U\u0301\u00DAU\u0302\u00DBU\u0308\u00DCY\u0301\u00DDa\u0300\u00E0a\u0301\u00E1a\u0302\u00E2a\u0303\u00E3a\u0308\u00E4a\u030A\u00E5" + + "c\u0327\u00E7e\u0300\u00E8e\u0301\u00E9e\u0302\u00EAe\u0308\u00EBi\u0300\u00ECi\u0301\u00EDi\u0302\u00EEi\u0308\u00EFn\u0303\u00F1o\u0300\u00F2o\u0301\u00F3o\u0302\u00F4o\u0303\u00F5o\u0308\u00F6u\u0300\u00F9" + + "u\u0301\u00FAu\u0302\u00FBu\u0308\u00FCy\u0301\u00FDy\u0308\u00FF"; + + /// The stretches of that carry a point, which keep their carriers. + private static List<(int Start, int End)>? PointRanges(ImmutableArray runs) + { + List<(int Start, int End)>? ranges = null; + foreach (var run in runs) + foreach (var markup in run.Markups) + if (markup is IPointMarkup) + { + (ranges ??= []).Add((run.Start, run.End)); + break; + } + return ranges; + } + + private static bool InPoint(List<(int Start, int End)>? points, int index) + { + if (points is null) return false; + foreach (var (start, end) in points) + if (index >= start && index < end) return true; + return false; + } + + private static bool IsVariationSelectors(ReadOnlySpan text) + { + foreach (var c in text) + if (c is not (>= '︀' and <= '️')) return false; + return true; + } + + private static bool IsOneCluster(string? text) + { + if (string.IsNullOrEmpty(text)) return false; + var count = 0; + foreach (var _ in Graphemes.Enumerate(text)) count++; + return count == 1; + } + + private static bool IsAscii(ReadOnlySpan text) => Ascii.IsValid(text); + + private static bool IsLatin1(ReadOnlySpan text) + { + foreach (var c in text) + if (c > 'ÿ') return false; + return true; + } + + private static bool IsPrintableAscii(ReadOnlySpan text) + { + foreach (var c in text) + if (c is < ' ' or > '~') return false; + return true; + } +} diff --git a/MarkupString/Layout/Blocks/Block.cs b/MarkupString/Layout/Blocks/Block.cs index 967b0c2..56e0106 100644 --- a/MarkupString/Layout/Blocks/Block.cs +++ b/MarkupString/Layout/Blocks/Block.cs @@ -49,6 +49,14 @@ public virtual BlockMeasure Measure(LayoutContext context, int width) return new BlockMeasure(1, natural); } + /// + /// The block with applied to the text it shows: its content and titles, not the + /// pieces it draws with (borders, markers, fill), which already gives each + /// reader. How reaches every block in a tree. By default the block as it + /// is; a block of your own that holds text overrides it. + /// + public virtual Block MapText(Func map) => this; + /// Text as a block. public static implicit operator Block(MarkupText text) => new TextBlock(text); } @@ -70,6 +78,14 @@ public sealed record LayoutContext /// Plain ASCII only, for a client that cannot show anything else: box drawing becomes its nearest ASCII (). public bool AsciiOnly { get; init; } + /// + /// For a reader whose client shows only ASCII or Latin-1, the characters it cannot show replaced + /// before the block is measured, so a stand-in of another width keeps the columns; null to keep + /// them. , and + /// apply it. + /// + public AsciiFold? Fold { get; init; } + /// /// The content alone in reading order, for a screen reader: no borders, fill or columns, each /// title on a line of its own, each item after the one before it, a picture as its description. diff --git a/MarkupString/Layout/Blocks/BlockLayout.cs b/MarkupString/Layout/Blocks/BlockLayout.cs index 5b1d415..c481c48 100644 --- a/MarkupString/Layout/Blocks/BlockLayout.cs +++ b/MarkupString/Layout/Blocks/BlockLayout.cs @@ -19,13 +19,21 @@ public static class BlockLayout public static MarkupText Build(Block root, int width, bool fluid = false, LayoutContext? context = null) { ArgumentNullException.ThrowIfNull(root); + // The tree stays as it was given; only the lines are folded, by Lines. var text = MarkupText.Join(MarkupText.NewLine, Lines(root, width, context)); return text.Length == 0 ? text : MarkupText.Wrap(new LayoutMarkup(root, width, fluid, text.Text), text); } - /// Lays out at display cells, one entry per line. - public static IReadOnlyList Lines(Block block, int width, LayoutContext? context = null) => - (context ?? LayoutContext.Default).Lines(block, width); + /// + /// Lays out at display cells, one entry per line, its + /// text folded first when the context has a . + /// + public static IReadOnlyList Lines(Block block, int width, LayoutContext? context = null) + { + ArgumentNullException.ThrowIfNull(block); + context ??= LayoutContext.Default; + return context.Lines(context.Fold is { } fold ? block.MapText(fold.Fold) : block, width); + } /// /// The tree was laid out from when the whole of it is one intact block, diff --git a/MarkupString/Layout/Blocks/BlockText.cs b/MarkupString/Layout/Blocks/BlockText.cs index 3dd0403..1645d7d 100644 --- a/MarkupString/Layout/Blocks/BlockText.cs +++ b/MarkupString/Layout/Blocks/BlockText.cs @@ -188,6 +188,20 @@ private static MarkupText Edge(MarkupText left, MarkupText fill, MarkupText righ return MarkupText.Concat([left, middle.FormatColumn(format)[0], right]); } + /// Each of with applied to its text (). + public static ImmutableArray Map(ImmutableArray blocks, Func map) => + blocks.IsDefault ? blocks : blocks.Select(block => block.MapText(map)).ToImmutableArray(); + + /// Each of with applied to its text. + public static ImmutableArray Map(ImmutableArray titles, Func map) => + titles.IsDefault ? titles : titles.Select(title => title with { Text = map(title.Text) }).ToImmutableArray(); + + /// Each of , and the items under it, with applied to its text. + public static ImmutableArray Map(ImmutableArray items, Func map) => + items.IsDefault + ? items + : items.Select(item => item with { Content = item.Content.MapText(map), Children = Map(item.Children, map) }).ToImmutableArray(); + /// A number as a person writes it: 6, 2.5. public static string Figure(double value) => value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); } diff --git a/MarkupString/Layout/Blocks/BorderStyle.cs b/MarkupString/Layout/Blocks/BorderStyle.cs index 96ed609..13ba0ea 100644 --- a/MarkupString/Layout/Blocks/BorderStyle.cs +++ b/MarkupString/Layout/Blocks/BorderStyle.cs @@ -134,7 +134,7 @@ public BorderStyle ToAscii() } /// The ASCII stand-in for a box-drawing character (U+2500 to U+257F), or null. - private static char? AsciiFor(char c) => c switch + internal static char? AsciiFor(char c) => c switch { '─' or '┄' or '┈' or '╌' or '╴' or '╶' => '-', '━' or '┅' or '┉' or '╍' or '═' or '╸' or '╺' => '=', diff --git a/MarkupString/Layout/Blocks/Bullets.cs b/MarkupString/Layout/Blocks/Bullets.cs index a72fb03..a64e4aa 100644 --- a/MarkupString/Layout/Blocks/Bullets.cs +++ b/MarkupString/Layout/Blocks/Bullets.cs @@ -9,6 +9,10 @@ namespace MarkupString.Layout; /// The items. public sealed record Bullets(ImmutableArray Items) : Block { + /// + public override Block MapText(Func map) => + this with { Items = BlockText.Map(Items, map) }; + /// What kind of marker. public BulletStyle Style { get; init; } = BulletStyle.Bullet; @@ -116,6 +120,10 @@ public enum BulletStyle /// The items. public sealed record Grid(ImmutableArray Items) : Block { + /// + public override Block MapText(Func map) => + this with { Items = Items.IsDefault ? Items : Items.Select(map).ToImmutableArray() }; + /// Cells between two columns. public int Gap { get; init; } = 2; diff --git a/MarkupString/Layout/Blocks/Fields.cs b/MarkupString/Layout/Blocks/Fields.cs index 353de5d..dacb8d9 100644 --- a/MarkupString/Layout/Blocks/Fields.cs +++ b/MarkupString/Layout/Blocks/Fields.cs @@ -14,6 +14,10 @@ namespace MarkupString.Layout; /// public sealed record Fields(ImmutableArray Items) : Block { + /// + public override Block MapText(Func map) => + this with { Items = Items.IsDefault ? Items : Items.Select(item => item with { Label = map(item.Label), Value = item.Value.MapText(map) }).ToImmutableArray() }; + /// The narrowest a value column may be before each label goes over its value instead. private const int MinValue = 10; diff --git a/MarkupString/Layout/Blocks/Figure.cs b/MarkupString/Layout/Blocks/Figure.cs index 72b53d7..bf13e58 100644 --- a/MarkupString/Layout/Blocks/Figure.cs +++ b/MarkupString/Layout/Blocks/Figure.cs @@ -8,6 +8,10 @@ namespace MarkupString.Layout; /// Text art standing in for the picture where pictures are not shown; its lines keep their own spacing. Empty for none. public sealed record Figure(ImageMarkup Image, MarkupText Art) : Block { + /// + public override Block MapText(Func map) => + this with { Art = map(Art), Beside = Beside?.MapText(map) }; + /// Which side the picture sits on, with flowing round it. public FigureFloat Float { get; init; } diff --git a/MarkupString/Layout/Blocks/Flex.cs b/MarkupString/Layout/Blocks/Flex.cs index 2c4cc37..6d5b892 100644 --- a/MarkupString/Layout/Blocks/Flex.cs +++ b/MarkupString/Layout/Blocks/Flex.cs @@ -6,6 +6,10 @@ namespace MarkupString.Layout; /// The items; size one with , or it shares out what the sized ones leave. public sealed record Flex(ImmutableArray Items) : Block { + /// + public override Block MapText(Func map) => + this with { Items = BlockText.Map(Items, map) }; + /// Cells between two items, when there is no . public int Gap { get; init; } = 2; @@ -143,6 +147,10 @@ public override void DrawLinear(LayoutContext context, int width, IListWhat is sized. public sealed record Sized(Block Content) : Block { + /// + public override Block MapText(Func map) => + this with { Content = Content.MapText(map) }; + /// The width it asks for; shares out what the others leave. public BlockSize Basis { get; init; } diff --git a/MarkupString/Layout/Blocks/Frame.cs b/MarkupString/Layout/Blocks/Frame.cs index f0aab59..babea94 100644 --- a/MarkupString/Layout/Blocks/Frame.cs +++ b/MarkupString/Layout/Blocks/Frame.cs @@ -23,6 +23,10 @@ public sealed record EdgeTitle(MarkupText Text, Alignment Side = Alignment.Cente /// public sealed record Rule(MarkupText? Title = null) : Block { + /// + public override Block MapText(Func map) => + this with { Title = Title is null ? null : map(Title), Titles = BlockText.Map(Titles, map) }; + /// Whose horizontal edge and title brackets the line is drawn with; unset, the frame's around it or the theme's. public BorderStyle? Border { get; init; } @@ -54,6 +58,16 @@ public override void DrawLinear(LayoutContext context, int width, IListUsually made with . public sealed record Frame(Block Body) : Block { + /// + public override Block MapText(Func map) => + this with + { + Body = Body.MapText(map), + Title = Title is null ? null : map(Title), + Titles = BlockText.Map(Titles, map), + BottomTitles = BlockText.Map(BottomTitles, map), + }; + /// The characters the frame is drawn with; unset, the theme's. public BorderStyle? Border { get; init; } diff --git a/MarkupString/Layout/Blocks/Gauge.cs b/MarkupString/Layout/Blocks/Gauge.cs index ab0569c..83fd452 100644 --- a/MarkupString/Layout/Blocks/Gauge.cs +++ b/MarkupString/Layout/Blocks/Gauge.cs @@ -8,6 +8,10 @@ namespace MarkupString.Layout; /// How much there could be; the bar is full at this. public sealed record Gauge(double Value, double Maximum) : Block { + /// + public override Block MapText(Func map) => + this with { Label = Label is null ? null : map(Label) }; + /// What it measures, or none. public MarkupText? Label { get; init; } diff --git a/MarkupString/Layout/Blocks/Modifiers.cs b/MarkupString/Layout/Blocks/Modifiers.cs index bc3385c..0258b56 100644 --- a/MarkupString/Layout/Blocks/Modifiers.cs +++ b/MarkupString/Layout/Blocks/Modifiers.cs @@ -7,6 +7,10 @@ namespace MarkupString.Layout; /// Where its text sits. public sealed record Aligned(Block Content, Alignment Alignment) : Block { + /// + public override Block MapText(Func map) => + this with { Content = Content.MapText(map) }; + /// public override void Draw(LayoutContext context, int width, IList lines) => (context with { TextAlignment = Alignment }).Draw(Content, width, lines); @@ -20,6 +24,10 @@ public override void Draw(LayoutContext context, int width, IList li /// The colours. public sealed record Shaded(Block Content, ColorGradient Gradient) : Block { + /// + public override Block MapText(Func map) => + this with { Content = Content.MapText(map) }; + /// Which way the colours run. public GradientFlow Flow { get; init; } = GradientFlow.Across; @@ -33,6 +41,10 @@ public override void Draw(LayoutContext context, int width, IList li /// The layer laid over each of its lines. public sealed record Colored(Block Content, IMarkup Markup) : Block { + /// + public override Block MapText(Func map) => + this with { Content = Content.MapText(map) }; + /// public override void Draw(LayoutContext context, int width, IList lines) { @@ -45,6 +57,10 @@ public override void Draw(LayoutContext context, int width, IList li /// What it sets; what it leaves unset comes from around it. public sealed record Themed(Block Content, LayoutTheme Theme) : Block { + /// + public override Block MapText(Func map) => + this with { Content = Content.MapText(map) }; + /// /// Whether only fills in what the theme around it leaves unset, rather than /// overriding it: a game's default look, under which the theme a reader draws it in still shows. diff --git a/MarkupString/Layout/Blocks/Table.cs b/MarkupString/Layout/Blocks/Table.cs index 39d24e1..12bd998 100644 --- a/MarkupString/Layout/Blocks/Table.cs +++ b/MarkupString/Layout/Blocks/Table.cs @@ -12,6 +12,14 @@ namespace MarkupString.Layout; /// The rows, a cell per column; a short row is padded with empty cells. A cell's text takes its column's alignment unless it sets its own. public sealed record Table(ImmutableArray Columns, ImmutableArray> Rows) : Block { + /// + public override Block MapText(Func map) => + this with + { + Columns = Columns.IsDefault ? Columns : Columns.Select(column => column with { Header = map(column.Header) }).ToImmutableArray(), + Rows = Rows.IsDefault ? Rows : Rows.Select(row => BlockText.Map(row, map)).ToImmutableArray(), + }; + /// Cells between two columns, when there is no . public int Gap { get; init; } = 2; diff --git a/MarkupString/Layout/Blocks/TextBlock.cs b/MarkupString/Layout/Blocks/TextBlock.cs index fdfb70a..7448dab 100644 --- a/MarkupString/Layout/Blocks/TextBlock.cs +++ b/MarkupString/Layout/Blocks/TextBlock.cs @@ -6,6 +6,10 @@ namespace MarkupString.Layout; /// The text, with its own markup. public sealed record TextBlock(MarkupText Content) : Block { + /// + public override Block MapText(Func map) => + this with { Content = map(Content) }; + /// Where each line sits in the width; unset, where the context puts text (). public Alignment? Alignment { get; init; } @@ -31,6 +35,10 @@ public override BlockMeasure Measure(LayoutContext context, int width) /// As a 's body, a child becomes a divider that meets the frame's sides. public sealed record Stack(ImmutableArray Children) : Block { + /// + public override Block MapText(Func map) => + this with { Children = BlockText.Map(Children, map) }; + /// public override void Draw(LayoutContext context, int width, IList lines) { diff --git a/MarkupString/Layout/Blocks/Tree.cs b/MarkupString/Layout/Blocks/Tree.cs index 34b5995..7a71145 100644 --- a/MarkupString/Layout/Blocks/Tree.cs +++ b/MarkupString/Layout/Blocks/Tree.cs @@ -9,6 +9,10 @@ namespace MarkupString.Layout; /// The top-level items. public sealed record Tree(ImmutableArray Items) : Block { + /// + public override Block MapText(Func map) => + this with { Items = BlockText.Map(Items, map) }; + /// The characters the guide lines are drawn with; unset, the theme's. public TreeGuide? Guide { get; init; } diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 7dc5c58..b29dae6 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -1 +1,28 @@ #nullable enable +MarkupString.AsciiFold +MarkupString.AsciiFold.AsciiFold(System.Collections.Generic.IEnumerable>? translations = null, bool latin1 = false) -> void +MarkupString.AsciiFold.Fold(MarkupString.MarkupText! text) -> MarkupString.MarkupText! +MarkupString.AsciiFold.Fold(string! text) -> string! +MarkupString.AsciiFold.Latin1.get -> bool +MarkupString.AsciiFold.Translations.get -> System.Collections.Generic.IReadOnlyDictionary! +MarkupString.Layout.LayoutContext.Fold.get -> MarkupString.AsciiFold? +MarkupString.Layout.LayoutContext.Fold.init -> void +override MarkupString.Layout.Aligned.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Bullets.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Colored.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Fields.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Figure.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Flex.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Frame.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Gauge.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Grid.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Rule.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Shaded.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Sized.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Stack.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Table.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.TextBlock.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Themed.MapText(System.Func! map) -> MarkupString.Layout.Block! +override MarkupString.Layout.Tree.MapText(System.Func! map) -> MarkupString.Layout.Block! +static MarkupString.AsciiFold.Default.get -> MarkupString.AsciiFold! +virtual MarkupString.Layout.Block.MapText(System.Func! map) -> MarkupString.Layout.Block! diff --git a/docs/layout.md b/docs/layout.md index 2869a71..de11292 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -500,9 +500,30 @@ or heavy one `=`, an upright `|`, a corner or tee `+`. A double frame stays reco colour on a piece is kept. A piece holding anything else (an emoji, a title bracket like `┤ `) takes the `ascii` preset's piece, as does any tree guide piece, so the last branch stays `` `- ``. A flex separator is translated the same way, and so are gauge, bullet and table pieces (a `•` becomes `*`, -a `█` `#`). Text inside a block is never changed. `Linear` drops borders +a `█` `#`). Text inside a block is changed only by `Fold`, below. `Linear` drops borders and guides, reads fields as `Label: value` lines and indents tree levels with spaces. +**Folding text.** `AsciiFold` replaces each character a client cannot show with the nearest one it +can: `·` and `•` become `*`, dashes `-`, curly quotes straight ones, `é` an `e`, a fullwidth letter +the letter and a space, and anything else `?`, once for each cell it took. Each built-in stand-in +is as wide as what it replaces, so text padded into columns keeps them. `new AsciiFold(latin1: true)` +keeps what Latin-1 has (`·`, `é`), and `new AsciiFold(translations)` puts stand-ins of your own +first, such as `·` to `" - "` or `…` to `"..."`; each key is one character outside ASCII, each value +printable ASCII. `fold.Fold(text)` folds a string or a `MarkupText`, whose markup stays on the +characters it was on. + +Given as `LayoutContext.Fold`, the fold reaches the text inside every block (`Block.MapText`) before +the block is measured, so a stand-in of another width still lines up. The tree a block carries is +not changed. Text outside blocks is the caller's to fold. + +```csharp +var fold = new AsciiFold([new("·", " - ")]); +var context = new LayoutContext { AsciiOnly = true, Fold = fold }; +var text = fold.Fold(BlockLayout.Relayout(output, width, context)); +``` + +A block of your own that holds text overrides `MapText` so the fold reaches it. + **Nesting.** `BlockLayout.AsBlock(content)` returns the tree of a text that is one whole block, and a `TextBlock` otherwise, so a builder that takes text as an argument nests a block it is given. `BlockLayout.Blocks(content)` splits a text into the blocks standing on lines of their own and the