Skip to content

Commit 0dc73cc

Browse files
Repeat writes the text once; cut 2.18.1 (#62)
* Repeat writes the text once instead of concatenating copies Repeat(n) filled an array with n references and concatenated them, about 18 bytes and 16-28 ns a character. It now builds the string with a few doubling block copies and the runs directly; one style over the whole text becomes one run without the coalescing pass. Space.Repeat(1000000) goes from 18.5 ms / 18 MB to 1.0 ms / 2 MB, Space.Repeat(1000) from 27.8 us to 0.4 us. The result matches the concatenated copies. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGK2ENFwejRuxQ4am7VoVo * Cut 2.18.1: changelog headings and the public API 2.17 and 2.18 ship Put AsciiFold under 2.17.0, the theme colours under 2.18.0 and the Repeat change under 2.18.1, move their API to PublicAPI.Shipped.txt, and validate packages against 2.18.0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGK2ENFwejRuxQ4am7VoVo --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 09b995c commit 0dc73cc

10 files changed

Lines changed: 128 additions & 52 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
## Unreleased
1010

11+
## 2.18.1 — 2026-10-13
12+
13+
### Changed
14+
15+
- **`Repeat` writes the text once instead of concatenating copies.** It builds the string in a few
16+
block copies and the runs directly, so `MarkupText.Space.Repeat(1000000)` takes about 1 ms and 2 MB
17+
instead of 18 ms and 18 MB. Padding, fills and blank lines in layouts go through it too. The
18+
result is unchanged.
19+
20+
## 2.18.0 — 2026-10-10
21+
1122
### Added
1223

1324
- **More theme colours.** `ThemeRole` gains `Subtle` (text quieter than muted), `Link`, `Highlight` (a
@@ -18,17 +29,22 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1829
- **A page can theme the sixteen ANSI colours.** HTML writes a standard colour as
1930
`var(--ms-ansi-N, #hex)`, so a page that sets `--ms-ansi-0` to `--ms-ansi-15` gives `ansi()` colours
2031
its own scheme, and `--ms-ansi-bg-N` for backgrounds. A page that sets none shows the same colours as before.
21-
- **Text for a client without Unicode.** `AsciiFold` replaces each character an ASCII-only or
22-
Latin-1 client cannot show with the nearest one it can (`·` to `*`, `—` to `-`, `é` to `e`, `?` for
23-
the rest), keeping markup and, for the built-in stand-ins, each character's width. A game's own
24-
stand-ins come first. `LayoutContext.Fold` folds the text inside every block before it is
25-
measured, through the new `Block.MapText`, so tables stay aligned whatever the stand-ins' widths.
2632

2733
### Changed
2834

2935
- **A base16 palette's tertiary colour is `base09` (orange),** not `base0C`, which info already used,
3036
so bullets and info text are no longer the same colour.
3137

38+
## 2.17.0 — 2026-10-09
39+
40+
### Added
41+
42+
- **Text for a client without Unicode.** `AsciiFold` replaces each character an ASCII-only or
43+
Latin-1 client cannot show with the nearest one it can (`·` to `*`, `—` to `-`, `é` to `e`, `?` for
44+
the rest), keeping markup and, for the built-in stand-ins, each character's width. A game's own
45+
stand-ins come first. `LayoutContext.Fold` folds the text inside every block before it is
46+
measured, through the new `Block.MapText`, so tables stay aligned whatever the stand-ins' widths.
47+
3248
## 2.16.0 — 2026-10-09
3349

3450
### Added

‎MarkupString.Ansi/MarkupString.Ansi.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
each time one goes out; drop it for a release that removes public API on purpose, and put
1313
it back straight after. -->
1414
<EnablePackageValidation>true</EnablePackageValidation>
15-
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
15+
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
1616
<Description>Terminal styling for MarkupString: colours (16, 256 and truecolor), text attributes and links, an ansi() code parser, and emitters that fold a run's layers into one SGR sequence, HTML span, Pueblo/MXP tag or BBCode tag.</Description>
1717
</PropertyGroup>
1818

‎MarkupString.Html/MarkupString.Html.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
each time one goes out; drop it for a release that removes public API on purpose, and put
1313
it back straight after. -->
1414
<EnablePackageValidation>true</EnablePackageValidation>
15-
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
15+
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
1616
<Description>Raw HTML tag markup for MarkupString — an anchor, a pre, a span — rendered as itself in the HTML, Pueblo and MXP formats and folded into terminal styling elsewhere, with checked construction and tag policies for untrusted input.</Description>
1717
</PropertyGroup>
1818

‎MarkupString.Mxp/MarkupString.Mxp.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
each time one goes out; drop it for a release that removes public API on purpose, and put
1313
it back straight after. -->
1414
<EnablePackageValidation>true</EnablePackageValidation>
15-
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
15+
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
1616
<Description>MXP for MarkupString: writes the shared vocabulary — sounds, music, pictures, panes, gauges, status, variables, link expiry, relocation, login prompts — as MXP tags, holding each to what the client said it supports.</Description>
1717
</PropertyGroup>
1818

‎MarkupString.Pueblo/MarkupString.Pueblo.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
each time one goes out; drop it for a release that removes public API on purpose, and put
1313
it back straight after. -->
1414
<EnablePackageValidation>true</EnablePackageValidation>
15-
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
15+
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
1616
<Description>Pueblo for MarkupString: writes the shared vocabulary — sounds, pictures, panes, clearing the screen, prefetching — in the Pueblo client's own HTML extensions.</Description>
1717
</PropertyGroup>
1818

‎MarkupString.Tests/MarkupTextOperationsTests.cs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,47 @@ public async Task Repeat_ThreeTimes_RepeatsCorrectly()
360360
public async Task Repeat_ZeroTimes_ReturnsEmpty()
361361
=> await Assert.That(MarkupText.Plain("ab").Repeat(0).Length).IsEqualTo(0);
362362

363+
public static IEnumerable<Func<MarkupText>> RepeatUnits()
364+
{
365+
yield return () => MarkupText.Space;
366+
yield return () => MarkupText.Plain("abc");
367+
yield return () => MarkupText.Wrap(Red, "-");
368+
yield return () => MarkupText.Wrap(Red, "ab");
369+
yield return () => MarkupText.Concat([MarkupText.Wrap(Red, "a"), MarkupText.Plain("bc"), MarkupText.Wrap(Red, "d")]);
370+
yield return () => MarkupText.Concat(MarkupText.Plain("x"), MarkupText.Wrap(Red, "y"));
371+
yield return MarkupText.Bell;
372+
yield return () => MarkupText.Concat(MarkupText.Plain("Hey"), MarkupText.Bell());
373+
}
374+
375+
/// <summary>Repeat builds what concatenating the copies one by one builds, runs and all.</summary>
376+
[Test]
377+
[MethodDataSource(nameof(RepeatUnits))]
378+
public async Task Repeat_MatchesConcatenatedCopies(MarkupText unit)
379+
{
380+
foreach (var count in new[] { 2, 3, 7, 64, 1001 })
381+
{
382+
var expected = MarkupText.Concat(Enumerable.Repeat(unit, count).ToArray().AsSpan());
383+
var result = unit.Repeat(count);
384+
385+
await Assert.That(result.Text).IsEqualTo(expected.Text);
386+
await Assert.That(result.Runs.SequenceEqual(expected.Runs)).IsTrue();
387+
}
388+
}
389+
390+
[Test]
391+
public async Task Repeat_PlainText_AllocatesOnlyTheString()
392+
{
393+
const int count = 100_000;
394+
GC.KeepAlive(MarkupText.Space.Repeat(count));
395+
396+
var before = GC.GetAllocatedBytesForCurrentThread();
397+
GC.KeepAlive(MarkupText.Space.Repeat(count));
398+
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
399+
400+
// Two bytes a character for the string, plus the MarkupText itself.
401+
await Assert.That(allocated).IsBetween(2L * count, 2L * count + 512);
402+
}
403+
363404
// Remove, Replace, Insert
364405

365406
[Test]

‎MarkupString/MarkupString.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
each time one goes out; drop it for a release that removes public API on purpose, and put
1313
it back straight after. -->
1414
<EnablePackageValidation>true</EnablePackageValidation>
15-
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
15+
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
1616
<Description>Immutable styled text: a string plus layered markup runs, rendered to ANSI, HTML, Pueblo, MXP, BBCode or plain text through a registry of emitters, and round-tripped through JSON.</Description>
1717
</PropertyGroup>
1818

‎MarkupString/MarkupText.Operations.cs‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,28 @@ public MarkupText Repeat(int count)
297297
{
298298
if (count <= 0 || Length == 0) return Empty;
299299
if (count == 1) return this;
300-
var parts = new MarkupText[count];
301-
Array.Fill(parts, this);
302-
return Concat(parts.AsSpan());
300+
var text = Length == 1
301+
? new string(Text[0], count)
302+
: string.Create(checked(Length * count), Text, static (span, unit) =>
303+
{
304+
// Each copy doubles what is written, so n copies take log n block copies.
305+
unit.AsSpan().CopyTo(span);
306+
for (var filled = unit.Length; filled < span.Length; filled *= 2)
307+
span[..Math.Min(filled, span.Length - filled)].CopyTo(span[filled..]);
308+
});
309+
if (Runs.IsDefaultOrEmpty) return new MarkupText(text, ImmutableArray<Run>.Empty);
310+
// One style over the whole text: the copies would coalesce into one run anyway. A point
311+
// marks a single carrier, so its copies stay apart.
312+
if (Runs is [{ Start: 0 } whole] && whole.Length == Length && !whole.Markups.Any(m => m is IPointMarkup))
313+
return new MarkupText(text, [new Run(0, text.Length, whole.Markups)]);
314+
315+
var runs = ImmutableArray.CreateBuilder<Run>(Runs.Length * count);
316+
for (var copy = 0; copy < count; copy++)
317+
{
318+
var offset = copy * Length;
319+
foreach (var run in Runs) runs.Add(run with { Start = run.Start + offset });
320+
}
321+
return new MarkupText(text, runs.MoveToImmutable());
303322
}
304323

305324
/// <summary>Removes <paramref name="length"/> code units from <paramref name="index"/>.</summary>

0 commit comments

Comments
 (0)