diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2943e2..715f6a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: # SDK (the MSBuild build host crashes). Whitespace rules are syntactic, so nothing is lost. - name: Verify formatting run: | - for dir in MarkupString MarkupString.Ansi MarkupString.Html MarkupString.Mxp MarkupString.Pueblo MarkupString.Tests MarkupString.AotSmoke; do + for dir in MarkupString MarkupString.Ansi MarkupString.Extensions.DependencyInjection MarkupString.Html MarkupString.Mxp MarkupString.Pueblo MarkupString.Tests MarkupString.AotSmoke; do dotnet format whitespace --folder "$dir" --exclude "**/bin/**" --exclude "**/obj/**" --verify-no-changes done diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd79cb..bb7d12a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,56 @@ # Changelog -All notable changes to `MarkupString`, `MarkupString.Ansi`, `MarkupString.Html`, `MarkupString.Mxp` -and `MarkupString.Pueblo`. The packages share one version and are released together. +All notable changes to `MarkupString`, `MarkupString.Ansi`, `MarkupString.Html`, `MarkupString.Mxp`, +`MarkupString.Pueblo` and `MarkupString.Extensions.DependencyInjection`. The packages share one version and are released together. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 2.26.0 — 2026-11-06 + +### Added + +- **Decoding server output.** `MarkupString.Decoding` reads what a server sends into `MarkupText` lines: a VT + tokenizer, a `DecoderRegistry` of plugins (`IDecoderPlugin`, handlers tried newest first), and a `Decoder` per + connection with `Feed`, `EndLine` and `EndPrompt`. Carriage return, backspace and erase overwrite in place, so a + progress bar ends as its last state. Titles, palette changes, the clipboard, variables and replies to send back + are `DecodeEvent`s; `ClientIdentity` says what the client answers, and `ToMtts` gives its MTTS bits. See + [Decoding](docs/decoding.md). +- **Terminal plugins** in `MarkupString.Ansi.Decoding`: `WithVt()` reads SGR (colon subparameters included), + the C0 controls, the DEC special graphics set, OSC 8 and MSLP links, titles, palette, clipboard, and answers + device attributes, XTVERSION, size and colour queries. The cursor position is not answered unless the host + allows it. +- **Pictures**: `WithPictures()` reads Kitty graphics (placeholders included), iTerm2 inline images and sixel into + `ImageMarkup` rows, with PNG and sixel decoders and a per-connection `PictureStore` that is also the + `ITerminalPictureSource` to write them out again. What the ANSI writer sends reads back cell for cell. +- **MXP and Pueblo decoding**: `WithMxp()` in `MarkupString.Mxp` (line modes, open and secure tags, `SEND` + menus, custom elements, entities, `VERSION` and `SUPPORT`) and `WithPueblo()` in `MarkupString.Pueblo`. What + each package writes reads back to the same markup. `MarkupString.Mxp` now depends on `MarkupString.Ansi`. +- **Display.** `MarkupString.Display` resolves markup to RGB styles for an output, degraded to its + `OutputCapabilities`: `SpanRenderer` for a web page (spans, inline CSS, classes) and `CellRenderer` for a cell + grid (wide characters, tabs, wrapping, picture cells, hit testing). `DisplayRegistry.Core.WithAnsi()` folds + terminal styles; `TerminalProfile.ToOutputCapabilities` and `ToTerminalFeatures` convert between the two + models. See [Display](docs/display.md). +- `CommandMenuMarkup` (a menu of commands, or a prompt), `HighlightMarkup` (a background) and `LinkGroupMarkup` + (an OSC 8 `id`, which becomes `Interaction.Group`) in core, with codecs. +- **`MarkupString.Extensions.DependencyInjection`**: `AddMarkupString()` registers the markup, decoder and display + registries, a shared picture cache, an `IDecoderFactory`, default `DisplayOptions` and the link policy as singletons, + configured in code or with `BindConfiguration()` (`MarkupStringOptions`, AOT-safe). See + [Dependency injection](docs/dependency-injection.md). +- Interfaces to inject: `IDecoderFactory` (a `DecoderRegistry` is one), `IStyleResolver` (a `DisplayRegistry` is one; + `SpanRenderer` and `CellRenderer` take it), and `IUrlPolicy` with `UrlPolicy.Safe` and `UrlPolicy.WithSchemes`, set as + `DecoderOptions.Links`, `DisplayOptions.Links`, `MarkupRegistry.WithLinks` (every writer asks it) and + `HtmlTagPolicy.Links`. +- Decode diagnostics are reported on the `MarkupString.Decoding` meter (`markupstring.decoding.dropped`, by `kind`), + through `DecoderOptions.Meters` when a host gives its `IMeterFactory`, and raised as `DecodeDiagnostics.Dropped`; + the DI package logs them at debug level to the host's `ILoggerFactory`. +- `IPictureCache` and `MemoryPictureCache`: `WithPictures(cache)` shares decoded pictures between connections, and a + host can keep them in a cache of its own. +- Kitty animation frames (`a=f`, and frame times with `a=a`) decode into a moving `StoredPicture`, given as a GIF. +- Spans and cells at `ColorLevel.Standard` or `Xterm256` get the nearest colour of that palette. +- `AnsiStyle.Concealed` (SGR 8), written as SGR 8 and as `ms-conceal` in HTML. +- `PictureRow.FirstColumn`, for a picture row shown from a column other than its first. + ## 2.25.0 — 2026-11-02 ### Changed diff --git a/Directory.Build.props b/Directory.Build.props index 2ad814a..8740ba5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -23,7 +23,7 @@ diff --git a/Directory.Build.targets b/Directory.Build.targets index 4654d8e..201a538 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,7 +1,7 @@ - + markupstring-icon.png diff --git a/MarkupString.Ansi/AnsiCss.cs b/MarkupString.Ansi/AnsiCss.cs index e9ffd86..fb74c57 100644 --- a/MarkupString.Ansi/AnsiCss.cs +++ b/MarkupString.Ansi/AnsiCss.cs @@ -33,6 +33,7 @@ public static class AnsiCss ".ms-underline { text-decoration: underline; }\n" + ".ms-strike { text-decoration: line-through; }\n" + ".ms-overline { text-decoration: overline; }\n" + + ".ms-conceal { color: transparent; }\n" + ".ms-blink { animation: ms-blink 1s step-start infinite; }\n" + ".ms-invert { color: var(--ms-bg, #000); background-color: var(--ms-fg, #fff); }\n" + ".ms-cmd-link { cursor: pointer; text-decoration: underline dotted; }\n" + diff --git a/MarkupString.Ansi/AnsiMarkupCodec.cs b/MarkupString.Ansi/AnsiMarkupCodec.cs index ad5a57d..5ebb598 100644 --- a/MarkupString.Ansi/AnsiMarkupCodec.cs +++ b/MarkupString.Ansi/AnsiMarkupCodec.cs @@ -52,6 +52,7 @@ public void Write(Utf8JsonWriter writer, IMarkup markup) if (style.Overlined) writer.WriteNumber("ov", 1); if (style.Underlined) writer.WriteNumber("un", 1); if (style.StrikeThrough) writer.WriteNumber("st", 1); + if (style.Concealed) writer.WriteNumber("cn", 1); if (style.BlinkOff) writer.WriteNumber("nbl", 1); if (style.BoldOff) writer.WriteNumber("nbo", 1); if (style.InvertedOff) writer.WriteNumber("nin", 1); @@ -80,6 +81,7 @@ public IMarkup Read(JsonElement element) => Overlined = element.TryGetProperty("ov", out _), Underlined = element.TryGetProperty("un", out _), StrikeThrough = element.TryGetProperty("st", out _), + Concealed = element.TryGetProperty("cn", out _), BlinkOff = element.TryGetProperty("nbl", out _), BoldOff = element.TryGetProperty("nbo", out _), InvertedOff = element.TryGetProperty("nin", out _), diff --git a/MarkupString.Ansi/AnsiStyle.cs b/MarkupString.Ansi/AnsiStyle.cs index 39967d7..61b23ca 100644 --- a/MarkupString.Ansi/AnsiStyle.cs +++ b/MarkupString.Ansi/AnsiStyle.cs @@ -30,6 +30,9 @@ public readonly record struct AnsiStyle public bool Inverted { get; init; } public bool StrikeThrough { get; init; } + /// Hidden (SGR 8): drawn in the background colour, as a password is, but still text to copy. + public bool Concealed { get; init; } + /// /// Set by the F code: blink is off in this span, whatever encloses it. See . /// @@ -87,6 +90,7 @@ public readonly record struct AnsiStyle | (InvertedOff ? 1 << 10 : 0) | (UnderlinedOff ? 1 << 11 : 0) | (Clear ? 1 << 12 : 0) + | (Concealed ? 1 << 13 : 0) | ((int)LinkKind << 16); /// Field-by-field equality, written out because a style is compared each time a markup set is looked up. @@ -108,7 +112,7 @@ public override int GetHashCode() => /// public bool HasAnyAttribute => Foreground is not null || Background is not null || - Bold || Faint || Italic || Underlined || Overlined || Blink || Inverted || StrikeThrough || Clear || + Bold || Faint || Italic || Underlined || Overlined || Blink || Inverted || StrikeThrough || Concealed || Clear || BlinkOff || BoldOff || InvertedOff || UnderlinedOff; /// @@ -156,6 +160,7 @@ public AnsiStyle Combine(AnsiStyle inner) Blink = (outer.Blink || inner.Blink) && !inner.BlinkOff, Inverted = (outer.Inverted || inner.Inverted) && !inner.InvertedOff, StrikeThrough = outer.StrikeThrough || inner.StrikeThrough, + Concealed = outer.Concealed || inner.Concealed, // Still off for whatever this span encloses, unless it turns the attribute back on. BlinkOff = inner.BlinkOff || (outer.BlinkOff && !inner.Blink), BoldOff = inner.BoldOff || (outer.BoldOff && !inner.Bold), diff --git a/MarkupString.Ansi/Decoding/AnsiDisplay.cs b/MarkupString.Ansi/Decoding/AnsiDisplay.cs new file mode 100644 index 0000000..093e54b --- /dev/null +++ b/MarkupString.Ansi/Decoding/AnsiDisplay.cs @@ -0,0 +1,184 @@ +using MarkupString.Display; +namespace MarkupString.Ansi.Decoding; + +/// Registers terminal styles on a , and reads a terminal's capabilities. +public static class AnsiDisplay +{ + /// The registry with added. + /// The registry. + /// Draw bold text in a standard colour in its bright version, as many MUD clients do. + public static DisplayRegistry WithAnsi(this DisplayRegistry registry, bool boldIsBright = false) + { + ArgumentNullException.ThrowIfNull(registry); + return registry.With(boldIsBright ? AnsiStyles.BoldIsBright : AnsiStyles.Instance); + } + + /// The shared vocabulary and terminal styles. + public static DisplayRegistry Default { get; } = DisplayRegistry.Core.WithAnsi(); + + /// What a terminal shows, from its profile and the colour depth the host settled on. + public static OutputCapabilities ToOutputCapabilities(this TerminalProfile profile, AnsiColorDepth depth) + { + ArgumentNullException.ThrowIfNull(profile); + var features = profile.Features; + var pictures = PictureMethods.None; + if ((features & TerminalFeatures.KittyGraphics) != 0) pictures |= PictureMethods.KittyPlaceholders; + if ((features & TerminalFeatures.InlineImages) != 0) pictures |= PictureMethods.InlineImages; + if ((features & TerminalFeatures.Sixel) != 0) pictures |= PictureMethods.Sixel; + if ((features & TerminalFeatures.BlockArt) != 0) pictures |= PictureMethods.HalfBlocks; + + var interactions = InteractionKinds.None; + if ((features & TerminalFeatures.Hyperlinks) != 0) interactions |= InteractionKinds.Url; + if ((features & TerminalFeatures.CommandLinks) != 0) interactions |= InteractionKinds.Command; + + return new OutputCapabilities + { + Colors = depth switch + { + AnsiColorDepth.TrueColor => ColorLevel.TrueColor, + AnsiColorDepth.Xterm256 => ColorLevel.Xterm256, + AnsiColorDepth.Standard => ColorLevel.Standard, + AnsiColorDepth.Attributes => ColorLevel.Attributes, + _ => ColorLevel.None, + }, + Attributes = TextAttributes.All, + Interactions = interactions, + Pictures = pictures, + MovingPictures = (features & TerminalFeatures.MovingPictures) != 0, + }; + } + + /// The colour depth an output shows, for an ANSI writer. + public static AnsiColorDepth ToColorDepth(this OutputCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + return capabilities.Colors switch + { + ColorLevel.TrueColor => AnsiColorDepth.TrueColor, + ColorLevel.Xterm256 => AnsiColorDepth.Xterm256, + ColorLevel.Standard => AnsiColorDepth.Standard, + ColorLevel.Attributes => AnsiColorDepth.Attributes, + _ => AnsiColorDepth.None, + }; + } + + /// The terminal features an output has, for an ANSI writer. + public static TerminalFeatures ToTerminalFeatures(this OutputCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + var features = TerminalFeatures.None; + if ((capabilities.Interactions & InteractionKinds.Url) != 0) features |= TerminalFeatures.Hyperlinks; + if ((capabilities.Interactions & InteractionKinds.Command) != 0) features |= TerminalFeatures.CommandLinks; + if ((capabilities.Pictures & PictureMethods.KittyPlaceholders) != 0) features |= TerminalFeatures.KittyGraphics; + if ((capabilities.Pictures & PictureMethods.InlineImages) != 0) features |= TerminalFeatures.InlineImages; + if ((capabilities.Pictures & PictureMethods.Sixel) != 0) features |= TerminalFeatures.Sixel; + if ((capabilities.Pictures & PictureMethods.HalfBlocks) != 0) features |= TerminalFeatures.BlockArt; + if (capabilities.MovingPictures) features |= TerminalFeatures.MovingPictures; + return features; + } +} + +/// +/// Folds every layer with a terminal style (: SGR, an MXP colour, an HTML +/// <b>) into colours, attributes and a link, outermost first as nests them. +/// +public sealed class AnsiStyles : IStyleContributor +{ + private readonly bool _boldIsBright; + + private AnsiStyles(bool boldIsBright) => _boldIsBright = boldIsBright; + + /// Colours as they are written. + public static AnsiStyles Instance { get; } = new(false); + + /// Bold text in a standard colour drawn in its bright version. + public static AnsiStyles BoldIsBright { get; } = new(true); + + /// + public void Contribute(MarkupSet markups, StyleBuilder style, DisplayOptions options) + { + ArgumentNullException.ThrowIfNull(markups); + ArgumentNullException.ThrowIfNull(style); + ArgumentNullException.ThrowIfNull(options); + + var folded = AnsiStyle.None; + var any = false; + for (var i = markups.Count - 1; i >= 0; i--) + { + if (markups[i] is IAnsiStyleSource source && source.TryGetAnsiStyle(MarkupFormat.Ansi, out var layer)) + { + folded = folded.Combine(layer); + any = true; + } + } + + if (!any) return; + + var colors = options.Colors ?? AnsiColors.Default; + var foreground = folded.Foreground; + if (_boldIsBright && folded.Bold && foreground is AnsiColor.Standard { Bright: false } standard) + { + foreground = AnsiColor.StandardOf(standard.Index, true); + } + + if (Resolve(foreground, colors) is { } fg) style.Foreground = fg; + if (Resolve(folded.Background, colors) is { } bg) style.Background ??= bg; + + var attributes = TextAttributes.None; + if (folded.Bold) attributes |= TextAttributes.Bold; + if (folded.Faint) attributes |= TextAttributes.Faint; + if (folded.Italic) attributes |= TextAttributes.Italic; + if (folded.Underlined) attributes |= TextAttributes.Underline; + if (folded.Blink) attributes |= TextAttributes.Blink; + if (folded.Inverted) attributes |= TextAttributes.Inverse; + if (folded.Concealed) attributes |= TextAttributes.Conceal; + if (folded.StrikeThrough) attributes |= TextAttributes.Strikethrough; + if (folded.Overlined) attributes |= TextAttributes.Overline; + style.Attributes |= attributes; + + if (!string.IsNullOrEmpty(folded.LinkUrl) && style.Interaction is null) + { + if (folded.LinkKind == LinkKind.Command) + { + style.Interaction = new Interaction(InteractionKind.Command, folded.LinkUrl) { Hint = folded.LinkText }; + } + else if (options.Links.IsAllowed(folded.LinkUrl)) + { + style.Interaction = new Interaction(InteractionKind.Url, folded.LinkUrl) { Hint = folded.LinkText }; + } + } + } + + private static RgbColor? Resolve(AnsiColor? color, IColorResolver colors) => color switch + { + AnsiColor.Standard standard => colors.Standard(standard.Index, standard.Bright), + AnsiColor.Xterm { Index: < 16 } xterm => colors.Standard(xterm.Index & 7, xterm.Index >= 8), + AnsiColor.Xterm xterm => colors.Xterm(xterm.Index), + AnsiColor.Rgb rgb => new RgbColor(rgb.R, rgb.G, rgb.B), + _ => null, + }; +} + +/// The xterm palette, as has it, as an . +public class AnsiColors : IColorResolver +{ + /// The xterm palette, text left as it is. + public static AnsiColors Default { get; } = new(); + + /// + public virtual RgbColor Standard(int index, bool bright) + { + var rgb = AnsiPalette.Standard((byte)(index & 7), bright); + return new RgbColor(rgb.R, rgb.G, rgb.B); + } + + /// + public virtual RgbColor Xterm(int index) + { + var rgb = AnsiPalette.Xterm((byte)index); + return new RgbColor(rgb.R, rgb.G, rgb.B); + } + + /// + public virtual RgbColor Legible(RgbColor foreground, RgbColor background) => foreground; +} diff --git a/MarkupString.Ansi/Decoding/PictureStore.cs b/MarkupString.Ansi/Decoding/PictureStore.cs new file mode 100644 index 0000000..6b1432e --- /dev/null +++ b/MarkupString.Ansi/Decoding/PictureStore.cs @@ -0,0 +1,345 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +namespace MarkupString.Ansi.Decoding; + +/// +/// A picture a decoder read from the server: its pixels when it could decode them, and the file as it came when +/// there was one. s refer to it by . +/// +public sealed class StoredPicture +{ + private readonly ReadOnlyMemory _file; + private readonly string? _fileType; + private ReadOnlyMemory _encoded; + + internal StoredPicture(string key, int width, int height, TerminalPicture? pixels, ReadOnlyMemory file, string? fileType) + { + Key = key; + Width = width; + Height = height; + Pixels = pixels; + _file = file; + _fileType = file.IsEmpty ? null : fileType; + } + + /// What identifies it: a hash of its contents, so the same picture sent twice is stored once. + public string Key { get; } + + /// Its width in pixels. + public int Width { get; } + + /// Its height in pixels. + public int Height { get; } + + /// What an that shows it says: picture: and the key. + public string Source => PictureStore.Scheme + Key; + + /// + /// Its pixels, or null for a file this package cannot decode (a JPEG, a GIF), which only a browser can show. A + /// moving picture's frames are in ; they can grow after the picture is first + /// stored, as the server sends them. + /// + public TerminalPicture? Pixels { get; private set; } + + /// Whether it moves: it has more than one frame. + public bool IsMoving => Pixels is { Frames.Count: > 1 }; + + /// + /// The type of : the file's own, such as image/png; image/gif for a moving + /// picture sent as frames; image/png for still pixels. + /// + public string MediaType => IsMoving ? "image/gif" : _fileType ?? "image/png"; + + /// What the server sent, as a file: its own for a file, a looping GIF for frames, a PNG for still pixels. + public ReadOnlyMemory Encoded + { + get + { + if (!_encoded.IsEmpty) return _encoded; + if (IsMoving) return _encoded = GifWriter.Encode(Pixels!.Frames, Width, Height); + if (!_file.IsEmpty) return _file; + return _encoded = Pixels is { } pixels ? PngWriter.Encode(pixels.Rgba.Span, pixels.Width, pixels.Height) : default; + } + } + + /// What the server sent as the file, or empty when it sent bare pixels. + public ReadOnlyMemory File => _file; + + /// How many bytes it holds. + public long Size => _file.Length + PixelBytes(Pixels); + + /// The picture as a data: address, for an <img>. + public string ToDataUrl() => $"data:{MediaType};base64,{Convert.ToBase64String(Encoded.Span)}"; + + internal void ReplacePixels(TerminalPicture pixels) + { + Pixels = pixels; + _encoded = default; + } + + internal static long PixelBytes(TerminalPicture? pixels) => + pixels is null ? 0 : pixels.Frames.Count > 0 ? pixels.Frames.Sum(frame => (long)frame.Rgba.Length) : pixels.Rgba.Length; +} + +/// +/// Where decoded pictures are kept, by : a hash of the picture, so the same picture +/// is kept once however often, and by however many connections, it is sent. Give one to +/// to share it +/// between connections, or to keep pictures somewhere of your own. is the default. +/// +/// +/// Decoders on different threads may share one cache, so an implementation must be safe to call from several +/// threads at once. A picture is not changed once it is in a cache, except one that a connection made for its own +/// moving picture, under a key of its own; is called again when that one gains a frame. +/// +public interface IPictureCache +{ + /// The picture with , if it is held. + bool TryGet(string key, [NotNullWhen(true)] out StoredPicture? picture); + + /// Holds , or takes note that its changed. + void Set(StoredPicture picture); + + /// Forgets the picture with . + bool Remove(string key); +} + +/// Pictures in memory, within a budget of bytes; the least recently used go first. Safe to share. +public sealed class MemoryPictureCache : IPictureCache +{ + private sealed class Entry(StoredPicture picture) + { + public StoredPicture Picture { get; } = picture; + + public long Size { get; set; } + } + + private readonly Dictionary> _pictures = new(StringComparer.Ordinal); + private readonly LinkedList _order = []; + private readonly Lock _lock = new(); + private long _budget = 64L * 1024 * 1024; + + /// The most bytes held. 64 MiB unless set. + public long Budget + { + get => _budget; + set + { + lock (_lock) + { + _budget = value; + Evict(null); + } + } + } + + /// The bytes held now. + public long Used { get; private set; } + + /// The pictures held now. + public int Count + { + get + { + lock (_lock) return _pictures.Count; + } + } + + /// + public bool TryGet(string key, [NotNullWhen(true)] out StoredPicture? picture) + { + ArgumentNullException.ThrowIfNull(key); + lock (_lock) + { + if (_pictures.TryGetValue(key, out var node)) + { + _order.Remove(node); + _order.AddFirst(node); + picture = node.Value.Picture; + return true; + } + } + + picture = null; + return false; + } + + /// + public void Set(StoredPicture picture) + { + ArgumentNullException.ThrowIfNull(picture); + lock (_lock) + { + if (_pictures.TryGetValue(picture.Key, out var node)) + { + _order.Remove(node); + Used -= node.Value.Size; + } + else + { + node = new LinkedListNode(new Entry(picture)); + _pictures[picture.Key] = node; + } + + node.Value.Size = picture.Size; + Used += node.Value.Size; + _order.AddFirst(node); + Evict(picture); + } + } + + /// + public bool Remove(string key) + { + ArgumentNullException.ThrowIfNull(key); + lock (_lock) return RemoveHeld(key); + } + + private bool RemoveHeld(string key) + { + if (!_pictures.Remove(key, out var node)) return false; + _order.Remove(node); + Used -= node.Value.Size; + return true; + } + + /// Drops the least recently used until the budget is met, keeping the newest and . + private void Evict(StoredPicture? keep) + { + while (Used > _budget && _order.Count > 1 && _order.Last!.Value.Picture != keep) RemoveHeld(_order.Last.Value.Picture.Key); + } +} + +/// +/// The pictures one connection's decoder read, kept in an . It is also the +/// that draws them again in a terminal. Get a decoder's with +/// . +/// +/// +/// A picture the server deletes (Kitty a=d) is only forgotten by its id: lines already read still show it, +/// until the cache lets it go. A line whose picture is gone shows its text instead. +/// +public sealed class PictureStore : ITerminalPictureSource +{ + /// The scheme of a . + public const string Scheme = "picture:"; + + private readonly HashSet _read = new(StringComparer.Ordinal); + private readonly HashSet _transmitted = []; + + /// A store of its own, in a new . + public PictureStore() + : this(null) + { + } + + /// A store that keeps its pictures in , or in a new . + public PictureStore(IPictureCache? cache) => Cache = cache ?? new MemoryPictureCache(); + + /// Where the pictures are kept. + public IPictureCache Cache { get; } + + /// The largest picture decoded, in pixels. + public long MaxPixels { get; set; } = 4096L * 4096; + + /// How many pictures this connection has read, held or not. + public int Count => _read.Count; + + /// + /// Raised when a picture is first stored, so a host can serve it from an address of its own. One already in a + /// shared cache is not raised again. + /// + public event Action? Added; + + /// Stores decoded pixels. + public StoredPicture Add(int width, int height, byte[] rgba) => Add(width, height, rgba, default, null); + + /// Stores a file, with its pixels when they were decoded. + public StoredPicture Add(int width, int height, byte[]? rgba, ReadOnlyMemory encoded, string? mediaType) + { + var key = KeyOf(encoded.IsEmpty ? rgba : encoded.Span); + _read.Add(key); + if (Cache.TryGet(key, out var existing)) return existing; + + var pixels = rgba is null ? null : new TerminalPicture(key, width, height, rgba); + var picture = new StoredPicture(key, width, height, pixels, encoded, mediaType); + Cache.Set(picture); + Added?.Invoke(picture); + return picture; + } + + /// + /// The picture already held for the file , so it need not be decoded again; never one + /// larger than . + /// + internal bool TryGetFile(ReadOnlySpan encoded, [NotNullWhen(true)] out StoredPicture? picture) + { + var key = KeyOf(encoded); + if (Cache.TryGet(key, out picture) && (long)picture.Width * picture.Height <= MaxPixels) + { + _read.Add(key); + return true; + } + + picture = null; + return false; + } + + /// + /// A copy of under a key of its own, for this connection to add frames to, so the + /// still picture, which other lines and connections may show, stays as it is. + /// + internal StoredPicture Fork(StoredPicture still) + { + var key = $"{still.Key}-{Guid.NewGuid():N}"; + var pixels = still.Pixels is { } source ? new TerminalPicture(key, source.Width, source.Height, source.Rgba) : null; + var picture = new StoredPicture(key, still.Width, still.Height, pixels, default, null); + _read.Add(key); + Cache.Set(picture); + Added?.Invoke(picture); + return picture; + } + + /// Replaces a picture's pixels, as when a moving picture gains a frame. + internal void ReplacePixels(StoredPicture picture, TerminalPicture pixels) + { + picture.ReplacePixels(pixels); + if (Cache.TryGet(picture.Key, out var held) && held == picture) Cache.Set(picture); + } + + /// The picture with , if it is still held. + public bool TryGet(string key, [NotNullWhen(true)] out StoredPicture? picture) => Cache.TryGet(key, out picture); + + /// The picture shows, if it is one of these and still held. + public bool TryGet(ImageMarkup image, [NotNullWhen(true)] out StoredPicture? picture) + { + ArgumentNullException.ThrowIfNull(image); + picture = null; + return image.Source.StartsWith(Scheme, StringComparison.Ordinal) && TryGet(image.Source[Scheme.Length..], out picture); + } + + /// Forgets the picture with , for every connection that shares the cache. + public bool Remove(string key) + { + _read.Remove(key); + return Cache.Remove(key); + } + + /// + public bool TryGetPicture(ImageMarkup image, [NotNullWhen(true)] out TerminalPicture? picture) + { + picture = TryGet(image, out var stored) ? stored.Pixels : null; + return picture is not null; + } + + /// + public bool MarkTransmitted(uint imageId) => _transmitted.Add(imageId); + + /// The key of a picture whose file, or else pixels, are . + internal static string KeyOf(ReadOnlySpan data) + { + Span hash = stackalloc byte[32]; + SHA256.HashData(data, hash); + return Convert.ToHexStringLower(hash[..12]); + } +} diff --git a/MarkupString.Ansi/Decoding/PicturesPlugin.cs b/MarkupString.Ansi/Decoding/PicturesPlugin.cs new file mode 100644 index 0000000..30e6373 --- /dev/null +++ b/MarkupString.Ansi/Decoding/PicturesPlugin.cs @@ -0,0 +1,1006 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.IO.Compression; +using System.Text; +using MarkupString.Decoding; +using Decoder = MarkupString.Decoding.Decoder; +namespace MarkupString.Ansi.Decoding; + +/// +/// Pictures from the server: Kitty graphics (APC G, Unicode placeholders included), iTerm2 inline images +/// (OSC 1337 File=, and its multipart form) and sixel (DCS q). Each becomes an +/// over the cells it covers, its pixels kept in the decoder's . +/// +/// +/// +/// A picture is placed as a terminal places it: its first row beside the text already on the line, each further +/// row on a line of its own. A picture drawn between a cursor save and restore (ESC 7 … ESC 8) is +/// drawn over the lines that follow it instead, which is how a game writes a picture beside text. +/// +/// +/// Only pictures sent in the sequence itself are read (Kitty t=d): one that names a file, a temporary file +/// or shared memory is refused, since the server's files are not the client's. +/// +/// +public sealed class PicturesPlugin : IDecoderPlugin +{ + /// The most base64 a chunked picture may add up to. + public const int MaxTransmission = 16 * 1024 * 1024; + + private static readonly Dictionary DiacriticIndex = BuildDiacriticIndex(); + + /// The instance that gives each connection a cache of its own. + public static PicturesPlugin Instance { get; } = new(null); + + /// The plugin with every connection's pictures kept in , or each in its own when null. + public PicturesPlugin(IPictureCache? cache) => Cache = cache; + + /// The cache every connection's pictures are kept in, or null for one each. + public IPictureCache? Cache { get; } + + /// + public string Name => "pictures"; + + /// The pictures has read. + public static PictureStore Pictures(Decoder decoder) + { + ArgumentNullException.ThrowIfNull(decoder); + var state = decoder.State(); + state.Bind(decoder.Registry.Plugins.OfType().FirstOrDefault()?.Cache); + return state.Store; + } + + private PictureState Bind(DecodeContext context) + { + var state = context.State(); + state.Bind(Cache); + return state; + } + + /// + public void Register(DecoderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder + .OnApc((payload, context) => payload.Length > 0 && payload[0] == 'G' && Bind(context) is not null && Kitty(payload[1..], context)) + .OnOsc(1337, (payload, context) => Bind(context) is not null && InlineImage(payload, context)) + .OnDcs('q', (in CsiSequence header, ReadOnlySpan data, DecodeContext context) => Bind(context) is not null && Sixel(header, data, context)) + .OnPrint((text, context) => Bind(context) is not null && Placeholders(text, context)) + .OnLineEnd(context => + { + Bind(context); + DrawOverlays(context); + }); + } + + // ── Kitty ─────────────────────────────────────────────────────────────────── + + private static bool Kitty(ReadOnlySpan payload, DecodeContext context) + { + var state = context.State(); + var separator = payload.IndexOf(';'); + var control = separator < 0 ? payload : payload[..separator]; + var data = separator < 0 ? [] : payload[(separator + 1)..]; + var keys = KittyKeys.Parse(control); + + if (state.Chunks is { } chunks) + { + // A continuation: only m (and q) count; the keys came with the first chunk. + if (chunks.Length + data.Length > MaxTransmission) + { + state.Chunks = null; + context.Diagnostics.PicturesFailed++; + return true; + } + + chunks.Append(data); + if (keys.More) return true; + state.Chunks = null; + Complete(state.ChunkKeys, chunks.ToString(), context); + return true; + } + + if (keys.More) + { + state.Chunks = new StringBuilder().Append(data); + state.ChunkKeys = keys; + return true; + } + + Complete(keys, data.ToString(), context); + return true; + } + + private static void Complete(KittyKeys keys, string base64, DecodeContext context) + { + var state = context.State(); + switch (keys.Action) + { + case 'd': + Delete(keys, state); + return; + case 'p': + if (state.KittyImages.TryGetValue(keys.Id, out var known) && state.Store.TryGet(known.Key, out var stored)) + { + var columns = keys.Columns > 0 ? keys.Columns : known.Columns; + var rows = keys.Rows > 0 ? keys.Rows : known.Rows; + Place(context, stored, columns, rows, cursorRight: true, keys.StayPut); + KittyReply(context, keys, null); + } + else + { + KittyReply(context, keys, "ENOENT:no such image"); + } + + return; + case 't' or 'T' or 'q' or 'f': + break; + case 'a': + Animate(keys, state); + return; + default: + // Composing frames (a=c) is not read. + context.Diagnostics.UnhandledSequences++; + return; + } + + if (keys.Medium != 'd') + { + context.Diagnostics.PicturesFailed++; + KittyReply(context, keys, "EINVAL:only direct transmission is read"); + return; + } + + var bytes = FromBase64(base64); + if (bytes is not null && keys.Compression == 'z') bytes = Inflate(bytes, state.Store.MaxPixels * 4 + 1024); + if (keys.Action == 'f') + { + if (bytes is null || DecodePixels(keys, bytes, state.Store.MaxPixels) is not { } frame || !AddFrame(keys, frame, state)) + { + context.Diagnostics.PicturesFailed++; + KittyReply(context, keys, "ENOENT:no such image, or a frame that could not be read"); + return; + } + + KittyReply(context, keys, null); + return; + } + + var picture = bytes is null ? null : DecodeKitty(keys, bytes, state.Store); + if (picture is null) + { + context.Diagnostics.PicturesFailed++; + KittyReply(context, keys, "EBADF:could not decode the picture"); + return; + } + + if (keys.Action == 'q') + { + KittyReply(context, keys, null); + return; + } + + var (cellColumns, cellRows) = Cells(picture.Width, picture.Height, keys.Columns, keys.Rows, context.Identity); + var id = keys.Id; + if (id == 0 && keys.Number != 0) id = state.NextKittyId++; + if (id != 0) + { + state.KittyImages[id] = new KittyImage(picture.Key, cellColumns, cellRows); + state.Animations.Remove(id); + } + + if (keys.Action == 'T' && !keys.Virtual) Place(context, picture, cellColumns, cellRows, cursorRight: true, keys.StayPut); + KittyReply(context, keys with { Id = id }, null); + } + + private static StoredPicture? DecodeKitty(KittyKeys keys, byte[] bytes, PictureStore store) + { + // A file already held is not decoded again. + if (keys.Format == 100 && store.TryGetFile(bytes, out var held) && held.Pixels is not null) return held; + if (DecodePixels(keys, bytes, store.MaxPixels) is not { } pixels) return null; + return keys.Format == 100 + ? store.Add(pixels.Width, pixels.Height, pixels.Rgba, bytes, "image/png") + : store.Add(pixels.Width, pixels.Height, pixels.Rgba); + } + + /// The pixels a Kitty transmission carries: a PNG, or raw RGB or RGBA s × v. + private static (int Width, int Height, byte[] Rgba)? DecodePixels(KittyKeys keys, byte[] bytes, long maxPixels) + { + switch (keys.Format) + { + case 100: + return PngDecoder.TryDecode(bytes, maxPixels, out var width, out var height, out var rgba) ? (width, height, rgba) : null; + case 24 or 32: + { + if (keys.Width <= 0 || keys.Height <= 0 || (long)keys.Width * keys.Height > maxPixels) return null; + var channels = keys.Format / 8; + if (bytes.Length < (long)keys.Width * keys.Height * channels) return null; + var pixels = new byte[keys.Width * keys.Height * 4]; + for (var i = 0; i < keys.Width * keys.Height; i++) + { + pixels[i * 4] = bytes[i * channels]; + pixels[i * 4 + 1] = bytes[i * channels + 1]; + pixels[i * 4 + 2] = bytes[i * channels + 2]; + pixels[i * 4 + 3] = channels == 4 ? bytes[i * 4 + 3] : (byte)255; + } + + return (keys.Width, keys.Height, pixels); + } + default: + return null; + } + } + + // ── Kitty animation ───────────────────────────────────────────────────────── + + /// The default gap between frames, as Kitty has it. + private const int DefaultGap = 40; + + /// The most frames a moving picture is read with; later ones are dropped. + public const int MaxFrames = 1000; + + /// + /// a=f: a frame, made on a copy of frame c (or blank, filled with Y) or written over frame + /// r, with the data put at x, y, blended over what is there unless X=1. + /// + private static bool AddFrame(KittyKeys keys, (int Width, int Height, byte[] Rgba) data, PictureState state) + { + if (!state.KittyImages.TryGetValue(keys.Id, out var known) || !state.Store.TryGet(known.Key, out var stored) + || stored.Pixels is not { } pixels) + { + return false; + } + + var animation = Animation(keys.Id, ref stored, pixels, state); + var size = stored.Width * stored.Height * 4; + + // For a frame, c and r are frame numbers (from 1), not cells. + var edit = keys.Rows; + var baseFrame = keys.Columns; + byte[] canvas; + if (edit > 0 && edit <= animation.Frames.Count) + { + canvas = (byte[])animation.Frames[edit - 1].Clone(); + } + else if (baseFrame > 0 && baseFrame <= animation.Frames.Count) + { + canvas = (byte[])animation.Frames[baseFrame - 1].Clone(); + } + else + { + canvas = new byte[size]; + if (keys.Background != 0) + { + var fill = keys.Background; + for (var i = 0; i < canvas.Length; i += 4) + { + canvas[i] = (byte)(fill >> 24); + canvas[i + 1] = (byte)(fill >> 16); + canvas[i + 2] = (byte)(fill >> 8); + canvas[i + 3] = (byte)fill; + } + } + } + + Composite(canvas, stored.Width, stored.Height, data, keys.X, keys.Y, keys.Replace); + + if (edit > 0 && edit <= animation.Frames.Count) + { + animation.Frames[edit - 1] = canvas; + if (keys.Gap != 0) animation.Gaps[edit - 1] = keys.Gap; + } + else + { + if (animation.Frames.Count >= MaxFrames) return true; + animation.Frames.Add(canvas); + animation.Gaps.Add(keys.Gap != 0 ? keys.Gap : DefaultGap); + } + + Publish(stored, animation, state.Store); + return true; + } + + /// a=a: a frame's gap (r, z). Playing, stopping and looping are the client's. + private static void Animate(KittyKeys keys, PictureState state) + { + if (!state.KittyImages.TryGetValue(keys.Id, out var known) || !state.Store.TryGet(known.Key, out var stored) + || stored.Pixels is not { } pixels) + { + return; + } + + var frame = keys.Rows; + if (frame <= 0 || keys.Gap == 0) return; + var animation = Animation(keys.Id, ref stored, pixels, state); + if (frame > animation.Frames.Count) return; + animation.Gaps[frame - 1] = keys.Gap; + Publish(stored, animation, state.Store); + } + + private static KittyAnimation Animation(uint id, ref StoredPicture stored, TerminalPicture pixels, PictureState state) + { + if (state.Animations.TryGetValue(id, out var animation)) return animation; + + // Frames go on a copy of its own, so the still picture, shown elsewhere too, stays still. + stored = state.Store.Fork(stored); + state.KittyImages[id] = state.KittyImages[id] with { Key = stored.Key }; + animation = new KittyAnimation(); + if (pixels.Frames.Count > 0) + { + foreach (var frame in pixels.Frames) + { + animation.Frames.Add(frame.Rgba.ToArray()); + animation.Gaps.Add((int)frame.Duration.TotalMilliseconds); + } + } + else + { + animation.Frames.Add(pixels.Rgba.ToArray()); + animation.Gaps.Add(DefaultGap); + } + + state.Animations[id] = animation; + return animation; + } + + private static void Publish(StoredPicture stored, KittyAnimation animation, PictureStore store) + { + // A negative gap is a frame Kitty skips; it is left out. + var frames = new List(animation.Frames.Count); + for (var i = 0; i < animation.Frames.Count; i++) + { + if (animation.Gaps[i] < 0 && i > 0) continue; + frames.Add(new TerminalPictureFrame(animation.Frames[i], TimeSpan.FromMilliseconds(Math.Max(0, animation.Gaps[i])))); + } + + store.ReplacePixels(stored, new TerminalPicture(stored.Key, stored.Width, stored.Height, frames)); + } + + private static void Composite(byte[] canvas, int width, int height, (int Width, int Height, byte[] Rgba) data, int left, int top, bool replace) + { + for (var y = 0; y < data.Height; y++) + { + var cy = top + y; + if (cy < 0 || cy >= height) continue; + for (var x = 0; x < data.Width; x++) + { + var cx = left + x; + if (cx < 0 || cx >= width) continue; + var s = (y * data.Width + x) * 4; + var d = (cy * width + cx) * 4; + var alpha = data.Rgba[s + 3]; + if (replace || alpha == 255) + { + Array.Copy(data.Rgba, s, canvas, d, 4); + continue; + } + + if (alpha == 0) continue; + + // Source over destination, straight alpha. + var a = alpha / 255.0; + var da = canvas[d + 3] / 255.0; + var oa = a + da * (1 - a); + for (var c = 0; c < 3; c++) + { + var value = (data.Rgba[s + c] * a + canvas[d + c] * da * (1 - a)) / oa; + canvas[d + c] = (byte)Math.Round(value); + } + + canvas[d + 3] = (byte)Math.Round(oa * 255); + } + } + } + + private static void Delete(KittyKeys keys, PictureState state) + { + switch (keys.Delete) + { + case 'a' or 'A' or '\0': + state.KittyImages.Clear(); + state.Animations.Clear(); + break; + case 'i' or 'I' or 'n' or 'N': + state.KittyImages.Remove(keys.Id); + state.Animations.Remove(keys.Id); + break; + } + } + + private static void KittyReply(DecodeContext context, KittyKeys keys, string? error) + { + if (keys.Id == 0 && keys.Number == 0) return; + if (keys.Quiet >= 2 || (error is null && keys.Quiet >= 1)) return; + if ((context.Identity.Answers & ClientAnswers.Pictures) == 0) return; + + var reply = new StringBuilder("\e_G"); + if (keys.Id != 0) reply.Append(CultureInfo.InvariantCulture, $"i={keys.Id}"); + if (keys.Number != 0) reply.Append(keys.Id != 0 ? "," : "").Append(CultureInfo.InvariantCulture, $"I={keys.Number}"); + reply.Append(';').Append(error ?? "OK").Append("\e\\"); + context.Reply(reply.ToString()); + } + + private static bool Placeholders(ReadOnlySpan text, DecodeContext context) + { + if (text.IndexOf('\uDBFB') < 0) return false; + + var state = context.State(); + var at = 0; + while (at < text.Length) + { + var start = IndexOfPlaceholder(text, at); + if (start < 0) + { + context.Write(text[at..]); + break; + } + + if (start > at) context.Write(text[at..start]); + + // One stretch of placeholders on the same row, in consecutive columns, is one write. + var id = PlaceholderId(context); + int row = -1, column = -1, count = 0, first = 0; + var i = start; + while (i < text.Length && IsPlaceholderAt(text, i)) + { + i += 2; + var cellRow = Diacritic(text, ref i); + var cellColumn = Diacritic(text, ref i); + Diacritic(text, ref i); + + if (cellRow < 0) cellRow = row < 0 ? 0 : row; + if (cellColumn < 0) cellColumn = column < 0 ? 0 : column + 1; + if (count > 0 && (cellRow != row || cellColumn != column + 1)) + { + WritePlaceholders(context, state, id, row, first, count); + count = 0; + } + + if (count == 0) first = cellColumn; + row = cellRow; + column = cellColumn; + count++; + } + + WritePlaceholders(context, state, id, row, first, count); + at = i; + } + + return true; + } + + private static void WritePlaceholders(DecodeContext context, PictureState state, uint id, int row, int first, int count) + { + if (count == 0) return; + var blank = new string(' ', count); + if (state.KittyImages.TryGetValue(id, out var known) && state.Store.TryGet(known.Key, out var stored) && row < known.Rows) + { + var image = Markup(stored) with { Row = new PictureRow(row, known.Rows, known.Columns) { FirstColumn = first } }; + context.Write(blank, image); + } + else + { + context.Write(blank); + } + } + + private static uint PlaceholderId(DecodeContext context) => + context.Pen(VtPens.Style) is AnsiMarkup { Style.Foreground: { } color } + ? color switch + { + AnsiColor.Rgb rgb => (uint)(rgb.R << 16 | rgb.G << 8 | rgb.B), + AnsiColor.Xterm xterm => xterm.Index, + AnsiColor.Standard standard => (uint)(standard.Index + (standard.Bright ? 8 : 0)), + _ => 0u, + } + : 0u; + + private static int IndexOfPlaceholder(ReadOnlySpan text, int from) + { + for (var i = from; i < text.Length - 1; i++) + { + if (IsPlaceholderAt(text, i)) return i; + } + + return -1; + } + + private static bool IsPlaceholderAt(ReadOnlySpan text, int i) => i + 1 < text.Length && text[i] == '\uDBFB' && text[i + 1] == '\uDEEE'; + + private static int Diacritic(ReadOnlySpan text, ref int i) + { + if (i >= text.Length || Rune.DecodeFromUtf16(text[i..], out var rune, out var used) != System.Buffers.OperationStatus.Done) return -1; + if (!DiacriticIndex.TryGetValue(rune.Value, out var index)) return -1; + i += used; + return index; + } + + private static Dictionary BuildDiacriticIndex() + { + var index = new Dictionary(TerminalPictureWriter.Diacritics.Length); + for (var i = 0; i < TerminalPictureWriter.Diacritics.Length; i++) index[TerminalPictureWriter.Diacritics[i]] = i; + return index; + } + + // ── iTerm2 ────────────────────────────────────────────────────────────────── + + private static bool InlineImage(ReadOnlySpan payload, DecodeContext context) + { + var state = context.State(); + if (payload.StartsWith("File=", StringComparison.Ordinal)) + { + var colon = payload.IndexOf(':'); + if (colon < 0) return false; + ShowInlineImage(InlineArguments.Parse(payload[5..colon]), FromBase64(payload[(colon + 1)..].ToString()), context); + return true; + } + + if (payload.StartsWith("MultipartFile=", StringComparison.Ordinal)) + { + state.Multipart = InlineArguments.Parse(payload[14..]); + state.MultipartData = new StringBuilder(); + return true; + } + + if (payload.StartsWith("FilePart=", StringComparison.Ordinal)) + { + if (state.MultipartData is not { } parts) return true; + if (parts.Length + payload.Length > MaxTransmission) + { + state.Multipart = null; + state.MultipartData = null; + context.Diagnostics.PicturesFailed++; + return true; + } + + parts.Append(payload[9..]); + return true; + } + + if (payload.SequenceEqual("FileEnd")) + { + if (state.Multipart is { } arguments && state.MultipartData is { } data) + { + ShowInlineImage(arguments, FromBase64(data.ToString()), context); + } + + state.Multipart = null; + state.MultipartData = null; + return true; + } + + return false; + } + + private static void ShowInlineImage(InlineArguments arguments, byte[]? bytes, DecodeContext context) + { + if (bytes is null) + { + context.Diagnostics.PicturesFailed++; + return; + } + + if (!arguments.Inline) + { + context.Raise(new DownloadEvent(arguments.Name, bytes.Length)); + return; + } + + var store = context.State().Store; + StoredPicture? picture = null; + if (store.TryGetFile(bytes, out var held)) + { + // A file already held is not decoded again. + picture = held; + } + else if (PngDecoder.TryDecode(bytes, store.MaxPixels, out var width, out var height, out var rgba)) + { + picture = store.Add(width, height, rgba, bytes, "image/png"); + } + else if (Sniff(bytes) is { } file && (long)file.Width * file.Height <= store.MaxPixels) + { + // Not decodable here, but a browser shows it. + picture = store.Add(file.Width, file.Height, null, bytes, file.MediaType); + } + + if (picture is null) + { + context.Diagnostics.PicturesFailed++; + return; + } + + var identity = context.Identity; + var columns = arguments.Width.ToCells(identity.Columns, identity.CellWidth); + var rows = arguments.Height.ToCells(identity.Rows, identity.CellHeight); + if (!arguments.PreserveAspectRatio && columns > 0 && rows > 0) + { + Place(context, picture, Math.Min(columns, identity.Columns), rows, cursorRight: true, stayPut: false, arguments.Name); + return; + } + + var (cellColumns, cellRows) = Cells(picture.Width, picture.Height, columns, rows, identity); + Place(context, picture, cellColumns, cellRows, cursorRight: true, stayPut: false, arguments.Name); + } + + /// The size and type of a GIF, JPEG or WebP, read from its header. + private static (int Width, int Height, string MediaType)? Sniff(ReadOnlySpan data) + { + if (data.Length >= 10 && data.StartsWith("GIF8"u8)) + { + return (BinaryPrimitives.ReadUInt16LittleEndian(data[6..]), BinaryPrimitives.ReadUInt16LittleEndian(data[8..]), "image/gif"); + } + + if (data.Length >= 30 && data.StartsWith("RIFF"u8) && data[8..12].SequenceEqual("WEBP"u8)) + { + var chunk = data[12..16]; + if (chunk.SequenceEqual("VP8 "u8)) + return (BinaryPrimitives.ReadUInt16LittleEndian(data[26..]) & 0x3FFF, BinaryPrimitives.ReadUInt16LittleEndian(data[28..]) & 0x3FFF, "image/webp"); + if (chunk.SequenceEqual("VP8L"u8) && data.Length >= 25) + { + var bits = BinaryPrimitives.ReadUInt32LittleEndian(data[21..]); + return ((int)(bits & 0x3FFF) + 1, (int)((bits >> 14) & 0x3FFF) + 1, "image/webp"); + } + + if (chunk.SequenceEqual("VP8X"u8)) + return ((data[24] | data[25] << 8 | data[26] << 16) + 1, (data[27] | data[28] << 8 | data[29] << 16) + 1, "image/webp"); + } + + if (data.Length >= 4 && data[0] == 0xFF && data[1] == 0xD8) + { + var i = 2; + while (i + 9 < data.Length && data[i] == 0xFF) + { + var marker = data[i + 1]; + var length = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 2)..]); + if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC) + { + return (BinaryPrimitives.ReadUInt16BigEndian(data[(i + 7)..]), BinaryPrimitives.ReadUInt16BigEndian(data[(i + 5)..]), "image/jpeg"); + } + + i += 2 + length; + } + } + + return null; + } + + // ── Sixel ─────────────────────────────────────────────────────────────────── + + private static bool Sixel(in CsiSequence header, ReadOnlySpan data, DecodeContext context) + { + var store = context.State().Store; + if (!SixelDecoder.TryDecode(data, header.Get(1) == 1, store.MaxPixels, out var width, out var height, out var rgba)) + { + context.Diagnostics.PicturesFailed++; + return true; + } + + var picture = store.Add(width, height, rgba); + var (columns, rows) = Cells(width, height, 0, 0, context.Identity); + Place(context, picture, columns, rows, cursorRight: false, stayPut: false); + return true; + } + + // ── Placing ───────────────────────────────────────────────────────────────── + + /// The cells a picture takes: as asked, else its pixels over the cell size, no wider than the pane. + private static (int Columns, int Rows) Cells(int width, int height, int columns, int rows, ClientIdentity identity) + { + var cellWidth = Math.Max(1, identity.CellWidth); + var cellHeight = Math.Max(1, identity.CellHeight); + if (columns <= 0 && rows <= 0) + { + columns = (width + cellWidth - 1) / cellWidth; + rows = (height + cellHeight - 1) / cellHeight; + } + else if (rows <= 0) + { + rows = (int)Math.Ceiling((double)columns * cellWidth * height / width / cellHeight); + } + else if (columns <= 0) + { + columns = (int)Math.Ceiling((double)rows * cellHeight * width / height / cellWidth); + } + + if (identity.Columns > 0 && columns > identity.Columns) + { + rows = Math.Max(1, (int)Math.Ceiling((double)rows * identity.Columns / columns)); + columns = identity.Columns; + } + + return (Math.Max(1, columns), Math.Max(1, rows)); + } + + private static ImageMarkup Markup(StoredPicture picture, string? description = null) => + new(picture.Source, description, picture.Width, picture.Height); + + private static void Place(DecodeContext context, StoredPicture picture, int columns, int rows, bool cursorRight, bool stayPut, string? description = null) + { + var image = Markup(picture, description); + var vt = context.State(); + if (vt.SavedColumn is not null || stayPut) + { + // Drawn over the lines that follow, which the server leaves room for. + context.State().Overlays.Add(new Overlay(image, rows, columns, context.Column)); + return; + } + + context.PlacePicture(image, rows, columns, cursorRight); + } + + private static void DrawOverlays(DecodeContext context) + { + var overlays = context.State().Overlays; + if (overlays.Count == 0) return; + + var cursor = context.Column; + for (var i = 0; i < overlays.Count; i++) + { + var overlay = overlays[i]; + context.MoveToColumn(overlay.Column); + context.Write(new string(' ', overlay.Columns), overlay.Image with { Row = new PictureRow(overlay.Next, overlay.Rows, overlay.Columns) }); + overlay.Next++; + } + + overlays.RemoveAll(overlay => overlay.Next >= overlay.Rows); + context.MoveToColumn(cursor); + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + private static byte[]? FromBase64(string base64) + { + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + return null; + } + } + + private static byte[]? Inflate(byte[] data, long limit) + { + try + { + using var input = new MemoryStream(data); + using var inflater = new ZLibStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + var buffer = new byte[81920]; + int read; + while ((read = inflater.Read(buffer, 0, buffer.Length)) > 0) + { + output.Write(buffer, 0, read); + if (output.Length > limit) return null; + } + + return output.ToArray(); + } + catch (InvalidDataException) + { + return null; + } + } + + internal readonly record struct KittyImage(string Key, int Columns, int Rows); + + internal sealed class KittyAnimation + { + public List Frames { get; } = []; + + public List Gaps { get; } = []; + } + + internal sealed class Overlay(ImageMarkup image, int rows, int columns, int column) + { + public ImageMarkup Image { get; } = image; + + public int Rows { get; } = rows; + + public int Columns { get; } = columns; + + public int Column { get; } = column; + + public int Next { get; set; } + } + + internal readonly record struct KittyKeys + { + public char Action { get; init; } + + public char Medium { get; init; } + + public char Compression { get; init; } + + public char Delete { get; init; } + + public int Format { get; init; } + + public uint Id { get; init; } + + public uint Number { get; init; } + + public int Width { get; init; } + + public int Height { get; init; } + + public int Columns { get; init; } + + public int Rows { get; init; } + + public int Quiet { get; init; } + + public bool More { get; init; } + + public bool Virtual { get; init; } + + public bool StayPut { get; init; } + + public int X { get; init; } + + public int Y { get; init; } + + public bool Replace { get; init; } + + public uint Background { get; init; } + + public int Gap { get; init; } + + public static KittyKeys Parse(ReadOnlySpan control) + { + var keys = new KittyKeys { Action = 't', Medium = 'd', Format = 32 }; + foreach (var range in control.Split(',')) + { + var pair = control[range]; + if (pair.Length < 3 || pair[1] != '=') continue; + var value = pair[2..]; + int.TryParse(value, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var number); + keys = pair[0] switch + { + 'a' => keys with { Action = value[0] }, + 't' => keys with { Medium = value[0] }, + 'o' => keys with { Compression = value[0] }, + 'd' => keys with { Delete = value[0] }, + 'f' => keys with { Format = number }, + 'i' => keys with { Id = (uint)Math.Max(0, number) }, + 'I' => keys with { Number = (uint)Math.Max(0, number) }, + 's' => keys with { Width = number }, + 'v' => keys with { Height = number }, + 'c' => keys with { Columns = number }, + 'r' => keys with { Rows = number }, + 'q' => keys with { Quiet = number }, + 'm' => keys with { More = number == 1 }, + 'U' => keys with { Virtual = number == 1 }, + 'C' => keys with { StayPut = number == 1 }, + 'x' => keys with { X = number }, + 'y' => keys with { Y = number }, + 'X' => keys with { Replace = number == 1 }, + 'Y' => keys with { Background = uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var fill) ? fill : 0 }, + 'z' => keys with { Gap = number }, + _ => keys, + }; + } + + return keys; + } + } + + internal readonly record struct InlineSize(int Value, char Unit) + { + public static InlineSize Auto => default; + + public int ToCells(int paneCells, int cellPixels) => Unit switch + { + 'c' => Value, + 'p' => (Value + cellPixels - 1) / Math.Max(1, cellPixels), + '%' => Math.Max(1, paneCells * Value / 100), + _ => 0, + }; + + public static InlineSize Parse(ReadOnlySpan text) + { + if (text.IsEmpty || text.SequenceEqual("auto")) return Auto; + var unit = 'c'; + if (text.EndsWith("px", StringComparison.Ordinal)) + { + unit = 'p'; + text = text[..^2]; + } + else if (text.EndsWith("%", StringComparison.Ordinal)) + { + unit = '%'; + text = text[..^1]; + } + + return int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var value) && value > 0 ? new InlineSize(value, unit) : Auto; + } + } + + internal sealed record InlineArguments(string? Name, bool Inline, InlineSize Width, InlineSize Height, bool PreserveAspectRatio) + { + public static InlineArguments Parse(ReadOnlySpan text) + { + string? name = null; + var inline = false; + InlineSize width = default, height = default; + var preserve = true; + foreach (var range in text.Split(';')) + { + var pair = text[range]; + var equals = pair.IndexOf('='); + if (equals < 0) continue; + var key = pair[..equals]; + var value = pair[(equals + 1)..]; + if (key.SequenceEqual("name")) + { + var bytes = FromBase64(value.ToString()); + name = bytes is null ? null : Encoding.UTF8.GetString(bytes); + } + else if (key.SequenceEqual("inline")) inline = value.SequenceEqual("1"); + else if (key.SequenceEqual("width")) width = InlineSize.Parse(value); + else if (key.SequenceEqual("height")) height = InlineSize.Parse(value); + else if (key.SequenceEqual("preserveAspectRatio")) preserve = !value.SequenceEqual("0"); + } + + return new InlineArguments(name, inline, width, height, preserve); + } + } +} + +/// The pictures plugin's state for one decoder. +internal sealed class PictureState +{ + private IPictureCache? _cache; + private PictureStore? _store; + + /// This connection's store, in the plugin's cache when it has one. + public PictureStore Store => _store ??= new PictureStore(_cache); + + /// Takes the plugin's cache, before the store is first made. + public void Bind(IPictureCache? cache) + { + if (_store is null) _cache = cache; + } + + public Dictionary KittyImages { get; } = []; + + public Dictionary Animations { get; } = []; + + public uint NextKittyId { get; set; } = 0x7F000000; + + public StringBuilder? Chunks { get; set; } + + public PicturesPlugin.KittyKeys ChunkKeys { get; set; } + + public PicturesPlugin.InlineArguments? Multipart { get; set; } + + public StringBuilder? MultipartData { get; set; } + + public List Overlays { get; } = []; +} + +/// Registers the pictures plugin. +public static class PictureDecoding +{ + /// The registry with . + public static DecoderRegistry WithPictures(this DecoderRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + return registry.With(PicturesPlugin.Instance); + } + + /// + /// The registry with , every decoder it makes keeping its pictures in + /// : a picture several connections are sent is decoded and kept once. + /// + public static DecoderRegistry WithPictures(this DecoderRegistry registry, IPictureCache cache) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(cache); + return registry.With(new PicturesPlugin(cache)); + } + + /// The pictures has read. + public static PictureStore Pictures(this Decoder decoder) => PicturesPlugin.Pictures(decoder); +} diff --git a/MarkupString.Ansi/Decoding/PngDecoder.cs b/MarkupString.Ansi/Decoding/PngDecoder.cs new file mode 100644 index 0000000..2a58ddb --- /dev/null +++ b/MarkupString.Ansi/Decoding/PngDecoder.cs @@ -0,0 +1,260 @@ +using System.Buffers.Binary; +using System.IO.Compression; +namespace MarkupString.Ansi.Decoding; + +/// +/// Decodes a PNG to RGBA: every colour type and bit depth, transparency (tRNS) and Adam7 interlacing. +/// Ancillary chunks other than tRNS are skipped, gamma included. +/// +internal static class PngDecoder +{ + private static ReadOnlySpan Signature => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + // Adam7: start column, start row, column step, row step. + private static readonly (int X, int Y, int Dx, int Dy)[] Passes = + [ + (0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2), + ]; + + /// Whether starts like a PNG. + public static bool IsPng(ReadOnlySpan data) => data.StartsWith(Signature); + + /// Reads the size from the header alone. + public static bool TryReadSize(ReadOnlySpan data, out int width, out int height) + { + width = height = 0; + if (data.Length < 24 || !IsPng(data)) return false; + width = BinaryPrimitives.ReadInt32BigEndian(data[16..]); + height = BinaryPrimitives.ReadInt32BigEndian(data[20..]); + return width > 0 && height > 0; + } + + /// Decodes , refusing a picture of more than . + public static bool TryDecode(ReadOnlySpan data, long maxPixels, out int width, out int height, out byte[] rgba) + { + width = height = 0; + rgba = []; + try + { + return Decode(data, maxPixels, out width, out height, out rgba); + } + catch (InvalidDataException) + { + return false; + } + } + + private static bool Decode(ReadOnlySpan data, long maxPixels, out int width, out int height, out byte[] rgba) + { + width = height = 0; + rgba = []; + if (!IsPng(data)) return false; + + var offset = Signature.Length; + int depth = 0, colorType = -1, interlace = 0; + byte[]? palette = null; + byte[]? transparency = null; + using var compressed = new MemoryStream(); + + while (offset + 8 <= data.Length) + { + var length = BinaryPrimitives.ReadInt32BigEndian(data[offset..]); + if (length < 0 || offset + 12L + length > data.Length) return false; + var type = data.Slice(offset + 4, 4); + var body = data.Slice(offset + 8, length); + offset += 12 + length; + + if (type.SequenceEqual("IHDR"u8)) + { + if (length < 13) return false; + width = BinaryPrimitives.ReadInt32BigEndian(body); + height = BinaryPrimitives.ReadInt32BigEndian(body[4..]); + depth = body[8]; + colorType = body[9]; + interlace = body[12]; + if (width <= 0 || height <= 0 || (long)width * height > maxPixels) return false; + if (body[10] != 0 || body[11] != 0 || interlace > 1) return false; + if (!Valid(colorType, depth)) return false; + } + else if (type.SequenceEqual("PLTE"u8)) + { + palette = body.ToArray(); + } + else if (type.SequenceEqual("tRNS"u8)) + { + transparency = body.ToArray(); + } + else if (type.SequenceEqual("IDAT"u8)) + { + compressed.Write(body); + } + else if (type.SequenceEqual("IEND"u8)) + { + break; + } + } + + if (colorType < 0 || (colorType == 3 && palette is null)) return false; + + compressed.Position = 0; + using var inflater = new ZLibStream(compressed, CompressionMode.Decompress); + var channels = colorType switch { 0 => 1, 2 => 3, 3 => 1, 4 => 2, _ => 4 }; + var bitsPerPixel = channels * depth; + var pixelBytes = Math.Max(1, bitsPerPixel / 8); + + var expected = 0L; + foreach (var (x, y, dx, dy) in interlace == 1 ? Passes : [(0, 0, 1, 1)]) + { + var columns = (width - x + dx - 1) / dx; + var rows = (height - y + dy - 1) / dy; + if (columns > 0 && rows > 0) expected += rows * (1 + ((long)columns * bitsPerPixel + 7) / 8); + } + + if (expected > int.MaxValue) return false; + var raw = new byte[expected]; + var read = 0; + while (read < raw.Length) + { + var n = inflater.Read(raw, read, raw.Length - read); + if (n == 0) return false; + read += n; + } + + rgba = new byte[(long)width * height * 4]; + var at = 0; + foreach (var (x0, y0, dx, dy) in interlace == 1 ? Passes : [(0, 0, 1, 1)]) + { + var columns = (width - x0 + dx - 1) / dx; + var rows = (height - y0 + dy - 1) / dy; + if (columns <= 0 || rows <= 0) continue; + + var stride = (int)(((long)columns * bitsPerPixel + 7) / 8); + var previous = new byte[stride]; + var current = new byte[stride]; + for (var row = 0; row < rows; row++) + { + var filter = raw[at]; + raw.AsSpan(at + 1, stride).CopyTo(current); + at += 1 + stride; + if (!Unfilter(filter, current, previous, pixelBytes)) return false; + + var y = y0 + row * dy; + for (var column = 0; column < columns; column++) + { + var x = x0 + column * dx; + Pixel(current, column, colorType, depth, palette, transparency, rgba.AsSpan((y * width + x) * 4, 4)); + } + + (previous, current) = (current, previous); + } + } + + return true; + } + + private static bool Valid(int colorType, int depth) => colorType switch + { + 0 => depth is 1 or 2 or 4 or 8 or 16, + 3 => depth is 1 or 2 or 4 or 8, + 2 or 4 or 6 => depth is 8 or 16, + _ => false, + }; + + private static bool Unfilter(byte filter, Span line, ReadOnlySpan previous, int bpp) + { + switch (filter) + { + case 0: + return true; + case 1: + for (var i = bpp; i < line.Length; i++) line[i] += line[i - bpp]; + return true; + case 2: + for (var i = 0; i < line.Length; i++) line[i] += previous[i]; + return true; + case 3: + for (var i = 0; i < line.Length; i++) + { + var left = i >= bpp ? line[i - bpp] : 0; + line[i] += (byte)((left + previous[i]) >> 1); + } + + return true; + case 4: + for (var i = 0; i < line.Length; i++) + { + int a = i >= bpp ? line[i - bpp] : 0, b = previous[i], c = i >= bpp ? previous[i - bpp] : 0; + int p = a + b - c, pa = Math.Abs(p - a), pb = Math.Abs(p - b), pc = Math.Abs(p - c); + line[i] += (byte)(pa <= pb && pa <= pc ? a : pb <= pc ? b : c); + } + + return true; + default: + return false; + } + } + + private static void Pixel(ReadOnlySpan line, int column, int colorType, int depth, byte[]? palette, byte[]? transparency, Span output) + { + switch (colorType) + { + case 0: + { + var value = Sample(line, column, depth); + var gray = Scale(value, depth); + output[0] = output[1] = output[2] = gray; + output[3] = transparency is { Length: >= 2 } && BinaryPrimitives.ReadUInt16BigEndian(transparency) == value ? (byte)0 : (byte)255; + break; + } + case 2: + { + int r = Sample(line, column * 3, depth), g = Sample(line, column * 3 + 1, depth), b = Sample(line, column * 3 + 2, depth); + output[0] = Scale(r, depth); + output[1] = Scale(g, depth); + output[2] = Scale(b, depth); + output[3] = transparency is { Length: >= 6 } + && BinaryPrimitives.ReadUInt16BigEndian(transparency) == r + && BinaryPrimitives.ReadUInt16BigEndian(transparency.AsSpan(2)) == g + && BinaryPrimitives.ReadUInt16BigEndian(transparency.AsSpan(4)) == b + ? (byte)0 + : (byte)255; + break; + } + case 3: + { + var index = Sample(line, column, depth); + if (index * 3 + 2 < palette!.Length) + { + output[0] = palette[index * 3]; + output[1] = palette[index * 3 + 1]; + output[2] = palette[index * 3 + 2]; + } + + output[3] = transparency is not null && index < transparency.Length ? transparency[index] : (byte)255; + break; + } + case 4: + output[0] = output[1] = output[2] = Scale(Sample(line, column * 2, depth), depth); + output[3] = Scale(Sample(line, column * 2 + 1, depth), depth); + break; + default: + for (var i = 0; i < 4; i++) output[i] = Scale(Sample(line, column * 4 + i, depth), depth); + break; + } + } + + /// Sample of a line, at its depth. + private static int Sample(ReadOnlySpan line, int index, int depth) => depth switch + { + 16 => BinaryPrimitives.ReadUInt16BigEndian(line[(index * 2)..]), + 8 => line[index], + _ => (line[index * depth / 8] >> (8 - depth - index * depth % 8)) & ((1 << depth) - 1), + }; + + private static byte Scale(int value, int depth) => depth switch + { + 16 => (byte)(value >> 8), + 8 => (byte)value, + _ => (byte)(value * 255 / ((1 << depth) - 1)), + }; +} diff --git a/MarkupString.Ansi/Decoding/SixelDecoder.cs b/MarkupString.Ansi/Decoding/SixelDecoder.cs new file mode 100644 index 0000000..6b7c7df --- /dev/null +++ b/MarkupString.Ansi/Decoding/SixelDecoder.cs @@ -0,0 +1,246 @@ +namespace MarkupString.Ansi.Decoding; + +/// Decodes sixel data (the part of DCS … q … ST after the q) to RGBA. +internal static class SixelDecoder +{ + // The VT340's sixteen colours, in percent. + private static readonly (int R, int G, int B)[] Vt340 = + [ + (0, 0, 0), (20, 20, 80), (80, 13, 13), (20, 80, 20), (80, 20, 80), (20, 80, 80), (80, 80, 20), (53, 53, 53), + (26, 26, 26), (33, 33, 60), (60, 26, 26), (33, 60, 33), (60, 33, 60), (33, 60, 60), (60, 60, 33), (80, 80, 80), + ]; + + /// Decodes . + /// The sixel data. + /// Whether pixels no sixel sets are transparent (P2 = 1) rather than colour 0. + /// The largest picture taken. + /// Its width. + /// Its height. + /// Its pixels. + public static bool TryDecode(ReadOnlySpan data, bool transparent, long maxPixels, out int width, out int height, out byte[] rgba) + { + width = height = 0; + rgba = []; + + var palette = new uint[256]; + for (var i = 0; i < palette.Length; i++) + { + var (r, g, b) = Vt340[i % Vt340.Length]; + palette[i] = Pack(Percent(r), Percent(g), Percent(b)); + } + + var canvas = new Canvas(maxPixels); + var color = palette[0]; + int x = 0, band = 0; + var i0 = 0; + + while (i0 < data.Length) + { + var c = data[i0++]; + switch (c) + { + case '"': + { + // Raster attributes: aspect numerator and denominator, then width and height. + var values = Numbers(data, ref i0, 4); + if (values.Count >= 4 && values[2] > 0 && values[3] > 0) + { + if (!canvas.Reserve(values[2], values[3])) return false; + } + + break; + } + case '#': + { + var values = Numbers(data, ref i0, 5); + if (values.Count == 0) break; + var register = Math.Clamp(values[0], 0, 255); + if (values.Count >= 5) + { + palette[register] = values[1] == 1 + ? Hls(values[2], values[3], values[4]) + : Pack(Percent(values[2]), Percent(values[3]), Percent(values[4])); + } + + color = palette[register]; + break; + } + case '!': + { + var values = Numbers(data, ref i0, 1); + var count = values.Count > 0 ? Math.Max(1, values[0]) : 1; + if (i0 < data.Length && data[i0] is >= '?' and <= '~') + { + if (!canvas.Put(x, band, data[i0] - '?', count, color)) return false; + x += count; + i0++; + } + + break; + } + case '$': + x = 0; + break; + case '-': + x = 0; + band++; + break; + case >= '?' and <= '~': + if (!canvas.Put(x, band, c - '?', 1, color)) return false; + x++; + break; + } + } + + if (canvas.Width == 0 || canvas.Height == 0) return false; + width = canvas.Width; + height = canvas.Height; + rgba = canvas.ToRgba(transparent ? 0u : palette[0] | 0xFF000000u); + return true; + } + + private static List Numbers(ReadOnlySpan data, ref int i, int max) + { + var values = new List(max); + var value = 0; + var any = false; + while (i < data.Length) + { + var c = data[i]; + if (c is >= '0' and <= '9') + { + value = Math.Min(value * 10 + (c - '0'), 1 << 20); + any = true; + } + else if (c == ';') + { + values.Add(value); + value = 0; + any = false; + } + else + { + break; + } + + i++; + } + + if (any || values.Count > 0) values.Add(value); + return values; + } + + private static byte Percent(int value) => (byte)Math.Round(Math.Clamp(value, 0, 100) * 255 / 100.0); + + private static uint Pack(byte r, byte g, byte b) => 0xFF000000u | ((uint)b << 16) | ((uint)g << 8) | r; + + /// A sixel HLS colour: hue in degrees with blue at 0, lightness and saturation in percent. + private static uint Hls(int hue, int lightness, int saturation) + { + var h = ((hue + 240) % 360) / 360.0; + double l = Math.Clamp(lightness, 0, 100) / 100.0, s = Math.Clamp(saturation, 0, 100) / 100.0; + if (s == 0) return Pack((byte)(l * 255), (byte)(l * 255), (byte)(l * 255)); + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + return Pack(Channel(p, q, h + 1 / 3.0), Channel(p, q, h), Channel(p, q, h - 1 / 3.0)); + } + + private static byte Channel(double p, double q, double t) + { + if (t < 0) t += 1; + if (t > 1) t -= 1; + var v = t < 1 / 6.0 ? p + (q - p) * 6 * t : t < 0.5 ? q : t < 2 / 3.0 ? p + (q - p) * (2 / 3.0 - t) * 6 : p; + return (byte)Math.Round(v * 255); + } + + private sealed class Canvas(long maxPixels) + { + private uint[] _pixels = []; + private int _stride; + private int _rows; + + public int Width { get; private set; } + + public int Height { get; private set; } + + public bool Reserve(int width, int height) + { + if (!Grow(width, height)) return false; + Width = Math.Max(Width, width); + Height = Math.Max(Height, height); + return true; + } + + public bool Put(int x, int band, int bits, int count, uint color) + { + if (bits == 0) + { + // Nothing drawn, but the picture is at least this wide. + return Extend(x + count, band * 6 + 1); + } + + var top = band * 6; + if (!Extend(x + count, top + 6)) return false; + for (var bit = 0; bit < 6; bit++) + { + if ((bits & (1 << bit)) == 0) continue; + var y = top + bit; + Height = Math.Max(Height, y + 1); + _pixels.AsSpan(y * _stride + x, count).Fill(color); + } + + return true; + } + + public byte[] ToRgba(uint background) + { + var rgba = new byte[(long)Width * Height * 4]; + var output = System.Runtime.InteropServices.MemoryMarshal.Cast(rgba.AsSpan()); + for (var y = 0; y < Height; y++) + { + for (var x = 0; x < Width; x++) + { + var pixel = _pixels[y * _stride + x]; + output[y * Width + x] = pixel == 0 ? background : pixel; + } + } + + if (!BitConverter.IsLittleEndian) + { + for (var i = 0; i < output.Length; i++) output[i] = System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(output[i]); + } + + return rgba; + } + + private bool Extend(int width, int height) + { + if (!Grow(width, height)) return false; + Width = Math.Max(Width, width); + return true; + } + + private bool Grow(int width, int height) + { + if ((long)width * height > maxPixels) return false; + if (width <= _stride && height <= _rows) return true; + + var stride = Math.Max(_stride, width); + var rows = Math.Max(_rows, height); + if (width > _stride) stride = (int)Math.Min(Math.Max(stride, _stride * 2L), Math.Max(width, maxPixels / Math.Max(1, rows))); + if (height > _rows) rows = (int)Math.Min(Math.Max(rows, _rows * 2L), Math.Max(height, maxPixels / Math.Max(1, stride))); + if ((long)stride * rows > maxPixels) + { + stride = width > _stride ? width : _stride; + rows = height > _rows ? height : _rows; + } + + var pixels = new uint[(long)stride * rows]; + for (var y = 0; y < _rows; y++) _pixels.AsSpan(y * _stride, _stride).CopyTo(pixels.AsSpan(y * stride)); + _pixels = pixels; + _stride = stride; + _rows = rows; + return true; + } + } +} diff --git a/MarkupString.Ansi/Decoding/VtPlugins.cs b/MarkupString.Ansi/Decoding/VtPlugins.cs new file mode 100644 index 0000000..63196a4 --- /dev/null +++ b/MarkupString.Ansi/Decoding/VtPlugins.cs @@ -0,0 +1,669 @@ +using System.Globalization; +using System.Text; +using MarkupString.Decoding; +using MarkupString.Display; +namespace MarkupString.Ansi.Decoding; + +/// Registers the terminal plugins on a . +public static class VtDecoding +{ + /// + /// The registry with SGR, the C0 controls and line editing, character sets, OSC (links, titles, palette, + /// clipboard) and the answers to terminal queries. + /// + public static DecoderRegistry WithVt(this DecoderRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + return registry + .With(SgrPlugin.Instance) + .With(ControlsPlugin.Instance) + .With(CharsetPlugin.Instance) + .With(OscPlugin.Instance) + .With(AnswersPlugin.Instance); + } + + /// A registry with nothing but the terminal plugins. + public static DecoderRegistry Terminal { get; } = DecoderRegistry.Empty.WithVt(); +} + +/// The pen slots the terminal plugins write under, for a plugin that wants to read or replace them. +public static class VtPens +{ + /// The SGR style, an . + public const string Style = "sgr"; + + /// The OSC 8 or MSLP link, an with only a link. + public const string Link = "link"; + + /// The OSC 8 link's id, a , set and cleared with . + public const string LinkGroup = "link-group"; +} + +/// SGR (CSI … m): colours and attributes, colon subparameters included. +public sealed class SgrPlugin : IDecoderPlugin +{ + /// The one instance. + public static SgrPlugin Instance { get; } = new(); + + private SgrPlugin() + { + } + + /// + public string Name => "sgr"; + + /// + public void Register(DecoderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.OnCsi('m', static (in CsiSequence sequence, DecodeContext context) => + { + var state = context.State(); + var before = state.Style; + state.Style = Apply(state.Style, sequence); + if (before.Underlined && !state.Style.Underlined && state.MslpUnderlined) + { + // The underline is an MSLP link's extent. + context.SetPen(VtPens.Link, null); + state.MslpUnderlined = false; + } + else if (!before.Underlined && state.Style.Underlined && context.Pen(VtPens.Link) is AnsiMarkup { Style.LinkKind: LinkKind.Command }) + { + state.MslpUnderlined = true; + } + + context.SetPen(VtPens.Style, state.Style.IsNone ? null : new AnsiMarkup(state.Style)); + return true; + }); + } + + /// with the parameters of an SGR sequence applied. + public static AnsiStyle Apply(AnsiStyle style, in CsiSequence sequence) + { + if (sequence.Count == 0) return AnsiStyle.None; + + for (var i = 0; i < sequence.Count; i++) + { + var code = sequence.Get(i); + var colon = i + 1 < sequence.Count && sequence.IsSubparameter(i + 1); + switch (code) + { + case 0: style = AnsiStyle.None; break; + case 1: style = style with { Bold = true }; break; + case 2: style = style with { Faint = true }; break; + case 3: style = style with { Italic = true }; break; + case 4: + // 4:0 is no underline; 4:1 to 4:5 are kinds of underline, all drawn as one. + style = style with { Underlined = !colon || sequence.Get(i + 1) != 0 }; + break; + case 5 or 6: style = style with { Blink = true }; break; + case 7: style = style with { Inverted = true }; break; + case 8: style = style with { Concealed = true }; break; + case 9: style = style with { StrikeThrough = true }; break; + case 21: style = style with { Underlined = true }; break; + case 22: style = style with { Bold = false, Faint = false }; break; + case 23: style = style with { Italic = false }; break; + case 24: style = style with { Underlined = false }; break; + case 25: style = style with { Blink = false }; break; + case 27: style = style with { Inverted = false }; break; + case 28: style = style with { Concealed = false }; break; + case 29: style = style with { StrikeThrough = false }; break; + case >= 30 and <= 37: style = style with { Foreground = AnsiColor.StandardOf((byte)(code - 30), false) }; break; + case 38: style = style with { Foreground = Extended(sequence, ref i) ?? style.Foreground }; break; + case 39: style = style with { Foreground = null }; break; + case >= 40 and <= 47: style = style with { Background = AnsiColor.StandardOf((byte)(code - 40), false) }; break; + case 48: style = style with { Background = Extended(sequence, ref i) ?? style.Background }; break; + case 49: style = style with { Background = null }; break; + case 53: style = style with { Overlined = true }; break; + case 55: style = style with { Overlined = false }; break; + case 58: Extended(sequence, ref i); break; + case >= 90 and <= 97: style = style with { Foreground = AnsiColor.StandardOf((byte)(code - 90), true) }; break; + case >= 100 and <= 107: style = style with { Background = AnsiColor.StandardOf((byte)(code - 100), true) }; break; + } + + // Subparameters belong to the code before them; whatever this code did not read is skipped. + while (i + 1 < sequence.Count && sequence.IsSubparameter(i + 1)) i++; + } + + return style; + } + + /// Reads 38;5;n, 38;2;r;g;b or their colon forms, leaving on the last parameter read. + private static AnsiColor? Extended(in CsiSequence sequence, ref int i) + { + if (i + 1 >= sequence.Count) return null; + var colon = sequence.IsSubparameter(i + 1); + var kind = sequence.Get(i + 1); + if (kind == 5) + { + if (i + 2 >= sequence.Count) return null; + var index = sequence.Get(i + 2); + i += 2; + return index is >= 0 and <= 255 ? AnsiColor.XtermOf((byte)index) : null; + } + + if (kind != 2) return null; + + var first = i + 2; + if (colon) + { + // 38:2::r:g:b, or 38:2:r:g:b from writers that leave the colour space out. + var count = 0; + while (first + count < sequence.Count && sequence.IsSubparameter(first + count)) count++; + if (count >= 4) first++; + else if (count < 3) return null; + } + else if (first + 2 >= sequence.Count) + { + return null; + } + + var r = sequence.Get(first); + var g = sequence.Get(first + 1); + var b = sequence.Get(first + 2); + i = first + 2; + return r is >= 0 and <= 255 && g is >= 0 and <= 255 && b is >= 0 and <= 255 + ? new AnsiColor.Rgb((byte)r, (byte)g, (byte)b) + : null; + } +} + +/// +/// The C0 controls and line editing: bell, carriage return, backspace, tab, form feed, vertical tab, CSI K, +/// CSI J, CSI G, CSI C, CSI D, and saving and restoring the cursor's column +/// (ESC 7, ESC 8). Cursor up (CSI A), index and reverse index (ESC D, ESC M) +/// are taken and ignored. +/// +public sealed class ControlsPlugin : IDecoderPlugin +{ + /// Columns between tab stops. + public const int TabStop = 8; + + /// The one instance. + public static ControlsPlugin Instance { get; } = new(); + + private ControlsPlugin() + { + } + + /// + public string Name => "controls"; + + /// + public void Register(DecoderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder + .OnControl('\a', static (_, context) => + { + context.Point(BellMarkup.Instance); + return true; + }) + .OnControl('\r', static (_, context) => + { + context.CarriageReturn(); + return true; + }) + .OnControl('\b', static (_, context) => + { + context.Backspace(); + return true; + }) + .OnControl('\t', static (_, context) => + { + context.MoveToColumn((context.Column / TabStop + 1) * TabStop); + return true; + }) + .OnControl('\f', static (_, context) => + { + context.Point(ClearScreenMarkup.Instance); + return true; + }) + .OnControl('\v', static (_, context) => + { + context.EndLine(); + return true; + }) + .OnCsi('K', static (in CsiSequence sequence, DecodeContext context) => + { + switch (sequence.Get(0)) + { + case 0: context.EraseToEndOfLine(); break; + case 1: context.EraseToStartOfLine(); break; + case 2: context.EraseLine(); break; + default: return false; + } + + return true; + }) + .OnCsi('J', static (in CsiSequence sequence, DecodeContext context) => + { + // A scrolling pane has no screen below the cursor to erase; only a whole clear means anything. + if (sequence.Get(0) is 2 or 3) context.Point(ClearScreenMarkup.Instance); + return true; + }) + .OnCsi('G', static (in CsiSequence sequence, DecodeContext context) => + { + context.MoveToColumn(sequence.Get(0, 1) - 1); + return true; + }) + .OnCsi('`', static (in CsiSequence sequence, DecodeContext context) => + { + context.MoveToColumn(sequence.Get(0, 1) - 1); + return true; + }) + .OnCsi('C', static (in CsiSequence sequence, DecodeContext context) => + { + context.MoveToColumn(context.Column + Math.Max(1, sequence.Get(0, 1))); + return true; + }) + .OnCsi('D', static (in CsiSequence sequence, DecodeContext context) => + { + context.Backspace(Math.Max(1, sequence.Get(0, 1))); + return true; + }) + // A scrolling pane cannot go back up a line, nor down one without ending it: cursor up, index and + // reverse index are taken and do nothing. A picture drawn over the cells after them is placed by the + // pictures plugin instead. + .OnCsi('A', static (in CsiSequence _, DecodeContext _) => true) + .OnEsc('D', static (in EscSequence _, DecodeContext _) => true) + .OnEsc('M', static (in EscSequence _, DecodeContext _) => true) + .OnEsc('7', static (in EscSequence _, DecodeContext context) => + { + context.State().SavedColumn = context.Column; + return true; + }) + .OnEsc('8', static (in EscSequence _, DecodeContext context) => + { + var state = context.State(); + if (state.SavedColumn is { } column) context.MoveToColumn(column); + state.SavedColumn = null; + return true; + }); + } +} + +/// The DEC special graphics set (ESC ( 0), and shifting between G0 and G1 with SO and SI. +public sealed class CharsetPlugin : IDecoderPlugin +{ + // 0x5F to 0x7E in the DEC special graphics set. + private const string Graphics = " ◆▒␉␌␍␊°±␤␋┘┐┌└┼⎺⎻─⎼⎽├┤┴┬│≤≥π≠£·"; + + /// The one instance. + public static CharsetPlugin Instance { get; } = new(); + + private CharsetPlugin() + { + } + + /// + public string Name => "charsets"; + + /// + public void Register(DecoderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder + .OnEsc('0', static (in EscSequence sequence, DecodeContext context) => Designate(sequence, context, true), "(") + .OnEsc('B', static (in EscSequence sequence, DecodeContext context) => Designate(sequence, context, false), "(") + .OnEsc('0', static (in EscSequence sequence, DecodeContext context) => Designate(sequence, context, true), ")") + .OnEsc('B', static (in EscSequence sequence, DecodeContext context) => Designate(sequence, context, false), ")") + .OnControl('\u000e', static (_, context) => + { + context.State().ShiftedOut = true; + return true; + }) + .OnControl('\u000f', static (_, context) => + { + context.State().ShiftedOut = false; + return true; + }) + .OnPrint(static (text, context) => + { + var state = context.State(); + if (!(state.ShiftedOut ? state.G1Graphics : state.G0Graphics)) return false; + + Span mapped = text.Length <= 256 ? stackalloc char[text.Length] : new char[text.Length]; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + mapped[i] = c is >= '_' and <= '~' ? Graphics[c - 0x5f] : c; + } + + context.Write(mapped); + return true; + }); + } + + private static bool Designate(in EscSequence sequence, DecodeContext context, bool graphics) + { + var state = context.State(); + if (sequence.Intermediates[0] == '(') state.G0Graphics = graphics; + else state.G1Graphics = graphics; + return true; + } +} + +/// +/// Operating system commands: OSC 8 links, MSLP command links (OSC 68), titles (0, 1, 2), palette changes (4, 10, +/// 11, 12, 104, 110, 111, 112) and the clipboard (52). +/// +public sealed class OscPlugin : IDecoderPlugin +{ + /// The longest OSC 8 address taken, as kitty and VTE cap it. + public const int MaxUriLength = 2083; + + /// The id among OSC 8's params (key=value pairs split by :), or null. + private static string? LinkId(ReadOnlySpan parameters) + { + foreach (var range in parameters.Split(':')) + { + var pair = parameters[range]; + if (pair.StartsWith("id=", StringComparison.Ordinal) && pair.Length > 3 && pair.Length <= 256) return pair[3..].ToString(); + } + + return null; + } + + /// The one instance. + public static OscPlugin Instance { get; } = new(); + + private OscPlugin() + { + } + + /// + public string Name => "osc"; + + /// + public void Register(DecoderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder + .OnOsc(8, static (payload, context) => + { + // 8 ; params ; uri — an empty uri ends the link. + var separator = payload.IndexOf(';'); + if (separator < 0) return false; + var uri = payload[(separator + 1)..]; + if (uri.IsEmpty || uri.Length > MaxUriLength) + { + context.SetPen(VtPens.Link, null); + context.SetPen(VtPens.LinkGroup, null); + return true; + } + + var target = uri.ToString(); + var safe = context.Options.Links.IsAllowed(target); + context.SetPen(VtPens.Link, safe ? new AnsiMarkup(new AnsiStyle { LinkUrl = target, LinkKind = LinkKind.Url }) : null); + var id = safe ? LinkId(payload[..separator]) : null; + context.SetPen(VtPens.LinkGroup, id is null ? null : new LinkGroupMarkup(id)); + return true; + }) + .OnOsc(68, static (payload, context) => + { + // 68 ; 1 ; SEND ; command, before the underlined text that is the link. + var parts = payload.ToString().Split(';', 3); + if (parts is not ["1", var type, var command] || command.Length == 0) return false; + if (!type.Equals("SEND", StringComparison.OrdinalIgnoreCase)) return false; + context.SetPen(VtPens.Link, new AnsiMarkup(new AnsiStyle { LinkUrl = command, LinkKind = LinkKind.Command })); + context.SetPen(VtPens.LinkGroup, null); + context.State().MslpUnderlined = false; + return true; + }) + .OnLineEnd(static context => + { + // An MSLP link is on one line; an OSC 8 link lasts until it is ended. + if (context.Pen(VtPens.Link) is AnsiMarkup { Style.LinkKind: LinkKind.Command }) + { + context.SetPen(VtPens.Link, null); + context.State().MslpUnderlined = false; + } + }) + .OnOsc(0, static (payload, context) => Title(payload, context, icon: true)) + .OnOsc(1, static (payload, context) => Title(payload, context, icon: true)) + .OnOsc(2, static (payload, context) => Title(payload, context, icon: false)) + .OnOsc(4, static (payload, context) => + { + var parts = payload.ToString().Split(';'); + for (var i = 0; i + 1 < parts.Length; i += 2) + { + if (!int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out var index) || index > 255) continue; + if (ParseColor(parts[i + 1]) is { } color) context.Raise(new PaletteEvent(PaletteSlot.Index, index, color)); + } + + return true; + }) + .OnOsc(104, static (payload, context) => + { + if (payload.IsEmpty) + { + context.Raise(new PaletteEvent(PaletteSlot.Index, -1, null)); + return true; + } + + foreach (var part in payload.ToString().Split(';')) + { + if (int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out var index) && index <= 255) + { + context.Raise(new PaletteEvent(PaletteSlot.Index, index, null)); + } + } + + return true; + }) + .OnOsc(10, static (payload, context) => Dynamic(payload, context, 10)) + .OnOsc(11, static (payload, context) => Dynamic(payload, context, 11)) + .OnOsc(12, static (payload, context) => Dynamic(payload, context, 12)) + .OnOsc(110, static (_, context) => Reset(context, PaletteSlot.Foreground)) + .OnOsc(111, static (_, context) => Reset(context, PaletteSlot.Background)) + .OnOsc(112, static (_, context) => Reset(context, PaletteSlot.Cursor)) + .OnOsc(52, static (payload, context) => + { + var separator = payload.IndexOf(';'); + if (separator < 0) return false; + var selection = payload[..separator].ToString(); + var data = payload[(separator + 1)..]; + if (data is "?") + { + context.Raise(new ClipboardEvent(selection, null)); + return true; + } + + var bytes = new byte[data.Length / 4 * 3 + 3]; + if (!Convert.TryFromBase64Chars(data, bytes, out var written)) return true; + context.Raise(new ClipboardEvent(selection, Encoding.UTF8.GetString(bytes, 0, written))); + return true; + }); + } + + /// + /// An X11 colour specification as OSC 4, 10 and 11 take it: rgb:r/g/b with one to four hex digits a + /// channel, or #rgb, #rrggbb, #rrrgggbbb, #rrrrggggbbbb. Null when it is none of those. + /// + public static RgbColor? ParseColor(ReadOnlySpan spec) + { + if (spec.StartsWith("rgb:", StringComparison.OrdinalIgnoreCase)) + { + var rest = spec[4..]; + Span channels = stackalloc Range[4]; + if (rest.Split(channels, '/') != 3) return null; + var r = Channel(rest[channels[0]]); + var g = Channel(rest[channels[1]]); + var b = Channel(rest[channels[2]]); + return r is { } rr && g is { } gg && b is { } bb ? new RgbColor(rr, gg, bb) : null; + } + + if (spec.Length > 1 && spec[0] == '#' && (spec.Length - 1) % 3 == 0 && spec.Length <= 13) + { + var size = (spec.Length - 1) / 3; + var r = Channel(spec.Slice(1, size)); + var g = Channel(spec.Slice(1 + size, size)); + var b = Channel(spec.Slice(1 + size * 2, size)); + return r is { } rr && g is { } gg && b is { } bb ? new RgbColor(rr, gg, bb) : null; + } + + return null; + } + + /// as an X11 rgb:rrrr/gggg/bbbb specification, as xterm answers. + public static string FormatColor(RgbColor color) => + string.Create(CultureInfo.InvariantCulture, $"rgb:{color.R:x2}{color.R:x2}/{color.G:x2}{color.G:x2}/{color.B:x2}{color.B:x2}"); + + private static byte? Channel(ReadOnlySpan hex) + { + if (hex.Length is < 1 or > 4) return null; + if (!int.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value)) return null; + var max = (1 << (hex.Length * 4)) - 1; + return (byte)Math.Round(value * 255.0 / max); + } + + private static bool Title(ReadOnlySpan payload, DecodeContext context, bool icon) + { + var title = new StringBuilder(payload.Length); + foreach (var c in payload) + { + if (!char.IsControl(c)) title.Append(c); + } + + context.Raise(new TitleEvent(title.ToString(), icon)); + return true; + } + + private static bool Dynamic(ReadOnlySpan payload, DecodeContext context, int command) + { + // OSC 10 ; a ; b sets 10 to a and 11 to b, and so on. + var parts = payload.ToString().Split(';'); + for (var i = 0; i < parts.Length; i++) + { + var slot = (command + i) switch + { + 10 => PaletteSlot.Foreground, + 11 => PaletteSlot.Background, + 12 => PaletteSlot.Cursor, + _ => (PaletteSlot?)null, + }; + if (slot is not { } which) break; + + if (parts[i] == "?") + { + AnswersPlugin.AnswerColor(context, which); + } + else if (ParseColor(parts[i]) is { } color) + { + context.Raise(new PaletteEvent(which, 0, color)); + } + } + + return true; + } + + private static bool Reset(DecodeContext context, PaletteSlot slot) + { + context.Raise(new PaletteEvent(slot, 0, null)); + return true; + } +} + +/// +/// Answers what a server may ask a terminal, from the decoder's : device attributes, +/// XTVERSION, the pane's size, the cursor's position and the default colours. Each is answered only when +/// allows it. +/// +public sealed class AnswersPlugin : IDecoderPlugin +{ + /// The one instance. + public static AnswersPlugin Instance { get; } = new(); + + private AnswersPlugin() + { + } + + /// + public string Name => "answers"; + + /// + public void Register(DecoderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder + .OnCsi('c', static (in CsiSequence sequence, DecodeContext context) => + { + if (sequence.Get(0) != 0) return false; + if (!Allowed(context, ClientAnswers.Identity)) return true; + // A VT220 with colour, and sixel when the client draws it. + var sixel = (context.Identity.Output.Pictures & PictureMethods.Sixel) != 0; + context.Reply(sixel ? "\e[?62;4;22c" : "\e[?62;22c"); + return true; + }) + .OnCsi('q', static (in CsiSequence sequence, DecodeContext context) => + { + if (sequence.Get(0) != 0) return false; + if (Allowed(context, ClientAnswers.Identity)) context.Reply($"\eP>|{context.Identity.Name} {context.Identity.Version}\e\\"); + return true; + }, prefix: '>') + .OnCsi('t', static (in CsiSequence sequence, DecodeContext context) => + { + var identity = context.Identity; + var reply = sequence.Get(0) switch + { + 14 => Format($"\e[4;{identity.Rows * identity.CellHeight};{identity.Columns * identity.CellWidth}t"), + 16 => Format($"\e[6;{identity.CellHeight};{identity.CellWidth}t"), + 18 => Format($"\e[8;{identity.Rows};{identity.Columns}t"), + _ => null, + }; + if (reply is null) return false; + if (Allowed(context, ClientAnswers.Size)) context.Reply(reply); + return true; + }) + .OnCsi('n', static (in CsiSequence sequence, DecodeContext context) => + { + switch (sequence.Get(0)) + { + case 5: + if (Allowed(context, ClientAnswers.Identity)) context.Reply("\e[0n"); + return true; + case 6: + // The row is always the last: a scrolling pane writes at its bottom. + if (Allowed(context, ClientAnswers.CursorPosition)) context.Reply(Format($"\e[{context.Identity.Rows};{context.Column + 1}R")); + return true; + default: + return false; + } + }); + } + + internal static void AnswerColor(DecodeContext context, PaletteSlot slot) + { + if (!Allowed(context, ClientAnswers.Colors)) return; + var color = slot switch + { + PaletteSlot.Foreground => context.Identity.Foreground, + PaletteSlot.Background => context.Identity.Background, + _ => null, + }; + if (color is not { } known) return; + var number = slot == PaletteSlot.Foreground ? 10 : 11; + context.Reply(Format($"\e]{number};{OscPlugin.FormatColor(known)}\e\\")); + } + + private static bool Allowed(DecodeContext context, ClientAnswers answer) => (context.Identity.Answers & answer) != 0; + + private static string Format(FormattableString text) => text.ToString(CultureInfo.InvariantCulture); +} + +/// The terminal plugins' state for one decoder. +internal sealed class VtState +{ + public AnsiStyle Style { get; set; } + + public bool MslpUnderlined { get; set; } + + public bool G0Graphics { get; set; } + + public bool G1Graphics { get; set; } + + public bool ShiftedOut { get; set; } + + public int? SavedColumn { get; set; } +} diff --git a/MarkupString.Ansi/Emitters/AnsiBBCodeEmitter.cs b/MarkupString.Ansi/Emitters/AnsiBBCodeEmitter.cs index 56b69f1..870211c 100644 --- a/MarkupString.Ansi/Emitters/AnsiBBCodeEmitter.cs +++ b/MarkupString.Ansi/Emitters/AnsiBBCodeEmitter.cs @@ -31,7 +31,7 @@ private static void WriteTags(in AnsiStyle style, ReadOnlySpan body, in Em var link = style.LinkKind == LinkKind.Url && style.LinkUrl is { Length: > 0 } candidate - && UrlSafety.IsSafeNavigableUrl(candidate) + && context.Registry.Links.IsAllowed(candidate) ? candidate : null; diff --git a/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs b/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs index fa1354f..aa4f515 100644 --- a/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs +++ b/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs @@ -197,10 +197,10 @@ internal static void WriteWrapped( /// Writes the body as the link the style carries, in whichever form allows: a /// navigable URL inside an OSC 8 hyperlink, a command as an MSLP link (ESC ] 68 ; 1 ; SEND ; command BEL /// before the text, which MSLP delimits with underline). Anything else — no link, a form the client does not - /// read, a URL with a scheme rejects, a command holding a control + /// read, a URL the registry's rejects, a command holding a control /// character that would end the sequence early — is written as plain text. /// - internal static void WriteLinked(in AnsiStyle style, ReadOnlySpan body, TerminalFeatures features, IBufferWriter output) + internal static void WriteLinked(in AnsiStyle style, ReadOnlySpan body, TerminalFeatures features, IUrlPolicy links, IBufferWriter output) { if (style.LinkUrl is not { Length: > 0 } target) { @@ -208,7 +208,7 @@ internal static void WriteLinked(in AnsiStyle style, ReadOnlySpan body, Te return; } - if (style.LinkKind == LinkKind.Url && (features & TerminalFeatures.Hyperlinks) != 0 && UrlSafety.IsSafeNavigableUrl(target)) + if (style.LinkKind == LinkKind.Url && (features & TerminalFeatures.Hyperlinks) != 0 && links.IsAllowed(target)) { output.Write(Osc8); output.Write(target); @@ -265,7 +265,7 @@ internal static void EmitTagged( { var shown = style.AtDepth(colorDepth); SgrWriter.Transition(AnsiStyle.None, shown, segmentOutput); - WriteTaggedLink(shown, segment, segmentOutput, flavour); + WriteTaggedLink(shown, segment, segmentOutput, flavour, segmentContext.Registry.Links); if (LeavesState(shown)) SgrWriter.Reset(segmentOutput); }); } @@ -278,13 +278,14 @@ internal static void EmitTagged( internal static bool LeavesState(in AnsiStyle style) => style.Foreground is not null || style.Background is not null || style.Bold || style.Faint || style.Italic || style.Underlined - || style.Overlined || style.Blink || style.Inverted || style.StrikeThrough; + || style.Overlined || style.Blink || style.Inverted || style.StrikeThrough || style.Concealed; private static void WriteTaggedLink( in AnsiStyle style, ReadOnlySpan body, IBufferWriter output, - TagFlavour flavour) + TagFlavour flavour, + IUrlPolicy links) { if (style.LinkUrl is not { Length: > 0 } url) { @@ -313,7 +314,7 @@ private static void WriteTaggedLink( return; } - if (!UrlSafety.IsSafeNavigableUrl(url)) + if (!links.IsAllowed(url)) { output.Write(body); return; diff --git a/MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs b/MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs index 0b63e2e..d193120 100644 --- a/MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs +++ b/MarkupString.Ansi/Emitters/AnsiHtmlEmitter.cs @@ -45,7 +45,7 @@ private static void WriteSpan(in AnsiStyle style, ReadOnlySpan body, in Em if (!hasStyle && !hasClasses) { - WriteAnchored(style, body, output); + WriteAnchored(style, body, context.Registry.Links, output); return; } @@ -76,7 +76,7 @@ private static void WriteSpan(in AnsiStyle style, ReadOnlySpan body, in Em } output.Write(">"); - WriteAnchored(style, body, output); + WriteAnchored(style, body, context.Registry.Links, output); output.Write(""); } @@ -114,7 +114,7 @@ private static void WriteColor(int slot, string hex, bool background, IBufferWri /// private static bool HasClasses(in AnsiStyle style, bool hasColourStyle) => style.Bold || style.Faint || style.Italic || style.Underlined - || style.StrikeThrough || style.Overlined || style.Blink + || style.StrikeThrough || style.Overlined || style.Blink || style.Concealed || (style.Inverted && !hasColourStyle); private static void WriteClasses(in AnsiStyle style, bool hasColourStyle, IBufferWriter output) @@ -127,6 +127,7 @@ private static void WriteClasses(in AnsiStyle style, bool hasColourStyle, IBuffe AppendClass(style.StrikeThrough, "ms-strike", ref first, output); AppendClass(style.Overlined, "ms-overline", ref first, output); AppendClass(style.Blink, "ms-blink", ref first, output); + AppendClass(style.Concealed, "ms-conceal", ref first, output); AppendClass(style.Inverted && !hasColourStyle, "ms-invert", ref first, output); } @@ -143,7 +144,7 @@ private static void AppendClass(bool on, string name, ref bool first, IBufferWri /// xch_cmd — so it carries role and tabindex to stay keyboard-reachable. A /// URL link with a scheme rejects falls back to plain text. /// - private static void WriteAnchored(in AnsiStyle style, ReadOnlySpan body, IBufferWriter output) + private static void WriteAnchored(in AnsiStyle style, ReadOnlySpan body, IUrlPolicy links, IBufferWriter output) { if (style.LinkUrl is not { Length: > 0 } url) { @@ -157,7 +158,7 @@ private static void WriteAnchored(in AnsiStyle style, ReadOnlySpan body, I output.Write(WebUtility.HtmlEncode(url)); output.Write("\""); } - else if (UrlSafety.IsSafeNavigableUrl(url)) + else if (links.IsAllowed(url)) { output.Write(" body, in EmitContext conte return true; } - var own = Delimiters(AnsiEmitterSupport.Fold(set, context.Format)); + var own = Delimiters(AnsiEmitterSupport.Fold(set, context.Format), context.Registry.Links); WriteDelimited(own, Shared(own, context.Previous, context), Shared(own, context.Next, context), body, output); return true; } @@ -51,12 +51,12 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan body, in EmitContext conte /// The delimiters is written with, outermost first, always in the same order — /// a link, then strikethrough, bold and italic — so two runs that share some of them share the outermost. /// - private static List Delimiters(in AnsiStyle style) + private static List Delimiters(in AnsiStyle style, IUrlPolicy links) { var delimiters = new List(4); if (style.LinkKind == LinkKind.Url && style.LinkUrl is { Length: > 0 } url - && UrlSafety.IsSafeNavigableUrl(url)) + && links.IsAllowed(url)) delimiters.Add(new Delimiter("[", "](" + MarkdownSyntax.Destination(url) + ")")); if (style.StrikeThrough) delimiters.Add(Strike); if (style.Bold) delimiters.Add(Strong); @@ -80,14 +80,14 @@ private static bool StandsAlone(MarkupSet markups, in EmitContext context) private static int Shared(List own, MarkupSet? neighbour, in EmitContext context) { if (own.Count == 0 || neighbour is null || StandsAlone(neighbour, context)) return 0; - var theirs = Delimiters(AnsiEmitterSupport.Fold(neighbour, context.Format)); + var theirs = Delimiters(AnsiEmitterSupport.Fold(neighbour, context.Format), context.Registry.Links); var shared = 0; while (shared < own.Count && shared < theirs.Count && own[shared] == theirs[shared]) shared++; return shared; } private static void WriteAlone(in AnsiStyle style, ReadOnlySpan body, in EmitContext context, IBufferWriter output) => - WriteDelimited(Delimiters(style), 0, 0, body, output); + WriteDelimited(Delimiters(style, context.Registry.Links), 0, 0, body, output); /// /// Writes inside , the first of diff --git a/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs b/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs index edaf826..6bd9edb 100644 --- a/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs +++ b/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs @@ -61,7 +61,7 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan body, in EmitContext conte } else { - AnsiEmitterSupport.WriteLinked(effective, body, _options.Features, core); + AnsiEmitterSupport.WriteLinked(effective, body, _options.Features, context.Registry.Links, core); } // Nothing follows that would diff this state away, so close it here rather than leaving the diff --git a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs index 19f5795..959f1d0 100644 --- a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs +++ b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs @@ -17,7 +17,7 @@ internal static class TerminalPictureWriter private const string StringTerminator = "\e\\"; /// Kitty's placeholder character. - private const int Placeholder = 0x10EEEE; + internal const int Placeholder = 0x10EEEE; /// Kitty's base64 chunk ceiling. private const int KittyChunk = 4096; @@ -26,7 +26,7 @@ internal static class TerminalPictureWriter /// The diacritics Kitty reads as a row or column number, in order: the first is 0. From kitty's /// gen/rowcolumn-diacritics.txt; a picture larger than this many cells either way is not drawn. /// - private static readonly int[] Diacritics = + internal static readonly int[] Diacritics = [ 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 5e64f6b..9288248 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -3,3 +3,107 @@ MarkupString.Ansi.AnsiMarkdownEmitter MarkupString.Ansi.AnsiMarkdownEmitter.AnsiMarkdownEmitter() -> void MarkupString.Ansi.AnsiMarkdownEmitter.Format.get -> MarkupString.MarkupFormat! MarkupString.Ansi.AnsiMarkdownEmitter.TryEmit(MarkupString.MarkupSet! set, System.ReadOnlySpan body, in MarkupString.EmitContext context, System.Buffers.IBufferWriter! output) -> bool +MarkupString.Ansi.AnsiStyle.Concealed.get -> bool +MarkupString.Ansi.AnsiStyle.Concealed.init -> void +MarkupString.Ansi.Decoding.AnsiColors +MarkupString.Ansi.Decoding.AnsiColors.AnsiColors() -> void +MarkupString.Ansi.Decoding.AnsiDisplay +MarkupString.Ansi.Decoding.AnsiStyles +MarkupString.Ansi.Decoding.AnsiStyles.Contribute(MarkupString.MarkupSet! markups, MarkupString.Display.StyleBuilder! style, MarkupString.Display.DisplayOptions! options) -> void +MarkupString.Ansi.Decoding.AnswersPlugin +MarkupString.Ansi.Decoding.AnswersPlugin.Name.get -> string! +MarkupString.Ansi.Decoding.AnswersPlugin.Register(MarkupString.Decoding.DecoderBuilder! builder) -> void +MarkupString.Ansi.Decoding.CharsetPlugin +MarkupString.Ansi.Decoding.CharsetPlugin.Name.get -> string! +MarkupString.Ansi.Decoding.CharsetPlugin.Register(MarkupString.Decoding.DecoderBuilder! builder) -> void +MarkupString.Ansi.Decoding.ControlsPlugin +MarkupString.Ansi.Decoding.ControlsPlugin.Name.get -> string! +MarkupString.Ansi.Decoding.ControlsPlugin.Register(MarkupString.Decoding.DecoderBuilder! builder) -> void +MarkupString.Ansi.Decoding.OscPlugin +MarkupString.Ansi.Decoding.OscPlugin.Name.get -> string! +MarkupString.Ansi.Decoding.OscPlugin.Register(MarkupString.Decoding.DecoderBuilder! builder) -> void +MarkupString.Ansi.Decoding.SgrPlugin +MarkupString.Ansi.Decoding.SgrPlugin.Name.get -> string! +MarkupString.Ansi.Decoding.SgrPlugin.Register(MarkupString.Decoding.DecoderBuilder! builder) -> void +MarkupString.Ansi.Decoding.VtDecoding +MarkupString.Ansi.Decoding.VtPens +const MarkupString.Ansi.Decoding.ControlsPlugin.TabStop = 8 -> int +const MarkupString.Ansi.Decoding.OscPlugin.MaxUriLength = 2083 -> int +const MarkupString.Ansi.Decoding.VtPens.Link = "link" -> string! +const MarkupString.Ansi.Decoding.VtPens.Style = "sgr" -> string! +static MarkupString.Ansi.Decoding.AnsiColors.Default.get -> MarkupString.Ansi.Decoding.AnsiColors! +static MarkupString.Ansi.Decoding.AnsiDisplay.Default.get -> MarkupString.Display.DisplayRegistry! +static MarkupString.Ansi.Decoding.AnsiDisplay.ToColorDepth(this MarkupString.Display.OutputCapabilities! capabilities) -> MarkupString.Ansi.AnsiColorDepth +static MarkupString.Ansi.Decoding.AnsiDisplay.ToOutputCapabilities(this MarkupString.Ansi.TerminalProfile! profile, MarkupString.Ansi.AnsiColorDepth depth) -> MarkupString.Display.OutputCapabilities! +static MarkupString.Ansi.Decoding.AnsiDisplay.ToTerminalFeatures(this MarkupString.Display.OutputCapabilities! capabilities) -> MarkupString.Ansi.TerminalFeatures +static MarkupString.Ansi.Decoding.AnsiDisplay.WithAnsi(this MarkupString.Display.DisplayRegistry! registry, bool boldIsBright = false) -> MarkupString.Display.DisplayRegistry! +static MarkupString.Ansi.Decoding.AnsiStyles.BoldIsBright.get -> MarkupString.Ansi.Decoding.AnsiStyles! +static MarkupString.Ansi.Decoding.AnsiStyles.Instance.get -> MarkupString.Ansi.Decoding.AnsiStyles! +static MarkupString.Ansi.Decoding.AnswersPlugin.Instance.get -> MarkupString.Ansi.Decoding.AnswersPlugin! +static MarkupString.Ansi.Decoding.CharsetPlugin.Instance.get -> MarkupString.Ansi.Decoding.CharsetPlugin! +static MarkupString.Ansi.Decoding.ControlsPlugin.Instance.get -> MarkupString.Ansi.Decoding.ControlsPlugin! +static MarkupString.Ansi.Decoding.OscPlugin.FormatColor(MarkupString.RgbColor color) -> string! +static MarkupString.Ansi.Decoding.OscPlugin.Instance.get -> MarkupString.Ansi.Decoding.OscPlugin! +static MarkupString.Ansi.Decoding.OscPlugin.ParseColor(System.ReadOnlySpan spec) -> MarkupString.RgbColor? +static MarkupString.Ansi.Decoding.SgrPlugin.Apply(MarkupString.Ansi.AnsiStyle style, in MarkupString.Decoding.CsiSequence sequence) -> MarkupString.Ansi.AnsiStyle +static MarkupString.Ansi.Decoding.SgrPlugin.Instance.get -> MarkupString.Ansi.Decoding.SgrPlugin! +static MarkupString.Ansi.Decoding.VtDecoding.Terminal.get -> MarkupString.Decoding.DecoderRegistry! +static MarkupString.Ansi.Decoding.VtDecoding.WithVt(this MarkupString.Decoding.DecoderRegistry! registry) -> MarkupString.Decoding.DecoderRegistry! +virtual MarkupString.Ansi.Decoding.AnsiColors.Legible(MarkupString.RgbColor foreground, MarkupString.RgbColor background) -> MarkupString.RgbColor +virtual MarkupString.Ansi.Decoding.AnsiColors.Standard(int index, bool bright) -> MarkupString.RgbColor +virtual MarkupString.Ansi.Decoding.AnsiColors.Xterm(int index) -> MarkupString.RgbColor +MarkupString.Ansi.Decoding.PictureDecoding +MarkupString.Ansi.Decoding.PictureStore +MarkupString.Ansi.Decoding.PictureStore.Add(int width, int height, byte[]! rgba) -> MarkupString.Ansi.Decoding.StoredPicture! +MarkupString.Ansi.Decoding.PictureStore.Add(int width, int height, byte[]? rgba, System.ReadOnlyMemory encoded, string? mediaType) -> MarkupString.Ansi.Decoding.StoredPicture! +MarkupString.Ansi.Decoding.PictureStore.Added -> System.Action? +MarkupString.Ansi.Decoding.PictureStore.Count.get -> int +MarkupString.Ansi.Decoding.PictureStore.MarkTransmitted(uint imageId) -> bool +MarkupString.Ansi.Decoding.PictureStore.MaxPixels.get -> long +MarkupString.Ansi.Decoding.PictureStore.MaxPixels.set -> void +MarkupString.Ansi.Decoding.PictureStore.PictureStore() -> void +MarkupString.Ansi.Decoding.PictureStore.Remove(string! key) -> bool +MarkupString.Ansi.Decoding.PictureStore.TryGet(MarkupString.ImageMarkup! image, out MarkupString.Ansi.Decoding.StoredPicture? picture) -> bool +MarkupString.Ansi.Decoding.PictureStore.TryGet(string! key, out MarkupString.Ansi.Decoding.StoredPicture? picture) -> bool +MarkupString.Ansi.Decoding.PictureStore.TryGetPicture(MarkupString.ImageMarkup! image, out MarkupString.Ansi.TerminalPicture? picture) -> bool +MarkupString.Ansi.Decoding.PicturesPlugin +MarkupString.Ansi.Decoding.PicturesPlugin.Name.get -> string! +MarkupString.Ansi.Decoding.PicturesPlugin.Register(MarkupString.Decoding.DecoderBuilder! builder) -> void +MarkupString.Ansi.Decoding.StoredPicture +MarkupString.Ansi.Decoding.StoredPicture.Encoded.get -> System.ReadOnlyMemory +MarkupString.Ansi.Decoding.StoredPicture.Height.get -> int +MarkupString.Ansi.Decoding.StoredPicture.Key.get -> string! +MarkupString.Ansi.Decoding.StoredPicture.Pixels.get -> MarkupString.Ansi.TerminalPicture? +MarkupString.Ansi.Decoding.StoredPicture.Size.get -> long +MarkupString.Ansi.Decoding.StoredPicture.Source.get -> string! +MarkupString.Ansi.Decoding.StoredPicture.ToDataUrl() -> string! +MarkupString.Ansi.Decoding.StoredPicture.Width.get -> int +const MarkupString.Ansi.Decoding.PictureStore.Scheme = "picture:" -> string! +const MarkupString.Ansi.Decoding.PicturesPlugin.MaxTransmission = 16777216 -> int +static MarkupString.Ansi.Decoding.PictureDecoding.Pictures(this MarkupString.Decoding.Decoder! decoder) -> MarkupString.Ansi.Decoding.PictureStore! +static MarkupString.Ansi.Decoding.PictureDecoding.WithPictures(this MarkupString.Decoding.DecoderRegistry! registry) -> MarkupString.Decoding.DecoderRegistry! +static MarkupString.Ansi.Decoding.PicturesPlugin.Instance.get -> MarkupString.Ansi.Decoding.PicturesPlugin! +static MarkupString.Ansi.Decoding.PicturesPlugin.Pictures(MarkupString.Decoding.Decoder! decoder) -> MarkupString.Ansi.Decoding.PictureStore! +MarkupString.Ansi.Decoding.StoredPicture.File.get -> System.ReadOnlyMemory +MarkupString.Ansi.Decoding.StoredPicture.IsMoving.get -> bool +MarkupString.Ansi.Decoding.StoredPicture.MediaType.get -> string! +const MarkupString.Ansi.Decoding.PicturesPlugin.MaxFrames = 1000 -> int +const MarkupString.Ansi.Decoding.VtPens.LinkGroup = "link-group" -> string! +MarkupString.Ansi.Decoding.IPictureCache +MarkupString.Ansi.Decoding.IPictureCache.Remove(string! key) -> bool +MarkupString.Ansi.Decoding.IPictureCache.Set(MarkupString.Ansi.Decoding.StoredPicture! picture) -> void +MarkupString.Ansi.Decoding.IPictureCache.TryGet(string! key, out MarkupString.Ansi.Decoding.StoredPicture? picture) -> bool +MarkupString.Ansi.Decoding.MemoryPictureCache +MarkupString.Ansi.Decoding.MemoryPictureCache.Budget.get -> long +MarkupString.Ansi.Decoding.MemoryPictureCache.Budget.set -> void +MarkupString.Ansi.Decoding.MemoryPictureCache.Count.get -> int +MarkupString.Ansi.Decoding.MemoryPictureCache.MemoryPictureCache() -> void +MarkupString.Ansi.Decoding.MemoryPictureCache.Remove(string! key) -> bool +MarkupString.Ansi.Decoding.MemoryPictureCache.Set(MarkupString.Ansi.Decoding.StoredPicture! picture) -> void +MarkupString.Ansi.Decoding.MemoryPictureCache.TryGet(string! key, out MarkupString.Ansi.Decoding.StoredPicture? picture) -> bool +MarkupString.Ansi.Decoding.MemoryPictureCache.Used.get -> long +MarkupString.Ansi.Decoding.PictureStore.Cache.get -> MarkupString.Ansi.Decoding.IPictureCache! +MarkupString.Ansi.Decoding.PictureStore.PictureStore(MarkupString.Ansi.Decoding.IPictureCache? cache) -> void +MarkupString.Ansi.Decoding.PicturesPlugin.Cache.get -> MarkupString.Ansi.Decoding.IPictureCache? +MarkupString.Ansi.Decoding.PicturesPlugin.PicturesPlugin(MarkupString.Ansi.Decoding.IPictureCache? cache) -> void +static MarkupString.Ansi.Decoding.PictureDecoding.WithPictures(this MarkupString.Decoding.DecoderRegistry! registry, MarkupString.Ansi.Decoding.IPictureCache! cache) -> MarkupString.Decoding.DecoderRegistry! diff --git a/MarkupString.Ansi/README.md b/MarkupString.Ansi/README.md index 2d22523..dd1a19d 100644 --- a/MarkupString.Ansi/README.md +++ b/MarkupString.Ansi/README.md @@ -138,6 +138,13 @@ confirmed: several terminals read Kitty graphics without the Unicode placeholder Kitty support is unannounced, so it is drawn for with its own protocol. Windows Terminal and Alacritty do not answer XTVERSION, so only a player can name them. +## Reading terminal output + +`DecoderRegistry.Empty.WithVt().WithPictures()` reads SGR, the C0 controls, OSC links, titles and +palette, terminal queries, and Kitty, iTerm2 and sixel pictures into `MarkupText` lines. +`DisplayRegistry.Core.WithAnsi()` resolves them for a web page or a cell grid. See +[Decoding](https://github.com/SharpMUSH/MarkupString/blob/main/docs/decoding.md) and [Display](https://github.com/SharpMUSH/MarkupString/blob/main/docs/display.md). + ## Styling the HTML output The HTML-family emitters write `ms-*` classes for the attributes with a fixed rendering (bold, diff --git a/MarkupString.Ansi/SgrWriter.cs b/MarkupString.Ansi/SgrWriter.cs index 9d003b0..adb60ac 100644 --- a/MarkupString.Ansi/SgrWriter.cs +++ b/MarkupString.Ansi/SgrWriter.cs @@ -120,6 +120,7 @@ private static bool Removes(in AnsiStyle from, in AnsiStyle to) => || (from.Blink && !to.Blink) || (from.Inverted && !to.Inverted) || (from.StrikeThrough && !to.StrikeThrough) + || (from.Concealed && !to.Concealed) || (from.Foreground is not null && to.Foreground is null) || (from.Background is not null && to.Background is null); @@ -150,6 +151,7 @@ private static bool WriteDifference(in AnsiStyle from, in AnsiStyle to, IBufferW if (to.Blink && !from.Blink) codes.Add(5); if (to.Inverted && !from.Inverted) codes.Add(7); if (to.StrikeThrough && !from.StrikeThrough) codes.Add(9); + if (to.Concealed && !from.Concealed) codes.Add(8); // A bright foreground's bold parameter is always covered by the attribute pass above, since // IsBold(to) is true whenever it is set. diff --git a/MarkupString.Ansi/UrlSafety.cs b/MarkupString.Ansi/UrlSafety.cs index 245470b..c17ee4e 100644 --- a/MarkupString.Ansi/UrlSafety.cs +++ b/MarkupString.Ansi/UrlSafety.cs @@ -10,15 +10,5 @@ public static class UrlSafety /// carrying one renders as plain text instead. This applies to URL links only — a command link /// carries a MUSH command, never a browser-navigable href. /// - public static bool IsSafeNavigableUrl(string? url) - { - if (string.IsNullOrWhiteSpace(url)) return false; - if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) return false; - - // A relative URL or a bare fragment cannot name a scheme, so there is none to distrust. - if (!uri.IsAbsoluteUri) return true; - - // Uri.Scheme is already lower-cased, so "JavaScript:" is caught here too. - return uri.Scheme is "http" or "https" or "mailto" or "ftp" or "ftps" or "tel"; - } + public static bool IsSafeNavigableUrl(string? url) => url is not null && UrlPolicy.Safe.IsAllowed(url); } diff --git a/MarkupString.AotSmoke/MarkupString.AotSmoke.csproj b/MarkupString.AotSmoke/MarkupString.AotSmoke.csproj index 820fcfb..0306f65 100644 --- a/MarkupString.AotSmoke/MarkupString.AotSmoke.csproj +++ b/MarkupString.AotSmoke/MarkupString.AotSmoke.csproj @@ -17,14 +17,21 @@ + + + + + + + diff --git a/MarkupString.AotSmoke/Program.cs b/MarkupString.AotSmoke/Program.cs index a93414e..56c92aa 100644 --- a/MarkupString.AotSmoke/Program.cs +++ b/MarkupString.AotSmoke/Program.cs @@ -1,10 +1,18 @@ using System.Buffers; using MarkupString; using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; +using MarkupString.Extensions.DependencyInjection; using MarkupString.Html; using MarkupString.Layout; using MarkupString.Mxp; +using MarkupString.Mxp.Decoding; using MarkupString.Pueblo; +using MarkupString.Pueblo.Decoding; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; // Native-AOT smoke test. It is published with PublishAot and run in CI: the publish must produce no // IL2xxx/IL3xxx warning, and the binary must exit 0. What it exercises is the whole pipeline that a @@ -129,6 +137,37 @@ || Graphemes.SnapEnd(longCluster, 1000) != longCluster.Length) failures.Add("long cluster snapping failed"); +// Decoding: terminal text, a sixel picture and MXP, rendered for a page and for a cell grid. +var decoders = DecoderRegistry.Empty.WithVt().WithPictures().WithMxp().WithPueblo(); +var decoder = decoders.CreateDecoder(); +decoder.EnableProtocol(MxpDecoding.Protocol); +var decoded = new List(decoder.Feed( + "\e[1;31mred\e[0m \e]8;;https://example.com\e\\link\e]8;;\e\\\n\e[1zlook\n\eP0;1q#1;2;100;0;0#1~~\e\\\n")); +var spans = SpanRenderer.Render(decoded[0].Content, AnsiDisplay.Default, new DisplayOptions()); +if (spans[0].Style.Foreground != new RgbColor(170, 0, 0) || spans[^1].Style.Interaction?.Kind != InteractionKind.Url) + failures.Add("decoding ANSI failed"); +if (SpanRenderer.Render(decoded[1].Content, AnsiDisplay.Default, new DisplayOptions())[0].Style.Interaction?.Target != "look") + failures.Add("decoding MXP failed"); +if (decoder.Pictures().Count != 1 + || CellRenderer.Layout(decoded[2].Content, 80, AnsiDisplay.Default, new DisplayOptions()).Rows[0].Cells[0].Style.Picture is null) + failures.Add("decoding a sixel picture failed"); + +// Dependency injection, with options bound from configuration by the source-generated binder. +var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary +{ + ["MarkupString:LinkSchemes:0"] = "mudclient", + ["MarkupString:Display:Colors"] = "Standard", +}).Build(); +using (var services = new ServiceCollection().AddSingleton(configuration) + .AddMarkupString().BindConfiguration().AddDecoders(registry => registry.WithMxp()).Services.BuildServiceProvider()) +{ + var injected = services.GetRequiredService().CreateDecoder(); + var linked = injected.Feed("\e]8;;mudclient://look\e\\look\e]8;;\e\\\n"); + var shown = SpanRenderer.Render(linked[0].Content, services.GetRequiredService(), services.GetRequiredService()); + if (shown[0].Style.Interaction?.Target != "mudclient://look" || services.GetRequiredService().Capabilities.Colors != ColorLevel.Standard) + failures.Add("dependency injection failed"); +} + if (failures.Count > 0) { Console.Error.WriteLine("AOT smoke test failed:"); diff --git a/MarkupString.Extensions.DependencyInjection/MarkupString.Extensions.DependencyInjection.csproj b/MarkupString.Extensions.DependencyInjection/MarkupString.Extensions.DependencyInjection.csproj new file mode 100644 index 0000000..da44efb --- /dev/null +++ b/MarkupString.Extensions.DependencyInjection/MarkupString.Extensions.DependencyInjection.csproj @@ -0,0 +1,31 @@ + + + MarkupString.Extensions.DependencyInjection + MarkupString.Extensions.DependencyInjection + MarkupString.Extensions.DependencyInjection + MarkupString.Extensions.DependencyInjection + true + true + true + CS1591 + Microsoft.Extensions.DependencyInjection setup for MarkupString: AddMarkupString() registers the markup, decoder and display registries, a shared picture cache, a decoder factory and the link policy, configured in code or bound from configuration. + + + + + + + + + + + + + + + + + + + + diff --git a/MarkupString.Extensions.DependencyInjection/MarkupStringOptions.cs b/MarkupString.Extensions.DependencyInjection/MarkupStringOptions.cs new file mode 100644 index 0000000..b00b6c3 --- /dev/null +++ b/MarkupString.Extensions.DependencyInjection/MarkupStringOptions.cs @@ -0,0 +1,63 @@ +using MarkupString.Display; +namespace MarkupString.Extensions.DependencyInjection; + +/// +/// The settings reads: set in code, or bound from a +/// configuration section ("MarkupString" in appsettings.json). +/// +public sealed class MarkupStringOptions +{ + /// How each connection's output is read. + public DecodingSettings Decoding { get; set; } = new(); + + /// How pictures are kept. + public PictureSettings Pictures { get; set; } = new(); + + /// How styles are drawn. + public DisplaySettings Display { get; set; } = new(); + + /// + /// Link schemes allowed besides http, https, mailto, ftp, ftps and tel, + /// such as mudclient. javascript, data, vbscript and file are never allowed. + /// + public List LinkSchemes { get; set; } = []; +} + +/// How each connection's output is read. +public sealed class DecodingSettings +{ + /// The longest line, in characters. + public int MaxLineLength { get; set; } = 64 * 1024; + + /// The most elements a text protocol may have open at once. + public int MaxOpenElements { get; set; } = 64; + + /// Whether a server may be told the cursor position (CSI 6 n). + public bool AnswerCursorPosition { get; set; } +} + +/// How pictures are kept. +public sealed class PictureSettings +{ + /// Whether pictures are read at all. + public bool Enabled { get; set; } = true; + + /// The bytes the shared holds. 64 MiB unless set. + public long Budget { get; set; } = 64L * 1024 * 1024; + + /// The largest picture decoded, in pixels. + public long MaxPixels { get; set; } = 4096L * 4096; +} + +/// How styles are drawn. +public sealed class DisplaySettings +{ + /// The output drawn for unless a caller says otherwise: Browser, CellGrid or PlainText. + public string Output { get; set; } = "Browser"; + + /// The most colours shown, or null for what shows. + public ColorLevel? Colors { get; set; } + + /// Whether bold standard colours are drawn bright, as many MU* clients do. + public bool BoldIsBright { get; set; } +} diff --git a/MarkupString.Extensions.DependencyInjection/PublicAPI.Shipped.txt b/MarkupString.Extensions.DependencyInjection/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/MarkupString.Extensions.DependencyInjection/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/MarkupString.Extensions.DependencyInjection/PublicAPI.Unshipped.txt b/MarkupString.Extensions.DependencyInjection/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..5f61081 --- /dev/null +++ b/MarkupString.Extensions.DependencyInjection/PublicAPI.Unshipped.txt @@ -0,0 +1,50 @@ +#nullable enable +MarkupString.Extensions.DependencyInjection.DecodingSettings +MarkupString.Extensions.DependencyInjection.DecodingSettings.AnswerCursorPosition.get -> bool +MarkupString.Extensions.DependencyInjection.DecodingSettings.AnswerCursorPosition.set -> void +MarkupString.Extensions.DependencyInjection.DecodingSettings.DecodingSettings() -> void +MarkupString.Extensions.DependencyInjection.DecodingSettings.MaxLineLength.get -> int +MarkupString.Extensions.DependencyInjection.DecodingSettings.MaxLineLength.set -> void +MarkupString.Extensions.DependencyInjection.DecodingSettings.MaxOpenElements.get -> int +MarkupString.Extensions.DependencyInjection.DecodingSettings.MaxOpenElements.set -> void +MarkupString.Extensions.DependencyInjection.DisplaySettings +MarkupString.Extensions.DependencyInjection.DisplaySettings.BoldIsBright.get -> bool +MarkupString.Extensions.DependencyInjection.DisplaySettings.BoldIsBright.set -> void +MarkupString.Extensions.DependencyInjection.DisplaySettings.Colors.get -> MarkupString.Display.ColorLevel? +MarkupString.Extensions.DependencyInjection.DisplaySettings.Colors.set -> void +MarkupString.Extensions.DependencyInjection.DisplaySettings.DisplaySettings() -> void +MarkupString.Extensions.DependencyInjection.DisplaySettings.Output.get -> string! +MarkupString.Extensions.DependencyInjection.DisplaySettings.Output.set -> void +MarkupString.Extensions.DependencyInjection.MarkupDecoderFactory +MarkupString.Extensions.DependencyInjection.MarkupDecoderFactory.CreateDecoder(MarkupString.Decoding.DecoderOptions? options = null) -> MarkupString.Decoding.Decoder! +MarkupString.Extensions.DependencyInjection.MarkupDecoderFactory.Defaults.get -> MarkupString.Decoding.DecoderOptions! +MarkupString.Extensions.DependencyInjection.MarkupStringBuilder +MarkupString.Extensions.DependencyInjection.MarkupStringBuilder.AddDecoders(System.Func! configure) -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder! +MarkupString.Extensions.DependencyInjection.MarkupStringBuilder.AddDisplay(System.Func! configure) -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder! +MarkupString.Extensions.DependencyInjection.MarkupStringBuilder.AddMarkup(System.Func! configure) -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder! +MarkupString.Extensions.DependencyInjection.MarkupStringBuilder.BindConfiguration(string! path = "MarkupString") -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder! +MarkupString.Extensions.DependencyInjection.MarkupStringBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +MarkupString.Extensions.DependencyInjection.MarkupStringBuilder.UsePictureCache() -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder! +MarkupString.Extensions.DependencyInjection.MarkupStringBuilder.UseUrlPolicy() -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder! +MarkupString.Extensions.DependencyInjection.MarkupStringOptions +MarkupString.Extensions.DependencyInjection.MarkupStringOptions.Decoding.get -> MarkupString.Extensions.DependencyInjection.DecodingSettings! +MarkupString.Extensions.DependencyInjection.MarkupStringOptions.Decoding.set -> void +MarkupString.Extensions.DependencyInjection.MarkupStringOptions.Display.get -> MarkupString.Extensions.DependencyInjection.DisplaySettings! +MarkupString.Extensions.DependencyInjection.MarkupStringOptions.Display.set -> void +MarkupString.Extensions.DependencyInjection.MarkupStringOptions.LinkSchemes.get -> System.Collections.Generic.List! +MarkupString.Extensions.DependencyInjection.MarkupStringOptions.LinkSchemes.set -> void +MarkupString.Extensions.DependencyInjection.MarkupStringOptions.MarkupStringOptions() -> void +MarkupString.Extensions.DependencyInjection.MarkupStringOptions.Pictures.get -> MarkupString.Extensions.DependencyInjection.PictureSettings! +MarkupString.Extensions.DependencyInjection.MarkupStringOptions.Pictures.set -> void +MarkupString.Extensions.DependencyInjection.PictureSettings +MarkupString.Extensions.DependencyInjection.PictureSettings.Budget.get -> long +MarkupString.Extensions.DependencyInjection.PictureSettings.Budget.set -> void +MarkupString.Extensions.DependencyInjection.PictureSettings.Enabled.get -> bool +MarkupString.Extensions.DependencyInjection.PictureSettings.Enabled.set -> void +MarkupString.Extensions.DependencyInjection.PictureSettings.MaxPixels.get -> long +MarkupString.Extensions.DependencyInjection.PictureSettings.MaxPixels.set -> void +MarkupString.Extensions.DependencyInjection.PictureSettings.PictureSettings() -> void +MarkupString.Extensions.DependencyInjection.ServiceCollectionExtensions +static MarkupString.Extensions.DependencyInjection.ServiceCollectionExtensions.AddMarkupString(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action? configure = null) -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder! +MarkupString.Extensions.DependencyInjection.MarkupDecoderFactory.CreateDecoderFor(MarkupString.Decoding.ClientIdentity! identity) -> MarkupString.Decoding.Decoder! +MarkupString.Extensions.DependencyInjection.MarkupDecoderFactory.MarkupDecoderFactory(MarkupString.Decoding.DecoderRegistry! registry, MarkupString.Extensions.DependencyInjection.MarkupStringOptions! options, MarkupString.IUrlPolicy! links, System.Diagnostics.Metrics.IMeterFactory? meters = null, Microsoft.Extensions.Logging.ILoggerFactory? loggers = null) -> void diff --git a/MarkupString.Extensions.DependencyInjection/README.md b/MarkupString.Extensions.DependencyInjection/README.md new file mode 100644 index 0000000..4fd6ae5 --- /dev/null +++ b/MarkupString.Extensions.DependencyInjection/README.md @@ -0,0 +1,13 @@ +# MarkupString.Extensions.DependencyInjection + +`AddMarkupString()` for `Microsoft.Extensions.DependencyInjection` hosts such as ASP.NET Core. See +[Dependency injection](https://github.com/SharpMUSH/MarkupString/blob/main/docs/dependency-injection.md). + +```csharp +builder.Services.AddMarkupString() + .BindConfiguration() // "MarkupString" in appsettings.json + .AddDecoders(registry => registry.WithMxp().WithPueblo()); + +// per connection +var decoder = app.Services.GetRequiredService().CreateDecoder(); +``` diff --git a/MarkupString.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/MarkupString.Extensions.DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..70fc5e5 --- /dev/null +++ b/MarkupString.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,212 @@ +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +namespace MarkupString.Extensions.DependencyInjection; + +/// Registers MarkupString with a service collection. +public static class ServiceCollectionExtensions +{ + /// + /// Registers, as singletons: the ; the (VT and + /// pictures) and an that makes each connection's decoder with the configured limits; + /// one every connection shares; the as the + /// , with default ; and the . + /// Decode metrics go to the host's , and what decoders drop is logged at debug level + /// to its (category MarkupString.Decoding), when it has them. A service registered before this + /// call is kept, so any of them can be replaced. Calling it again adds to the same setup. + /// + public static MarkupStringBuilder AddMarkupString(this IServiceCollection services, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + var setup = Setup(services); + services.AddOptions(); + if (configure is not null) services.Configure(configure); + + services.TryAddSingleton(static sp => UrlPolicy.WithSchemes(Options(sp).LinkSchemes)); + services.TryAddSingleton(static sp => new MemoryPictureCache { Budget = Options(sp).Pictures.Budget }); + services.TryAddSingleton(sp => + { + var options = Options(sp); + var registry = DecoderRegistry.Empty.WithVt(); + if (options.Pictures.Enabled) registry = registry.WithPictures(sp.GetRequiredService()); + return setup.Decoders.Aggregate(registry, static (current, step) => step(current)); + }); + services.TryAddSingleton(static sp => new MarkupDecoderFactory( + sp.GetRequiredService(), + Options(sp), + sp.GetRequiredService(), + sp.GetService(), + sp.GetService())); + services.TryAddSingleton(static sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => setup.Display.Aggregate( + DisplayRegistry.Core.WithAnsi(Options(sp).Display.BoldIsBright), static (current, step) => step(current))); + services.TryAddSingleton(static sp => sp.GetRequiredService()); + services.TryAddSingleton(static sp => DisplayDefaults(Options(sp).Display, sp.GetRequiredService())); + services.TryAddSingleton(sp => setup.Markup.Aggregate( + MarkupRegistry.Empty.WithAnsi().WithLinks(sp.GetRequiredService()), static (current, step) => step(current))); + return new MarkupStringBuilder(services, setup); + } + + private static MarkupStringOptions Options(IServiceProvider services) => services.GetRequiredService>().Value; + + private static DisplayOptions DisplayDefaults(DisplaySettings settings, IUrlPolicy links) + { + var capabilities = settings.Output.ToUpperInvariant() switch + { + "CELLGRID" => OutputCapabilities.CellGrid, + "PLAINTEXT" => OutputCapabilities.PlainText, + "BROWSER" => OutputCapabilities.Browser, + _ => throw new OptionsValidationException(nameof(DisplaySettings.Output), typeof(MarkupStringOptions), + [$"MarkupString:Display:Output is '{settings.Output}'; it must be Browser, CellGrid or PlainText."]), + }; + if (settings.Colors is { } colors) capabilities = capabilities with { Colors = colors }; + return new DisplayOptions { Capabilities = capabilities, Links = links }; + } + + private static MarkupStringSetup Setup(IServiceCollection services) + { + foreach (var descriptor in services) + { + if (descriptor.ServiceType == typeof(MarkupStringSetup) && descriptor.ImplementationInstance is MarkupStringSetup existing) return existing; + } + + var setup = new MarkupStringSetup(); + services.AddSingleton(setup); + return setup; + } +} + +/// The steps collects, applied when the registries are first resolved. +internal sealed class MarkupStringSetup +{ + public List> Decoders { get; } = []; + + public List> Display { get; } = []; + + public List> Markup { get; } = []; +} + +/// Adds to what registered. +public sealed class MarkupStringBuilder +{ + private readonly MarkupStringSetup _setup; + + internal MarkupStringBuilder(IServiceCollection services, MarkupStringSetup setup) + { + Services = services; + _setup = setup; + } + + /// The service collection. + public IServiceCollection Services { get; } + + /// Adds decoder plugins, such as registry => registry.WithMxp().WithPueblo(). + public MarkupStringBuilder AddDecoders(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + _setup.Decoders.Add(configure); + return this; + } + + /// Adds style contributors to the display registry. + public MarkupStringBuilder AddDisplay(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + _setup.Display.Add(configure); + return this; + } + + /// Adds emitters and codecs to the markup registry, such as registry => registry.WithHtml(). + public MarkupStringBuilder AddMarkup(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + _setup.Markup.Add(configure); + return this; + } + + /// Binds to the configuration section at . + public MarkupStringBuilder BindConfiguration(string path = "MarkupString") + { + ArgumentException.ThrowIfNullOrEmpty(path); + Services.AddOptions().BindConfiguration(path); + return this; + } + + /// Keeps pictures in , a singleton, instead of memory. + public MarkupStringBuilder UsePictureCache<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCache>() + where TCache : class, IPictureCache + { + Services.Replace(ServiceDescriptor.Singleton()); + return this; + } + + /// Decides which links are allowed with , a singleton. + public MarkupStringBuilder UseUrlPolicy<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TPolicy>() + where TPolicy : class, IUrlPolicy + { + Services.Replace(ServiceDescriptor.Singleton()); + return this; + } +} + +/// +/// Makes each connection's from the registered , with the +/// configured limits, link policy, metrics, logging and picture size. +/// +public sealed partial class MarkupDecoderFactory : IDecoderFactory +{ + private readonly DecoderRegistry _registry; + private readonly long _maxPixels; + private readonly ILogger? _logger; + + /// A factory over . + public MarkupDecoderFactory(DecoderRegistry registry, MarkupStringOptions options, IUrlPolicy links, IMeterFactory? meters = null, + ILoggerFactory? loggers = null) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(links); + _registry = registry; + _maxPixels = options.Pictures.MaxPixels; + _logger = loggers?.CreateLogger("MarkupString.Decoding"); + var answers = ClientAnswers.Default; + answers = options.Decoding.AnswerCursorPosition ? answers | ClientAnswers.CursorPosition : answers & ~ClientAnswers.CursorPosition; + Defaults = new DecoderOptions + { + MaxLineLength = options.Decoding.MaxLineLength, + MaxOpenElements = options.Decoding.MaxOpenElements, + Links = links, + Meters = meters, + Identity = new ClientIdentity { Answers = answers }, + }; + } + + /// What each decoder is made with unless a caller gives its own. + public DecoderOptions Defaults { get; } + + /// + public Decoder CreateDecoder(DecoderOptions? options = null) + { + var decoder = _registry.CreateDecoder(options ?? Defaults); + if (_registry.Has(PicturesPlugin.Instance.Name)) decoder.Pictures().MaxPixels = _maxPixels; + if (_logger is { } logger && logger.IsEnabled(LogLevel.Debug)) decoder.Diagnostics.Dropped += (kind, count) => LogDropped(logger, kind, count); + return decoder; + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Decoder dropped {Count} × {Kind}")] + private static partial void LogDropped(ILogger logger, DecodeDrop kind, int count); + + /// A decoder with the defaults, for a client that is . + public Decoder CreateDecoderFor(ClientIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + return CreateDecoder(Defaults with { Identity = identity }); + } +} diff --git a/MarkupString.Html/HtmlTagPolicy.cs b/MarkupString.Html/HtmlTagPolicy.cs index 1e82921..05a712e 100644 --- a/MarkupString.Html/HtmlTagPolicy.cs +++ b/MarkupString.Html/HtmlTagPolicy.cs @@ -46,13 +46,16 @@ public sealed record HtmlTagPolicy /// /// The attributes whose value this policy checks as an address: each must be one - /// accepts, with no whitespace or control character in it. + /// accepts, with no whitespace or control character in it. /// Empty by default — which addresses a client of yours may be sent to is your decision, not this /// library's. is the list of attributes HTML gives an address to, /// if you want to check them all. /// public IReadOnlySet UrlAttributes { get; init => field = IgnoringCase(value) ?? throw new ArgumentNullException(nameof(value)); } = FrozenSet.Empty; + /// Which addresses may hold. unless set. + public IUrlPolicy Links { get; init => field = value ?? throw new ArgumentNullException(nameof(value)); } = UrlPolicy.Safe; + /// What happens to the attributes when one is not allowed. public HtmlAttributeViolation OnViolation { get; init; } = HtmlAttributeViolation.DropAttribute; @@ -115,6 +118,6 @@ private bool Allows(HtmlAttribute attribute) => _ => set.ToFrozenSet(StringComparer.OrdinalIgnoreCase), }; - private static bool IsSafeAddress(string value) => - !value.Any(c => char.IsControl(c) || char.IsWhiteSpace(c)) && UrlSafety.IsSafeNavigableUrl(value); + private bool IsSafeAddress(string value) => + !value.Any(c => char.IsControl(c) || char.IsWhiteSpace(c)) && Links.IsAllowed(value); } diff --git a/MarkupString.Html/PublicAPI.Unshipped.txt b/MarkupString.Html/PublicAPI.Unshipped.txt index 7dc5c58..ac70350 100644 --- a/MarkupString.Html/PublicAPI.Unshipped.txt +++ b/MarkupString.Html/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +MarkupString.Html.HtmlTagPolicy.Links.get -> MarkupString.IUrlPolicy! +MarkupString.Html.HtmlTagPolicy.Links.init -> void diff --git a/MarkupString.Mxp/Decoding/MxpDecoding.cs b/MarkupString.Mxp/Decoding/MxpDecoding.cs new file mode 100644 index 0000000..04dc49b --- /dev/null +++ b/MarkupString.Mxp/Decoding/MxpDecoding.cs @@ -0,0 +1,811 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using MarkupString.Ansi; +using MarkupString.Decoding; +namespace MarkupString.Mxp.Decoding; + +/// +/// Says what an MXP line tag or a FLAG marks the text as: a room's name, its description, its exits, or +/// whatever a server's own element flags. It changes nothing about how the text looks. +/// +/// The flag, as the server named it, such as RoomName. +public sealed record MxpFlagMarkup(string Flag) : IDataMarkup +{ + /// A room's name (line tag 10). + public const string RoomName = "RoomName"; + + /// A room's description (line tag 11). + public const string RoomDescription = "RoomDesc"; + + /// A room's exits (line tag 12). + public const string RoomExits = "RoomExit"; + + /// The welcome text (line tag 19). + public const string Welcome = "Welcome"; +} + +/// Registers MXP decoding on a . +public static class MxpDecoding +{ + /// The name to pass once telnet has negotiated MXP. + public const string Protocol = "mxp"; + + /// The registry with . + public static DecoderRegistry WithMxp(this DecoderRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + return registry.With(MxpPlugin.Instance); + } + + /// The codec that stores , for a registry that serialises decoded lines. + public static IMarkupCodec FlagCodec { get; } = new MxpFlagCodec(); + + private sealed class MxpFlagCodec : IMarkupCodec + { + public string Kind => "mxp-flag"; + + public Type MarkupType => typeof(MxpFlagMarkup); + + public void Write(Utf8JsonWriter writer, IMarkup markup) => writer.WriteString("f", ((MxpFlagMarkup)markup).Flag); + + public IMarkup Read(JsonElement element) => + new MxpFlagMarkup(element.TryGetProperty("f", out var flag) && flag.ValueKind == JsonValueKind.String ? flag.GetString()! : string.Empty); + } +} + +/// +/// MXP, the MUD eXtension Protocol, as a text protocol: tags and entities in the text, and the line modes +/// (ESC [ n z) that say which tags a line may use. Turn it on with +/// () when telnet negotiates it. +/// +/// +/// +/// Styles (B, I, U, S, H, COLOR, FONT) become ; links +/// (A, SEND) a link on an , with a for a menu or a +/// prompt; IMAGE, SOUND, MUSIC, DEST, EXPIRE, RELOCATE, USER, +/// PASSWORD and VAR the shared vocabulary; and custom elements (<!ELEMENT>) what their +/// definitions say. VERSION and SUPPORT are answered from the decoder's identity. +/// +/// +/// A secure tag on a line that is not secure is dropped, as the protocol says, and counted as unhandled. +/// +/// +public sealed class MxpPlugin : IDecoderPlugin +{ + /// The one instance. + public static MxpPlugin Instance { get; } = new(); + + /// The tags this decoder reads, as <SUPPORTS> answers. + public static IReadOnlyList Supported { get; } = + [ + "B", "BOLD", "STRONG", "I", "ITALIC", "EM", "U", "UNDERLINE", "S", "STRIKEOUT", "H", "HIGH", "C", "COLOR", "FONT", + "BR", "SBR", "P", "NOBR", "A", "SEND", "EXPIRE", "VERSION", "SUPPORT", "IMAGE", "SOUND", "MUSIC", "FRAME", "DEST", + "RELOCATE", "USER", "PASSWORD", "VAR", "ELEMENT", "ENTITY", "ATTLIST", "TAG", + ]; + + private MxpPlugin() + { + } + + /// + public string Name => "mxp"; + + /// + public void Register(DecoderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder + .TextProtocol(MxpDecoding.Protocol, static () => new MxpReader()) + .OnCsi('z', static (in CsiSequence sequence, DecodeContext context) => + { + // A line mode means nothing until MXP is on, and is dropped. + if (context.IsActive(MxpDecoding.Protocol)) MxpReader.LineMode(sequence.Get(0), context); + return true; + }); + } +} + +internal enum MxpMode +{ + Open, + Secure, + Locked, +} + +internal sealed record MxpElementDefinition(string Name, string Definition, IReadOnlyList Attributes, string? Flag, bool Open, bool Empty); + +internal sealed class MxpOpenElement(string name, IMarkup? first, bool openMode, bool lineTag) +{ + public string Name { get; } = name; + + public IMarkup? First { get; } = first; + + public bool OpenMode { get; } = openMode; + + public bool LineTag { get; } = lineTag; +} + +internal sealed class MxpState +{ + public MxpMode Default { get; set; } = MxpMode.Open; + + public MxpMode Line { get; set; } = MxpMode.Open; + + public bool TemporarySecure { get; set; } + + public bool InBreak { get; set; } + + public List Open { get; } = []; + + public Dictionary Elements { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary Entities { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary LineTags { get; } = []; + + public Dictionary FrameTitles { get; } = new(StringComparer.OrdinalIgnoreCase); + + public ParsedTag? Capture { get; set; } + + public StringBuilder CaptureRaw { get; } = new(); + + public StringBuilder CaptureText { get; } = new(); + + public int CaptureDepth { get; set; } + + public List<(ImageMarkup Image, int Rows, int Columns, int Column, int Next)> Overlays { get; } = []; +} + +internal sealed class MxpReader : ITextProtocol, ITagSink +{ + private const int MaxCapture = 4096; + + private static readonly HashSet OpenTags = new(StringComparer.OrdinalIgnoreCase) + { + "B", "BOLD", "STRONG", "I", "ITALIC", "EM", "U", "UNDERLINE", "S", "STRIKEOUT", "STRIKE", "H", "HIGH", "C", "COLOR", + "FONT", "NOBR", "P", "BR", "SBR", "SMALL", "TT", + }; + + private readonly TagScanner _scanner = new(); + private DecodeContext? _context; + + private DecodeContext Context => _context!; + + private MxpState State => Context.State(); + + public bool ReadsMarkup + { + get + { + var state = State; + return state.TemporarySecure || state.Line != MxpMode.Locked; + } + } + + private bool Secure + { + get + { + var state = State; + return state.TemporarySecure || state.Line == MxpMode.Secure; + } + } + + public void Feed(ReadOnlySpan text, DecodeContext context) + { + _context = context; + _scanner.Feed(text, this); + } + + public void EndLine(DecodeContext context) + { + _context = context; + var state = State; + _scanner.Flush(this); + if (state.Capture is not null) FinishCapture(); + DrawOverlays(); + if (state.InBreak) return; + + // Elements opened on an open line, and line tags, end with it. + var index = state.Open.FindIndex(element => element.OpenMode || element.LineTag); + if (index >= 0) CloseFrom(index); + state.Line = state.Default; + state.TemporarySecure = false; + } + + public void Reset(DecodeContext context) + { + _context = context; + _scanner.Reset(); + var state = State; + if (state.Open.Count > 0) CloseFrom(0); + state.Capture = null; + state.Overlays.Clear(); + state.Line = state.Default = MxpMode.Open; + state.TemporarySecure = false; + } + + internal static void LineMode(int mode, DecodeContext context) + { + var state = context.State(); + switch (mode) + { + case 0: state.Line = MxpMode.Open; break; + case 1: state.Line = MxpMode.Secure; break; + case 2: state.Line = MxpMode.Locked; break; + case 3: + state.Line = state.Default = MxpMode.Open; + var first = state.Open.FirstOrDefault(element => element.First is not null)?.First; + if (first is not null) context.Close(first); + state.Open.Clear(); + break; + case 4: state.TemporarySecure = true; break; + case 5: state.Line = state.Default = MxpMode.Open; break; + case 6: state.Line = state.Default = MxpMode.Secure; break; + case 7: state.Line = state.Default = MxpMode.Locked; break; + case >= 10 and <= 99: + state.Line = MxpMode.Secure; + if (state.LineTags.TryGetValue(mode, out var element) && state.Elements.TryGetValue(element, out var definition)) + { + // The line is the element; the reader opens it and the line's end closes it. + var reader = new MxpReader { _context = context }; + reader.OpenCustom(definition, new ParsedTag(definition.Name, false, []), lineTag: true); + } + else if (mode switch { 10 => MxpFlagMarkup.RoomName, 11 => MxpFlagMarkup.RoomDescription, 12 => MxpFlagMarkup.RoomExits, 19 => MxpFlagMarkup.Welcome, _ => null } is { } flag) + { + var markup = new MxpFlagMarkup(flag); + if (context.Open(markup)) state.Open.Add(new MxpOpenElement(flag, markup, openMode: false, lineTag: true)); + } + + break; + } + } + + // ── ITagSink ──────────────────────────────────────────────────────────────── + + public void Text(ReadOnlySpan text) + { + var state = State; + if (state.Capture is not null) + { + Captured(text, text); + return; + } + + Context.Print(text); + } + + public void Entity(ReadOnlySpan name) + { + var decoded = TagSyntax.Entity(name, LookupEntity); + var state = State; + if (state.Capture is not null) + { + Captured($"&{name};", decoded ?? $"&{name};"); + return; + } + + Context.Print(decoded ?? $"&{name};"); + } + + public void Tag(ReadOnlySpan body) + { + var state = State; + var secure = Secure; + state.TemporarySecure = false; + + if (state.Capture is { } capture) + { + var inner = TagSyntax.Parse(body); + if (inner is not null && inner.Name.Equals(capture.Name, StringComparison.OrdinalIgnoreCase)) + { + if (inner.Closing && state.CaptureDepth == 0) + { + FinishCapture(); + return; + } + + state.CaptureDepth += inner.Closing ? -1 : 1; + } + + Captured($"<{body}>", []); + return; + } + + if (!body.IsEmpty && body[0] == '!') + { + if (secure) Definition(body[1..]); + else Context.Diagnostics.UnhandledSequences++; + return; + } + + var tag = TagSyntax.Parse(body, LookupEntity); + if (tag is null) return; + + if (tag.Closing) + { + Close(tag.Name); + return; + } + + var custom = state.Elements.TryGetValue(tag.Name, out var definition) ? definition : null; + var open = custom?.Open ?? OpenTags.Contains(tag.Name); + if (!secure && !open) + { + Context.Diagnostics.UnhandledSequences++; + return; + } + + if (custom is not null) + { + OpenCustom(custom, tag, lineTag: false); + return; + } + + if (!Element(tag, openMode: !secure)) Context.Diagnostics.UnhandledSequences++; + } + + // ── Elements ──────────────────────────────────────────────────────────────── + + private bool Element(ParsedTag tag, bool openMode) + { + var context = Context; + switch (tag.Name.ToUpperInvariant()) + { + case "B" or "BOLD" or "STRONG" or "H" or "HIGH": + OpenStyle(tag.Name, new AnsiStyle { Bold = true }, openMode); + return true; + case "I" or "ITALIC" or "EM": + OpenStyle(tag.Name, new AnsiStyle { Italic = true }, openMode); + return true; + case "U" or "UNDERLINE": + OpenStyle(tag.Name, new AnsiStyle { Underlined = true }, openMode); + return true; + case "S" or "STRIKEOUT" or "STRIKE": + OpenStyle(tag.Name, new AnsiStyle { StrikeThrough = true }, openMode); + return true; + case "C" or "COLOR": + OpenStyle(tag.Name, new AnsiStyle { Foreground = Color(tag.Get("FORE", 0)), Background = Color(tag.Get("BACK", 1)) }, openMode); + return true; + case "FONT": + OpenStyle(tag.Name, new AnsiStyle { Foreground = Color(tag.Get("COLOR")), Background = Color(tag.Get("BACK")) }, openMode); + return true; + case "SMALL" or "TT" or "P" or "NOBR": + return true; + case "BR": + State.InBreak = true; + context.EndLine(); + State.InBreak = false; + return true; + case "SBR": + context.Print(" "); + return true; + case "A": + { + var href = tag.Get("HREF", 0); + IMarkup? link = href is not null && Context.Options.Links.IsAllowed(href) + ? new AnsiMarkup(new AnsiStyle { LinkUrl = href, LinkKind = LinkKind.Url, LinkText = tag.Get("HINT", 1) }) + : null; + OpenElement(tag.Name, link is null ? [] : [link], openMode); + return true; + } + case "SEND": + { + var href = tag.Get("HREF", 0); + if (href is null || href.Contains("&text;", StringComparison.OrdinalIgnoreCase)) + { + StartCapture(tag); + return true; + } + + OpenSend(tag, href, openMode); + return true; + } + case "VAR" or "V": + StartCapture(tag); + return true; + case "EXPIRE": + context.Point(new ExpireLinksMarkup(tag.Get("NAME", 0))); + return true; + case "VERSION": + if ((context.Identity.Answers & ClientAnswers.Mxp) != 0) + { + context.Reply(string.Create(CultureInfo.InvariantCulture, + $"\e[1z\n")); + } + + return true; + case "SUPPORT": + if ((context.Identity.Answers & ClientAnswers.Mxp) != 0) context.Reply(Supports(tag)); + return true; + case "IMAGE": + Image(tag); + return true; + case "SOUND" or "MUSIC": + Sound(tag); + return true; + case "FRAME": + { + var name = tag.Get("NAME", 0); + if (name is not null) State.FrameTitles[name] = tag.Get("TITLE"); + return true; + } + case "DEST": + { + var name = tag.Get("NAME", 0); + if (string.IsNullOrEmpty(name)) return false; + State.FrameTitles.TryGetValue(name, out var title); + OpenElement(tag.Name, [new PaneMarkup(name, title)], openMode); + return true; + } + case "RELOCATE": + { + var host = tag.Get("HOST", 0); + if (host is null || !int.TryParse(tag.Get("PORT", 1), NumberStyles.None, CultureInfo.InvariantCulture, out var port)) return false; + context.Point(new RelocateMarkup(host, port, tag.Has("QUIET"))); + return true; + } + case "USER": + context.Point(new LoginPromptMarkup(LoginField.User)); + return true; + case "PASSWORD": + context.Point(new LoginPromptMarkup(LoginField.Password)); + return true; + case "GAUGE" or "STAT": + // A gauge stands for a variable the host already has from VAR and ENTITY; there is no text to show. + return true; + default: + return false; + } + } + + private void OpenStyle(string name, AnsiStyle style, bool openMode) => + OpenElement(name, style.IsNone ? [] : [new AnsiMarkup(style)], openMode); + + private void OpenElement(string name, IMarkup[] markups, bool openMode, bool lineTag = false) + { + IMarkup? first = null; + foreach (var markup in markups) + { + if (!Context.Open(markup)) break; + first ??= markup; + } + + State.Open.Add(new MxpOpenElement(name, first, openMode, lineTag)); + } + + private void Close(string name) + { + var state = State; + var index = state.Open.FindLastIndex(element => element.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) CloseFrom(index); + } + + private void CloseFrom(int index) + { + var state = State; + for (var i = index; i < state.Open.Count; i++) + { + if (state.Open[i].First is { } first) + { + Context.Close(first); + break; + } + } + + state.Open.RemoveRange(index, state.Open.Count - index); + } + + private void OpenSend(ParsedTag tag, string href, bool openMode) + { + var commands = href.Split('|'); + var hints = tag.Get("HINT", 1)?.Split('|') ?? []; + var prompt = tag.Has("PROMPT"); + + // With one hint more than commands, the first is the link's own and the rest label the menu. + string? hint; + string[] labels; + if (hints.Length == commands.Length + 1) + { + hint = hints[0]; + labels = hints[1..]; + } + else + { + hint = hints.Length > 0 ? hints[0] : null; + labels = hints.Length == commands.Length ? hints : commands; + } + + var link = new AnsiMarkup(new AnsiStyle { LinkUrl = commands[0], LinkKind = LinkKind.Command, LinkText = hint }); + if (commands.Length == 1 && !prompt) + { + OpenElement(tag.Name, [link], openMode); + return; + } + + var choices = new CommandChoice[commands.Length]; + for (var i = 0; i < commands.Length; i++) choices[i] = new CommandChoice(labels[i], commands[i]); + OpenElement(tag.Name, [new CommandMenuMarkup(choices, hint, prompt), link], openMode); + } + + private void OpenCustom(MxpElementDefinition definition, ParsedTag tag, bool lineTag) + { + var state = State; + var before = Context.Elements.Count; + var opened = state.Open.Count; + + if (definition.Flag is { } flag) Context.Open(new MxpFlagMarkup(flag)); + + // The definition's tags, with the element's attributes put in for &name;, run as secure tags. + var expanded = Expand(definition, tag); + var replay = new TagScanner(); + state.TemporarySecure = false; + var saved = state.Line; + state.Line = MxpMode.Secure; + replay.Feed(expanded, this); + state.Line = saved; + + // What the definition opened is one element now, closed together. + if (state.Open.Count > opened) state.Open.RemoveRange(opened, state.Open.Count - opened); + var first = Context.Elements.Count > before ? Context.Elements[before] : null; + if (definition.Empty) + { + if (first is not null) Context.Close(first); + return; + } + + state.Open.Add(new MxpOpenElement(definition.Name, first, openMode: !Secure && !lineTag && definition.Open, lineTag)); + } + + private static string Expand(MxpElementDefinition definition, ParsedTag tag) + { + var text = definition.Definition; + var position = 0; + foreach (var attribute in definition.Attributes) + { + var name = attribute.Name ?? attribute.Value; + var value = tag.Get(name, position++) ?? (attribute.Name is null ? string.Empty : attribute.Value); + text = text.Replace($"&{name};", value, StringComparison.OrdinalIgnoreCase); + } + + return text; + } + + private void Definition(ReadOnlySpan body) + { + var tag = TagSyntax.Parse(body); + if (tag is null) return; + var state = State; + var name = tag.Get("NAME", 0); + if (name is null) return; + + switch (tag.Name.ToUpperInvariant()) + { + case "ELEMENT" or "EL": + { + if (tag.Has("DELETE")) + { + state.Elements.Remove(name); + return; + } + + var attributes = TagSyntax.Parse("x " + (tag.Get("ATT") ?? string.Empty))?.Arguments ?? []; + var definition = new MxpElementDefinition(name, tag.Get("DEFINITION", 1) ?? string.Empty, attributes, tag.Get("FLAG"), tag.Has("OPEN"), tag.Has("EMPTY")); + state.Elements[name] = definition; + if (int.TryParse(tag.Get("TAG"), NumberStyles.None, CultureInfo.InvariantCulture, out var number) && number is >= 20 and <= 99) + { + state.LineTags[number] = name; + } + + return; + } + case "ATTLIST" or "AT": + { + if (!state.Elements.TryGetValue(name, out var definition)) return; + var attributes = TagSyntax.Parse("x " + (tag.Get("ATT", 1) ?? string.Empty))?.Arguments ?? []; + state.Elements[name] = definition with { Attributes = attributes }; + return; + } + case "TAG": + { + var number = tag.Get("INDEX", 0); + var element = tag.Get("ELEMENT", 1) ?? tag.Get("NAME"); + if (int.TryParse(number, NumberStyles.None, CultureInfo.InvariantCulture, out var index) && element is not null) state.LineTags[index] = element; + return; + } + case "ENTITY" or "EN": + { + if (tag.Has("DELETE") || tag.Has("REMOVE")) + { + state.Entities.Remove(name); + Context.Raise(new VariableEvent(name, null)); + return; + } + + var value = tag.Get("VALUE", 1) ?? string.Empty; + if (tag.Has("ADD") && state.Entities.TryGetValue(name, out var existing)) value = existing + "|" + value; + state.Entities[name] = value; + if (!tag.Has("PRIVATE")) Context.Raise(new VariableEvent(name, value)); + return; + } + } + } + + // ── Capturing the text of SEND and VAR ────────────────────────────────────── + + private void StartCapture(ParsedTag tag) + { + var state = State; + state.Capture = tag; + state.CaptureDepth = 0; + state.CaptureRaw.Clear(); + state.CaptureText.Clear(); + } + + private void Captured(ReadOnlySpan raw, ReadOnlySpan text) + { + var state = State; + state.CaptureRaw.Append(raw); + state.CaptureText.Append(text); + if (state.CaptureRaw.Length > MaxCapture) FinishCapture(); + } + + private void FinishCapture() + { + var state = State; + var tag = state.Capture!; + var raw = state.CaptureRaw.ToString(); + var text = state.CaptureText.ToString(); + state.Capture = null; + + var name = tag.Name.ToUpperInvariant(); + if (name == "SEND") + { + var href = tag.Get("HREF", 0)?.Replace("&text;", text, StringComparison.OrdinalIgnoreCase) ?? text; + var hint = tag.Get("HINT", 1)?.Replace("&text;", text, StringComparison.OrdinalIgnoreCase); + var arguments = tag.Arguments.Where(argument => argument.Name is not null && !argument.Name.Equals("HREF", StringComparison.OrdinalIgnoreCase) + && !argument.Name.Equals("HINT", StringComparison.OrdinalIgnoreCase)).ToList(); + if (hint is not null) arguments.Add(new TagArgument("HINT", hint)); + if (tag.Has("PROMPT")) arguments.Add(new TagArgument(null, "PROMPT")); + if (href.Length > 0) OpenSend(tag with { Arguments = arguments }, href, openMode: false); + else OpenElement(tag.Name, [], openMode: false); + } + else + { + var variable = tag.Get("NAME", 0); + if (!string.IsNullOrEmpty(variable)) + { + state.Entities[variable] = text; + if (!tag.Has("PRIVATE")) Context.Raise(new VariableEvent(variable, text)); + OpenElement(tag.Name, [new VariableMarkup(variable)], openMode: false); + } + else + { + OpenElement(tag.Name, [], openMode: false); + } + } + + var saved = state.Line; + state.Line = MxpMode.Secure; + new TagScanner().Feed(raw, this); + state.Line = saved; + Close(tag.Name); + } + + // ── Pictures and sounds ───────────────────────────────────────────────────── + + private void Image(ParsedTag tag) + { + var file = tag.Get("FNAME", 0); + if (string.IsNullOrEmpty(file)) return; + var source = Address(tag.Get("URL"), file); + var (width, widthCells) = Size(tag.Get("W")); + var (height, heightCells) = Size(tag.Get("H")); + var align = Enum.TryParse(tag.Get("ALIGN"), ignoreCase: true, out var parsed) ? parsed : (ImageAlign?)null; + var image = new ImageMarkup(source, null, width, height, align); + + if (widthCells > 0 && heightCells > 0) + { + // Sized in cells: drawn over the cells the server leaves for it on this line and the ones after. + State.Overlays.Add((image, heightCells, widthCells, Context.Column, 0)); + return; + } + + Context.Write(file, image); + } + + private static (int? Pixels, int Cells) Size(string? value) + { + if (string.IsNullOrEmpty(value)) return (null, 0); + if (value.EndsWith('c') && int.TryParse(value.AsSpan(0, value.Length - 1), NumberStyles.None, CultureInfo.InvariantCulture, out var cells)) return (null, cells); + var digits = value.EndsWith("px", StringComparison.OrdinalIgnoreCase) ? value[..^2] : value; + return int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var pixels) && pixels > 0 ? (pixels, 0) : (null, 0); + } + + private void DrawOverlays() + { + var overlays = State.Overlays; + if (overlays.Count == 0) return; + var context = Context; + var cursor = context.Column; + for (var i = 0; i < overlays.Count; i++) + { + var (image, rows, columns, column, next) = overlays[i]; + context.MoveToColumn(column); + context.Write(new string(' ', columns), image with { Row = new PictureRow(next, rows, columns) }); + overlays[i] = (image, rows, columns, column, next + 1); + } + + overlays.RemoveAll(overlay => overlay.Next >= overlay.Rows); + context.MoveToColumn(cursor); + } + + private void Sound(ParsedTag tag) + { + var music = tag.Name.Equals("MUSIC", StringComparison.OrdinalIgnoreCase); + var channel = music ? SoundChannel.Music : SoundChannel.Effects; + var file = tag.Get("FNAME", 0); + if (string.IsNullOrEmpty(file)) return; + if (file.Equals("Off", StringComparison.OrdinalIgnoreCase)) + { + Context.Point(new SoundStopMarkup(channel)); + return; + } + + int? volume = int.TryParse(tag.Get("V"), NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? Math.Clamp(v, 0, 100) : null; + int? repeats = int.TryParse(tag.Get("L"), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var l) + ? l < 0 ? SoundMarkup.Forever : l + : null; + var continues = music && tag.Get("C") == "1"; + Context.Point(new SoundMarkup(Address(tag.Get("U"), file), channel, volume, repeats, continues)); + } + + private static string Address(string? directory, string file) + { + if (string.IsNullOrEmpty(directory) || Uri.TryCreate(file, UriKind.Absolute, out _)) return file; + return directory.EndsWith('/') ? directory + file : directory + "/" + file; + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + private string? LookupEntity(string name) => State.Entities.TryGetValue(name, out var value) ? value : null; + + private static AnsiColor? Color(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return null; + name = name.Trim(); + if (name[0] == '#' && name.Length == 7 + && int.TryParse(name.AsSpan(1), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value)) + { + return new AnsiColor.Rgb((byte)(value >> 16), (byte)(value >> 8), (byte)value); + } + + var known = System.Drawing.Color.FromName(name); + return known.IsKnownColor && !known.IsSystemColor ? new AnsiColor.Rgb(known.R, known.G, known.B) : null; + } + + private static string Quote(string value) => "\"" + value.Replace("\"", "'", StringComparison.Ordinal) + "\""; + + private static string Supports(ParsedTag tag) + { + var supported = new HashSet(MxpPlugin.Supported, StringComparer.OrdinalIgnoreCase); + var answer = new StringBuilder("\e[1z argument.Name is null).Select(argument => argument.Value).ToList(); + if (asked.Count == 0) + { + foreach (var name in MxpPlugin.Supported) answer.Append(" +").Append(name.ToLowerInvariant()); + } + else + { + foreach (var item in asked) + { + // "send.prompt" asks about an attribute; the element's answer stands for it. + var element = item.Split('.')[0]; + answer.Append(supported.Contains(element) ? " +" : " -").Append(item.ToLowerInvariant()); + } + } + + return answer.Append(">\n").ToString(); + } +} diff --git a/MarkupString.Mxp/MarkupString.Mxp.csproj b/MarkupString.Mxp/MarkupString.Mxp.csproj index 079ec3e..92349ca 100644 --- a/MarkupString.Mxp/MarkupString.Mxp.csproj +++ b/MarkupString.Mxp/MarkupString.Mxp.csproj @@ -27,5 +27,6 @@ + diff --git a/MarkupString.Mxp/PublicAPI.Unshipped.txt b/MarkupString.Mxp/PublicAPI.Unshipped.txt index 7dc5c58..ea461a6 100644 --- a/MarkupString.Mxp/PublicAPI.Unshipped.txt +++ b/MarkupString.Mxp/PublicAPI.Unshipped.txt @@ -1 +1,26 @@ #nullable enable +MarkupString.Mxp.Decoding.MxpDecoding +MarkupString.Mxp.Decoding.MxpFlagMarkup +MarkupString.Mxp.Decoding.MxpFlagMarkup.$() -> MarkupString.Mxp.Decoding.MxpFlagMarkup! +MarkupString.Mxp.Decoding.MxpFlagMarkup.Deconstruct(out string! Flag) -> void +MarkupString.Mxp.Decoding.MxpFlagMarkup.Equals(MarkupString.Mxp.Decoding.MxpFlagMarkup? other) -> bool +MarkupString.Mxp.Decoding.MxpFlagMarkup.Flag.get -> string! +MarkupString.Mxp.Decoding.MxpFlagMarkup.Flag.init -> void +MarkupString.Mxp.Decoding.MxpFlagMarkup.MxpFlagMarkup(string! Flag) -> void +MarkupString.Mxp.Decoding.MxpPlugin +MarkupString.Mxp.Decoding.MxpPlugin.Name.get -> string! +MarkupString.Mxp.Decoding.MxpPlugin.Register(MarkupString.Decoding.DecoderBuilder! builder) -> void +const MarkupString.Mxp.Decoding.MxpDecoding.Protocol = "mxp" -> string! +const MarkupString.Mxp.Decoding.MxpFlagMarkup.RoomDescription = "RoomDesc" -> string! +const MarkupString.Mxp.Decoding.MxpFlagMarkup.RoomExits = "RoomExit" -> string! +const MarkupString.Mxp.Decoding.MxpFlagMarkup.RoomName = "RoomName" -> string! +const MarkupString.Mxp.Decoding.MxpFlagMarkup.Welcome = "Welcome" -> string! +override MarkupString.Mxp.Decoding.MxpFlagMarkup.Equals(object? obj) -> bool +override MarkupString.Mxp.Decoding.MxpFlagMarkup.GetHashCode() -> int +override MarkupString.Mxp.Decoding.MxpFlagMarkup.ToString() -> string! +static MarkupString.Mxp.Decoding.MxpDecoding.FlagCodec.get -> MarkupString.IMarkupCodec! +static MarkupString.Mxp.Decoding.MxpDecoding.WithMxp(this MarkupString.Decoding.DecoderRegistry! registry) -> MarkupString.Decoding.DecoderRegistry! +static MarkupString.Mxp.Decoding.MxpFlagMarkup.operator !=(MarkupString.Mxp.Decoding.MxpFlagMarkup? left, MarkupString.Mxp.Decoding.MxpFlagMarkup? right) -> bool +static MarkupString.Mxp.Decoding.MxpFlagMarkup.operator ==(MarkupString.Mxp.Decoding.MxpFlagMarkup? left, MarkupString.Mxp.Decoding.MxpFlagMarkup? right) -> bool +static MarkupString.Mxp.Decoding.MxpPlugin.Instance.get -> MarkupString.Mxp.Decoding.MxpPlugin! +static MarkupString.Mxp.Decoding.MxpPlugin.Supported.get -> System.Collections.Generic.IReadOnlyList! diff --git a/MarkupString.Mxp/README.md b/MarkupString.Mxp/README.md index 88d0f6c..4f0b046 100644 --- a/MarkupString.Mxp/README.md +++ b/MarkupString.Mxp/README.md @@ -33,6 +33,12 @@ line.Render(MarkupFormat.Mxp, registry); These are MXP's secure elements, which a client acts on only on a line opened in secure mode, so render through a registry with `WithMxpSecureLines()`. +## Reading MXP + +`WithMxp()` on a `DecoderRegistry` reads MXP from a server: line modes, open and secure tags, `SEND` +menus, custom elements and entities. Turn it on with `decoder.EnableProtocol(MxpDecoding.Protocol)` +once telnet negotiates it. See [Decoding](https://github.com/SharpMUSH/MarkupString/blob/main/docs/decoding.md#mxp). + ## What the client supports MXP lets a server ask which elements a client renders (``), and a client that answered diff --git a/MarkupString.Pueblo/Decoding/PuebloDecoding.cs b/MarkupString.Pueblo/Decoding/PuebloDecoding.cs new file mode 100644 index 0000000..dec48d6 --- /dev/null +++ b/MarkupString.Pueblo/Decoding/PuebloDecoding.cs @@ -0,0 +1,322 @@ +using System.Globalization; +using MarkupString.Ansi; +using MarkupString.Decoding; +namespace MarkupString.Pueblo.Decoding; + +/// Registers Pueblo decoding on a . +public static class PuebloDecoding +{ + /// The name to pass once the server has said it speaks Pueblo. + public const string Protocol = "pueblo"; + + /// The registry with . + public static DecoderRegistry WithPueblo(this DecoderRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + return registry.With(PuebloPlugin.Instance); + } + + /// + /// Whether is a server's Pueblo greeting (This world is Pueblo 1.10 Enhanced.), after + /// which a client answers PUEBLOCLIENT and turns the protocol on. + /// + public static bool IsGreeting(string line) => + line is not null && line.TrimStart().StartsWith("This world is Pueblo ", StringComparison.OrdinalIgnoreCase); +} + +/// +/// Pueblo as a text protocol: plain MUD text until the server sends </xch_mudtext> (or +/// <img xch_mode=html>), then HTML, in which line breaks are <br> and a bare line feed is +/// whitespace, until <xch_mudtext>. ANSI inside it is read as ever. +/// +/// +/// Styles (b, i, u, s, font color) become ; links +/// (a href, a xch_cmd) a link on one; and img, img xch_sound, xch_pane, +/// xch_page, xch_prefetch, pre and xch_mudtext the shared vocabulary. +/// +public sealed class PuebloPlugin : IDecoderPlugin +{ + /// The one instance. + public static PuebloPlugin Instance { get; } = new(); + + private PuebloPlugin() + { + } + + /// + public string Name => "pueblo"; + + /// + public void Register(DecoderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder + .TextProtocol(PuebloDecoding.Protocol, static () => new PuebloReader()) + .OnControl('\n', static (_, context) => + { + // In HTML a line feed is whitespace;
ends the line. + if (!context.IsActive(PuebloDecoding.Protocol)) return false; + var state = context.State(); + return state.Html && state.Preformatted == 0; + }) + .OnControl('\r', static (_, context) => + { + if (!context.IsActive(PuebloDecoding.Protocol)) return false; + var state = context.State(); + return state.Html && state.Preformatted == 0; + }); + } +} + +internal sealed class PuebloState +{ + public bool Html { get; set; } + + public int Preformatted { get; set; } + + public List<(string Name, IMarkup? First)> Open { get; } = []; + + public IMarkup? Pane { get; set; } +} + +internal sealed class PuebloReader : ITextProtocol, ITagSink +{ + private readonly TagScanner _scanner = new(); + private DecodeContext? _context; + + private DecodeContext Context => _context!; + + private PuebloState State => Context.State(); + + public bool ReadsMarkup => true; + + public void Feed(ReadOnlySpan text, DecodeContext context) + { + _context = context; + _scanner.Feed(text, this); + } + + public void EndLine(DecodeContext context) + { + _context = context; + _scanner.Flush(this); + } + + public void Reset(DecodeContext context) + { + _context = context; + _scanner.Reset(); + var state = State; + // Close the earliest of what this protocol opened, which closes the rest. + var ours = state.Open.Select(element => element.First).Append(state.Pane).OfType().ToHashSet(ReferenceEqualityComparer.Instance); + var first = context.Elements.FirstOrDefault(ours.Contains); + if (first is not null) context.Close(first); + state.Open.Clear(); + state.Pane = null; + state.Html = false; + state.Preformatted = 0; + } + + public void Text(ReadOnlySpan text) => Context.Print(text); + + public void Entity(ReadOnlySpan name) + { + var decoded = State.Html ? TagSyntax.Entity(name) : null; + Context.Print(decoded ?? $"&{name};"); + } + + public void Tag(ReadOnlySpan body) + { + var state = State; + var tag = TagSyntax.Parse(body, static name => null); + var name = tag?.Name.ToLowerInvariant(); + + if (name == "xch_mudtext") + { + if (tag!.Closing) + { + state.Html = true; + Close("xch_mudtext"); + } + else + { + state.Html = false; + OpenElement("xch_mudtext", PreformattedMarkup.Instance); + } + + return; + } + + if (name == "img" && tag!.Get("xch_mode") is { } mode) + { + state.Html = mode.Equals("html", StringComparison.OrdinalIgnoreCase) || mode.Equals("purehtml", StringComparison.OrdinalIgnoreCase); + return; + } + + if (!state.Html || tag is null) + { + // MUD text: what looks like a tag is text. + Context.Print($"<{body}>"); + return; + } + + if (tag.Closing) + { + if (name == "pre" && state.Preformatted > 0) state.Preformatted--; + Close(name!); + return; + } + + if (!Element(tag, name!)) Context.Diagnostics.UnhandledSequences++; + } + + private bool Element(ParsedTag tag, string name) + { + var context = Context; + switch (name) + { + case "b" or "strong": + OpenStyle(name, new AnsiStyle { Bold = true }); + return true; + case "i" or "em": + OpenStyle(name, new AnsiStyle { Italic = true }); + return true; + case "u": + OpenStyle(name, new AnsiStyle { Underlined = true }); + return true; + case "s" or "strike" or "del": + OpenStyle(name, new AnsiStyle { StrikeThrough = true }); + return true; + case "font": + OpenStyle(name, new AnsiStyle { Foreground = Color(tag.Get("color")), Background = Color(tag.Get("bgcolor")) }); + return true; + case "a": + { + AnsiStyle? link = null; + var hint = tag.Get("xch_hint"); + if (tag.Get("xch_cmd") is { Length: > 0 } command) + { + link = new AnsiStyle { LinkUrl = command, LinkKind = LinkKind.Command, LinkText = hint }; + } + else if (tag.Get("href") is { } href && Context.Options.Links.IsAllowed(href)) + { + link = new AnsiStyle { LinkUrl = href, LinkKind = LinkKind.Url, LinkText = hint }; + } + + OpenElement(name, link is { } style ? new AnsiMarkup(style) : null); + return true; + } + case "br": + context.EndLine(); + return true; + case "p" or "hr": + if (!context.LineIsEmpty) context.EndLine(); + return true; + case "pre": + State.Preformatted++; + OpenElement(name, PreformattedMarkup.Instance); + return true; + case "img": + Image(tag); + return true; + case "xch_page": + if (string.Equals(tag.Get("clear"), "text", StringComparison.OrdinalIgnoreCase)) context.Point(ClearScreenMarkup.Instance); + return true; + case "xch_prefetch": + if (tag.Get("href") is { Length: > 0 } source) context.Point(new PrefetchMarkup(source)); + return true; + case "xch_pane": + Pane(tag); + return true; + case "html" or "head" or "body" or "title" or "tt" or "small" or "big" or "center" or "span" or "div" or "nobr": + return true; + default: + return false; + } + } + + private void Image(ParsedTag tag) + { + var context = Context; + if (tag.Get("xch_sound") is { } action) + { + var device = tag.Get("xch_device"); + var channel = device is null ? (SoundChannel?)null + : device.Equals("midi", StringComparison.OrdinalIgnoreCase) ? SoundChannel.Music : SoundChannel.Effects; + if (action.Equals("stop", StringComparison.OrdinalIgnoreCase)) + { + context.Point(new SoundStopMarkup(channel)); + return; + } + + if (tag.Get("href") is not { Length: > 0 } href) return; + int? volume = int.TryParse(tag.Get("xch_volume"), NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? Math.Clamp(v, 0, 100) : null; + var loops = action.Equals("loop", StringComparison.OrdinalIgnoreCase); + context.Point(new SoundMarkup(href, channel ?? SoundChannel.Effects, volume, loops ? SoundMarkup.Forever : null)); + return; + } + + if (tag.Get("src") is not { Length: > 0 } src) return; + var alt = tag.Get("alt"); + int? width = int.TryParse(tag.Get("width"), NumberStyles.None, CultureInfo.InvariantCulture, out var w) && w > 0 ? w : null; + int? height = int.TryParse(tag.Get("height"), NumberStyles.None, CultureInfo.InvariantCulture, out var h) && h > 0 ? h : null; + var align = Enum.TryParse(tag.Get("align"), ignoreCase: true, out var parsed) ? parsed : (ImageAlign?)null; + var image = new ImageMarkup(src, string.IsNullOrEmpty(alt) ? null : alt, width, height, align); + context.Write(string.IsNullOrEmpty(alt) ? " " : alt, image); + } + + private void Pane(ParsedTag tag) + { + if (!string.Equals(tag.Get("action"), "redirect", StringComparison.OrdinalIgnoreCase)) return; + var state = State; + if (state.Pane is { } current) + { + Context.Close(current); + state.Pane = null; + } + + var name = tag.Get("name"); + if (string.IsNullOrEmpty(name) || name.StartsWith('_')) return; + var pane = new PaneMarkup(name, tag.Get("panetitle")); + if (Context.Open(pane)) state.Pane = pane; + } + + private void OpenStyle(string name, AnsiStyle style) => OpenElement(name, style.IsNone ? null : new AnsiMarkup(style)); + + private void OpenElement(string name, IMarkup? markup) + { + if (markup is not null && !Context.Open(markup)) markup = null; + State.Open.Add((name, markup)); + } + + private void Close(string name) + { + var open = State.Open; + var index = open.FindLastIndex(element => element.Name == name); + if (index < 0) return; + for (var i = index; i < open.Count; i++) + { + if (open[i].First is { } first) + { + Context.Close(first); + break; + } + } + + open.RemoveRange(index, open.Count - index); + } + + private static AnsiColor? Color(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return null; + name = name.Trim(); + if (name[0] == '#' && name.Length == 7 + && int.TryParse(name.AsSpan(1), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value)) + { + return new AnsiColor.Rgb((byte)(value >> 16), (byte)(value >> 8), (byte)value); + } + + var known = System.Drawing.Color.FromName(name); + return known.IsKnownColor && !known.IsSystemColor ? new AnsiColor.Rgb(known.R, known.G, known.B) : null; + } +} diff --git a/MarkupString.Pueblo/PublicAPI.Unshipped.txt b/MarkupString.Pueblo/PublicAPI.Unshipped.txt index 7dc5c58..ec744c5 100644 --- a/MarkupString.Pueblo/PublicAPI.Unshipped.txt +++ b/MarkupString.Pueblo/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ #nullable enable +MarkupString.Pueblo.Decoding.PuebloDecoding +MarkupString.Pueblo.Decoding.PuebloPlugin +MarkupString.Pueblo.Decoding.PuebloPlugin.Name.get -> string! +MarkupString.Pueblo.Decoding.PuebloPlugin.Register(MarkupString.Decoding.DecoderBuilder! builder) -> void +const MarkupString.Pueblo.Decoding.PuebloDecoding.Protocol = "pueblo" -> string! +static MarkupString.Pueblo.Decoding.PuebloDecoding.IsGreeting(string! line) -> bool +static MarkupString.Pueblo.Decoding.PuebloDecoding.WithPueblo(this MarkupString.Decoding.DecoderRegistry! registry) -> MarkupString.Decoding.DecoderRegistry! +static MarkupString.Pueblo.Decoding.PuebloPlugin.Instance.get -> MarkupString.Pueblo.Decoding.PuebloPlugin! diff --git a/MarkupString.Pueblo/README.md b/MarkupString.Pueblo/README.md index 3f6dfab..5430e10 100644 --- a/MarkupString.Pueblo/README.md +++ b/MarkupString.Pueblo/README.md @@ -30,6 +30,11 @@ line.Render(MarkupFormat.Pueblo, registry); // North: the gate ``` +## Reading Pueblo + +`WithPueblo()` on a `DecoderRegistry` reads Pueblo's HTML from a server. Turn it on with +`decoder.EnableProtocol(PuebloDecoding.Protocol)` after the greeting. See [Decoding](https://github.com/SharpMUSH/MarkupString/blob/main/docs/decoding.md#pueblo). + ## Line endings A Pueblo connection is in HTML mode, where a newline is whitespace, so `MarkupFormat.Pueblo` writes a diff --git a/MarkupString.Tests/Ansi/AnsiCssTests.cs b/MarkupString.Tests/Ansi/AnsiCssTests.cs index 93a1167..7cecfa8 100644 --- a/MarkupString.Tests/Ansi/AnsiCssTests.cs +++ b/MarkupString.Tests/Ansi/AnsiCssTests.cs @@ -23,6 +23,7 @@ public class AnsiCssTests AnsiStyle.None with { Overlined = true }, AnsiStyle.None with { Blink = true }, AnsiStyle.None with { Inverted = true }, + AnsiStyle.None with { Concealed = true }, ]; [Test] diff --git a/MarkupString.Tests/Decoding/DisplayTests.cs b/MarkupString.Tests/Decoding/DisplayTests.cs new file mode 100644 index 0000000..e73d0de --- /dev/null +++ b/MarkupString.Tests/Decoding/DisplayTests.cs @@ -0,0 +1,235 @@ +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Display; + +public class DisplayTests +{ + private static readonly AnsiMarkup Red = AnsiMarkup.Create(foreground: new AnsiColor.Standard(1, false)); + private static readonly AnsiMarkup Link = new(new AnsiStyle { LinkUrl = "look", LinkKind = LinkKind.Command }); + private static readonly DisplayOptions Browser = new(); + + [Test] + public async Task Resolve_Plain_ForNoMarkup() + { + await Assert.That(AnsiDisplay.Default.Resolve(null, Browser)).IsEqualTo(DisplayStyle.Plain); + } + + [Test] + public async Task Resolve_PlainTextOutput_DropsEverything() + { + var options = new DisplayOptions { Capabilities = OutputCapabilities.PlainText }; + var style = AnsiDisplay.Default.Resolve(MarkupSet.Of([Red, Link]), options); + await Assert.That(style).IsEqualTo(DisplayStyle.Plain); + } + + [Test] + public async Task Resolve_ReducesColours_ToWhatTheOutputShows() + { + var orange = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(250, 130, 10), background: new AnsiColor.Rgb(10, 10, 120)); + var sixteen = new DisplayOptions { Capabilities = OutputCapabilities.Browser with { Colors = ColorLevel.Standard } }; + var xterm = new DisplayOptions { Capabilities = OutputCapabilities.Browser with { Colors = ColorLevel.Xterm256 } }; + + var reduced = AnsiDisplay.Default.Resolve(MarkupSet.Of(orange), sixteen); + await Assert.That(reduced.Foreground).IsEqualTo(new RgbColor(255, 85, 85)); + await Assert.That(reduced.Background).IsEqualTo(new RgbColor(0, 0, 170)); + + var cube = AnsiDisplay.Default.Resolve(MarkupSet.Of(orange), xterm); + await Assert.That(cube.Foreground).IsEqualTo(new RgbColor(255, 135, 0)); + await Assert.That(cube.Background).IsEqualTo(new RgbColor(0, 0, 135)); + + var full = AnsiDisplay.Default.Resolve(MarkupSet.Of(orange), Browser); + await Assert.That(full.Foreground).IsEqualTo(new RgbColor(250, 130, 10)); + } + + [Test] + public async Task Resolve_LinkGroup_NamesTheInteraction() + { + var style = AnsiDisplay.Default.Resolve(MarkupSet.Of([new LinkGroupMarkup("inner"), Link, new LinkGroupMarkup("outer")]), Browser); + await Assert.That(style.Interaction).IsEqualTo(new Interaction(InteractionKind.Command, "look") { Group = "inner" }); + await Assert.That(AnsiDisplay.Default.Resolve(MarkupSet.Of(new LinkGroupMarkup("alone")), Browser).Interaction).IsNull(); + } + + [Test] + public async Task Resolve_PromptBecomesCommand_WhenPromptsAreNotShown() + { + var menu = new CommandMenuMarkup([new CommandChoice("look", "look")], Prompt: false); + var options = new DisplayOptions { Capabilities = OutputCapabilities.Browser with { Interactions = InteractionKinds.Command } }; + var style = AnsiDisplay.Default.Resolve(MarkupSet.Of(menu), options); + await Assert.That(style.Interaction!.Kind).IsEqualTo(InteractionKind.Command); + await Assert.That(style.Interaction.Choices).IsNull(); + } + + [Test] + public async Task Resolve_Menu_KeepsChoices() + { + var menu = new CommandMenuMarkup([new CommandChoice("Buy", "buy sword"), new CommandChoice("Look", "look sword")], "Sword"); + var style = AnsiDisplay.Default.Resolve(MarkupSet.Of(menu), Browser); + await Assert.That(style.Interaction!.Target).IsEqualTo("buy sword"); + await Assert.That(style.Interaction.Hint).IsEqualTo("Sword"); + await Assert.That(style.Interaction.Choices!.Count).IsEqualTo(2); + } + + [Test] + public async Task Resolve_Highlight_WinsOverAnsiBackground() + { + var background = AnsiMarkup.Create(background: new AnsiColor.Standard(4, false)); + var style = AnsiDisplay.Default.Resolve(MarkupSet.Of([new HighlightMarkup(new RgbColor(255, 255, 0)), background]), Browser); + await Assert.That(style.Background).IsEqualTo(new RgbColor(255, 255, 0)); + } + + [Test] + public async Task Resolve_InnerColourWins() + { + var green = AnsiMarkup.Create(foreground: new AnsiColor.Standard(2, false)); + var style = AnsiDisplay.Default.Resolve(MarkupSet.Of([green, Red]), Browser); + await Assert.That(style.Foreground).IsEqualTo(new RgbColor(0, 170, 0)); + } + + [Test] + public async Task BoldIsBright() + { + var registry = DisplayRegistry.Core.WithAnsi(boldIsBright: true); + var style = registry.Resolve(MarkupSet.Of(AnsiMarkup.Create(foreground: new AnsiColor.Standard(1, false), bold: true)), Browser); + await Assert.That(style.Foreground).IsEqualTo(new RgbColor(255, 85, 85)); + } + + [Test] + public async Task Resolve_PromptMenu_IsAPrompt() + { + var menu = new CommandMenuMarkup([new CommandChoice("Say", "say ")], Prompt: true); + var style = AnsiDisplay.Default.Resolve(MarkupSet.Of(menu), Browser); + await Assert.That(style.Interaction!.Kind).IsEqualTo(InteractionKind.Prompt); + await Assert.That(SpanRenderer.CssClasses(style)).IsEqualTo("ms-prompt"); + } + + [Test] + public async Task Inverse_WithoutColours_IsAClass() + { + var options = new DisplayOptions { Capabilities = OutputCapabilities.Browser with { Colors = ColorLevel.Attributes } }; + var style = AnsiDisplay.Default.Resolve(MarkupSet.Of(AnsiMarkup.Create(inverted: true)), options); + await Assert.That(SpanRenderer.CssClasses(style)).IsEqualTo("ms-invert"); + } + + [Test] + public async Task Spans_MergeEqualStyles() + { + var text = MarkupText.Concat([MarkupText.Wrap(Red, "a"), MarkupText.Wrap(AnsiMarkup.Create(foreground: new AnsiColor.Xterm(1)), "b"), MarkupText.Plain("c")]); + var spans = SpanRenderer.Render(text, AnsiDisplay.Default, Browser); + await Assert.That(spans.Select(span => span.Text)).IsEquivalentTo(["ab", "c"]); + } + + [Test] + public async Task Spans_AsciiOutput_FoldsText() + { + var options = new DisplayOptions { Capabilities = OutputCapabilities.Browser with { Unicode = UnicodeLevel.Ascii } }; + var spans = SpanRenderer.Render(MarkupText.Plain("café"), AnsiDisplay.Default, options); + await Assert.That(spans.Single().Text).IsEqualTo("cafe"); + } + + [Test] + public async Task Spans_Sound_DroppedWithoutSound() + { + var text = MarkupText.Concat([MarkupText.Plain("a"), MarkupText.Sound("boom.wav")]); + var options = new DisplayOptions { Capabilities = OutputCapabilities.Browser with { Sound = false } }; + await Assert.That(SpanRenderer.Render(text, AnsiDisplay.Default, options).Count).IsEqualTo(1); + await Assert.That(SpanRenderer.Render(text, AnsiDisplay.Default, Browser)[^1].Point).IsTypeOf(); + } + + [Test] + public async Task Css_And_Classes() + { + var style = new DisplayStyle + { + Foreground = new RgbColor(255, 0, 0), + Attributes = TextAttributes.Bold | TextAttributes.Underline | TextAttributes.Strikethrough | TextAttributes.Blink, + Interaction = new Interaction(InteractionKind.Command, "look"), + }; + await Assert.That(SpanRenderer.Css(style)).IsEqualTo("color:#ff0000;font-weight:bold;text-decoration:underline line-through"); + await Assert.That(SpanRenderer.CssClasses(style)).IsEqualTo("ms-blink ms-command"); + } + + [Test] + public async Task Cells_WideCharacters_TakeTwoCells() + { + var layout = CellRenderer.Layout(MarkupText.Plain("a漢b"), 0, AnsiDisplay.Default, Browser); + var cells = layout.Rows.Single().Cells; + await Assert.That(cells.Select(cell => cell.Text)).IsEquivalentTo(["a", "漢", "", "b"]); + await Assert.That(cells[2].IsContinuation).IsTrue(); + } + + [Test] + public async Task Cells_WrapAtTheWidth() + { + var layout = CellRenderer.Layout(MarkupText.Plain("abcdefg"), 3, AnsiDisplay.Default, Browser); + await Assert.That(layout.Rows.Select(row => string.Concat(row.Cells.Select(cell => cell.Text)))).IsEquivalentTo(["abc", "def", "g"]); + await Assert.That(layout.Rows.Select(row => row.Wrapped)).IsEquivalentTo([true, true, false]); + } + + [Test] + public async Task Cells_WideCharacterDoesNotSplit() + { + var layout = CellRenderer.Layout(MarkupText.Plain("ab漢"), 3, AnsiDisplay.Default, Browser); + await Assert.That(layout.Rows.Select(row => string.Concat(row.Cells.Select(cell => cell.Text)))).IsEquivalentTo(["ab", "漢"]); + } + + [Test] + public async Task Cells_WordWrap() + { + var layout = CellRenderer.Layout(MarkupText.Plain("the quick fox"), 10, AnsiDisplay.Default, Browser, wordWrap: true); + await Assert.That(layout.Rows.Select(row => string.Concat(row.Cells.Select(cell => cell.Text)))).IsEquivalentTo(["the quick ", "fox"]); + } + + [Test] + public async Task Cells_LineFeed_StartsARow() + { + var layout = CellRenderer.Layout(MarkupText.Plain("a\nb"), 0, AnsiDisplay.Default, Browser); + await Assert.That(layout.Rows.Count).IsEqualTo(2); + await Assert.That(layout.Rows[0].Wrapped).IsFalse(); + } + + [Test] + public async Task Cells_Tab_AdvancesToTheStop() + { + var layout = CellRenderer.Layout(MarkupText.Plain("ab\tc"), 0, AnsiDisplay.Default, new DisplayOptions { TabWidth = 4 }); + await Assert.That(layout.Rows[0].Cells.Count).IsEqualTo(5); + await Assert.That(layout.Rows[0].Cells[4].Text).IsEqualTo("c"); + } + + [Test] + public async Task Cells_HitTest_FindsTheLink() + { + var text = MarkupText.Concat([MarkupText.Plain("go "), MarkupText.Wrap(Link, "漢")]); + var layout = CellRenderer.Layout(text, 0, AnsiDisplay.Default, Browser); + await Assert.That(layout.InteractionAt(0, 0)).IsNull(); + await Assert.That(layout.InteractionAt(0, 3)!.Target).IsEqualTo("look"); + await Assert.That(layout.InteractionAt(0, 4)!.Target).IsEqualTo("look"); + await Assert.That(layout.InteractionAt(0, 5)).IsNull(); + } + + [Test] + public async Task Cells_PictureColumns_CountFromTheFirstShown() + { + var picture = new ImageMarkup("cat.png") { Row = new PictureRow(0, 2, 3) { FirstColumn = 1 } }; + var layout = CellRenderer.Layout(MarkupText.Wrap(picture, " "), 0, AnsiDisplay.Default, Browser); + await Assert.That(layout.Rows[0].Cells.Select(cell => cell.PictureColumn)).IsEquivalentTo([1, 2, 3]); + } + + [Test] + public async Task Cells_PicturesDropped_WhenNotShown() + { + var picture = new ImageMarkup("cat.png") { Row = new PictureRow(0, 1, 2) }; + var options = new DisplayOptions { Capabilities = OutputCapabilities.PlainText }; + var layout = CellRenderer.Layout(MarkupText.Wrap(picture, " "), 0, AnsiDisplay.Default, options); + await Assert.That(layout.Rows[0].Cells[0].Style.Picture).IsNull(); + } + + [Test] + public async Task MenuMarkup_RoundTripsThroughJson() + { + var menu = new CommandMenuMarkup([new CommandChoice("Buy", "buy"), new CommandChoice("Look", "look")], "hint", Prompt: true); + var text = MarkupText.Wrap(new HighlightMarkup(new RgbColor(1, 2, 3)), MarkupText.Wrap(menu, "sword")); + var json = MarkupTextSerializer.Serialize(text); + var back = MarkupTextSerializer.Deserialize(json); + await Assert.That(back.Runs.Single().Markups).IsEqualTo(text.Runs.Single().Markups); + } +} diff --git a/MarkupString.Tests/Decoding/InjectionTests.cs b/MarkupString.Tests/Decoding/InjectionTests.cs new file mode 100644 index 0000000..7783643 --- /dev/null +++ b/MarkupString.Tests/Decoding/InjectionTests.cs @@ -0,0 +1,195 @@ +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; +using MarkupString.Extensions.DependencyInjection; +using MarkupString.Html; +using MarkupString.Mxp; +using MarkupString.Mxp.Decoding; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +/// The seams a host injects through, and the package that wires them into a service collection. +public class InjectionTests +{ + private const string MudLink = "\e]8;;mudclient://look\e\\look\e]8;;\e\\\n"; + + private static Interaction? FirstInteraction(IReadOnlyList lines, IStyleResolver styles, DisplayOptions options) => + SpanRenderer.Render(lines[0].Content, styles, options)[0].Style.Interaction; + + [Test] + public async Task UrlPolicy_AddsSchemes_ButNeverTheDangerousOnes() + { + var policy = UrlPolicy.WithSchemes("mudclient", "JavaScript"); + await Assert.That(policy.IsAllowed("mudclient://look")).IsTrue(); + await Assert.That(policy.IsAllowed("javascript:alert(1)")).IsFalse(); + await Assert.That(policy.IsAllowed("/relative")).IsTrue(); + await Assert.That(UrlPolicy.Safe.IsAllowed("mudclient://look")).IsFalse(); + } + + [Test] + public async Task UrlPolicy_DecidesDecodedAndDisplayedLinks() + { + var policy = UrlPolicy.WithSchemes("mudclient"); + var lines = VtDecoding.Terminal.CreateDecoder(new DecoderOptions { Links = policy }).Feed(MudLink); + var display = new DisplayOptions { Links = policy }; + await Assert.That(FirstInteraction(lines, AnsiDisplay.Default, display)).IsEqualTo(new Interaction(InteractionKind.Url, "mudclient://look")); + await Assert.That(FirstInteraction(VtDecoding.Terminal.CreateDecoder().Feed(MudLink), AnsiDisplay.Default, display)).IsNull(); + } + + [Test] + public async Task Diagnostics_AreReportedAsMetrics() + { + var services = new ServiceCollection().AddMetrics().BuildServiceProvider(); + var meters = services.GetRequiredService(); + var seen = new List<(long Value, string Kind)>(); + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == "MarkupString.Decoding" && instrument.Meter.Scope == meters) l.EnableMeasurementEvents(instrument); + }; + listener.SetMeasurementEventCallback((_, value, tags, _) => seen.Add((value, (string)tags[0].Value!))); + listener.Start(); + + var decoder = VtDecoding.Terminal.CreateDecoder(new DecoderOptions { Meters = meters }); + decoder.Feed("\e[5;5H\e]777;x\a"); + + await Assert.That(seen).IsEquivalentTo([(1L, "unhandled-sequence"), (1L, "unhandled-sequence")]); + } + + [Test] + public async Task AddMarkupString_RegistersEverything() + { + using var services = new ServiceCollection() + .AddMarkupString(options => options.Pictures.MaxPixels = 100) + .AddDecoders(registry => registry.WithMxp()) + .Services.BuildServiceProvider(); + + var factory = services.GetRequiredService(); + var first = factory.CreateDecoder(); + var second = factory.CreateDecoder(); + await Assert.That(first.Registry.Has(MxpDecoding.Protocol)).IsTrue(); + await Assert.That(first.Pictures().Cache).IsSameReferenceAs(second.Pictures().Cache); + await Assert.That(first.Pictures().Cache).IsSameReferenceAs(services.GetRequiredService()); + await Assert.That(first.Pictures().MaxPixels).IsEqualTo(100); + await Assert.That(services.GetRequiredService()).IsSameReferenceAs(services.GetRequiredService()); + await Assert.That(services.GetRequiredService()).IsNotNull(); + } + + [Test] + public async Task AddMarkupString_BindsConfiguration() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["MarkupString:LinkSchemes:0"] = "mudclient", + ["MarkupString:Display:Output"] = "CellGrid", + ["MarkupString:Display:Colors"] = "Standard", + ["MarkupString:Decoding:MaxLineLength"] = "80", + ["MarkupString:Pictures:Budget"] = "1000", + }).Build(); + using var services = new ServiceCollection() + .AddSingleton(configuration) + .AddMarkupString().BindConfiguration() + .Services.BuildServiceProvider(); + + var display = services.GetRequiredService(); + await Assert.That(display.Capabilities).IsEqualTo(OutputCapabilities.CellGrid with { Colors = ColorLevel.Standard }); + await Assert.That(((MemoryPictureCache)services.GetRequiredService()).Budget).IsEqualTo(1000); + var decoder = services.GetRequiredService().CreateDecoder(); + await Assert.That(decoder.Options.MaxLineLength).IsEqualTo(80); + var lines = decoder.Feed(MudLink); + await Assert.That(FirstInteraction(lines, services.GetRequiredService(), display)?.Target).IsEqualTo("mudclient://look"); + } + + private sealed class NoCache : IPictureCache + { + public bool TryGet(string key, [NotNullWhen(true)] out StoredPicture? picture) + { + picture = null; + return false; + } + + public void Set(StoredPicture picture) + { + } + + public bool Remove(string key) => false; + } + + [Test] + public async Task AddMarkupString_TakesAPictureCacheOfYourOwn() + { + using var services = new ServiceCollection().AddMarkupString().UsePictureCache().Services.BuildServiceProvider(); + await Assert.That(services.GetRequiredService().CreateDecoder().Pictures().Cache).IsTypeOf(); + } + + public static IEnumerable> LinkFormats() => + [() => MarkupFormat.Ansi, () => MarkupFormat.Html, () => MarkupFormat.Mxp, () => MarkupFormat.BBCode, () => MarkupFormat.Markdown]; + + [Test] + [MethodDataSource(nameof(LinkFormats))] + public async Task Writers_AskTheRegistrysLinkPolicy(MarkupFormat format) + { + var link = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { LinkUrl = "mudclient://look", LinkKind = LinkKind.Url }), "look"); + var safe = MarkupRegistry.Empty.WithAnsi().WithMxp().WithAnsiOutput(AnsiColorDepth.TrueColor); + var open = safe.WithLinks(UrlPolicy.WithSchemes("mudclient")); + await Assert.That(link.Render(format, safe)).DoesNotContain("mudclient://look"); + await Assert.That(link.Render(format, open)).Contains("mudclient://look"); + await Assert.That(open.WithMxp().Links).IsSameReferenceAs(open.Links); + } + + [Test] + public async Task HtmlTagPolicy_AsksItsLinkPolicy() + { + var policy = HtmlTagPolicy.WellFormed with { UrlAttributes = HtmlTagPolicy.AddressAttributes }; + await Assert.That(policy.TryCreate("a", "href=\"mudclient://look\"", out var dropped)).IsTrue(); + await Assert.That(dropped!.Attributes).IsNull(); + await Assert.That((policy with { Links = UrlPolicy.WithSchemes("mudclient") }).TryCreate("a", "href=\"mudclient://look\"", out var kept)).IsTrue(); + await Assert.That(kept!.Attributes).Contains("mudclient://look"); + } + + [Test] + public async Task Diagnostics_RaiseDropped() + { + var decoder = VtDecoding.Terminal.CreateDecoder(); + var dropped = new List<(DecodeDrop, int)>(); + decoder.Diagnostics.Dropped += (kind, count) => dropped.Add((kind, count)); + decoder.Feed("\e[5;5H\u0001"); + await Assert.That(dropped).IsEquivalentTo([(DecodeDrop.UnhandledSequence, 1), (DecodeDrop.UnhandledControl, 1)]); + } + + private sealed class Captured : ILoggerProvider, ILogger + { + public List Lines { get; } = []; + + public ILogger CreateLogger(string categoryName) => this; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Lines.Add(formatter(state, exception)); + + public void Dispose() + { + } + } + + [Test] + public async Task AddMarkupString_LogsWhatDecodersDrop() + { + var captured = new Captured(); + using var services = new ServiceCollection() + .AddLogging(logging => logging.SetMinimumLevel(LogLevel.Debug).AddProvider(captured)) + .AddMarkupString(options => options.LinkSchemes.Add("mudclient")) + .Services.BuildServiceProvider(); + services.GetRequiredService().CreateDecoder().Feed("\e[5;5H"); + await Assert.That(captured.Lines).IsEquivalentTo(["Decoder dropped 1 × UnhandledSequence"]); + var link = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { LinkUrl = "mudclient://look", LinkKind = LinkKind.Url }), "look"); + await Assert.That(link.Render(MarkupFormat.Html, services.GetRequiredService())).Contains("href=\"mudclient://look\""); + } +} diff --git a/MarkupString.Tests/Decoding/MxpDecodingTests.cs b/MarkupString.Tests/Decoding/MxpDecodingTests.cs new file mode 100644 index 0000000..734a819 --- /dev/null +++ b/MarkupString.Tests/Decoding/MxpDecodingTests.cs @@ -0,0 +1,237 @@ +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; +using MarkupString.Mxp; +using MarkupString.Mxp.Decoding; + +public class MxpDecodingTests +{ + private static readonly DecoderRegistry Registry = VtDecoding.Terminal.WithMxp(); + + private static (List Lines, List Events, Decoder Decoder) Decode(string text, DecoderOptions? options = null) + { + var decoder = Registry.CreateDecoder(options); + var events = new List(); + decoder.EventRaised += events.Add; + decoder.EnableProtocol(MxpDecoding.Protocol); + var lines = new List(decoder.Feed(text)); + if (decoder.HasPendingLine) lines.Add(decoder.EndLine()); + return (lines, events, decoder); + } + + private static IReadOnlyList Spans(DisplayLine line) => SpanRenderer.Render(line.Content, AnsiDisplay.Default, new DisplayOptions()); + + private static IEnumerable Markups(DisplayLine line) => line.Content.Runs.SelectMany(run => run.Markups); + + [Test] + public async Task OpenTags_WorkOnAnOpenLine() + { + var (lines, _, _) = Decode("bold red\n"); + var spans = Spans(lines[0]); + await Assert.That(spans.Select(span => span.Text)).IsEquivalentTo(["bold", " ", "red"]); + await Assert.That(spans[0].Style.Has(TextAttributes.Bold)).IsTrue(); + await Assert.That(spans[2].Style.Foreground).IsEqualTo(new RgbColor(255, 0, 0)); + } + + [Test] + public async Task SecureTags_AreDroppedOnAnOpenLine() + { + var (lines, _, decoder) = Decode("look\n"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("look"); + await Assert.That(Spans(lines[0]).Single().Style.Interaction).IsNull(); + await Assert.That(decoder.Diagnostics.UnhandledSequences).IsEqualTo(1); + } + + [Test] + public async Task Send_OnASecureLine_IsACommand() + { + var (lines, _, _) = Decode("\e[1zsword here\n"); + var spans = Spans(lines[0]); + await Assert.That(spans[0].Text).IsEqualTo("sword"); + await Assert.That(spans[0].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Command, "look sword") { Hint = "Look at it" }); + await Assert.That(spans[1].Style.Interaction).IsNull(); + } + + [Test] + public async Task Send_WithoutHref_SendsItsText() + { + var (lines, _, _) = Decode("\e[1znorth\n"); + await Assert.That(Spans(lines[0]).Single().Style.Interaction!.Target).IsEqualTo("north"); + } + + [Test] + public async Task Send_TextSubstitution() + { + var (lines, _, _) = Decode("\e[1zsword\n"); + await Assert.That(Spans(lines[0]).Single().Style.Interaction!.Target).IsEqualTo("get sword"); + } + + [Test] + public async Task Send_Menu() + { + var (lines, _, _) = Decode("\e[1zsword\n"); + var interaction = Spans(lines[0]).Single().Style.Interaction!; + await Assert.That(interaction.Target).IsEqualTo("buy sword"); + await Assert.That(interaction.Hint).IsEqualTo("Sword"); + await Assert.That(interaction.Choices!).IsEquivalentTo([new CommandChoice("Buy", "buy sword"), new CommandChoice("Look", "look sword")]); + } + + [Test] + public async Task Send_Prompt() + { + var (lines, _, _) = Decode("\e[1ztalk\n"); + await Assert.That(Spans(lines[0]).Single().Style.Interaction!.Kind).IsEqualTo(InteractionKind.Prompt); + } + + [Test] + public async Task OpenElements_CloseAtTheEndOfTheLine() + { + var (lines, _, _) = Decode("one\ntwo\n"); + await Assert.That(Spans(lines[1]).Single().Style.Has(TextAttributes.Bold)).IsFalse(); + } + + [Test] + public async Task LockedLine_IsText() + { + var (lines, _, _) = Decode("\e[2z<\nx\n"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("<"); + await Assert.That(lines[1].Content.ToPlainText()).IsEqualTo("x"); + } + + [Test] + public async Task Entities() + { + var (lines, _, _) = Decode("a <b> & AB &nope; & c\n"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("a & AB &nope; & c"); + } + + [Test] + public async Task TagsSplitAcrossFeeds() + { + var decoder = Registry.CreateDecoder(); + decoder.EnableProtocol(MxpDecoding.Protocol); + decoder.Feed("x\n"); + await Assert.That(SpanRenderer.Render(lines[0].Content, AnsiDisplay.Default, new DisplayOptions()).Single().Style.Foreground).IsEqualTo(new RgbColor(255, 0, 0)); + } + + [Test] + public async Task Comments_AreDropped() + { + var (lines, _, _) = Decode("ab\n"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("ab"); + } + + [Test] + public async Task SgrInsideMxp_Wins() + { + var (lines, _, _) = Decode("\e[32mgreen\e[0m\n"); + await Assert.That(Spans(lines[0]).Single().Style.Foreground).IsEqualTo(new RgbColor(0, 170, 0)); + } + + [Test] + public async Task CustomElements() + { + var (lines, _, _) = Decode("\e[1z' ATT='dir' FLAG=RoomExit>\n\e[1zN S\n"); + var spans = Spans(lines[1]); + await Assert.That(spans[0].Text).IsEqualTo("N"); + await Assert.That(spans[0].Style.Interaction!.Target).IsEqualTo("north"); + await Assert.That(spans[0].Style.Foreground).IsEqualTo(new RgbColor(0, 255, 255)); + await Assert.That(Markups(lines[1]).OfType().First().Flag).IsEqualTo("RoomExit"); + await Assert.That(spans[1].Style).IsEqualTo(DisplayStyle.Plain); + } + + [Test] + public async Task LineTags_MarkTheLine() + { + var (lines, _, _) = Decode("\e[10zThe Square\nnext\n"); + await Assert.That(Markups(lines[0]).OfType().Single().Flag).IsEqualTo(MxpFlagMarkup.RoomName); + await Assert.That(Markups(lines[1]).Any()).IsFalse(); + } + + [Test] + public async Task Version_And_Support_AreAnswered() + { + var options = new DecoderOptions { Identity = new ClientIdentity { Name = "Test", Version = "3" } }; + var (_, events, _) = Decode("\e[1z\e[1z", options); + await Assert.That(events.OfType().Select(reply => reply.Text)).IsEquivalentTo([ + "\e[1z\n", + "\e[1z\n", + ]); + } + + [Test] + public async Task Var_SetsAVariable_AndShowsItsText() + { + var (lines, events, _) = Decode("\e[1zHP: 42\n"); + await Assert.That(events.OfType().Single()).IsEqualTo(new VariableEvent("hp", "42")); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("HP: 42"); + await Assert.That(Markups(lines[0]).OfType().Single().Name).IsEqualTo("hp"); + } + + [Test] + public async Task Entity_Definition_IsUsedAndPublished() + { + var (lines, events, _) = Decode("\e[1z\nHello &hero;\n"); + await Assert.That(lines[^1].Content.ToPlainText()).IsEqualTo("Hello Ada"); + await Assert.That(events.OfType().Single()).IsEqualTo(new VariableEvent("hero", "Ada")); + } + + [Test] + public async Task Br_BreaksWithoutEndingTheSecureLine() + { + var (lines, _, _) = Decode("\e[1zone
two\n"); + await Assert.That(lines.Select(line => line.Content.ToPlainText())).IsEquivalentTo(["one", "two"]); + await Assert.That(Spans(lines[1]).Single().Style.Interaction).IsNotNull(); + } + + [Test] + public async Task Reset_ClosesEverything() + { + var (lines, _, _) = Decode("\e[6zbold\e[3zplain\n"); + var spans = Spans(lines[0]); + await Assert.That(spans[^1].Text).IsEqualTo("plain"); + await Assert.That(spans[^1].Style.Has(TextAttributes.Bold)).IsFalse(); + } + + [Test] + public async Task LineModes_AreIgnoredUntilMxpIsOn() + { + var decoder = Registry.CreateDecoder(); + var lines = decoder.Feed("\e[1zx\n"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("x"); + await Assert.That(decoder.Diagnostics.UnhandledSequences).IsEqualTo(0); + } + + [Test] + public async Task WhatThisPackageWrites_ReadsBack() + { + var registry = MarkupRegistry.Empty.WithAnsi().WithMxp().WithMxpSecureLines(); + var link = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { LinkUrl = "look", LinkKind = LinkKind.Command, LinkText = "Look" }), "look"); + var text = MarkupText.Concat([ + MarkupText.Sound("https://example.test/sounds/door.wav", volume: 80), + MarkupText.Pane(MarkupText.Concat([MarkupText.Plain("North: "), link]), "map", "The Map")]); + var wire = text.Render(MarkupFormat.Mxp, registry); + + var (lines, _, _) = Decode(wire + "\n"); + var line = lines.Single(); + await Assert.That(line.Content.ToPlainText()).IsEqualTo("North: look"); + await Assert.That(Markups(line).OfType().Single()).IsEqualTo(new SoundMarkup("https://example.test/sounds/door.wav", Volume: 80)); + await Assert.That(Markups(line).OfType().First()).IsEqualTo(new PaneMarkup("map", "The Map")); + var spans = Spans(line); + await Assert.That(spans[^1].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Command, "look") { Hint = "Look" }); + } + + [Test] + public async Task Image_InCells_IsDrawnOverTheLines() + { + var (lines, _, _) = Decode("\e[1z a\n b\n"); + var first = lines[0].Content.Runs.SelectMany(run => run.Markups).OfType().Single(); + await Assert.That(first.Source).IsEqualTo("https://example.test/cat.png"); + await Assert.That(first.Row!.Value.Row).IsEqualTo(0); + await Assert.That(lines[1].Content.Runs.SelectMany(run => run.Markups).OfType().Single().Row!.Value.Row).IsEqualTo(1); + await Assert.That(lines[1].Content.ToPlainText()).IsEqualTo(" b"); + } +} diff --git a/MarkupString.Tests/Decoding/PictureDecodingTests.cs b/MarkupString.Tests/Decoding/PictureDecodingTests.cs new file mode 100644 index 0000000..800b8f8 --- /dev/null +++ b/MarkupString.Tests/Decoding/PictureDecodingTests.cs @@ -0,0 +1,364 @@ +using System.Diagnostics.CodeAnalysis; +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; +using MarkupString.Layout; + +/// Pictures read back from what this package writes for each terminal, and from what other programs send. +public class PictureDecodingTests +{ + private static readonly DecoderRegistry Registry = VtDecoding.Terminal.WithPictures(); + private static readonly ImageMarkup Cat = new("cat.png", "A cat"); + + private sealed class Source(TerminalPicture picture) : ITerminalPictureSource + { + public bool TryGetPicture(ImageMarkup image, [NotNullWhen(true)] out TerminalPicture? found) + { + found = picture; + return true; + } + + public bool MarkTransmitted(uint imageId) => true; + } + + /// Red on the left, blue on the right, opaque. + private static TerminalPicture RedBlue(int width = 4, int height = 4) + { + var rgba = new byte[width * height * 4]; + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var o = (y * width + x) * 4; + if (x < width / 2) rgba[o] = 255; + else rgba[o + 2] = 255; + rgba[o + 3] = 255; + } + } + + return new TerminalPicture("red-blue", width, height, rgba); + } + + /// A figure of the picture in 4 × 2 cells, then a line of text, written for . + private static string Wire(TerminalFeatures features, TerminalPicture picture) + { + var laid = BlockLayout.Build(new Figure(Cat, MarkupText.Empty), 10, context: new LayoutContext { Pictures = (_, _) => new PictureCells(4, 2) }); + var options = new AnsiOutputOptions(Features: features) { Pictures = new Source(picture), CellWidth = 8, CellHeight = 16 }; + return laid.Render(MarkupFormat.Ansi, MarkupRegistry.Empty.WithAnsi().WithAnsiOutput(options)) + "\nafter\n"; + } + + private static (IReadOnlyList Lines, Decoder Decoder) Decode(string wire) + { + var decoder = Registry.CreateDecoder(); + var lines = new List(decoder.Feed(wire)); + if (decoder.HasPendingLine) lines.Add(decoder.EndLine()); + return (lines, decoder); + } + + /// The picture rows on each line: (row, rows, columns, first column, cells). + private static List<(int Row, int Rows, int Columns, int Cells)> PictureRows(IReadOnlyList lines) + { + var rows = new List<(int, int, int, int)>(); + foreach (var line in lines) + { + var layout = CellRenderer.Layout(line.Content, 0, AnsiDisplay.Default, new DisplayOptions()); + var cells = layout.Rows.SelectMany(row => row.Cells).Where(cell => cell.Style.Picture?.Row is not null).ToList(); + if (cells.Count == 0) continue; + var row = cells[0].Style.Picture!.Row!.Value; + rows.Add((row.Row, row.Rows, row.Columns, cells.Count)); + } + + return rows; + } + + private static async Task AssertSamePixels(StoredPicture stored, TerminalPicture expected) + { + await Assert.That(stored.Pixels).IsNotNull(); + await Assert.That(stored.Width).IsEqualTo(expected.Width); + await Assert.That(stored.Height).IsEqualTo(expected.Height); + await Assert.That(stored.Pixels!.Rgba.ToArray()).IsEquivalentTo(expected.Rgba.ToArray()); + } + + [Test] + public async Task Kitty_Placeholders_RoundTrip() + { + var picture = RedBlue(); + var (lines, decoder) = Decode(Wire(TerminalFeatures.KittyGraphics, picture)); + + await Assert.That(PictureRows(lines)).IsEquivalentTo([(0, 2, 4, 4), (1, 2, 4, 4)]); + await Assert.That(decoder.Pictures().Count).IsEqualTo(1); + var image = lines.SelectMany(line => line.Content.Runs).SelectMany(run => run.Markups).OfType().First(); + await Assert.That(decoder.Pictures().TryGet(image, out var stored)).IsTrue(); + await AssertSamePixels(stored!, picture); + await Assert.That(lines[^1].Content.ToPlainText()).IsEqualTo("after"); + await Assert.That(decoder.Diagnostics.PicturesFailed).IsEqualTo(0); + } + + [Test] + public async Task InlineImages_Overlay_RoundTrip() + { + var picture = RedBlue(); + var (lines, decoder) = Decode(Wire(TerminalFeatures.InlineImages, picture)); + + await Assert.That(PictureRows(lines)).IsEquivalentTo([(0, 2, 4, 4), (1, 2, 4, 4)]); + await Assert.That(decoder.Pictures().TryGet(lines[0].Content.Runs.SelectMany(run => run.Markups).OfType().First(), out var stored)).IsTrue(); + await AssertSamePixels(stored!, picture); + await Assert.That(stored!.MediaType).IsEqualTo("image/png"); + await Assert.That(lines[^1].Content.ToPlainText()).IsEqualTo("after"); + } + + [Test] + public async Task Sixel_Overlay_RoundTrip() + { + var picture = RedBlue(16, 12); + var (lines, decoder) = Decode(Wire(TerminalFeatures.Sixel, picture)); + + var rows = PictureRows(lines); + await Assert.That(rows.Select(row => row.Row)).IsEquivalentTo([0, 1]); + await Assert.That(rows.All(row => row.Columns == 4 && row.Cells == 4)).IsTrue(); + await Assert.That(decoder.Pictures().Count).IsEqualTo(1); + await Assert.That(lines[^1].Content.ToPlainText()).IsEqualTo("after"); + await Assert.That(decoder.Diagnostics.PicturesFailed).IsEqualTo(0); + } + + [Test] + public async Task Sixel_DecodesColoursAndSize() + { + // Two colours, red in the left column and blue in the right, six pixels tall, raster 2 × 6. + var (lines, decoder) = Decode("\eP0;1q\"1;1;2;6#1;2;100;0;0#2;2;0;0;100#1~$#2?~\e\\"); + var stored = decoder.Pictures().TryGet(lines[0].Content.Runs.SelectMany(run => run.Markups).OfType().First(), out var found) ? found : null; + await Assert.That(stored!.Width).IsEqualTo(2); + await Assert.That(stored.Height).IsEqualTo(6); + var rgba = stored.Pixels!.Rgba.ToArray(); + await Assert.That(rgba[..4]).IsEquivalentTo(new byte[] { 255, 0, 0, 255 }); + await Assert.That(rgba[4..8]).IsEquivalentTo(new byte[] { 0, 0, 255, 255 }); + } + + [Test] + public async Task Kitty_PlacedImage_TakesRowsAndMovesTheCursor() + { + // a=T without placeholders: 16 × 32 pixels of RGB at 8 × 16 cells is 2 × 2 cells. + var rgb = Convert.ToBase64String(new byte[16 * 32 * 3]); + var (lines, _) = Decode($"ab\e_Ga=T,f=24,s=16,v=32;{rgb}\e\\cd\n"); + await Assert.That(lines.Count).IsEqualTo(3); + await Assert.That(PictureRows(lines)).IsEquivalentTo([(0, 2, 2, 2), (1, 2, 2, 2)]); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("ab "); + await Assert.That(lines[2].Content.ToPlainText()).IsEqualTo(" cd"); + } + + [Test] + public async Task Kitty_Chunks_AndReplies() + { + var bytes = Convert.ToBase64String(new byte[8 * 16 * 4]); + var decoder = Registry.CreateDecoder(); + var replies = new List(); + decoder.EventRaised += e => { if (e is ReplyEvent reply) replies.Add(reply.Text); }; + decoder.Feed($"\e_Ga=t,i=7,s=8,v=16,m=1;{bytes[..100]}\e\\\e_Gm=1;{bytes[100..200]}\e\\\e_Gm=0;{bytes[200..]}\e\\"); + decoder.Feed("\e_Ga=p,i=7\e\\"); + decoder.Feed("\e_Ga=p,i=9\e\\"); + decoder.Feed("\e_Ga=t,t=f,i=8;L3RtcC94\e\\"); + await Assert.That(replies).IsEquivalentTo([ + "\e_Gi=7;OK\e\\", + "\e_Gi=7;OK\e\\", + "\e_Gi=9;ENOENT:no such image\e\\", + "\e_Gi=8;EINVAL:only direct transmission is read\e\\", + ]); + await Assert.That(decoder.Pictures().Count).IsEqualTo(1); + } + + [Test] + public async Task Kitty_Quiet_SendsNothing() + { + var decoder = Registry.CreateDecoder(); + var replies = 0; + decoder.EventRaised += e => { if (e is ReplyEvent) replies++; }; + decoder.Feed($"\e_Ga=t,i=7,s=1,v=1,q=2;{Convert.ToBase64String(new byte[4])}\e\\"); + await Assert.That(replies).IsEqualTo(0); + } + + [Test] + public async Task Kitty_MovingPicture_RoundTrip() + { + var red = RedBlue(2, 2).Rgba.ToArray(); + var blue = red.Reverse().ToArray(); + var picture = new TerminalPicture("moving", 2, 2, + [new TerminalPictureFrame(red, TimeSpan.FromMilliseconds(100)), new TerminalPictureFrame(blue, TimeSpan.FromMilliseconds(250))]); + var (lines, decoder) = Decode(Wire(TerminalFeatures.KittyGraphics | TerminalFeatures.MovingPictures, picture)); + + var image = lines.SelectMany(line => line.Content.Runs).SelectMany(run => run.Markups).OfType().First(); + await Assert.That(decoder.Pictures().TryGet(image, out var stored)).IsTrue(); + await Assert.That(stored!.IsMoving).IsTrue(); + var frames = stored.Pixels!.Frames; + await Assert.That(frames.Select(frame => frame.Duration.TotalMilliseconds)).IsEquivalentTo([100.0, 250.0]); + await Assert.That(frames[0].Rgba.ToArray()).IsEquivalentTo(red); + await Assert.That(frames[1].Rgba.ToArray()).IsEquivalentTo(blue); + await Assert.That(stored.MediaType).IsEqualTo("image/gif"); + await Assert.That(stored.ToDataUrl()).StartsWith("data:image/gif;base64,R0lGOD"); + await Assert.That(decoder.Diagnostics.PicturesFailed).IsEqualTo(0); + } + + [Test] + public async Task Kitty_Frame_BlendsOverItsBase_AndEditsInPlace() + { + // A 2 × 1 black root frame; frame 2 puts half-transparent white over its right pixel; then frame 2's left pixel is + // replaced with red. + var decoder = Registry.CreateDecoder(); + decoder.Feed($"\e_Ga=t,i=3,f=32,s=2,v=1;{Convert.ToBase64String([0, 0, 0, 255, 0, 0, 0, 255])}\e\\"); + decoder.Feed($"\e_Ga=f,i=3,f=32,s=1,v=1,x=1,c=1,z=70;{Convert.ToBase64String([255, 255, 255, 128])}\e\\"); + decoder.Feed($"\e_Ga=f,i=3,f=32,s=1,v=1,r=2,X=1;{Convert.ToBase64String([255, 0, 0, 255])}\e\\"); + decoder.Feed("\e_Ga=a,i=3,r=1,z=500\e\\"); + + var stored = decoder.Pictures().TryGet(KeyOf(decoder), out var found) ? found : null; + var frames = stored!.Pixels!.Frames; + await Assert.That(frames.Count).IsEqualTo(2); + await Assert.That(frames[0].Rgba.ToArray()).IsEquivalentTo(new byte[] { 0, 0, 0, 255, 0, 0, 0, 255 }); + await Assert.That(frames[1].Rgba.ToArray()).IsEquivalentTo(new byte[] { 255, 0, 0, 255, 128, 128, 128, 255 }); + await Assert.That(frames.Select(frame => frame.Duration.TotalMilliseconds)).IsEquivalentTo([500.0, 70.0]); + await Assert.That(decoder.Diagnostics.PicturesFailed).IsEqualTo(0); + } + + [Test] + public async Task Kitty_Frame_ForAnUnknownImage_Fails() + { + var decoder = Registry.CreateDecoder(); + decoder.Feed($"\e_Ga=f,i=4,f=32,s=1,v=1;{Convert.ToBase64String(new byte[4])}\e\\"); + await Assert.That(decoder.Diagnostics.PicturesFailed).IsEqualTo(1); + } + + private static string KeyOf(Decoder decoder) + { + var placed = decoder.Feed("\e_Ga=p,i=3,c=2,r=1\e\\\n"); + return placed.SelectMany(line => line.Content.Runs).SelectMany(run => run.Markups).OfType().First().Source[PictureStore.Scheme.Length..]; + } + + [Test] + public async Task InlineImage_NotInline_IsADownload() + { + var decoder = Registry.CreateDecoder(); + var events = new List(); + decoder.EventRaised += events.Add; + decoder.Feed($"\e]1337;File=name={Convert.ToBase64String("a.txt"u8.ToArray())};size=3:{Convert.ToBase64String("abc"u8.ToArray())}\a"); + await Assert.That(events.OfType().Single()).IsEqualTo(new DownloadEvent("a.txt", 3)); + await Assert.That(decoder.Pictures().Count).IsEqualTo(0); + } + + [Test] + public async Task InlineImage_Gif_IsKeptForABrowser() + { + // A 1 × 1 GIF; its size comes from the header. + var gif = Convert.FromBase64String("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"); + var decoder = Registry.CreateDecoder(); + var lines = decoder.Feed($"\e]1337;File=inline=1;width=3:{Convert.ToBase64String(gif)}\a\n"); + var image = lines[0].Content.Runs.SelectMany(run => run.Markups).OfType().Single(); + await Assert.That(decoder.Pictures().TryGet(image, out var stored)).IsTrue(); + await Assert.That(stored!.Pixels).IsNull(); + await Assert.That(stored.MediaType).IsEqualTo("image/gif"); + await Assert.That(stored.ToDataUrl()).StartsWith("data:image/gif;base64,R0lGOD"); + await Assert.That(image.Row!.Value.Columns).IsEqualTo(3); + } + + [Test] + public async Task Store_EvictsTheLeastRecentlyUsed() + { + var cache = new MemoryPictureCache { Budget = 100 }; + var store = new PictureStore(cache); + var first = store.Add(4, 4, new byte[64]); + var second = store.Add(4, 4, Enumerable.Repeat((byte)1, 64).ToArray()); + await Assert.That(store.TryGet(first.Key, out _)).IsFalse(); + await Assert.That(store.TryGet(second.Key, out _)).IsTrue(); + await Assert.That(cache.Used).IsEqualTo(64); + } + + [Test] + public async Task SharedCache_DecodesTheSamePictureOnce() + { + var cache = new MemoryPictureCache(); + var registry = VtDecoding.Terminal.WithPictures(cache); + var wire = Wire(TerminalFeatures.KittyGraphics, RedBlue()); + var first = registry.CreateDecoder(); + var second = registry.CreateDecoder(); + var added = 0; + first.Pictures().Added += _ => added++; + second.Pictures().Added += _ => added++; + + first.Feed(wire); + second.Feed(wire); + + await Assert.That(first.Pictures().Cache).IsSameReferenceAs(cache); + await Assert.That(cache.Count).IsEqualTo(1); + await Assert.That(added).IsEqualTo(1); + await Assert.That(second.Pictures().Count).IsEqualTo(1); + } + + [Test] + public async Task SharedCache_AMovingPictureDoesNotChangeTheStillOne() + { + var cache = new MemoryPictureCache(); + var registry = VtDecoding.Terminal.WithPictures(cache); + var still = registry.CreateDecoder(); + var moving = registry.CreateDecoder(); + var root = $"\e_Ga=t,i=3,f=32,s=1,v=1;{Convert.ToBase64String([0, 0, 0, 255])}\e\\"; + still.Feed(root); + moving.Feed(root); + moving.Feed($"\e_Ga=f,i=3,f=32,s=1,v=1,X=1;{Convert.ToBase64String([255, 0, 0, 255])}\e\\"); + + var placed = (Decoder decoder) => decoder.Feed("\e_Ga=p,i=3,c=1,r=1\e\\\n") + .SelectMany(line => line.Content.Runs).SelectMany(run => run.Markups).OfType().First(); + await Assert.That(still.Pictures().TryGet(placed(still), out var a)).IsTrue(); + await Assert.That(moving.Pictures().TryGet(placed(moving), out var b)).IsTrue(); + await Assert.That(a!.IsMoving).IsFalse(); + await Assert.That(b!.IsMoving).IsTrue(); + await Assert.That(cache.Count).IsEqualTo(2); + } + + private sealed class CountingCache : IPictureCache + { + public Dictionary Held { get; } = []; + + public int Sets { get; private set; } + + public bool TryGet(string key, [NotNullWhen(true)] out StoredPicture? picture) => Held.TryGetValue(key, out picture); + + public void Set(StoredPicture picture) + { + Sets++; + Held[picture.Key] = picture; + } + + public bool Remove(string key) => Held.Remove(key); + } + + [Test] + public async Task CustomCache_HoldsThePictures() + { + var cache = new CountingCache(); + var decoder = VtDecoding.Terminal.WithPictures(cache).CreateDecoder(); + var lines = decoder.Feed(Wire(TerminalFeatures.InlineImages, RedBlue())); + var image = lines.SelectMany(line => line.Content.Runs).SelectMany(run => run.Markups).OfType().First(); + await Assert.That(cache.Held.ContainsKey(image.Source[PictureStore.Scheme.Length..])).IsTrue(); + await Assert.That(cache.Sets).IsEqualTo(1); + } + + [Test] + public async Task Store_SamePictureTwice_IsOne() + { + var store = new PictureStore(); + var a = store.Add(1, 1, [1, 2, 3, 4]); + var b = store.Add(1, 1, [1, 2, 3, 4]); + await Assert.That(b).IsSameReferenceAs(a); + await Assert.That(store.Count).IsEqualTo(1); + } + + [Test] + public async Task Png_DecodesPaletteAndInterlace() + { + // 2 × 2 interlaced palette PNG: red, green / blue, white. + var png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAMAAAEyb82AAAAADFBMVEX/AAAA/wAAAP/////7AGD2AAAAD0lEQVR4nGNgYGBkYGIGAAASAAe4hQWPAAAAAElFTkSuQmCC"); + var decoder = Registry.CreateDecoder(); + var lines = decoder.Feed($"\e]1337;File=inline=1:{Convert.ToBase64String(png)}\a\n"); + var image = lines[0].Content.Runs.SelectMany(run => run.Markups).OfType().Single(); + await Assert.That(decoder.Pictures().TryGet(image, out var stored)).IsTrue(); + await Assert.That(stored!.Pixels!.Rgba.ToArray()).IsEquivalentTo(new byte[] { 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255 }); + } +} diff --git a/MarkupString.Tests/Decoding/PuebloDecodingTests.cs b/MarkupString.Tests/Decoding/PuebloDecodingTests.cs new file mode 100644 index 0000000..54c2ca5 --- /dev/null +++ b/MarkupString.Tests/Decoding/PuebloDecodingTests.cs @@ -0,0 +1,90 @@ +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; +using MarkupString.Html; +using MarkupString.Pueblo; +using MarkupString.Pueblo.Decoding; + +public class PuebloDecodingTests +{ + private static readonly DecoderRegistry Registry = VtDecoding.Terminal.WithPueblo(); + + private static List Decode(string text) + { + var decoder = Registry.CreateDecoder(); + decoder.EnableProtocol(PuebloDecoding.Protocol); + var lines = new List(decoder.Feed(text)); + if (decoder.HasPendingLine) lines.Add(decoder.EndLine()); + return lines; + } + + private static IReadOnlyList Spans(DisplayLine line) => SpanRenderer.Render(line.Content, AnsiDisplay.Default, new DisplayOptions()); + + private static IEnumerable Markups(DisplayLine line) => line.Content.Runs.SelectMany(run => run.Markups); + + [Test] + public async Task MudText_TagsAreText() + { + var lines = Decode("x\n"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("x"); + } + + [Test] + public async Task Html_BreaksAtBr_NotAtLineFeeds() + { + var lines = Decode("one
\ntwo\nstill two
\n"); + await Assert.That(lines.Select(line => line.Content.ToPlainText())).IsEquivalentTo(["one", "twostill two"]); + } + + [Test] + public async Task Html_StylesAndLinks() + { + var lines = Decode("bold
look site
"); + var spans = Spans(lines[0]); + await Assert.That(spans[0].Style.Has(TextAttributes.Bold)).IsTrue(); + await Assert.That(spans[2].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Command, "look") { Hint = "Look" }); + await Assert.That(spans[4].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Url, "https://example.com")); + } + + [Test] + public async Task Html_Entities() + { + var lines = Decode("a <b> & c
"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("a & c"); + } + + [Test] + public async Task MudTextRegion_KeepsItsLines() + { + var lines = Decode("a\nb\nc
"); + await Assert.That(lines.Select(line => line.Content.ToPlainText())).IsEquivalentTo(["a", "b", "c"]); + await Assert.That(Markups(lines[0]).Contains(PreformattedMarkup.Instance)).IsTrue(); + } + + [Test] + public async Task WhatThisPackageWrites_ReadsBack() + { + var registry = MarkupRegistry.Empty.WithAnsi().WithHtml().WithPueblo(); + var link = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { LinkUrl = "look", LinkKind = LinkKind.Command, LinkText = "Look" }), "look"); + var text = MarkupText.Concat([ + MarkupText.Sound("https://example.test/door.wav", volume: 80), + MarkupText.Pane(MarkupText.Concat([MarkupText.Plain("North: "), link]), "map", "The Map"), + MarkupText.Plain("\nnext & last")]); + var wire = text.Render(MarkupFormat.Pueblo, registry); + + var lines = Decode("" + wire); + await Assert.That(lines.Select(line => line.Content.ToPlainText())).IsEquivalentTo(["North: look", "next & last"]); + await Assert.That(Markups(lines[0]).OfType().Single()).IsEqualTo(new SoundMarkup("https://example.test/door.wav", Volume: 80)); + await Assert.That(Markups(lines[0]).OfType().First()).IsEqualTo(new PaneMarkup("map", "The Map")); + await Assert.That(Spans(lines[0])[^1].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Command, "look") { Hint = "Look" }); + await Assert.That(Markups(lines[1]).OfType().Any()).IsFalse(); + } + + [Test] + public async Task Greeting() + { + await Assert.That(PuebloDecoding.IsGreeting("This world is Pueblo 1.10 Enhanced.")).IsTrue(); + await Assert.That(PuebloDecoding.IsGreeting("Welcome!")).IsFalse(); + } +} diff --git a/MarkupString.Tests/Decoding/VtDecodingTests.cs b/MarkupString.Tests/Decoding/VtDecodingTests.cs new file mode 100644 index 0000000..e5cb968 --- /dev/null +++ b/MarkupString.Tests/Decoding/VtDecodingTests.cs @@ -0,0 +1,339 @@ +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; + +public class VtDecodingTests +{ + private static readonly DisplayOptions Options = new(); + + private static IReadOnlyList Decode(string text, DecoderOptions? options = null) => + Decoder.DecodeAll(text, VtDecoding.Terminal, options); + + private static IReadOnlyList Spans(MarkupText text) => SpanRenderer.Render(text, AnsiDisplay.Default, Options); + + private static DisplaySpan Only(string text) + { + var lines = Decode(text); + var spans = Spans(lines[0].Content); + return spans.Single(span => span.Text.Trim().Length > 0); + } + + [Test] + public async Task LineFeeds_SplitLines() + { + var lines = Decode("one\ntwo\r\nthree"); + await Assert.That(lines.Select(line => line.Content.ToPlainText())).IsEquivalentTo(["one", "two", "three"]); + await Assert.That(lines.Select(line => line.Sequence)).IsEquivalentTo([0L, 1L, 2L]); + } + + [Test] + public async Task Prompt_EndsWithoutALineFeed() + { + var decoder = VtDecoding.Terminal.CreateDecoder(); + await Assert.That(decoder.Feed("Name: ")).IsEmpty(); + var prompt = decoder.EndPrompt(); + await Assert.That(prompt!.Kind).IsEqualTo(LineKind.Prompt); + await Assert.That(prompt.Content.ToPlainText()).IsEqualTo("Name: "); + await Assert.That(decoder.EndPrompt()).IsNull(); + } + + [Test] + public async Task CarriageReturn_OverwritesTheLine() + { + var lines = Decode("[ ] 0%\r[## ] 50%\r[####] 100%\n"); + await Assert.That(lines.Single().Content.ToPlainText()).IsEqualTo("[####] 100%"); + } + + [Test] + public async Task CarriageReturn_ShorterText_KeepsTheRest() + { + var lines = Decode("abcdef\rXY\n"); + await Assert.That(lines.Single().Content.ToPlainText()).IsEqualTo("XYcdef"); + } + + [Test] + public async Task EraseToEndOfLine_AfterCarriageReturn() + { + var lines = Decode("abcdef\rXY\e[K\n"); + await Assert.That(lines.Single().Content.ToPlainText()).IsEqualTo("XY"); + } + + [Test] + public async Task Backspace_Overwrites() + { + var lines = Decode("ab\bc\n"); + await Assert.That(lines.Single().Content.ToPlainText()).IsEqualTo("ac"); + } + + [Test] + public async Task Tab_PadsToTheNextStop() + { + var lines = Decode("ab\tc\n"); + await Assert.That(lines.Single().Content.ToPlainText()).IsEqualTo("ab c"); + } + + [Test] + public async Task Overwrite_KeepsStyleOfWhatIsLeft() + { + var lines = Decode("\e[31mred\e[0m\rX\n"); + var spans = Spans(lines.Single().Content); + await Assert.That(spans.Select(span => span.Text)).IsEquivalentTo(["X", "ed"]); + await Assert.That(spans[1].Style.Foreground).IsEqualTo(new RgbColor(170, 0, 0)); + } + + [Test] + public async Task Sgr_StandardColour() + { + var span = Only("\e[1;32mgo\e[0m\n"); + await Assert.That(span.Style.Foreground).IsEqualTo(new RgbColor(0, 170, 0)); + await Assert.That(span.Style.Has(TextAttributes.Bold)).IsTrue(); + } + + [Test] + public async Task Sgr_TrueColour_SemicolonsAndColons() + { + await Assert.That(Only("\e[38;2;1;2;3mx\n").Style.Foreground).IsEqualTo(new RgbColor(1, 2, 3)); + await Assert.That(Only("\e[38:2::4:5:6mx\n").Style.Foreground).IsEqualTo(new RgbColor(4, 5, 6)); + await Assert.That(Only("\e[48:2:7:8:9mx\n").Style.Background).IsEqualTo(new RgbColor(7, 8, 9)); + } + + [Test] + public async Task Sgr_Xterm256() + { + await Assert.That(Only("\e[38;5;196mx\n").Style.Foreground).IsEqualTo(new RgbColor(255, 0, 0)); + } + + [Test] + public async Task Sgr_UnderlineStyleSubparameter_IsStillUnderline() + { + await Assert.That(Only("\e[4:3mx\n").Style.Has(TextAttributes.Underline)).IsTrue(); + var off = Decode("\e[4m\e[4:0mx\n"); + await Assert.That(Spans(off[0].Content).Single().Style.Has(TextAttributes.Underline)).IsFalse(); + } + + [Test] + public async Task Sgr_Conceal() + { + await Assert.That(Only("\e[8msecret\n").Style.Has(TextAttributes.Conceal)).IsTrue(); + } + + [Test] + public async Task Sgr_Reset_ClearsEverything() + { + var lines = Decode("\e[1;31ma\e[mb\n"); + var spans = Spans(lines[0].Content); + await Assert.That(spans[1].Text).IsEqualTo("b"); + await Assert.That(spans[1].Style).IsEqualTo(DisplayStyle.Plain); + } + + [Test] + public async Task Sgr_RoundTripsToAnsi() + { + var lines = Decode("\e[1;31mhi\e[0m there\n"); + var ansi = lines[0].Content.Render(MarkupFormat.Ansi, MarkupRegistry.Empty.WithAnsi()); + await Assert.That(ansi).IsEqualTo("\e[1;31mhi\e[0m there"); + } + + [Test] + public async Task Sgr_StyleCarriesAcrossLines() + { + var lines = Decode("\e[34mone\ntwo\e[0m\n"); + await Assert.That(Spans(lines[1].Content).Single().Style.Foreground).IsEqualTo(new RgbColor(0, 0, 170)); + } + + [Test] + public async Task Inverse_SwapsColours() + { + var style = Only("\e[7;31mx\n").Style; + await Assert.That(style.Foreground).IsEqualTo(Options.DefaultBackground); + await Assert.That(style.Background).IsEqualTo(new RgbColor(170, 0, 0)); + } + + [Test] + public async Task Osc8_MakesALink() + { + var lines = Decode("see \e]8;;https://example.com\e\\here\e]8;;\e\\ ok\n"); + var spans = Spans(lines[0].Content); + await Assert.That(spans.Select(span => span.Text)).IsEquivalentTo(["see ", "here", " ok"]); + await Assert.That(spans[1].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Url, "https://example.com")); + await Assert.That(spans[2].Style.Interaction).IsNull(); + } + + [Test] + public async Task Osc8_Id_GroupsTheLinkAcrossLines() + { + var lines = Decode("\e]8;foo=1:id=menu;https://example.com\e\\one\ntwo\e]8;;\e\\ three \e]8;;https://example.com\e\\x\e]8;;\e\\\n"); + var first = Spans(lines[0].Content).Single().Style.Interaction; + var second = Spans(lines[1].Content); + await Assert.That(first).IsEqualTo(new Interaction(InteractionKind.Url, "https://example.com") { Group = "menu" }); + await Assert.That(second[0].Style.Interaction).IsEqualTo(first); + await Assert.That(second[1].Style.Interaction).IsNull(); + await Assert.That(second[2].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Url, "https://example.com")); + } + + [Test] + public async Task Osc8_UnsafeScheme_IsText() + { + var lines = Decode("\e]8;;javascript:alert(1)\e\\x\e]8;;\e\\\n"); + await Assert.That(Spans(lines[0].Content).Single().Style.Interaction).IsNull(); + } + + [Test] + public async Task Mslp_LinkLastsAsLongAsTheUnderline() + { + var lines = Decode("\e]68;1;SEND;look\a\e[4mlook\e[24m here\n"); + var spans = Spans(lines[0].Content); + await Assert.That(spans[0].Text).IsEqualTo("look"); + await Assert.That(spans[0].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Command, "look")); + await Assert.That(spans[1].Style.Interaction).IsNull(); + } + + [Test] + public async Task Mslp_RoundTripsThroughTheAnsiWriter() + { + var link = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { LinkUrl = "look", LinkKind = LinkKind.Command }), "look"); + var registry = MarkupRegistry.Empty.WithAnsiOutput(new AnsiOutputOptions { Features = TerminalFeatures.CommandLinks }); + var wire = link.Render(MarkupFormat.Ansi, registry); + var spans = Spans(Decode(wire + "\n")[0].Content); + await Assert.That(spans.Single().Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Command, "look")); + } + + [Test] + public async Task DecSpecialGraphics_DrawsBoxes() + { + var lines = Decode("\e(0lqk\e(B ok\n"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("┌─┐ ok"); + } + + [Test] + public async Task ShiftOut_UsesG1() + { + var lines = Decode("\e)0a\u000eqx\u000fq\n"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("a─│q"); + } + + [Test] + public async Task Bell_IsAPoint() + { + var lines = Decode("ding\a\n"); + var spans = Spans(lines[0].Content); + await Assert.That(spans[^1].Point).IsSameReferenceAs(BellMarkup.Instance); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("ding"); + } + + [Test] + public async Task Title_Palette_Clipboard_AreEvents() + { + var decoder = VtDecoding.Terminal.CreateDecoder(); + var events = new List(); + decoder.EventRaised += events.Add; + decoder.Feed("\e]2;The Game\a\e]4;1;rgb:ff/00/80\a\e]11;#102030\a\e]104\a\e]52;c;aGk=\a"); + await Assert.That(events).IsEquivalentTo(new DecodeEvent[] + { + new TitleEvent("The Game", false), + new PaletteEvent(PaletteSlot.Index, 1, new RgbColor(255, 0, 128)), + new PaletteEvent(PaletteSlot.Background, 0, new RgbColor(16, 32, 48)), + new PaletteEvent(PaletteSlot.Index, -1, null), + new ClipboardEvent("c", "hi"), + }); + } + + [Test] + public async Task ColorSpec_ScalesChannels() + { + await Assert.That(OscPlugin.ParseColor("rgb:f/8/0")).IsEqualTo(new RgbColor(255, 136, 0)); + await Assert.That(OscPlugin.ParseColor("rgb:ffff/0000/8080")).IsEqualTo(new RgbColor(255, 0, 128)); + await Assert.That(OscPlugin.ParseColor("#fff")).IsEqualTo(new RgbColor(255, 255, 255)); + await Assert.That(OscPlugin.ParseColor("red")).IsNull(); + } + + [Test] + public async Task Answers_FromTheIdentity() + { + var identity = new ClientIdentity { Name = "TestClient", Version = "2.1", Columns = 100, Rows = 30, Foreground = new RgbColor(1, 2, 3) }; + var decoder = VtDecoding.Terminal.CreateDecoder(new DecoderOptions { Identity = identity }); + var replies = new List(); + decoder.EventRaised += e => { if (e is ReplyEvent reply) replies.Add(reply.Text); }; + decoder.Feed("\e[c\e[>q\e[18t\e[16t\e]10;?\a\e[6n"); + await Assert.That(replies).IsEquivalentTo([ + "\e[?62;22c", + "\eP>|TestClient 2.1\e\\", + "\e[8;30;100t", + "\e[6;16;8t", + "\e]10;rgb:0101/0202/0303\e\\", + ]); + } + + [Test] + public async Task Answers_CursorPosition_WhenAllowed() + { + var identity = new ClientIdentity { Answers = ClientAnswers.Default | ClientAnswers.CursorPosition }; + var decoder = VtDecoding.Terminal.CreateDecoder(new DecoderOptions { Identity = identity }); + var replies = new List(); + decoder.EventRaised += e => { if (e is ReplyEvent reply) replies.Add(reply.Text); }; + decoder.Feed("abc\e[6n"); + await Assert.That(replies).IsEquivalentTo(["\e[24;4R"]); + } + + [Test] + public async Task Answers_None_WhenTurnedOff() + { + var decoder = VtDecoding.Terminal.CreateDecoder(new DecoderOptions { Identity = new ClientIdentity { Answers = ClientAnswers.None } }); + var replies = 0; + decoder.EventRaised += e => { if (e is ReplyEvent) replies++; }; + decoder.Feed("\e[c\e[>q\e[18t"); + await Assert.That(replies).IsEqualTo(0); + await Assert.That(decoder.Diagnostics.UnhandledSequences).IsEqualTo(0); + } + + [Test] + public async Task Unknown_IsCounted() + { + var decoder = VtDecoding.Terminal.CreateDecoder(); + decoder.Feed("\e[5;5H\e]777;x\a"); + await Assert.That(decoder.Diagnostics.UnhandledSequences).IsEqualTo(2); + } + + [Test] + public async Task LineLength_BreaksTheLine() + { + var decoder = VtDecoding.Terminal.CreateDecoder(new DecoderOptions { MaxLineLength = 4 }); + var lines = new List(decoder.Feed("abc")); + lines.AddRange(decoder.Feed("defghij\n")); + await Assert.That(lines.Select(line => line.Content.ToPlainText())).IsEquivalentTo(["abcd", "efgh", "ij"]); + await Assert.That(decoder.Diagnostics.LimitsReached).IsEqualTo(2); + } + + [Test] + public async Task Overwrite_InsideAWideCharacter_BlanksIt() + { + await Assert.That(Decode("漢字\rX\n")[0].Content.ToPlainText()).IsEqualTo("X 字"); + await Assert.That(Decode("漢字\e[2GX\n")[0].Content.ToPlainText()).IsEqualTo(" X字"); + await Assert.That(Decode("ab漢\e[2G字\n")[0].Content.ToPlainText()).IsEqualTo("a字 "); + } + + [Test] + public async Task Mslp_WithoutUnderline_EndsWithTheLine() + { + var lines = Decode("\e]68;1;SEND;look\alook\nmore\n"); + await Assert.That(Spans(lines[0].Content).Single().Style.Interaction).IsNotNull(); + await Assert.That(Spans(lines[1].Content).Single().Style.Interaction).IsNull(); + } + + [Test] + public async Task ClearScreen_IsAPoint() + { + var lines = Decode("\e[2Jtop\n"); + await Assert.That(lines[0].Content.Runs[0].Markups.Innermost).IsSameReferenceAs(ClearScreenMarkup.Instance); + } + + [Test] + public async Task Capabilities_FromAProfile() + { + var capabilities = TerminalProfile.Kitty.ToOutputCapabilities(AnsiColorDepth.TrueColor); + await Assert.That(capabilities.Pictures).IsEqualTo(PictureMethods.KittyPlaceholders | PictureMethods.HalfBlocks); + await Assert.That(capabilities.Interactions).IsEqualTo(InteractionKinds.Url); + await Assert.That(capabilities.ToTerminalFeatures() & TerminalFeatures.Pictures).IsEqualTo(TerminalFeatures.KittyGraphics | TerminalFeatures.BlockArt); + } +} diff --git a/MarkupString.Tests/Decoding/VtTokenizerTests.cs b/MarkupString.Tests/Decoding/VtTokenizerTests.cs new file mode 100644 index 0000000..f3cb699 --- /dev/null +++ b/MarkupString.Tests/Decoding/VtTokenizerTests.cs @@ -0,0 +1,120 @@ +using MarkupString.Decoding; + +public class VtTokenizerTests +{ + private sealed class Recorder : IVtTokenSink + { + public List Tokens { get; } = []; + + public void Print(ReadOnlySpan text) => Add($"print:{text}"); + + public void Execute(char control) => Add($"exec:{(int)control}"); + + public void Csi(in CsiSequence sequence) + { + var parameters = new List(); + for (var i = 0; i < sequence.Count; i++) + { + parameters.Add((sequence.IsSubparameter(i) ? ":" : "") + sequence.Parameters[i]); + } + + Tokens.Add($"csi:{(sequence.Prefix == '\0' ? "" : sequence.Prefix.ToString())}{string.Join(",", parameters)}{sequence.Intermediates}{sequence.Final}"); + } + + public void Esc(in EscSequence sequence) => Tokens.Add($"esc:{sequence.Intermediates}{sequence.Final}"); + + public void Osc(ReadOnlySpan payload) => Tokens.Add($"osc:{payload}"); + + public void Dcs(in CsiSequence header, ReadOnlySpan data) => Tokens.Add($"dcs:{header.Final}:{data}"); + + public void Apc(ReadOnlySpan payload) => Tokens.Add($"apc:{payload}"); + + public void Abandoned(VtSequenceKind kind) => Tokens.Add($"abandoned:{kind}"); + + private void Add(string token) + { + // Print may arrive in pieces; join neighbours so tests read the text whole. + if (token.StartsWith("print:", StringComparison.Ordinal) && Tokens.Count > 0 && Tokens[^1].StartsWith("print:", StringComparison.Ordinal)) + { + Tokens[^1] += token["print:".Length..]; + return; + } + + Tokens.Add(token); + } + } + + private static List Tokens(params string[] chunks) + { + var tokenizer = new VtTokenizer(VtLimits.Default); + var sink = new Recorder(); + foreach (var chunk in chunks) tokenizer.Feed(chunk, sink); + return sink.Tokens; + } + + [Test] + public async Task Csi_WithParameters_IsOneToken() + { + await Assert.That(Tokens("a\e[1;31mb")).IsEquivalentTo(["print:a", "csi:1,31m", "print:b"]); + } + + [Test] + public async Task Csi_SplitAcrossFeeds_IsStillOneToken() + { + await Assert.That(Tokens("\e[3", "8;5;2", "08mx")).IsEquivalentTo(["csi:38,5,208m", "print:x"]); + } + + [Test] + public async Task Csi_ColonSubparameters_AreMarked() + { + await Assert.That(Tokens("\e[38:2::10:20:30m")).IsEquivalentTo(["csi:38,:2,:-1,:10,:20,:30m"]); + } + + [Test] + public async Task Csi_PrivatePrefix_IsKept() + { + await Assert.That(Tokens("\e[>q")).IsEquivalentTo(["csi:>q"]); + } + + [Test] + public async Task Osc_EndsAtBelOrSt() + { + await Assert.That(Tokens("\e]2;one\a\e]2;two\e\\")).IsEquivalentTo(["osc:2;one", "osc:2;two"]); + } + + [Test] + public async Task Apc_EndsAtSt() + { + await Assert.That(Tokens("\e_Ga=q;AAAA\e\\z")).IsEquivalentTo(["apc:Ga=q;AAAA", "print:z"]); + } + + [Test] + public async Task Dcs_CarriesItsData() + { + await Assert.That(Tokens("\eP0;1q#0~-\e\\")).IsEquivalentTo(["dcs:q:#0~-"]); + } + + [Test] + public async Task LineFeed_AbandonsAnOpenSequence_ThenEndsTheLine() + { + await Assert.That(Tokens("\e[31\nok")).IsEquivalentTo(["abandoned:Csi", "exec:10", "print:ok"]); + } + + [Test] + public async Task Cancel_AbandonsTheSequence() + { + await Assert.That(Tokens("\e[31\u0018ok")).IsEquivalentTo(["abandoned:Csi", "print:ok"]); + } + + [Test] + public async Task Esc_WithIntermediate() + { + await Assert.That(Tokens("\e(0q\e(B")).IsEquivalentTo(["esc:(0", "print:q", "esc:(B"]); + } + + [Test] + public async Task C0Controls_AreExecuted() + { + await Assert.That(Tokens("a\rb\bc\a")).IsEquivalentTo(["print:a", "exec:13", "print:b", "exec:8", "print:c", "exec:7"]); + } +} diff --git a/MarkupString.Tests/MarkupString.Tests.csproj b/MarkupString.Tests/MarkupString.Tests.csproj index aa858d5..1529ede 100644 --- a/MarkupString.Tests/MarkupString.Tests.csproj +++ b/MarkupString.Tests/MarkupString.Tests.csproj @@ -10,6 +10,10 @@ + + + + @@ -21,6 +25,7 @@ + diff --git a/MarkupString.slnx b/MarkupString.slnx index 5ea97db..9b5c4a4 100644 --- a/MarkupString.slnx +++ b/MarkupString.slnx @@ -1,6 +1,7 @@ + diff --git a/MarkupString/Decoding/DecodeContext.cs b/MarkupString/Decoding/DecodeContext.cs new file mode 100644 index 0000000..47fcc34 --- /dev/null +++ b/MarkupString/Decoding/DecodeContext.cs @@ -0,0 +1,361 @@ +namespace MarkupString.Decoding; + +/// +/// What a handler may do while a reads: write text under the current markup, change that +/// markup, end lines, place pictures and raise events. One per decoder. +/// +/// +/// +/// Text is written under a built from two things. Pens are named slots that a +/// protocol sets and replaces, as SGR replaces the current style. Elements are a stack a tag protocol +/// opens and closes. A pen sits inside every element, so an SGR colour written inside an MXP <COLOR> +/// wins, as it does in an MXP client. +/// +/// +public sealed class DecodeContext +{ + private readonly Decoder _decoder; + private readonly Dictionary _states = []; + private readonly List<(string Slot, IMarkup Markup)> _pens = []; + private readonly List _elements = []; + private readonly Dictionary _protocols = new(StringComparer.OrdinalIgnoreCase); + private MarkupSet? _set; + private bool _setValid; + private ITextProtocol? _active; + private long _sequence; + + internal DecodeContext(Decoder decoder) + { + _decoder = decoder; + Line = new LineBuilder(decoder.Options.MaxLineLength); + } + + internal LineBuilder Line { get; } + + internal string? ActiveProtocolName { get; private set; } + + /// The decoder's options. + public DecoderOptions Options => _decoder.Options; + + /// Who the client is, as the host last set it. + public ClientIdentity Identity => _decoder.Identity; + + /// Counts of what was dropped. + public DecodeDiagnostics Diagnostics => _decoder.Diagnostics; + + /// The pane the lines being read are for, or null for the main output. + public string? Destination { get; set; } + + /// Whether nothing has been written to the current line. + public bool LineIsEmpty => Line.IsEmpty; + + /// The column the next character is written at, in cells. + public int Column => Line.Cursor; + + /// The elements open now, outermost first. + public IReadOnlyList Elements => _elements; + + /// + /// This decoder's instance of a plugin's own state, made on first use. A plugin keeps its per-connection state + /// here, because the plugin itself is shared by every decoder of its registry. + /// + public T State() where T : class, new() + { + if (_states.TryGetValue(typeof(T), out var state)) return (T)state; + var made = new T(); + _states[typeof(T)] = made; + return made; + } + + /// Sets pen to , or clears it with null. + public void SetPen(string slot, IMarkup? markup) + { + ArgumentNullException.ThrowIfNull(slot); + var index = _pens.FindIndex(pen => pen.Slot == slot); + if (markup is null) + { + if (index < 0) return; + _pens.RemoveAt(index); + } + else if (index < 0) + { + _pens.Add((slot, markup)); + } + else if (Equals(_pens[index].Markup, markup)) + { + return; + } + else + { + _pens[index] = (slot, markup); + } + + _setValid = false; + } + + /// What pen holds, or null. + public IMarkup? Pen(string slot) + { + foreach (var pen in _pens) + { + if (pen.Slot == slot) return pen.Markup; + } + + return null; + } + + /// Opens over the text that follows, until . + /// False when are already open; nothing is opened. + public bool Open(IMarkup markup) + { + ArgumentNullException.ThrowIfNull(markup); + if (_elements.Count >= Options.MaxOpenElements) + { + Diagnostics.LimitsReached++; + return false; + } + + _elements.Add(markup); + _setValid = false; + return true; + } + + /// + /// Closes (the same instance was given) and every element opened + /// after it, as HTML closes elements left open inside one. + /// + /// False when it is not open. + public bool Close(IMarkup markup) + { + var index = _elements.FindLastIndex(open => ReferenceEquals(open, markup)); + if (index < 0) return false; + _elements.RemoveRange(index, _elements.Count - index); + _setValid = false; + return true; + } + + /// Closes the elements from on, leaving the first open. + public void CloseTo(int count) + { + if (count < 0) count = 0; + if (count >= _elements.Count) return; + _elements.RemoveRange(count, _elements.Count - count); + _setValid = false; + } + + /// The markup text written now is under, innermost first, or null for none. + public MarkupSet? CurrentMarkup + { + get + { + if (_setValid) return _set; + if (_pens.Count == 0 && _elements.Count == 0) + { + _set = null; + } + else + { + var layers = new List(_pens.Count + _elements.Count); + for (var i = _pens.Count - 1; i >= 0; i--) layers.Add(_pens[i].Markup); + for (var i = _elements.Count - 1; i >= 0; i--) layers.Add(_elements[i]); + _set = MarkupSet.Of(layers); + } + + _setValid = true; + return _set; + } + } + + /// + /// Writes printable text, passing it to the registry's print filters first (a character set, picture + /// placeholders). A text protocol writes its literal text with this. + /// + public void Print(ReadOnlySpan text) + { + if (text.IsEmpty) return; + foreach (var filter in _decoder.Registry.PrintFilters) + { + if (filter(text, this)) return; + } + + Write(text); + } + + /// Writes text under the current markup, as it stands. + public void Write(ReadOnlySpan text) => Write(text, null); + + /// Writes text under the current markup with inside it. + public void Write(ReadOnlySpan text, IMarkup? inner) + { + if (text.IsEmpty) return; + var set = CurrentMarkup; + if (inner is not null) + { + if (set is null) + { + set = MarkupSet.Of(inner); + } + else + { + var layers = new IMarkup[set.Count + 1]; + layers[0] = inner; + for (var i = 0; i < set.Count; i++) layers[i + 1] = set[i]; + set = MarkupSet.Of(layers.AsSpan()); + } + } + // A line too long for the limit is ended where the limit falls, and the rest starts the next. + while (Line.Length + text.Length > Line.MaxLength) + { + var room = Math.Max(0, Line.MaxLength - Line.Length); + if (room > 0 && char.IsHighSurrogate(text[room - 1])) room--; + if (room == 0 && Line.IsEmpty) room = Math.Min(text.Length, Math.Max(1, Line.MaxLength)); + if (room > 0) Line.Write(text[..room].ToString(), set); + Diagnostics.LimitsReached++; + EndLine(); + text = text[room..]; + if (text.IsEmpty) return; + } + + Line.Write(text.ToString(), set); + } + + /// Writes already built text at the cursor. + public void Write(MarkupText text) + { + ArgumentNullException.ThrowIfNull(text); + if (text.Length == 0) return; + if (!Line.Put(text)) Diagnostics.LimitsReached++; + } + + /// Writes a point: a bell, a sound, a clear. It takes no cells. + public void Point(IPointMarkup point) + { + ArgumentNullException.ThrowIfNull(point); + Line.Put(MarkupText.Point(point)); + } + + /// Moves the cursor to column 0 of the current line; what is written next overwrites. + public void CarriageReturn() => Line.Cursor = 0; + + /// Moves the cursor to the left, stopping at column 0. + public void Backspace(int cells = 1) => Line.Cursor = Math.Max(0, Line.Cursor - Math.Max(0, cells)); + + /// Moves the cursor to of the current line, which is padded with spaces when it is shorter. + public void MoveToColumn(int column) => Line.Cursor = Math.Clamp(column, 0, Options.MaxLineLength); + + /// Erases from the cursor to the end of the line. + public void EraseToEndOfLine() => Line.EraseFrom(Line.Cursor); + + /// Erases from the start of the line to the cursor, leaving blanks. + public void EraseToStartOfLine() => Line.EraseTo(Line.Cursor); + + /// Erases the whole line; the cursor stays where it is. + public void EraseLine() + { + var cursor = Line.Cursor; + Line.EraseFrom(0); + Line.Cursor = cursor; + } + + /// Ends the current line. + public void EndLine() => EndLine(LineKind.Output); + + internal void EndLine(LineKind kind) + { + _active?.EndLine(this); + foreach (var handler in _decoder.Registry.LineEndHandlers) handler(this); + var content = Line.Take(); + _decoder.Complete(new DisplayLine(content, kind) { Sequence = _sequence++, Destination = Destination }); + } + + /// + /// Places a picture tall and wide at the cursor, as a terminal + /// draws one: its first row beside the text already on the line, each further row on a line of its own + /// indented to the same column. The cursor then stands below the picture, at its right edge when + /// (Kitty) or at column 0 (sixel). + /// + /// The picture; each row is written under a copy with its set. + /// Its height in cells. + /// Its width in cells. + /// Where the cursor goes afterwards. + /// The first column of the picture shown, for a cropped placement. + public void PlacePicture(ImageMarkup picture, int rows, int columns, bool cursorRight, int firstColumn = 0) + { + ArgumentNullException.ThrowIfNull(picture); + if (rows <= 0 || columns <= 0) return; + + var column = Line.Cursor; + var blank = new string(' ', columns); + for (var row = 0; row < rows; row++) + { + if (row > 0) Line.Cursor = column; + var cells = picture with { Row = new PictureRow(row, rows, columns) { FirstColumn = firstColumn } }; + Write(blank, cells); + EndLine(); + } + + Line.Cursor = cursorRight ? column + columns : 0; + } + + /// Raises an event. + public void Raise(DecodeEvent decodeEvent) + { + ArgumentNullException.ThrowIfNull(decodeEvent); + _decoder.Raise(decodeEvent); + } + + /// Raises a : text for the client to send back to the server. + public void Reply(string text) + { + ArgumentException.ThrowIfNullOrEmpty(text); + _decoder.Raise(new ReplyEvent(text)); + } + + /// Whether the text protocol named is reading the text. + public bool IsActive(string name) => ActiveProtocolName is not null && string.Equals(ActiveProtocolName, name, StringComparison.OrdinalIgnoreCase); + + /// Turns a text protocol on, turning off any other. + public bool EnableProtocol(string name) + { + ArgumentNullException.ThrowIfNull(name); + if (IsActive(name)) return true; + if (!_decoder.Registry.TextProtocols.TryGetValue(name, out var create)) return false; + + if (_active is not null) DisableProtocol(ActiveProtocolName!); + if (!_protocols.TryGetValue(name, out var protocol)) _protocols[name] = protocol = create(); + _active = protocol; + ActiveProtocolName = name; + Raise(new ProtocolEvent(name, true)); + return true; + } + + /// Turns a text protocol off. + public void DisableProtocol(string name) + { + if (!IsActive(name)) return; + var protocol = _active!; + _active = null; + ActiveProtocolName = null; + protocol.Reset(this); + Raise(new ProtocolEvent(name, false)); + } + + internal void Feed(ReadOnlySpan text) + { + if (_active is not null) _active.Feed(text, this); + else Print(text); + } + + internal void ResetAll() + { + _active?.Reset(this); + _active = null; + ActiveProtocolName = null; + _states.Clear(); + _pens.Clear(); + _elements.Clear(); + _setValid = false; + Destination = null; + Line.Take(); + } +} diff --git a/MarkupString/Decoding/DecodeModel.cs b/MarkupString/Decoding/DecodeModel.cs new file mode 100644 index 0000000..013fdb8 --- /dev/null +++ b/MarkupString/Decoding/DecodeModel.cs @@ -0,0 +1,208 @@ +using MarkupString.Display; + +namespace MarkupString.Decoding; + +/// What a decoded line is. +public enum LineKind +{ + /// Output that ended with a line break. + Output, + + /// A prompt: output the server marked with telnet GA or EOR, or left without a line break. + Prompt, +} + +/// One decoded line, ready for scrollback. +/// The line's text and markup, without its line break. +/// Whether it is output or a prompt. +public sealed record DisplayLine(MarkupText Content, LineKind Kind) +{ + /// The line's number within its decoder, from 0, for keying a view of it. + public long Sequence { get; init; } + + /// The pane the server sent the line to (MXP <DEST>, Pueblo xch_pane), or null for the main output. + public string? Destination { get; init; } +} + +/// Something a decoder found that is not text to show. +public abstract record DecodeEvent; + +/// Text the client should send back to the server: an answer to a query. +/// The answer, exactly as it is to be sent. +public sealed record ReplyEvent(string Text) : DecodeEvent; + +/// The server set the window or tab title (OSC 0, 1 or 2). +/// The title. +/// Whether it named the icon (OSC 1) rather than, or as well as, the window. +public sealed record TitleEvent(string Title, bool Icon) : DecodeEvent; + +/// Which colour a changes. +public enum PaletteSlot +{ + /// An entry of the 256-colour palette (OSC 4). + Index, + + /// The default foreground (OSC 10). + Foreground, + + /// The default background (OSC 11). + Background, + + /// The cursor (OSC 12). + Cursor, +} + +/// The server redefined a colour, or reset it (OSC 4, 10, 11, 12, 104, 110, 111, 112). +/// Which colour. +/// The palette entry, for ; -1 with a null colour resets every entry. +/// The new colour, or null for a reset. +public sealed record PaletteEvent(PaletteSlot Slot, int Index, RgbColor? Color) : DecodeEvent; + +/// The server asked to write the clipboard (OSC 52). Refuse it unless the player allowed it. +/// The selection it named, such as c for the clipboard. +/// The text, decoded, or null when it asked to read the clipboard instead. +public sealed record ClipboardEvent(string Selection, string? Text) : DecodeEvent; + +/// The server set a variable (MXP <VAR> or an entity), for gauges and status bars. +/// The variable. +/// Its value, or null when it was deleted. +public sealed record VariableEvent(string Name, string? Value) : DecodeEvent; + +/// The server offered a file to save (iTerm2 inline=0). It is never saved by the decoder. +/// The name it gave, if any. +/// Its size in bytes. +public sealed record DownloadEvent(string? Name, int Size) : DecodeEvent; + +/// A text protocol such as MXP was turned on or off. +/// The protocol. +/// Whether it is now on. +public sealed record ProtocolEvent(string Name, bool Active) : DecodeEvent; + +/// Who the client is, for answering a server that asks, and what its output can show. +public sealed record ClientIdentity +{ + /// The client's name, as it answers XTVERSION and MXP <VERSION>. + public string Name { get; init; } = "MarkupString"; + + /// The client's version. + public string Version { get; init; } = "1.0"; + + /// What the output the session is shown on can draw. + public OutputCapabilities Output { get; init; } = OutputCapabilities.PlainText; + + /// The pane's width in cells. + public int Columns { get; init; } = 80; + + /// The pane's height in cells. + public int Rows { get; init; } = 24; + + /// One cell's width in pixels. 8 by 16 keeps a square picture square under both Kitty and half blocks. + public int CellWidth { get; init; } = 8; + + /// One cell's height in pixels. + public int CellHeight { get; init; } = 16; + + /// The default foreground, for a server that asks (OSC 10), or null not to say. + public RgbColor? Foreground { get; init; } + + /// The default background, for a server that asks (OSC 11), or null not to say. + public RgbColor? Background { get; init; } + + /// Which questions the decoder answers. + public ClientAnswers Answers { get; init; } = ClientAnswers.Default; + + /// + /// The client's MTTS bits (Mud Terminal Type Standard), for the last step of the telnet TTYPE cycle: + /// ANSI, VT100, UTF-8, 256 colours, OSC colour palette, truecolor and MSLP from . + /// + /// Whether the player uses a screen reader. + /// Whether the connection is encrypted. + public MttsFlags ToMtts(bool screenReader = false, bool tls = false) + { + var flags = MttsFlags.Utf8 | MttsFlags.Vt100; + if (Output.Colors >= ColorLevel.Standard) flags |= MttsFlags.Ansi; + if (Output.Colors >= ColorLevel.Xterm256) flags |= MttsFlags.Colors256 | MttsFlags.OscColorPalette; + if (Output.Colors >= ColorLevel.TrueColor) flags |= MttsFlags.TrueColor; + if ((Output.Interactions & InteractionKinds.Command) != 0) flags |= MttsFlags.Mslp; + if (Output.Unicode == UnicodeLevel.Ascii) flags &= ~MttsFlags.Utf8; + if (screenReader) flags |= MttsFlags.ScreenReader; + if (tls) flags |= MttsFlags.Ssl; + return flags; + } +} + +/// The questions a decoder answers. Each one can be turned off. +[Flags] +public enum ClientAnswers +{ + /// None. + None = 0, + + /// Device attributes (CSI c) and the terminal's name and version (XTVERSION, CSI > q). + Identity = 1, + + /// The pane's size in cells and pixels (CSI 14 t, 16 t, 18 t). + Size = 2, + + /// The default colours (OSC 10, 11 with ?). + Colors = 4, + + /// Picture protocol queries (Kitty a=q). + Pictures = 8, + + /// + /// The cursor's position (CSI 6 n). Off by default: a server that relies on it assumes a full screen, + /// which a scrolling pane is not. + /// + CursorPosition = 16, + + /// MXP <SUPPORT> and <VERSION>. + Mxp = 32, + + /// Everything but the cursor position. + Default = Identity | Size | Colors | Pictures | Mxp, +} + +/// The Mud Terminal Type Standard's bits. +[Flags] +public enum MttsFlags +{ + /// None. + None = 0, + + /// ANSI colour. + Ansi = 1, + + /// VT100 control sequences. + Vt100 = 2, + + /// UTF-8. + Utf8 = 4, + + /// The 256-colour palette. + Colors256 = 8, + + /// Mouse tracking. + MouseTracking = 16, + + /// Redefining the palette with OSC 4. + OscColorPalette = 32, + + /// The player uses a screen reader. + ScreenReader = 64, + + /// The client is a proxy. + Proxy = 128, + + /// 24-bit colour. + TrueColor = 256, + + /// The Mud New-Environment Standard. + Mnes = 512, + + /// The Mud Server Link Protocol: command links. + Mslp = 1024, + + /// The connection is encrypted. + Ssl = 2048, +} diff --git a/MarkupString/Decoding/Decoder.cs b/MarkupString/Decoding/Decoder.cs new file mode 100644 index 0000000..0132d80 --- /dev/null +++ b/MarkupString/Decoding/Decoder.cs @@ -0,0 +1,336 @@ +namespace MarkupString.Decoding; + +/// +/// Turns one connection's output into s. Make one per connection from a +/// ; it holds the connection's state (the current style, open elements, half-read +/// sequences) and is not safe to use from two threads at once. +/// +/// +/// +/// A line ends at a line feed in the text, or when the host says so with (a telnet layer +/// that strips line terminators) or (telnet GA or EOR). +/// +/// Things that are not text — a title, an answer to send back — are raised through . +/// +public sealed class Decoder +{ + private readonly VtTokenizer _tokenizer; + private readonly Sink _sink; + private readonly List _completed = []; + + internal Decoder(DecoderRegistry registry, DecoderOptions options) + { + Registry = registry; + Options = options; + Identity = options.Identity; + Diagnostics = new DecodeDiagnostics(DecodeMetrics.For(options.Meters)); + _tokenizer = new VtTokenizer(options.Limits); + Context = new DecodeContext(this); + _sink = new Sink(this); + } + + /// The registry this decoder was made from. + public DecoderRegistry Registry { get; } + + /// The options it was made with. + public DecoderOptions Options { get; } + + /// Who the client is. Set it again when the pane is resized or the player changes a setting. + public ClientIdentity Identity { get; set; } + + /// Counts of what was dropped, for a diagnostics view. + public DecodeDiagnostics Diagnostics { get; } + + /// Raised for each event, as it is found. + public event Action? EventRaised; + + /// The line read so far, not yet ended. + public MarkupText PendingLine => Context.Line.Peek(); + + /// Whether anything has been read into the current line. + public bool HasPendingLine => !Context.Line.IsEmpty; + + /// The text protocol now reading the text, such as mxp, or null. + public string? ActiveProtocol => Context.ActiveProtocolName; + + internal DecodeContext Context { get; } + + /// This decoder's instance of a plugin's state, as gives it to the plugin. + public T State() where T : class, new() => Context.State(); + + /// Reads and returns the lines it completed. + public IReadOnlyList Feed(ReadOnlySpan text) + { + _tokenizer.Feed(text, _sink); + return TakeCompleted(); + } + + /// Reads and returns the lines it completed. + public IReadOnlyList Feed(string? text) => Feed((text ?? string.Empty).AsSpan()); + + /// Ends the current line, as a line terminator the host removed would have, and returns it. + public DisplayLine EndLine() + { + _tokenizer.Interrupt(_sink); + Context.EndLine(); + var lines = TakeCompleted(); + return lines[^1]; + } + + /// + /// Ends the current line as a prompt (telnet GA or EOR) and returns it, or returns null when nothing was read + /// into it. + /// + public DisplayLine? EndPrompt() + { + _tokenizer.Interrupt(_sink); + if (Context.Line.IsEmpty) return null; + Context.EndLine(LineKind.Prompt); + var lines = TakeCompleted(); + return lines[^1]; + } + + /// Turns on a text protocol the registry offers, such as mxp after telnet negotiates it. + /// False when the registry offers no protocol of that name. + public bool EnableProtocol(string name) => Context.EnableProtocol(name); + + /// Turns a text protocol off. + public void DisableProtocol(string name) => Context.DisableProtocol(name); + + /// Forgets everything: style, open elements, half-read sequences and the pending line. + public void Reset() + { + _tokenizer.Reset(); + Context.ResetAll(); + _completed.Clear(); + } + + /// Decodes a whole text at once: its lines, the last one included when it has no line break. + public static IReadOnlyList DecodeAll(string? text, DecoderRegistry registry, DecoderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(registry); + var decoder = registry.CreateDecoder(options); + var lines = new List(decoder.Feed(text)); + if (decoder.HasPendingLine) lines.Add(decoder.EndLine()); + return lines; + } + + internal void Complete(DisplayLine line) => _completed.Add(line); + + internal void Raise(DecodeEvent e) => EventRaised?.Invoke(e); + + private IReadOnlyList TakeCompleted() + { + if (_completed.Count == 0) return []; + var lines = _completed.ToArray(); + _completed.Clear(); + return lines; + } + + /// Dispatches what the tokenizer finds to the registry's handlers. + private sealed class Sink(Decoder decoder) : IVtTokenSink + { + private DecodeContext Context => decoder.Context; + private DecoderRegistry Registry => decoder.Registry; + + public void Print(ReadOnlySpan text) => Context.Feed(text); + + public void Execute(char control) + { + if (Registry.Controls.TryGetValue(control, out var handlers)) + { + foreach (var handler in handlers) + { + if (handler(control, Context)) return; + } + } + + // A line feed always ends a line, whatever handles the rest of C0. + if (control == '\n') + { + Context.EndLine(); + return; + } + + decoder.Diagnostics.UnhandledControls++; + } + + public void Csi(in CsiSequence sequence) + { + if (Registry.Csi.TryGetValue(new CsiKey(sequence.Prefix, sequence.Intermediates.ToString(), sequence.Final), out var handlers)) + { + foreach (var handler in handlers) + { + if (handler(sequence, Context)) return; + } + } + + decoder.Diagnostics.UnhandledSequences++; + } + + public void Esc(in EscSequence sequence) + { + if (Registry.Esc.TryGetValue(new CsiKey('\0', sequence.Intermediates.ToString(), sequence.Final), out var handlers)) + { + foreach (var handler in handlers) + { + if (handler(sequence, Context)) return; + } + } + + decoder.Diagnostics.UnhandledSequences++; + } + + public void Osc(ReadOnlySpan payload) + { + var separator = payload.IndexOf(';'); + var number = separator < 0 ? payload : payload[..separator]; + var rest = separator < 0 ? [] : payload[(separator + 1)..]; + if (int.TryParse(number, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var command) + && Registry.Osc.TryGetValue(command, out var handlers)) + { + foreach (var handler in handlers) + { + if (handler(rest, Context)) return; + } + } + + decoder.Diagnostics.UnhandledSequences++; + } + + public void Dcs(in CsiSequence header, ReadOnlySpan data) + { + if (Registry.Dcs.TryGetValue(new CsiKey(header.Prefix, header.Intermediates.ToString(), header.Final), out var handlers)) + { + foreach (var handler in handlers) + { + if (handler(header, data, Context)) return; + } + } + + decoder.Diagnostics.UnhandledSequences++; + } + + public void Apc(ReadOnlySpan payload) + { + foreach (var handler in Registry.Apc) + { + if (handler(payload, Context)) return; + } + + decoder.Diagnostics.UnhandledSequences++; + } + + public void Abandoned(VtSequenceKind kind) => decoder.Diagnostics.AbandonedSequences++; + } +} + +/// +/// Counts of what a decoder dropped. Each is also reported on the MarkupString.Decoding meter, as the +/// counter markupstring.decoding.dropped tagged with its kind. +/// +public sealed class DecodeDiagnostics +{ + private readonly DecodeMetrics? _metrics; + private int _abandoned, _unhandled, _controls, _limits, _pictures; + + /// Counts that are not reported anywhere. + public DecodeDiagnostics() + { + } + + internal DecodeDiagnostics(DecodeMetrics metrics) => _metrics = metrics; + + /// Sequences that were malformed, too long, or cut off by a line ending. + public int AbandonedSequences + { + get => _abandoned; + set => _abandoned = Report(_abandoned, value, DecodeDrop.AbandonedSequence); + } + + /// Well-formed sequences no handler claimed. + public int UnhandledSequences + { + get => _unhandled; + set => _unhandled = Report(_unhandled, value, DecodeDrop.UnhandledSequence); + } + + /// Control characters no handler claimed. + public int UnhandledControls + { + get => _controls; + set => _controls = Report(_controls, value, DecodeDrop.UnhandledControl); + } + + /// Things dropped because a limit was reached: an element stack, a line length, a picture size. + public int LimitsReached + { + get => _limits; + set => _limits = Report(_limits, value, DecodeDrop.Limit); + } + + /// Pictures that could not be decoded. + public int PicturesFailed + { + get => _pictures; + set => _pictures = Report(_pictures, value, DecodeDrop.Picture); + } + + /// Raised each time a count goes up, with what was dropped and how many. + public event Action? Dropped; + + private int Report(int before, int after, DecodeDrop kind) + { + if (after <= before) return after; + _metrics?.Dropped(after - before, kind); + Dropped?.Invoke(kind, after - before); + return after; + } +} + +/// What a decoder dropped, as reports it. +public enum DecodeDrop +{ + /// A sequence that was malformed, too long, or cut off by a line ending. + AbandonedSequence, + + /// A well-formed sequence no handler claimed. + UnhandledSequence, + + /// A control character no handler claimed. + UnhandledControl, + + /// Something dropped because a limit was reached. + Limit, + + /// A picture that could not be decoded. + Picture, +} + +/// The decoding meter, one for each meter factory. +internal sealed class DecodeMetrics +{ + public const string MeterName = "MarkupString.Decoding"; + + private static readonly DecodeMetrics Shared = new(new System.Diagnostics.Metrics.Meter(MeterName)); + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable ByFactory = []; + + private readonly System.Diagnostics.Metrics.Counter _dropped; + + private DecodeMetrics(System.Diagnostics.Metrics.Meter meter) => + _dropped = meter.CreateCounter("markupstring.decoding.dropped", "{item}", "What decoders dropped, by kind."); + + public static DecodeMetrics For(System.Diagnostics.Metrics.IMeterFactory? factory) => + factory is null ? Shared : ByFactory.GetValue(factory, static f => new DecodeMetrics(f.Create(new System.Diagnostics.Metrics.MeterOptions(MeterName)))); + + public void Dropped(int count, DecodeDrop kind) => _dropped.Add(count, new KeyValuePair("kind", Tag(kind))); + + private static string Tag(DecodeDrop kind) => kind switch + { + DecodeDrop.AbandonedSequence => "abandoned-sequence", + DecodeDrop.UnhandledSequence => "unhandled-sequence", + DecodeDrop.UnhandledControl => "unhandled-control", + DecodeDrop.Limit => "limit", + _ => "picture", + }; +} diff --git a/MarkupString/Decoding/DecoderRegistry.cs b/MarkupString/Decoding/DecoderRegistry.cs new file mode 100644 index 0000000..a53a7c9 --- /dev/null +++ b/MarkupString/Decoding/DecoderRegistry.cs @@ -0,0 +1,245 @@ +using System.Collections.Frozen; + +namespace MarkupString.Decoding; + +/// Claims a control sequence. Returns false to let an older handler for the same sequence try. +public delegate bool CsiHandler(in CsiSequence sequence, DecodeContext context); + +/// Claims an escape sequence. Returns false to let an older handler try. +public delegate bool EscHandler(in EscSequence sequence, DecodeContext context); + +/// Claims an operating system command. is what follows its number and ;. +public delegate bool OscHandler(ReadOnlySpan payload, DecodeContext context); + +/// Claims a device control string. +public delegate bool DcsHandler(in CsiSequence header, ReadOnlySpan data, DecodeContext context); + +/// Claims an application program command. +public delegate bool ApcHandler(ReadOnlySpan payload, DecodeContext context); + +/// Claims a C0 control character. +public delegate bool ControlHandler(char control, DecodeContext context); + +/// +/// Sees printable text before it is written, and returns true when it wrote the text itself (with +/// ), as a filter that translates a character set does. +/// +public delegate bool PrintFilter(ReadOnlySpan text, DecodeContext context); + +/// +/// A protocol carried in the text itself, such as MXP or Pueblo: while it is on, every piece of printable text +/// goes to it, and it writes what is literal text with . +/// One instance serves one decoder. +/// +public interface ITextProtocol +{ + /// Reads printable text. + void Feed(ReadOnlySpan text, DecodeContext context); + + /// The line is ending: finish or abandon whatever is half read, and end what lasts only a line. + void EndLine(DecodeContext context); + + /// The protocol was turned off, or the decoder reset: forget everything. + void Reset(DecodeContext context); +} + +/// A protocol that adds handlers to a . +public interface IDecoderPlugin +{ + /// The plugin's name, such as vt or mxp. + string Name { get; } + + /// Adds this plugin's handlers. + void Register(DecoderBuilder builder); +} + +/// Collects a plugin's handlers. Handlers added later are tried first. +public sealed class DecoderBuilder +{ + internal readonly Dictionary> Csi = []; + internal readonly Dictionary> Dcs = []; + internal readonly Dictionary> Esc = []; + internal readonly Dictionary> Osc = []; + internal readonly Dictionary> Controls = []; + internal readonly List Apc = []; + internal readonly List PrintFilters = []; + internal readonly Dictionary> TextProtocols = new(StringComparer.OrdinalIgnoreCase); + internal readonly List> LineEndHandlers = []; + + internal DecoderBuilder() + { + } + + /// Handles the control sequence ending in , with that prefix and those intermediates. + public DecoderBuilder OnCsi(char final, CsiHandler handler, char prefix = '\0', string intermediates = "") + { + ArgumentNullException.ThrowIfNull(handler); + Add(Csi, new CsiKey(prefix, intermediates ?? string.Empty, final), handler); + return this; + } + + /// Handles the device control string whose header ends in . + public DecoderBuilder OnDcs(char final, DcsHandler handler, char prefix = '\0', string intermediates = "") + { + ArgumentNullException.ThrowIfNull(handler); + Add(Dcs, new CsiKey(prefix, intermediates ?? string.Empty, final), handler); + return this; + } + + /// Handles the escape sequence ending in after those intermediates. + public DecoderBuilder OnEsc(char final, EscHandler handler, string intermediates = "") + { + ArgumentNullException.ThrowIfNull(handler); + Add(Esc, new CsiKey('\0', intermediates ?? string.Empty, final), handler); + return this; + } + + /// Handles operating system command . + public DecoderBuilder OnOsc(int command, OscHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + ArgumentOutOfRangeException.ThrowIfNegative(command); + Add(Osc, command, handler); + return this; + } + + /// Handles application program commands. Every handler sees each one until one claims it. + public DecoderBuilder OnApc(ApcHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + Apc.Insert(0, handler); + return this; + } + + /// Handles the C0 control . + public DecoderBuilder OnControl(char control, ControlHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + Add(Controls, control, handler); + return this; + } + + /// Sees printable text before it is written. + public DecoderBuilder OnPrint(PrintFilter filter) + { + ArgumentNullException.ThrowIfNull(filter); + PrintFilters.Insert(0, filter); + return this; + } + + /// Is told whenever a line ends, after a text protocol has been. + public DecoderBuilder OnLineEnd(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + LineEndHandlers.Add(handler); + return this; + } + + /// Offers a text protocol by name; a decoder makes one instance when it is first turned on. + public DecoderBuilder TextProtocol(string name, Func create) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(create); + TextProtocols[name] = create; + return this; + } + + private static void Add(Dictionary> table, TKey key, T handler) where TKey : notnull + { + if (!table.TryGetValue(key, out var list)) table[key] = list = []; + list.Insert(0, handler); + } +} + +internal readonly record struct CsiKey(char Prefix, string Intermediates, char Final); + +/// +/// The protocols a decoder reads, built once and shared. Each connection makes its own from +/// it, which holds that connection's state. +/// +public sealed class DecoderRegistry : IDecoderFactory +{ + private readonly IDecoderPlugin[] _plugins; + + private DecoderRegistry(IDecoderPlugin[] plugins) + { + _plugins = plugins; + var builder = new DecoderBuilder(); + foreach (var plugin in plugins) plugin.Register(builder); + + Csi = builder.Csi.ToFrozenDictionary(pair => pair.Key, pair => pair.Value.ToArray()); + Dcs = builder.Dcs.ToFrozenDictionary(pair => pair.Key, pair => pair.Value.ToArray()); + Esc = builder.Esc.ToFrozenDictionary(pair => pair.Key, pair => pair.Value.ToArray()); + Osc = builder.Osc.ToFrozenDictionary(pair => pair.Key, pair => pair.Value.ToArray()); + Controls = builder.Controls.ToFrozenDictionary(pair => pair.Key, pair => pair.Value.ToArray()); + Apc = [.. builder.Apc]; + PrintFilters = [.. builder.PrintFilters]; + TextProtocols = builder.TextProtocols.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + LineEndHandlers = [.. builder.LineEndHandlers]; + } + + /// A registry that reads nothing: every sequence is dropped and text passes through. + public static DecoderRegistry Empty { get; } = new([]); + + /// The plugins, in the order they were added. + public IReadOnlyList Plugins => _plugins; + + internal FrozenDictionary Csi { get; } + internal FrozenDictionary Dcs { get; } + internal FrozenDictionary Esc { get; } + internal FrozenDictionary Osc { get; } + internal FrozenDictionary Controls { get; } + internal ApcHandler[] Apc { get; } + internal PrintFilter[] PrintFilters { get; } + internal FrozenDictionary> TextProtocols { get; } + internal Action[] LineEndHandlers { get; } + + /// A registry with added. A plugin of the same name is replaced. + public DecoderRegistry With(IDecoderPlugin plugin) + { + ArgumentNullException.ThrowIfNull(plugin); + var plugins = _plugins.Where(existing => !string.Equals(existing.Name, plugin.Name, StringComparison.OrdinalIgnoreCase)).Append(plugin); + return new DecoderRegistry([.. plugins]); + } + + /// Whether a plugin of that name is installed. + public bool Has(string name) => _plugins.Any(plugin => string.Equals(plugin.Name, name, StringComparison.OrdinalIgnoreCase)); + + /// Whether a text protocol of that name is offered. + public bool HasTextProtocol(string name) => TextProtocols.ContainsKey(name); + + /// A decoder for one connection. + public Decoder CreateDecoder(DecoderOptions? options = null) => new(this, options ?? new DecoderOptions()); +} + +/// Settings for one decoder. +public sealed record DecoderOptions +{ + /// How far a control sequence may grow. + public VtLimits Limits { get; init; } = VtLimits.Default; + + /// Who the client is, for answering a server that asks. + public ClientIdentity Identity { get; init; } = new(); + + /// The most elements a text protocol may have open at once. + public int MaxOpenElements { get; init; } = 64; + + /// The longest line, in characters; a longer line is ended there and the rest starts a new one. + public int MaxLineLength { get; init; } = 64 * 1024; + + /// Which link addresses a server may send; one it may not is read as text. unless set. + public IUrlPolicy Links { get; init; } = UrlPolicy.Safe; + + /// + /// Where are reported as metrics (the MarkupString.Decoding meter), or null + /// for the shared meter. Pass the host's, as ASP.NET Core registers one. + /// + public System.Diagnostics.Metrics.IMeterFactory? Meters { get; init; } +} + +/// Makes a for each connection: a , or a host's own. +public interface IDecoderFactory +{ + /// A decoder for one connection. + Decoder CreateDecoder(DecoderOptions? options = null); +} diff --git a/MarkupString/Decoding/LineBuilder.cs b/MarkupString/Decoding/LineBuilder.cs new file mode 100644 index 0000000..b2be130 --- /dev/null +++ b/MarkupString/Decoding/LineBuilder.cs @@ -0,0 +1,145 @@ +namespace MarkupString.Decoding; + +/// +/// The line being read: pieces of text appended at the cursor, and overwritten in place when the cursor has been +/// moved back (a carriage return, a backspace), so a progress bar redrawn over itself ends as its last state. +/// +internal sealed class LineBuilder(int maxLength) +{ + private readonly List _parts = []; + private int _width; + private int _length; + private int _cursor; + + public bool IsEmpty => _parts.Count == 0; + + /// The characters on the line. + public int Length => _length; + + /// The longest the line may be. + public int MaxLength => maxLength; + + /// The column the next text is written at. Past the end, the gap is filled with spaces when text comes. + public int Cursor + { + get => _cursor; + set => _cursor = Math.Max(0, value); + } + + public bool Write(string text, MarkupSet? markups) => + Put(markups is null ? MarkupText.Plain(text) : MarkupText.Wrap(markups, text)); + + /// Writes at the cursor and moves the cursor past it. + /// False when the line was full and the text was dropped. + public bool Put(MarkupText text) + { + if (_length + text.Length > maxLength) return false; + + if (_cursor > _width) + { + var gap = MarkupText.Plain(new string(' ', _cursor - _width)); + Append(gap); + } + + var width = text.DisplayWidth; + if (_cursor == _width) + { + Append(text); + _cursor = _width; + return true; + } + + // Overwrite the cells from the cursor on. A wide character the cursor lands inside, or that the new text + // only partly covers, is blanked where it is not covered, as a terminal blanks it. + var line = Materialise(); + var before = line.Substring(0, DisplayWidth.IndexAtWidth(line.Text, _cursor)); + var leading = _cursor - before.DisplayWidth; + + var end = _cursor + width; + var afterIndex = DisplayWidth.IndexAtWidth(line.Text, end); + var reached = DisplayWidth.Of(line.Text.AsSpan(0, afterIndex)); + if (reached < end && afterIndex < line.Length) + { + var span = line.Text.AsSpan(afterIndex); + var enumerator = Graphemes.Enumerate(span); + if (enumerator.MoveNext()) + { + var cluster = enumerator.Current.End.GetOffset(span.Length); + reached += DisplayWidth.Of(span[..cluster]); + afterIndex += cluster; + } + } + + var trailing = Math.Max(0, reached - end); + var after = line.Substring(afterIndex); + Replace(MarkupText.Concat([ + before, + leading > 0 ? MarkupText.Plain(new string(' ', leading)) : MarkupText.Empty, + text, + trailing > 0 ? MarkupText.Plain(new string(' ', trailing)) : MarkupText.Empty, + after])); + _cursor = end; + return true; + } + + /// Removes everything from column on. + public void EraseFrom(int column) + { + if (column >= _width) return; + var line = Materialise(); + Replace(line.Substring(0, DisplayWidth.IndexAtWidth(line.Text, column))); + } + + /// Blanks everything up to and including column . + public void EraseTo(int column) + { + if (_width == 0) return; + var line = Materialise(); + var end = Math.Min(_width, column + 1); + var after = line.Substring(DisplayWidth.IndexAtWidth(line.Text, end)); + Replace(MarkupText.Concat(MarkupText.Plain(new string(' ', end)), after)); + } + + /// The line as it stands. + public MarkupText Peek() => _parts.Count switch + { + 0 => MarkupText.Empty, + 1 => _parts[0], + _ => Materialise(), + }; + + /// The line, which is then empty with the cursor at column 0. + public MarkupText Take() + { + var line = Peek(); + _parts.Clear(); + _width = 0; + _length = 0; + _cursor = 0; + return line; + } + + private void Append(MarkupText text) + { + _parts.Add(text); + _width += text.DisplayWidth; + _length += text.Length; + } + + private MarkupText Materialise() + { + if (_parts.Count == 1) return _parts[0]; + var line = MarkupText.Concat(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_parts)); + _parts.Clear(); + _parts.Add(line); + return line; + } + + private void Replace(MarkupText line) + { + _parts.Clear(); + _width = 0; + _length = 0; + if (line.Length > 0) Append(line); + } +} diff --git a/MarkupString/Decoding/TagScanner.cs b/MarkupString/Decoding/TagScanner.cs new file mode 100644 index 0000000..d9e9e19 --- /dev/null +++ b/MarkupString/Decoding/TagScanner.cs @@ -0,0 +1,187 @@ +using System.Text; +namespace MarkupString.Decoding; + +/// What a finds. +public interface ITagSink +{ + /// Whether < and & start markup now; when false they are text. + bool ReadsMarkup { get; } + + /// Text. + void Text(ReadOnlySpan text); + + /// A tag: what was between < and >. + void Tag(ReadOnlySpan body); + + /// An entity: what was between & and ;. + void Entity(ReadOnlySpan name); +} + +/// +/// Splits a tag protocol's text (MXP, Pueblo's HTML) into text, tags and entities, across as many feeds as it +/// arrives in. Comments (<!-- -->) are dropped. A tag or entity too long to be one is text. +/// +public sealed class TagScanner +{ + private enum State + { + Text, + Tag, + Quoted, + Comment, + Entity, + } + + private readonly StringBuilder _pending = new(); + private State _state; + private char _quote; + + /// The longest tag read as one. + public int MaxTagLength { get; init; } = 8192; + + /// The longest entity name read as one. + public int MaxEntityLength { get; init; } = 32; + + /// Whether a tag or an entity is half read. + public bool InMarkup => _state != State.Text; + + /// Reads . + public void Feed(ReadOnlySpan text, ITagSink sink) + { + ArgumentNullException.ThrowIfNull(sink); + var start = 0; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + switch (_state) + { + case State.Text: + if (!sink.ReadsMarkup || (c != '<' && c != '&')) continue; + if (i > start) sink.Text(text[start..i]); + _pending.Clear(); + _state = c == '<' ? State.Tag : State.Entity; + start = i + 1; + continue; + + case State.Tag: + if (c is '"' or '\'') + { + _quote = c; + _state = State.Quoted; + } + else if (c == '>') + { + _state = State.Text; + start = i + 1; + sink.Tag(Take()); + continue; + } + + _pending.Append(c); + if (_pending.Length == 3 && _pending.Equals("!--")) + { + _pending.Clear(); + _state = State.Comment; + } + else if (_pending.Length > MaxTagLength) + { + Abandon('<', sink); + start = i + 1; + } + + continue; + + case State.Quoted: + _pending.Append(c); + if (c == _quote) _state = State.Tag; + else if (_pending.Length > MaxTagLength) + { + Abandon('<', sink); + start = i + 1; + } + + continue; + + case State.Comment: + _pending.Append(c); + if (_pending.Length > 3) _pending.Remove(0, _pending.Length - 3); + if (_pending.Equals("-->")) + { + _pending.Clear(); + _state = State.Text; + start = i + 1; + } + + continue; + + case State.Entity: + if (c == ';') + { + _state = State.Text; + start = i + 1; + sink.Entity(Take()); + continue; + } + + if (char.IsAsciiLetterOrDigit(c) || c is '#' or '_' or '-' or '.') + { + _pending.Append(c); + if (_pending.Length > MaxEntityLength) + { + Abandon('&', sink); + start = i + 1; + } + + continue; + } + + // Not an entity after all: the ampersand and what followed are text, this character included. + Abandon('&', sink); + start = i; + i--; + continue; + } + } + + if (_state == State.Text && start < text.Length) sink.Text(text[start..]); + } + + /// Ends what is half read, as text: a line ended inside a tag. + public void Flush(ITagSink sink) + { + ArgumentNullException.ThrowIfNull(sink); + switch (_state) + { + case State.Tag or State.Quoted: + Abandon('<', sink); + break; + case State.Entity: + Abandon('&', sink); + break; + case State.Comment: + // A comment may run over lines; it waits for its end. + return; + } + } + + /// Forgets what is half read. + public void Reset() + { + _pending.Clear(); + _state = State.Text; + } + + private string Take() + { + var taken = _pending.ToString(); + _pending.Clear(); + return taken; + } + + private void Abandon(char opener, ITagSink sink) + { + var text = opener + Take(); + _state = State.Text; + sink.Text(text); + } +} diff --git a/MarkupString/Decoding/TagSyntax.cs b/MarkupString/Decoding/TagSyntax.cs new file mode 100644 index 0000000..6f45693 --- /dev/null +++ b/MarkupString/Decoding/TagSyntax.cs @@ -0,0 +1,181 @@ +namespace MarkupString.Decoding; + +/// One argument of a tag: name=value, name alone, or a value with no name. +/// The name, or null for a positional value. +/// The value, unquoted; for a name alone, empty. +public readonly record struct TagArgument(string? Name, string Value); + +/// A tag as MXP and Pueblo write them: <name arg arg=value>, </name> or <name/>. +/// The tag's name, as written. +/// Whether it is a closing tag. +/// Its arguments, in order. +public sealed record ParsedTag(string Name, bool Closing, IReadOnlyList Arguments) +{ + /// Whether it closes itself (<name/>). + public bool SelfClosing { get; init; } + + /// + /// The argument named , ignoring case, or else the positional one at + /// (counting positional arguments only), or null. + /// + public string? Get(string name, int position = -1) + { + foreach (var argument in Arguments) + { + if (argument.Name is not null && argument.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) return argument.Value; + } + + if (position < 0) return null; + var at = 0; + foreach (var argument in Arguments) + { + if (argument.Name is not null) continue; + if (at++ == position) return argument.Value; + } + + return null; + } + + /// Whether a flag (a positional argument such as PROMPT) or a named argument is present. + public bool Has(string name) + { + foreach (var argument in Arguments) + { + if (string.Equals(argument.Name ?? argument.Value, name, StringComparison.OrdinalIgnoreCase)) return true; + } + + return false; + } +} + +/// Reads tags and entities the way MXP and Pueblo write them. +public static class TagSyntax +{ + /// + /// Parses what is between < and >. Values may be quoted with " or '; entities in them + /// are decoded with when it is given. + /// + public static ParsedTag? Parse(ReadOnlySpan body, Func? entity = null) + { + body = body.Trim(); + var closing = false; + var selfClosing = false; + if (body.StartsWith('/')) + { + closing = true; + body = body[1..].TrimStart(); + } + + if (body.EndsWith('/')) + { + selfClosing = true; + body = body[..^1].TrimEnd(); + } + + var nameEnd = 0; + while (nameEnd < body.Length && !char.IsWhiteSpace(body[nameEnd])) nameEnd++; + if (nameEnd == 0) return null; + var name = body[..nameEnd].ToString(); + var rest = body[nameEnd..]; + + var arguments = new List(); + while (true) + { + rest = rest.TrimStart(); + if (rest.IsEmpty) break; + + string? argumentName = null; + if (rest[0] is not ('"' or '\'')) + { + var end = 0; + while (end < rest.Length && !char.IsWhiteSpace(rest[end]) && rest[end] != '=') end++; + var word = rest[..end].ToString(); + rest = rest[end..].TrimStart(); + if (rest.IsEmpty || rest[0] != '=') + { + arguments.Add(new TagArgument(null, Decode(word, entity))); + continue; + } + + argumentName = word; + rest = rest[1..].TrimStart(); + } + + string value; + if (!rest.IsEmpty && rest[0] is '"' or '\'') + { + var close = rest[1..].IndexOf(rest[0]); + value = close < 0 ? rest[1..].ToString() : rest.Slice(1, close).ToString(); + rest = close < 0 ? [] : rest[(close + 2)..]; + } + else + { + var end = 0; + while (end < rest.Length && !char.IsWhiteSpace(rest[end])) end++; + value = rest[..end].ToString(); + rest = rest[end..]; + } + + arguments.Add(new TagArgument(argumentName, Decode(value, entity))); + } + + return new ParsedTag(name, closing, arguments) { SelfClosing = selfClosing }; + } + + /// + /// The text an entity name (what is between & and ;) stands for: a number (#65, + /// #x41), one of HTML's, or what returns; null when it is none of those. + /// + public static string? Entity(ReadOnlySpan name, Func? entity = null) + { + if (name.IsEmpty) return null; + if (name[0] == '#') + { + var number = name[1..]; + var hex = !number.IsEmpty && number[0] is 'x' or 'X'; + if (hex) number = number[1..]; + if (!int.TryParse(number, hex ? System.Globalization.NumberStyles.AllowHexSpecifier : System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var code)) return null; + if (code is <= 0 or > 0x10FFFF || (code is >= 0xD800 and <= 0xDFFF)) return null; + // A control character from an entity would reach the screen as a control; it is dropped. + if (code < 0x20 && code != '\t') return string.Empty; + return char.ConvertFromUtf32(code); + } + + if (entity?.Invoke(name.ToString()) is { } defined) return defined; + var raw = $"&{name};"; + var decoded = System.Net.WebUtility.HtmlDecode(raw); + return decoded == raw ? null : decoded; + } + + /// with its entities decoded; one that is not known is left as it is. + public static string Decode(string text, Func? entity = null) + { + var amp = text.IndexOf('&'); + if (amp < 0) return text; + + var result = new System.Text.StringBuilder(text.Length); + var at = 0; + while (amp >= 0) + { + var semicolon = text.IndexOf(';', amp + 1); + if (semicolon < 0 || semicolon - amp > 32) break; + var decoded = Entity(text.AsSpan(amp + 1, semicolon - amp - 1), entity); + result.Append(text, at, amp - at); + if (decoded is null) + { + result.Append(text, amp, semicolon - amp + 1); + } + else + { + result.Append(decoded); + } + + at = semicolon + 1; + amp = text.IndexOf('&', at); + } + + result.Append(text, at, text.Length - at); + return result.ToString(); + } +} diff --git a/MarkupString/Decoding/VtSequences.cs b/MarkupString/Decoding/VtSequences.cs new file mode 100644 index 0000000..8024ff7 --- /dev/null +++ b/MarkupString/Decoding/VtSequences.cs @@ -0,0 +1,96 @@ +namespace MarkupString.Decoding; + +/// What a hands on as it reads. +public interface IVtTokenSink +{ + /// Printable text with no controls in it. + void Print(ReadOnlySpan text); + + /// A C0 control character other than ESC: a line feed, a bell, a backspace. + void Execute(char control); + + /// A complete control sequence. + void Csi(in CsiSequence sequence); + + /// A complete escape sequence. + void Esc(in EscSequence sequence); + + /// An operating system command's payload, the number and its ; included. + void Osc(ReadOnlySpan payload); + + /// A device control string: its header, read as a control sequence, and its data. + void Dcs(in CsiSequence header, ReadOnlySpan data); + + /// An application program command's payload. + void Apc(ReadOnlySpan payload); + + /// A sequence that was malformed, too long, or cut off by a line ending. + void Abandoned(VtSequenceKind kind); +} + +/// +/// A control sequence: an optional private prefix (< to ?), parameters, up to two intermediates, +/// and a final character. A parameter left empty reads as . +/// +public readonly ref struct CsiSequence +{ + /// The value of a parameter that was left empty. + public const int Omitted = -1; + + private readonly ReadOnlySpan _parameters; + private readonly ReadOnlySpan _subparameters; + + /// A sequence from its parts. + public CsiSequence(char prefix, ReadOnlySpan parameters, ReadOnlySpan subparameters, ReadOnlySpan intermediates, char final) + { + if (parameters.Length != subparameters.Length) + throw new ArgumentException("There is one subparameter flag per parameter.", nameof(subparameters)); + Prefix = prefix; + _parameters = parameters; + _subparameters = subparameters; + Intermediates = intermediates; + Final = final; + } + + /// The private prefix, or '\0'. + public char Prefix { get; } + + /// The intermediate characters, between the parameters and the final character. + public ReadOnlySpan Intermediates { get; } + + /// The final character, which with the prefix and intermediates names the sequence. + public char Final { get; } + + /// Every parameter, subparameters included, where empty. + public ReadOnlySpan Parameters => _parameters; + + /// How many parameters there are, subparameters included. + public int Count => _parameters.Length; + + /// Parameter , or when it is missing or empty. + public int Get(int index, int fallback = 0) => + index < _parameters.Length && _parameters[index] != Omitted ? _parameters[index] : fallback; + + /// Whether parameter follows a :, making it a subparameter of the one before. + public bool IsSubparameter(int index) => index < _subparameters.Length && _subparameters[index]; + + /// Whether this is the sequence with and no intermediates. + public bool Is(char final, char prefix = '\0') => Final == final && Prefix == prefix && Intermediates.IsEmpty; +} + +/// An escape sequence: ESC, up to two intermediates, and a final character. +public readonly ref struct EscSequence +{ + /// A sequence from its parts. + public EscSequence(ReadOnlySpan intermediates, char final) + { + Intermediates = intermediates; + Final = final; + } + + /// The intermediate characters, such as the ( of ESC ( 0. + public ReadOnlySpan Intermediates { get; } + + /// The final character. + public char Final { get; } +} diff --git a/MarkupString/Decoding/VtTokenizer.cs b/MarkupString/Decoding/VtTokenizer.cs new file mode 100644 index 0000000..88f652d --- /dev/null +++ b/MarkupString/Decoding/VtTokenizer.cs @@ -0,0 +1,628 @@ +namespace MarkupString.Decoding; + +/// +/// Splits a stream of text into printable text and terminal control sequences, following the DEC parser state +/// machine described by Paul Williams (the one xterm.js and Rust's vte implement). It recognises sequences +/// and never interprets them: what a sequence means is the 's business. +/// +/// +/// State carries across calls, so a sequence may arrive in pieces. +/// +/// A line feed ends any sequence in flight. Without that, an unterminated string sequence (a bare ESC ] or +/// ESC _, which a player can type into a channel) would swallow the rest of a session. Every buffer is +/// capped by ; a sequence that overflows is consumed to its end and reported abandoned. +/// +/// C1 controls (U+0080 to U+009F) are text, as xterm treats them in UTF-8. +/// +public sealed class VtTokenizer +{ + private const char Escape = '\e'; + private const char Bell = '\a'; + private const int Omitted = -1; + + private enum State + { + Ground, + Escape, + EscapeIntermediate, + CsiEntry, + CsiParam, + CsiIntermediate, + CsiIgnore, + OscString, + DcsEntry, + DcsParam, + DcsIntermediate, + DcsPassthrough, + DcsIgnore, + ApcString, + IgnoredString, + } + + private readonly VtLimits _limits; + private readonly int[] _parameters; + private readonly bool[] _subparameters; + private readonly char[] _intermediates = new char[2]; + private readonly System.Text.StringBuilder _string = new(); + + private State _state; + private int _parameterCount; + private int _intermediateCount; + private char _prefix; + private bool _parameterStarted; + private bool _overflow; + private bool _stringEscape; + private char _dcsFinal; + + /// A tokenizer with . + public VtTokenizer() : this(VtLimits.Default) + { + } + + /// A tokenizer with the given limits. + public VtTokenizer(VtLimits limits) + { + ArgumentNullException.ThrowIfNull(limits); + _limits = limits; + _parameters = new int[Math.Max(1, limits.MaxParameters)]; + _subparameters = new bool[_parameters.Length]; + } + + /// Whether a sequence has started and not yet ended. + public bool InSequence => _state != State.Ground; + + /// Reads , handing what it finds to in order. + public void Feed(ReadOnlySpan text, IVtTokenSink sink) + { + ArgumentNullException.ThrowIfNull(sink); + + var printStart = -1; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + + if (_state == State.Ground) + { + if (c >= ' ' && c != '\u007f') + { + if (printStart < 0) printStart = i; + continue; + } + + if (printStart >= 0) + { + sink.Print(text[printStart..i]); + printStart = -1; + } + + if (c == Escape) EnterEscape(); + else if (c != '\u007f') sink.Execute(c); + continue; + } + + Step(c, sink); + } + + if (printStart >= 0) sink.Print(text[printStart..]); + } + + /// + /// Abandons any sequence in flight, as a line ending does. A host whose telnet layer strips line terminators + /// calls this at each line boundary. + /// + public void Interrupt(IVtTokenSink sink) + { + ArgumentNullException.ThrowIfNull(sink); + if (_state != State.Ground) Abandon(sink); + } + + /// Forgets any sequence in flight without reporting it. + public void Reset() + { + _state = State.Ground; + _string.Clear(); + } + + private void Step(char c, IVtTokenSink sink) + { + // A line feed ends the line whatever is in flight. + if (c == '\n') + { + Abandon(sink); + sink.Execute(c); + return; + } + + // CAN and SUB cancel a sequence everywhere. + if (c is '\u0018' or '\u001a') + { + Abandon(sink); + return; + } + + switch (_state) + { + case State.Escape: + StepEscape(c, sink); + break; + case State.EscapeIntermediate: + StepEscapeIntermediate(c, sink); + break; + case State.CsiEntry: + case State.CsiParam: + case State.CsiIntermediate: + case State.CsiIgnore: + StepCsi(c, sink); + break; + case State.OscString: + StepOsc(c, sink); + break; + case State.DcsEntry: + case State.DcsParam: + case State.DcsIntermediate: + case State.DcsIgnore: + StepDcsHeader(c, sink); + break; + case State.DcsPassthrough: + case State.ApcString: + case State.IgnoredString: + StepString(c, sink); + break; + } + } + + private void EnterEscape() + { + _state = State.Escape; + _intermediateCount = 0; + _overflow = false; + } + + private void StepEscape(char c, IVtTokenSink sink) + { + switch (c) + { + case Escape: + EnterEscape(); + return; + case '[': + EnterParameters(State.CsiEntry); + return; + case ']': + EnterString(State.OscString); + return; + case 'P': + EnterParameters(State.DcsEntry); + return; + case '_': + EnterString(State.ApcString); + return; + case 'X' or '^': + EnterString(State.IgnoredString); + return; + } + + if (c < ' ') + { + sink.Execute(c); + return; + } + + if (c <= '/') + { + Collect(c); + _state = State.EscapeIntermediate; + return; + } + + DispatchEscape(c, sink); + } + + private void StepEscapeIntermediate(char c, IVtTokenSink sink) + { + if (c == Escape) + { + EnterEscape(); + return; + } + + if (c < ' ') + { + sink.Execute(c); + return; + } + + if (c <= '/') + { + Collect(c); + return; + } + + DispatchEscape(c, sink); + } + + private void DispatchEscape(char c, IVtTokenSink sink) + { + _state = State.Ground; + if (c > '~' || _overflow) + { + sink.Abandoned(VtSequenceKind.Escape); + return; + } + + sink.Esc(new EscSequence(_intermediates.AsSpan(0, _intermediateCount), c)); + } + + private void EnterParameters(State state) + { + _state = state; + _parameterCount = 0; + _intermediateCount = 0; + _prefix = '\0'; + _parameterStarted = false; + _overflow = false; + } + + private void EnterString(State state) + { + _state = state; + _string.Clear(); + _stringEscape = false; + _overflow = false; + } + + private void StepCsi(char c, IVtTokenSink sink) + { + if (c == Escape) + { + sink.Abandoned(VtSequenceKind.Csi); + EnterEscape(); + return; + } + + if (c < ' ') + { + sink.Execute(c); + return; + } + + if (ReadHeader(c, State.CsiEntry, State.CsiParam, State.CsiIntermediate, State.CsiIgnore)) return; + + _state = State.Ground; + if (c is < '@' or > '~' || _overflow) + { + sink.Abandoned(VtSequenceKind.Csi); + return; + } + + sink.Csi(Header(c)); + } + + private void StepDcsHeader(char c, IVtTokenSink sink) + { + if (c == Escape) + { + sink.Abandoned(VtSequenceKind.Dcs); + EnterEscape(); + return; + } + + // Controls inside a DCS header are ignored, as the state machine has it. + if (c < ' ') return; + + if (ReadHeader(c, State.DcsEntry, State.DcsParam, State.DcsIntermediate, State.DcsIgnore)) return; + + if (c is < '@' or > '~') + { + _overflow = true; + } + + _dcsFinal = c; + _string.Clear(); + _stringEscape = false; + _state = State.DcsPassthrough; + } + + /// + /// Reads one character of a CSI or DCS header: a private prefix, a parameter digit or separator, or an + /// intermediate. Returns false when is the final character. + /// + private bool ReadHeader(char c, State entry, State param, State intermediate, State ignore) + { + if (_state == ignore) + { + // Ignore everything up to and including the final character. + if (c is >= '@' and <= '~') + { + _overflow = true; + return false; + } + + return true; + } + + if (c is >= '<' and <= '?') + { + if (_state == entry) + { + _prefix = c; + _state = param; + } + else + { + _state = ignore; + } + + return true; + } + + if (c is >= '0' and <= '9' or ';' or ':') + { + if (_state == intermediate) + { + _state = ignore; + return true; + } + + _state = param; + ReadParameter(c); + return true; + } + + if (c is >= ' ' and <= '/') + { + Collect(c); + _state = intermediate; + return true; + } + + return false; + } + + private void ReadParameter(char c) + { + if (c is ';' or ':') + { + if (!_parameterStarted) StartParameter(subparameter: false); + // The separator closes this parameter; the next one starts now, empty. + if (_parameterCount >= _parameters.Length) + { + _overflow = true; + return; + } + + _parameters[_parameterCount] = Omitted; + _subparameters[_parameterCount] = c == ':'; + _parameterCount++; + _parameterStarted = true; + return; + } + + if (!_parameterStarted) StartParameter(subparameter: false); + if (_overflow) return; + + var index = _parameterCount - 1; + var current = _parameters[index] == Omitted ? 0 : _parameters[index]; + _parameters[index] = Math.Min(_limits.MaxParameterValue, (current * 10) + (c - '0')); + } + + private void StartParameter(bool subparameter) + { + if (_parameterCount >= _parameters.Length) + { + _overflow = true; + return; + } + + _parameters[_parameterCount] = Omitted; + _subparameters[_parameterCount] = subparameter; + _parameterCount++; + _parameterStarted = true; + } + + private CsiSequence Header(char final) => new( + _prefix, + _parameters.AsSpan(0, _parameterCount), + _subparameters.AsSpan(0, _parameterCount), + _intermediates.AsSpan(0, _intermediateCount), + final); + + private void Collect(char c) + { + if (_intermediateCount < _intermediates.Length) _intermediates[_intermediateCount++] = c; + else _overflow = true; + } + + private void StepOsc(char c, IVtTokenSink sink) + { + if (c == Bell) + { + EndOsc(sink); + return; + } + + if (_stringEscape) + { + _stringEscape = false; + if (c == '\\') + { + EndOsc(sink); + return; + } + + // ESC followed by anything else ends the string and starts a new escape. + sink.Abandoned(VtSequenceKind.Osc); + EnterEscape(); + StepEscape(c, sink); + return; + } + + if (c == Escape) + { + _stringEscape = true; + return; + } + + if (c < ' ') return; + Append(c, _limits.MaxStringLength); + } + + private void EndOsc(IVtTokenSink sink) + { + _state = State.Ground; + if (_overflow) + { + sink.Abandoned(VtSequenceKind.Osc); + } + else + { + sink.Osc(Chunk()); + } + + _string.Clear(); + } + + private void StepString(char c, IVtTokenSink sink) + { + if (_stringEscape) + { + _stringEscape = false; + if (c == '\\') + { + EndString(sink); + return; + } + + sink.Abandoned(Kind()); + EnterEscape(); + StepEscape(c, sink); + return; + } + + if (c == Escape) + { + _stringEscape = true; + return; + } + + // BEL is not a terminator for these by the standard, but tmux and some programs send it. + if (c == Bell) + { + EndString(sink); + return; + } + + if (_state == State.IgnoredString) return; + if (c < ' ') return; + + Append(c, _state == State.ApcString ? _limits.MaxApcLength : _limits.MaxStringLength); + } + + private void EndString(IVtTokenSink sink) + { + var state = _state; + _state = State.Ground; + if (state == State.IgnoredString) + { + _string.Clear(); + return; + } + + if (_overflow) + { + sink.Abandoned(state == State.ApcString ? VtSequenceKind.Apc : VtSequenceKind.Dcs); + } + else if (state == State.ApcString) + { + sink.Apc(Chunk()); + } + else + { + sink.Dcs(Header(_dcsFinal), Chunk()); + } + + _string.Clear(); + } + + private void Append(char c, int max) + { + if (_overflow) return; + if (_string.Length >= max) + { + _overflow = true; + _string.Clear(); + return; + } + + _string.Append(c); + } + + private ReadOnlySpan Chunk() + { + // One chunk is the common case; a multi-chunk builder is copied once. + foreach (var chunk in _string.GetChunks()) + { + if (chunk.Length == _string.Length) return chunk.Span; + break; + } + + return _string.ToString(); + } + + private VtSequenceKind Kind() => _state switch + { + State.ApcString => VtSequenceKind.Apc, + State.OscString => VtSequenceKind.Osc, + State.DcsPassthrough or State.DcsEntry or State.DcsParam or State.DcsIntermediate or State.DcsIgnore => VtSequenceKind.Dcs, + State.CsiEntry or State.CsiParam or State.CsiIntermediate or State.CsiIgnore => VtSequenceKind.Csi, + _ => VtSequenceKind.Escape, + }; + + private void Abandon(IVtTokenSink sink) + { + if (_state is State.Ground) return; + var kind = Kind(); + var ignored = _state == State.IgnoredString; + _state = State.Ground; + _string.Clear(); + if (!ignored) sink.Abandoned(kind); + } +} + +/// The kinds of sequence a recognises. +public enum VtSequenceKind +{ + /// A two-character escape, ESC then a final character, with intermediates. + Escape, + + /// A control sequence, ESC [. + Csi, + + /// An operating system command, ESC ]. + Osc, + + /// A device control string, ESC P. + Dcs, + + /// An application program command, ESC _. + Apc, +} + +/// How far a lets a sequence grow. +public sealed record VtLimits +{ + /// The defaults: 32 parameters, as vte has, a 16 MiB string for a sixel or iTerm2 picture, and 64 KiB for an APC. + public static VtLimits Default { get; } = new(); + + /// The most parameters a CSI or DCS header may have, subparameters included. + public int MaxParameters { get; init; } = 32; + + /// The largest value a parameter is read as; larger values are clamped to it. + public int MaxParameterValue { get; init; } = 65535; + + /// The longest OSC or DCS payload, in characters. + public int MaxStringLength { get; init; } = 16 * 1024 * 1024; + + /// The longest APC payload, in characters. Kitty sends pictures in chunks of 4096. + public int MaxApcLength { get; init; } = 64 * 1024; +} diff --git a/MarkupString/Display/CellRenderer.cs b/MarkupString/Display/CellRenderer.cs new file mode 100644 index 0000000..eed9940 --- /dev/null +++ b/MarkupString/Display/CellRenderer.cs @@ -0,0 +1,236 @@ +namespace MarkupString.Display; + +/// +/// One cell of a : a grapheme and how it looks. A wide grapheme takes two cells, the +/// second a continuation with empty . +/// +/// The grapheme, or empty for the right half of a wide one. +/// How it looks. +public readonly record struct Cell(string Text, DisplayStyle Style) +{ + /// Cells the grapheme takes: 1 or 2, or 0 for a continuation. + public int Width { get; init; } = 1; + + /// + /// For a cell of a picture ( set), its column in the picture, counted in + /// cells from the picture's left edge; with the picture's it says which part of + /// the picture to draw here. + /// + public int PictureColumn { get; init; } + + /// Whether this is the right half of a wide grapheme. + public bool IsContinuation => Width == 0; +} + +/// A point (a bell, a sound) and the cell it stands before. +/// The row. +/// The column. +/// The point. +public readonly record struct CellPoint(int Row, int Column, IPointMarkup Markup); + +/// One row of a . +public sealed class CellRow +{ + internal CellRow(List cells, bool wrapped) + { + Cells = cells; + Wrapped = wrapped; + } + + /// The cells, left to right. A row is as long as its text, never padded to the width. + public IReadOnlyList Cells { get; } + + /// Whether the next row continues this one because the text was wrapped, not because a line ended. + public bool Wrapped { get; } +} + +/// Text laid out on a grid of cells: what a cell renderer draws and hit tests against. +public sealed class CellLayout +{ + internal CellLayout(List rows, List points, int columns) + { + Rows = rows; + Points = points; + Columns = columns; + } + + /// The rows, top to bottom. + public IReadOnlyList Rows { get; } + + /// The points, in order. + public IReadOnlyList Points { get; } + + /// The width it was laid out to, or 0 for unwrapped. + public int Columns { get; } + + /// The cell at and , or null past the text. + public Cell? At(int row, int column) + { + if (row < 0 || row >= Rows.Count || column < 0) return null; + var cells = Rows[row].Cells; + return column < cells.Count ? cells[column] : null; + } + + /// + /// What clicking the cell at and does, or null. The right half + /// of a wide grapheme answers for the whole of it. + /// + public Interaction? InteractionAt(int row, int column) + { + if (At(row, column) is not { } cell) return null; + if (cell.IsContinuation && column > 0) cell = Rows[row].Cells[column - 1]; + return cell.Style.Interaction; + } +} + +/// +/// Lays text out on a grid of cells, for an output that draws cells itself: a Skia window, a terminal emulator's +/// buffer. Graphemes are measured with ; tabs advance to the next stop. +/// +public static class CellRenderer +{ + /// Lays out. + /// The text; a line feed in it starts a new row. + /// The width to wrap at, or 0 not to wrap. + /// The styles. + /// The output. + /// Wrap at the last space that fits, rather than at the last cell. + public static CellLayout Layout(MarkupText text, int columns, IStyleResolver registry, DisplayOptions options, bool wordWrap = false) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(options); + ArgumentOutOfRangeException.ThrowIfNegative(columns); + + var builder = new Builder(columns, Math.Max(1, options.TabWidth), wordWrap); + var styles = new Dictionary(ReferenceEqualityComparer.Instance); + var fold = options.Capabilities.Unicode == UnicodeLevel.Ascii; + var narrow = options.Capabilities.Unicode != UnicodeLevel.Full; + var source = text.Text; + var at = 0; + + foreach (var run in text.Runs) + { + if (run.Start > at) builder.Write(Fold(source[at..run.Start], fold), DisplayStyle.Plain, narrow); + + var point = SpanRenderer.FindPoint(run.Markups); + if (point is not null) + { + if (SpanRenderer.Shows(point, options.Capabilities)) builder.Point(point); + } + else + { + if (!styles.TryGetValue(run.Markups, out var style)) + { + styles[run.Markups] = style = registry.Resolve(run.Markups, options); + } + + builder.Write(Fold(source.Substring(run.Start, run.Length), fold), style, narrow); + } + + at = run.End; + } + + if (at < source.Length) builder.Write(Fold(source[at..], fold), DisplayStyle.Plain, narrow); + return builder.Finish(); + } + + private static string Fold(string text, bool fold) => fold ? AsciiFold.Default.Fold(text) : text; + + private sealed class Builder(int columns, int tabWidth, bool wordWrap) + { + private readonly List _rows = []; + private readonly List _points = []; + private List _row = []; + + public void Point(IPointMarkup point) => _points.Add(new CellPoint(_rows.Count, _row.Count, point)); + + public void Write(string text, DisplayStyle style, bool narrow) + { + foreach (var range in Graphemes.Enumerate(text)) + { + var grapheme = text[range]; + switch (grapheme) + { + case "\n": + case "\r\n": + EndRow(wrapped: false); + continue; + case "\t": + var stop = (_row.Count / tabWidth + 1) * tabWidth; + if (columns > 0) stop = Math.Min(stop, columns); + if (stop <= _row.Count) stop = _row.Count + 1; + while (_row.Count < stop) Put(new Cell(" ", style)); + continue; + } + + var width = DisplayWidth.Of(grapheme); + if (width <= 0) + { + // A control character, or a mark with nothing before it on the row: it takes no cell. + if (_row.Count > 0 && !char.IsControl(grapheme[0]) && _row[^1] is { IsContinuation: false } last) + { + _row[^1] = last with { Text = last.Text + grapheme }; + } + + continue; + } + + if (width > 2) width = 2; + if (narrow && width == 2) + { + grapheme = "?"; + width = 1; + } + + var pictureColumn = 0; + if (style.Picture is { } picture) + { + pictureColumn = _row.Count > 0 && _row[^1].Style.Picture is { } previous && ReferenceEquals(previous, picture) + ? _row[^1].PictureColumn + 1 + : picture.Row?.FirstColumn ?? 0; + } + + if (columns > 0 && _row.Count + width > columns) Wrap(width); + Put(new Cell(grapheme, style) { Width = width, PictureColumn = pictureColumn }); + if (width == 2) _row.Add(new Cell(string.Empty, style) { Width = 0, PictureColumn = pictureColumn + 1 }); + } + } + + public CellLayout Finish() + { + if (_row.Count > 0 || _rows.Count == 0 || _points.Exists(point => point.Row == _rows.Count)) EndRow(wrapped: false); + return new CellLayout(_rows, _points, columns); + } + + private void Put(Cell cell) + { + if (columns > 0 && _row.Count >= columns) Wrap(cell.Width); + _row.Add(cell); + } + + private void Wrap(int width) + { + if (wordWrap) + { + var space = _row.FindLastIndex(cell => cell.Text == " " && cell.Style.Picture is null); + if (space > 0) + { + var carried = _row.GetRange(space + 1, _row.Count - space - 1); + _row.RemoveRange(space + 1, _row.Count - space - 1); + EndRow(wrapped: true); + _row.AddRange(carried); + return; + } + } + + EndRow(wrapped: true); + } + + private void EndRow(bool wrapped) + { + _rows.Add(new CellRow(_row, wrapped)); + _row = []; + } + } +} diff --git a/MarkupString/Display/DisplayRegistry.cs b/MarkupString/Display/DisplayRegistry.cs new file mode 100644 index 0000000..6f6eb47 --- /dev/null +++ b/MarkupString/Display/DisplayRegistry.cs @@ -0,0 +1,212 @@ +namespace MarkupString.Display; + +/// +/// The style contributors a structured renderer folds markup with. Immutable; build one once with +/// With(...) and share it. +/// +public sealed class DisplayRegistry : IStyleResolver +{ + private readonly IStyleContributor[] _contributors; + + private DisplayRegistry(IStyleContributor[] contributors) => _contributors = contributors; + + /// A registry that knows no markup: everything is plain. + public static DisplayRegistry Empty { get; } = new([]); + + /// A registry that knows the shared vocabulary: pictures, panes, menus and highlights. + public static DisplayRegistry Core { get; } = Empty.With(VocabularyStyles.Instance); + + /// The contributors, in the order they run. + public IReadOnlyList Contributors => _contributors; + + /// A registry with added after the others. + public DisplayRegistry With(IStyleContributor contributor) + { + ArgumentNullException.ThrowIfNull(contributor); + return new DisplayRegistry([.. _contributors, contributor]); + } + + /// Folds into one style, degraded to what can show. + public DisplayStyle Resolve(MarkupSet? markups, DisplayOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (markups is null || markups.Count == 0) return DisplayStyle.Plain; + + var builder = new StyleBuilder(); + foreach (var contributor in _contributors) contributor.Contribute(markups, builder, options); + if (builder.Interaction is { Group: null } interaction) + { + // The innermost group names the link, whichever contributor made it. + for (var i = 0; i < markups.Count; i++) + { + if (markups[i] is not LinkGroupMarkup group) continue; + builder.Interaction = interaction with { Group = group.Group }; + break; + } + } + + return Degrade(builder, options); + } + + private static DisplayStyle Degrade(StyleBuilder style, DisplayOptions options) + { + var capabilities = options.Capabilities; + + if (capabilities.Colors <= ColorLevel.Attributes) + { + style.Foreground = null; + style.Background = null; + } + + style.Attributes &= capabilities.Colors == ColorLevel.None ? TextAttributes.None : capabilities.Attributes | TextAttributes.Inverse; + + if (style.Has(TextAttributes.Inverse)) + { + if (capabilities.Colors <= ColorLevel.Attributes && (capabilities.Attributes & TextAttributes.Inverse) == 0) + { + style.Attributes &= ~TextAttributes.Inverse; + } + else if (capabilities.Colors > ColorLevel.Attributes) + { + (style.Foreground, style.Background) = (style.Background ?? options.DefaultBackground, style.Foreground ?? options.DefaultForeground); + } + } + + var level = capabilities.Colors; + style.Background = Reduce(style.Background, level, options.Colors); + style.Foreground = Reduce(style.Foreground, level, options.Colors); + if (style.Foreground is { } foreground && options.Colors is { } resolver) + { + // The client's change for legibility is reduced too, as the output can only show its palette. + style.Foreground = Reduce(resolver.Legible(foreground, style.Background ?? options.DefaultBackground), level, resolver); + } + + if (style.Interaction is { } interaction) style.Interaction = Degrade(interaction, capabilities.Interactions); + if (capabilities.Pictures == PictureMethods.None) style.Picture = null; + if (!capabilities.Panes) style.Pane = null; + + return style.Build(); + } + + /// + /// as an output of shows it: the nearest of its sixteen or 256 + /// colours, by redmean distance, as draws them (or the xterm palette). + /// + private static RgbColor? Reduce(RgbColor? color, ColorLevel level, IColorResolver? resolver) + { + if (color is not { } rgb || level is not (ColorLevel.Standard or ColorLevel.Xterm256)) return color; + var count = level == ColorLevel.Standard ? 16 : 256; + var best = rgb; + var nearest = double.MaxValue; + for (var i = 0; i < count; i++) + { + var entry = Palette(i, resolver); + var distance = Redmean(rgb, entry); + if (distance >= nearest) continue; + (nearest, best) = (distance, entry); + if (distance == 0) break; + } + + return best; + } + + private static RgbColor Palette(int index, IColorResolver? resolver) + { + if (index < 16) return resolver?.Standard(index & 7, index >= 8) ?? ColorMath.StandardColor(index); + return resolver?.Xterm(index) ?? XtermColor(index); + } + + private static ReadOnlySpan CubeLevels => [0, 95, 135, 175, 215, 255]; + + private static RgbColor XtermColor(int index) + { + if (index >= 232) + { + var grey = (byte)(8 + 10 * (index - 232)); + return new RgbColor(grey, grey, grey); + } + + var offset = index - 16; + return new RgbColor(CubeLevels[offset / 36], CubeLevels[offset / 6 % 6], CubeLevels[offset % 6]); + } + + /// The "redmean" distance, squared: a cheap measure of how different two colours look. + private static double Redmean(RgbColor a, RgbColor b) + { + var mean = (a.R + b.R) / 2.0; + double r = a.R - b.R, g = a.G - b.G, bl = a.B - b.B; + return (2 + mean / 256) * r * r + 4 * g * g + (2 + (255 - mean) / 256) * bl * bl; + } + + private static Interaction? Degrade(Interaction interaction, InteractionKinds allowed) + { + if (interaction.Choices is { Count: > 0 } && (allowed & InteractionKinds.Menu) == 0) + { + interaction = interaction with { Choices = null }; + } + + var kind = interaction.Kind switch + { + InteractionKind.Url => InteractionKinds.Url, + InteractionKind.Command => InteractionKinds.Command, + _ => InteractionKinds.Prompt, + }; + + if ((allowed & kind) != 0) return interaction; + + // A prompt link on an output that only sends commands is still useful as a command. + if (interaction.Kind == InteractionKind.Prompt && (allowed & InteractionKinds.Command) != 0) + { + return interaction with { Kind = InteractionKind.Command }; + } + + return null; + } +} + +file static class StyleBuilderExtensions +{ + public static bool Has(this StyleBuilder style, TextAttributes attribute) => (style.Attributes & attribute) == attribute; +} + +/// Styles for the shared vocabulary: pictures, panes, command menus and highlights. +public sealed class VocabularyStyles : IStyleContributor +{ + /// The one instance. + public static VocabularyStyles Instance { get; } = new(); + + private VocabularyStyles() + { + } + + /// + public void Contribute(MarkupSet markups, StyleBuilder style, DisplayOptions options) + { + ArgumentNullException.ThrowIfNull(markups); + ArgumentNullException.ThrowIfNull(style); + + // Outermost first, so the innermost layer has the last word. + for (var i = markups.Count - 1; i >= 0; i--) + { + switch (markups[i]) + { + case ImageMarkup image: + style.Picture = image; + break; + case PaneMarkup pane: + style.Pane = pane.Name; + break; + case CommandMenuMarkup menu when menu.Choices.Count > 0: + style.Interaction = new Interaction(menu.Prompt ? InteractionKind.Prompt : InteractionKind.Command, menu.Choices[0].Command) + { + Hint = menu.Hint, + Choices = menu.Choices, + }; + break; + case HighlightMarkup highlight: + style.Background = highlight.Color; + break; + } + } + } +} diff --git a/MarkupString/Display/DisplayStyle.cs b/MarkupString/Display/DisplayStyle.cs new file mode 100644 index 0000000..6b6de1b --- /dev/null +++ b/MarkupString/Display/DisplayStyle.cs @@ -0,0 +1,172 @@ +namespace MarkupString.Display; + +/// What clicking a stretch of text does. +public enum InteractionKind +{ + /// Opens , an address. + Url, + + /// Sends to the game as a command. + Command, + + /// Puts in the input line, for the player to finish and send. + Prompt, +} + +/// A clickable stretch of text, resolved from whichever markup made it clickable. +/// What a click does. +/// The address or command. +public sealed record Interaction(InteractionKind Kind, string Target) +{ + /// A hint to show on hover, or null. + public string? Hint { get; init; } + + /// A menu of further choices (MXP <SEND href="a|b">), or null. is the first. + public IReadOnlyList? Choices { get; init; } + + /// + /// Text with the same group is one link for hover and focus, even across lines (as an OSC 8 id groups them), as a contributor sets it. Null groups + /// by alone. + /// + public string? Group { get; init; } + + /// Value equality, the choices compared item by item. + public bool Equals(Interaction? other) => + other is not null + && Kind == other.Kind + && string.Equals(Target, other.Target, StringComparison.Ordinal) + && string.Equals(Hint, other.Hint, StringComparison.Ordinal) + && string.Equals(Group, other.Group, StringComparison.Ordinal) + && (ReferenceEquals(Choices, other.Choices) || (Choices is not null && other.Choices is not null && Choices.SequenceEqual(other.Choices))); + + /// + public override int GetHashCode() => HashCode.Combine(Kind, Target, Hint, Group, Choices?.Count ?? -1); +} + +/// +/// How a stretch of text looks once every layer of its markup is folded together and resolved to colours: what a +/// cell or a span is drawn with. Colours are RGB, already swapped when the text is inverse. +/// +public readonly record struct DisplayStyle +{ + /// The text colour, or null for the output's default. + public RgbColor? Foreground { get; init; } + + /// The background colour, or null for the output's default. + public RgbColor? Background { get; init; } + + /// The attributes. stays set for information; the colours are already swapped. + public TextAttributes Attributes { get; init; } + + /// What clicking it does, or null. + public Interaction? Interaction { get; init; } + + /// The picture these cells are part of, or null. + public ImageMarkup? Picture { get; init; } + + /// The pane it belongs in, or null for the main output. + public string? Pane { get; init; } + + /// No styling at all. + public static DisplayStyle Plain => default; + + /// Whether is set. + public bool Has(TextAttributes attribute) => (Attributes & attribute) == attribute; +} + +/// +/// Collects a style as contributors fold a run's markup into it. Contributors run in the order they were added to +/// the ; each reads the layers it knows and leaves the rest. +/// +public sealed class StyleBuilder +{ + /// The text colour so far, or null. + public RgbColor? Foreground { get; set; } + + /// The background colour so far, or null. + public RgbColor? Background { get; set; } + + /// The attributes so far. + public TextAttributes Attributes { get; set; } + + /// The interaction so far, or null. + public Interaction? Interaction { get; set; } + + /// The picture so far, or null. + public ImageMarkup? Picture { get; set; } + + /// The pane so far, or null. + public string? Pane { get; set; } + + internal void Clear() + { + Foreground = null; + Background = null; + Attributes = TextAttributes.None; + Interaction = null; + Picture = null; + Pane = null; + } + + internal DisplayStyle Build() => new() + { + Foreground = Foreground, + Background = Background, + Attributes = Attributes, + Interaction = Interaction, + Picture = Picture, + Pane = Pane, + }; +} + +/// Folds the layers it knows of a run's markup into a . +public interface IStyleContributor +{ + /// Reads (innermost first) and adds what it knows to . + void Contribute(MarkupSet markups, StyleBuilder style, DisplayOptions options); +} + +/// Turns a run's markup into the style an output draws it with: a , or a host's own. +public interface IStyleResolver +{ + /// Folds into one style, degraded to what can show. + DisplayStyle Resolve(MarkupSet? markups, DisplayOptions options); +} + +/// Turns a palette colour into RGB, so a client's theme decides what "red" is. +public interface IColorResolver +{ + /// One of the sixteen standard colours: 0 to 7, bright or not. + RgbColor Standard(int index, bool bright); + + /// An entry of the 256-colour palette. + RgbColor Xterm(int index); + + /// + /// The colour text is finally drawn in, given its background: where a client keeps text legible against its + /// background, it changes the colour here. Return to leave it alone. + /// + RgbColor Legible(RgbColor foreground, RgbColor background); +} + +/// What a structured renderer is drawing for. +public sealed record DisplayOptions +{ + /// What the output can show; whatever it cannot is dropped from each style. + public OutputCapabilities Capabilities { get; init; } = OutputCapabilities.Browser; + + /// How palette colours become RGB, or null for the xterm palette. + public IColorResolver? Colors { get; init; } + + /// Which link addresses become clickable; one that may not stays text. unless set. + public IUrlPolicy Links { get; init; } = UrlPolicy.Safe; + + /// The default text colour, used when inverse text has no colour of its own. + public RgbColor DefaultForeground { get; init; } = new(229, 229, 229); + + /// The default background, used when inverse text has no background of its own. + public RgbColor DefaultBackground { get; init; } = new(0, 0, 0); + + /// How many cells a tab advances to, in the cell renderer. + public int TabWidth { get; init; } = 8; +} diff --git a/MarkupString/Display/OutputCapabilities.cs b/MarkupString/Display/OutputCapabilities.cs new file mode 100644 index 0000000..1b2559d --- /dev/null +++ b/MarkupString/Display/OutputCapabilities.cs @@ -0,0 +1,223 @@ +namespace MarkupString.Display; + +/// How many colours an output can show. +public enum ColorLevel +{ + /// No colour, and no attributes either. + None, + + /// No colour, but attributes such as bold and underline. + Attributes, + + /// The sixteen standard colours. + Standard, + + /// The xterm 256-colour palette. + Xterm256, + + /// Any RGB colour. + TrueColor, +} + +/// Text attributes, as an output can draw them and a carries them. +[Flags] +public enum TextAttributes +{ + /// None. + None = 0, + + /// Bold, or bright. + Bold = 1 << 0, + + /// Faint, or dim. + Faint = 1 << 1, + + /// Italic. + Italic = 1 << 2, + + /// Underlined. + Underline = 1 << 3, + + /// Blinking. + Blink = 1 << 4, + + /// Foreground and background swapped. + Inverse = 1 << 5, + + /// Hidden: drawn in the background colour, but still text. + Conceal = 1 << 6, + + /// Struck through. + Strikethrough = 1 << 7, + + /// A line over the text. + Overline = 1 << 8, + + /// Every attribute. + All = Bold | Faint | Italic | Underline | Blink | Inverse | Conceal | Strikethrough | Overline, +} + +/// What an output does when the text it shows is clicked. +[Flags] +public enum InteractionKinds +{ + /// Nothing is clickable. + None = 0, + + /// A link opens an address. + Url = 1, + + /// A link sends a command to the game. + Command = 2, + + /// A link puts a command in the input line without sending it. + Prompt = 4, + + /// A link offers a menu of commands. + Menu = 8, + + /// Every kind. + All = Url | Command | Prompt | Menu, +} + +/// The ways an output can draw a picture, best first. +[Flags] +public enum PictureMethods +{ + /// Pictures are shown as their text. + None = 0, + + /// The output draws pixels itself: a window, or a browser. + Bitmap = 1, + + /// Kitty graphics drawn with Unicode placeholders. + KittyPlaceholders = 2, + + /// iTerm2 inline images. + InlineImages = 4, + + /// Sixel graphics. + Sixel = 8, + + /// Coloured half blocks, two pixels to a cell. + HalfBlocks = 16, +} + +/// How much of Unicode an output can draw. +public enum UnicodeLevel +{ + /// Everything, wide characters and emoji included. + Full, + + /// Everything that is one cell wide. + Narrow, + + /// ASCII only; the rest is folded with . + Ascii, +} + +/// +/// What one output can show. A renderer reads it to decide how far to degrade what it was given, and a client +/// reads it to decide what to tell the server it accepts. +/// +public sealed record OutputCapabilities +{ + /// How many colours it shows. + public ColorLevel Colors { get; init; } = ColorLevel.TrueColor; + + /// The attributes it draws. Blink is often left out by choice. + public TextAttributes Attributes { get; init; } = TextAttributes.All; + + /// What happens when text is clicked. + public InteractionKinds Interactions { get; init; } = InteractionKinds.All; + + /// How it draws pictures. + public PictureMethods Pictures { get; init; } + + /// Whether a moving picture plays rather than showing its first frame. + public bool MovingPictures { get; init; } + + /// Whether it plays sounds. + public bool Sound { get; init; } + + /// Whether it shows text in named panes apart from the main output. + public bool Panes { get; init; } + + /// How much of Unicode it draws. + public UnicodeLevel Unicode { get; init; } = UnicodeLevel.Full; + + /// Plain text and nothing else. + public static OutputCapabilities PlainText { get; } = new() + { + Colors = ColorLevel.None, + Attributes = TextAttributes.None, + Interactions = InteractionKinds.None, + }; + + /// A grid of cells the client draws itself, such as a window drawn with Skia. + public static OutputCapabilities CellGrid { get; } = new() + { + Pictures = PictureMethods.Bitmap | PictureMethods.HalfBlocks, + MovingPictures = true, + Sound = true, + Panes = true, + }; + + /// A web page. + public static OutputCapabilities Browser { get; } = new() + { + Pictures = PictureMethods.Bitmap, + MovingPictures = true, + Sound = true, + Panes = true, + }; + + /// + /// What both this output and can show: the lesser colour level and Unicode level, + /// and the shared attributes, interactions and picture methods. + /// + public OutputCapabilities Intersect(OutputCapabilities other) + { + ArgumentNullException.ThrowIfNull(other); + return new OutputCapabilities + { + Colors = (ColorLevel)Math.Min((int)Colors, (int)other.Colors), + Attributes = Attributes & other.Attributes, + Interactions = Interactions & other.Interactions, + Pictures = Pictures & other.Pictures, + MovingPictures = MovingPictures && other.MovingPictures, + Sound = Sound && other.Sound, + Panes = Panes && other.Panes, + Unicode = (UnicodeLevel)Math.Max((int)Unicode, (int)other.Unicode), + }; + } + + /// + /// What both this output and together can show, for a client that shows one session in + /// two places and tells the server once. + /// + public OutputCapabilities Union(OutputCapabilities other) + { + ArgumentNullException.ThrowIfNull(other); + return new OutputCapabilities + { + Colors = (ColorLevel)Math.Max((int)Colors, (int)other.Colors), + Attributes = Attributes | other.Attributes, + Interactions = Interactions | other.Interactions, + Pictures = Pictures | other.Pictures, + MovingPictures = MovingPictures || other.MovingPictures, + Sound = Sound || other.Sound, + Panes = Panes || other.Panes, + Unicode = (UnicodeLevel)Math.Min((int)Unicode, (int)other.Unicode), + }; + } + + /// The best way it draws a picture, or . + public PictureMethods BestPictureMethod => + (Pictures & PictureMethods.Bitmap) != 0 ? PictureMethods.Bitmap + : (Pictures & PictureMethods.KittyPlaceholders) != 0 ? PictureMethods.KittyPlaceholders + : (Pictures & PictureMethods.InlineImages) != 0 ? PictureMethods.InlineImages + : (Pictures & PictureMethods.Sixel) != 0 ? PictureMethods.Sixel + : (Pictures & PictureMethods.HalfBlocks) != 0 ? PictureMethods.HalfBlocks + : PictureMethods.None; +} diff --git a/MarkupString/Display/SpanRenderer.cs b/MarkupString/Display/SpanRenderer.cs new file mode 100644 index 0000000..ddc7118 --- /dev/null +++ b/MarkupString/Display/SpanRenderer.cs @@ -0,0 +1,148 @@ +using System.Globalization; +using System.Text; +namespace MarkupString.Display; + +/// +/// A stretch of text with one style, or a point (a bell, a sound) when is set and +/// is empty. +/// +/// The text. +/// How it looks. +public readonly record struct DisplaySpan(string Text, DisplayStyle Style) +{ + /// The point that stands here, or null for text. + public IPointMarkup? Point { get; init; } +} + +/// +/// Turns text into spans, each one style, for an output that draws runs of text: a web page, a rich text +/// control. Adjacent text with the same style is one span; a picture row is one span of its blank cells. +/// +public static class SpanRenderer +{ + /// The spans of , in order. + public static IReadOnlyList Render(MarkupText text, IStyleResolver registry, DisplayOptions options) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(options); + + var spans = new List(); + var styles = new Dictionary(ReferenceEqualityComparer.Instance); + var fold = options.Capabilities.Unicode == UnicodeLevel.Ascii; + var source = text.Text; + var at = 0; + + foreach (var run in text.Runs) + { + if (run.Start > at) Add(spans, source[at..run.Start], DisplayStyle.Plain, fold); + + var point = FindPoint(run.Markups); + if (point is not null) + { + if (Shows(point, options.Capabilities)) spans.Add(new DisplaySpan(string.Empty, DisplayStyle.Plain) { Point = point }); + } + else + { + if (!styles.TryGetValue(run.Markups, out var style)) + { + styles[run.Markups] = style = registry.Resolve(run.Markups, options); + } + + Add(spans, source.Substring(run.Start, run.Length), style, fold); + } + + at = run.End; + } + + if (at < source.Length) Add(spans, source[at..], DisplayStyle.Plain, fold); + return spans; + } + + /// + /// The inline CSS for : colours, weight, slant and decorations. Empty for plain text. + /// Blink is left to , since CSS has no blink of its own. + /// + public static string Css(DisplayStyle style) + { + var css = new StringBuilder(); + if (style.Foreground is { } foreground && !style.Has(TextAttributes.Conceal)) Append(css, "color", Hex(foreground)); + if (style.Background is { } background) Append(css, "background-color", Hex(background)); + if (style.Has(TextAttributes.Bold)) Append(css, "font-weight", "bold"); + if (style.Has(TextAttributes.Faint)) Append(css, "opacity", "0.6"); + if (style.Has(TextAttributes.Italic)) Append(css, "font-style", "italic"); + + var lines = new List(3); + if (style.Has(TextAttributes.Underline)) lines.Add("underline"); + if (style.Has(TextAttributes.Overline)) lines.Add("overline"); + if (style.Has(TextAttributes.Strikethrough)) lines.Add("line-through"); + if (lines.Count > 0) Append(css, "text-decoration", string.Join(' ', lines)); + + if (style.Has(TextAttributes.Conceal)) Append(css, "color", "transparent"); + return css.ToString(); + } + + /// + /// The class names a page styles itself: ms-blink, ms-conceal, ms-link, ms-command, + /// ms-prompt, ms-menu, ms-picture, and ms-invert for inverse text with no colours of its own to + /// swap (an output that shows attributes but no colour). Empty for plain text. + /// + public static string CssClasses(DisplayStyle style) + { + var classes = new List(3); + if (style.Has(TextAttributes.Blink)) classes.Add("ms-blink"); + if (style.Has(TextAttributes.Conceal)) classes.Add("ms-conceal"); + if (style.Has(TextAttributes.Inverse) && style.Foreground is null && style.Background is null) classes.Add("ms-invert"); + if (style.Interaction is { } interaction) + { + classes.Add(interaction.Kind switch + { + InteractionKind.Url => "ms-link", + InteractionKind.Command => "ms-command", + _ => "ms-prompt", + }); + if (interaction.Choices is { Count: > 1 }) classes.Add("ms-menu"); + } + + if (style.Picture is not null) classes.Add("ms-picture"); + return string.Join(' ', classes); + } + + /// as #rrggbb. + public static string Hex(RgbColor color) => string.Create(CultureInfo.InvariantCulture, $"#{color.R:x2}{color.G:x2}{color.B:x2}"); + + internal static IPointMarkup? FindPoint(MarkupSet markups) + { + foreach (var markup in markups) + { + if (markup is IPointMarkup point) return point; + } + + return null; + } + + internal static bool Shows(IPointMarkup point, OutputCapabilities capabilities) => point switch + { + SoundMarkup or SoundStopMarkup => capabilities.Sound, + _ => true, + }; + + private static void Add(List spans, string text, DisplayStyle style, bool fold) + { + if (text.Length == 0) return; + if (fold) text = AsciiFold.Default.Fold(text); + if (spans.Count > 0 && spans[^1] is { Point: null } last && last.Style == style) + { + spans[^1] = last with { Text = last.Text + text }; + return; + } + + spans.Add(new DisplaySpan(text, style)); + } + + private static void Append(StringBuilder css, string property, string value) + { + if (css.Length > 0) css.Append(';'); + css.Append(property).Append(':').Append(value); + } +} diff --git a/MarkupString/Elements/CommandMenuMarkup.cs b/MarkupString/Elements/CommandMenuMarkup.cs new file mode 100644 index 0000000..3ea63e2 --- /dev/null +++ b/MarkupString/Elements/CommandMenuMarkup.cs @@ -0,0 +1,47 @@ +namespace MarkupString; + +/// One choice of a command menu. +/// What the menu shows. +/// What choosing it sends. +public sealed record CommandChoice(string Label, string Command); + +/// +/// Makes the text it wraps a menu of commands: a click sends the first, and a client with menus offers the rest. +/// MXP writes it as <SEND href="a|b" hint="…|A|B">. +/// +/// The commands, the default first. +/// A hint to show on hover, or null. +/// Whether a click puts the command in the input line instead of sending it. +public sealed record CommandMenuMarkup(IReadOnlyList Choices, string? Hint = null, bool Prompt = false) : IMarkup +{ + /// Value equality, the choices compared item by item. + public bool Equals(CommandMenuMarkup? other) => + other is not null + && Prompt == other.Prompt + && string.Equals(Hint, other.Hint, StringComparison.Ordinal) + && Choices.SequenceEqual(other.Choices); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Hint, StringComparer.Ordinal); + hash.Add(Prompt); + foreach (var choice in Choices) hash.Add(choice); + return hash.ToHashCode(); + } +} + +/// +/// Paints the background behind the text it wraps, as a search match or a selection is shown. Unlike a colour +/// layer it says nothing about the text's own colour. +/// +/// The background. +public sealed record HighlightMarkup(RgbColor Color) : IMarkup; + +/// +/// Makes the link it wraps one link with every other stretch in the same group, even across lines, as an OSC 8 +/// id does. It becomes . +/// +/// The group's name. +public sealed record LinkGroupMarkup(string Group) : IMarkup; diff --git a/MarkupString/Elements/ElementCodecs.cs b/MarkupString/Elements/ElementCodecs.cs index 76a62ae..1f2ba3c 100644 --- a/MarkupString/Elements/ElementCodecs.cs +++ b/MarkupString/Elements/ElementCodecs.cs @@ -50,6 +50,7 @@ internal static class ElementCodecs w.WriteNumber("rs", row.Rows); w.WriteNumber("c", row.Columns); if (row.IsDescription) w.WriteBoolean("dsc", true); + if (row.FirstColumn != 0) w.WriteNumber("fc", row.FirstColumn); } }, static e => new ImageMarkup( @@ -60,7 +61,11 @@ internal static class ElementCodecs Enum.TryParse(String(e, "a"), ignoreCase: true, out var align) ? align : null) { Row = Int(e, "r") is { } row && Int(e, "rs") is { } rows && Int(e, "c") is { } columns - ? new PictureRow(row, rows, columns) { IsDescription = e.TryGetProperty("dsc", out var described) && described.ValueKind == JsonValueKind.True } + ? new PictureRow(row, rows, columns) + { + IsDescription = e.TryGetProperty("dsc", out var described) && described.ValueKind == JsonValueKind.True, + FirstColumn = Int(e, "fc") ?? 0, + } : null, }), new Codec("pane", @@ -113,6 +118,31 @@ internal static class ElementCodecs if (m.Caption is not null) w.WriteString("ca", m.Caption); }, static e => new StatusMarkup(String(e, "n") ?? string.Empty, String(e, "m"), String(e, "ca"))), + new Codec("menu", + static (w, m) => + { + w.WriteStartArray("ch"); + foreach (var choice in m.Choices) + { + w.WriteStartArray(); + w.WriteStringValue(choice.Label); + w.WriteStringValue(choice.Command); + w.WriteEndArray(); + } + w.WriteEndArray(); + if (m.Hint is not null) w.WriteString("hi", m.Hint); + if (m.Prompt) w.WriteBoolean("p", true); + }, + static e => new CommandMenuMarkup( + Choices(e), + String(e, "hi"), + e.TryGetProperty("p", out var p) && p.ValueKind == JsonValueKind.True)), + new Codec("highlight", + static (w, m) => w.WriteString("c", $"#{m.Color.R:x2}{m.Color.G:x2}{m.Color.B:x2}"), + static e => new HighlightMarkup(Hex(String(e, "c")))), + new Codec("link-group", + static (w, m) => w.WriteString("g", m.Group), + static e => new LinkGroupMarkup(String(e, "g") ?? string.Empty)), new Codec("standin", static (w, m) => w.WriteString("t", m.Text), static e => new StandInMarkup(String(e, "t") ?? string.Empty)), @@ -136,6 +166,27 @@ internal static class ElementCodecs ? number : null; + private static CommandChoice[] Choices(JsonElement element) + { + if (!element.TryGetProperty("ch", out var array) || array.ValueKind != JsonValueKind.Array) return []; + var choices = new List(array.GetArrayLength()); + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Array || item.GetArrayLength() != 2) continue; + var label = item[0].ValueKind == JsonValueKind.String ? item[0].GetString() : null; + var command = item[1].ValueKind == JsonValueKind.String ? item[1].GetString() : null; + if (label is not null && command is not null) choices.Add(new CommandChoice(label, command)); + } + + return [.. choices]; + } + + private static RgbColor Hex(string? text) => + text is { Length: 7 } && text[0] == '#' + && int.TryParse(text.AsSpan(1), System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var value) + ? new RgbColor((byte)(value >> 16), (byte)(value >> 8), (byte)value) + : default; + private static SoundChannel? Channel(JsonElement element) => String(element, "c") switch { "music" => SoundChannel.Music, diff --git a/MarkupString/Elements/PictureRow.cs b/MarkupString/Elements/PictureRow.cs index 0e0b0af..ca61d7e 100644 --- a/MarkupString/Elements/PictureRow.cs +++ b/MarkupString/Elements/PictureRow.cs @@ -23,4 +23,10 @@ public readonly record struct PictureRow(int Row, int Rows, int Columns) /// description would be a strip. /// public bool IsDescription { get; init; } + + /// + /// The column of the picture these cells start at, from 0. A placement that shows part of a picture (a Kitty + /// placement cropped by its placeholder text) starts past its left edge; a whole picture starts at 0. + /// + public int FirstColumn { get; init; } } diff --git a/MarkupString/MarkupRegistry.cs b/MarkupString/MarkupRegistry.cs index 9402899..329e5a4 100644 --- a/MarkupString/MarkupRegistry.cs +++ b/MarkupString/MarkupRegistry.cs @@ -41,6 +41,19 @@ private MarkupRegistry( _blockEmitters = blockEmitters; } + /// + /// Which link addresses the emitters write as links; one that may not is written as its text. Every emitter in + /// this package asks it. unless set with . + /// + public IUrlPolicy Links { get; private init; } = UrlPolicy.Safe; + + /// The registry with links decided by . + public MarkupRegistry WithLinks(IUrlPolicy links) + { + ArgumentNullException.ThrowIfNull(links); + return new MarkupRegistry(_emitters, _setEmitters, _framers, _lineFramers, _codecsByKind, _codecsByType, _blockEmitters, _blockCodecsByKind, _blockCodecsByType) { Links = links }; + } + /// A registry that knows nothing: every markup passes its body through unchanged. public static readonly MarkupRegistry Empty = new( FrozenDictionary<(Type, MarkupFormat), IMarkupEmitter>.Empty, @@ -102,7 +115,7 @@ public MarkupRegistry With(IMarkupEmitter emitter) var emitters = new Dictionary<(Type, MarkupFormat), IMarkupEmitter>(_emitters.Count + 1); foreach (var pair in _emitters) emitters[pair.Key] = pair.Value; emitters[(emitter.MarkupType, emitter.Format)] = emitter; - return new MarkupRegistry(emitters.ToFrozenDictionary(), _setEmitters, _framers, _lineFramers, _codecsByKind, _codecsByType, _blockEmitters, _blockCodecsByKind, _blockCodecsByType); + return new MarkupRegistry(emitters.ToFrozenDictionary(), _setEmitters, _framers, _lineFramers, _codecsByKind, _codecsByType, _blockEmitters, _blockCodecsByKind, _blockCodecsByType) { Links = Links }; } /// Returns a registry with added, replacing any set emitter for the same format. @@ -112,7 +125,7 @@ public MarkupRegistry With(IMarkupSetEmitter emitter) var setEmitters = new Dictionary(_setEmitters.Count + 1); foreach (var pair in _setEmitters) setEmitters[pair.Key] = pair.Value; setEmitters[emitter.Format] = emitter; - return new MarkupRegistry(_emitters, setEmitters.ToFrozenDictionary(), _framers, _lineFramers, _codecsByKind, _codecsByType, _blockEmitters, _blockCodecsByKind, _blockCodecsByType); + return new MarkupRegistry(_emitters, setEmitters.ToFrozenDictionary(), _framers, _lineFramers, _codecsByKind, _codecsByType, _blockEmitters, _blockCodecsByKind, _blockCodecsByType) { Links = Links }; } /// Returns a registry with added, replacing any framer for the same format. @@ -122,7 +135,7 @@ public MarkupRegistry With(IFormatFramer framer) var framers = new Dictionary(_framers.Count + 1); foreach (var pair in _framers) framers[pair.Key] = pair.Value; framers[framer.Format] = framer; - return new MarkupRegistry(_emitters, _setEmitters, framers.ToFrozenDictionary(), _lineFramers, _codecsByKind, _codecsByType, _blockEmitters, _blockCodecsByKind, _blockCodecsByType); + return new MarkupRegistry(_emitters, _setEmitters, framers.ToFrozenDictionary(), _lineFramers, _codecsByKind, _codecsByType, _blockEmitters, _blockCodecsByKind, _blockCodecsByType) { Links = Links }; } /// Returns a registry with added, replacing any line framer for the same format. @@ -132,7 +145,7 @@ public MarkupRegistry With(ILineFramer framer) var lineFramers = new Dictionary(_lineFramers.Count + 1); foreach (var pair in _lineFramers) lineFramers[pair.Key] = pair.Value; lineFramers[framer.Format] = framer; - return new MarkupRegistry(_emitters, _setEmitters, _framers, lineFramers.ToFrozenDictionary(), _codecsByKind, _codecsByType, _blockEmitters, _blockCodecsByKind, _blockCodecsByType); + return new MarkupRegistry(_emitters, _setEmitters, _framers, lineFramers.ToFrozenDictionary(), _codecsByKind, _codecsByType, _blockEmitters, _blockCodecsByKind, _blockCodecsByType) { Links = Links }; } /// @@ -162,7 +175,7 @@ public MarkupRegistry With(IMarkupCodec codec) foreach (var pair in _codecsByType) if (!string.Equals(pair.Value.Kind, codec.Kind, StringComparison.Ordinal)) byType[pair.Key] = pair.Value; byType[codec.MarkupType] = codec; - return new MarkupRegistry(_emitters, _setEmitters, _framers, _lineFramers, byKind.ToFrozenDictionary(StringComparer.Ordinal), byType.ToFrozenDictionary(), _blockEmitters, _blockCodecsByKind, _blockCodecsByType); + return new MarkupRegistry(_emitters, _setEmitters, _framers, _lineFramers, byKind.ToFrozenDictionary(StringComparer.Ordinal), byType.ToFrozenDictionary(), _blockEmitters, _blockCodecsByKind, _blockCodecsByType) { Links = Links }; } /// @@ -177,7 +190,7 @@ public MarkupRegistry With(IBlockEmitter emitter) var emitters = new Dictionary<(Type, MarkupFormat), IBlockEmitter>(_blockEmitters.Count + 1); foreach (var pair in _blockEmitters) emitters[pair.Key] = pair.Value; emitters[(emitter.MarkupType, emitter.Format)] = emitter; - return new MarkupRegistry(_emitters, _setEmitters, _framers, _lineFramers, _codecsByKind, _codecsByType, emitters.ToFrozenDictionary(), _blockCodecsByKind, _blockCodecsByType); + return new MarkupRegistry(_emitters, _setEmitters, _framers, _lineFramers, _codecsByKind, _codecsByType, emitters.ToFrozenDictionary(), _blockCodecsByKind, _blockCodecsByType) { Links = Links }; } /// The block emitter for in , or null. @@ -239,7 +252,8 @@ public MarkupRegistry With(Layout.BlockCodec codec) if (!string.Equals(pair.Value.Kind, codec.Kind, StringComparison.Ordinal)) byType[pair.Key] = pair.Value; byType[codec.BlockType] = codec; return new MarkupRegistry(_emitters, _setEmitters, _framers, _lineFramers, _codecsByKind, _codecsByType, _blockEmitters, - byKind.ToFrozenDictionary(StringComparer.Ordinal), byType.ToFrozenDictionary()); + byKind.ToFrozenDictionary(StringComparer.Ordinal), byType.ToFrozenDictionary()) + { Links = Links }; } /// The block codec registered for , or . diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index b94aa8b..de9ef4b 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -29,3 +29,739 @@ MarkupString.ColorGradient.Roles.init -> void MarkupString.ColorGradient.Toned(System.Func! tones) -> MarkupString.ColorGradient! MarkupString.Layout.LayoutContext.Tones.get -> System.Func? MarkupString.Layout.LayoutContext.Tones.init -> void +MarkupString.Decoding.ApcHandler +MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientAnswers.Colors = 4 -> MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientAnswers.CursorPosition = 16 -> MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientAnswers.Default = MarkupString.Decoding.ClientAnswers.Identity | MarkupString.Decoding.ClientAnswers.Size | MarkupString.Decoding.ClientAnswers.Colors | MarkupString.Decoding.ClientAnswers.Pictures | MarkupString.Decoding.ClientAnswers.Mxp -> MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientAnswers.Identity = 1 -> MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientAnswers.Mxp = 32 -> MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientAnswers.None = 0 -> MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientAnswers.Pictures = 8 -> MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientAnswers.Size = 2 -> MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientIdentity +MarkupString.Decoding.ClientIdentity.$() -> MarkupString.Decoding.ClientIdentity! +MarkupString.Decoding.ClientIdentity.Answers.get -> MarkupString.Decoding.ClientAnswers +MarkupString.Decoding.ClientIdentity.Answers.init -> void +MarkupString.Decoding.ClientIdentity.Background.get -> MarkupString.RgbColor? +MarkupString.Decoding.ClientIdentity.Background.init -> void +MarkupString.Decoding.ClientIdentity.CellHeight.get -> int +MarkupString.Decoding.ClientIdentity.CellHeight.init -> void +MarkupString.Decoding.ClientIdentity.CellWidth.get -> int +MarkupString.Decoding.ClientIdentity.CellWidth.init -> void +MarkupString.Decoding.ClientIdentity.ClientIdentity() -> void +MarkupString.Decoding.ClientIdentity.Columns.get -> int +MarkupString.Decoding.ClientIdentity.Columns.init -> void +MarkupString.Decoding.ClientIdentity.Equals(MarkupString.Decoding.ClientIdentity? other) -> bool +MarkupString.Decoding.ClientIdentity.Foreground.get -> MarkupString.RgbColor? +MarkupString.Decoding.ClientIdentity.Foreground.init -> void +MarkupString.Decoding.ClientIdentity.Name.get -> string! +MarkupString.Decoding.ClientIdentity.Name.init -> void +MarkupString.Decoding.ClientIdentity.Output.get -> MarkupString.Display.OutputCapabilities! +MarkupString.Decoding.ClientIdentity.Output.init -> void +MarkupString.Decoding.ClientIdentity.Rows.get -> int +MarkupString.Decoding.ClientIdentity.Rows.init -> void +MarkupString.Decoding.ClientIdentity.ToMtts(bool screenReader = false, bool tls = false) -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.ClientIdentity.Version.get -> string! +MarkupString.Decoding.ClientIdentity.Version.init -> void +MarkupString.Decoding.ClipboardEvent +MarkupString.Decoding.ClipboardEvent.ClipboardEvent(string! Selection, string? Text) -> void +MarkupString.Decoding.ClipboardEvent.Deconstruct(out string! Selection, out string? Text) -> void +MarkupString.Decoding.ClipboardEvent.Equals(MarkupString.Decoding.ClipboardEvent? other) -> bool +MarkupString.Decoding.ClipboardEvent.Selection.get -> string! +MarkupString.Decoding.ClipboardEvent.Selection.init -> void +MarkupString.Decoding.ClipboardEvent.Text.get -> string? +MarkupString.Decoding.ClipboardEvent.Text.init -> void +MarkupString.Decoding.ControlHandler +MarkupString.Decoding.CsiHandler +MarkupString.Decoding.CsiSequence +MarkupString.Decoding.CsiSequence.Count.get -> int +MarkupString.Decoding.CsiSequence.CsiSequence() -> void +MarkupString.Decoding.CsiSequence.CsiSequence(char prefix, System.ReadOnlySpan parameters, System.ReadOnlySpan subparameters, System.ReadOnlySpan intermediates, char final) -> void +MarkupString.Decoding.CsiSequence.Final.get -> char +MarkupString.Decoding.CsiSequence.Get(int index, int fallback = 0) -> int +MarkupString.Decoding.CsiSequence.Intermediates.get -> System.ReadOnlySpan +MarkupString.Decoding.CsiSequence.Is(char final, char prefix = '\0') -> bool +MarkupString.Decoding.CsiSequence.IsSubparameter(int index) -> bool +MarkupString.Decoding.CsiSequence.Parameters.get -> System.ReadOnlySpan +MarkupString.Decoding.CsiSequence.Prefix.get -> char +MarkupString.Decoding.DcsHandler +MarkupString.Decoding.DecodeContext +MarkupString.Decoding.DecodeContext.Backspace(int cells = 1) -> void +MarkupString.Decoding.DecodeContext.CarriageReturn() -> void +MarkupString.Decoding.DecodeContext.Close(MarkupString.IMarkup! markup) -> bool +MarkupString.Decoding.DecodeContext.CloseTo(int count) -> void +MarkupString.Decoding.DecodeContext.Column.get -> int +MarkupString.Decoding.DecodeContext.CurrentMarkup.get -> MarkupString.MarkupSet? +MarkupString.Decoding.DecodeContext.Destination.get -> string? +MarkupString.Decoding.DecodeContext.Destination.set -> void +MarkupString.Decoding.DecodeContext.Diagnostics.get -> MarkupString.Decoding.DecodeDiagnostics! +MarkupString.Decoding.DecodeContext.DisableProtocol(string! name) -> void +MarkupString.Decoding.DecodeContext.Elements.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.Decoding.DecodeContext.EnableProtocol(string! name) -> bool +MarkupString.Decoding.DecodeContext.EndLine() -> void +MarkupString.Decoding.DecodeContext.EraseLine() -> void +MarkupString.Decoding.DecodeContext.EraseToEndOfLine() -> void +MarkupString.Decoding.DecodeContext.EraseToStartOfLine() -> void +MarkupString.Decoding.DecodeContext.Identity.get -> MarkupString.Decoding.ClientIdentity! +MarkupString.Decoding.DecodeContext.IsActive(string! name) -> bool +MarkupString.Decoding.DecodeContext.MoveToColumn(int column) -> void +MarkupString.Decoding.DecodeContext.Open(MarkupString.IMarkup! markup) -> bool +MarkupString.Decoding.DecodeContext.Options.get -> MarkupString.Decoding.DecoderOptions! +MarkupString.Decoding.DecodeContext.Pen(string! slot) -> MarkupString.IMarkup? +MarkupString.Decoding.DecodeContext.PlacePicture(MarkupString.ImageMarkup! picture, int rows, int columns, bool cursorRight, int firstColumn = 0) -> void +MarkupString.Decoding.DecodeContext.Point(MarkupString.IPointMarkup! point) -> void +MarkupString.Decoding.DecodeContext.Print(System.ReadOnlySpan text) -> void +MarkupString.Decoding.DecodeContext.Raise(MarkupString.Decoding.DecodeEvent! decodeEvent) -> void +MarkupString.Decoding.DecodeContext.Reply(string! text) -> void +MarkupString.Decoding.DecodeContext.SetPen(string! slot, MarkupString.IMarkup? markup) -> void +MarkupString.Decoding.DecodeContext.State() -> T! +MarkupString.Decoding.DecodeContext.Write(MarkupString.MarkupText! text) -> void +MarkupString.Decoding.DecodeContext.Write(System.ReadOnlySpan text) -> void +MarkupString.Decoding.DecodeContext.Write(System.ReadOnlySpan text, MarkupString.IMarkup? inner) -> void +MarkupString.Decoding.DecodeDiagnostics +MarkupString.Decoding.DecodeDiagnostics.AbandonedSequences.get -> int +MarkupString.Decoding.DecodeDiagnostics.DecodeDiagnostics() -> void +MarkupString.Decoding.DecodeDiagnostics.LimitsReached.get -> int +MarkupString.Decoding.DecodeDiagnostics.PicturesFailed.get -> int +MarkupString.Decoding.DecodeDiagnostics.UnhandledControls.get -> int +MarkupString.Decoding.DecodeDiagnostics.UnhandledSequences.get -> int +MarkupString.Decoding.DecodeEvent +MarkupString.Decoding.DecodeEvent.DecodeEvent() -> void +MarkupString.Decoding.DecodeEvent.DecodeEvent(MarkupString.Decoding.DecodeEvent! original) -> void +MarkupString.Decoding.Decoder +MarkupString.Decoding.Decoder.ActiveProtocol.get -> string? +MarkupString.Decoding.Decoder.Diagnostics.get -> MarkupString.Decoding.DecodeDiagnostics! +MarkupString.Decoding.Decoder.DisableProtocol(string! name) -> void +MarkupString.Decoding.Decoder.EnableProtocol(string! name) -> bool +MarkupString.Decoding.Decoder.EndLine() -> MarkupString.Decoding.DisplayLine! +MarkupString.Decoding.Decoder.EndPrompt() -> MarkupString.Decoding.DisplayLine? +MarkupString.Decoding.Decoder.EventRaised -> System.Action? +MarkupString.Decoding.Decoder.Feed(System.ReadOnlySpan text) -> System.Collections.Generic.IReadOnlyList! +MarkupString.Decoding.Decoder.Feed(string? text) -> System.Collections.Generic.IReadOnlyList! +MarkupString.Decoding.Decoder.HasPendingLine.get -> bool +MarkupString.Decoding.Decoder.Identity.get -> MarkupString.Decoding.ClientIdentity! +MarkupString.Decoding.Decoder.Identity.set -> void +MarkupString.Decoding.Decoder.Options.get -> MarkupString.Decoding.DecoderOptions! +MarkupString.Decoding.Decoder.PendingLine.get -> MarkupString.MarkupText! +MarkupString.Decoding.Decoder.Registry.get -> MarkupString.Decoding.DecoderRegistry! +MarkupString.Decoding.Decoder.Reset() -> void +MarkupString.Decoding.DecoderBuilder +MarkupString.Decoding.DecoderBuilder.OnApc(MarkupString.Decoding.ApcHandler! handler) -> MarkupString.Decoding.DecoderBuilder! +MarkupString.Decoding.DecoderBuilder.OnControl(char control, MarkupString.Decoding.ControlHandler! handler) -> MarkupString.Decoding.DecoderBuilder! +MarkupString.Decoding.DecoderBuilder.OnCsi(char final, MarkupString.Decoding.CsiHandler! handler, char prefix = '\0', string! intermediates = "") -> MarkupString.Decoding.DecoderBuilder! +MarkupString.Decoding.DecoderBuilder.OnDcs(char final, MarkupString.Decoding.DcsHandler! handler, char prefix = '\0', string! intermediates = "") -> MarkupString.Decoding.DecoderBuilder! +MarkupString.Decoding.DecoderBuilder.OnEsc(char final, MarkupString.Decoding.EscHandler! handler, string! intermediates = "") -> MarkupString.Decoding.DecoderBuilder! +MarkupString.Decoding.DecoderBuilder.OnLineEnd(System.Action! handler) -> MarkupString.Decoding.DecoderBuilder! +MarkupString.Decoding.DecoderBuilder.OnOsc(int command, MarkupString.Decoding.OscHandler! handler) -> MarkupString.Decoding.DecoderBuilder! +MarkupString.Decoding.DecoderBuilder.OnPrint(MarkupString.Decoding.PrintFilter! filter) -> MarkupString.Decoding.DecoderBuilder! +MarkupString.Decoding.DecoderBuilder.TextProtocol(string! name, System.Func! create) -> MarkupString.Decoding.DecoderBuilder! +MarkupString.Decoding.DecoderOptions +MarkupString.Decoding.DecoderOptions.$() -> MarkupString.Decoding.DecoderOptions! +MarkupString.Decoding.DecoderOptions.DecoderOptions() -> void +MarkupString.Decoding.DecoderOptions.Equals(MarkupString.Decoding.DecoderOptions? other) -> bool +MarkupString.Decoding.DecoderOptions.Identity.get -> MarkupString.Decoding.ClientIdentity! +MarkupString.Decoding.DecoderOptions.Identity.init -> void +MarkupString.Decoding.DecoderOptions.Limits.get -> MarkupString.Decoding.VtLimits! +MarkupString.Decoding.DecoderOptions.Limits.init -> void +MarkupString.Decoding.DecoderOptions.MaxLineLength.get -> int +MarkupString.Decoding.DecoderOptions.MaxLineLength.init -> void +MarkupString.Decoding.DecoderOptions.MaxOpenElements.get -> int +MarkupString.Decoding.DecoderOptions.MaxOpenElements.init -> void +MarkupString.Decoding.DecoderRegistry +MarkupString.Decoding.DecoderRegistry.CreateDecoder(MarkupString.Decoding.DecoderOptions? options = null) -> MarkupString.Decoding.Decoder! +MarkupString.Decoding.DecoderRegistry.Has(string! name) -> bool +MarkupString.Decoding.DecoderRegistry.HasTextProtocol(string! name) -> bool +MarkupString.Decoding.DecoderRegistry.Plugins.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.Decoding.DecoderRegistry.With(MarkupString.Decoding.IDecoderPlugin! plugin) -> MarkupString.Decoding.DecoderRegistry! +MarkupString.Decoding.DisplayLine +MarkupString.Decoding.DisplayLine.$() -> MarkupString.Decoding.DisplayLine! +MarkupString.Decoding.DisplayLine.Content.get -> MarkupString.MarkupText! +MarkupString.Decoding.DisplayLine.Content.init -> void +MarkupString.Decoding.DisplayLine.Deconstruct(out MarkupString.MarkupText! Content, out MarkupString.Decoding.LineKind Kind) -> void +MarkupString.Decoding.DisplayLine.Destination.get -> string? +MarkupString.Decoding.DisplayLine.Destination.init -> void +MarkupString.Decoding.DisplayLine.DisplayLine(MarkupString.MarkupText! Content, MarkupString.Decoding.LineKind Kind) -> void +MarkupString.Decoding.DisplayLine.Equals(MarkupString.Decoding.DisplayLine? other) -> bool +MarkupString.Decoding.DisplayLine.Kind.get -> MarkupString.Decoding.LineKind +MarkupString.Decoding.DisplayLine.Kind.init -> void +MarkupString.Decoding.DisplayLine.Sequence.get -> long +MarkupString.Decoding.DisplayLine.Sequence.init -> void +MarkupString.Decoding.DownloadEvent +MarkupString.Decoding.DownloadEvent.Deconstruct(out string? Name, out int Size) -> void +MarkupString.Decoding.DownloadEvent.DownloadEvent(string? Name, int Size) -> void +MarkupString.Decoding.DownloadEvent.Equals(MarkupString.Decoding.DownloadEvent? other) -> bool +MarkupString.Decoding.DownloadEvent.Name.get -> string? +MarkupString.Decoding.DownloadEvent.Name.init -> void +MarkupString.Decoding.DownloadEvent.Size.get -> int +MarkupString.Decoding.DownloadEvent.Size.init -> void +MarkupString.Decoding.EscHandler +MarkupString.Decoding.EscSequence +MarkupString.Decoding.EscSequence.EscSequence() -> void +MarkupString.Decoding.EscSequence.EscSequence(System.ReadOnlySpan intermediates, char final) -> void +MarkupString.Decoding.EscSequence.Final.get -> char +MarkupString.Decoding.EscSequence.Intermediates.get -> System.ReadOnlySpan +MarkupString.Decoding.IDecoderPlugin +MarkupString.Decoding.IDecoderPlugin.Name.get -> string! +MarkupString.Decoding.IDecoderPlugin.Register(MarkupString.Decoding.DecoderBuilder! builder) -> void +MarkupString.Decoding.ITextProtocol +MarkupString.Decoding.ITextProtocol.EndLine(MarkupString.Decoding.DecodeContext! context) -> void +MarkupString.Decoding.ITextProtocol.Feed(System.ReadOnlySpan text, MarkupString.Decoding.DecodeContext! context) -> void +MarkupString.Decoding.ITextProtocol.Reset(MarkupString.Decoding.DecodeContext! context) -> void +MarkupString.Decoding.IVtTokenSink +MarkupString.Decoding.IVtTokenSink.Abandoned(MarkupString.Decoding.VtSequenceKind kind) -> void +MarkupString.Decoding.IVtTokenSink.Apc(System.ReadOnlySpan payload) -> void +MarkupString.Decoding.IVtTokenSink.Csi(in MarkupString.Decoding.CsiSequence sequence) -> void +MarkupString.Decoding.IVtTokenSink.Dcs(in MarkupString.Decoding.CsiSequence header, System.ReadOnlySpan data) -> void +MarkupString.Decoding.IVtTokenSink.Esc(in MarkupString.Decoding.EscSequence sequence) -> void +MarkupString.Decoding.IVtTokenSink.Execute(char control) -> void +MarkupString.Decoding.IVtTokenSink.Osc(System.ReadOnlySpan payload) -> void +MarkupString.Decoding.IVtTokenSink.Print(System.ReadOnlySpan text) -> void +MarkupString.Decoding.LineKind +MarkupString.Decoding.LineKind.Output = 0 -> MarkupString.Decoding.LineKind +MarkupString.Decoding.LineKind.Prompt = 1 -> MarkupString.Decoding.LineKind +MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.Ansi = 1 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.Colors256 = 8 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.Mnes = 512 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.MouseTracking = 16 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.Mslp = 1024 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.None = 0 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.OscColorPalette = 32 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.Proxy = 128 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.ScreenReader = 64 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.Ssl = 2048 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.TrueColor = 256 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.Utf8 = 4 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.MttsFlags.Vt100 = 2 -> MarkupString.Decoding.MttsFlags +MarkupString.Decoding.OscHandler +MarkupString.Decoding.PaletteEvent +MarkupString.Decoding.PaletteEvent.Color.get -> MarkupString.RgbColor? +MarkupString.Decoding.PaletteEvent.Color.init -> void +MarkupString.Decoding.PaletteEvent.Deconstruct(out MarkupString.Decoding.PaletteSlot Slot, out int Index, out MarkupString.RgbColor? Color) -> void +MarkupString.Decoding.PaletteEvent.Equals(MarkupString.Decoding.PaletteEvent? other) -> bool +MarkupString.Decoding.PaletteEvent.Index.get -> int +MarkupString.Decoding.PaletteEvent.Index.init -> void +MarkupString.Decoding.PaletteEvent.PaletteEvent(MarkupString.Decoding.PaletteSlot Slot, int Index, MarkupString.RgbColor? Color) -> void +MarkupString.Decoding.PaletteEvent.Slot.get -> MarkupString.Decoding.PaletteSlot +MarkupString.Decoding.PaletteEvent.Slot.init -> void +MarkupString.Decoding.PaletteSlot +MarkupString.Decoding.PaletteSlot.Background = 2 -> MarkupString.Decoding.PaletteSlot +MarkupString.Decoding.PaletteSlot.Cursor = 3 -> MarkupString.Decoding.PaletteSlot +MarkupString.Decoding.PaletteSlot.Foreground = 1 -> MarkupString.Decoding.PaletteSlot +MarkupString.Decoding.PaletteSlot.Index = 0 -> MarkupString.Decoding.PaletteSlot +MarkupString.Decoding.PrintFilter +MarkupString.Decoding.ProtocolEvent +MarkupString.Decoding.ProtocolEvent.Active.get -> bool +MarkupString.Decoding.ProtocolEvent.Active.init -> void +MarkupString.Decoding.ProtocolEvent.Deconstruct(out string! Name, out bool Active) -> void +MarkupString.Decoding.ProtocolEvent.Equals(MarkupString.Decoding.ProtocolEvent? other) -> bool +MarkupString.Decoding.ProtocolEvent.Name.get -> string! +MarkupString.Decoding.ProtocolEvent.Name.init -> void +MarkupString.Decoding.ProtocolEvent.ProtocolEvent(string! Name, bool Active) -> void +MarkupString.Decoding.ReplyEvent +MarkupString.Decoding.ReplyEvent.Deconstruct(out string! Text) -> void +MarkupString.Decoding.ReplyEvent.Equals(MarkupString.Decoding.ReplyEvent? other) -> bool +MarkupString.Decoding.ReplyEvent.ReplyEvent(string! Text) -> void +MarkupString.Decoding.ReplyEvent.Text.get -> string! +MarkupString.Decoding.ReplyEvent.Text.init -> void +MarkupString.Decoding.TitleEvent +MarkupString.Decoding.TitleEvent.Deconstruct(out string! Title, out bool Icon) -> void +MarkupString.Decoding.TitleEvent.Equals(MarkupString.Decoding.TitleEvent? other) -> bool +MarkupString.Decoding.TitleEvent.Icon.get -> bool +MarkupString.Decoding.TitleEvent.Icon.init -> void +MarkupString.Decoding.TitleEvent.Title.get -> string! +MarkupString.Decoding.TitleEvent.Title.init -> void +MarkupString.Decoding.TitleEvent.TitleEvent(string! Title, bool Icon) -> void +MarkupString.Decoding.VariableEvent +MarkupString.Decoding.VariableEvent.Deconstruct(out string! Name, out string? Value) -> void +MarkupString.Decoding.VariableEvent.Equals(MarkupString.Decoding.VariableEvent? other) -> bool +MarkupString.Decoding.VariableEvent.Name.get -> string! +MarkupString.Decoding.VariableEvent.Name.init -> void +MarkupString.Decoding.VariableEvent.Value.get -> string? +MarkupString.Decoding.VariableEvent.Value.init -> void +MarkupString.Decoding.VariableEvent.VariableEvent(string! Name, string? Value) -> void +MarkupString.Decoding.VtLimits +MarkupString.Decoding.VtLimits.$() -> MarkupString.Decoding.VtLimits! +MarkupString.Decoding.VtLimits.Equals(MarkupString.Decoding.VtLimits? other) -> bool +MarkupString.Decoding.VtLimits.MaxApcLength.get -> int +MarkupString.Decoding.VtLimits.MaxApcLength.init -> void +MarkupString.Decoding.VtLimits.MaxParameterValue.get -> int +MarkupString.Decoding.VtLimits.MaxParameterValue.init -> void +MarkupString.Decoding.VtLimits.MaxParameters.get -> int +MarkupString.Decoding.VtLimits.MaxParameters.init -> void +MarkupString.Decoding.VtLimits.MaxStringLength.get -> int +MarkupString.Decoding.VtLimits.MaxStringLength.init -> void +MarkupString.Decoding.VtLimits.VtLimits() -> void +MarkupString.Decoding.VtSequenceKind +MarkupString.Decoding.VtSequenceKind.Apc = 4 -> MarkupString.Decoding.VtSequenceKind +MarkupString.Decoding.VtSequenceKind.Csi = 1 -> MarkupString.Decoding.VtSequenceKind +MarkupString.Decoding.VtSequenceKind.Dcs = 3 -> MarkupString.Decoding.VtSequenceKind +MarkupString.Decoding.VtSequenceKind.Escape = 0 -> MarkupString.Decoding.VtSequenceKind +MarkupString.Decoding.VtSequenceKind.Osc = 2 -> MarkupString.Decoding.VtSequenceKind +MarkupString.Decoding.VtTokenizer +MarkupString.Decoding.VtTokenizer.Feed(System.ReadOnlySpan text, MarkupString.Decoding.IVtTokenSink! sink) -> void +MarkupString.Decoding.VtTokenizer.InSequence.get -> bool +MarkupString.Decoding.VtTokenizer.Interrupt(MarkupString.Decoding.IVtTokenSink! sink) -> void +MarkupString.Decoding.VtTokenizer.Reset() -> void +MarkupString.Decoding.VtTokenizer.VtTokenizer() -> void +MarkupString.Decoding.VtTokenizer.VtTokenizer(MarkupString.Decoding.VtLimits! limits) -> void +MarkupString.Display.ColorLevel +MarkupString.Display.ColorLevel.Attributes = 1 -> MarkupString.Display.ColorLevel +MarkupString.Display.ColorLevel.None = 0 -> MarkupString.Display.ColorLevel +MarkupString.Display.ColorLevel.Standard = 2 -> MarkupString.Display.ColorLevel +MarkupString.Display.ColorLevel.TrueColor = 4 -> MarkupString.Display.ColorLevel +MarkupString.Display.ColorLevel.Xterm256 = 3 -> MarkupString.Display.ColorLevel +MarkupString.Display.InteractionKinds +MarkupString.Display.InteractionKinds.All = MarkupString.Display.InteractionKinds.Url | MarkupString.Display.InteractionKinds.Command | MarkupString.Display.InteractionKinds.Prompt | MarkupString.Display.InteractionKinds.Menu -> MarkupString.Display.InteractionKinds +MarkupString.Display.InteractionKinds.Command = 2 -> MarkupString.Display.InteractionKinds +MarkupString.Display.InteractionKinds.Menu = 8 -> MarkupString.Display.InteractionKinds +MarkupString.Display.InteractionKinds.None = 0 -> MarkupString.Display.InteractionKinds +MarkupString.Display.InteractionKinds.Prompt = 4 -> MarkupString.Display.InteractionKinds +MarkupString.Display.InteractionKinds.Url = 1 -> MarkupString.Display.InteractionKinds +MarkupString.Display.OutputCapabilities +MarkupString.Display.OutputCapabilities.$() -> MarkupString.Display.OutputCapabilities! +MarkupString.Display.OutputCapabilities.Attributes.get -> MarkupString.Display.TextAttributes +MarkupString.Display.OutputCapabilities.Attributes.init -> void +MarkupString.Display.OutputCapabilities.BestPictureMethod.get -> MarkupString.Display.PictureMethods +MarkupString.Display.OutputCapabilities.Colors.get -> MarkupString.Display.ColorLevel +MarkupString.Display.OutputCapabilities.Colors.init -> void +MarkupString.Display.OutputCapabilities.Equals(MarkupString.Display.OutputCapabilities? other) -> bool +MarkupString.Display.OutputCapabilities.Interactions.get -> MarkupString.Display.InteractionKinds +MarkupString.Display.OutputCapabilities.Interactions.init -> void +MarkupString.Display.OutputCapabilities.Intersect(MarkupString.Display.OutputCapabilities! other) -> MarkupString.Display.OutputCapabilities! +MarkupString.Display.OutputCapabilities.MovingPictures.get -> bool +MarkupString.Display.OutputCapabilities.MovingPictures.init -> void +MarkupString.Display.OutputCapabilities.OutputCapabilities() -> void +MarkupString.Display.OutputCapabilities.Panes.get -> bool +MarkupString.Display.OutputCapabilities.Panes.init -> void +MarkupString.Display.OutputCapabilities.Pictures.get -> MarkupString.Display.PictureMethods +MarkupString.Display.OutputCapabilities.Pictures.init -> void +MarkupString.Display.OutputCapabilities.Sound.get -> bool +MarkupString.Display.OutputCapabilities.Sound.init -> void +MarkupString.Display.OutputCapabilities.Unicode.get -> MarkupString.Display.UnicodeLevel +MarkupString.Display.OutputCapabilities.Unicode.init -> void +MarkupString.Display.OutputCapabilities.Union(MarkupString.Display.OutputCapabilities! other) -> MarkupString.Display.OutputCapabilities! +MarkupString.Display.PictureMethods +MarkupString.Display.PictureMethods.Bitmap = 1 -> MarkupString.Display.PictureMethods +MarkupString.Display.PictureMethods.HalfBlocks = 16 -> MarkupString.Display.PictureMethods +MarkupString.Display.PictureMethods.InlineImages = 4 -> MarkupString.Display.PictureMethods +MarkupString.Display.PictureMethods.KittyPlaceholders = 2 -> MarkupString.Display.PictureMethods +MarkupString.Display.PictureMethods.None = 0 -> MarkupString.Display.PictureMethods +MarkupString.Display.PictureMethods.Sixel = 8 -> MarkupString.Display.PictureMethods +MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.All = MarkupString.Display.TextAttributes.Bold | MarkupString.Display.TextAttributes.Faint | MarkupString.Display.TextAttributes.Italic | MarkupString.Display.TextAttributes.Underline | MarkupString.Display.TextAttributes.Blink | MarkupString.Display.TextAttributes.Inverse | MarkupString.Display.TextAttributes.Conceal | MarkupString.Display.TextAttributes.Strikethrough | MarkupString.Display.TextAttributes.Overline -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.Blink = 16 -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.Bold = 1 -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.Conceal = 64 -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.Faint = 2 -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.Inverse = 32 -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.Italic = 4 -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.None = 0 -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.Overline = 256 -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.Strikethrough = 128 -> MarkupString.Display.TextAttributes +MarkupString.Display.TextAttributes.Underline = 8 -> MarkupString.Display.TextAttributes +MarkupString.Display.UnicodeLevel +MarkupString.Display.UnicodeLevel.Ascii = 2 -> MarkupString.Display.UnicodeLevel +MarkupString.Display.UnicodeLevel.Full = 0 -> MarkupString.Display.UnicodeLevel +MarkupString.Display.UnicodeLevel.Narrow = 1 -> MarkupString.Display.UnicodeLevel +MarkupString.PictureRow.FirstColumn.get -> int +MarkupString.PictureRow.FirstColumn.init -> void +abstract MarkupString.Decoding.DecodeEvent.$() -> MarkupString.Decoding.DecodeEvent! +const MarkupString.Decoding.CsiSequence.Omitted = -1 -> int +override MarkupString.Decoding.ClientIdentity.Equals(object? obj) -> bool +override MarkupString.Decoding.ClientIdentity.GetHashCode() -> int +override MarkupString.Decoding.ClientIdentity.ToString() -> string! +override MarkupString.Decoding.ClipboardEvent.$() -> MarkupString.Decoding.ClipboardEvent! +override MarkupString.Decoding.ClipboardEvent.Equals(object? obj) -> bool +override MarkupString.Decoding.ClipboardEvent.GetHashCode() -> int +override MarkupString.Decoding.ClipboardEvent.ToString() -> string! +override MarkupString.Decoding.DecodeEvent.Equals(object? obj) -> bool +override MarkupString.Decoding.DecodeEvent.GetHashCode() -> int +override MarkupString.Decoding.DecodeEvent.ToString() -> string! +override MarkupString.Decoding.DecoderOptions.Equals(object? obj) -> bool +override MarkupString.Decoding.DecoderOptions.GetHashCode() -> int +override MarkupString.Decoding.DecoderOptions.ToString() -> string! +override MarkupString.Decoding.DisplayLine.Equals(object? obj) -> bool +override MarkupString.Decoding.DisplayLine.GetHashCode() -> int +override MarkupString.Decoding.DisplayLine.ToString() -> string! +override MarkupString.Decoding.DownloadEvent.$() -> MarkupString.Decoding.DownloadEvent! +override MarkupString.Decoding.DownloadEvent.Equals(object? obj) -> bool +override MarkupString.Decoding.DownloadEvent.GetHashCode() -> int +override MarkupString.Decoding.DownloadEvent.ToString() -> string! +override MarkupString.Decoding.PaletteEvent.$() -> MarkupString.Decoding.PaletteEvent! +override MarkupString.Decoding.PaletteEvent.Equals(object? obj) -> bool +override MarkupString.Decoding.PaletteEvent.GetHashCode() -> int +override MarkupString.Decoding.PaletteEvent.ToString() -> string! +override MarkupString.Decoding.ProtocolEvent.$() -> MarkupString.Decoding.ProtocolEvent! +override MarkupString.Decoding.ProtocolEvent.Equals(object? obj) -> bool +override MarkupString.Decoding.ProtocolEvent.GetHashCode() -> int +override MarkupString.Decoding.ProtocolEvent.ToString() -> string! +override MarkupString.Decoding.ReplyEvent.$() -> MarkupString.Decoding.ReplyEvent! +override MarkupString.Decoding.ReplyEvent.Equals(object? obj) -> bool +override MarkupString.Decoding.ReplyEvent.GetHashCode() -> int +override MarkupString.Decoding.ReplyEvent.ToString() -> string! +override MarkupString.Decoding.TitleEvent.$() -> MarkupString.Decoding.TitleEvent! +override MarkupString.Decoding.TitleEvent.Equals(object? obj) -> bool +override MarkupString.Decoding.TitleEvent.GetHashCode() -> int +override MarkupString.Decoding.TitleEvent.ToString() -> string! +override MarkupString.Decoding.VariableEvent.$() -> MarkupString.Decoding.VariableEvent! +override MarkupString.Decoding.VariableEvent.Equals(object? obj) -> bool +override MarkupString.Decoding.VariableEvent.GetHashCode() -> int +override MarkupString.Decoding.VariableEvent.ToString() -> string! +override MarkupString.Decoding.VtLimits.Equals(object? obj) -> bool +override MarkupString.Decoding.VtLimits.GetHashCode() -> int +override MarkupString.Decoding.VtLimits.ToString() -> string! +override MarkupString.Display.OutputCapabilities.Equals(object? obj) -> bool +override MarkupString.Display.OutputCapabilities.GetHashCode() -> int +override MarkupString.Display.OutputCapabilities.ToString() -> string! +override sealed MarkupString.Decoding.ClipboardEvent.Equals(MarkupString.Decoding.DecodeEvent? other) -> bool +override sealed MarkupString.Decoding.DownloadEvent.Equals(MarkupString.Decoding.DecodeEvent? other) -> bool +override sealed MarkupString.Decoding.PaletteEvent.Equals(MarkupString.Decoding.DecodeEvent? other) -> bool +override sealed MarkupString.Decoding.ProtocolEvent.Equals(MarkupString.Decoding.DecodeEvent? other) -> bool +override sealed MarkupString.Decoding.ReplyEvent.Equals(MarkupString.Decoding.DecodeEvent? other) -> bool +override sealed MarkupString.Decoding.TitleEvent.Equals(MarkupString.Decoding.DecodeEvent? other) -> bool +override sealed MarkupString.Decoding.VariableEvent.Equals(MarkupString.Decoding.DecodeEvent? other) -> bool +static MarkupString.Decoding.ClientIdentity.operator !=(MarkupString.Decoding.ClientIdentity? left, MarkupString.Decoding.ClientIdentity? right) -> bool +static MarkupString.Decoding.ClientIdentity.operator ==(MarkupString.Decoding.ClientIdentity? left, MarkupString.Decoding.ClientIdentity? right) -> bool +static MarkupString.Decoding.ClipboardEvent.operator !=(MarkupString.Decoding.ClipboardEvent? left, MarkupString.Decoding.ClipboardEvent? right) -> bool +static MarkupString.Decoding.ClipboardEvent.operator ==(MarkupString.Decoding.ClipboardEvent? left, MarkupString.Decoding.ClipboardEvent? right) -> bool +static MarkupString.Decoding.DecodeEvent.operator !=(MarkupString.Decoding.DecodeEvent? left, MarkupString.Decoding.DecodeEvent? right) -> bool +static MarkupString.Decoding.DecodeEvent.operator ==(MarkupString.Decoding.DecodeEvent? left, MarkupString.Decoding.DecodeEvent? right) -> bool +static MarkupString.Decoding.Decoder.DecodeAll(string? text, MarkupString.Decoding.DecoderRegistry! registry, MarkupString.Decoding.DecoderOptions? options = null) -> System.Collections.Generic.IReadOnlyList! +static MarkupString.Decoding.DecoderOptions.operator !=(MarkupString.Decoding.DecoderOptions? left, MarkupString.Decoding.DecoderOptions? right) -> bool +static MarkupString.Decoding.DecoderOptions.operator ==(MarkupString.Decoding.DecoderOptions? left, MarkupString.Decoding.DecoderOptions? right) -> bool +static MarkupString.Decoding.DecoderRegistry.Empty.get -> MarkupString.Decoding.DecoderRegistry! +static MarkupString.Decoding.DisplayLine.operator !=(MarkupString.Decoding.DisplayLine? left, MarkupString.Decoding.DisplayLine? right) -> bool +static MarkupString.Decoding.DisplayLine.operator ==(MarkupString.Decoding.DisplayLine? left, MarkupString.Decoding.DisplayLine? right) -> bool +static MarkupString.Decoding.DownloadEvent.operator !=(MarkupString.Decoding.DownloadEvent? left, MarkupString.Decoding.DownloadEvent? right) -> bool +static MarkupString.Decoding.DownloadEvent.operator ==(MarkupString.Decoding.DownloadEvent? left, MarkupString.Decoding.DownloadEvent? right) -> bool +static MarkupString.Decoding.PaletteEvent.operator !=(MarkupString.Decoding.PaletteEvent? left, MarkupString.Decoding.PaletteEvent? right) -> bool +static MarkupString.Decoding.PaletteEvent.operator ==(MarkupString.Decoding.PaletteEvent? left, MarkupString.Decoding.PaletteEvent? right) -> bool +static MarkupString.Decoding.ProtocolEvent.operator !=(MarkupString.Decoding.ProtocolEvent? left, MarkupString.Decoding.ProtocolEvent? right) -> bool +static MarkupString.Decoding.ProtocolEvent.operator ==(MarkupString.Decoding.ProtocolEvent? left, MarkupString.Decoding.ProtocolEvent? right) -> bool +static MarkupString.Decoding.ReplyEvent.operator !=(MarkupString.Decoding.ReplyEvent? left, MarkupString.Decoding.ReplyEvent? right) -> bool +static MarkupString.Decoding.ReplyEvent.operator ==(MarkupString.Decoding.ReplyEvent? left, MarkupString.Decoding.ReplyEvent? right) -> bool +static MarkupString.Decoding.TitleEvent.operator !=(MarkupString.Decoding.TitleEvent? left, MarkupString.Decoding.TitleEvent? right) -> bool +static MarkupString.Decoding.TitleEvent.operator ==(MarkupString.Decoding.TitleEvent? left, MarkupString.Decoding.TitleEvent? right) -> bool +static MarkupString.Decoding.VariableEvent.operator !=(MarkupString.Decoding.VariableEvent? left, MarkupString.Decoding.VariableEvent? right) -> bool +static MarkupString.Decoding.VariableEvent.operator ==(MarkupString.Decoding.VariableEvent? left, MarkupString.Decoding.VariableEvent? right) -> bool +static MarkupString.Decoding.VtLimits.Default.get -> MarkupString.Decoding.VtLimits! +static MarkupString.Decoding.VtLimits.operator !=(MarkupString.Decoding.VtLimits? left, MarkupString.Decoding.VtLimits? right) -> bool +static MarkupString.Decoding.VtLimits.operator ==(MarkupString.Decoding.VtLimits? left, MarkupString.Decoding.VtLimits? right) -> bool +static MarkupString.Display.OutputCapabilities.Browser.get -> MarkupString.Display.OutputCapabilities! +static MarkupString.Display.OutputCapabilities.CellGrid.get -> MarkupString.Display.OutputCapabilities! +static MarkupString.Display.OutputCapabilities.PlainText.get -> MarkupString.Display.OutputCapabilities! +static MarkupString.Display.OutputCapabilities.operator !=(MarkupString.Display.OutputCapabilities? left, MarkupString.Display.OutputCapabilities? right) -> bool +static MarkupString.Display.OutputCapabilities.operator ==(MarkupString.Display.OutputCapabilities? left, MarkupString.Display.OutputCapabilities? right) -> bool +virtual MarkupString.Decoding.ApcHandler.Invoke(System.ReadOnlySpan payload, MarkupString.Decoding.DecodeContext! context) -> bool +virtual MarkupString.Decoding.ControlHandler.Invoke(char control, MarkupString.Decoding.DecodeContext! context) -> bool +virtual MarkupString.Decoding.CsiHandler.Invoke(in MarkupString.Decoding.CsiSequence sequence, MarkupString.Decoding.DecodeContext! context) -> bool +virtual MarkupString.Decoding.DcsHandler.Invoke(in MarkupString.Decoding.CsiSequence header, System.ReadOnlySpan data, MarkupString.Decoding.DecodeContext! context) -> bool +virtual MarkupString.Decoding.DecodeEvent.EqualityContract.get -> System.Type! +virtual MarkupString.Decoding.DecodeEvent.Equals(MarkupString.Decoding.DecodeEvent? other) -> bool +virtual MarkupString.Decoding.DecodeEvent.PrintMembers(System.Text.StringBuilder! builder) -> bool +virtual MarkupString.Decoding.EscHandler.Invoke(in MarkupString.Decoding.EscSequence sequence, MarkupString.Decoding.DecodeContext! context) -> bool +virtual MarkupString.Decoding.OscHandler.Invoke(System.ReadOnlySpan payload, MarkupString.Decoding.DecodeContext! context) -> bool +virtual MarkupString.Decoding.PrintFilter.Invoke(System.ReadOnlySpan text, MarkupString.Decoding.DecodeContext! context) -> bool +MarkupString.CommandChoice +MarkupString.CommandChoice.$() -> MarkupString.CommandChoice! +MarkupString.CommandChoice.Command.get -> string! +MarkupString.CommandChoice.Command.init -> void +MarkupString.CommandChoice.CommandChoice(string! Label, string! Command) -> void +MarkupString.CommandChoice.Deconstruct(out string! Label, out string! Command) -> void +MarkupString.CommandChoice.Equals(MarkupString.CommandChoice? other) -> bool +MarkupString.CommandChoice.Label.get -> string! +MarkupString.CommandChoice.Label.init -> void +MarkupString.CommandMenuMarkup +MarkupString.CommandMenuMarkup.$() -> MarkupString.CommandMenuMarkup! +MarkupString.CommandMenuMarkup.Choices.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.CommandMenuMarkup.Choices.init -> void +MarkupString.CommandMenuMarkup.CommandMenuMarkup(System.Collections.Generic.IReadOnlyList! Choices, string? Hint = null, bool Prompt = false) -> void +MarkupString.CommandMenuMarkup.Deconstruct(out System.Collections.Generic.IReadOnlyList! Choices, out string? Hint, out bool Prompt) -> void +MarkupString.CommandMenuMarkup.Equals(MarkupString.CommandMenuMarkup? other) -> bool +MarkupString.CommandMenuMarkup.Hint.get -> string? +MarkupString.CommandMenuMarkup.Hint.init -> void +MarkupString.CommandMenuMarkup.Prompt.get -> bool +MarkupString.CommandMenuMarkup.Prompt.init -> void +MarkupString.Display.Cell +MarkupString.Display.Cell.Cell() -> void +MarkupString.Display.Cell.Cell(string! Text, MarkupString.Display.DisplayStyle Style) -> void +MarkupString.Display.Cell.Deconstruct(out string! Text, out MarkupString.Display.DisplayStyle Style) -> void +MarkupString.Display.Cell.Equals(MarkupString.Display.Cell other) -> bool +MarkupString.Display.Cell.IsContinuation.get -> bool +MarkupString.Display.Cell.PictureColumn.get -> int +MarkupString.Display.Cell.PictureColumn.init -> void +MarkupString.Display.Cell.Style.get -> MarkupString.Display.DisplayStyle +MarkupString.Display.Cell.Style.init -> void +MarkupString.Display.Cell.Text.get -> string! +MarkupString.Display.Cell.Text.init -> void +MarkupString.Display.Cell.Width.get -> int +MarkupString.Display.Cell.Width.init -> void +MarkupString.Display.CellLayout +MarkupString.Display.CellLayout.At(int row, int column) -> MarkupString.Display.Cell? +MarkupString.Display.CellLayout.Columns.get -> int +MarkupString.Display.CellLayout.InteractionAt(int row, int column) -> MarkupString.Display.Interaction? +MarkupString.Display.CellLayout.Points.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.Display.CellLayout.Rows.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.Display.CellPoint +MarkupString.Display.CellPoint.CellPoint() -> void +MarkupString.Display.CellPoint.CellPoint(int Row, int Column, MarkupString.IPointMarkup! Markup) -> void +MarkupString.Display.CellPoint.Column.get -> int +MarkupString.Display.CellPoint.Column.init -> void +MarkupString.Display.CellPoint.Deconstruct(out int Row, out int Column, out MarkupString.IPointMarkup! Markup) -> void +MarkupString.Display.CellPoint.Equals(MarkupString.Display.CellPoint other) -> bool +MarkupString.Display.CellPoint.Markup.get -> MarkupString.IPointMarkup! +MarkupString.Display.CellPoint.Markup.init -> void +MarkupString.Display.CellPoint.Row.get -> int +MarkupString.Display.CellPoint.Row.init -> void +MarkupString.Display.CellRenderer +MarkupString.Display.CellRow +MarkupString.Display.CellRow.Cells.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.Display.CellRow.Wrapped.get -> bool +MarkupString.Display.DisplayOptions +MarkupString.Display.DisplayOptions.$() -> MarkupString.Display.DisplayOptions! +MarkupString.Display.DisplayOptions.Capabilities.get -> MarkupString.Display.OutputCapabilities! +MarkupString.Display.DisplayOptions.Capabilities.init -> void +MarkupString.Display.DisplayOptions.Colors.get -> MarkupString.Display.IColorResolver? +MarkupString.Display.DisplayOptions.Colors.init -> void +MarkupString.Display.DisplayOptions.DefaultBackground.get -> MarkupString.RgbColor +MarkupString.Display.DisplayOptions.DefaultBackground.init -> void +MarkupString.Display.DisplayOptions.DefaultForeground.get -> MarkupString.RgbColor +MarkupString.Display.DisplayOptions.DefaultForeground.init -> void +MarkupString.Display.DisplayOptions.DisplayOptions() -> void +MarkupString.Display.DisplayOptions.Equals(MarkupString.Display.DisplayOptions? other) -> bool +MarkupString.Display.DisplayOptions.TabWidth.get -> int +MarkupString.Display.DisplayOptions.TabWidth.init -> void +MarkupString.Display.DisplayRegistry +MarkupString.Display.DisplayRegistry.Contributors.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.Display.DisplayRegistry.Resolve(MarkupString.MarkupSet? markups, MarkupString.Display.DisplayOptions! options) -> MarkupString.Display.DisplayStyle +MarkupString.Display.DisplayRegistry.With(MarkupString.Display.IStyleContributor! contributor) -> MarkupString.Display.DisplayRegistry! +MarkupString.Display.DisplaySpan +MarkupString.Display.DisplaySpan.Deconstruct(out string! Text, out MarkupString.Display.DisplayStyle Style) -> void +MarkupString.Display.DisplaySpan.DisplaySpan() -> void +MarkupString.Display.DisplaySpan.DisplaySpan(string! Text, MarkupString.Display.DisplayStyle Style) -> void +MarkupString.Display.DisplaySpan.Equals(MarkupString.Display.DisplaySpan other) -> bool +MarkupString.Display.DisplaySpan.Point.get -> MarkupString.IPointMarkup? +MarkupString.Display.DisplaySpan.Point.init -> void +MarkupString.Display.DisplaySpan.Style.get -> MarkupString.Display.DisplayStyle +MarkupString.Display.DisplaySpan.Style.init -> void +MarkupString.Display.DisplaySpan.Text.get -> string! +MarkupString.Display.DisplaySpan.Text.init -> void +MarkupString.Display.DisplayStyle +MarkupString.Display.DisplayStyle.Attributes.get -> MarkupString.Display.TextAttributes +MarkupString.Display.DisplayStyle.Attributes.init -> void +MarkupString.Display.DisplayStyle.Background.get -> MarkupString.RgbColor? +MarkupString.Display.DisplayStyle.Background.init -> void +MarkupString.Display.DisplayStyle.DisplayStyle() -> void +MarkupString.Display.DisplayStyle.Equals(MarkupString.Display.DisplayStyle other) -> bool +MarkupString.Display.DisplayStyle.Foreground.get -> MarkupString.RgbColor? +MarkupString.Display.DisplayStyle.Foreground.init -> void +MarkupString.Display.DisplayStyle.Has(MarkupString.Display.TextAttributes attribute) -> bool +MarkupString.Display.DisplayStyle.Interaction.get -> MarkupString.Display.Interaction? +MarkupString.Display.DisplayStyle.Interaction.init -> void +MarkupString.Display.DisplayStyle.Pane.get -> string? +MarkupString.Display.DisplayStyle.Pane.init -> void +MarkupString.Display.DisplayStyle.Picture.get -> MarkupString.ImageMarkup? +MarkupString.Display.DisplayStyle.Picture.init -> void +MarkupString.Display.IColorResolver +MarkupString.Display.IColorResolver.Legible(MarkupString.RgbColor foreground, MarkupString.RgbColor background) -> MarkupString.RgbColor +MarkupString.Display.IColorResolver.Standard(int index, bool bright) -> MarkupString.RgbColor +MarkupString.Display.IColorResolver.Xterm(int index) -> MarkupString.RgbColor +MarkupString.Display.IStyleContributor +MarkupString.Display.IStyleContributor.Contribute(MarkupString.MarkupSet! markups, MarkupString.Display.StyleBuilder! style, MarkupString.Display.DisplayOptions! options) -> void +MarkupString.Display.Interaction +MarkupString.Display.Interaction.$() -> MarkupString.Display.Interaction! +MarkupString.Display.Interaction.Choices.get -> System.Collections.Generic.IReadOnlyList? +MarkupString.Display.Interaction.Choices.init -> void +MarkupString.Display.Interaction.Deconstruct(out MarkupString.Display.InteractionKind Kind, out string! Target) -> void +MarkupString.Display.Interaction.Equals(MarkupString.Display.Interaction? other) -> bool +MarkupString.Display.Interaction.Group.get -> string? +MarkupString.Display.Interaction.Group.init -> void +MarkupString.Display.Interaction.Hint.get -> string? +MarkupString.Display.Interaction.Hint.init -> void +MarkupString.Display.Interaction.Interaction(MarkupString.Display.InteractionKind Kind, string! Target) -> void +MarkupString.Display.Interaction.Kind.get -> MarkupString.Display.InteractionKind +MarkupString.Display.Interaction.Kind.init -> void +MarkupString.Display.Interaction.Target.get -> string! +MarkupString.Display.Interaction.Target.init -> void +MarkupString.Display.InteractionKind +MarkupString.Display.InteractionKind.Command = 1 -> MarkupString.Display.InteractionKind +MarkupString.Display.InteractionKind.Prompt = 2 -> MarkupString.Display.InteractionKind +MarkupString.Display.InteractionKind.Url = 0 -> MarkupString.Display.InteractionKind +MarkupString.Display.SpanRenderer +MarkupString.Display.StyleBuilder +MarkupString.Display.StyleBuilder.Attributes.get -> MarkupString.Display.TextAttributes +MarkupString.Display.StyleBuilder.Attributes.set -> void +MarkupString.Display.StyleBuilder.Background.get -> MarkupString.RgbColor? +MarkupString.Display.StyleBuilder.Background.set -> void +MarkupString.Display.StyleBuilder.Foreground.get -> MarkupString.RgbColor? +MarkupString.Display.StyleBuilder.Foreground.set -> void +MarkupString.Display.StyleBuilder.Interaction.get -> MarkupString.Display.Interaction? +MarkupString.Display.StyleBuilder.Interaction.set -> void +MarkupString.Display.StyleBuilder.Pane.get -> string? +MarkupString.Display.StyleBuilder.Pane.set -> void +MarkupString.Display.StyleBuilder.Picture.get -> MarkupString.ImageMarkup? +MarkupString.Display.StyleBuilder.Picture.set -> void +MarkupString.Display.StyleBuilder.StyleBuilder() -> void +MarkupString.Display.VocabularyStyles +MarkupString.Display.VocabularyStyles.Contribute(MarkupString.MarkupSet! markups, MarkupString.Display.StyleBuilder! style, MarkupString.Display.DisplayOptions! options) -> void +MarkupString.HighlightMarkup +MarkupString.HighlightMarkup.$() -> MarkupString.HighlightMarkup! +MarkupString.HighlightMarkup.Color.get -> MarkupString.RgbColor +MarkupString.HighlightMarkup.Color.init -> void +MarkupString.HighlightMarkup.Deconstruct(out MarkupString.RgbColor Color) -> void +MarkupString.HighlightMarkup.Equals(MarkupString.HighlightMarkup? other) -> bool +MarkupString.HighlightMarkup.HighlightMarkup(MarkupString.RgbColor Color) -> void +override MarkupString.CommandChoice.Equals(object? obj) -> bool +override MarkupString.CommandChoice.GetHashCode() -> int +override MarkupString.CommandChoice.ToString() -> string! +override MarkupString.CommandMenuMarkup.Equals(object? obj) -> bool +override MarkupString.CommandMenuMarkup.GetHashCode() -> int +override MarkupString.CommandMenuMarkup.ToString() -> string! +override MarkupString.Display.Cell.GetHashCode() -> int +override MarkupString.Display.CellPoint.GetHashCode() -> int +override MarkupString.Display.DisplayOptions.Equals(object? obj) -> bool +override MarkupString.Display.DisplayOptions.GetHashCode() -> int +override MarkupString.Display.DisplayOptions.ToString() -> string! +override MarkupString.Display.DisplaySpan.GetHashCode() -> int +override MarkupString.Display.DisplayStyle.GetHashCode() -> int +override MarkupString.Display.Interaction.Equals(object? obj) -> bool +override MarkupString.Display.Interaction.GetHashCode() -> int +override MarkupString.Display.Interaction.ToString() -> string! +override MarkupString.HighlightMarkup.Equals(object? obj) -> bool +override MarkupString.HighlightMarkup.GetHashCode() -> int +override MarkupString.HighlightMarkup.ToString() -> string! +static MarkupString.CommandChoice.operator !=(MarkupString.CommandChoice? left, MarkupString.CommandChoice? right) -> bool +static MarkupString.CommandChoice.operator ==(MarkupString.CommandChoice? left, MarkupString.CommandChoice? right) -> bool +static MarkupString.CommandMenuMarkup.operator !=(MarkupString.CommandMenuMarkup? left, MarkupString.CommandMenuMarkup? right) -> bool +static MarkupString.CommandMenuMarkup.operator ==(MarkupString.CommandMenuMarkup? left, MarkupString.CommandMenuMarkup? right) -> bool +static MarkupString.Display.Cell.operator !=(MarkupString.Display.Cell left, MarkupString.Display.Cell right) -> bool +static MarkupString.Display.Cell.operator ==(MarkupString.Display.Cell left, MarkupString.Display.Cell right) -> bool +static MarkupString.Display.CellPoint.operator !=(MarkupString.Display.CellPoint left, MarkupString.Display.CellPoint right) -> bool +static MarkupString.Display.CellPoint.operator ==(MarkupString.Display.CellPoint left, MarkupString.Display.CellPoint right) -> bool +static MarkupString.Display.DisplayOptions.operator !=(MarkupString.Display.DisplayOptions? left, MarkupString.Display.DisplayOptions? right) -> bool +static MarkupString.Display.DisplayOptions.operator ==(MarkupString.Display.DisplayOptions? left, MarkupString.Display.DisplayOptions? right) -> bool +static MarkupString.Display.DisplayRegistry.Core.get -> MarkupString.Display.DisplayRegistry! +static MarkupString.Display.DisplayRegistry.Empty.get -> MarkupString.Display.DisplayRegistry! +static MarkupString.Display.DisplaySpan.operator !=(MarkupString.Display.DisplaySpan left, MarkupString.Display.DisplaySpan right) -> bool +static MarkupString.Display.DisplaySpan.operator ==(MarkupString.Display.DisplaySpan left, MarkupString.Display.DisplaySpan right) -> bool +static MarkupString.Display.DisplayStyle.Plain.get -> MarkupString.Display.DisplayStyle +static MarkupString.Display.DisplayStyle.operator !=(MarkupString.Display.DisplayStyle left, MarkupString.Display.DisplayStyle right) -> bool +static MarkupString.Display.DisplayStyle.operator ==(MarkupString.Display.DisplayStyle left, MarkupString.Display.DisplayStyle right) -> bool +static MarkupString.Display.Interaction.operator !=(MarkupString.Display.Interaction? left, MarkupString.Display.Interaction? right) -> bool +static MarkupString.Display.Interaction.operator ==(MarkupString.Display.Interaction? left, MarkupString.Display.Interaction? right) -> bool +static MarkupString.Display.SpanRenderer.Css(MarkupString.Display.DisplayStyle style) -> string! +static MarkupString.Display.SpanRenderer.CssClasses(MarkupString.Display.DisplayStyle style) -> string! +static MarkupString.Display.SpanRenderer.Hex(MarkupString.RgbColor color) -> string! +static MarkupString.Display.VocabularyStyles.Instance.get -> MarkupString.Display.VocabularyStyles! +static MarkupString.HighlightMarkup.operator !=(MarkupString.HighlightMarkup? left, MarkupString.HighlightMarkup? right) -> bool +static MarkupString.HighlightMarkup.operator ==(MarkupString.HighlightMarkup? left, MarkupString.HighlightMarkup? right) -> bool +~override MarkupString.Display.Cell.Equals(object obj) -> bool +~override MarkupString.Display.Cell.ToString() -> string +~override MarkupString.Display.CellPoint.Equals(object obj) -> bool +~override MarkupString.Display.CellPoint.ToString() -> string +~override MarkupString.Display.DisplaySpan.Equals(object obj) -> bool +~override MarkupString.Display.DisplaySpan.ToString() -> string +~override MarkupString.Display.DisplayStyle.Equals(object obj) -> bool +~override MarkupString.Display.DisplayStyle.ToString() -> string +MarkupString.Decoding.Decoder.State() -> T! +MarkupString.Decoding.DecodeDiagnostics.AbandonedSequences.set -> void +MarkupString.Decoding.DecodeDiagnostics.LimitsReached.set -> void +MarkupString.Decoding.DecodeDiagnostics.PicturesFailed.set -> void +MarkupString.Decoding.DecodeDiagnostics.UnhandledControls.set -> void +MarkupString.Decoding.DecodeDiagnostics.UnhandledSequences.set -> void +MarkupString.Decoding.ITagSink +MarkupString.Decoding.ITagSink.Entity(System.ReadOnlySpan name) -> void +MarkupString.Decoding.ITagSink.ReadsMarkup.get -> bool +MarkupString.Decoding.ITagSink.Tag(System.ReadOnlySpan body) -> void +MarkupString.Decoding.ITagSink.Text(System.ReadOnlySpan text) -> void +MarkupString.Decoding.ParsedTag +MarkupString.Decoding.ParsedTag.$() -> MarkupString.Decoding.ParsedTag! +MarkupString.Decoding.ParsedTag.Arguments.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.Decoding.ParsedTag.Arguments.init -> void +MarkupString.Decoding.ParsedTag.Closing.get -> bool +MarkupString.Decoding.ParsedTag.Closing.init -> void +MarkupString.Decoding.ParsedTag.Deconstruct(out string! Name, out bool Closing, out System.Collections.Generic.IReadOnlyList! Arguments) -> void +MarkupString.Decoding.ParsedTag.Equals(MarkupString.Decoding.ParsedTag? other) -> bool +MarkupString.Decoding.ParsedTag.Get(string! name, int position = -1) -> string? +MarkupString.Decoding.ParsedTag.Has(string! name) -> bool +MarkupString.Decoding.ParsedTag.Name.get -> string! +MarkupString.Decoding.ParsedTag.Name.init -> void +MarkupString.Decoding.ParsedTag.ParsedTag(string! Name, bool Closing, System.Collections.Generic.IReadOnlyList! Arguments) -> void +MarkupString.Decoding.ParsedTag.SelfClosing.get -> bool +MarkupString.Decoding.ParsedTag.SelfClosing.init -> void +MarkupString.Decoding.TagArgument +MarkupString.Decoding.TagArgument.Deconstruct(out string? Name, out string! Value) -> void +MarkupString.Decoding.TagArgument.Equals(MarkupString.Decoding.TagArgument other) -> bool +MarkupString.Decoding.TagArgument.Name.get -> string? +MarkupString.Decoding.TagArgument.Name.init -> void +MarkupString.Decoding.TagArgument.TagArgument() -> void +MarkupString.Decoding.TagArgument.TagArgument(string? Name, string! Value) -> void +MarkupString.Decoding.TagArgument.Value.get -> string! +MarkupString.Decoding.TagArgument.Value.init -> void +MarkupString.Decoding.TagScanner +MarkupString.Decoding.TagScanner.Feed(System.ReadOnlySpan text, MarkupString.Decoding.ITagSink! sink) -> void +MarkupString.Decoding.TagScanner.Flush(MarkupString.Decoding.ITagSink! sink) -> void +MarkupString.Decoding.TagScanner.InMarkup.get -> bool +MarkupString.Decoding.TagScanner.MaxEntityLength.get -> int +MarkupString.Decoding.TagScanner.MaxEntityLength.init -> void +MarkupString.Decoding.TagScanner.MaxTagLength.get -> int +MarkupString.Decoding.TagScanner.MaxTagLength.init -> void +MarkupString.Decoding.TagScanner.Reset() -> void +MarkupString.Decoding.TagScanner.TagScanner() -> void +MarkupString.Decoding.TagSyntax +override MarkupString.Decoding.ParsedTag.Equals(object? obj) -> bool +override MarkupString.Decoding.ParsedTag.GetHashCode() -> int +override MarkupString.Decoding.ParsedTag.ToString() -> string! +override MarkupString.Decoding.TagArgument.GetHashCode() -> int +static MarkupString.Decoding.ParsedTag.operator !=(MarkupString.Decoding.ParsedTag? left, MarkupString.Decoding.ParsedTag? right) -> bool +static MarkupString.Decoding.ParsedTag.operator ==(MarkupString.Decoding.ParsedTag? left, MarkupString.Decoding.ParsedTag? right) -> bool +static MarkupString.Decoding.TagArgument.operator !=(MarkupString.Decoding.TagArgument left, MarkupString.Decoding.TagArgument right) -> bool +static MarkupString.Decoding.TagArgument.operator ==(MarkupString.Decoding.TagArgument left, MarkupString.Decoding.TagArgument right) -> bool +static MarkupString.Decoding.TagSyntax.Decode(string! text, System.Func? entity = null) -> string! +static MarkupString.Decoding.TagSyntax.Entity(System.ReadOnlySpan name, System.Func? entity = null) -> string? +static MarkupString.Decoding.TagSyntax.Parse(System.ReadOnlySpan body, System.Func? entity = null) -> MarkupString.Decoding.ParsedTag? +~override MarkupString.Decoding.TagArgument.Equals(object obj) -> bool +~override MarkupString.Decoding.TagArgument.ToString() -> string +MarkupString.Decoding.DecodeContext.LineIsEmpty.get -> bool +MarkupString.LinkGroupMarkup +MarkupString.LinkGroupMarkup.$() -> MarkupString.LinkGroupMarkup! +MarkupString.LinkGroupMarkup.Deconstruct(out string! Group) -> void +MarkupString.LinkGroupMarkup.Equals(MarkupString.LinkGroupMarkup? other) -> bool +MarkupString.LinkGroupMarkup.Group.get -> string! +MarkupString.LinkGroupMarkup.Group.init -> void +MarkupString.LinkGroupMarkup.LinkGroupMarkup(string! Group) -> void +override MarkupString.LinkGroupMarkup.Equals(object? obj) -> bool +override MarkupString.LinkGroupMarkup.GetHashCode() -> int +override MarkupString.LinkGroupMarkup.ToString() -> string! +static MarkupString.LinkGroupMarkup.operator !=(MarkupString.LinkGroupMarkup? left, MarkupString.LinkGroupMarkup? right) -> bool +static MarkupString.LinkGroupMarkup.operator ==(MarkupString.LinkGroupMarkup? left, MarkupString.LinkGroupMarkup? right) -> bool +MarkupString.Decoding.DecoderOptions.Links.get -> MarkupString.IUrlPolicy! +MarkupString.Decoding.DecoderOptions.Links.init -> void +MarkupString.Decoding.DecoderOptions.Meters.get -> System.Diagnostics.Metrics.IMeterFactory? +MarkupString.Decoding.DecoderOptions.Meters.init -> void +MarkupString.Decoding.IDecoderFactory +MarkupString.Decoding.IDecoderFactory.CreateDecoder(MarkupString.Decoding.DecoderOptions? options = null) -> MarkupString.Decoding.Decoder! +MarkupString.Display.DisplayOptions.Links.get -> MarkupString.IUrlPolicy! +MarkupString.Display.DisplayOptions.Links.init -> void +MarkupString.Display.IStyleResolver +MarkupString.Display.IStyleResolver.Resolve(MarkupString.MarkupSet? markups, MarkupString.Display.DisplayOptions! options) -> MarkupString.Display.DisplayStyle +MarkupString.IUrlPolicy +MarkupString.IUrlPolicy.IsAllowed(string! url) -> bool +MarkupString.UrlPolicy +MarkupString.UrlPolicy.IsAllowed(string! url) -> bool +MarkupString.UrlPolicy.Schemes.get -> System.Collections.Generic.IReadOnlyCollection! +static MarkupString.Display.CellRenderer.Layout(MarkupString.MarkupText! text, int columns, MarkupString.Display.IStyleResolver! registry, MarkupString.Display.DisplayOptions! options, bool wordWrap = false) -> MarkupString.Display.CellLayout! +static MarkupString.Display.SpanRenderer.Render(MarkupString.MarkupText! text, MarkupString.Display.IStyleResolver! registry, MarkupString.Display.DisplayOptions! options) -> System.Collections.Generic.IReadOnlyList! +static MarkupString.UrlPolicy.Safe.get -> MarkupString.UrlPolicy! +static MarkupString.UrlPolicy.WithSchemes(params System.Collections.Generic.IEnumerable! schemes) -> MarkupString.UrlPolicy! +MarkupString.MarkupRegistry.Links.get -> MarkupString.IUrlPolicy! +MarkupString.MarkupRegistry.WithLinks(MarkupString.IUrlPolicy! links) -> MarkupString.MarkupRegistry! +MarkupString.Decoding.DecodeDiagnostics.Dropped -> System.Action? +MarkupString.Decoding.DecodeDrop +MarkupString.Decoding.DecodeDrop.AbandonedSequence = 0 -> MarkupString.Decoding.DecodeDrop +MarkupString.Decoding.DecodeDrop.Limit = 3 -> MarkupString.Decoding.DecodeDrop +MarkupString.Decoding.DecodeDrop.Picture = 4 -> MarkupString.Decoding.DecodeDrop +MarkupString.Decoding.DecodeDrop.UnhandledControl = 2 -> MarkupString.Decoding.DecodeDrop +MarkupString.Decoding.DecodeDrop.UnhandledSequence = 1 -> MarkupString.Decoding.DecodeDrop diff --git a/MarkupString/UrlPolicy.cs b/MarkupString/UrlPolicy.cs new file mode 100644 index 0000000..480cb3d --- /dev/null +++ b/MarkupString/UrlPolicy.cs @@ -0,0 +1,47 @@ +namespace MarkupString; + +/// Which link addresses may become something a client navigates to. +public interface IUrlPolicy +{ + /// Whether may be a link. One that may not is shown as its text. + bool IsAllowed(string url); +} + +/// +/// The usual : relative addresses and http, https, mailto, ftp, +/// ftps and tel, with whatever other schemes it is made with. javascript:, data:, +/// vbscript: and file: are never allowed. +/// +public sealed class UrlPolicy : IUrlPolicy +{ + private static readonly string[] Usual = ["http", "https", "mailto", "ftp", "ftps", "tel"]; + private static readonly string[] Never = ["javascript", "data", "vbscript", "file"]; + + private readonly HashSet _schemes; + + private UrlPolicy(IEnumerable schemes) => _schemes = new HashSet(schemes, StringComparer.OrdinalIgnoreCase); + + /// The usual schemes only. + public static UrlPolicy Safe { get; } = new(Usual); + + /// The usual schemes and (such as mudclient), without the colon. + public static UrlPolicy WithSchemes(params IEnumerable schemes) + { + ArgumentNullException.ThrowIfNull(schemes); + return new UrlPolicy(Usual.Concat(schemes.Where(scheme => !Never.Contains(scheme, StringComparer.OrdinalIgnoreCase)))); + } + + /// The schemes it allows. + public IReadOnlyCollection Schemes => _schemes; + + /// + public bool IsAllowed(string url) + { + if (string.IsNullOrWhiteSpace(url)) return false; + if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) return false; + + // A relative URL or a bare fragment cannot name a scheme, so there is none to distrust. + if (!uri.IsAbsoluteUri) return true; + return _schemes.Contains(uri.Scheme); + } +} diff --git a/README.md b/README.md index 5352f79..a9d54ba 100644 --- a/README.md +++ b/README.md @@ -165,11 +165,13 @@ rendering opinions. | [`MarkupString.Html`](https://www.nuget.org/packages/MarkupString.Html) | Checked raw HTML markup with configurable tag policies for trusted and untrusted input. | | [`MarkupString.Mxp`](https://www.nuget.org/packages/MarkupString.Mxp) | The shared vocabulary expressed as client-supported MXP elements. | | [`MarkupString.Pueblo`](https://www.nuget.org/packages/MarkupString.Pueblo) | The shared vocabulary expressed through Pueblo client extensions. | +| [`MarkupString.Extensions.DependencyInjection`](https://www.nuget.org/packages/MarkupString.Extensions.DependencyInjection) | `AddMarkupString()` for ASP.NET Core and other `Microsoft.Extensions` hosts, with options bound from configuration. | ```sh dotnet add package MarkupString.Html dotnet add package MarkupString.Mxp dotnet add package MarkupString.Pueblo +dotnet add package MarkupString.Extensions.DependencyInjection ``` All packages share one version and are released together. @@ -228,6 +230,9 @@ reflection hidden behind convenience APIs. | [Formats and rendering](docs/formats.md) | Built-in formats, shared vocabulary, framers, untrusted tags, and custom formats. | | [Custom markup kinds](docs/custom-markup.md) | Implementing an `IMarkup`, emitter, and codec. | | [Serialization](docs/serialization.md) | JSON wire format, forward compatibility, and `UnknownMarkup`. | +| [Decoding](docs/decoding.md) | Reading server output: ANSI and VT sequences, OSC, events, replies, limits, and plugins. | +| [Display](docs/display.md) | Output capabilities, degrading, spans for a web page, and cell grids with hit testing. | +| [Dependency injection](docs/dependency-injection.md) | `AddMarkupString()`, configuration, a shared picture cache, link policy, metrics, and the interfaces to replace. | | [Releasing](docs/releasing.md) | Versioning and publishing for maintainers. | ## Requirements diff --git a/docs/decoding.md b/docs/decoding.md new file mode 100644 index 0000000..d7b08a1 --- /dev/null +++ b/docs/decoding.md @@ -0,0 +1,620 @@ +# Decoding server output + +A `Decoder` reads what a server sends (text with ANSI and VT sequences mixed in) and turns it into +`MarkupText` lines. Styling becomes markup runs, so a decoded line renders, measures, wraps and +serializes like any other `MarkupText`. Rendering a line for an output is covered in +[Display](display.md). + +```csharp +using MarkupString; +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; + +MarkupRegistry.Default = MarkupRegistry.Empty.WithAnsi(); + +// Once, at startup: which protocols to read. +var registry = DecoderRegistry.Empty.WithVt(); + +// One per connection: it holds that connection's style, half-read sequences and pending line. +var decoder = registry.CreateDecoder(new DecoderOptions +{ + Identity = new ClientIdentity { Name = "MyClient", Version = "2.1", Columns = 100, Rows = 40 }, +}); + +var replies = new List(); // send each of these to the server +decoder.EventRaised += e => +{ + switch (e) + { + case ReplyEvent reply: replies.Add(reply.Text); break; + case TitleEvent title: Console.WriteLine($"title: {title.Title}"); break; + } +}; + +foreach (var line in decoder.Feed("\e]2;The Hall\a\e[1;33mWelcome\e[0m back.\r\nExits: north\e[c\r\n> ")) +{ + Console.WriteLine($"{line.Sequence}: {line.Content.Render(MarkupFormat.Ansi)}"); +} + +// The telnet layer saw GA or EOR. +if (decoder.EndPrompt() is { } prompt) Console.WriteLine($"prompt: {prompt.Content}"); +``` + +``` +title: The Hall +0: \e[1;33mWelcome\e[0m back. +1: Exits: north +prompt: > +``` + +Output in this guide shows ESC as `\e` and BEL as `\a`. Events are raised while `Feed` reads, +before it returns the lines. The `\e[c` on the second line +asked for device attributes, so `replies` holds `\e[?62;22c`. + +The registry is immutable and shared. A decoder is not safe to use from two threads at once. + +## Lines + +`Feed` returns the lines its text completed, which may be none. A line ends at a line feed in the +text. Whatever is left is the pending line (`PendingLine`, `HasPendingLine`), and a sequence cut in +half by a packet boundary is finished by the next `Feed`: + +```csharp +var decoder = registry.CreateDecoder(); +decoder.Feed("\e[3"); // no lines +decoder.Feed("1mred\n"); // \e[31mred\e[0m +decoder.Feed("still red\n"); // \e[31mstill red\e[0m +``` + +The style carries from line to line, as it does on a terminal. Each line is rendered closed, so it +stands alone in scrollback. + +Two calls end a line for the host: + +| Call | When | Returns | +|---|---|---| +| `EndLine()` | Your telnet layer strips line terminators and knows where a line ended | The line, even when empty | +| `EndPrompt()` | Telnet GA or EOR | The line with `Kind = LineKind.Prompt`, or null when nothing was read | + +Both abandon a sequence still in flight. `Decoder.DecodeAll(text, registry)` decodes a whole string +at once and includes a last line that has no line break: + +```csharp +foreach (var line in Decoder.DecodeAll("one\ntwo", registry)) + Console.WriteLine($"{line.Kind} {line.Content}"); +``` + +``` +Output one +Output two +``` + +A `DisplayLine` carries `Content`, `Kind`, `Sequence` (its number within the decoder, from 0, for +keying a view) and `Destination` (the pane it was sent to, or null for the main output). + +`Reset()` forgets the style, open elements, half-read sequences and the pending line. Set +`decoder.Identity` again when the pane is resized; answers read it each time. + +## Events + +Anything that is not text to show is raised through `EventRaised`, as it is found. + +| Event | Raised for | +|---|---| +| `ReplyEvent(Text)` | An answer to a query. Send `Text` to the server exactly as it is. | +| `TitleEvent(Title, Icon)` | OSC 0, 1, 2. `Icon` is true for 0 and 1. | +| `PaletteEvent(Slot, Index, Color)` | OSC 4, 10, 11, 12 set a colour; 104, 110, 111, 112 reset one (`Color` null). | +| `ClipboardEvent(Selection, Text)` | OSC 52. `Text` is null when the server asked to read the clipboard. | +| `ProtocolEvent(Name, Active)` | A text protocol was turned on or off. | +| `VariableEvent(Name, Value)` | A server variable (MXP). | +| `DownloadEvent(Name, Size)` | A file offered to save (iTerm2 `inline=0`). The decoder never saves it. | + +The decoder acts on none of these. Refuse a clipboard write unless the player allowed it. + +## What the terminal plugins read + +`WithVt()` adds five plugins. `VtDecoding.Terminal` is a registry with just these. + +### `SgrPlugin` (`CSI … m`) + +| Codes | Effect | +|---|---| +| empty, `0` | Reset | +| `1`, `2`, `3` | Bold, faint, italic | +| `4`, `21` | Underline. `4:0` turns it off; `4:1` to `4:5` are all drawn as one underline. | +| `5`, `6` | Blink | +| `7`, `8`, `9` | Inverse, conceal, strikethrough | +| `22` | Neither bold nor faint | +| `23`, `24`, `25`, `27`, `28`, `29` | Turn off italic, underline, blink, inverse, conceal, strikethrough | +| `30`–`37`, `90`–`97` | Standard foreground, bright for 90–97 | +| `40`–`47`, `100`–`107` | Standard background, bright for 100–107 | +| `38`, `48` | Extended colour: `5;n`, `2;r;g;b`, and the colon forms `5:n`, `2:r:g:b`, `2::r:g:b`, `2:cs:r:g:b` | +| `39`, `49` | Default foreground, background | +| `53`, `55` | Overline on, off | +| `58` | Underline colour: its parameters are read and dropped | + +Any other code is ignored. An extended colour out of range leaves the colour as it was. The style +is written under the `VtPens.Style` pen as an `AnsiMarkup`. + +### `ControlsPlugin` + +| Input | Effect | +|---|---| +| BEL | A `BellMarkup` point | +| CR | Cursor to column 0 | +| BS | Cursor one cell left, stopping at 0 | +| HT | Cursor to the next multiple of 8 (`ControlsPlugin.TabStop`) | +| FF | A `ClearScreenMarkup` point | +| VT | Ends the line | +| `CSI K` | `0` erase to end of line, `1` blank to the cursor, `2` erase the line. Other values are not claimed. | +| `CSI J` | `2` or `3` writes a `ClearScreenMarkup` point. `0` and `1` are claimed and do nothing: a scrolling pane has no screen below the cursor. | +| `CSI G`, ``CSI ` `` | Cursor to column n (from 1) | +| `CSI C`, `CSI D` | Cursor n cells right, left | +| `ESC 7`, `ESC 8` | Save the cursor's column, and go back to it | +| `CSI A`, `ESC D`, `ESC M` | Claimed and ignored: a scrolling pane cannot go up a line, or down one without ending it | + +A line feed always ends the line, with or without this plugin. DEL is dropped. C1 controls +(U+0080 to U+009F) are text, as xterm treats them in UTF-8. CAN and SUB cancel a sequence in flight. + +### `CharsetPlugin` + +`ESC ( 0` and `ESC ) 0` designate the DEC special graphics set into G0 or G1; `ESC ( B` and +`ESC ) B` put ASCII back. SO and SI shift between G0 and G1. While graphics is in force, `_` to `~` +become line drawing and symbols: + +```csharp +Decoder.DecodeAll("\e(0lqqk\e(B ok", registry)[0].Content; // ┌──┐ ok +``` + +### `OscPlugin` + +| OSC | Effect | +|---|---| +| `0`, `1`, `2` | `TitleEvent`, control characters removed | +| `4;n;spec…` | `PaletteEvent` for each index and colour pair | +| `104` | Reset the listed entries, or every entry (`Index = -1`) when empty | +| `10`, `11`, `12` | Set the foreground, background, cursor colour, or answer `?` (10 and 11 only). `10;a;b` sets 10 and 11 at once. | +| `110`, `111`, `112` | Reset them | +| `52;sel;base64` | `ClipboardEvent` with the decoded text, or a null text for `?`. Invalid base64 is dropped. | +| `8;params;uri` | A link over the text that follows, until an empty uri. An address longer than 2083 characters, or one `DecoderOptions.Links` refuses (`UrlPolicy.Safe` unless set, which refuses `javascript:` and the like), ends the link instead. An `id=` among the params becomes a `LinkGroupMarkup`, and so `Interaction.Group`: the text under it is one link even across lines. | +| `68;1;SEND;command` | An MSLP command link over the underlined text that follows. Turning the underline off ends it, and so does the end of the line. | + +Colours are X11 specs: `rgb:r/g/b` with one to four hex digits a channel, or `#rgb`, `#rrggbb`, +`#rrrgggbbb`, `#rrrrggggbbbb`. `OscPlugin.ParseColor` and `FormatColor` are public. Links are +written under the `VtPens.Link` pen, an OSC 8 `id` under `VtPens.LinkGroup`. + +### `AnswersPlugin` + +| Query | Reply | Allowed by | +|---|---|---| +| `CSI c`, `CSI 0 c` | `\e[?62;22c`, or `\e[?62;4;22c` when `Identity.Output.Pictures` has `Sixel` | `Identity` | +| `CSI > q` | `\eP>\|Name Version\e\\` (XTVERSION) | `Identity` | +| `CSI 5 n` | `\e[0n` | `Identity` | +| `CSI 14 t`, `16 t`, `18 t` | The pane in pixels, a cell in pixels, the pane in cells | `Size` | +| `OSC 10 ; ?`, `OSC 11 ; ?` | `Identity.Foreground`, `Background` as `rgb:rrrr/gggg/bbbb`, when set | `Colors` | +| `CSI 6 n` | `\e[{Rows};{column + 1}R` | `CursorPosition` | + +A query whose answer is not allowed is still claimed, and nothing is sent. + +## Overwriting + +A carriage return or backspace moves the cursor back, and what is written next overwrites cells in +place. A progress bar redrawn over itself ends as its last state. + +| Input | Line | +|---|---| +| `Loading 10%\rLoading 100%` | `Loading 100%` | +| `abcdef\rXY` | `XYcdef` | +| `abc\b\bX` | `aXc` | +| `hello world\r\e[Kbye` | `bye` | +| `a\tb` | `a b` | +| `a\e[10Gb` | `a b` | +| `漢字\rX` | `X 字` | + +Moving the cursor past the end of the line pads with spaces only when text follows, so a tab at the +end of a line leaves no trailing blanks. The style of overwritten cells is replaced with the new text's. A wide character that is only partly +overwritten is blanked where it is not covered, as a terminal blanks it. + +## Limits and diagnostics + +Every buffer has a cap. `VtLimits` holds the tokenizer's: + +| Property | Default | | +|---|---|---| +| `MaxParameters` | 32 | Parameters in a CSI or DCS header, subparameters included | +| `MaxParameterValue` | 65535 | Larger values are clamped | +| `MaxStringLength` | 16 MiB | An OSC or DCS payload | +| `MaxApcLength` | 64 KiB | An APC payload | + +A sequence that overflows is read to its end and dropped. A line feed ends any sequence in flight, +so a bare `ESC ]` typed into a channel cannot swallow the rest of the session. + +`DecoderOptions` holds the decoder's: + +| Property | Default | | +|---|---|---| +| `MaxOpenElements` | 64 | Elements a text protocol may have open at once | +| `MaxLineLength` | 65536 | Characters in a line. A longer line is ended at the limit and the rest starts the next line. | +| `Links` | `UrlPolicy.Safe` | Which link addresses a server's links may have; any other is read as text | +| `Meters` | null | The `IMeterFactory` the diagnostics are reported to; null uses a shared meter | + +`decoder.Diagnostics` counts what was dropped: + +```csharp +var decoder = registry.CreateDecoder(); +decoder.Feed("\e[?25lHello\e]0;never closed\nworld\u0001\n"); +// AbandonedSequences = 1 the OSC cut off by the line feed +// UnhandledSequences = 1 CSI ? 25 l, which no plugin claims +// UnhandledControls = 1 U+0001 +``` + +`LimitsReached` counts lines broken at the limit and elements refused. `PicturesFailed` counts pictures that +could not be decoded. Each count is also reported as the metric `markupstring.decoding.dropped` on the +`MarkupString.Decoding` meter, tagged with its `kind`; see [Dependency injection](dependency-injection.md#metrics). + +`DecoderRegistry` is an `IDecoderFactory`, so code that makes decoders can take the interface and a +test or host can give it another. + +## Client identity and answers + +`ClientIdentity` is what the decoder answers with: `Name`, `Version`, `Columns`, `Rows`, +`CellWidth` and `CellHeight` (8 by 16 by default), `Foreground`, `Background`, and `Output`, the +`OutputCapabilities` of where the session is shown. + +`Answers` says which questions get an answer. `ClientAnswers.Default` is everything except +`CursorPosition`. A server that sends `CSI 6 n` assumes a full screen it can address, and a scrolling +pane is not one: the reply always gives the last row. Turn it on for a server you know uses it only +to find the column: + +```csharp +var decoder = registry.CreateDecoder(new DecoderOptions +{ + Identity = new ClientIdentity + { + Answers = ClientAnswers.Default | ClientAnswers.CursorPosition, + Background = new RgbColor(0, 0, 0), + }, +}); +decoder.Feed("\e[c\e[>q\e[18t\e]11;?\a> \e[6n"); +``` + +The replies, in order: + +``` +\e[?62;22c +\eP>|MarkupString 1.0\e\ +\e[8;24;80t +\e]11;rgb:0000/0000/0000\e\ +\e[24;3R +``` + +`ClientAnswers.None` answers nothing. + +## MTTS + +`ToMtts` gives the bits for the last step of the telnet TTYPE cycle, from `Output`: + +| Bit | Set when | +|---|---| +| `Vt100`, `Utf8` | Always, but `Utf8` is cleared for `UnicodeLevel.Ascii` | +| `Ansi` | `Colors` is `Standard` or better | +| `Colors256`, `OscColorPalette` | `Xterm256` or better | +| `TrueColor` | `TrueColor` | +| `Mslp` | `Interactions` has `Command` | +| `ScreenReader`, `Ssl` | You pass `screenReader: true`, `tls: true` | + +```csharp +var identity = new ClientIdentity { Output = OutputCapabilities.Browser }; +identity.ToMtts(tls: true); // Ansi, Vt100, Utf8, Colors256, OscColorPalette, TrueColor, Mslp, Ssl +(int)identity.ToMtts(tls: true); // 3375, sent as "MTTS 3375" +``` + +The default `Output` is `OutputCapabilities.PlainText`, which gives only `Vt100, Utf8`. + +## Writing a plugin + +A plugin is an `IDecoderPlugin`: a name and a `Register` method that adds handlers to a +`DecoderBuilder`. + +```csharp +sealed class CursorPlugin : IDecoderPlugin +{ + public string Name => "cursor"; + + // A scrolling pane has no cursor to show or hide: claim CSI ? 25 h and CSI ? 25 l so they are not counted. + public void Register(DecoderBuilder builder) => builder + .OnCsi('h', static (in CsiSequence sequence, DecodeContext context) => sequence.Get(0) == 25, prefix: '?') + .OnCsi('l', static (in CsiSequence sequence, DecodeContext context) => sequence.Get(0) == 25, prefix: '?'); +} + +var registry = DecoderRegistry.Empty.WithVt().With(new CursorPlugin()); +``` + +| Method | Handles | +|---|---| +| `OnCsi(final, handler, prefix, intermediates)` | A control sequence | +| `OnEsc(final, handler, intermediates)` | An escape sequence | +| `OnOsc(number, handler)` | An OSC; the handler gets what follows the number and `;` | +| `OnDcs(final, handler, prefix, intermediates)` | A device control string | +| `OnApc(handler)` | Every APC, until one handler claims it | +| `OnControl(c, handler)` | A C0 control | +| `OnPrint(filter)` | Printable text before it is written; return true when the filter wrote it | +| `OnLineEnd(action)` | Every line end, after the active text protocol's `EndLine` | +| `TextProtocol(name, create)` | A protocol carried in the text, such as MXP | + +`CsiSequence.Get(index, fallback)` reads a parameter, giving `fallback` when it is missing or empty. +`IsSubparameter(index)` says whether it followed a `:`. `Is(final, prefix)` tests the sequence's name. + +`registry.With(plugin)` replaces a plugin of the same name. `Has(name)` and `Plugins` list what is +installed. + +### Handler order + +For one sequence, the newest handler is tried first: handlers from a plugin added later run before +an earlier plugin's, and within a plugin a later call runs before an earlier one. A handler returns +true to claim the sequence and false to pass it to the next older handler. A sequence nobody claims +is counted in `UnhandledSequences`. + +This lets a plugin change one case of a sequence and leave the rest alone: + +```csharp +sealed class BellCount +{ + public int Rung { get; set; } +} + +sealed class QuietBellPlugin : IDecoderPlugin +{ + public string Name => "quiet-bell"; + + // Added after WithVt, so it sees each bell before ControlsPlugin does. + public void Register(DecoderBuilder builder) => builder.OnControl('\a', static (_, context) => + { + var bells = context.State(); + bells.Rung++; + return bells.Rung > 1; // true swallows it; false lets ControlsPlugin write the bell + }); +} +``` + +Line end handlers are the exception: they all run, in the order they were added. + +### Per-connection state + +The plugin instance is shared by every decoder of the registry, so it holds no state of its own. +`context.State()` gives this decoder's instance of `T`, made on first use and cleared by +`Reset()`. The host can read the same instance with `decoder.State()`. + +### Pens and elements + +Text is written under the markup in force, which is built from two things: + +- **Pens** are named slots a protocol sets and replaces: `context.SetPen(slot, markup)`, `Pen(slot)`, + and `SetPen(slot, null)` to clear. SGR is a pen (`VtPens.Style`); so is a link (`VtPens.Link`). +- **Elements** are a stack a tag protocol opens and closes: `Open(markup)`, `Close(markup)` with + the same instance, which also closes anything opened after it, and `CloseTo(count)`. `Open` returns + false at `MaxOpenElements`. + +Pens sit inside every element, so an SGR colour inside an element wins, as it does in an MXP client. + +Besides `Print`, a handler can `Write` text as it is (skipping print filters), `Write(text, inner)` +with one more layer inside, `Point(point)`, move the cursor (`CarriageReturn`, `Backspace`, +`MoveToColumn`), erase (`EraseToEndOfLine`, `EraseToStartOfLine`, `EraseLine`), `EndLine()`, set +`Destination` for the lines that follow, `Raise(event)` and `Reply(text)`. + +### Text protocols + +A text protocol reads every piece of printable text while it is on, and prints what is literal with +`context.Print`. Only one is on at a time. This one makes `*text*` bold: + +```csharp +sealed class StarsPlugin : IDecoderPlugin +{ + public string Name => "stars"; + + public void Register(DecoderBuilder builder) => builder.TextProtocol("stars", () => new StarsProtocol()); +} + +sealed class StarsProtocol : ITextProtocol +{ + private readonly IMarkup _bold = AnsiMarkup.Create(bold: true); + private bool _open; + + public void Feed(ReadOnlySpan text, DecodeContext context) + { + while (true) + { + var star = text.IndexOf('*'); + if (star < 0) + { + context.Print(text); + return; + } + + context.Print(text[..star]); + if (_open) Close(context); + else _open = context.Open(_bold); + text = text[(star + 1)..]; + } + } + + public void EndLine(DecodeContext context) => Close(context); + + public void Reset(DecodeContext context) => Close(context); + + private void Close(DecodeContext context) + { + if (_open) context.Close(_bold); + _open = false; + } +} +``` + +The registry offers the protocol; the decoder makes an instance the first time it is turned on, +typically after telnet negotiates it: + +```csharp +var decoder = DecoderRegistry.Empty.WithVt().With(new StarsPlugin()).CreateDecoder(); +decoder.EnableProtocol("stars"); // raises ProtocolEvent { Name = stars, Active = True } +decoder.Feed("a *bold \e[31mred\e[0m bold* plain *open\n")[0].Content.Render(MarkupFormat.Ansi); +``` + +``` +a \e[1mbold \e[31mred\e[0m\e[1m bold\e[0m plain \e[1mopen\e[0m +``` + +The SGR reset inside the element ends the red pen, not the bold element. `EndLine` closed the +element left open, so the next line starts plain. `EnableProtocol` returns false when the registry +offers no protocol of that name; `ActiveProtocol` names the one that is on. + +## Pictures + +`WithPictures()` adds `PicturesPlugin`, which reads Kitty graphics (`APC G`), iTerm2 inline images +(`OSC 1337 File=`, and `MultipartFile=`/`FilePart=`/`FileEnd`) and sixel (`DCS q`). Each picture +becomes an `ImageMarkup` over the cells it covers, one `PictureRow` per line, so a cell renderer +draws it and the ANSI writer can send it on to another terminal in whatever way that terminal +draws pictures. Kitty to Kitty, sixel to Kitty, and iTerm2 to half blocks all go through the same +markup. + +```csharp +var registry = DecoderRegistry.Empty.WithVt().WithPictures(); +var decoder = registry.CreateDecoder(new DecoderOptions +{ + Identity = new ClientIdentity { CellWidth = 8, CellHeight = 16, Columns = 100 }, +}); + +var lines = decoder.Feed(wire); +var store = decoder.Pictures(); // this connection's PictureStore +foreach (var image in lines.SelectMany(l => l.Content.Runs).SelectMany(r => r.Markups).OfType()) +{ + if (store.TryGet(image, out var picture)) Console.WriteLine($"{picture.Width}x{picture.Height} {picture.MediaType}"); +} +``` + +The pixels live in the decoder's `PictureStore`, and an image's `Source` is `picture:` and a key +(a hash of the picture, so the same picture sent twice is stored once). The store: + +- keeps its pictures in an `IPictureCache`: by default a `MemoryPictureCache` of its own, which holds + `Budget` bytes (64 MiB) and drops the least recently used first. A line whose picture is gone shows + its text instead. +- shares one cache between connections with `WithPictures(cache)`, so a picture every player is sent + is decoded and kept once. A file already in the cache is not decoded again. The cache can be your + own (on disk, in Redis); it must be safe to call from several threads. A connection that adds + frames to a picture does so on a copy under a key of its own, so the still picture other lines + show does not change. +- refuses a picture of more than `MaxPixels` (4096 × 4096). +- is the `ITerminalPictureSource` to give `AnsiOutputOptions.Pictures`, so decoded pictures can be + written to a terminal again. +- gives `StoredPicture.ToDataUrl()` for an ``, and raises `Added` so a host can serve pictures + from its own address instead. A picture already in a shared cache is not raised again. + +```csharp +var cache = new MemoryPictureCache { Budget = 256L * 1024 * 1024 }; // one for the whole server +var registry = DecoderRegistry.Empty.WithVt().WithPictures(cache); +var decoder = registry.CreateDecoder(); // per connection, same cache +``` + +PNG and sixel are decoded here, and Kitty's raw RGB and RGBA, zlib-compressed or not. A GIF, JPEG or +WebP sent as an iTerm2 image is kept as the file with no pixels: a browser shows it, a terminal gets +its text. + +A picture's size in cells comes from its pixels and `ClientIdentity.CellWidth` and `CellHeight`, +unless the sequence says otherwise (Kitty `c` and `r`, iTerm2 `width` and `height` in cells, `px` +or `%`). It is never wider than `ClientIdentity.Columns`. + +Placement follows the terminal: the first row beside the text already on the line, each further row +on its own line, the cursor below the picture afterwards. A picture drawn between `ESC 7` and +`ESC 8`, or Kitty's `C=1`, is drawn over the lines that follow instead, which is how a server writes +a picture beside text and how this package's own ANSI writer sends iTerm2 and sixel pictures. + +Kitty specifics: + +- Only `t=d` (the picture in the sequence) is read. A file, temporary file or shared memory is + refused with `EINVAL`, since the server's files are not the client's. +- `a=t`, `a=T`, `a=p`, `a=q` and `a=d` are read, and so are animation frames. `a=f` adds a frame, + or edits frame `r`: built on a copy of frame `c` (or a blank filled with `Y`), its data put at `x`, + `y` and blended over what is there unless `X=1`, shown for `z` ms (40 unless given). `a=a` with `r` + and `z` sets a frame's time. Playing, stopping and looping are left to the client, and so is + composing (`a=c`), which is counted as unhandled. At most `PicturesPlugin.MaxFrames` (1000) frames + are kept. A picture with frames is a `StoredPicture` whose `IsMoving` is true; its `Pixels.Frames` + grow as frames arrive, and `Encoded` and `ToDataUrl()` give it as a looping GIF. +- Unicode placeholders (U+10EEEE with row and column diacritics, the image id in the foreground + colour) draw a picture transmitted with `U=1`. That is how this package's writer sends Kitty + pictures, so its output reads back cell for cell. +- Replies (`\e_Gi=…;OK\e\\`) follow `q` and need `ClientAnswers.Pictures`. A picture sent with + neither `i` nor `I` gets none. + +An iTerm2 file with `inline=0` is not shown: a `DownloadEvent` says it was offered. Nothing is saved. + +## MXP + +`MarkupString.Mxp` adds `WithMxp()` for a `DecoderRegistry`. MXP is a text protocol, off until the +host turns it on after telnet negotiates option 91: + +```csharp +using MarkupString.Mxp.Decoding; + +var registry = DecoderRegistry.Empty.WithVt().WithMxp(); +var decoder = registry.CreateDecoder(); +decoder.EnableProtocol(MxpDecoding.Protocol); + +decoder.Feed("\e[1zsword\n"); +``` + +Line modes (`ESC [ n z`) decide what a line may use. Until MXP is on they are claimed and ignored. + +| Mode | | +|---|---| +| `0`, `1`, `2` | This line is open, secure or locked | +| `3` | Close every MXP element and go back to open | +| `4` | The next tag is secure | +| `5`, `6`, `7` | Open, secure or locked from now on | +| `10`, `11`, `12`, `19` | This line is a room name, description, exits or welcome text: an `MxpFlagMarkup` over it | +| `20`–`99` | This line is the element the server tied to that number with `TAG=` | + +On an open line only the open tags work: `B`, `I`, `U`, `S`, `H`, `C`/`COLOR`, `FONT`, `BR`, `SBR`, +`P`, `NOBR`. A secure tag there is dropped and counted in `UnhandledSequences`. On a locked line `<` +and `&` are text. Elements opened on an open line close at its end. + +What the tags become: + +| Tag | Markup | +|---|---| +| `B`, `I`, `U`, `S`, `H`, `COLOR`, `FONT` | An `AnsiMarkup`. SGR inside an MXP colour wins, as in an MXP client. | +| `A href hint` | A URL link, when `DecoderOptions.Links` allows the address | +| `SEND href hint prompt` | A command link. With several commands (`a\|b`) or `PROMPT`, a `CommandMenuMarkup` as well. With no `href`, or `&text;` in it, the command is the tag's text. | +| `IMAGE fname url w h align` | An `ImageMarkup`. Sized in cells (`W=4c H=2c`), it is drawn over the cells the server leaves for it. | +| `SOUND`, `MUSIC` | `SoundMarkup` or `SoundStopMarkup` points | +| `FRAME`, `DEST` | A `PaneMarkup` over the text in `DEST`, titled from its `FRAME` | +| `VAR name` | A `VariableMarkup` over its text, and a `VariableEvent` | +| `EXPIRE`, `RELOCATE`, `USER`, `PASSWORD` | Their points | +| `VERSION`, `SUPPORT` | Answered (`\e[1z`, `\e[1z`) when `ClientAnswers.Mxp` allows | + +`` defines an element from other tags, with `ATT` attributes substituted for `&name;`, +`FLAG` for an `MxpFlagMarkup`, `OPEN`, `EMPTY` and `TAG`. `` defines an entity, used as +`&name;` and published as a `VariableEvent` unless `PRIVATE`. `` and `` are read +too. To serialise lines with `MxpFlagMarkup`, add `MxpDecoding.FlagCodec` to the `MarkupRegistry`. + +What `MarkupFormat.Mxp` writes reads back to the same markup. + +## Pueblo + +`MarkupString.Pueblo` adds `WithPueblo()`. Turn the protocol on when the server greets the client +(`PuebloDecoding.IsGreeting(line)`) and the client has answered `PUEBLOCLIENT`. + +The server starts in MUD text, where tags are text. `` or `` +switches to HTML, where a line ends at `
` and a bare line feed is whitespace; `` +switches back, and its region is a `PreformattedMarkup`. ANSI is read in both. + +| Tag | Markup | +|---|---| +| `b`, `i`, `u`, `s`, `font color` | An `AnsiMarkup` | +| `a xch_cmd xch_hint`, `a href` | A command or URL link | +| `img src alt` | An `ImageMarkup` over the alt text | +| `img xch_sound=play\|loop\|stop href xch_volume` | `SoundMarkup` or `SoundStopMarkup` | +| `xch_pane action=redirect name panetitle` | A `PaneMarkup` until the next redirect | +| `xch_page clear=text`, `xch_prefetch href` | `ClearScreenMarkup`, `PrefetchMarkup` | +| `br`, `p`, `hr`, `pre` | Line breaks, and a `PreformattedMarkup` for `pre` | + +What `MarkupFormat.Pueblo` writes reads back to the same markup. diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md new file mode 100644 index 0000000..b0295ab --- /dev/null +++ b/docs/dependency-injection.md @@ -0,0 +1,91 @@ +# Dependency injection + +Everything in MarkupString can be built by hand: the registries are immutable values, and options are +records. A host that uses `Microsoft.Extensions.DependencyInjection` (ASP.NET Core, the generic host) +can instead register it all at once with `MarkupString.Extensions.DependencyInjection`. + +```csharp +builder.Services.AddMarkupString() + .BindConfiguration() // the "MarkupString" section + .AddDecoders(registry => registry.WithMxp().WithPueblo()) + .AddMarkup(registry => registry.WithHtml().WithMxp().WithPueblo()); +builder.Services.AddMetrics(); // optional: decode metrics on the host's meter factory +``` + +Then, for each connection: + +```csharp +public sealed class Connection(MarkupDecoderFactory decoders, IStyleResolver styles, DisplayOptions display) +{ + private readonly Decoder _decoder = decoders.CreateDecoderFor(new ClientIdentity { Columns = 100 }); + + public IEnumerable> Receive(string text) => + _decoder.Feed(text).Select(line => SpanRenderer.Render(line.Content, styles, display)); +} +``` + +## What is registered + +Every service is a singleton, added only when nothing of that type is registered yet, so registering +your own first replaces it. + +| Service | What it is | +|---|---| +| `MarkupRegistry` | `MarkupRegistry.Empty.WithAnsi().WithLinks(policy)` and each `AddMarkup` step | +| `DecoderRegistry` | `WithVt()`, `WithPictures(cache)` unless pictures are off, and each `AddDecoders` step | +| `MarkupDecoderFactory`, `IDecoderFactory` | Makes a decoder per connection with the configured limits, link policy, metrics and picture size | +| `IPictureCache` | One `MemoryPictureCache` that every connection shares | +| `DisplayRegistry`, `IStyleResolver` | `DisplayRegistry.Core.WithAnsi()` and each `AddDisplay` step | +| `DisplayOptions` | The configured output, colour level and link policy | +| `IUrlPolicy` | `UrlPolicy.WithSchemes(LinkSchemes)` | + +`UsePictureCache()` and `UseUrlPolicy()` swap in your own as singletons. A cache of your own +(on disk, in Redis) must be safe to call from several threads; see [Pictures](decoding.md#pictures). + +## Configuration + +`AddMarkupString(options => …)` sets `MarkupStringOptions` in code, and `BindConfiguration(path)` +binds it from configuration. The binder is source-generated, so it works in a native AOT app. + +```json +{ + "MarkupString": { + "LinkSchemes": [ "mudclient" ], + "Decoding": { "MaxLineLength": 65536, "MaxOpenElements": 64, "AnswerCursorPosition": false }, + "Pictures": { "Enabled": true, "Budget": 268435456, "MaxPixels": 16777216 }, + "Display": { "Output": "Browser", "Colors": "Xterm256", "BoldIsBright": false } + } +} +``` + +`Display:Output` is `Browser`, `CellGrid` or `PlainText`; `Display:Colors`, when set, lowers its +colour level. The options are read once, when each service is first made. + +## The interfaces underneath + +These are in the core packages and need no container. + +- `IDecoderFactory`: `CreateDecoder(options)`. `DecoderRegistry` implements it. +- `IStyleResolver`: `Resolve(markups, options)`. `DisplayRegistry` implements it, and `SpanRenderer` + and `CellRenderer` take one. +- `IUrlPolicy`: which link addresses are allowed. `UrlPolicy.Safe` is the default; + `UrlPolicy.WithSchemes("mudclient")` adds schemes, never `javascript`, `data`, `vbscript` or `file`. + Set it as `DecoderOptions.Links` (what a server's links become), `DisplayOptions.Links` (what is + drawn as clickable), `MarkupRegistry.WithLinks` (what every writer, ANSI, HTML, MXP, Pueblo, BBCode + and Markdown, writes as a link) and `HtmlTagPolicy.Links` (which addresses a checked tag may hold). + `AddMarkupString` sets the first three to the registered policy. +- `IPictureCache`: where decoded pictures are kept; `WithPictures(cache)` shares one. +- `IColorResolver`: how palette colours become RGB, as `DisplayOptions.Colors`. + +## Metrics and logging + +Each decoder reports what it drops on the `MarkupString.Decoding` meter, as the counter +`markupstring.decoding.dropped` with a `kind` tag: `abandoned-sequence`, `unhandled-sequence`, +`unhandled-control`, `limit` or `picture`. With `DecoderOptions.Meters` (the factory sets it to the +host's `IMeterFactory` when one is registered) the meter comes from that factory, so +`dotnet-counters` and OpenTelemetry see it with the host's other meters. + +`decoder.Diagnostics.Dropped` is raised with a `DecodeDrop` and a count each time one goes up. When the +host registers an `ILoggerFactory`, the factory logs each one at `Debug` under the category +`MarkupString.Decoding` (event 1, `Decoder dropped {Count} × {Kind}`); it subscribes only when that +level is on, so a quiet host pays nothing. diff --git a/docs/display.md b/docs/display.md new file mode 100644 index 0000000..16bdb41 --- /dev/null +++ b/docs/display.md @@ -0,0 +1,348 @@ +# Display + +A decoded line is a `MarkupText` (see [Decoding](decoding.md)). To show it, fold each run's markup +into a `DisplayStyle` (RGB colours, attributes, what a click does) degraded to what the output can +show. `SpanRenderer` gives runs of text for a web page; `CellRenderer` gives a grid of cells for a +window that draws its own. + +```csharp +using System.Net; +using MarkupString; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; + +var decoders = DecoderRegistry.Empty.WithVt(); +var display = DisplayRegistry.Core.WithAnsi(); +var options = new DisplayOptions { Capabilities = OutputCapabilities.Browser }; + +var line = Decoder.DecodeAll("\e[1;31mDanger\e[0m: see \e]8;;https://example.com/help\e\\help\e]8;;\e\\", decoders)[0]; + +var html = new System.Text.StringBuilder(); +foreach (var span in SpanRenderer.Render(line.Content, display, options)) +{ + if (span.Point is not null) continue; + var css = SpanRenderer.Css(span.Style); + var classes = SpanRenderer.CssClasses(span.Style); + var encoded = WebUtility.HtmlEncode(span.Text); + if (span.Style.Interaction is { Kind: InteractionKind.Url } link) + html.Append($"{encoded}"); + else if (css.Length == 0 && classes.Length == 0) + html.Append(encoded); + else + html.Append($"{encoded}"); +} +``` + +```html +Danger: see help +``` + +`Decoder` clashes with `System.Text.Decoder`, so a file that imports `System.Text` has to qualify one +of them. + +## What an output can show + +`OutputCapabilities` describes one output: + +| Property | Type | Default | +|---|---|---| +| `Colors` | `ColorLevel`: `None`, `Attributes`, `Standard`, `Xterm256`, `TrueColor` | `TrueColor` | +| `Attributes` | `TextAttributes` flags: bold, faint, italic, underline, blink, inverse, conceal, strikethrough, overline | `All` | +| `Interactions` | `InteractionKinds` flags: `Url`, `Command`, `Prompt`, `Menu` | `All` | +| `Pictures` | `PictureMethods` flags: `Bitmap`, `KittyPlaceholders`, `InlineImages`, `Sixel`, `HalfBlocks` | `None` | +| `MovingPictures`, `Sound`, `Panes` | `bool` | `false` | +| `Unicode` | `UnicodeLevel`: `Full`, `Narrow`, `Ascii` | `Full` | + +`ColorLevel.None` means no attributes either; `Attributes` means attributes without colour. + +Three presets: + +| Preset | Differs from the defaults | +|---|---| +| `PlainText` | No colours, no attributes, no interactions | +| `Browser` | `Bitmap` pictures, moving pictures, sound, panes | +| `CellGrid` | `Bitmap` and `HalfBlocks` pictures, moving pictures, sound, panes | + +Change one with `with`: + +```csharp +var capabilities = OutputCapabilities.Browser with { Colors = ColorLevel.Xterm256, Attributes = TextAttributes.All & ~TextAttributes.Blink }; +``` + +A terminal's capabilities come from its `TerminalProfile` and the colour depth you settled on: + +```csharp +var terminal = TerminalProfile.Kitty.ToOutputCapabilities(AnsiColorDepth.Xterm256); +// Colors = Xterm256, Interactions = Url, Pictures = KittyPlaceholders, HalfBlocks, MovingPictures = True +``` + +`ToOutputCapabilities` sets `Url` from `Hyperlinks`, `Command` from `CommandLinks`, and pictures from +`KittyGraphics`, `InlineImages`, `Sixel` and `BlockArt`. It never sets sound or panes. + +`Intersect` gives what both outputs can show: the lesser colour and Unicode level, and the shared +flags. `Union` gives what either can, for a client that shows one session in two places and tells +the server once. + +```csharp +var both = OutputCapabilities.Browser.Intersect(terminal); // Xterm256, Url, no pictures, no sound +var either = OutputCapabilities.Browser.Union(terminal); // TrueColor, All, Bitmap | KittyPlaceholders | HalfBlocks, sound +``` + +`BestPictureMethod` is the first of `Bitmap`, `KittyPlaceholders`, `InlineImages`, `Sixel`, +`HalfBlocks` that the output has. + +## The display registry + +A `DisplayRegistry` is the list of `IStyleContributor`s that fold markup into a style. Build it once +and share it. + +| Registry | Knows | +|---|---| +| `DisplayRegistry.Empty` | Nothing: every run is plain | +| `DisplayRegistry.Core` | The shared vocabulary (`VocabularyStyles`): `ImageMarkup`, `PaneMarkup`, `CommandMenuMarkup`, `HighlightMarkup` | +| `Core.WithAnsi()` | Also `AnsiStyles`: every layer that is an `IAnsiStyleSource` (SGR, an MXP colour, an HTML ``) | +| `AnsiDisplay.Default` | The same as `Core.WithAnsi()` | + +`WithAnsi(boldIsBright: true)` draws bold text in a standard colour in its bright version, as many +MUD clients do: `\e[1;31m` becomes `#ff5555` instead of `#aa0000`. + +Contributors run in the order they were added, each writing into one `StyleBuilder`. Within a +contributor the innermost layer has the last word. `AnsiStyles` sets the foreground and attributes, +but leaves a background or interaction an earlier contributor set, so a `HighlightMarkup` over SGR +text keeps its background and a command menu keeps its menu. + +A contributor of your own reads the layers it knows and leaves the rest: + +```csharp +sealed class ChatColor : IStyleContributor +{ + public void Contribute(MarkupSet markups, StyleBuilder style, DisplayOptions options) + { + foreach (var markup in markups) + { + if (markup is PaneMarkup { Name: "chat" }) style.Foreground ??= new RgbColor(150, 200, 255); + } + } +} + +var display = DisplayRegistry.Core.WithAnsi().With(new ChatColor()); +``` + +Added last with `??=`, it colours chat text only where the server gave no colour. + +`display.Resolve(markups, options)` folds one `MarkupSet` and degrades it. The renderers call it once +per markup set in a text. + +## Options + +`DisplayOptions`: + +| Property | Default | | +|---|---|---| +| `Capabilities` | `Browser` | What the output can show | +| `Colors` | null | An `IColorResolver`; null uses the xterm palette | +| `DefaultForeground` | `(229, 229, 229)` | Used when inverse text has no colour of its own | +| `DefaultBackground` | `(0, 0, 0)` | Used when inverse text has no background of its own | +| `Links` | `UrlPolicy.Safe` | Which link addresses are drawn as clickable; any other stays text | +| `TabWidth` | 8 | Cells between tab stops, in `CellRenderer` | + +`SpanRenderer` and `CellRenderer` take an `IStyleResolver`, which a `DisplayRegistry` is, so a host can +give them its own. + +An `IColorResolver` decides what "red" is. Subclass `AnsiColors` to change part of the palette: + +```csharp +sealed class Theme : AnsiColors +{ + public override RgbColor Standard(int index, bool bright) => + index == 1 && !bright ? new RgbColor(220, 50, 47) : base.Standard(index, bright); + + // Keep text readable when the server paints it on its own colour. + public override RgbColor Legible(RgbColor foreground, RgbColor background) => + foreground == background ? new RgbColor(255, 255, 255) : foreground; +} + +var themed = new DisplayOptions { Colors = new Theme() }; +var text = Decoder.DecodeAll("\e[31mred\e[0m \e[34;44msame\e[0m", decoders)[0].Content; +``` + +``` +"red" color:#dc322f +" " +"same" color:#ffffff;background-color:#0000aa +``` + +`Legible` is called only when `Colors` is set, with the final foreground and the background +(`DefaultBackground` when there is none). + +## Degrading + +`Resolve` drops what the output cannot show, in this order: + +- **Colours.** At `ColorLevel.Attributes` or `None` both colours are dropped. At `Standard` each colour + becomes the nearest of the sixteen, and at `Xterm256` the nearest of the 256 (after the inverse swap + below), by redmean distance, as `Colors` draws them (the xterm palette when it is null). The colour + `Legible` returns is reduced the same way. At `TrueColor` RGB passes through. +- **Attributes.** Those the output lacks are dropped. At `ColorLevel.None` every attribute is. +- **Prompts.** A `CommandMenuMarkup` with `Prompt` set is a `Prompt` interaction. +- **Inverse.** With colour, foreground and background are swapped, filling in `DefaultForeground` and + `DefaultBackground`; `Inverse` stays set for information. At `Attributes` the flag is kept for the + output to draw when it has `TextAttributes.Inverse`, and dropped when it does not. +- **Link groups.** A `LinkGroupMarkup` (as an OSC 8 `id` makes) sets the `Group` of the interaction + it is over, the innermost winning, whichever contributor made the link. +- **Interactions.** Menu choices go when the output has no `Menu`. A prompt link becomes a command + link on an output with `Command` but not `Prompt`. Any other interaction the output lacks is dropped, + and the text stays. +- **Pictures** are dropped when `Pictures` is `None`, leaving the picture's text. +- **Panes** are dropped when `Panes` is false, so the text shows in the main output. + +The renderers also fold text with `AsciiFold` at `UnicodeLevel.Ascii`, draw a wide character as `?` +in cells at `Narrow`, and drop sound points (`SoundMarkup`, `SoundStopMarkup`) when `Sound` is false. +Other points, such as a bell, are kept. + +The same line for three outputs: + +```csharp +var sample = Decoder.DecodeAll("\e[1;31mred\e[0m \e[7minverse\e[0m", decoders)[0].Content; +SpanRenderer.Render(sample, display, new DisplayOptions { Capabilities = OutputCapabilities.Browser }); +``` + +| Capabilities | Spans | +|---|---| +| `Browser` | `"red"` #aa0000, bold · `" "` · `"inverse"` #000000 on #e5e5e5, inverse | +| `Browser with { Colors = ColorLevel.Attributes }` | `"red"` bold · `" "` · `"inverse"` inverse, no colours | +| `PlainText` | `"red inverse"` | + +A command menu on a browser, and on an output with commands but no menus: + +```csharp +var menu = MarkupText.Wrap(new CommandMenuMarkup([new("Look", "look sword"), new("Take", "take sword")], "A sword"), "sword"); + +display.Resolve(menu.Runs[0].Markups, options).Interaction; +// Kind = Command, Target = look sword, Choices: 2 + +display.Resolve(menu.Runs[0].Markups, new DisplayOptions +{ + Capabilities = OutputCapabilities.Browser with { Interactions = InteractionKinds.Command }, +}).Interaction; +// Kind = Command, Target = look sword, Choices: null +``` + +## Spans + +`SpanRenderer.Render(text, registry, options)` returns `DisplaySpan`s in order. Adjacent text with +the same style is one span. A point (a bell, a sound, a clear) is a span of its own with empty `Text` +and `Point` set. Text no markup covers is `DisplayStyle.Plain`. + +`SpanRenderer.Css(style)` is inline CSS: `color`, `background-color`, `font-weight:bold`, +`opacity:0.6` for faint, `font-style:italic`, `text-decoration` for underline, overline and +line-through, and `color:transparent` for conceal, in place of the text's colour. `CssClasses(style)` gives the classes a page +styles itself: + +| Class | When | +|---|---| +| `ms-blink` | Blink, which CSS has no property for | +| `ms-conceal` | Conceal | +| `ms-link`, `ms-command`, `ms-prompt` | The interaction's kind | +| `ms-menu` | An interaction with more than one choice | +| `ms-picture` | A picture | +| `ms-invert` | Inverse with no colours to swap, at `ColorLevel.Attributes`; `AnsiCss.Fixed` styles it | + +`SpanRenderer.Hex(color)` writes `#rrggbb`. + +## Cells + +`CellRenderer.Layout(text, columns, registry, options, wordWrap)` lays text on a grid for an output +that draws cells itself. + +```csharp +var grid = new DisplayOptions { Capabilities = OutputCapabilities.CellGrid }; +var text = Decoder.DecodeAll("Go \e]8;;https://example.com\e\\north\e]8;;\e\\ to the 漢字 gate", decoders)[0].Content; + +var layout = CellRenderer.Layout(text, 12, display, grid, wordWrap: true); +foreach (var row in layout.Rows) +{ + var shown = string.Concat(row.Cells.Select(cell => cell.Text)); + Console.WriteLine($"|{shown}| {row.Cells.Count} cells{(row.Wrapped ? ", wrapped" : "")}"); +} +``` + +``` +|Go north to | 12 cells, wrapped +|the 漢字 | 9 cells, wrapped +|gate| 4 cells +``` + +- `columns` is the width to wrap at; 0 does not wrap. A line feed in the text starts a new row. +- Without `wordWrap` a row breaks at the last cell. With it, a row breaks after the last space that + fits, and the space stays at the end of the row. A word with no space before it on the row breaks + at the last cell. +- A row is as long as its text and is never padded. `Wrapped` says the next row continues it. +- A tab advances to the next multiple of `TabWidth`, never past `columns`, and its cells take the + style of its run. +- A wide character takes two cells: the second is a continuation, with empty `Text` and `Width` 0 + (`IsContinuation`). A wide character that does not fit in the last column moves to the next row. +- A combining mark joins the cell before it. + +`layout.At(row, column)` is the cell there, or null past the text. `InteractionAt(row, column)` is +what clicking it does; the right half of a wide character answers for the whole. + +```csharp +layout.InteractionAt(0, 4)?.Target; // https://example.com +layout.InteractionAt(0, 1)?.Target; // null +layout.At(1, 7); // the continuation of 字: Text "", Width 0 +``` + +Points are listed apart, in `layout.Points`, each with the row and column it stands before. + +### Pictures + +A picture laid out over rows of cells (an `ImageMarkup` with a `Row`) has blank cells, each with +`Style.Picture` set. `Cell.PictureColumn` is the cell's column within the picture, from its left +edge, or from `PictureRow.FirstColumn` for a cropped placement. With `Picture.Row.Row` it says which +part of the picture to draw in that cell. + +```csharp +var picture = new ImageMarkup("map.png", "A map") { Row = new PictureRow(0, 3, 6) }; +var row = MarkupText.Concat([MarkupText.Plain("> "), MarkupText.Wrap(picture, " ")]); +CellRenderer.Layout(row, 0, display, grid).Rows[0].Cells.Select(c => c.Style.Picture is null ? "." : c.PictureColumn.ToString()); +// . . 0 1 2 3 4 5 +``` + +Word wrap never breaks at a space inside a picture. + +## Back to ANSI + +A terminal output takes the decoded line as it is, through `Render(MarkupFormat.Ansi)` with a +registry for that client. `ToColorDepth` and `ToTerminalFeatures` turn capabilities into what +`AnsiOutputOptions` takes: + +```csharp +var caps = OutputCapabilities.Browser with { Colors = ColorLevel.Standard, Interactions = InteractionKinds.None }; +var ansi = MarkupRegistry.Empty.WithAnsi().WithAnsiOutput(new AnsiOutputOptions(caps.ToColorDepth(), caps.ToTerminalFeatures())); + +var rich = Decoder.DecodeAll("\e[38;2;255;100;0morange\e[0m \e]8;;https://example.com\e\\link\e]8;;\e\\", decoders)[0].Content; +rich.Render(MarkupFormat.Ansi, MarkupRegistry.Empty.WithAnsi()); // \e[38;2;255;100;0morange\e[0m \e]8;;https://example.com\alink\e]8;;\a +rich.Render(MarkupFormat.Ansi, ansi); // \e[1;31morange\e[0m link +``` + +At `Standard` the orange became the nearest standard colour, bright red, written as bold red; with no +`Url` interaction the link is its text. + +## Telling the server + +The capabilities you render with are also what the client tells the server it accepts. Put them in +the decoder's identity, so answers and MTTS match what is drawn: + +```csharp +var window = OutputCapabilities.CellGrid; +var logView = OutputCapabilities.Browser with { Colors = ColorLevel.Xterm256 }; + +var identity = new ClientIdentity { Output = window.Union(logView) }; +identity.ToMtts(); // Ansi, Vt100, Utf8, Colors256, OscColorPalette, TrueColor, Mslp +``` + +Use `Union` when one session is shown in two places and the server is told once; render each place +with its own capabilities, so each drops what it cannot show. `ToMtts` is covered in +[Decoding](decoding.md#mtts). diff --git a/docs/formats.md b/docs/formats.md index cfffcc5..a677d22 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -204,7 +204,7 @@ line.Render(MarkupFormat.Markdown); // **Note:** see [the rules](https://exampl opened again round a line ending, since emphasis cannot cross a blank line. Markdown itself does not read a delimiter between punctuation and a letter, as in `**Note:**text`; leave a space there. - **Links**: a URL link is `[text](address)`, its address percent-encoded where Markdown would end it - early (a space, parentheses, angle brackets, a backslash, a pipe); an address `UrlSafety` refuses, and a + early (a space, parentheses, angle brackets, a backslash, a pipe); an address the registry's `Links` refuses, and a command link, are their text. A picture is `![description](address)` when its address is http(s) or relative, and its text otherwise; a picture inside a link is a linked image. Points — sounds, a bell, a clear — write nothing, and the rest of the shared vocabulary writes its text. @@ -303,7 +303,7 @@ text.Render(MarkupFormat.Mxp); // `HtmlMarkup.Create` writes its tag name and attribute string as given. `HtmlTagPolicy` is the machinery for holding one to a list of allowed tags and attributes, with any address-bearing attribute checked -against `UrlSafety`; `WithHtml(policy)` applies it to every tag rendered in `Html` as it is written, so +against the policy's `Links` (`UrlPolicy.Safe` unless set); `WithHtml(policy)` applies it to every tag rendered in `Html` as it is written, so a refused tag leaves its body unwrapped and a refused attribute is dropped. The library ships no posture of its own — `HtmlTagPolicy.WellFormed` allows any well-formed tag, and