diff --git a/docs/design/design-tokens.md b/docs/design/design-tokens.md index c1130b4..5f7a0bb 100644 --- a/docs/design/design-tokens.md +++ b/docs/design/design-tokens.md @@ -65,20 +65,26 @@ Index → hex. This **is** `AnsiColor.Indexed(0..15)`: xterm-256: indices 16–231 are the 6×6×6 cube, 232–255 the grayscale ramp — computed by the standard xterm formula (the prototype shows representative -samples). The renderer maps any `AnsiColor.Indexed(n)` for `n` in 0–255. +samples). `OutputPalette` holds these sixteen; the cube and ramp are xterm's. -## Styled-segment render contract +## Output render contract -Maps `StyledSegment` (`TextStyle` = fg/bg/bold/underline/inverse) to a rendered -span. Equivalent to the prototype's `runStyle`: +Server output is decoded by MarkupString (`OutputDecoder`) and folded into +spans by its `SpanRenderer` (`OutputDisplay`), drawn by `OutputView`. +Equivalent to the prototype's `runStyle`: -- Default foreground (no fg set) → `--pho` (`#c4d1c8`); default background → - transparent (the `--outbg` shows through). -- `Foreground = Indexed(n)` → palette/cube hex; `Background = Indexed(n)` → - hex + small horizontal padding (`0 2px`). +- Default foreground (no fg set) → no inline colour, so `.sc-line`'s `--pho` + (`#c4d1c8`) shows; default background → transparent (the `--outbg` shows + through). +- Palette colours → `OutputPalette` hex; truecolour as sent. A span with a + background also gets a small horizontal padding (`0 2px`). - **Inverse**: swap — foreground becomes `--outbg`, background becomes the - segment's foreground colour (or `--pho` if none). -- **Bold** → font-weight 700. **Underline** → `text-decoration: underline`. + span's foreground colour (or `--pho` if none). +- **Bold** → `font-weight:bold`, **faint** → `opacity:0.6`, **italic**, + **underline**, **overline**, **strikethrough** → `text-decoration`; + **blink** → class `ms-blink`; **conceal** → transparent text. +- Command links are `button.sc-link`; web links `a.sc-link`; an MXP menu adds + a `▾` toggle listing its choices. - Glow (when enabled): `text-shadow: 0 0 6px rgba(150,210,170,.28)` on the feed. ## Connection-state palette (drives tabs, pills, dots) diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs index 25e300a..c6a37da 100644 --- a/src/SharpClient.Core/Connection/TelnetConnection.cs +++ b/src/SharpClient.Core/Connection/TelnetConnection.cs @@ -41,7 +41,7 @@ public sealed class TelnetConnection : ITelnetConnection private static readonly TimeSpan TlsHandshakeTimeout = TimeSpan.FromSeconds(20); // TNC names nobody unless told to: without this, TTYPE answers UNKNOWN and NEW-ENVIRON sends no - // CLIENT_NAME. MTTS claims what AnsiParser renders: 16, 256 and 24-bit colour over UTF-8. + // CLIENT_NAME. MTTS claims what the output view draws: 16, 256 and 24-bit colour over UTF-8. private static ClientIdentity Identity { get; } = new("SharpClient") { TerminalType = "XTERM", diff --git a/src/SharpClient.Core/Rendering/AnsiColor.cs b/src/SharpClient.Core/Rendering/AnsiColor.cs deleted file mode 100644 index 9a9a2c2..0000000 --- a/src/SharpClient.Core/Rendering/AnsiColor.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace SharpClient.Core.Rendering; - -public enum AnsiColorKind -{ - Default, - Indexed, - - /// 24-bit colour (SGR 38;2 / 48;2). holds 0xRRGGBB. - Rgb, -} - -public readonly record struct AnsiColor(AnsiColorKind Kind, int Index) -{ - public static AnsiColor Default => new(AnsiColorKind.Default, 0); - - public static AnsiColor Indexed(int index) => new(AnsiColorKind.Indexed, index); - - public static AnsiColor Rgb(int r, int g, int b) => - new(AnsiColorKind.Rgb, (Math.Clamp(r, 0, 255) << 16) | (Math.Clamp(g, 0, 255) << 8) | Math.Clamp(b, 0, 255)); -} diff --git a/src/SharpClient.Core/Rendering/AnsiPalette.cs b/src/SharpClient.Core/Rendering/AnsiPalette.cs deleted file mode 100644 index b43add2..0000000 --- a/src/SharpClient.Core/Rendering/AnsiPalette.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace SharpClient.Core.Rendering; - -public static class AnsiPalette -{ - // Shared across the Rendering layer — SegmentStyle references this rather than duplicating. - internal const string PhosphorDefault = "#c4d1c8"; - - private static readonly string[] Base16 = - [ - "#3a3f4b", "#e06c75", "#8fc16f", "#e5c07b", "#61afef", "#c678dd", "#56b6c2", "#abb2bf", - "#5c6672", "#ff8088", "#b5e890", "#ffd596", "#7cc4ff", "#e29bf0", "#6fd3df", "#e8edf2", - ]; - - private static readonly int[] CubeSteps = [0, 95, 135, 175, 215, 255]; - - public static string ToHex(int index) - { - if (index is >= 0 and < 16) - { - return Base16[index]; - } - - if (index is >= 16 and <= 231) - { - var n = index - 16; - var r = CubeSteps[n / 36 % 6]; - var g = CubeSteps[n / 6 % 6]; - var b = CubeSteps[n % 6]; - return Rgb(r, g, b); - } - - if (index is >= 232 and <= 255) - { - var v = 8 + (index - 232) * 10; - return Rgb(v, v, v); - } - - return PhosphorDefault; - } - - private static string Rgb(int r, int g, int b) => - $"#{r:x2}{g:x2}{b:x2}"; -} diff --git a/src/SharpClient.Core/Rendering/AnsiParser.cs b/src/SharpClient.Core/Rendering/AnsiParser.cs deleted file mode 100644 index b0407d3..0000000 --- a/src/SharpClient.Core/Rendering/AnsiParser.cs +++ /dev/null @@ -1,591 +0,0 @@ -using System.Text; - -namespace SharpClient.Core.Rendering; - -public static class AnsiParser -{ - private const char Escape = '\e'; - - public static IReadOnlyList Parse(string line, MxpParserState? mxp = null) - { - var segments = new List(); - var text = new StringBuilder(); - var style = TextStyle.Default; - var index = 0; - - // Pending command-link metadata, carried onto every segment flushed while a - // / link is open (so SGR changes inside a link stay clickable). - string? pendingCommand = null; - string? pendingHint = null; - var inSend = false; - var sendShorthand = false; - var inAnchor = false; - - void Flush() - { - if (text.Length == 0) - { - return; - } - - segments.Add(new StyledSegment(text.ToString(), style) - { - Command = pendingCommand, - Hint = pendingHint, - }); - text.Clear(); - } - - void AddImage(string? url, string label) - { - if (url is null) - { - return; - } - - Flush(); - segments.Add(new StyledSegment(label, style) - { - ImageUrl = url, - Command = pendingCommand, - Hint = pendingHint, - }); - } - - void HandleTag(string raw) - { - var body = raw.Trim(); - var isClose = body.StartsWith('/'); - if (isClose) - { - body = body[1..].TrimStart(); - } - - var sp = body.IndexOfAny([' ', '\t']); - var name = (sp < 0 ? body : body[..sp]).ToLowerInvariant(); - var attrs = sp < 0 ? string.Empty : body[(sp + 1)..]; - - if (!isClose) - { - if (name == "send" && mxp!.IsMxpActive) - { - // Secure elements are only honoured on a Secure line; otherwise the - // tag is stripped and its inner text flows through as plain text. - if (mxp.IsSecure) - { - Flush(); - var href = GetAttr(attrs, "href"); - pendingHint = GetAttr(attrs, "hint"); - if (href is not null) - { - pendingCommand = href; - sendShorthand = false; - } - else - { - pendingCommand = null; - sendShorthand = true; // inner text becomes the command at - } - - inSend = true; - } - } - else if (name == "a" && mxp!.IsPuebloActive) - { - var cmd = GetAttr(attrs, "xch_cmd"); - if (cmd is not null) - { - Flush(); - pendingCommand = cmd; - pendingHint = GetAttr(attrs, "xch_hint"); - inAnchor = true; - } - } - else if (name == "image" && mxp!.IsMxpActive && mxp.IsSecure) - { - // IMAGE is not an open tag, so like SEND it needs a Secure line. - var fileName = GetAttr(attrs, "fname") ?? FirstPositional(attrs); - var url = fileName is null - ? null - : ImageSource.ResolveMxp(fileName, GetAttr(attrs, "url"), GetAttr(attrs, "t")); - AddImage(url, ImageSource.DisplayName(fileName)); - } - else if (name == "img" && mxp!.IsPuebloActive) - { - // is Pueblo's mode switch, not a picture: it has no src. - var src = GetAttr(attrs, "src"); - AddImage(src is null ? null : ImageSource.ResolvePueblo(src), GetAttr(attrs, "alt") ?? ImageSource.DisplayName(src)); - } - // Any other tag (or a non-command anchor) is stripped silently. - } - else - { - if (name == "send" && inSend) - { - if (sendShorthand && pendingCommand is null) - { - pendingCommand = text.ToString(); - } - - Flush(); - pendingCommand = null; - pendingHint = null; - inSend = false; - sendShorthand = false; - } - else if (name == "a" && inAnchor) - { - Flush(); - pendingCommand = null; - pendingHint = null; - inAnchor = false; - } - // Other closing tags are stripped silently. - } - } - - while (index < line.Length) - { - var current = line[index]; - if (current == Escape && index + 1 < line.Length && line[index + 1] == '[') - { - var end = index + 2; - while (end < line.Length && !IsCsiFinal(line[end])) - { - end++; - } - - if (end < line.Length) - { - var final = line[end]; - var parameters = line.Substring(index + 2, end - (index + 2)); - if (final == 'm') - { - Flush(); - style = ApplySgr(style, parameters); - } - else if (final == 'z' && mxp is not null && mxp.IsMxpActive) - { - if (int.TryParse(parameters, out var mode)) - { - mxp.ApplyMode(mode); - } - } - - index = end + 1; - continue; - } - - break; - } - - if (current == Escape) - { - index++; - continue; - } - - // MXP / Pueblo markup. Locked MXP lines (mode 2) suppress all parsing. - var markup = mxp is not null - && (mxp.IsPuebloActive || (mxp.IsMxpActive && mxp.LineMode != 2)); - - if (current == '<' && markup) - { - // Quote-aware scan for the tag terminator so a '>' inside a quoted attribute - // value (e.g. ) doesn't truncate the tag. - var gt = FindTagEnd(line, index + 1); - if (gt < 0) - { - text.Append(current); // unterminated tag: treat '<' as literal - index++; - continue; - } - - HandleTag(line.Substring(index + 1, gt - (index + 1))); - index = gt + 1; - continue; - } - - // HTML entity decoding only happens inside MXP/Pueblo markup (after tag tokenisation, - // on text runs). In plain ANSI output '&' is always literal. A lone or unrecognised - // '&' is emitted verbatim — never dropped. - if (current == '&' && markup && TryDecodeEntity(line, index, out var entity, out var consumed)) - { - text.Append(entity); - index += consumed; - continue; - } - - text.Append(current); - index++; - } - - Flush(); - return segments; - } - - /// Extract an attribute value (quoted or unquoted) from a tag body, case-insensitively. - private static string? GetAttr(string body, string name) - { - var from = 0; - while (from < body.Length) - { - var idx = body.IndexOf(name, from, StringComparison.OrdinalIgnoreCase); - if (idx < 0) - { - return null; - } - - var atTokenStart = idx == 0 || body[idx - 1] is ' ' or '\t'; - var j = idx + name.Length; - while (j < body.Length && body[j] is ' ' or '\t') - { - j++; - } - - if (atTokenStart && j < body.Length && body[j] == '=') - { - j++; - while (j < body.Length && body[j] is ' ' or '\t') - { - j++; - } - - if (j < body.Length && body[j] is '"' or '\'') - { - var quote = body[j]; - var start = ++j; - while (j < body.Length && body[j] != quote) - { - j++; - } - - return DecodeEntities(body[start..j]); - } - else - { - var start = j; - while (j < body.Length && body[j] is not (' ' or '\t')) - { - j++; - } - - return DecodeEntities(body[start..j]); - } - } - - from = idx + name.Length; - } - - return null; - } - - /// The first attribute given without a name (MXP allows <IMAGE map.png URL=...>). - private static string? FirstPositional(string body) - { - var i = 0; - while (i < body.Length) - { - while (i < body.Length && body[i] is ' ' or '\t') - { - i++; - } - - if (i >= body.Length) - { - return null; - } - - var quoted = body[i] is '"' or '\''; - var start = i; - if (quoted) - { - var quote = body[i++]; - start = i; - while (i < body.Length && body[i] != quote) - { - i++; - } - - var value = body[start..i]; - i++; - return DecodeEntities(value); - } - - var isNamed = false; - while (i < body.Length && body[i] is not (' ' or '\t')) - { - if (body[i] == '=') - { - isNamed = true; - i++; - if (i < body.Length && body[i] is '"' or '\'') - { - var quote = body[i++]; - while (i < body.Length && body[i] != quote) - { - i++; - } - - i++; - } - - continue; - } - - i++; - } - - if (!isNamed) - { - return DecodeEntities(body[start..i]); - } - } - - return null; - } - - /// - /// Scan from for the '>' that closes a tag, skipping over single- or - /// double-quoted attribute values so a literal '>' inside an attribute doesn't end the tag early - /// (the Pueblo reference client guards this exact case). Returns -1 if unterminated. - /// - private static int FindTagEnd(string s, int start) - { - var quote = '\0'; - for (var k = start; k < s.Length; k++) - { - var c = s[k]; - if (quote != '\0') - { - if (c == quote) - { - quote = '\0'; - } - } - else if (c is '"' or '\'') - { - quote = c; - } - else if (c == '>') - { - return k; - } - } - - return -1; - } - - /// Decode every HTML entity in a string (used for attribute values). - private static string DecodeEntities(string s) - { - if (s.IndexOf('&') < 0) - { - return s; - } - - var sb = new StringBuilder(s.Length); - var i = 0; - while (i < s.Length) - { - if (s[i] == '&' && TryDecodeEntity(s, i, out var decoded, out var consumed)) - { - sb.Append(decoded); - i += consumed; - } - else - { - sb.Append(s[i]); - i++; - } - } - - return sb.ToString(); - } - - /// - /// Try to decode the HTML entity beginning at (must point at '&'). - /// Handles the core named entities, &apos;/&nbsp;/&copy;/&reg;/&trade;, - /// and numeric &#NN; (decimal) / &#xNN; (hex), decoding to full Unicode. - /// Named lookup is case-insensitive (friendlier than the reference client). A terminating ';' - /// is required; an unrecognised or unterminated sequence returns false so the caller emits the - /// literal '&' (entities are never silently dropped). Numeric values below U+0020 or outside - /// the Unicode range are consumed but produce no output (control chars are ignored, per MXP). - /// - private static bool TryDecodeEntity(string s, int i, out string decoded, out int consumed) - { - decoded = string.Empty; - consumed = 0; - - if (i >= s.Length || s[i] != '&') - { - return false; - } - - var semi = s.IndexOf(';', i + 1); - if (semi < 0 || semi - (i + 1) is 0 or > 12) - { - return false; // no terminator, empty, or implausibly long - } - - var name = s[(i + 1)..semi]; - consumed = semi - i + 1; - - if (name[0] == '#') - { - var isHex = name.Length > 1 && name[1] is 'x' or 'X'; - var digits = isHex ? name[2..] : name[1..]; - var v = 0; - var ok = digits.Length > 0 && (isHex - ? int.TryParse(digits, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out v) - : int.TryParse(digits, out v)); - if (!ok) - { - consumed = 0; - return false; - } - - if (v < 0x20 || v > 0x10FFFF || (v >= 0xD800 && v <= 0xDFFF)) - { - decoded = string.Empty; // ignore control / invalid / surrogate code points - return true; - } - - decoded = char.ConvertFromUtf32(v); - return true; - } - - var mapped = name.ToLowerInvariant() switch - { - "lt" => "<", - "gt" => ">", - "amp" => "&", - "quot" => "\"", - "apos" => "'", - "nbsp" => " ", - "copy" => "©", - "reg" => "®", - "trade" => "™", - _ => null, - }; - - if (mapped is null) - { - consumed = 0; - return false; - } - - decoded = mapped; - return true; - } - - private static bool IsCsiFinal(char c) => c is >= '@' and <= '~'; - - private static TextStyle ApplySgr(TextStyle style, string parameters) - { - if (parameters.Length == 0) - { - return TextStyle.Default; - } - - var codes = parameters.Split(';'); - for (var i = 0; i < codes.Length; i++) - { - if (!int.TryParse(codes[i], out var code)) - { - continue; - } - - switch (code) - { - case 0: - style = TextStyle.Default; - break; - case 1: - style = style with { Bold = true }; - break; - case 4: - style = style with { Underline = true }; - break; - case 7: - style = style with { Inverse = true }; - break; - case 22: - style = style with { Bold = false }; - break; - case 24: - style = style with { Underline = false }; - break; - case 27: - style = style with { Inverse = false }; - break; - case 39: - style = style with { Foreground = AnsiColor.Default }; - break; - case 49: - style = style with { Background = AnsiColor.Default }; - break; - case 38 when TryReadExtended(codes, ref i, out var fg): - style = style with { Foreground = fg }; - break; - case 48 when TryReadExtended(codes, ref i, out var bg): - style = style with { Background = bg }; - break; - case >= 30 and <= 37: - style = style with { Foreground = AnsiColor.Indexed(code - 30) }; - break; - case >= 90 and <= 97: - style = style with { Foreground = AnsiColor.Indexed(code - 90 + 8) }; - break; - case >= 40 and <= 47: - style = style with { Background = AnsiColor.Indexed(code - 40) }; - break; - case >= 100 and <= 107: - style = style with { Background = AnsiColor.Indexed(code - 100 + 8) }; - break; - } - } - - return style; - } - - private static bool TryReadExtended(string[] codes, ref int i, out AnsiColor colour) - { - colour = AnsiColor.Default; - if (i + 1 >= codes.Length) - { - return false; - } - - if (codes[i + 1] == "5") - { - if (i + 2 < codes.Length && int.TryParse(codes[i + 2], out var n)) - { - colour = AnsiColor.Indexed(n); - i += 2; - return true; - } - - return false; - } - - if (codes[i + 1] == "2") - { - // Truecolor: "2" + R + G + B. A short or malformed sequence is still consumed, so its - // components are not read as SGR codes of their own, and leaves the colour at default. - var remaining = codes.Length - 1 - i; - if (remaining >= 4 - && int.TryParse(codes[i + 2], out var r) - && int.TryParse(codes[i + 3], out var g) - && int.TryParse(codes[i + 4], out var b)) - { - colour = AnsiColor.Rgb(r, g, b); - } - - i += Math.Min(4, remaining); - return true; - } - - return false; - } -} diff --git a/src/SharpClient.Core/Rendering/ImageSource.cs b/src/SharpClient.Core/Rendering/ImageSource.cs index a12fae3..fcc074a 100644 --- a/src/SharpClient.Core/Rendering/ImageSource.cs +++ b/src/SharpClient.Core/Rendering/ImageSource.cs @@ -10,47 +10,13 @@ public static class ImageSource private const int MaxNameLength = 48; /// - /// MXP <IMAGE>: "The classname is appended to the URL, along with the name of the - /// graphics file itself", so URL=http://h/img/ T=maps map.png is http://h/img/maps/map.png. - /// Without URL= the name must itself be absolute; a bare file name refers to a local media - /// directory this client does not have. + /// The address an image the decoder read (MXP <IMAGE>, Pueblo <img>) may be loaded + /// from: an absolute http(s) URL, normalised, or a data:image URI. Null for anything else, including a + /// bare file name, which refers to a local media directory this client does not have. /// - public static string? ResolveMxp(string fileName, string? url, string? type) + public static string? Resolve(string source) { - var name = fileName.Trim(); - if (name.Length == 0) - { - return null; - } - - if (IsDataImage(name)) - { - return string.IsNullOrWhiteSpace(url) ? name : null; - } - - if (string.IsNullOrWhiteSpace(url)) - { - return Absolute(name); - } - - var basePath = EnsureSlash(url.Trim()); - if (!string.IsNullOrWhiteSpace(type)) - { - basePath = EnsureSlash(basePath + type.Trim().Trim('/')); - } - - if (!Uri.TryCreate(basePath, UriKind.Absolute, out var baseUri) || !IsWeb(baseUri)) - { - return null; - } - - return Uri.TryCreate(baseUri, name, out var full) && IsWeb(full) ? full.AbsoluteUri : null; - } - - /// Pueblo <img src>, which is plain HTML: an absolute URL or a data:image URI. - public static string? ResolvePueblo(string src) - { - var value = src.Trim(); + var value = source.Trim(); return IsDataImage(value) ? value : Absolute(value); } @@ -89,7 +55,5 @@ private static bool IsDataImage(string value) => private static string? Absolute(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && IsWeb(uri) ? uri.AbsoluteUri : null; - private static string EnsureSlash(string value) => value.EndsWith('/') ? value : value + "/"; - private static bool IsWeb(Uri uri) => uri.Scheme is "http" or "https"; } diff --git a/src/SharpClient.Core/Rendering/MxpParserState.cs b/src/SharpClient.Core/Rendering/MxpParserState.cs deleted file mode 100644 index 1cad530..0000000 --- a/src/SharpClient.Core/Rendering/MxpParserState.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace SharpClient.Core.Rendering; - -/// -/// Per-session markup-negotiation state for MXP (telnet option 91) and Pueblo. -/// Passed to on each line so the -/// parser knows whether clickable command-links are permitted on the current line. -/// -public sealed class MxpParserState -{ - /// Whether MXP telnet option 91 was successfully negotiated. - public bool IsMxpActive { get; set; } - - /// Whether the server switched this connection into Pueblo HTML mode. - public bool IsPuebloActive { get; set; } - - /// - /// Current MXP line mode. Per-line modes (0=Open,1=Secure,2=Locked,3=Reset) revert to - /// at each newline; lock modes (5,6) change the default. - /// - public int LineMode { get; set; } - - /// Persistent default mode — 0 (Open) or 1 (Secure, via Lock-Secure). - public int DefaultLineMode { get; set; } - - /// Reset the transient line mode to the persistent default. Call at line start. - public void BeginLine() => LineMode = DefaultLineMode; - - /// Apply an ESC[#z mode sequence. - public void ApplyMode(int mode) - { - switch (mode) - { - case 0 or 1 or 2: - LineMode = mode; - break; - case 3: // Reset: close tags, revert to Open for this line - LineMode = 0; - break; - case 5: // Lock Open: Open becomes the persistent default - LineMode = 0; - DefaultLineMode = 0; - break; - case 6: // Lock Secure: Secure becomes the persistent default - LineMode = 1; - DefaultLineMode = 1; - break; - } - } - - /// True when the current line permits Secure elements (SEND / A). - public bool IsSecure => LineMode == 1; -} diff --git a/src/SharpClient.Core/Rendering/MxpTags.cs b/src/SharpClient.Core/Rendering/MxpTags.cs index 305cb6e..ecc2fbf 100644 --- a/src/SharpClient.Core/Rendering/MxpTags.cs +++ b/src/SharpClient.Core/Rendering/MxpTags.cs @@ -1,20 +1,32 @@ namespace SharpClient.Core.Rendering; /// -/// What SharpClient's MXP rendering () acts on, for answering a server's -/// <SUPPORT>. A tag or attribute is listed only when the parser does something with it: -/// claiming more makes a server send markup that is then shown stripped. +/// What SharpClient's MXP rendering ( and the output view) acts on, for answering +/// a server's <SUPPORT>. A tag or attribute is listed only when the client does something with it: +/// claiming more makes a server send markup that is then shown stripped. Sound, frames, variables and +/// relocation are decoded but not acted on, so they are not claimed. /// public static class MxpTags { /// The MXP revision answered to <VERSION>. public const string MxpRevision = "1.0"; - // Each rendered tag with the attributes the parser reads from it. + // Each rendered tag with the attributes the client reads from it. SEND's PROMPT is not claimed: + // a prompt link is sent as a command, since there is no prompt-into-input yet. private static readonly (string Tag, string[] Attributes)[] s_rendered = [ + ("b", []), ("bold", []), ("strong", []), + ("i", []), ("italic", []), ("em", []), + ("u", []), ("underline", []), + ("s", []), ("strikeout", []), + ("h", []), ("high", []), + ("c", ["fore", "back"]), ("color", ["fore", "back"]), + ("font", ["color", "back"]), + ("br", []), ("sbr", []), ("p", []), ("nobr", []), + ("a", ["href", "hint"]), ("send", ["href", "hint"]), - ("image", ["fname", "url", "t"]), + ("image", ["fname", "url", "t", "w", "h"]), + ("element", []), ("entity", []), ("attlist", []), ("tag", []), ]; public static IReadOnlyList Supported { get; } = [.. s_rendered.Select(r => r.Tag)]; diff --git a/src/SharpClient.Core/Rendering/OutputDecoder.cs b/src/SharpClient.Core/Rendering/OutputDecoder.cs new file mode 100644 index 0000000..5b1e557 --- /dev/null +++ b/src/SharpClient.Core/Rendering/OutputDecoder.cs @@ -0,0 +1,122 @@ +using MarkupString; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; +using MarkupString.Mxp.Decoding; +using MarkupString.Pueblo.Decoding; + +namespace SharpClient.Core.Rendering; + +/// +/// One connection's decoder: turns each line the telnet layer delivers into +/// lines, keeping the style, MXP line mode and open elements from line to line. Calls are serialised: +/// the read loop decodes while negotiation and state changes arrive on other threads. +/// +public sealed class OutputDecoder +{ + /// ANSI/VT, MXP and Pueblo. MXP and Pueblo stay off until telnet negotiates them. + public static DecoderRegistry Registry { get; } = DecoderRegistry.Empty.WithVt().WithMxp().WithPueblo(); + + private readonly Lock _lock = new(); + private readonly Decoder _decoder = Registry.CreateDecoder(new DecoderOptions + { + // The telnet layer answers MXP /, and there is no path for VT replies yet, + // so the decoder answers nothing. + Identity = new ClientIdentity { Name = "SharpClient", Answers = ClientAnswers.None }, + }); + + /// The text protocol that is on: mxp, pueblo, or null. + public string? ActiveProtocol + { + get + { + lock (_lock) + { + return _decoder.ActiveProtocol; + } + } + } + + /// MXP was negotiated (telnet option 91). + public void EnableMxp() + { + lock (_lock) + { + _decoder.EnableProtocol(MxpDecoding.Protocol); + } + } + + /// The server switched the connection to Pueblo. + public void EnablePueblo() + { + lock (_lock) + { + _decoder.EnableProtocol(PuebloDecoding.Protocol); + } + } + + /// The connection ended: forget the style, protocols and anything half-read. + public void Reset() + { + lock (_lock) + { + _decoder.Reset(); + } + } + + /// + /// The lines one telnet line makes. Usually one; an MXP <BR> or Pueblo <br> makes + /// more. A telnet line that only closed a line already ended by <br> adds no blank line. + /// + public IReadOnlyList Decode(string raw) + { + lock (_lock) + { + return DecodeLocked(raw); + } + } + + private List DecodeLocked(string raw) + { + var fed = _decoder.Feed(raw); + var lines = new List(fed.Count + 1); + foreach (var line in fed) + { + lines.Add(line.Content); + } + + if (_decoder.HasPendingLine || lines.Count == 0) + { + lines.Add(_decoder.EndLine().Content); + } + + return lines; + } +} + +/// How SharpClient's output view draws a decoded line. +public static class OutputDisplay +{ + /// ANSI styles and the shared vocabulary (pictures, menus), then trigger highlights. + public static DisplayRegistry Styles { get; } = DisplayRegistry.Core.WithAnsi().With(TriggerHighlightStyles.Instance); + + /// + /// A browser that opens links, sends commands and shows menus and pictures. Prompt links become + /// command links (there is no prompt-into-input yet), and sound and panes are not drawn. + /// + public static DisplayOptions Options { get; } = new() + { + Capabilities = OutputCapabilities.Browser with + { + Interactions = InteractionKinds.Url | InteractionKinds.Command | InteractionKinds.Menu, + Sound = false, + Panes = false, + }, + Colors = OutputPalette.Instance, + DefaultForeground = OutputPalette.Phosphor, + DefaultBackground = OutputPalette.Background, + }; + + public static IReadOnlyList Render(MarkupText content) => + SpanRenderer.Render(content, Styles, Options); +} diff --git a/src/SharpClient.Core/Rendering/OutputPalette.cs b/src/SharpClient.Core/Rendering/OutputPalette.cs new file mode 100644 index 0000000..a31ae22 --- /dev/null +++ b/src/SharpClient.Core/Rendering/OutputPalette.cs @@ -0,0 +1,38 @@ +using MarkupString; +using MarkupString.Ansi.Decoding; + +namespace SharpClient.Core.Rendering; + +/// +/// SharpClient's colours for the sixteen standard ANSI colours, and the output's own foreground and +/// background. The 256-colour cube and grey ramp are xterm's, as before. +/// +public sealed class OutputPalette : AnsiColors +{ + public static OutputPalette Instance { get; } = new(); + + /// Text with no colour of its own (the CSS variable --pho). + public static RgbColor Phosphor { get; } = new(0xc4, 0xd1, 0xc8); + + /// The output area's background (the CSS variable --outbg). + public static RgbColor Background { get; } = new(0x09, 0x0c, 0x10); + + private static readonly RgbColor[] s_base16 = + [ + Hex(0x3a3f4b), Hex(0xe06c75), Hex(0x8fc16f), Hex(0xe5c07b), Hex(0x61afef), Hex(0xc678dd), Hex(0x56b6c2), Hex(0xabb2bf), + Hex(0x5c6672), Hex(0xff8088), Hex(0xb5e890), Hex(0xffd596), Hex(0x7cc4ff), Hex(0xe29bf0), Hex(0x6fd3df), Hex(0xe8edf2), + ]; + + private OutputPalette() + { + } + + public override RgbColor Standard(int index, bool bright) => s_base16[(index & 7) + (bright ? 8 : 0)]; + + public override RgbColor Xterm(int index) => index is >= 0 and < 16 ? s_base16[index] : base.Xterm(index); + + /// Palette entry (0–255) in this palette. + public RgbColor Entry(int index) => index < 16 ? Standard(index & 7, index >= 8) : Xterm(index); + + private static RgbColor Hex(int rgb) => new((byte)(rgb >> 16), (byte)(rgb >> 8), (byte)rgb); +} diff --git a/src/SharpClient.Core/Rendering/SegmentStyle.cs b/src/SharpClient.Core/Rendering/SegmentStyle.cs deleted file mode 100644 index 72478be..0000000 --- a/src/SharpClient.Core/Rendering/SegmentStyle.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Text; - -namespace SharpClient.Core.Rendering; - -public static class SegmentStyle -{ - // PhosphorDefault is the single source of truth: AnsiPalette.PhosphorDefault (internal const). - private const string OutputBackground = "#090c10"; - - public static string ToCss(StyledSegment segment) - { - var style = segment.Style; - var fg = Resolve(style.Foreground, AnsiPalette.PhosphorDefault); - string? bg = style.Background.Kind == AnsiColorKind.Default - ? null - : Resolve(style.Background, AnsiPalette.PhosphorDefault); - - if (style.Inverse) - { - (fg, bg) = (OutputBackground, fg); - } - - var sb = new StringBuilder(); - sb.Append("color:").Append(fg).Append(';'); - if (bg is not null) - { - sb.Append("background:").Append(bg).Append(';'); - sb.Append("padding:0 2px;"); - } - - if (style.Bold) - { - sb.Append("font-weight:700;"); - } - - if (style.Underline) - { - sb.Append("text-decoration:underline;"); - } - - return sb.ToString(); - } - - private static string Resolve(AnsiColor color, string fallback) => color.Kind switch - { - AnsiColorKind.Indexed => AnsiPalette.ToHex(color.Index), - AnsiColorKind.Rgb => $"#{color.Index & 0xFFFFFF:x6}", - _ => fallback, - }; -} diff --git a/src/SharpClient.Core/Rendering/StyledSegment.cs b/src/SharpClient.Core/Rendering/StyledSegment.cs deleted file mode 100644 index 1ed1b15..0000000 --- a/src/SharpClient.Core/Rendering/StyledSegment.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SharpClient.Core.Rendering; - -public readonly record struct StyledSegment(string Text, TextStyle Style) -{ - /// - /// If non-null, this segment is a clickable command-link (MXP <SEND> or Pueblo - /// <a xch_cmd>). Clicking it sends this string to the server. Null for normal text. - /// - public string? Command { get; init; } - - /// Optional tooltip for a command link. Null if unspecified. - public string? Hint { get; init; } - - /// - /// If non-null, this segment is an inline image (MXP <IMAGE> or Pueblo <img>) and - /// is its alternative text. Always an absolute http(s) URL or a data:image URI. - /// - public string? ImageUrl { get; init; } -} diff --git a/src/SharpClient.Core/Rendering/TextStyle.cs b/src/SharpClient.Core/Rendering/TextStyle.cs deleted file mode 100644 index 8245443..0000000 --- a/src/SharpClient.Core/Rendering/TextStyle.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace SharpClient.Core.Rendering; - -public readonly record struct TextStyle -{ - public AnsiColor Foreground { get; init; } - - public AnsiColor Background { get; init; } - - public bool Bold { get; init; } - - public bool Underline { get; init; } - - public bool Inverse { get; init; } - - public static TextStyle Default => new() - { - Foreground = AnsiColor.Default, - Background = AnsiColor.Default, - }; -} diff --git a/src/SharpClient.Core/Rendering/TriggerHighlight.cs b/src/SharpClient.Core/Rendering/TriggerHighlight.cs new file mode 100644 index 0000000..85e4d95 --- /dev/null +++ b/src/SharpClient.Core/Rendering/TriggerHighlight.cs @@ -0,0 +1,38 @@ +using MarkupString; +using MarkupString.Display; + +namespace SharpClient.Core.Rendering; + +/// A Highlight trigger's colour over a line: palette entry (0–255). +public sealed record TriggerHighlightMarkup(int Index) : IMarkup; + +/// +/// Draws as the text colour. Added after the ANSI styles, so it +/// replaces whatever colour the server gave, and inverse text still swaps it into the background. +/// +public sealed class TriggerHighlightStyles : IStyleContributor +{ + public static TriggerHighlightStyles Instance { get; } = new(); + + private TriggerHighlightStyles() + { + } + + public void Contribute(MarkupSet markups, StyleBuilder style, DisplayOptions options) + { + ArgumentNullException.ThrowIfNull(markups); + ArgumentNullException.ThrowIfNull(style); + ArgumentNullException.ThrowIfNull(options); + + // Innermost first: the first highlight found is the one applied last. + foreach (var markup in markups) + { + if (markup is TriggerHighlightMarkup highlight) + { + var palette = options.Colors as OutputPalette ?? OutputPalette.Instance; + style.Foreground = palette.Entry(Math.Clamp(highlight.Index, 0, 255)); + return; + } + } + } +} diff --git a/src/SharpClient.Core/Sessions/Session.cs b/src/SharpClient.Core/Sessions/Session.cs index f1da93d..b4a1bac 100644 --- a/src/SharpClient.Core/Sessions/Session.cs +++ b/src/SharpClient.Core/Sessions/Session.cs @@ -1,3 +1,5 @@ +using MarkupString; +using MarkupString.Ansi; using SharpClient.Core.Connection; using SharpClient.Core.Domain; using SharpClient.Core.Persistence; @@ -7,7 +9,8 @@ namespace SharpClient.Core.Sessions; -public sealed record ScrollbackLine(IReadOnlyList Segments); +/// One line of output, decoded. draws it. +public sealed record ScrollbackLine(MarkupText Content); public sealed class Session : ISession { @@ -33,7 +36,7 @@ public sealed class Session : ISession private readonly List _scrollback = []; private readonly List _negotiationLog = []; private readonly List _gmcpLog = []; - private readonly MxpParserState _mxp = new(); + private readonly OutputDecoder _decoder = new(); private event Action? _protocolChanged; public Session( @@ -114,7 +117,8 @@ public Task ConnectAsync(string host, int port, CancellationToken cancellationTo public Task ReconnectAsync() => _connection.ReconnectAsync(); - private static readonly TextStyle EchoStyle = TextStyle.Default with { Foreground = AnsiColor.Indexed(8) }; + // Bright black (palette 8): dim against the server's own output. + private static readonly AnsiMarkup EchoStyle = AnsiMarkup.Create(foreground: new AnsiColor.Standard(0, Bright: true)); public async Task SendAsync(string line) { @@ -124,7 +128,7 @@ public async Task SendAsync(string line) // Local echo: MUSH/MUD servers normally don't echo your commands back, so // show what was typed in the scrollback (dim, prefixed) for visibility. - var echo = new ScrollbackLine([new StyledSegment("> " + line, EchoStyle)]); + var echo = new ScrollbackLine(MarkupText.Wrap(EchoStyle, "> " + line)); lock (_scrollbackLock) { _scrollback.Add(echo); @@ -151,46 +155,49 @@ public async ValueTask DisposeAsync() private void OnPuebloEnabled() { - _mxp.IsPuebloActive = true; + _decoder.EnablePueblo(); _protocolChanged?.Invoke(); } private void OnMxpEnabled() { - _mxp.IsMxpActive = true; + _decoder.EnableMxp(); _protocolChanged?.Invoke(); } private async void OnLineReceived(string raw) { - // Reset per-line MXP mode to the negotiated default before parsing this line. - _mxp.BeginLine(); + // The decoder keeps the style, MXP line mode and open elements from line to line. + var decoded = _decoder.Decode(raw); - IReadOnlyList segments; + IReadOnlyList lines; IReadOnlyList sendCommands; IReadOnlyList notifications; if (_triggerEngine is not null && _triggerRules is not null) { - var outcome = _triggerEngine.Apply(raw, _triggerRules, _mxp); - segments = outcome.Segments; + var outcome = _triggerEngine.Apply(raw, decoded, _triggerRules); + lines = outcome.Lines; sendCommands = outcome.SendCommands; notifications = outcome.Notifications; } else { - segments = AnsiParser.Parse(raw, _mxp); + lines = decoded; sendCommands = []; notifications = []; } - var line = new ScrollbackLine(segments); - lock (_scrollbackLock) + foreach (var content in lines) { - _scrollback.Add(line); - } + var line = new ScrollbackLine(content); + lock (_scrollbackLock) + { + _scrollback.Add(line); + } - LineAppended?.Invoke(line); + LineAppended?.Invoke(line); + } foreach (var cmd in sendCommands) { @@ -220,10 +227,7 @@ private void OnStateChanged(ConnectionState state) // server it reaches may not offer either. if (previous == ConnectionState.Connected && state != ConnectionState.Connected) { - _mxp.IsMxpActive = false; - _mxp.IsPuebloActive = false; - _mxp.DefaultLineMode = 0; - _mxp.LineMode = 0; + _decoder.Reset(); } StateChanged?.Invoke(state); diff --git a/src/SharpClient.Core/SharpClient.Core.csproj b/src/SharpClient.Core/SharpClient.Core.csproj index d58c403..8b224d4 100644 --- a/src/SharpClient.Core/SharpClient.Core.csproj +++ b/src/SharpClient.Core/SharpClient.Core.csproj @@ -9,6 +9,10 @@ did not, which crashed MSDP negotiation. No explicit FSharp.Core reference needed anymore. --> + + + + diff --git a/src/SharpClient.Core/Triggers/ITriggerEngine.cs b/src/SharpClient.Core/Triggers/ITriggerEngine.cs index 12e2ae1..a7fea6c 100644 --- a/src/SharpClient.Core/Triggers/ITriggerEngine.cs +++ b/src/SharpClient.Core/Triggers/ITriggerEngine.cs @@ -1,16 +1,16 @@ using SharpClient.Core.Domain; -using SharpClient.Core.Rendering; +using MarkupString; namespace SharpClient.Core.Triggers; public sealed record TriggerOutcome( - IReadOnlyList Segments, + IReadOnlyList Lines, IReadOnlyList SendCommands, IReadOnlyList Notifications); public interface ITriggerEngine { - // Parse rawLine via AnsiParser (with optional MXP/Pueblo markup state), apply Highlight - // rules (restyle matched runs), collect Send/Notify actions. Disabled rules are ignored. - public TriggerOutcome Apply(string rawLine, IReadOnlyList rules, MxpParserState? mxp = null); + // Match rules against rawLine (as the server sent it), apply Highlight rules to the decoded lines it + // made, and collect Send/Notify actions. Disabled rules are ignored. + public TriggerOutcome Apply(string rawLine, IReadOnlyList lines, IReadOnlyList rules); } diff --git a/src/SharpClient.Core/Triggers/TriggerEngine.cs b/src/SharpClient.Core/Triggers/TriggerEngine.cs index 6afa5a9..4d7a0c5 100644 --- a/src/SharpClient.Core/Triggers/TriggerEngine.cs +++ b/src/SharpClient.Core/Triggers/TriggerEngine.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; +using MarkupString; using SharpClient.Core.Domain; using SharpClient.Core.Rendering; @@ -15,12 +16,11 @@ public sealed class TriggerEngine : ITriggerEngine private static readonly ConcurrentDictionary<(string Pattern, RegexOptions Options), Regex> RegexCache = new(); - public TriggerOutcome Apply(string rawLine, IReadOnlyList rules, MxpParserState? mxp = null) + public TriggerOutcome Apply(string rawLine, IReadOnlyList lines, IReadOnlyList rules) { - IReadOnlyList segments = AnsiParser.Parse(rawLine, mxp); var sendCommands = new List(); var notifications = new List(); - AnsiColor? highlightForeground = null; + int? highlightForeground = null; foreach (var rule in rules) { @@ -44,23 +44,23 @@ public TriggerOutcome Apply(string rawLine, IReadOnlyList rules, Mx break; case TriggerActionKind.Highlight: // v1: whole-line highlight simplification — apply the highlight foreground to every - // segment regardless of where the match occurred. Per-segment span highlighting is + // run regardless of where the match occurred. Per-segment span highlighting is // deferred to a future phase. Later Highlight rules override earlier ones. if (int.TryParse(rule.ActionValue, out var index) && index is >= 0 and <= 255) { - highlightForeground = AnsiColor.Indexed(index); + highlightForeground = index; } break; } } - if (highlightForeground.HasValue) + if (highlightForeground is { } fg) { - var fg = highlightForeground.Value; - segments = segments.Select(s => s with { Style = s.Style with { Foreground = fg } }).ToList(); + var highlight = new TriggerHighlightMarkup(fg); + lines = lines.Select(line => MarkupText.Wrap(highlight, line)).ToList(); } - return new TriggerOutcome(segments, sendCommands, notifications); + return new TriggerOutcome(lines, sendCommands, notifications); } private static bool Matches(string rawLine, TriggerRule rule) diff --git a/src/SharpClient.UI/Components/OutputView.razor b/src/SharpClient.UI/Components/OutputView.razor index fc8ecac..6f672b6 100644 --- a/src/SharpClient.UI/Components/OutputView.razor +++ b/src/SharpClient.UI/Components/OutputView.razor @@ -1,35 +1,102 @@ +@using System.Runtime.CompilerServices +@using MarkupString.Display @using SharpClient.Core.Rendering @using SharpClient.Core.Sessions
- @foreach (var line in Lines) + @for (var lineIndex = 0; lineIndex < Lines.Count; lineIndex++) { + var spans = SpansOf(Lines[lineIndex]);
- @foreach (var segment in line.Segments) + @for (var spanIndex = 0; spanIndex < spans.Count; spanIndex++) { - @if (segment.ImageUrl is not null && ShowImages) + var span = spans[spanIndex]; + if (span.Point is not null) { - @* no-referrer: the image host learns nothing about where it was shown. *@ - @if (segment.Command is not null && OnSendCommand is not null) + // Bells, clears and the like: nothing to draw. + continue; + } + + var style = span.Style; + var interaction = style.Interaction; + var command = interaction is { Kind: InteractionKind.Command } && OnSendCommand is not null ? interaction : null; + var url = interaction is { Kind: InteractionKind.Url } ? interaction : null; + var text = span.Text; + + if (style.Picture is { } picture) + { + var src = ImageSource.Resolve(picture.Source); + if (src is null || picture.Row is { Row: > 0 }) { - + // A source the client may not load shows nothing; a picture sized in cells is drawn + // once, on its first row. + continue; } - else + + text = picture.Description ?? ImageSource.DisplayName(picture.Source); + if (ShowImages) { - @segment.Text + @* no-referrer: the image host learns nothing about where it was shown. *@ + if (command is not null) + { + + } + else if (url is not null) + { + @text + } + else + { + @text + } + + continue; } } - else if (segment.Command is not null && OnSendCommand is not null) + + var css = CssOf(style); + var classes = SpanRenderer.CssClasses(style); + if (command is not null) + { + var key = (lineIndex, spanIndex); + + if (command.Choices is { Count: > 1 } choices) + { + + + @if (_openMenu == key) + { + + @foreach (var choice in choices) + { + + } + + } + + } + } + else if (url is not null) + { + @text + } + else if (css.Length == 0 && classes.Length == 0) { - + @text } else { - @(segment.Text.Length == 0 ? " " : segment.Text) + @text } }
@@ -37,14 +104,45 @@
@code { + // A line's spans depend only on the line (the display options are fixed), so each line is folded once + // rather than on every render of the scrollback. + private static readonly ConditionalWeakTable> s_spans = new(); + + private (int Line, int Span)? _openMenu; + [Parameter] public IReadOnlyList Lines { get; set; } = []; - /// Invoked when a clickable MXP/Pueblo command-link is clicked. + /// Invoked when a clickable MXP/Pueblo command-link, or one of its menu choices, is clicked. [Parameter] public Func? OnSendCommand { get; set; } - /// When false, an image segment renders as its text (its name or alt text) instead. + /// When false, an image renders as its text (its name or alt text) instead. [Parameter] public bool ShowImages { get; set; } = true; + + private static IReadOnlyList SpansOf(ScrollbackLine line) => + s_spans.GetValue(line, static l => OutputDisplay.Render(l.Content)); + + private static string CssOf(DisplayStyle style) + { + var css = SpanRenderer.Css(style); + // A little room either side, so a coloured background does not crowd the glyphs. + return style.Background is null ? css : css + ";padding:0 2px"; + } + + private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value; + + private static string Join(string first, string more) => more.Length == 0 ? first : first + " " + more; + + private void ToggleMenu((int Line, int Span) key) => _openMenu = _openMenu == key ? null : key; + + private async Task ChooseAsync(string choice) + { + _openMenu = null; + if (OnSendCommand is not null) + { + await OnSendCommand(choice); + } + } } diff --git a/src/SharpClient.UI/wwwroot/app.css b/src/SharpClient.UI/wwwroot/app.css index e89e0e1..48ee84e 100644 --- a/src/SharpClient.UI/wwwroot/app.css +++ b/src/SharpClient.UI/wwwroot/app.css @@ -660,6 +660,61 @@ body { background: var(--acc-soft); } +/* An OSC 8 / MXP / Pueblo web link. */ +a.sc-link { + display: inline; +} + +/* MXP : the text sends the first command, the toggle beside it lists them all. */ +.sc-menu { + position: relative; + display: inline-block; +} +.sc-menu-toggle { + background: none; + border: none; + padding: 0 2px; + margin: 0; + font: inherit; + line-height: inherit; + cursor: pointer; + color: var(--acc2); +} +.sc-menu-toggle:hover { + color: var(--acc); + background: var(--acc-soft); +} +.sc-menu-list { + position: absolute; + left: 0; + top: 100%; + z-index: 20; + display: flex; + flex-direction: column; + min-width: 8em; + padding: 4px 0; + background: var(--panel); + border: 1px solid var(--bd); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, .4); +} +.sc-menu-choice { + padding: 4px 10px; + text-decoration: none; + white-space: nowrap; +} + +/* SGR 5 / 6. Players who find it distracting have reduced motion set. */ +.ms-blink { + animation: ms-blink 1s steps(1, end) infinite; +} +@keyframes ms-blink { + 50% { visibility: hidden; } +} +@media (prefers-reduced-motion: reduce) { + .ms-blink { animation: none; } +} + /* ── Bottom nav ────────────────────────────────────────────────────────── */ .sc-nav { flex: none; diff --git a/tests/SharpClient.Tests/Presentation/SessionsViewModelTests.cs b/tests/SharpClient.Tests/Presentation/SessionsViewModelTests.cs index 0c71b0b..072a003 100644 --- a/tests/SharpClient.Tests/Presentation/SessionsViewModelTests.cs +++ b/tests/SharpClient.Tests/Presentation/SessionsViewModelTests.cs @@ -125,7 +125,7 @@ public async Task LineAppendedOnActiveSessionRaisesVmChanged() vm.Changed += () => fired = true; // Simulate server line arriving on the active session. - session.Append(new ScrollbackLine([])); + session.Append(new ScrollbackLine(MarkupString.MarkupText.Empty)); await Assert.That(fired).IsTrue(); } diff --git a/tests/SharpClient.Tests/Rendering/AnsiPaletteTests.cs b/tests/SharpClient.Tests/Rendering/AnsiPaletteTests.cs deleted file mode 100644 index 88497f4..0000000 --- a/tests/SharpClient.Tests/Rendering/AnsiPaletteTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using SharpClient.Core.Rendering; - -namespace SharpClient.Tests.Rendering; - -public sealed class AnsiPaletteTests -{ - [Test] - [Arguments(0, "#3a3f4b")] - [Arguments(1, "#e06c75")] - [Arguments(7, "#abb2bf")] - [Arguments(8, "#5c6672")] - [Arguments(15, "#e8edf2")] - public async Task BaseSixteenMatchDesignTokens(int index, string hex) - { - await Assert.That(AnsiPalette.ToHex(index)).IsEqualTo(hex); - } - - [Test] - public async Task CubeBlackIsIndex16() - { - await Assert.That(AnsiPalette.ToHex(16)).IsEqualTo("#000000"); - } - - [Test] - public async Task CubeWhiteIsIndex231() - { - await Assert.That(AnsiPalette.ToHex(231)).IsEqualTo("#ffffff"); - } - - [Test] - public async Task GrayscaleRampStartsDark() - { - await Assert.That(AnsiPalette.ToHex(232)).IsEqualTo("#080808"); - } - - [Test] - public async Task OutOfRangeFallsBackToPhosphor() - { - await Assert.That(AnsiPalette.ToHex(999)).IsEqualTo("#c4d1c8"); - } - - [Test] - public async Task GrayscaleRampEndsAtIndex255() - { - // v = 8 + (255 − 232) × 10 = 8 + 230 = 238 = 0xEE - await Assert.That(AnsiPalette.ToHex(255)).IsEqualTo("#eeeeee"); - } - - [Test] - public async Task MidCubeIndex100Maps() - { - // n = 100 − 16 = 84; r = CubeSteps[84/36%6]=CubeSteps[2]=135=0x87 - // g = CubeSteps[84/6%6]=CubeSteps[2]=135=0x87; b = CubeSteps[84%6]=CubeSteps[0]=0=0x00 - await Assert.That(AnsiPalette.ToHex(100)).IsEqualTo("#878700"); - } - - [Test] - public async Task NegativeIndexFallsBackToPhosphor() - { - await Assert.That(AnsiPalette.ToHex(-1)).IsEqualTo("#c4d1c8"); - } -} diff --git a/tests/SharpClient.Tests/Rendering/AnsiParserTests.cs b/tests/SharpClient.Tests/Rendering/AnsiParserTests.cs deleted file mode 100644 index f49591a..0000000 --- a/tests/SharpClient.Tests/Rendering/AnsiParserTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -using SharpClient.Core.Rendering; - -namespace SharpClient.Tests.Rendering; - -public sealed class AnsiParserTests -{ - [Test] - public async Task DefaultStyleHasDefaultColours() - { - var style = TextStyle.Default; - - await Assert.That(style.Foreground).IsEqualTo(AnsiColor.Default); - await Assert.That(style.Background).IsEqualTo(AnsiColor.Default); - await Assert.That(style.Bold).IsFalse(); - } - - [Test] - public async Task IndexedColourCarriesItsIndex() - { - var colour = AnsiColor.Indexed(200); - - await Assert.That(colour.Kind).IsEqualTo(AnsiColorKind.Indexed); - await Assert.That(colour.Index).IsEqualTo(200); - } - - [Test] - public async Task PlainTextIsOneDefaultSegment() - { - var segments = AnsiParser.Parse("hello world"); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("hello world"); - await Assert.That(segments[0].Style).IsEqualTo(TextStyle.Default); - } - - [Test] - public async Task RedForegroundAppliesToFollowingText() - { - var segments = AnsiParser.Parse("\e[31mred\e[0m normal"); - - await Assert.That(segments.Count).IsEqualTo(2); - await Assert.That(segments[0].Text).IsEqualTo("red"); - await Assert.That(segments[0].Style.Foreground).IsEqualTo(AnsiColor.Indexed(1)); - await Assert.That(segments[1].Text).IsEqualTo(" normal"); - await Assert.That(segments[1].Style.Foreground).IsEqualTo(AnsiColor.Default); - } - - [Test] - public async Task BrightForegroundMapsToHighIndex() - { - var segments = AnsiParser.Parse("\e[92mbright"); - - await Assert.That(segments[0].Style.Foreground).IsEqualTo(AnsiColor.Indexed(10)); - } - - [Test] - public async Task Xterm256ForegroundIsParsed() - { - var segments = AnsiParser.Parse("\e[38;5;208morange"); - - await Assert.That(segments[0].Style.Foreground).IsEqualTo(AnsiColor.Indexed(208)); - } - - [Test] - public async Task BoldAndUnderlineCombine() - { - var segments = AnsiParser.Parse("\e[1;4mhi"); - - await Assert.That(segments[0].Style.Bold).IsTrue(); - await Assert.That(segments[0].Style.Underline).IsTrue(); - } - - [Test] - public async Task NonSgrCsiIsStripped() - { - var segments = AnsiParser.Parse("a\e[2Kb"); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("ab"); - } - - [Test] - public async Task EmptyLineProducesNoSegments() - { - var segments = AnsiParser.Parse(string.Empty); - - await Assert.That(segments.Count).IsEqualTo(0); - } - - [Test] - public async Task TruecolorForegroundIsParsedAndLeavesBackgroundAlone() - { - var segments = AnsiParser.Parse("\e[38;2;40;50;60mX"); - - await Assert.That(segments[0].Style.Background).IsEqualTo(AnsiColor.Default); - await Assert.That(segments[0].Style.Foreground).IsEqualTo(AnsiColor.Rgb(40, 50, 60)); - } - - [Test] - public async Task TruecolorBackgroundIsParsed() - { - var segments = AnsiParser.Parse("\e[48;2;10;20;30mX"); - - await Assert.That(segments[0].Style.Foreground).IsEqualTo(AnsiColor.Default); - await Assert.That(segments[0].Style.Background).IsEqualTo(AnsiColor.Rgb(10, 20, 30)); - await Assert.That(segments[0].Text).IsEqualTo("X"); - } - - [Test] - public async Task TruecolorFollowedByOtherCodesAppliesBoth() - { - var segments = AnsiParser.Parse("\e[1;38;2;255;128;0;4mX"); - - await Assert.That(segments[0].Style.Foreground).IsEqualTo(AnsiColor.Rgb(255, 128, 0)); - await Assert.That(segments[0].Style.Bold).IsTrue(); - await Assert.That(segments[0].Style.Underline).IsTrue(); - } - - [Test] - public async Task TruncatedTruecolorIsConsumedWithoutColour() - { - // "38;2;40" is missing G and B: nothing is coloured, and "40" is not read as a background. - var segments = AnsiParser.Parse("\e[38;2;40mX"); - - await Assert.That(segments[0].Style.Foreground).IsEqualTo(AnsiColor.Default); - await Assert.That(segments[0].Style.Background).IsEqualTo(AnsiColor.Default); - } - - [Test] - public async Task OutOfRangeTruecolorComponentsAreClamped() - { - var segments = AnsiParser.Parse("\e[38;2;300;-5;128mX"); - - await Assert.That(segments[0].Style.Foreground).IsEqualTo(AnsiColor.Rgb(255, 0, 128)); - } - - [Test] - public async Task TrailingNewlineIsPreservedInSegmentText() - { - // A line delivered from the server may include a trailing '\n'. - // AnsiParser is not responsible for stripping it — the raw character - // must survive into the segment text unchanged. - var segments = AnsiParser.Parse("hello\n"); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("hello\n"); - } -} diff --git a/tests/SharpClient.Tests/Rendering/InlineImageTests.cs b/tests/SharpClient.Tests/Rendering/InlineImageTests.cs deleted file mode 100644 index fb9940a..0000000 --- a/tests/SharpClient.Tests/Rendering/InlineImageTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using SharpClient.Core.Rendering; - -namespace SharpClient.Tests.Rendering; - -public sealed class InlineImageTests -{ - private static MxpParserState Mxp() => new() { IsMxpActive = true }; - - private static MxpParserState Pueblo() => new() { IsPuebloActive = true }; - - [Test] - public async Task MxpImageOnSecureLineJoinsUrlTypeAndName() - { - var segments = AnsiParser.Parse("\e[1z", Mxp()); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].ImageUrl).IsEqualTo("https://h.example/img/maps/map.png"); - await Assert.That(segments[0].Text).IsEqualTo("map.png"); - } - - [Test] - public async Task MxpImageAcceptsFnameAttribute() - { - var segments = AnsiParser.Parse("\e[1z", Mxp()); - - await Assert.That(segments[0].ImageUrl).IsEqualTo("https://h.example/a/b.jpg?x=1"); - await Assert.That(segments[0].Text).IsEqualTo("b.jpg"); - } - - [Test] - public async Task MxpImageOnOpenLineIsIgnored() - { - var segments = AnsiParser.Parse("before after", Mxp()); - - await Assert.That(segments.Any(s => s.ImageUrl is not null)).IsFalse(); - } - - [Test] - public async Task MxpImageWithBareFileNameAndNoUrlIsIgnored() - { - var segments = AnsiParser.Parse("\e[1z", Mxp()); - - await Assert.That(segments.Count).IsEqualTo(0); - } - - [Test] - public async Task ImagesAreIgnoredWithoutMxpOrPueblo() - { - var segments = AnsiParser.Parse("\e[1z", new MxpParserState()); - - await Assert.That(segments.Any(s => s.ImageUrl is not null)).IsFalse(); - } - - [Test] - public async Task PuebloImgUsesSrcAndAlt() - { - var segments = AnsiParser.Parse("Look: \"a!", Pueblo()); - - await Assert.That(segments.Count).IsEqualTo(3); - await Assert.That(segments[1].ImageUrl).IsEqualTo("https://h.example/cat.gif"); - await Assert.That(segments[1].Text).IsEqualTo("a cat"); - await Assert.That(segments[2].Text).IsEqualTo("!"); - } - - [Test] - public async Task PuebloModeSwitchImgIsNotAPicture() - { - var segments = AnsiParser.Parse("", Pueblo()); - - await Assert.That(segments.Count).IsEqualTo(0); - } - - [Test] - [Arguments("javascript:alert(1)")] - [Arguments("file:///etc/passwd")] - [Arguments("/local/path.png")] - [Arguments("content://media/1")] - public async Task NonWebSchemesAreRefused(string src) - { - var segments = AnsiParser.Parse($"", Pueblo()); - - await Assert.That(segments.Count).IsEqualTo(0); - } - - [Test] - public async Task DataImageIsAccepted() - { - var segments = AnsiParser.Parse("", Pueblo()); - - await Assert.That(segments[0].ImageUrl).IsEqualTo("data:image/png;base64,iVBORw0KGgo="); - } - - [Test] - public async Task ImageInsideASendLinkIsClickable() - { - var segments = AnsiParser.Parse("\e[1z", Mxp()); - - await Assert.That(segments[0].ImageUrl).IsEqualTo("https://h.example/m.png"); - await Assert.That(segments[0].Command).IsEqualTo("look map"); - } -} diff --git a/tests/SharpClient.Tests/Rendering/LineSpans.cs b/tests/SharpClient.Tests/Rendering/LineSpans.cs new file mode 100644 index 0000000..418c8fb --- /dev/null +++ b/tests/SharpClient.Tests/Rendering/LineSpans.cs @@ -0,0 +1,24 @@ +using MarkupString.Display; +using SharpClient.Core.Rendering; +using SharpClient.Core.Sessions; + +namespace SharpClient.Tests.Rendering; + +/// What the output view draws for a line, for asserting on. +internal static class LineSpans +{ + public static IReadOnlyList Spans(this ScrollbackLine line) => OutputDisplay.Render(line.Content); + + public static IReadOnlyList Spans(this MarkupString.MarkupText line) => OutputDisplay.Render(line); + + /// The command a span sends when clicked, or null. + public static string? Command(this DisplaySpan span) => + span.Style.Interaction is { Kind: InteractionKind.Command } interaction ? interaction.Target : null; + + /// The CSS colour of the span's text, or null for the output's default. + public static string? Color(this DisplaySpan span) => + span.Style.Foreground is { } color ? SpanRenderer.Hex(color) : null; + + public static string? BackgroundColor(this DisplaySpan span) => + span.Style.Background is { } color ? SpanRenderer.Hex(color) : null; +} diff --git a/tests/SharpClient.Tests/Rendering/MxpParserTests.cs b/tests/SharpClient.Tests/Rendering/MxpParserTests.cs deleted file mode 100644 index eb54980..0000000 --- a/tests/SharpClient.Tests/Rendering/MxpParserTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -using SharpClient.Core.Rendering; - -namespace SharpClient.Tests.Rendering; - -public sealed class MxpParserTests -{ - private static MxpParserState Mxp() => new() { IsMxpActive = true }; - - [Test] - public async Task SendOnSecureLineProducesCommandLink() - { - var segments = AnsiParser.Parse("\e[1zNorth", Mxp()); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("North"); - await Assert.That(segments[0].Command).IsEqualTo("go north"); - await Assert.That(segments[0].Hint).IsEqualTo("Move north"); - } - - [Test] - public async Task ShorthandSendUsesInnerTextAsCommand() - { - var segments = AnsiParser.Parse("\e[1zlook", Mxp()); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("look"); - await Assert.That(segments[0].Command).IsEqualTo("look"); - } - - [Test] - public async Task SendOnOpenLineIsStrippedWithNoCommand() - { - // No ESC[1z: line stays in Open mode, where Secure elements are forbidden. - var segments = AnsiParser.Parse("North", Mxp()); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("North"); // inner text shown as plain - await Assert.That(segments[0].Command).IsNull(); - } - - [Test] - public async Task LockSecurePersistsAcrossLines() - { - var mxp = Mxp(); - - // Lock-Secure makes Secure the persistent default. - _ = AnsiParser.Parse("\e[6zwelcome", mxp); - mxp.BeginLine(); // simulate next line start - - var segments = AnsiParser.Parse("Quit", mxp); - - await Assert.That(segments[0].Command).IsEqualTo("quit"); - } - - [Test] - public async Task LockedModeTreatsAngleBracketsAsLiteralText() - { - var segments = AnsiParser.Parse("\e[2za c", Mxp()); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("a c"); - await Assert.That(segments[0].Command).IsNull(); - } - - [Test] - public async Task PuebloAnchorWithXchCmdProducesCommandLink() - { - var mxp = new MxpParserState { IsPuebloActive = true }; - - var segments = AnsiParser.Parse("North", mxp); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("North"); - await Assert.That(segments[0].Command).IsEqualTo("go north"); - await Assert.That(segments[0].Hint).IsEqualTo("Move"); - } - - [Test] - public async Task TagsAreInertWhenNeitherMxpNorPuebloActive() - { - // Default Parse (no MXP state) must behave exactly as before — '<' is literal. - var segments = AnsiParser.Parse("y"); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("y"); - await Assert.That(segments[0].Command).IsNull(); - } - - [Test] - public async Task LinkKeepsCommandAcrossSgrChangeInside() - { - var segments = AnsiParser.Parse("\e[1z\e[31mAttack\e[0m", Mxp()); - - await Assert.That(segments.Count).IsEqualTo(1); - await Assert.That(segments[0].Text).IsEqualTo("Attack"); - await Assert.That(segments[0].Command).IsEqualTo("hit"); - await Assert.That(segments[0].Style.Foreground).IsEqualTo(AnsiColor.Indexed(1)); - } -} diff --git a/tests/SharpClient.Tests/Rendering/MxpTagsTests.cs b/tests/SharpClient.Tests/Rendering/MxpTagsTests.cs index 2f86f5b..f97e0e6 100644 --- a/tests/SharpClient.Tests/Rendering/MxpTagsTests.cs +++ b/tests/SharpClient.Tests/Rendering/MxpTagsTests.cs @@ -8,7 +8,8 @@ public sealed class MxpTagsTests public async Task EmptyRequestListsEverySupportedTag() { var (supported, unsupported) = MxpTags.Answer([]); - await Assert.That(string.Join(",", supported)).IsEqualTo("send,image"); + await Assert.That(supported).Contains("send").And.Contains("image").And.Contains("color").And.Contains("a"); + await Assert.That(supported).DoesNotContain("frame").And.DoesNotContain("sound"); await Assert.That(unsupported).IsEmpty(); } @@ -23,9 +24,9 @@ public async Task TagsAreSortedIntoSupportedAndNot() [Test] public async Task AttributesAreAnsweredPerAttribute() { - var (supported, unsupported) = MxpTags.Answer(["send.href", "send.prompt", "image.url", "color.fore"]); - await Assert.That(string.Join(",", supported)).IsEqualTo("send.href,image.url"); - await Assert.That(string.Join(",", unsupported)).IsEqualTo("send.prompt,color.fore"); + var (supported, unsupported) = MxpTags.Answer(["send.href", "send.prompt", "image.url", "image.t", "color.fore", "font.face"]); + await Assert.That(string.Join(",", supported)).IsEqualTo("send.href,image.url,image.t,color.fore"); + await Assert.That(string.Join(",", unsupported)).IsEqualTo("send.prompt,font.face"); } [Test] diff --git a/tests/SharpClient.Tests/Rendering/OutputRenderingTests.cs b/tests/SharpClient.Tests/Rendering/OutputRenderingTests.cs new file mode 100644 index 0000000..15adb88 --- /dev/null +++ b/tests/SharpClient.Tests/Rendering/OutputRenderingTests.cs @@ -0,0 +1,543 @@ +using MarkupString.Display; +using SharpClient.Core.Rendering; + +namespace SharpClient.Tests.Rendering; + +/// +/// What a line from the server looks like once decoded and folded into spans: every capability the +/// old AnsiParser had, and the SGR it used to drop. +/// +public sealed class OutputRenderingTests +{ + private const string PuebloHtml = ""; + + private static IReadOnlyList Plain(string raw) => Render(new OutputDecoder(), raw); + + private static IReadOnlyList Mxp(string raw) + { + var decoder = new OutputDecoder(); + decoder.EnableMxp(); + return Render(decoder, raw); + } + + private static IReadOnlyList Pueblo(string raw) + { + var decoder = new OutputDecoder(); + decoder.EnablePueblo(); + decoder.Decode(PuebloHtml); + return Render(decoder, raw); + } + + private static IReadOnlyList Render(OutputDecoder decoder, string raw) => + [.. decoder.Decode(raw).SelectMany(line => line.Spans())]; + + private static string Text(IReadOnlyList spans) => string.Concat(spans.Select(s => s.Text)); + + private static string Hex(int index) => SpanRenderer.Hex(OutputPalette.Instance.Entry(index)); + + // ── ANSI ────────────────────────────────────────────────────────────── + + [Test] + public async Task PlainTextIsOneUnstyledSpan() + { + var spans = Plain("hello world"); + + await Assert.That(spans.Count).IsEqualTo(1); + await Assert.That(spans[0].Text).IsEqualTo("hello world"); + await Assert.That(spans[0].Style).IsEqualTo(DisplayStyle.Plain); + } + + [Test] + public async Task EmptyLineHasNoSpans() + { + await Assert.That(Plain(string.Empty)).IsEmpty(); + } + + [Test] + [Arguments("\e[31m", 1)] + [Arguments("\e[92m", 10)] + [Arguments("\e[38;5;208m", 208)] + [Arguments("\e[38;5;3m", 3)] + [Arguments("\e[38:5:208m", 208)] + public async Task PaletteColoursUseTheClientPalette(string sgr, int index) + { + var spans = Plain(sgr + "x"); + + await Assert.That(spans[0].Color()).IsEqualTo(Hex(index)); + } + + [Test] + public async Task StandardColoursAreTheClientsOwn() + { + await Assert.That(Plain("\e[31mx")[0].Color()).IsEqualTo("#e06c75"); + await Assert.That(Plain("\e[97mx")[0].Color()).IsEqualTo("#e8edf2"); + await Assert.That(Plain("\e[44mx")[0].BackgroundColor()).IsEqualTo("#61afef"); + await Assert.That(Plain("\e[38;5;16mx")[0].Color()).IsEqualTo("#000000"); + await Assert.That(Plain("\e[38;5;232mx")[0].Color()).IsEqualTo("#080808"); + } + + [Test] + public async Task ResetEndsTheColour() + { + var spans = Plain("\e[31mred\e[0m normal"); + + await Assert.That(spans.Count).IsEqualTo(2); + await Assert.That(spans[0].Text).IsEqualTo("red"); + await Assert.That(spans[0].Color()).IsEqualTo(Hex(1)); + await Assert.That(spans[1].Text).IsEqualTo(" normal"); + await Assert.That(spans[1].Color()).IsNull(); + } + + [Test] + public async Task StyleCarriesToTheNextLine() + { + var decoder = new OutputDecoder(); + decoder.Decode("\e[32mgreen"); + + await Assert.That(decoder.Decode("still green")[0].Spans()[0].Color()).IsEqualTo(Hex(2)); + } + + [Test] + public async Task TruecolorForegroundAndBackground() + { + var fg = Plain("\e[38;2;40;50;60mX")[0]; + var bg = Plain("\e[48;2;10;20;30mX")[0]; + + await Assert.That(fg.Color()).IsEqualTo("#28323c"); + await Assert.That(fg.BackgroundColor()).IsNull(); + await Assert.That(bg.BackgroundColor()).IsEqualTo("#0a141e"); + await Assert.That(bg.Color()).IsNull(); + } + + [Test] + public async Task TruecolorAmongOtherCodesAppliesAll() + { + var span = Plain("\e[1;38;2;255;128;0;4mX")[0]; + + await Assert.That(span.Color()).IsEqualTo("#ff8000"); + await Assert.That(span.Style.Has(TextAttributes.Bold)).IsTrue(); + await Assert.That(span.Style.Has(TextAttributes.Underline)).IsTrue(); + } + + [Test] + public async Task TruncatedTruecolorColoursNothing() + { + // "38;2;40" is missing G and B: nothing is coloured, and "40" is not read as a background. + var span = Plain("\e[38;2;40mX")[0]; + + await Assert.That(span.Color()).IsNull(); + await Assert.That(span.BackgroundColor()).IsNull(); + } + + [Test] + [Arguments("\e[1m", TextAttributes.Bold)] + [Arguments("\e[2m", TextAttributes.Faint)] + [Arguments("\e[3m", TextAttributes.Italic)] + [Arguments("\e[4m", TextAttributes.Underline)] + [Arguments("\e[21m", TextAttributes.Underline)] + [Arguments("\e[5m", TextAttributes.Blink)] + [Arguments("\e[8m", TextAttributes.Conceal)] + [Arguments("\e[9m", TextAttributes.Strikethrough)] + [Arguments("\e[53m", TextAttributes.Overline)] + public async Task AttributesAreDrawn(string sgr, TextAttributes attribute) + { + var span = Plain(sgr + "x")[0]; + + await Assert.That(span.Style.Has(attribute)).IsTrue(); + } + + [Test] + [Arguments("\e[1m", "\e[22m")] + [Arguments("\e[3m", "\e[23m")] + [Arguments("\e[4m", "\e[24m")] + [Arguments("\e[5m", "\e[25m")] + [Arguments("\e[7m", "\e[27m")] + [Arguments("\e[9m", "\e[29m")] + public async Task AttributesTurnOff(string on, string off) + { + var spans = Plain(on + "a" + off + "b"); + + await Assert.That(spans[^1].Text).IsEqualTo("b"); + await Assert.That(spans[^1].Style).IsEqualTo(DisplayStyle.Plain); + } + + [Test] + public async Task CssCarriesEveryAttribute() + { + var css = SpanRenderer.Css(Plain("\e[1;2;3;4;9;53mx")[0].Style); + + await Assert.That(css).Contains("font-weight:bold"); + await Assert.That(css).Contains("opacity:0.6"); + await Assert.That(css).Contains("font-style:italic"); + await Assert.That(css).Contains("text-decoration:underline overline line-through"); + await Assert.That(SpanRenderer.CssClasses(Plain("\e[5mx")[0].Style)).IsEqualTo("ms-blink"); + } + + [Test] + public async Task InverseSwapsColoursAgainstTheOutputDefaults() + { + var coloured = Plain("\e[32;7mx")[0]; + var plain = Plain("\e[7mx")[0]; + + await Assert.That(coloured.Color()).IsEqualTo("#090c10"); + await Assert.That(coloured.BackgroundColor()).IsEqualTo(Hex(2)); + await Assert.That(plain.Color()).IsEqualTo("#090c10"); + await Assert.That(plain.BackgroundColor()).IsEqualTo("#c4d1c8"); + } + + [Test] + public async Task BoldIsNotBrightened() + { + await Assert.That(Plain("\e[1;31mx")[0].Color()).IsEqualTo(Hex(1)); + } + + [Test] + public async Task OtherControlSequencesAreNotShown() + { + await Assert.That(Text(Plain("a\e[?25lb\e[Hc"))).IsEqualTo("abc"); + } + + [Test] + public async Task OscHyperlinkIsAWebLink() + { + var spans = Plain("see \e]8;;https://example.com/help\e\\help\e]8;;\e\\ now"); + + await Assert.That(spans[1].Text).IsEqualTo("help"); + await Assert.That(spans[1].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Url, "https://example.com/help")); + await Assert.That(spans[2].Style.Interaction).IsNull(); + } + + [Test] + public async Task OscHyperlinkToAScriptIsText() + { + var spans = Plain("\e]8;;javascript:alert(1)\e\\x\e]8;;\e\\"); + + await Assert.That(spans.All(s => s.Style.Interaction is null)).IsTrue(); + } + + [Test] + public async Task MarkupIsLiteralWithoutMxpOrPueblo() + { + var spans = Plain("y 5 > 3"); + + await Assert.That(Text(spans)).IsEqualTo("y 5 > 3"); + await Assert.That(spans.All(s => s.Command() is null)).IsTrue(); + } + + // ── MXP ─────────────────────────────────────────────────────────────── + + [Test] + public async Task SendOnSecureLineIsACommandLink() + { + var spans = Mxp("\e[1zNorth"); + + await Assert.That(spans.Count).IsEqualTo(1); + await Assert.That(spans[0].Text).IsEqualTo("North"); + await Assert.That(spans[0].Command()).IsEqualTo("go north"); + await Assert.That(spans[0].Style.Interaction!.Hint).IsEqualTo("Move north"); + } + + [Test] + public async Task ShorthandSendSendsItsText() + { + var spans = Mxp("\e[1zlook"); + + await Assert.That(spans[0].Text).IsEqualTo("look"); + await Assert.That(spans[0].Command()).IsEqualTo("look"); + } + + [Test] + public async Task SendOnOpenLineShowsItsTextOnly() + { + var spans = Mxp("North"); + + await Assert.That(Text(spans)).IsEqualTo("North"); + await Assert.That(spans.All(s => s.Command() is null)).IsTrue(); + } + + [Test] + public async Task LockSecurePersistsAcrossLines() + { + var decoder = new OutputDecoder(); + decoder.EnableMxp(); + decoder.Decode("\e[6zwelcome"); + + var spans = Render(decoder, "Quit"); + + await Assert.That(spans[0].Command()).IsEqualTo("quit"); + } + + [Test] + public async Task SecureLineModeEndsWithTheLine() + { + var decoder = new OutputDecoder(); + decoder.EnableMxp(); + decoder.Decode("\e[1zsecure"); + + var spans = Render(decoder, "Quit"); + + await Assert.That(spans.All(s => s.Command() is null)).IsTrue(); + } + + [Test] + public async Task LockedLineShowsMarkupAsText() + { + var spans = Mxp("\e[2za c"); + + await Assert.That(Text(spans)).IsEqualTo("a c"); + } + + [Test] + public async Task ColourInsideALinkKeepsTheLink() + { + var spans = Mxp("\e[1z\e[31mAttack\e[0m"); + + await Assert.That(spans.Count).IsEqualTo(1); + await Assert.That(spans[0].Text).IsEqualTo("Attack"); + await Assert.That(spans[0].Command()).IsEqualTo("hit"); + await Assert.That(spans[0].Color()).IsEqualTo(Hex(1)); + } + + [Test] + public async Task SendWithSeveralCommandsIsAMenu() + { + var spans = Mxp("\e[1zsword"); + + var interaction = spans[0].Style.Interaction!; + await Assert.That(interaction.Target).IsEqualTo("buy sword"); + await Assert.That(interaction.Hint).IsEqualTo("Sword"); + await Assert.That(interaction.Choices!.Select(c => (c.Label, c.Command))) + .IsEquivalentTo([("Buy", "buy sword"), ("Look", "look sword")]); + } + + [Test] + public async Task PromptSendIsSentAsACommand() + { + var spans = Mxp("\e[1ztalk"); + + await Assert.That(spans[0].Style.Interaction!.Kind).IsEqualTo(InteractionKind.Command); + } + + [Test] + public async Task EntitiesAreDecoded() + { + await Assert.That(Text(Mxp("5 > 3 & <"))).IsEqualTo("5 > 3 & <"); + } + + [Test] + public async Task OpenTagsStyleText() + { + var spans = Mxp("bold it u s c"); + + await Assert.That(spans[0].Style.Has(TextAttributes.Bold)).IsTrue(); + await Assert.That(spans[2].Style.Has(TextAttributes.Italic)).IsTrue(); + await Assert.That(spans[4].Style.Has(TextAttributes.Underline)).IsTrue(); + await Assert.That(spans[6].Style.Has(TextAttributes.Strikethrough)).IsTrue(); + await Assert.That(spans[8].Color()).IsEqualTo("#ff0000"); + await Assert.That(spans[8].BackgroundColor()).IsEqualTo("#000080"); + } + + [Test] + public async Task MxpAnchorIsAWebLink() + { + var spans = Mxp("\e[1zsite"); + + await Assert.That(spans[0].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Url, "https://example.com") { Hint = "Site" }); + } + + [Test] + public async Task BreakStartsANewLine() + { + var decoder = new OutputDecoder(); + decoder.EnableMxp(); + + var lines = decoder.Decode("one
two"); + + await Assert.That(lines.Select(l => l.Text)).IsEquivalentTo(["one", "two"]); + } + + [Test] + public async Task MxpImageOnSecureLineIsAPicture() + { + var spans = Mxp("\e[1z"); + + await Assert.That(spans[0].Style.Picture!.Source).IsEqualTo("https://h.example/a/b.jpg?x=1"); + await Assert.That(ImageSource.DisplayName(spans[0].Style.Picture!.Source)).IsEqualTo("b.jpg"); + } + + [Test] + public async Task MxpImageJoinsUrlAndName() + { + var spans = Mxp("\e[1z"); + + await Assert.That(ImageSource.Resolve(spans[0].Style.Picture!.Source)).IsEqualTo("https://h.example/img/map.png"); + } + + [Test] + public async Task MxpImageJoinsUrlTypeAndName() + { + var spans = Mxp("\e[1z"); + + await Assert.That(ImageSource.Resolve(spans[0].Style.Picture!.Source)).IsEqualTo("https://h.example/img/maps/map.png"); + await Assert.That(ImageSource.DisplayName(spans[0].Style.Picture!.Source)).IsEqualTo("map.png"); + } + + [Test] + public async Task MxpImageOnOpenLineIsNotAPicture() + { + var spans = Mxp("before after"); + + await Assert.That(spans.Any(s => s.Style.Picture is not null)).IsFalse(); + } + + [Test] + public async Task MxpImageWithBareFileNameIsNotLoaded() + { + var spans = Mxp("\e[1z"); + + await Assert.That(ImageSource.Resolve(spans[0].Style.Picture!.Source)).IsNull(); + } + + [Test] + public async Task ImageInsideASendIsClickable() + { + var spans = Mxp("\e[1z"); + + await Assert.That(spans[0].Style.Picture!.Source).IsEqualTo("https://h.example/m.png"); + await Assert.That(spans[0].Command()).IsEqualTo("look map"); + } + + [Test] + public async Task ImagesAreTextWithoutMxpOrPueblo() + { + var spans = Plain("\e[1z"); + + await Assert.That(spans.Any(s => s.Style.Picture is not null)).IsFalse(); + } + + [Test] + public async Task CustomElementsExpand() + { + var decoder = new OutputDecoder(); + decoder.EnableMxp(); + decoder.Decode("\e[1z'>"); + + var spans = Render(decoder, "\e[1zThe Hall"); + + await Assert.That(spans[0].Text).IsEqualTo("The Hall"); + await Assert.That(spans[0].Style.Has(TextAttributes.Bold)).IsTrue(); + await Assert.That(spans[0].Color()).IsEqualTo("#ff0000"); + } + + // ── Pueblo ──────────────────────────────────────────────────────────── + + [Test] + public async Task PuebloAnchorWithXchCmdIsACommandLink() + { + var spans = Pueblo("North"); + + await Assert.That(spans.Count).IsEqualTo(1); + await Assert.That(spans[0].Text).IsEqualTo("North"); + await Assert.That(spans[0].Command()).IsEqualTo("go north"); + await Assert.That(spans[0].Style.Interaction!.Hint).IsEqualTo("Move"); + } + + [Test] + public async Task PuebloEntitiesAreDecoded() + { + await Assert.That(Text(Pueblo("5 > 3 && 2 < 4 "q""))).IsEqualTo("5 > 3 && 2 < 4 \"q\""); + await Assert.That(Text(Pueblo("it's here"))).IsEqualTo("it's here"); + await Assert.That(Text(Pueblo(">>&"))).IsEqualTo(">>&"); + } + + [Test] + public async Task LoneAndUnknownAmpersandsStay() + { + await Assert.That(Text(Pueblo("Tom & Jerry"))).IsEqualTo("Tom & Jerry"); + await Assert.That(Text(Pueblo("x¬athing;y"))).IsEqualTo("x¬athing;y"); + } + + [Test] + public async Task EntityInsideAnAttributeIsDecoded() + { + var spans = Pueblo("go"); + + await Assert.That(spans[0].Command()).IsEqualTo("say 5 > 3"); + } + + [Test] + public async Task GreaterThanInsideAQuotedAttributeDoesNotEndTheTag() + { + var spans = Pueblo(" 3\">go"); + + await Assert.That(spans.Count).IsEqualTo(1); + await Assert.That(spans[0].Text).IsEqualTo("go"); + await Assert.That(spans[0].Command()).IsEqualTo("say 5 > 3"); + } + + [Test] + public async Task PuebloImgUsesSrcAndAlt() + { + var spans = Pueblo("Look: \"a!"); + + await Assert.That(spans.Count).IsEqualTo(3); + await Assert.That(spans[1].Style.Picture!.Source).IsEqualTo("https://h.example/cat.gif"); + await Assert.That(spans[1].Style.Picture!.Description).IsEqualTo("a cat"); + await Assert.That(spans[2].Text).IsEqualTo("!"); + } + + [Test] + public async Task PuebloModeSwitchesAndPageClearShowNothing() + { + var spans = Pueblo(""); + + await Assert.That(spans.All(s => s.Point is not null)).IsTrue(); + } + + [Test] + [Arguments("javascript:alert(1)")] + [Arguments("file:///etc/passwd")] + [Arguments("/local/path.png")] + [Arguments("content://media/1")] + public async Task NonWebImageSourcesAreRefused(string src) + { + var spans = Pueblo($""); + + await Assert.That(ImageSource.Resolve(spans[0].Style.Picture!.Source)).IsNull(); + } + + [Test] + public async Task DataImageIsAccepted() + { + var spans = Pueblo(""); + + await Assert.That(ImageSource.Resolve(spans[0].Style.Picture!.Source)).IsEqualTo("data:image/png;base64,iVBORw0KGgo="); + } + + [Test] + public async Task PuebloLineEndingInBreakAddsNoBlankLine() + { + var decoder = new OutputDecoder(); + decoder.EnablePueblo(); + decoder.Decode(PuebloHtml); + + var lines = decoder.Decode("Hello
"); + + await Assert.That(lines.Select(l => l.Text)).IsEquivalentTo(["Hello"]); + } + + [Test] + public async Task ResetForgetsProtocolsAndStyle() + { + var decoder = new OutputDecoder(); + decoder.EnableMxp(); + decoder.Decode("\e[31m\e[6z"); + decoder.Reset(); + + var spans = Render(decoder, "y"); + + await Assert.That(decoder.ActiveProtocol).IsNull(); + await Assert.That(Text(spans)).IsEqualTo("y"); + await Assert.That(spans[0].Color()).IsNull(); + } +} diff --git a/tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs b/tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs deleted file mode 100644 index 92f2c44..0000000 --- a/tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using SharpClient.Core.Rendering; - -namespace SharpClient.Tests.Rendering; - -/// -/// HTML-entity handling for Pueblo/MXP markup. In Pueblo HTML mode the server entity-encodes the -/// reserved characters (e.g. a literal '>' is sent as "&gt;"), so the client must decode them -/// on render — but only inside markup mode, and a lone/unknown '&' must pass through literally. -/// -public sealed class PuebloEntityTests -{ - private static MxpParserState Pueblo() => new() { IsPuebloActive = true }; - - private static string Render(string line, MxpParserState? mxp) - { - var sb = new System.Text.StringBuilder(); - foreach (var seg in AnsiParser.Parse(line, mxp)) - { - sb.Append(seg.Text); - } - - return sb.ToString(); - } - - [Test] - public async Task DecodesCoreEntitiesInPuebloMode() - { - await Assert.That(Render("5 > 3 && 2 < 4 "q"", Pueblo())) - .IsEqualTo("5 > 3 && 2 < 4 \"q\""); - } - - [Test] - public async Task DecodesAposAndNbsp() - { - await Assert.That(Render("it's here", Pueblo())).IsEqualTo("it's here"); - } - - [Test] - public async Task DecodesNumericDecimalAndHex() - { - await Assert.That(Render(">>&", Pueblo())).IsEqualTo(">>&"); - } - - [Test] - public async Task IgnoresControlRangeNumericEntities() - { - // U+0007 (BEL) is below U+0020: consumed but produces no output (matches MXP behaviour). - await Assert.That(Render("ab", Pueblo())).IsEqualTo("ab"); - } - - [Test] - public async Task LoneAmpersandPassesThroughLiterally() - { - await Assert.That(Render("Tom & Jerry", Pueblo())).IsEqualTo("Tom & Jerry"); - } - - [Test] - public async Task UnknownEntityPassesThroughLiterally() - { - await Assert.That(Render("x¬athing;y", Pueblo())).IsEqualTo("x¬athing;y"); - } - - [Test] - public async Task DoesNotDecodeEntitiesOutsideMarkupMode() - { - // No MXP/Pueblo active: '&' and entities are plain text, never decoded. - await Assert.That(Render("5 > 3", null)).IsEqualTo("5 > 3"); - } - - [Test] - public async Task DecodesEntitiesInMxpMode() - { - await Assert.That(Render("5 > 3", new MxpParserState { IsMxpActive = true })) - .IsEqualTo("5 > 3"); - } - - [Test] - public async Task EntityInsideAttributeValueIsDecoded() - { - var segs = AnsiParser.Parse("go", Pueblo()); - - await Assert.That(segs.Count).IsEqualTo(1); - await Assert.That(segs[0].Text).IsEqualTo("go"); - await Assert.That(segs[0].Command).IsEqualTo("say 5 > 3"); - } - - [Test] - public async Task RawGreaterThanInsideQuotedAttributeDoesNotTruncateTag() - { - // A literal '>' inside a quoted attribute must not end the tag early. - var segs = AnsiParser.Parse(" 3\">go", Pueblo()); - - await Assert.That(segs.Count).IsEqualTo(1); - await Assert.That(segs[0].Text).IsEqualTo("go"); - await Assert.That(segs[0].Command).IsEqualTo("say 5 > 3"); - } -} diff --git a/tests/SharpClient.Tests/Rendering/SegmentStyleTests.cs b/tests/SharpClient.Tests/Rendering/SegmentStyleTests.cs deleted file mode 100644 index fac2d57..0000000 --- a/tests/SharpClient.Tests/Rendering/SegmentStyleTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using SharpClient.Core.Rendering; - -namespace SharpClient.Tests.Rendering; - -public sealed class SegmentStyleTests -{ - private static StyledSegment Seg(TextStyle style) => new("x", style); - - [Test] - public async Task DefaultIsPhosphorForeground() - { - var css = SegmentStyle.ToCss(Seg(TextStyle.Default)); - - await Assert.That(css).IsEqualTo("color:#c4d1c8;"); - } - - [Test] - public async Task IndexedForegroundUsesPalette() - { - var css = SegmentStyle.ToCss(Seg(TextStyle.Default with { Foreground = AnsiColor.Indexed(1) })); - - await Assert.That(css).IsEqualTo("color:#e06c75;"); - } - - [Test] - public async Task TruecolorForegroundAndBackgroundAreHex() - { - var css = SegmentStyle.ToCss(Seg(TextStyle.Default with - { - Foreground = AnsiColor.Rgb(255, 128, 0), - Background = AnsiColor.Rgb(1, 2, 3), - })); - - await Assert.That(css).IsEqualTo("color:#ff8000;background:#010203;padding:0 2px;"); - } - - [Test] - public async Task IndexedBackgroundAddsPadding() - { - var css = SegmentStyle.ToCss(Seg(TextStyle.Default with { Background = AnsiColor.Indexed(4) })); - - await Assert.That(css).IsEqualTo("color:#c4d1c8;background:#61afef;padding:0 2px;"); - } - - [Test] - public async Task InverseSwapsForegroundAndBackground() - { - var css = SegmentStyle.ToCss(Seg(TextStyle.Default with { Foreground = AnsiColor.Indexed(2), Inverse = true })); - - await Assert.That(css).IsEqualTo("color:#090c10;background:#8fc16f;padding:0 2px;"); - } - - [Test] - public async Task InverseWithDefaultForegroundUsesPhosphorBackground() - { - var css = SegmentStyle.ToCss(Seg(TextStyle.Default with { Inverse = true })); - - await Assert.That(css).IsEqualTo("color:#090c10;background:#c4d1c8;padding:0 2px;"); - } - - [Test] - public async Task BoldAndUnderlineAppend() - { - var css = SegmentStyle.ToCss(Seg(TextStyle.Default with { Bold = true, Underline = true })); - - await Assert.That(css).IsEqualTo("color:#c4d1c8;font-weight:700;text-decoration:underline;"); - } -} diff --git a/tests/SharpClient.Tests/Sessions/RuntimeWiringTests.cs b/tests/SharpClient.Tests/Sessions/RuntimeWiringTests.cs index 699338c..f13c10a 100644 --- a/tests/SharpClient.Tests/Sessions/RuntimeWiringTests.cs +++ b/tests/SharpClient.Tests/Sessions/RuntimeWiringTests.cs @@ -289,7 +289,7 @@ public async Task IncomingLineFiresTriggerNotification() } [Test] - public async Task IncomingLineUsesAnsiParserWhenNoTriggerEngine() + public async Task IncomingLineIsDecodedWhenNoTriggerEngine() { var conn = new FakeTelnetConnection(); await using var session = new Session(conn); @@ -297,7 +297,7 @@ public async Task IncomingLineUsesAnsiParserWhenNoTriggerEngine() conn.Emit("plain text"); await Assert.That(session.Scrollback.Count).IsEqualTo(1); - await Assert.That(session.Scrollback[0].Segments[0].Text).IsEqualTo("plain text"); + await Assert.That(session.Scrollback[0].Content.Text).IsEqualTo("plain text"); } [Test] diff --git a/tests/SharpClient.Tests/Sessions/ScrollbackConcurrencyTests.cs b/tests/SharpClient.Tests/Sessions/ScrollbackConcurrencyTests.cs index 57538e8..057086a 100644 --- a/tests/SharpClient.Tests/Sessions/ScrollbackConcurrencyTests.cs +++ b/tests/SharpClient.Tests/Sessions/ScrollbackConcurrencyTests.cs @@ -43,7 +43,7 @@ public async Task EnumeratingScrollbackWhileLinesArriveDoesNotThrow() var seen = 0; foreach (var line in session.Scrollback) { - seen += line.Segments.Count; + seen += line.Content.Runs.Length; } } } diff --git a/tests/SharpClient.Tests/Sessions/SessionMxpTests.cs b/tests/SharpClient.Tests/Sessions/SessionMxpTests.cs index ab34387..c6fe728 100644 --- a/tests/SharpClient.Tests/Sessions/SessionMxpTests.cs +++ b/tests/SharpClient.Tests/Sessions/SessionMxpTests.cs @@ -1,9 +1,13 @@ using SharpClient.Core.Sessions; +using SharpClient.Tests.Rendering; namespace SharpClient.Tests.Sessions; public sealed class SessionMxpTests { + // What a Pueblo server sends once the client has answered PUEBLOCLIENT: leave MUD text for HTML. + private const string PuebloHtml = ""; + [Test] public async Task MxpSecureSendBecomesClickableSegment() { @@ -13,9 +17,9 @@ public async Task MxpSecureSendBecomesClickableSegment() conn.EnableMxp(); conn.Emit("\e[1zLook here"); - var line = session.Scrollback[^1]; - await Assert.That(line.Segments[0].Text).IsEqualTo("Look here"); - await Assert.That(line.Segments[0].Command).IsEqualTo("look"); + var spans = session.Scrollback[^1].Spans(); + await Assert.That(spans[0].Text).IsEqualTo("Look here"); + await Assert.That(spans[0].Command()).IsEqualTo("look"); } [Test] @@ -27,7 +31,7 @@ public async Task SendIsNotClickableWhenMxpNeverNegotiated() // No EnableMxp(): markup must pass through inert, never clickable. conn.Emit("\e[1zclick"); - await Assert.That(session.Scrollback[^1].Segments[0].Command).IsNull(); + await Assert.That(session.Scrollback[^1].Spans().All(s => s.Command() is null)).IsTrue(); } [Test] @@ -37,11 +41,12 @@ public async Task PuebloModeFromTheServerEnablesLinks() await using var session = new Session(conn); conn.EnablePueblo(); + conn.Emit(PuebloHtml); conn.Emit("North"); - var line = session.Scrollback[^1]; - await Assert.That(line.Segments[0].Text).IsEqualTo("North"); - await Assert.That(line.Segments[0].Command).IsEqualTo("go north"); + var spans = session.Scrollback[^1].Spans(); + await Assert.That(spans[0].Text).IsEqualTo("North"); + await Assert.That(spans[0].Command()).IsEqualTo("go north"); } [Test] @@ -53,10 +58,11 @@ public async Task QuotedPuebloHelloDoesNotEnablePueblo() // A player can say the hello. Only the server's start sequence (via TNC) enables Pueblo, so // the anchor that follows must stay inert and nothing may be sent. conn.Emit("Bob says, \"This world is Pueblo 1.10 Enhanced.\""); + conn.Emit(PuebloHtml); conn.Emit("free stuff"); await Assert.That(conn.Sent).IsEmpty(); - await Assert.That(session.Scrollback[^1].Segments.All(s => s.Command is null)).IsTrue(); + await Assert.That(session.Scrollback[^1].Spans().All(s => s.Command() is null)).IsTrue(); } [Test] @@ -67,10 +73,12 @@ public async Task MarkupStateIsDroppedWhenTheConnectionGoesDown() conn.RaiseState(SharpClient.Core.Connection.ConnectionState.Connected); conn.EnablePueblo(); + conn.Emit(PuebloHtml); conn.RaiseState(SharpClient.Core.Connection.ConnectionState.Reconnecting); conn.RaiseState(SharpClient.Core.Connection.ConnectionState.Connected); + conn.Emit(PuebloHtml); conn.Emit("North"); - await Assert.That(session.Scrollback[^1].Segments.All(s => s.Command is null)).IsTrue(); + await Assert.That(session.Scrollback[^1].Spans().All(s => s.Command() is null)).IsTrue(); } } diff --git a/tests/SharpClient.Tests/Sessions/SessionStateTests.cs b/tests/SharpClient.Tests/Sessions/SessionStateTests.cs index 49abdb9..7cc9932 100644 --- a/tests/SharpClient.Tests/Sessions/SessionStateTests.cs +++ b/tests/SharpClient.Tests/Sessions/SessionStateTests.cs @@ -30,7 +30,7 @@ public async Task SessionParsesLinesFromAnyConnection() fake.Emit("plain"); await Assert.That(session.Scrollback.Count).IsEqualTo(1); - await Assert.That(session.Scrollback[0].Segments[0].Text).IsEqualTo("plain"); + await Assert.That(session.Scrollback[0].Content.Text).IsEqualTo("plain"); } [Test] diff --git a/tests/SharpClient.Tests/Sessions/SessionTests.cs b/tests/SharpClient.Tests/Sessions/SessionTests.cs index a89f8ed..4f7a583 100644 --- a/tests/SharpClient.Tests/Sessions/SessionTests.cs +++ b/tests/SharpClient.Tests/Sessions/SessionTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using SharpClient.Core.Connection; using SharpClient.Core.Sessions; +using SharpClient.Tests.Rendering; using SharpClient.Tests.Connection; using TelnetNegotiationCore.Builders; @@ -46,8 +47,10 @@ public async Task ReceivedLineIsParsedIntoScrollback() await server.SendLineAsync("\e[31mAlert\e[0m"); var line = await appended.Task.WaitAsync(TimeSpan.FromSeconds(5)); - await Assert.That(line.Segments.Count).IsEqualTo(1); - await Assert.That(line.Segments[0].Text).IsEqualTo("Alert"); + var spans = line.Spans(); + await Assert.That(spans.Count).IsEqualTo(1); + await Assert.That(spans[0].Text).IsEqualTo("Alert"); + await Assert.That(spans[0].Color()).IsEqualTo("#e06c75"); await Assert.That(session.Scrollback.Count).IsEqualTo(1); } @@ -63,6 +66,6 @@ public async Task SendAppendsLocalEcho() await Assert.That(conn.Sent).Contains("hello"); // ...and locally echoed into the scrollback so the user sees what they typed. await Assert.That(session.Scrollback.Count).IsEqualTo(1); - await Assert.That(session.Scrollback[0].Segments[0].Text).Contains("hello"); + await Assert.That(session.Scrollback[0].Content.Text).IsEqualTo("> hello"); } } diff --git a/tests/SharpClient.Tests/Triggers/TriggerEngineTests.cs b/tests/SharpClient.Tests/Triggers/TriggerEngineTests.cs index 721f239..f4a0cd2 100644 --- a/tests/SharpClient.Tests/Triggers/TriggerEngineTests.cs +++ b/tests/SharpClient.Tests/Triggers/TriggerEngineTests.cs @@ -1,5 +1,6 @@ using SharpClient.Core.Domain; using SharpClient.Core.Rendering; +using SharpClient.Tests.Rendering; using SharpClient.Core.Triggers; namespace SharpClient.Tests.Triggers; @@ -8,24 +9,32 @@ public sealed class TriggerEngineTests { private readonly TriggerEngine _engine = new(); + private static TriggerOutcome Apply(TriggerEngine engine, string raw, IReadOnlyList rules) => + engine.Apply(raw, new OutputDecoder().Decode(raw), rules); + + private static IEnumerable Spans(TriggerOutcome outcome) => + outcome.Lines.SelectMany(line => line.Spans()); + + private static string Hex(int index) => MarkupString.Display.SpanRenderer.Hex(OutputPalette.Instance.Entry(index)); + [Test] - public async Task NoRulesReturnsAnsiParsedSegmentsAndEmptyActions() + public async Task NoRulesReturnsTheDecodedLinesAndEmptyActions() { - var outcome = _engine.Apply("hello world", []); + var decoded = new OutputDecoder().Decode("hello world"); + var outcome = _engine.Apply("hello world", decoded, []); - var expected = AnsiParser.Parse("hello world"); - await Assert.That(outcome.Segments.Count).IsEqualTo(expected.Count); - await Assert.That(outcome.Segments[0]).IsEqualTo(expected[0]); + await Assert.That(outcome.Lines).IsEqualTo(decoded); await Assert.That(outcome.SendCommands).IsEmpty(); await Assert.That(outcome.Notifications).IsEmpty(); } [Test] - public async Task NoRulesEmptyLineReturnsEmptySegmentsAndEmptyActions() + public async Task NoRulesEmptyLineReturnsOneEmptyLineAndEmptyActions() { - var outcome = _engine.Apply(string.Empty, []); + var outcome = Apply(_engine, string.Empty, []); - await Assert.That(outcome.Segments).IsEmpty(); + await Assert.That(outcome.Lines.Count).IsEqualTo(1); + await Assert.That(Spans(outcome)).IsEmpty(); await Assert.That(outcome.SendCommands).IsEmpty(); await Assert.That(outcome.Notifications).IsEmpty(); } @@ -41,7 +50,7 @@ public async Task SubstringMatchSendIsCollected() ActionValue = "attack dragon", }; - var outcome = _engine.Apply("A dragon appears!", [rule]); + var outcome = Apply(_engine, "A dragon appears!", [rule]); await Assert.That(outcome.SendCommands).Contains("attack dragon"); await Assert.That(outcome.Notifications).IsEmpty(); @@ -58,7 +67,7 @@ public async Task SubstringMatchNotifyIsCollected() ActionValue = "HP critical!", }; - var outcome = _engine.Apply("Your health low, be careful.", [rule]); + var outcome = Apply(_engine, "Your health low, be careful.", [rule]); await Assert.That(outcome.Notifications).Contains("HP critical!"); await Assert.That(outcome.SendCommands).IsEmpty(); @@ -75,7 +84,7 @@ public async Task RegexMatchSendIsCollected() ActionValue = "loot", }; - var outcome = _engine.Apply("You kill the goblin.", [rule]); + var outcome = Apply(_engine, "You kill the goblin.", [rule]); await Assert.That(outcome.SendCommands).Contains("loot"); } @@ -91,7 +100,7 @@ public async Task RegexNoMatchContributesNothing() ActionValue = "flee", }; - var outcome = _engine.Apply("A dragon appears!", [rule]); + var outcome = Apply(_engine, "A dragon appears!", [rule]); await Assert.That(outcome.SendCommands).IsEmpty(); } @@ -107,7 +116,7 @@ public async Task SubstringNoMatchContributesNothing() ActionValue = "flee", }; - var outcome = _engine.Apply("A dragon appears!", [rule]); + var outcome = Apply(_engine, "A dragon appears!", [rule]); await Assert.That(outcome.SendCommands).IsEmpty(); } @@ -124,7 +133,7 @@ public async Task DisabledRuleIsIgnored() Enabled = false, }; - var outcome = _engine.Apply("A dragon appears!", [rule]); + var outcome = Apply(_engine, "A dragon appears!", [rule]); await Assert.That(outcome.SendCommands).IsEmpty(); } @@ -140,13 +149,13 @@ public async Task InvalidRegexPatternDoesNotThrowTreatedAsNoMatch() ActionValue = "should not appear", }; - var outcome = _engine.Apply("some line", [rule]); + var outcome = Apply(_engine, "some line", [rule]); await Assert.That(outcome.SendCommands).IsEmpty(); } [Test] - public async Task HighlightRestylesAllSegmentsWithGivenIndex() + public async Task HighlightRestylesAllSpansWithGivenIndex() { var rule = new TriggerRule { @@ -156,17 +165,17 @@ public async Task HighlightRestylesAllSegmentsWithGivenIndex() ActionValue = "196", }; - var outcome = _engine.Apply("the red dragon", [rule]); + var outcome = Apply(_engine, "the red dragon", [rule]); - await Assert.That(outcome.Segments).IsNotEmpty(); - foreach (var segment in outcome.Segments) + await Assert.That(Spans(outcome)).IsNotEmpty(); + foreach (var segment in Spans(outcome)) { - await Assert.That(segment.Style.Foreground).IsEqualTo(AnsiColor.Indexed(196)); + await Assert.That(segment.Color()).IsEqualTo(Hex(196)); } } [Test] - public async Task HighlightWithAnsiInputRestylesAllSegments() + public async Task HighlightWithAnsiInputRestylesAllSpans() { var rule = new TriggerRule { @@ -176,12 +185,12 @@ public async Task HighlightWithAnsiInputRestylesAllSegments() ActionValue = "9", }; - // Two segments: "danger" (red fg) and " ahead" (default) - var outcome = _engine.Apply("\e[31mdanger\e[0m ahead", [rule]); + // "danger" is red and " ahead" default; the highlight makes both one colour, so one span. + var outcome = Apply(_engine, "\e[31mdanger\e[0m ahead", [rule]); - await Assert.That(outcome.Segments.Count).IsEqualTo(2); - await Assert.That(outcome.Segments[0].Style.Foreground).IsEqualTo(AnsiColor.Indexed(9)); - await Assert.That(outcome.Segments[1].Style.Foreground).IsEqualTo(AnsiColor.Indexed(9)); + var spans = Spans(outcome).ToList(); + await Assert.That(spans.Select(s => s.Text)).IsEquivalentTo(["danger ahead"]); + await Assert.That(spans[0].Color()).IsEqualTo(Hex(9)); } [Test] @@ -195,9 +204,9 @@ public async Task HighlightInvalidActionValueDoesNotApplyHighlight() ActionValue = "not-a-number", }; - var outcome = _engine.Apply("hello world", [rule]); + var outcome = Apply(_engine, "hello world", [rule]); - await Assert.That(outcome.Segments[0].Style.Foreground).IsEqualTo(AnsiColor.Default); + await Assert.That(Spans(outcome).Single().Color()).IsNull(); } [Test] @@ -218,7 +227,7 @@ public async Task MultipleMatchingRulesAccumulate() ActionValue = "Dragon spotted!", }; - var outcome = _engine.Apply("The dragon roars.", [sendRule, notifyRule]); + var outcome = Apply(_engine, "The dragon roars.", [sendRule, notifyRule]); await Assert.That(outcome.SendCommands).Contains("attack"); await Assert.That(outcome.Notifications).Contains("Dragon spotted!"); @@ -242,11 +251,11 @@ public async Task LaterHighlightRuleOverridesEarlierOne() ActionValue = "9", }; - var outcome = _engine.Apply("The dragon roars.", [first, second]); + var outcome = Apply(_engine, "The dragon roars.", [first, second]); - foreach (var segment in outcome.Segments) + foreach (var segment in Spans(outcome)) { - await Assert.That(segment.Style.Foreground).IsEqualTo(AnsiColor.Indexed(9)); + await Assert.That(segment.Color()).IsEqualTo(Hex(9)); } } @@ -268,7 +277,7 @@ public async Task MultipleSendCommandsAllCollected() ActionValue = "loot", }; - var outcome = _engine.Apply("The dragon dies.", [rule1, rule2]); + var outcome = Apply(_engine, "The dragon dies.", [rule1, rule2]); await Assert.That(outcome.SendCommands.Count).IsEqualTo(2); await Assert.That(outcome.SendCommands).Contains("attack"); @@ -295,17 +304,17 @@ public async Task HighlightOutOfRangeActionValueSkippedFirstColourKept() ActionValue = "256", // out of range — skipped }; - var outcome = _engine.Apply("brave hero stands", [first, second]); + var outcome = Apply(_engine, "brave hero stands", [first, second]); - await Assert.That(outcome.Segments).IsNotEmpty(); - foreach (var segment in outcome.Segments) + await Assert.That(Spans(outcome)).IsNotEmpty(); + foreach (var segment in Spans(outcome)) { - await Assert.That(segment.Style.Foreground).IsEqualTo(AnsiColor.Indexed(3)); + await Assert.That(segment.Color()).IsEqualTo(Hex(3)); } } [Test] - public async Task HighlightOnlyOutOfRangeActionValueLeavesSegmentsDefault() + public async Task HighlightOnlyOutOfRangeActionValueLeavesSpansDefault() { var rule = new TriggerRule { @@ -315,12 +324,12 @@ public async Task HighlightOnlyOutOfRangeActionValueLeavesSegmentsDefault() ActionValue = "256", // out of range — no highlight applied }; - var outcome = _engine.Apply("brave hero stands", [rule]); + var outcome = Apply(_engine, "brave hero stands", [rule]); - await Assert.That(outcome.Segments).IsNotEmpty(); - foreach (var segment in outcome.Segments) + await Assert.That(Spans(outcome)).IsNotEmpty(); + foreach (var segment in Spans(outcome)) { - await Assert.That(segment.Style.Foreground).IsEqualTo(AnsiColor.Default); + await Assert.That(segment.Color()).IsNull(); } } } diff --git a/tests/SharpClient.UI.Tests/OutputViewLinkTests.cs b/tests/SharpClient.UI.Tests/OutputViewLinkTests.cs index 734e6ee..6a49a83 100644 --- a/tests/SharpClient.UI.Tests/OutputViewLinkTests.cs +++ b/tests/SharpClient.UI.Tests/OutputViewLinkTests.cs @@ -7,35 +7,77 @@ namespace SharpClient.UI.Tests; public sealed class OutputViewLinkTests { - private static ScrollbackLine Line(params StyledSegment[] segments) => new(segments); + private static ScrollbackLine[] Mxp(string raw) + { + var decoder = new OutputDecoder(); + decoder.EnableMxp(); + return [.. decoder.Decode(raw).Select(content => new ScrollbackLine(content))]; + } + + private static ScrollbackLine[] Pueblo(string raw) + { + var decoder = new OutputDecoder(); + decoder.EnablePueblo(); + decoder.Decode(""); + return [.. decoder.Decode(raw).Select(content => new ScrollbackLine(content))]; + } [Test] - public async Task CommandSegmentRendersClickableButtonThatSendsCommand() + public async Task CommandLinkRendersClickableButtonThatSendsCommand() { using var ctx = new BunitContext(); string? sent = null; - var line = Line(new StyledSegment("North", TextStyle.Default) { Command = "go north", Hint = "Move north" }); - var cut = ctx.Render(p => p - .Add(c => c.Lines, new[] { line }) + .Add(c => c.Lines, Mxp("\e[1zNorth")) .Add(c => c.OnSendCommand, cmd => { sent = cmd; return Task.CompletedTask; })); var button = cut.Find("button.sc-link"); await Assert.That(button.TextContent).IsEqualTo("North"); await Assert.That(button.GetAttribute("title")).IsEqualTo("Move north"); - button.Click(); await Assert.That(sent).IsEqualTo("go north"); } [Test] - public async Task PlainSegmentStillRendersSpanNotButton() + public async Task CommandLinkKeepsItsColour() + { + using var ctx = new BunitContext(); + var cut = ctx.Render(p => p + .Add(c => c.Lines, Mxp("\e[1z\e[31mAttack\e[0m")) + .Add(c => c.OnSendCommand, _ => Task.CompletedTask)); + + await Assert.That(cut.Find("button.sc-link").GetAttribute("style")).IsEqualTo("color:#e06c75"); + } + + [Test] + public async Task CommandLinkWithoutAHandlerIsText() { using var ctx = new BunitContext(); - var line = Line(new StyledSegment("plain", TextStyle.Default)); + var cut = ctx.Render(p => p.Add(c => c.Lines, Mxp("\e[1zgo"))); + await Assert.That(cut.FindAll("button").Count).IsEqualTo(0); + await Assert.That(cut.Find("span").TextContent).IsEqualTo("go"); + } + + [Test] + public async Task PuebloCommandLinkIsAButton() + { + using var ctx = new BunitContext(); + string? sent = null; + var cut = ctx.Render(p => p + .Add(c => c.Lines, Pueblo("North")) + .Add(c => c.OnSendCommand, cmd => { sent = cmd; return Task.CompletedTask; })); + + cut.Find("button.sc-link").Click(); + await Assert.That(sent).IsEqualTo("go north"); + } + + [Test] + public async Task PlainTextStillRendersSpanNotButton() + { + using var ctx = new BunitContext(); var cut = ctx.Render(p => p - .Add(c => c.Lines, new[] { line }) + .Add(c => c.Lines, Mxp("plain")) .Add(c => c.OnSendCommand, _ => Task.CompletedTask)); await Assert.That(cut.FindAll("button.sc-link").Count).IsEqualTo(0); @@ -43,29 +85,74 @@ public async Task PlainSegmentStillRendersSpanNotButton() } [Test] - public async Task ImageSegmentRendersImgWithoutReferrer() + public async Task MenuSendsTheFirstCommandAndListsTheRest() { using var ctx = new BunitContext(); - var line = Line(new StyledSegment("a cat", TextStyle.Default) { ImageUrl = "https://h.example/cat.gif" }); + var sent = new List(); + var cut = ctx.Render(p => p + .Add(c => c.Lines, Mxp("\e[1zsword")) + .Add(c => c.OnSendCommand, cmd => { sent.Add(cmd); return Task.CompletedTask; })); + + cut.Find("button.sc-link").Click(); + await Assert.That(cut.FindAll("button.sc-menu-choice").Count).IsEqualTo(0); + + cut.Find("button.sc-menu-toggle").Click(); + var choices = cut.FindAll("button.sc-menu-choice"); + await Assert.That(choices.Select(c => c.TextContent)).IsEquivalentTo(["Buy", "Look"]); + choices[1].Click(); - var cut = ctx.Render(p => p.Add(c => c.Lines, new[] { line })); + await Assert.That(sent).IsEquivalentTo(["buy sword", "look sword"]); + await Assert.That(cut.FindAll("button.sc-menu-choice").Count).IsEqualTo(0); + } + + [Test] + public async Task WebLinkOpensInANewWindowWithoutAReferrer() + { + using var ctx = new BunitContext(); + var cut = ctx.Render(p => p.Add(c => c.Lines, + Mxp("see \e]8;;https://example.com/help\e\\help\e]8;;\e\\"))); + + var link = cut.Find("a.sc-link"); + await Assert.That(link.GetAttribute("href")).IsEqualTo("https://example.com/help"); + await Assert.That(link.GetAttribute("target")).IsEqualTo("_blank"); + await Assert.That(link.GetAttribute("rel")).IsEqualTo("noopener noreferrer"); + await Assert.That(link.TextContent).IsEqualTo("help"); + } + + [Test] + public async Task ImageRendersImgWithoutReferrer() + { + using var ctx = new BunitContext(); + var cut = ctx.Render(p => p.Add(c => c.Lines, + Pueblo("\"a"))); var img = cut.Find("img.sc-image"); await Assert.That(img.GetAttribute("src")).IsEqualTo("https://h.example/cat.gif"); await Assert.That(img.GetAttribute("alt")).IsEqualTo("a cat"); + await Assert.That(img.GetAttribute("width")).IsEqualTo("40"); await Assert.That(img.GetAttribute("referrerpolicy")).IsEqualTo("no-referrer"); await Assert.That(cut.FindAll("button").Count).IsEqualTo(0); } + [Test] + public async Task MxpImageIsNamedAfterItsFile() + { + using var ctx = new BunitContext(); + var cut = ctx.Render(p => p.Add(c => c.Lines, + Mxp("\e[1z"))); + + var img = cut.Find("img.sc-image"); + await Assert.That(img.GetAttribute("src")).IsEqualTo("https://h.example/img/maps/map.png"); + await Assert.That(img.GetAttribute("alt")).IsEqualTo("map.png"); + } + [Test] public async Task ImageInsideCommandLinkIsAClickableButton() { using var ctx = new BunitContext(); string? sent = null; - var line = Line(new StyledSegment("map", TextStyle.Default) { ImageUrl = "https://h.example/m.png", Command = "look map" }); - var cut = ctx.Render(p => p - .Add(c => c.Lines, new[] { line }) + .Add(c => c.Lines, Mxp("\e[1z")) .Add(c => c.OnSendCommand, cmd => { sent = cmd; return Task.CompletedTask; })); cut.Find("button.sc-image-link img.sc-image"); @@ -77,13 +164,24 @@ public async Task ImageInsideCommandLinkIsAClickableButton() public async Task ImagesOffRendersTheTextInstead() { using var ctx = new BunitContext(); - var line = Line(new StyledSegment("a cat", TextStyle.Default) { ImageUrl = "https://h.example/cat.gif" }); - var cut = ctx.Render(p => p - .Add(c => c.Lines, new[] { line }) + .Add(c => c.Lines, Pueblo("\"a")) .Add(c => c.ShowImages, false)); await Assert.That(cut.FindAll("img").Count).IsEqualTo(0); await Assert.That(cut.Markup).Contains("a cat"); } + + [Test] + [Arguments("javascript:alert(1)")] + [Arguments("file:///etc/passwd")] + [Arguments("/local/path.png")] + public async Task RefusedImageSourceShowsNothing(string src) + { + using var ctx = new BunitContext(); + var cut = ctx.Render(p => p.Add(c => c.Lines, Pueblo($"\"x\""))); + + await Assert.That(cut.FindAll("img").Count).IsEqualTo(0); + await Assert.That(cut.FindAll("span").Count).IsEqualTo(0); + } } diff --git a/tests/SharpClient.UI.Tests/OutputViewTests.cs b/tests/SharpClient.UI.Tests/OutputViewTests.cs index f8c4ff2..73217d6 100644 --- a/tests/SharpClient.UI.Tests/OutputViewTests.cs +++ b/tests/SharpClient.UI.Tests/OutputViewTests.cs @@ -7,33 +7,75 @@ namespace SharpClient.UI.Tests; public sealed class OutputViewTests { - private static ScrollbackLine Line(params StyledSegment[] segments) => new(segments); + private static ScrollbackLine[] Lines(string raw) => + [.. new OutputDecoder().Decode(raw).Select(content => new ScrollbackLine(content))]; [Test] - public async Task RendersOneSpanPerSegmentWithRenderContractStyle() + public async Task RendersOneSpanPerStyleWithTheClientPalette() { using var ctx = new BunitContext(); - var line = Line( - new StyledSegment("red", TextStyle.Default with { Foreground = AnsiColor.Indexed(1) }), - new StyledSegment(" plain", TextStyle.Default)); - var cut = ctx.Render(p => p.Add(c => c.Lines, new[] { line })); + var cut = ctx.Render(p => p.Add(c => c.Lines, Lines("\e[31mred\e[0m plain"))); var spans = cut.FindAll("span"); await Assert.That(spans.Count).IsEqualTo(2); - await Assert.That(spans[0].GetAttribute("style")).IsEqualTo("color:#e06c75;"); + await Assert.That(spans[0].GetAttribute("style")).IsEqualTo("color:#e06c75"); await Assert.That(spans[0].TextContent).IsEqualTo("red"); - await Assert.That(spans[1].GetAttribute("style")).IsEqualTo("color:#c4d1c8;"); + // Default text takes .sc-line's colour. + await Assert.That(spans[1].GetAttribute("style")).IsNull(); + await Assert.That(spans[1].TextContent).IsEqualTo(" plain"); } [Test] - public async Task EmptySegmentRendersNonBreakingSpace() + public async Task BackgroundGetsPadding() { using var ctx = new BunitContext(); - var line = Line(new StyledSegment(string.Empty, TextStyle.Default)); - var cut = ctx.Render(p => p.Add(c => c.Lines, new[] { line })); + var cut = ctx.Render(p => p.Add(c => c.Lines, Lines("\e[44mbg"))); - await Assert.That(cut.Find("span").TextContent).IsEqualTo(" "); + await Assert.That(cut.Find("span").GetAttribute("style")).IsEqualTo("background-color:#61afef;padding:0 2px"); + } + + [Test] + public async Task AttributesAreDrawn() + { + using var ctx = new BunitContext(); + + var cut = ctx.Render(p => p.Add(c => c.Lines, Lines("\e[1;3;4;9mx\e[0m \e[5mblink"))); + + var spans = cut.FindAll("span"); + await Assert.That(spans[0].GetAttribute("style")).IsEqualTo("font-weight:bold;font-style:italic;text-decoration:underline line-through"); + await Assert.That(spans[2].ClassList).Contains("ms-blink"); + } + + [Test] + public async Task EmptyLineRendersAnEmptyLine() + { + using var ctx = new BunitContext(); + + var cut = ctx.Render(p => p.Add(c => c.Lines, Lines(string.Empty))); + + await Assert.That(cut.FindAll("div.sc-line").Count).IsEqualTo(1); + await Assert.That(cut.FindAll("span").Count).IsEqualTo(0); + } + + [Test] + public async Task TriggerHighlightReplacesTheServersColour() + { + using var ctx = new BunitContext(); + var rule = new SharpClient.Core.Domain.TriggerRule + { + Kind = SharpClient.Core.Domain.TriggerKind.Substring, + Pattern = "danger", + Action = SharpClient.Core.Domain.TriggerActionKind.Highlight, + ActionValue = "9", + }; + const string raw = "\e[32mdanger\e[0m ahead"; + var outcome = new SharpClient.Core.Triggers.TriggerEngine().Apply(raw, new OutputDecoder().Decode(raw), [rule]); + + var cut = ctx.Render(p => p.Add(c => c.Lines, outcome.Lines.Select(l => new ScrollbackLine(l)).ToArray())); + + await Assert.That(cut.Find("span").GetAttribute("style")).IsEqualTo("color:#ff8088"); + await Assert.That(cut.Find("span").TextContent).IsEqualTo("danger ahead"); } }