diff --git a/CHANGELOG.md b/CHANGELOG.md index 98093eb..221d4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 diff --git a/MarkupString.Ansi/AnsiRegistration.cs b/MarkupString.Ansi/AnsiRegistration.cs index e63a317..29e17f3 100644 --- a/MarkupString.Ansi/AnsiRegistration.cs +++ b/MarkupString.Ansi/AnsiRegistration.cs @@ -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)); } } diff --git a/MarkupString.Ansi/Emitters/BellEmitter.cs b/MarkupString.Ansi/Emitters/BellEmitter.cs new file mode 100644 index 0000000..0555858 --- /dev/null +++ b/MarkupString.Ansi/Emitters/BellEmitter.cs @@ -0,0 +1,33 @@ +using System.Buffers; +namespace MarkupString.Ansi; + +/// +/// Writes a : the bell character itself for a client that reads one, and for +/// an empty ms-bell element, which is a page's cue to do +/// whatever it does about a bell — a sound, a flash, a title change, nothing. +/// +/// +/// 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. +/// +public sealed class BellEmitter(MarkupFormat format) : IMarkupEmitter +{ + /// The element an HTML page receives in place of the character. + public const string HtmlElement = ""; + + /// + public Type MarkupType => typeof(BellMarkup); + + /// + public MarkupFormat Format { get; } = format; + + /// + public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter output) + { + ArgumentNullException.ThrowIfNull(markup); + ArgumentNullException.ThrowIfNull(output); + + output.Write(Format == MarkupFormat.Html ? HtmlElement : BellMarkup.Character); + } +} diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 7dc5c58..fbc4edd 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -1 +1,7 @@ #nullable enable +const MarkupString.Ansi.BellEmitter.HtmlElement = "" -> string! +MarkupString.Ansi.BellEmitter +MarkupString.Ansi.BellEmitter.BellEmitter(MarkupString.MarkupFormat! format) -> void +MarkupString.Ansi.BellEmitter.Emit(MarkupString.IMarkup! markup, System.ReadOnlySpan body, in MarkupString.EmitContext context, System.Buffers.IBufferWriter! output) -> void +MarkupString.Ansi.BellEmitter.Format.get -> MarkupString.MarkupFormat! +MarkupString.Ansi.BellEmitter.MarkupType.get -> System.Type! diff --git a/MarkupString.Tests/BellTests.cs b/MarkupString.Tests/BellTests.cs new file mode 100644 index 0000000..61a3bbb --- /dev/null +++ b/MarkupString.Tests/BellTests.cs @@ -0,0 +1,83 @@ +using MarkupString.Ansi; +using MarkupString.Html; +namespace MarkupString.Tests; + +/// +/// 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. +/// +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); + } + + /// + /// 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. + /// + [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"); + } + + /// + /// 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. + /// + [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(); + } +} diff --git a/MarkupString/BellMarkup.cs b/MarkupString/BellMarkup.cs new file mode 100644 index 0000000..a2d4d39 --- /dev/null +++ b/MarkupString/BellMarkup.cs @@ -0,0 +1,25 @@ +namespace MarkupString; + +/// +/// 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. builds one. +/// +/// +/// A bell rides on the single U+0007 it marks, which is a real position in the text and measures zero +/// display cells (), 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. +/// +public sealed class BellMarkup : IMarkup +{ + /// The character a bell is carried on. + public const string Character = "\u0007"; + + /// The one instance; a bell carries no state. + public static readonly BellMarkup Instance = new(); + + private BellMarkup() + { + } +} diff --git a/MarkupString/MarkupText.cs b/MarkupString/MarkupText.cs index bb7d074..a7c83c5 100644 --- a/MarkupString/MarkupText.cs +++ b/MarkupString/MarkupText.cs @@ -36,6 +36,12 @@ internal MarkupText(string text, ImmutableArray runs) _ => new MarkupText(text, ImmutableArray.Empty), }; + /// + /// A bell: a point in the text asking the client to get someone's attention, measuring zero display + /// cells. See . + /// + 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) => diff --git a/MarkupString/MarkupTextSerializer.cs b/MarkupString/MarkupTextSerializer.cs index 93dac6a..d605731 100644 --- a/MarkupString/MarkupTextSerializer.cs +++ b/MarkupString/MarkupTextSerializer.cs @@ -44,6 +44,9 @@ public static class MarkupTextSerializer /// The kind written for, and read back as, . private const string NeutralKind = "neutral"; + /// The kind written for, and read back as, . + private const string BellKind = "bell"; + /// /// Leaves non-ASCII text as literal UTF-8 rather than \uXXXX escapes. The default encoder /// triples the cost of CJK and Cyrillic text, which several games are written in. "Unsafe" here @@ -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()) @@ -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); diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 81f53f7..2d75242 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -10,3 +10,7 @@ MarkupString.MxpSecureLineFramer.Format.get -> MarkupString.MarkupFormat! MarkupString.MxpSecureLineFramer.WriteLineStart(System.Buffers.IBufferWriter! 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! diff --git a/docs/formats.md b/docs/formats.md index 66ecb1c..1ee5094 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -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 +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