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
26 changes: 21 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

## 2.18.1 — 2026-10-13

### Changed

- **`Repeat` writes the text once instead of concatenating copies.** It builds the string in a few
block copies and the runs directly, so `MarkupText.Space.Repeat(1000000)` takes about 1 ms and 2 MB
instead of 18 ms and 18 MB. Padding, fills and blank lines in layouts go through it too. The
result is unchanged.

## 2.18.0 — 2026-10-10

### Added

- **More theme colours.** `ThemeRole` gains `Subtle` (text quieter than muted), `Link`, `Highlight` (a
Expand All @@ -18,17 +29,22 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **A page can theme the sixteen ANSI colours.** HTML writes a standard colour as
`var(--ms-ansi-N, #hex)`, so a page that sets `--ms-ansi-0` to `--ms-ansi-15` gives `ansi()` colours
its own scheme, and `--ms-ansi-bg-N` for backgrounds. A page that sets none shows the same colours as before.
- **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.

### Changed

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

## 2.17.0 — 2026-10-09

### 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
Expand Down
2 changes: 1 addition & 1 deletion MarkupString.Ansi/MarkupString.Ansi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
each time one goes out; drop it for a release that removes public API on purpose, and put
it back straight after. -->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
<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>
</PropertyGroup>

Expand Down
2 changes: 1 addition & 1 deletion MarkupString.Html/MarkupString.Html.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
each time one goes out; drop it for a release that removes public API on purpose, and put
it back straight after. -->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
<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>
</PropertyGroup>

Expand Down
2 changes: 1 addition & 1 deletion MarkupString.Mxp/MarkupString.Mxp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
each time one goes out; drop it for a release that removes public API on purpose, and put
it back straight after. -->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
<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>
</PropertyGroup>

Expand Down
2 changes: 1 addition & 1 deletion MarkupString.Pueblo/MarkupString.Pueblo.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
each time one goes out; drop it for a release that removes public API on purpose, and put
it back straight after. -->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
<Description>Pueblo for MarkupString: writes the shared vocabulary — sounds, pictures, panes, clearing the screen, prefetching — in the Pueblo client's own HTML extensions.</Description>
</PropertyGroup>

Expand Down
41 changes: 41 additions & 0 deletions MarkupString.Tests/MarkupTextOperationsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,47 @@ public async Task Repeat_ThreeTimes_RepeatsCorrectly()
public async Task Repeat_ZeroTimes_ReturnsEmpty()
=> await Assert.That(MarkupText.Plain("ab").Repeat(0).Length).IsEqualTo(0);

public static IEnumerable<Func<MarkupText>> RepeatUnits()
{
yield return () => MarkupText.Space;
yield return () => MarkupText.Plain("abc");
yield return () => MarkupText.Wrap(Red, "-");
yield return () => MarkupText.Wrap(Red, "ab");
yield return () => MarkupText.Concat([MarkupText.Wrap(Red, "a"), MarkupText.Plain("bc"), MarkupText.Wrap(Red, "d")]);
yield return () => MarkupText.Concat(MarkupText.Plain("x"), MarkupText.Wrap(Red, "y"));
yield return MarkupText.Bell;
yield return () => MarkupText.Concat(MarkupText.Plain("Hey"), MarkupText.Bell());
}

/// <summary>Repeat builds what concatenating the copies one by one builds, runs and all.</summary>
[Test]
[MethodDataSource(nameof(RepeatUnits))]
public async Task Repeat_MatchesConcatenatedCopies(MarkupText unit)
{
foreach (var count in new[] { 2, 3, 7, 64, 1001 })
{
var expected = MarkupText.Concat(Enumerable.Repeat(unit, count).ToArray().AsSpan());
var result = unit.Repeat(count);

await Assert.That(result.Text).IsEqualTo(expected.Text);
await Assert.That(result.Runs.SequenceEqual(expected.Runs)).IsTrue();
}
}

[Test]
public async Task Repeat_PlainText_AllocatesOnlyTheString()
{
const int count = 100_000;
GC.KeepAlive(MarkupText.Space.Repeat(count));

var before = GC.GetAllocatedBytesForCurrentThread();
GC.KeepAlive(MarkupText.Space.Repeat(count));
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;

// Two bytes a character for the string, plus the MarkupText itself.
await Assert.That(allocated).IsBetween(2L * count, 2L * count + 512);
}

// Remove, Replace, Insert

[Test]
Expand Down
2 changes: 1 addition & 1 deletion MarkupString/MarkupString.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
each time one goes out; drop it for a release that removes public API on purpose, and put
it back straight after. -->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>2.15.5</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>2.18.0</PackageValidationBaselineVersion>
<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>
</PropertyGroup>

Expand Down
25 changes: 22 additions & 3 deletions MarkupString/MarkupText.Operations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,28 @@ public MarkupText Repeat(int count)
{
if (count <= 0 || Length == 0) return Empty;
if (count == 1) return this;
var parts = new MarkupText[count];
Array.Fill(parts, this);
return Concat(parts.AsSpan());
var text = Length == 1
? new string(Text[0], count)
: string.Create(checked(Length * count), Text, static (span, unit) =>
{
// Each copy doubles what is written, so n copies take log n block copies.
unit.AsSpan().CopyTo(span);
for (var filled = unit.Length; filled < span.Length; filled *= 2)
span[..Math.Min(filled, span.Length - filled)].CopyTo(span[filled..]);
});
if (Runs.IsDefaultOrEmpty) return new MarkupText(text, ImmutableArray<Run>.Empty);
// One style over the whole text: the copies would coalesce into one run anyway. A point
// marks a single carrier, so its copies stay apart.
if (Runs is [{ Start: 0 } whole] && whole.Length == Length && !whole.Markups.Any(m => m is IPointMarkup))
return new MarkupText(text, [new Run(0, text.Length, whole.Markups)]);

var runs = ImmutableArray.CreateBuilder<Run>(Runs.Length * count);
for (var copy = 0; copy < count; copy++)
{
var offset = copy * Length;
foreach (var run in Runs) runs.Add(run with { Start = run.Start + offset });
}
return new MarkupText(text, runs.MoveToImmutable());
}

/// <summary>Removes <paramref name="length"/> code units from <paramref name="index"/>.</summary>
Expand Down
Loading
Loading