Skip to content

Commit bf75324

Browse files
Add MarkupText.StandIn: text with its own ASCII stand-in (2.23.0) (#71)
* Add MarkupText.StandIn: text that carries its own ASCII stand-in (2.23.0) A StandInMarkup data layer names the ASCII text a reader whose client cannot show the covered text is sent instead. AsciiFold replaces the whole covered text with it when any character in it would be replaced, before the game's translations and the built-in stand-ins, and leaves it alone when the reader shows all of it. Stored on the wire as "standin". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwFSYuSdkcBDL82H3jvQvG * Keep points inside a replaced stand-in; ignore a stand-in whose text is not printable ASCII Points a stand-in covers keep their carriers and markup. A StandInMarkup built directly or read from the wire with non-ASCII text no longer reaches an ASCII reader. Stand-in lookup walks the ordered ranges once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwFSYuSdkcBDL82H3jvQvG --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 203b0e1 commit bf75324

8 files changed

Lines changed: 231 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget
66
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
77
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

9+
## 2.23.0 — Unreleased
10+
11+
### Added
12+
13+
- **`MarkupText.StandIn(content, text)`** (`StandInMarkup`): the ASCII text a reader whose client cannot
14+
show `content` is sent instead, so a 📌 can reach an ASCII client as `P` rather than `??`.
15+
`AsciiFold` replaces the whole covered text when any character in it would be replaced, before a
16+
game's translations and the built-in stand-ins, and leaves it alone for a reader who shows all of
17+
it. No format writes anything for it, and it is stored on the wire as `standin`.
18+
919
## 2.22.1 — Unreleased
1020

1121
### Fixed

‎MarkupString.Tests/AsciiFoldTests.cs‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,90 @@ public async Task Relayout_KeepsTheTreeAsGiven()
117117
var layout = relaid.Runs.SelectMany(run => run.Markups).OfType<LayoutMarkup>().Single();
118118
await Assert.That(((Rule)layout.Root).Title!.ToPlainText()).IsEqualTo("a · b");
119119
}
120+
[Test]
121+
public async Task StandIn_ReplacesTheWholeTextForAReaderWhoCannotShowIt()
122+
{
123+
var text = MarkupText.Concat([MarkupText.StandIn(MarkupText.Plain("📌"), "P"), MarkupText.Plain(" Rules")]);
124+
125+
await Assert.That(AsciiFold.Default.Fold(text).Text).IsEqualTo("P Rules");
126+
await Assert.That(AsciiFold.Default.Fold(MarkupText.StandIn(MarkupText.Plain("✓ done"), "[x] done")).Text).IsEqualTo("[x] done");
127+
await Assert.That(AsciiFold.Default.Fold(MarkupText.StandIn(MarkupText.Plain("✨"), "")).Text).IsEqualTo("");
128+
}
129+
130+
[Test]
131+
public async Task StandIn_ComesBeforeTheGamesStandIns()
132+
=> await Assert.That(new AsciiFold([new("📌", "*")]).Fold(MarkupText.StandIn(MarkupText.Plain("📌"), "P")).Text).IsEqualTo("P");
133+
134+
[Test]
135+
public async Task StandIn_LeavesTextTheReaderShows()
136+
{
137+
var text = MarkupText.Concat([MarkupText.StandIn(MarkupText.Plain("café"), "cafe!"), MarkupText.Plain(" ·")]);
138+
139+
await Assert.That(new AsciiFold(latin1: true).Fold(text).Text).IsEqualTo("café ·");
140+
await Assert.That(AsciiFold.Default.Fold(text).Text).IsEqualTo("cafe! *");
141+
}
142+
143+
[Test]
144+
public async Task StandIn_IsOneAcrossAColourChangeAndCarriesTheFirstCharactersMarkup()
145+
{
146+
var text = MarkupText.Concat([MarkupText.Plain("a"), MarkupText.StandIn(MarkupText.Concat([MarkupText.Wrap(Red, "→"), MarkupText.Plain("…")]), "->"), MarkupText.Plain("b")]);
147+
var folded = AsciiFold.Default.Fold(text);
148+
149+
await Assert.That(folded.Text).IsEqualTo("a->b");
150+
await Assert.That(folded.Runs.Any(run => run.Start == 1 && run.Length == 2 && run.Markups.Contains(Red))).IsTrue();
151+
}
152+
153+
[Test]
154+
public async Task StandIn_SurvivesTheWire()
155+
{
156+
var text = MarkupText.StandIn(MarkupText.Plain("📌"), "P");
157+
var read = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(text));
158+
159+
await Assert.That(AsciiFold.Default.Fold(read).Text).IsEqualTo("P");
160+
await Assert.That(read.Runs.Single().Markups.Contains(new StandInMarkup("P"))).IsTrue();
161+
}
162+
163+
[Test]
164+
public async Task StandIn_LaysOutAtItsOwnWidth()
165+
{
166+
var table = new Table([new TableColumn(MarkupText.Plain("")), new TableColumn(MarkupText.Plain("Title"))],
167+
[
168+
[MarkupText.StandIn(MarkupText.Plain("📌"), "P"), MarkupText.Plain("Rules")],
169+
[MarkupText.Plain("U"), MarkupText.Plain("News")],
170+
]);
171+
var context = new LayoutContext { AsciiOnly = true, Fold = AsciiFold.Default };
172+
173+
var lines = BlockLayout.Lines(table, 30, context).Select(line => line.ToPlainText()).ToArray();
174+
175+
await Assert.That(lines.Single(line => line.Contains("Rules")).IndexOf("Rules")).IsEqualTo(lines.Single(line => line.Contains("News")).IndexOf("News"));
176+
await Assert.That(lines.Any(line => line.TrimStart().StartsWith("P"))).IsTrue();
177+
}
178+
179+
[Test]
180+
public async Task StandIn_KeepsThePointsItCovers()
181+
{
182+
var after = AsciiFold.Default.Fold(MarkupText.StandIn(MarkupText.Concat([MarkupText.Plain("📌"), MarkupText.Sound("ping.wav")]), "P"));
183+
var before = AsciiFold.Default.Fold(MarkupText.StandIn(MarkupText.Concat([MarkupText.Sound("ping.wav"), MarkupText.Plain("📌")]), "P"));
184+
185+
await Assert.That(after.Text).IsEqualTo("P" + MarkupText.PointCarrier);
186+
await Assert.That(after.Runs.Any(run => run.Start == 1 && run.Length == 1 && run.Markups.Any(m => m is SoundMarkup))).IsTrue();
187+
await Assert.That(before.Text).IsEqualTo(MarkupText.PointCarrier + "P");
188+
await Assert.That(before.Runs.Any(run => run.Start == 0 && run.Length == 1 && run.Markups.Any(m => m is SoundMarkup))).IsTrue();
189+
}
190+
191+
[Test]
192+
public async Task StandIn_IsIgnoredWhenItsTextIsNotPrintableAscii()
193+
=> await Assert.That(AsciiFold.Default.Fold(MarkupText.Wrap(new StandInMarkup("é"), MarkupText.Plain("📌"))).Text).IsEqualTo("??");
194+
195+
[Test]
196+
public async Task StandIn_ReplacesEachOfMany()
197+
{
198+
var text = MarkupText.Concat(Enumerable.Range(0, 1000).SelectMany(_ => new[] { MarkupText.StandIn(MarkupText.Plain("📌"), "P"), MarkupText.Plain(" ") }));
199+
200+
await Assert.That(AsciiFold.Default.Fold(text).Text).IsEqualTo(string.Concat(Enumerable.Repeat("P ", 1000)));
201+
}
202+
203+
[Test]
204+
public async Task StandIn_RefusesTextThatIsNotPrintableAscii()
205+
=> await Assert.That(() => MarkupText.StandIn(MarkupText.Plain("📌"), "é")).Throws<ArgumentException>();
120206
}

‎MarkupString/AsciiFold.cs‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ namespace MarkupString;
2121
/// </para>
2222
/// <para>
2323
/// Markup is kept: a replacement carries the markup of the character it replaces. A point
24-
/// (<see cref="IPointMarkup"/>) keeps its carrier.
24+
/// (<see cref="IPointMarkup"/>) keeps its carrier. Text under a <see cref="StandInMarkup"/> is replaced
25+
/// whole by its stand-in when any of it would be replaced.
2526
/// </para>
2627
/// </remarks>
2728
public sealed class AsciiFold
@@ -82,14 +83,42 @@ public MarkupText Fold(MarkupText text)
8283
if (IsAscii(source)) return text;
8384

8485
var points = PointRanges(text.Runs);
86+
var standIns = StandInRanges(text.Runs);
8587
var span = source.AsSpan();
8688
var builder = new StringBuilder(source.Length);
8789
// Where each index of the old text lands in the new one, so the runs can follow.
8890
var map = new int[source.Length + 1];
8991
var changed = false;
92+
// The end of the stand-in being replaced, and its text until it is written.
93+
var skipTo = 0;
94+
string? pending = null;
95+
// The first stand-in that may still start here; they are in order, as the graphemes are.
96+
var nextStandIn = 0;
9097
foreach (var range in Graphemes.Enumerate(span))
9198
{
9299
var (start, end) = (range.Start.Value, range.End.Value);
100+
if (start >= skipTo && StandInAt(standIns, ref nextStandIn, start) is var (standInEnd, standIn)
101+
&& NeedsFold(span[start..standInEnd], points, start))
102+
{
103+
skipTo = standInEnd;
104+
pending = standIn;
105+
changed = true;
106+
}
107+
108+
if (start < skipTo)
109+
{
110+
// A point keeps its carrier. The first character that is not one is written as the stand-in's
111+
// text, so its run holds the text, and the rest are left out.
112+
for (var i = start; i < end; i++) map[i] = builder.Length;
113+
if (InPoint(points, start)) builder.Append(span[start..end]);
114+
else if (pending is not null)
115+
{
116+
builder.Append(pending);
117+
pending = null;
118+
}
119+
continue;
120+
}
121+
93122
var cluster = span[start..end];
94123
var replacement = InPoint(points, start) ? null : Translate(cluster);
95124
for (var i = start; i < end; i++) map[i] = builder.Length;
@@ -233,6 +262,56 @@ public MarkupText Fold(MarkupText text)
233262
return ranges;
234263
}
235264

265+
/// <summary>
266+
/// The stretches of <paramref name="runs"/> under a <see cref="StandInMarkup"/>, in order, with touching runs
267+
/// under the same one joined: a colour change inside a stand-in does not split it.
268+
/// </summary>
269+
private static List<(int Start, int End, string Text)>? StandInRanges(ImmutableArray<Run> runs)
270+
{
271+
List<(int Start, int End, string Text)>? ranges = null;
272+
StandInMarkup? last = null;
273+
foreach (var run in runs)
274+
{
275+
StandInMarkup? found = null;
276+
// One whose text is not printable ASCII (built directly, or read from the wire) is ignored, so the
277+
// covered text folds as any other would.
278+
foreach (var markup in run.Markups)
279+
if (markup is StandInMarkup standIn && IsPrintableAscii(standIn.Text))
280+
{
281+
found = standIn;
282+
break;
283+
}
284+
if (found is null)
285+
{
286+
last = null;
287+
continue;
288+
}
289+
290+
ranges ??= [];
291+
if (found.Equals(last) && ranges[^1].End == run.Start) ranges[^1] = ranges[^1] with { End = run.End };
292+
else ranges.Add((run.Start, run.End, found.Text));
293+
last = found;
294+
}
295+
return ranges;
296+
}
297+
298+
/// <summary>The end and text of the stand-in that starts at <paramref name="index"/>, or null.</summary>
299+
/// <remarks><paramref name="next"/> moves past every stand-in that starts before <paramref name="index"/>.</remarks>
300+
private static (int End, string Text)? StandInAt(List<(int Start, int End, string Text)>? standIns, ref int next, int index)
301+
{
302+
if (standIns is null) return null;
303+
while (next < standIns.Count && standIns[next].Start < index) next++;
304+
return next < standIns.Count && standIns[next].Start == index ? (standIns[next].End, standIns[next].Text) : null;
305+
}
306+
307+
/// <summary>Whether this fold would replace any character of <paramref name="text"/>, which starts at <paramref name="offset"/>.</summary>
308+
private bool NeedsFold(ReadOnlySpan<char> text, List<(int Start, int End)>? points, int offset)
309+
{
310+
foreach (var range in Graphemes.Enumerate(text))
311+
if (!InPoint(points, offset + range.Start.Value) && Translate(text[range]) is not null) return true;
312+
return false;
313+
}
314+
236315
private static bool InPoint(List<(int Start, int End)>? points, int index)
237316
{
238317
if (points is null) return false;

‎MarkupString/Elements/ElementCodecs.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ internal static class ElementCodecs
113113
if (m.Caption is not null) w.WriteString("ca", m.Caption);
114114
},
115115
static e => new StatusMarkup(String(e, "n") ?? string.Empty, String(e, "m"), String(e, "ca"))),
116+
new Codec<StandInMarkup>("standin",
117+
static (w, m) => w.WriteString("t", m.Text),
118+
static e => new StandInMarkup(String(e, "t") ?? string.Empty)),
116119
];
117120

118121
private static readonly Dictionary<Type, IMarkupCodec> ByType = All.ToDictionary(c => c.MarkupType);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace MarkupString;
2+
3+
/// <summary>
4+
/// The text a reader whose client cannot show the text it covers is sent instead: <c>P</c> for a 📌, say,
5+
/// where the built-in stand-ins would send <c>??</c>. <see cref="MarkupText.StandIn"/> builds one.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// <see cref="AsciiFold"/> replaces the whole covered text with <see cref="Text"/> when any character in it
10+
/// would be replaced, and leaves it alone when the reader shows all of it, so a Latin-1 reader keeps an
11+
/// <c>é</c> covered by one. The replacement carries the markup of the first character it replaces. It comes
12+
/// before a game's own stand-ins and the built-in ones.
13+
/// </para>
14+
/// <para>
15+
/// No format writes anything for it (<see cref="IDataMarkup"/>): a reader that shows the text gets the
16+
/// text. Two next to each other with the same text are one, as equal layers always are.
17+
/// </para>
18+
/// </remarks>
19+
/// <param name="Text">Printable ASCII, or empty to leave the covered text out.</param>
20+
public sealed record StandInMarkup(string Text) : IDataMarkup;

‎MarkupString/MarkupText.Elements.cs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,20 @@ public static MarkupText Gauge(MarkupText content, string variable, string maxim
138138
return Wrap(new GaugeMarkup(variable, maximum, caption, color), content);
139139
}
140140

141+
/// <summary>
142+
/// <paramref name="content"/>, sent as <paramref name="text"/> to a reader whose client cannot show it.
143+
/// See <see cref="StandInMarkup"/>.
144+
/// </summary>
145+
/// <exception cref="ArgumentException"><paramref name="text"/> is not printable ASCII.</exception>
146+
public static MarkupText StandIn(MarkupText content, string text)
147+
{
148+
ArgumentNullException.ThrowIfNull(content);
149+
ArgumentNullException.ThrowIfNull(text);
150+
foreach (var c in text)
151+
if (c is < ' ' or > '~') throw new ArgumentException("A stand-in is printable ASCII.", nameof(text));
152+
return Wrap(new StandInMarkup(text), content);
153+
}
154+
141155
/// <summary>
142156
/// <paramref name="variable"/> in the client's status bar, standing in <paramref name="content"/>
143157
/// for a client with none. See <see cref="StatusMarkup"/>.

‎MarkupString/PublicAPI.Unshipped.txt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,16 @@ MarkupString.GradientTarget.Borders = 1 -> MarkupString.GradientTarget
1010
MarkupString.GradientTarget.Text = 2 -> MarkupString.GradientTarget
1111
MarkupString.Layout.Shaded.Target.get -> MarkupString.GradientTarget
1212
MarkupString.Layout.Shaded.Target.init -> void
13+
MarkupString.StandInMarkup
14+
MarkupString.StandInMarkup.<Clone>$() -> MarkupString.StandInMarkup!
15+
MarkupString.StandInMarkup.Deconstruct(out string! Text) -> void
16+
MarkupString.StandInMarkup.Equals(MarkupString.StandInMarkup? other) -> bool
17+
MarkupString.StandInMarkup.StandInMarkup(string! Text) -> void
18+
MarkupString.StandInMarkup.Text.get -> string!
19+
MarkupString.StandInMarkup.Text.init -> void
20+
override MarkupString.StandInMarkup.Equals(object? obj) -> bool
21+
override MarkupString.StandInMarkup.GetHashCode() -> int
22+
override MarkupString.StandInMarkup.ToString() -> string!
23+
static MarkupString.StandInMarkup.operator !=(MarkupString.StandInMarkup? left, MarkupString.StandInMarkup? right) -> bool
24+
static MarkupString.StandInMarkup.operator ==(MarkupString.StandInMarkup? left, MarkupString.StandInMarkup? right) -> bool
25+
static MarkupString.MarkupText.StandIn(MarkupString.MarkupText! content, string! text) -> MarkupString.MarkupText!

‎docs/layout.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,11 @@ first, such as `·` to `" - "` or `…` to `"..."`; each key is one character ou
515515
printable ASCII. `fold.Fold(text)` folds a string or a `MarkupText`, whose markup stays on the
516516
characters it was on.
517517

518+
A text can carry its own stand-in: `MarkupText.StandIn(MarkupText.Plain("📌"), "P")` is sent as `P` to a
519+
reader who cannot show the pin, where the built-in fold would send `??`. The whole covered text is
520+
replaced when any character in it would be, and kept when the reader shows all of it. It comes before
521+
the fold's own translations, and no format writes anything for it (`StandInMarkup` is an `IDataMarkup`).
522+
518523
Given as `LayoutContext.Fold`, the fold reaches the text inside every block (`Block.MapText`) before
519524
the block is measured, so a stand-in of another width still lines up. The tree a block carries is
520525
not changed. Text outside blocks is the caller's to fold.

0 commit comments

Comments
 (0)