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 @@ -10,6 +10,16 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **A bell.** `MarkupText.Bell()` marks a point in the text where the client is asked to get someone's
attention: U+0007 for a terminal, Pueblo or MXP client, and an empty `<span class="ms-bell"
role="alert">` for HTML, where the page decides what a bell means. Plain and BBCode leave nothing
behind. It rides on the single U+0007 it marks, which measures zero display cells, so it survives
slicing, concatenation and padding as a point in the string without moving anything laid out around
it — and it is the only way to get a control character into rendered output, since the encodings
drop them from ordinary text.

### Added

- **Checked `HtmlMarkup` construction.** `HtmlMarkup.Tag(name, params attributes)` validates the
tag and attribute names and writes each value encoded, so nothing in a value can end the attribute
or the tag. `HtmlMarkup.IsValidTagName`, `IsValidAttributeName` and `TryParseAttributes` (which
Expand Down
9 changes: 8 additions & 1 deletion MarkupString.Ansi/AnsiRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ public static MarkupRegistry WithAnsi(this MarkupRegistry registry)
.With(new AnsiPuebloEmitter())
.With(new AnsiMxpEmitter())
.With(new AnsiBBCodeEmitter())
.With(new AnsiMarkupCodec());
.With(new AnsiMarkupCodec())
// A bell is not ANSI styling, but it is the same audience: a client that reads a control
// character, or an HTML page that reads an element. BBCode and Plain have neither, and drop
// the character with every other control.
.With(new BellEmitter(MarkupFormat.Ansi))
.With(new BellEmitter(MarkupFormat.Pueblo))
.With(new BellEmitter(MarkupFormat.Mxp))
.With(new BellEmitter(MarkupFormat.Html));
}
}
33 changes: 33 additions & 0 deletions MarkupString.Ansi/Emitters/BellEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Buffers;
namespace MarkupString.Ansi;

/// <summary>
/// Writes a <see cref="BellMarkup"/>: the bell character itself for a client that reads one, and for
/// <see cref="MarkupFormat.Html"/> an empty <c>ms-bell</c> element, which is a page's cue to do
/// whatever it does about a bell — a sound, a flash, a title change, nothing.
/// </summary>
/// <remarks>
/// One instance per format. The body is the U+0007 the bell rides on, and it is not written through:
/// the Html, Pueblo and Mxp encodings drop control characters from text, so the character a terminal
/// needs is written here rather than left to survive an encoding that removes it.
/// </remarks>
public sealed class BellEmitter(MarkupFormat format) : IMarkupEmitter
{
/// <summary>The element an HTML page receives in place of the character.</summary>
public const string HtmlElement = "<span class=\"ms-bell\" role=\"alert\"></span>";

/// <inheritdoc/>
public Type MarkupType => typeof(BellMarkup);

/// <inheritdoc/>
public MarkupFormat Format { get; } = format;

/// <inheritdoc/>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(markup);
ArgumentNullException.ThrowIfNull(output);

output.Write(Format == MarkupFormat.Html ? HtmlElement : BellMarkup.Character);
}
}
6 changes: 6 additions & 0 deletions MarkupString.Ansi/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
#nullable enable
const MarkupString.Ansi.BellEmitter.HtmlElement = "<span class=\"ms-bell\" role=\"alert\"></span>" -> string!
MarkupString.Ansi.BellEmitter
MarkupString.Ansi.BellEmitter.BellEmitter(MarkupString.MarkupFormat! format) -> void
MarkupString.Ansi.BellEmitter.Emit(MarkupString.IMarkup! markup, System.ReadOnlySpan<char> body, in MarkupString.EmitContext context, System.Buffers.IBufferWriter<char>! output) -> void
MarkupString.Ansi.BellEmitter.Format.get -> MarkupString.MarkupFormat!
MarkupString.Ansi.BellEmitter.MarkupType.get -> System.Type!
83 changes: 83 additions & 0 deletions MarkupString.Tests/BellTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using MarkupString.Ansi;
using MarkupString.Html;
namespace MarkupString.Tests;

/// <summary>
/// A bell is a point in the text, not a property of any of it: it asks the client to get someone's
/// attention where it sits, measures nothing, and survives every operation that carries the text.
/// </summary>
public class BellTests
{
private const string Bel = BellMarkup.Character;

private static readonly MarkupRegistry Registry = MarkupRegistry.Empty.WithAnsi().WithHtml();

private static string Render(MarkupText text, MarkupFormat format) => text.Render(format, Registry);

[Test]
public async Task ABellIsWrittenForEveryClientThatReadsOne()
{
var text = MarkupText.Concat(MarkupText.Plain("Hey"), MarkupText.Bell());

await Assert.That(Render(text, MarkupFormat.Ansi)).IsEqualTo("Hey" + Bel);
await Assert.That(Render(text, MarkupFormat.Pueblo)).IsEqualTo("Hey" + Bel);
await Assert.That(Render(text, MarkupFormat.Mxp)).IsEqualTo("Hey" + Bel);
await Assert.That(Render(text, MarkupFormat.Html)).IsEqualTo("Hey" + BellEmitter.HtmlElement);
}

/// <summary>
/// Pueblo and MXP drop control characters from text, which is why the emitter writes the character
/// rather than letting the body through: a bell has to survive the encoding that removes it.
/// </summary>
[Test]
public async Task AControlCharacterInOrdinaryTextIsStillDropped()
{
await Assert.That(Render(MarkupText.Plain("Hey" + Bel), MarkupFormat.Pueblo)).IsEqualTo("Hey");
await Assert.That(Render(MarkupText.Plain("Hey" + Bel), MarkupFormat.Html)).IsEqualTo("Hey");
}

[Test]
public async Task AFormatWithNoBellLeavesNothingBehind()
{
var text = MarkupText.Concat(MarkupText.Plain("Hey"), MarkupText.Bell());

await Assert.That(Render(text, MarkupFormat.Plain)).IsEqualTo("Hey");
await Assert.That(Render(text, MarkupFormat.BBCode)).IsEqualTo("Hey");
}

[Test]
public async Task ABellMeasuresNothing()
{
var text = MarkupText.Concat(MarkupText.Bell(), MarkupText.Plain("ab"));

await Assert.That(DisplayWidth.Of(text.Text)).IsEqualTo(2);
await Assert.That(text.ToPlainText()).IsEqualTo(Bel + "ab")
.Because("the plain text keeps the position; only a render decides what to do with it");
}

/// <summary>
/// It rides on one real character, so the operations that carry text carry it too, and a column it
/// sits in is not one cell narrower than its neighbours.
/// </summary>
[Test]
public async Task ABellSurvivesTheOperationsThatCarryText()
{
var line = MarkupText.Concat(MarkupText.Concat(MarkupText.Plain("a"), MarkupText.Bell()), MarkupText.Plain("bc"));

await Assert.That(Render(line.Substring(0, 3), MarkupFormat.Ansi)).IsEqualTo("a" + Bel + "b");
await Assert.That(Render(line.Pad(MarkupText.Plain(" "), 5, PadType.Right, TruncationType.Truncate), MarkupFormat.Ansi))
.IsEqualTo("a" + Bel + "bc ")
.Because("the bell is zero cells wide, so padding measures the three that show");
}

[Test]
public async Task ABellRoundTripsThroughTheSerializer()
{
var text = MarkupText.Concat(MarkupText.Plain("Hey"), MarkupText.Bell());

var back = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(text, Registry), Registry);

await Assert.That(Render(back, MarkupFormat.Ansi)).IsEqualTo("Hey" + Bel);
await Assert.That(back.Runs.Any(run => run.Markups.Any(markup => markup is BellMarkup))).IsTrue();
}
}
25 changes: 25 additions & 0 deletions MarkupString/BellMarkup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace MarkupString;

/// <summary>
/// A bell: the client is asked to get someone's attention. It is written as U+0007 for a client that
/// reads one — a terminal, Pueblo, MXP — and as a marked span for HTML, where the page decides what a
/// bell means. <see cref="MarkupText.Bell"/> builds one.
/// </summary>
/// <remarks>
/// A bell rides on the single U+0007 it marks, which is a real position in the text and measures zero
/// display cells (<see cref="DisplayWidth"/>), so it survives slicing, concatenation and padding as a
/// point in the string without moving anything that is laid out around it. A format with no bell drops
/// the character with every other control, and nothing is left behind.
/// </remarks>
public sealed class BellMarkup : IMarkup
{
/// <summary>The character a bell is carried on.</summary>
public const string Character = "\u0007";

/// <summary>The one instance; a bell carries no state.</summary>
public static readonly BellMarkup Instance = new();

private BellMarkup()
{
}
}
6 changes: 6 additions & 0 deletions MarkupString/MarkupText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ internal MarkupText(string text, ImmutableArray<Run> runs)
_ => new MarkupText(text, ImmutableArray<Run>.Empty),
};

/// <summary>
/// A bell: a point in the text asking the client to get someone's attention, measuring zero display
/// cells. See <see cref="BellMarkup"/>.
/// </summary>
public static MarkupText Bell() => Wrap(BellMarkup.Instance, BellMarkup.Character);

public static MarkupText Wrap(IMarkup markup, string text) => Wrap(MarkupSet.Of(markup), text);

public static MarkupText Wrap(MarkupSet markups, string text) =>
Expand Down
8 changes: 8 additions & 0 deletions MarkupString/MarkupTextSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public static class MarkupTextSerializer
/// <summary>The kind written for, and read back as, <see cref="NeutralMarkup.Instance"/>.</summary>
private const string NeutralKind = "neutral";

/// <summary>The kind written for, and read back as, <see cref="BellMarkup.Instance"/>.</summary>
private const string BellKind = "bell";

/// <summary>
/// Leaves non-ASCII text as literal UTF-8 rather than <c>\uXXXX</c> escapes. The default encoder
/// triples the cost of CJK and Cyrillic text, which several games are written in. "Unsafe" here
Expand Down Expand Up @@ -194,6 +197,10 @@ private static void WriteMarkup(Utf8JsonWriter writer, IMarkup markup, MarkupReg
{
writer.WriteString("k", NeutralKind);
}
else if (markup is BellMarkup)
{
writer.WriteString("k", BellKind);
}
else
{
var codec = (registry ?? MarkupRegistry.Default).FindCodec(markup.GetType())
Expand Down Expand Up @@ -343,6 +350,7 @@ private static IMarkup ReadMarkup(JsonElement element, MarkupRegistry? registry)
: "ansi";

if (kind == NeutralKind) return NeutralMarkup.Instance;
if (kind == BellKind) return BellMarkup.Instance;

var codec = (registry ?? MarkupRegistry.Default).FindCodec(kind);
return codec is null ? new UnknownMarkup(kind, element.GetRawText()) : codec.Read(element);
Expand Down
4 changes: 4 additions & 0 deletions MarkupString/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ MarkupString.MxpSecureLineFramer.Format.get -> MarkupString.MarkupFormat!
MarkupString.MxpSecureLineFramer.WriteLineStart(System.Buffers.IBufferWriter<char>! output) -> void
static readonly MarkupString.MxpSecureLineFramer.Instance -> MarkupString.MxpSecureLineFramer!
MarkupString.MarkupRegistry.WithMxpSecureLines() -> MarkupString.MarkupRegistry!
const MarkupString.BellMarkup.Character = "\a" -> string!
MarkupString.BellMarkup
static MarkupString.MarkupText.Bell() -> MarkupString.MarkupText!
static readonly MarkupString.BellMarkup.Instance -> MarkupString.BellMarkup!
18 changes: 18 additions & 0 deletions docs/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ link.Render(MarkupFormat.Plain); // north
A format nothing knows how to write is not an error: the layer is skipped and its body still comes
out. Text never disappears because a kind had no emitter.

## A bell

`MarkupText.Bell()` is a point in the text rather than a property of any of it: the client is asked to
get someone's attention where it sits.

```csharp
var line = MarkupText.Concat(MarkupText.Plain("Someone pages you"), MarkupText.Bell());

line.Render(MarkupFormat.Ansi); // Someone pages you\a
line.Render(MarkupFormat.Html); // Someone pages you<span class="ms-bell" role="alert"></span>
line.Render(MarkupFormat.Plain); // Someone pages you
```

It rides on the one U+0007 it marks, which measures zero display cells, so slicing, padding and
concatenation carry it without shifting a column. The text encodings drop control characters, so this
is the only way one reaches rendered output; what the HTML element means — a sound, a flash, a title
change, nothing — is the page's to decide.

## Pueblo and MXP

Pueblo and MXP are two different dialects, not one extending the other. Most formatting tags are
Expand Down
Loading