Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 2.23.0 — Unreleased

### Added

- **`MarkupText.StandIn(content, text)`** (`StandInMarkup`): the ASCII text a reader whose client cannot
show `content` is sent instead, so a 📌 can reach an ASCII client as `P` rather than `??`.
`AsciiFold` replaces the whole covered text when any character in it would be replaced, before a
game's translations and the built-in stand-ins, and leaves it alone for a reader who shows all of
it. No format writes anything for it, and it is stored on the wire as `standin`.

## 2.22.1 — Unreleased

### Fixed
Expand Down
86 changes: 86 additions & 0 deletions MarkupString.Tests/AsciiFoldTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,90 @@ public async Task Relayout_KeepsTheTreeAsGiven()
var layout = relaid.Runs.SelectMany(run => run.Markups).OfType<LayoutMarkup>().Single();
await Assert.That(((Rule)layout.Root).Title!.ToPlainText()).IsEqualTo("a · b");
}
[Test]
public async Task StandIn_ReplacesTheWholeTextForAReaderWhoCannotShowIt()
{
var text = MarkupText.Concat([MarkupText.StandIn(MarkupText.Plain("📌"), "P"), MarkupText.Plain(" Rules")]);

await Assert.That(AsciiFold.Default.Fold(text).Text).IsEqualTo("P Rules");
await Assert.That(AsciiFold.Default.Fold(MarkupText.StandIn(MarkupText.Plain("✓ done"), "[x] done")).Text).IsEqualTo("[x] done");
await Assert.That(AsciiFold.Default.Fold(MarkupText.StandIn(MarkupText.Plain("✨"), "")).Text).IsEqualTo("");
}

[Test]
public async Task StandIn_ComesBeforeTheGamesStandIns()
=> await Assert.That(new AsciiFold([new("📌", "*")]).Fold(MarkupText.StandIn(MarkupText.Plain("📌"), "P")).Text).IsEqualTo("P");

[Test]
public async Task StandIn_LeavesTextTheReaderShows()
{
var text = MarkupText.Concat([MarkupText.StandIn(MarkupText.Plain("café"), "cafe!"), MarkupText.Plain(" ·")]);

await Assert.That(new AsciiFold(latin1: true).Fold(text).Text).IsEqualTo("café ·");
await Assert.That(AsciiFold.Default.Fold(text).Text).IsEqualTo("cafe! *");
}

[Test]
public async Task StandIn_IsOneAcrossAColourChangeAndCarriesTheFirstCharactersMarkup()
{
var text = MarkupText.Concat([MarkupText.Plain("a"), MarkupText.StandIn(MarkupText.Concat([MarkupText.Wrap(Red, "→"), MarkupText.Plain("…")]), "->"), MarkupText.Plain("b")]);
var folded = AsciiFold.Default.Fold(text);

await Assert.That(folded.Text).IsEqualTo("a->b");
await Assert.That(folded.Runs.Any(run => run.Start == 1 && run.Length == 2 && run.Markups.Contains(Red))).IsTrue();
}

[Test]
public async Task StandIn_SurvivesTheWire()
{
var text = MarkupText.StandIn(MarkupText.Plain("📌"), "P");
var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(text));

await Assert.That(AsciiFold.Default.Fold(read).Text).IsEqualTo("P");
await Assert.That(read.Runs.Single().Markups.Contains(new StandInMarkup("P"))).IsTrue();
}

[Test]
public async Task StandIn_LaysOutAtItsOwnWidth()
{
var table = new Table([new TableColumn(MarkupText.Plain("")), new TableColumn(MarkupText.Plain("Title"))],
[
[MarkupText.StandIn(MarkupText.Plain("📌"), "P"), MarkupText.Plain("Rules")],
[MarkupText.Plain("U"), MarkupText.Plain("News")],
]);
var context = new LayoutContext { AsciiOnly = true, Fold = AsciiFold.Default };

var lines = BlockLayout.Lines(table, 30, context).Select(line => line.ToPlainText()).ToArray();

await Assert.That(lines.Single(line => line.Contains("Rules")).IndexOf("Rules")).IsEqualTo(lines.Single(line => line.Contains("News")).IndexOf("News"));
await Assert.That(lines.Any(line => line.TrimStart().StartsWith("P"))).IsTrue();
}

[Test]
public async Task StandIn_KeepsThePointsItCovers()
{
var after = AsciiFold.Default.Fold(MarkupText.StandIn(MarkupText.Concat([MarkupText.Plain("📌"), MarkupText.Sound("ping.wav")]), "P"));
var before = AsciiFold.Default.Fold(MarkupText.StandIn(MarkupText.Concat([MarkupText.Sound("ping.wav"), MarkupText.Plain("📌")]), "P"));

await Assert.That(after.Text).IsEqualTo("P" + MarkupText.PointCarrier);
await Assert.That(after.Runs.Any(run => run.Start == 1 && run.Length == 1 && run.Markups.Any(m => m is SoundMarkup))).IsTrue();
await Assert.That(before.Text).IsEqualTo(MarkupText.PointCarrier + "P");
await Assert.That(before.Runs.Any(run => run.Start == 0 && run.Length == 1 && run.Markups.Any(m => m is SoundMarkup))).IsTrue();
}

[Test]
public async Task StandIn_IsIgnoredWhenItsTextIsNotPrintableAscii()
=> await Assert.That(AsciiFold.Default.Fold(MarkupText.Wrap(new StandInMarkup("é"), MarkupText.Plain("📌"))).Text).IsEqualTo("??");

[Test]
public async Task StandIn_ReplacesEachOfMany()
{
var text = MarkupText.Concat(Enumerable.Range(0, 1000).SelectMany(_ => new[] { MarkupText.StandIn(MarkupText.Plain("📌"), "P"), MarkupText.Plain(" ") }));

await Assert.That(AsciiFold.Default.Fold(text).Text).IsEqualTo(string.Concat(Enumerable.Repeat("P ", 1000)));
}

[Test]
public async Task StandIn_RefusesTextThatIsNotPrintableAscii()
=> await Assert.That(() => MarkupText.StandIn(MarkupText.Plain("📌"), "é")).Throws<ArgumentException>();
}
81 changes: 80 additions & 1 deletion MarkupString/AsciiFold.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ namespace MarkupString;
/// </para>
/// <para>
/// Markup is kept: a replacement carries the markup of the character it replaces. A point
/// (<see cref="IPointMarkup"/>) keeps its carrier.
/// (<see cref="IPointMarkup"/>) keeps its carrier. Text under a <see cref="StandInMarkup"/> is replaced
/// whole by its stand-in when any of it would be replaced.
/// </para>
/// </remarks>
public sealed class AsciiFold
Expand Down Expand Up @@ -82,14 +83,42 @@ public MarkupText Fold(MarkupText text)
if (IsAscii(source)) return text;

var points = PointRanges(text.Runs);
var standIns = StandInRanges(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;
// The end of the stand-in being replaced, and its text until it is written.
var skipTo = 0;
string? pending = null;
// The first stand-in that may still start here; they are in order, as the graphemes are.
var nextStandIn = 0;
foreach (var range in Graphemes.Enumerate(span))
{
var (start, end) = (range.Start.Value, range.End.Value);
if (start >= skipTo && StandInAt(standIns, ref nextStandIn, start) is var (standInEnd, standIn)
&& NeedsFold(span[start..standInEnd], points, start))
{
skipTo = standInEnd;
pending = standIn;
changed = true;
}

if (start < skipTo)
{
// A point keeps its carrier. The first character that is not one is written as the stand-in's
// text, so its run holds the text, and the rest are left out.
for (var i = start; i < end; i++) map[i] = builder.Length;
if (InPoint(points, start)) builder.Append(span[start..end]);
else if (pending is not null)
{
builder.Append(pending);
pending = null;
}
continue;
}

var cluster = span[start..end];
var replacement = InPoint(points, start) ? null : Translate(cluster);
for (var i = start; i < end; i++) map[i] = builder.Length;
Expand Down Expand Up @@ -233,6 +262,56 @@ public MarkupText Fold(MarkupText text)
return ranges;
}

/// <summary>
/// The stretches of <paramref name="runs"/> under a <see cref="StandInMarkup"/>, in order, with touching runs
/// under the same one joined: a colour change inside a stand-in does not split it.
/// </summary>
private static List<(int Start, int End, string Text)>? StandInRanges(ImmutableArray<Run> runs)
{
List<(int Start, int End, string Text)>? ranges = null;
StandInMarkup? last = null;
foreach (var run in runs)
{
StandInMarkup? found = null;
// One whose text is not printable ASCII (built directly, or read from the wire) is ignored, so the
// covered text folds as any other would.
foreach (var markup in run.Markups)
if (markup is StandInMarkup standIn && IsPrintableAscii(standIn.Text))
{
found = standIn;
break;
}
if (found is null)
{
last = null;
continue;
}

ranges ??= [];
if (found.Equals(last) && ranges[^1].End == run.Start) ranges[^1] = ranges[^1] with { End = run.End };
else ranges.Add((run.Start, run.End, found.Text));
last = found;
}
return ranges;
}

/// <summary>The end and text of the stand-in that starts at <paramref name="index"/>, or null.</summary>
/// <remarks><paramref name="next"/> moves past every stand-in that starts before <paramref name="index"/>.</remarks>
private static (int End, string Text)? StandInAt(List<(int Start, int End, string Text)>? standIns, ref int next, int index)
{
if (standIns is null) return null;
while (next < standIns.Count && standIns[next].Start < index) next++;
return next < standIns.Count && standIns[next].Start == index ? (standIns[next].End, standIns[next].Text) : null;
}

/// <summary>Whether this fold would replace any character of <paramref name="text"/>, which starts at <paramref name="offset"/>.</summary>
private bool NeedsFold(ReadOnlySpan<char> text, List<(int Start, int End)>? points, int offset)
{
foreach (var range in Graphemes.Enumerate(text))
if (!InPoint(points, offset + range.Start.Value) && Translate(text[range]) is not null) return true;
return false;
}

private static bool InPoint(List<(int Start, int End)>? points, int index)
{
if (points is null) return false;
Expand Down
3 changes: 3 additions & 0 deletions MarkupString/Elements/ElementCodecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ internal static class ElementCodecs
if (m.Caption is not null) w.WriteString("ca", m.Caption);
},
static e => new StatusMarkup(String(e, "n") ?? string.Empty, String(e, "m"), String(e, "ca"))),
new Codec<StandInMarkup>("standin",
static (w, m) => w.WriteString("t", m.Text),
static e => new StandInMarkup(String(e, "t") ?? string.Empty)),
];

private static readonly Dictionary<Type, IMarkupCodec> ByType = All.ToDictionary(c => c.MarkupType);
Expand Down
20 changes: 20 additions & 0 deletions MarkupString/Elements/StandInMarkup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace MarkupString;

/// <summary>
/// The text a reader whose client cannot show the text it covers is sent instead: <c>P</c> for a 📌, say,
/// where the built-in stand-ins would send <c>??</c>. <see cref="MarkupText.StandIn"/> builds one.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="AsciiFold"/> replaces the whole covered text with <see cref="Text"/> when any character in it
/// would be replaced, and leaves it alone when the reader shows all of it, so a Latin-1 reader keeps an
/// <c>é</c> covered by one. The replacement carries the markup of the first character it replaces. It comes
/// before a game's own stand-ins and the built-in ones.
/// </para>
/// <para>
/// No format writes anything for it (<see cref="IDataMarkup"/>): a reader that shows the text gets the
/// text. Two next to each other with the same text are one, as equal layers always are.
/// </para>
/// </remarks>
/// <param name="Text">Printable ASCII, or empty to leave the covered text out.</param>
public sealed record StandInMarkup(string Text) : IDataMarkup;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
14 changes: 14 additions & 0 deletions MarkupString/MarkupText.Elements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,20 @@ public static MarkupText Gauge(MarkupText content, string variable, string maxim
return Wrap(new GaugeMarkup(variable, maximum, caption, color), content);
}

/// <summary>
/// <paramref name="content"/>, sent as <paramref name="text"/> to a reader whose client cannot show it.
/// See <see cref="StandInMarkup"/>.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="text"/> is not printable ASCII.</exception>
public static MarkupText StandIn(MarkupText content, string text)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(text);
foreach (var c in text)
if (c is < ' ' or > '~') throw new ArgumentException("A stand-in is printable ASCII.", nameof(text));
return Wrap(new StandInMarkup(text), content);
}

/// <summary>
/// <paramref name="variable"/> in the client's status bar, standing in <paramref name="content"/>
/// for a client with none. See <see cref="StatusMarkup"/>.
Expand Down
13 changes: 13 additions & 0 deletions MarkupString/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,16 @@ MarkupString.GradientTarget.Borders = 1 -> MarkupString.GradientTarget
MarkupString.GradientTarget.Text = 2 -> MarkupString.GradientTarget
MarkupString.Layout.Shaded.Target.get -> MarkupString.GradientTarget
MarkupString.Layout.Shaded.Target.init -> void
MarkupString.StandInMarkup
MarkupString.StandInMarkup.<Clone>$() -> MarkupString.StandInMarkup!
MarkupString.StandInMarkup.Deconstruct(out string! Text) -> void
MarkupString.StandInMarkup.Equals(MarkupString.StandInMarkup? other) -> bool
MarkupString.StandInMarkup.StandInMarkup(string! Text) -> void
MarkupString.StandInMarkup.Text.get -> string!
MarkupString.StandInMarkup.Text.init -> void
override MarkupString.StandInMarkup.Equals(object? obj) -> bool
override MarkupString.StandInMarkup.GetHashCode() -> int
override MarkupString.StandInMarkup.ToString() -> string!
static MarkupString.StandInMarkup.operator !=(MarkupString.StandInMarkup? left, MarkupString.StandInMarkup? right) -> bool
static MarkupString.StandInMarkup.operator ==(MarkupString.StandInMarkup? left, MarkupString.StandInMarkup? right) -> bool
static MarkupString.MarkupText.StandIn(MarkupString.MarkupText! content, string! text) -> MarkupString.MarkupText!
5 changes: 5 additions & 0 deletions docs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,11 @@ first, such as `·` to `" - "` or `…` to `"..."`; each key is one character ou
printable ASCII. `fold.Fold(text)` folds a string or a `MarkupText`, whose markup stays on the
characters it was on.

A text can carry its own stand-in: `MarkupText.StandIn(MarkupText.Plain("📌"), "P")` is sent as `P` to a
reader who cannot show the pin, where the built-in fold would send `??`. The whole covered text is
replaced when any character in it would be, and kept when the reader shows all of it. It comes before
the fold's own translations, and no format writes anything for it (`StandInMarkup` is an `IDataMarkup`).

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.
Expand Down
Loading