diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7cf19ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build-and-test: + name: build & test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up .NET 10 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore MuGlyph.slnx + + - name: Build + run: dotnet build MuGlyph.slnx -c Release --no-restore + + # TUnit runs on Microsoft.Testing.Platform; execute each test project directly + # (the classic `dotnet test`/VSTest path is not used by MTP on .NET 10). + - name: Test — Core + shell: bash + run: dotnet run -c Release --no-build --project tests/MuClient.Core.Tests/MuClient.Core.Tests.csproj + + - name: Test — Graphics + shell: bash + run: dotnet run -c Release --no-build --project tests/MuClient.Graphics.Tests/MuClient.Graphics.Tests.csproj + + - name: Test — Scripting + shell: bash + run: dotnet run -c Release --no-build --project tests/MuClient.Scripting.Tests/MuClient.Scripting.Tests.csproj diff --git a/CLAUDE.md b/CLAUDE.md index 62aa1a3..0778faa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,8 +27,31 @@ fallbacks) for inline images/maps. ## Repository state -Skeleton only: `README.md`, `LICENSE`, `.gitignore`, `.editorconfig`, `docs/PLAN.md`, this file. -No solution or code exists yet. The next unit of work is **milestone M1** (below). +**M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all seven projects on +`net10.0`; the solution has **195 passing tests**. In place: + +- **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, + `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**), + trigger/alias/macro engines + `IntervalScheduler`, plain-text + HTML logging, JSON config + + BeipMU importer, `Theme`/`ThemeLibrary`, and `WorldSession`/`SessionManager` orchestration. +- **Graphics** — Kitty encoder + Unicode placeholders, Sixel + half-block fallbacks, capability + probe (no UI dependency). +- **Scripting** — sandboxed MoonSharp `ScriptHost` (world/output/trigger/alias/timer/gmcp/log). +- **Tui** — Terminal.Gui v2 app (truecolor `OutputView`, `CommandInput`, theming, key routing). + +### Notes for future agents (learned the hard way) +- **.NET 10 SDK**: install via `apt-get install -y dotnet-sdk-10.0` (the Microsoft CDN is often + blocked; Ubuntu's repo works). NuGet (`api.nuget.org`) is reachable. +- **Tests use TUnit**, not xUnit — projects are `Exe` on Microsoft.Testing.Platform. Run them with + `dotnet run --project `; `dotnet test` is **not** wired up (MTP opt-in doesn't work on + this SDK). +- **TelnetNegotiationCore 2.5.3** has a fluent builder API (not the 1.0.0 the plan assumed) and now + provides MCCP/MSDP/MXP negotiation itself. `TelnetSession` sets the init-only + `CallbackOnByteAsync` reflectively to get raw data bytes (incl. unterminated prompts) — a + first-class `OnByte` builder hook is a good upstream PR. +- **Terminal.Gui v2** (2.4.x-develop) dropped `Toplevel`/`TabView`; use `IRunnable`/`Window` and + override `OnDrawingContent(DrawContext)`. The static `Application` API is `[Obsolete]` mid-migration + (suppressed via `NoWarn` in the Tui project). ## Architecture rule (non-negotiable) diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..32cff72 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + net10.0 + latest + enable + enable + false + false + true + true + Harry Cordewener + MuGlyph + Copyright (c) 2026 Harry Cordewener + MIT + https://github.com/HarryCordewener/MuGlyph + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..7efe5d0 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,25 @@ + + + + + true + + + + + + + + + + + + + + + + + + + + diff --git a/MuGlyph.slnx b/MuGlyph.slnx new file mode 100644 index 0000000..5df29b3 --- /dev/null +++ b/MuGlyph.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/README.md b/README.md index f1ccb0c..024709e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,10 @@ GPU-accelerated terminals (Kitty, WezTerm, Ghostty) on **Windows and Linux**. The goal is [BeipMU](https://beipdev.github.io/BeipMU/)-class feature parity in a terminal-native client: rich truecolor text, inline graphics, powerful automation, and full MU\* protocol support. -> **Status:** early scaffolding. See [`docs/PLAN.md`](docs/PLAN.md) for the architecture and roadmap. +> **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work +> from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. +> `MuClient.Core` is fully unit-tested (195 tests across the solution). See +> [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI @@ -37,16 +40,62 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. | `MuClient.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` | | `MuClient.Scripting` | MoonSharp host + scripting API | | `MuClient.Tui` | Terminal.Gui v2 application (windows, panes, settings, wiring) | -| `*.Tests` | xUnit test projects | +| `*.Tests` | TUnit test projects | + +## What works today + +- **Transport & telnet** — TCP with optional TLS (`SslStream`) and IPv6, wrapping + [TelnetNegotiationCore] for NAWS/MTTS/CHARSET/EOR/GA/GMCP/MSSP/MSDP/MCCP negotiation. + Prompt (GA/EOR) detection surfaces prompts separately from scrollback. +- **ANSI parser** — incremental SGR parsing: 16-colour, 256-colour, and 24-bit truecolour + (semicolon and colon forms), with the usual rendition attributes; non-SGR CSI/OSC sequences + are recognised and discarded. +- **Scrollback** — bounded, thread-safe styled-line model with change events. +- **Automation** — regex **triggers** (gag / highlight / rewrite / respond / spawn-route / + script), **aliases** (capture-group expansion, multi-command), **macros/keybinds**, and a + recurring/one-shot **timer** scheduler. +- **Logging** — plain-text and styled **HTML** session logs. +- **Config** — a fresh JSON schema plus a best-effort **BeipMU importer**. +- **Inline graphics** — Kitty graphics-protocol encoder (incl. Unicode placeholders), Sixel and + half-block fallbacks, and a capability probe that degrades cleanly when no protocol is present. +- **Scripting** — sandboxed **Lua** (MoonSharp) exposing `world`/`output`/`trigger`/`alias`/ + `timer`/`gmcp`/`log`, with hot-reload. +- **Theming** — yazi-style named themes (Dark / Light / Solarized Dark) with a 16-colour palette + override and semantic UI colours, serialised to the config as hex. +- **TUI** — a Terminal.Gui v2 app: truecolor output pane with wrapping/scrollback, command input + with history and tab-completion, status line, and key routing. ## Building +Requires the **.NET 10 SDK**. + +```bash +dotnet build MuGlyph.slnx -c Release +``` + +## Running + ```bash -dotnet build +dotnet run --project src/MuClient.Tui -- [--tls] [--insecure] [--name NAME] +muglyph --help # once published ``` -Requires the .NET 10 SDK. (Nothing to build yet — projects land in milestone M1.) +In-app: **PgUp/PgDn** scroll · **Up/Down** input history · **Tab** complete · **Ctrl+Q** quit. + +## Testing + +The test projects use [TUnit], which runs on the Microsoft.Testing.Platform. Run each directly +(the classic `dotnet test`/VSTest path is not used by MTP on .NET 10): + +```bash +dotnet run --project tests/MuClient.Core.Tests +dotnet run --project tests/MuClient.Graphics.Tests +dotnet run --project tests/MuClient.Scripting.Tests +``` ## License [MIT](LICENSE) © 2026 Harry Cordewener + +[TelnetNegotiationCore]: https://www.nuget.org/packages/TelnetNegotiationCore/ +[TUnit]: https://github.com/thomhurst/TUnit diff --git a/examples/example.lua b/examples/example.lua new file mode 100644 index 0000000..296d5de --- /dev/null +++ b/examples/example.lua @@ -0,0 +1,36 @@ +-- Example MuGlyph Lua script. +-- Loaded per-world; the sandbox exposes: world, output, trigger, alias, timer, gmcp, log. +-- (No io/os.execute/require — scripting is sandboxed.) + +output.print("Loaded example.lua for world: " .. world.name) + +-- Trigger: callback receives the whole match followed by each capture group. +trigger.add("(%w+) waves at you", function(whole, who) + world.send("wave " .. who) + output.print("Waved back at " .. who) +end) + +-- Alias: string form with $1..$9 capture references. +alias.add("^gt (.+)", "grouptell $1") + +-- Alias: function form (same call shape as triggers). +alias.add("^hp$", function() + world.send("score") +end) + +-- Timer: recurring; returns a handle with :cancel(). +local keepalive = timer.every(60000, function() + world.send("") -- blank line keeps some servers from idling us out +end) + +-- Timer: one-shot. +timer.after(2000, function() + output.print("Two seconds since load.") +end) + +-- GMCP: a handler registered for "Char" also fires for "Char.Vitals", etc. +gmcp.on("Char.Vitals", function(json) + output.print("Vitals update: " .. json) +end) + +log.info("example.lua ready") diff --git a/src/MuClient.Core/Automation/Alias.cs b/src/MuClient.Core/Automation/Alias.cs new file mode 100644 index 0000000..f45df92 --- /dev/null +++ b/src/MuClient.Core/Automation/Alias.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace MuClient.Core.Automation; + +/// +/// A command alias: when user input matches , it is expanded into one +/// or more commands via (with $1..$9 / ${name} +/// capture references). Newlines in the substitution produce multiple commands. +/// +public sealed class Alias +{ + private Regex? _compiled; + + public string Name { get; init; } = string.Empty; + + public required string Pattern { get; init; } + + public bool Enabled { get; set; } = true; + + public bool CaseSensitive { get; init; } + + /// The expansion template. May contain multiple newline-separated commands. + public string Substitution { get; init; } = string.Empty; + + /// Optional named script callback invoked instead of / in addition to expansion. + public string? ScriptCallback { get; init; } + + [JsonIgnore] + public Regex Regex => _compiled ??= new Regex( + Pattern, + RegexOptions.Compiled | (CaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase), + AutomationDefaults.RegexMatchTimeout); +} diff --git a/src/MuClient.Core/Automation/AliasEngine.cs b/src/MuClient.Core/Automation/AliasEngine.cs new file mode 100644 index 0000000..c75bc62 --- /dev/null +++ b/src/MuClient.Core/Automation/AliasEngine.cs @@ -0,0 +1,142 @@ +using System.Text.RegularExpressions; + +namespace MuClient.Core.Automation; + +/// The result of expanding a line of user input against the alias set. +public sealed class AliasResult +{ + private AliasResult(bool matched, Alias? alias, IReadOnlyList commands, Match? match) + { + Matched = matched; + Alias = alias; + Commands = commands; + Match = match; + } + + /// True if an alias matched the input. + public bool Matched { get; } + + /// The alias that matched, if any. + public Alias? Alias { get; } + + /// The expanded commands to send (empty when the alias is script-only). + public IReadOnlyList Commands { get; } + + /// The regex match (for script callbacks), if any. + public Match? Match { get; } + + public static readonly AliasResult NoMatch = new(false, null, Array.Empty(), null); + + public static AliasResult Hit(Alias alias, IReadOnlyList commands, Match match) => + new(true, alias, commands, match); +} + +/// Expands user input through an ordered set of es (first match wins). +public sealed class AliasEngine +{ + private readonly List _aliases = new(); + private readonly object _gate = new(); + + public AliasEngine(IEnumerable? aliases = null) + { + if (aliases is not null) + { + _aliases.AddRange(aliases); + } + } + + public IReadOnlyList Aliases + { + get + { + lock (_gate) + { + return _aliases.ToArray(); + } + } + } + + public void Add(Alias alias) + { + ArgumentNullException.ThrowIfNull(alias); + lock (_gate) + { + _aliases.Add(alias); + } + } + + public bool Remove(Alias alias) + { + lock (_gate) + { + return _aliases.Remove(alias); + } + } + + public void Clear() + { + lock (_gate) + { + _aliases.Clear(); + } + } + + /// + /// Returns the expansion of the first enabled alias matching , or + /// if none match (caller should send the input verbatim). + /// + public AliasResult Expand(string input) + { + ArgumentNullException.ThrowIfNull(input); + + Alias[] snapshot; + lock (_gate) + { + snapshot = _aliases.ToArray(); + } + + foreach (var alias in snapshot) + { + if (!alias.Enabled) + { + continue; + } + + Match match; + try + { + match = alias.Regex.Match(input); + } + catch (RegexMatchTimeoutException) + { + // A pathological pattern timed out; skip this alias rather than hang input. + continue; + } + + if (!match.Success) + { + continue; + } + + var commands = ExpandCommands(alias, match); + return AliasResult.Hit(alias, commands, match); + } + + return AliasResult.NoMatch; + } + + private static IReadOnlyList ExpandCommands(Alias alias, Match match) + { + if (string.IsNullOrEmpty(alias.Substitution)) + { + return Array.Empty(); + } + + var expanded = match.Result(alias.Substitution); + return expanded + .Split('\n') + .Select(c => c.TrimEnd('\r')) + .Where(c => c.Length > 0) + .ToArray(); + } +} diff --git a/src/MuClient.Core/Automation/AutomationDefaults.cs b/src/MuClient.Core/Automation/AutomationDefaults.cs new file mode 100644 index 0000000..e889ba1 --- /dev/null +++ b/src/MuClient.Core/Automation/AutomationDefaults.cs @@ -0,0 +1,11 @@ +namespace MuClient.Core.Automation; + +/// Shared defaults for the automation engines. +public static class AutomationDefaults +{ + /// + /// Match timeout applied to user-supplied trigger/alias regexes so a pathological pattern + /// (catastrophic backtracking) cannot hang output processing or command entry. + /// + public static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromMilliseconds(250); +} diff --git a/src/MuClient.Core/Automation/IntervalScheduler.cs b/src/MuClient.Core/Automation/IntervalScheduler.cs new file mode 100644 index 0000000..68bf671 --- /dev/null +++ b/src/MuClient.Core/Automation/IntervalScheduler.cs @@ -0,0 +1,136 @@ +namespace MuClient.Core.Automation; + +/// +/// Schedules recurring and one-shot callbacks. Backs script timer.every/timer.after +/// and any client-side periodic work. Each schedule returns an handle +/// that cancels it; disposing the scheduler cancels everything. +/// +public sealed class IntervalScheduler : IDisposable +{ + private readonly HashSet _handles = new(); + private readonly object _gate = new(); + private bool _disposed; + + /// Invokes every until cancelled. + public IDisposable Every(TimeSpan interval, Action callback, string? name = null) + { + ArgumentNullException.ThrowIfNull(callback); + if (interval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(interval), "Interval must be positive."); + } + + return Schedule(callback, interval, interval, name); + } + + /// Invokes once after . + public IDisposable After(TimeSpan delay, Action callback, string? name = null) + { + ArgumentNullException.ThrowIfNull(callback); + if (delay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(delay), "Delay must be non-negative."); + } + + return Schedule(callback, delay, Timeout.InfiniteTimeSpan, name); + } + + /// The number of live schedules. + public int Count + { + get + { + lock (_gate) + { + return _handles.Count; + } + } + } + + private IDisposable Schedule(Action callback, TimeSpan due, TimeSpan period, string? name) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var handle = new Handle(this, name, period == Timeout.InfiniteTimeSpan); + handle.Timer = new Timer( + _ => + { + try + { + callback(); + } + catch + { + // A throwing callback must never escape onto the ThreadPool thread and + // crash the process, nor stop a recurring schedule. Callers that need to + // observe errors (e.g. the script host) guard their own callbacks. + } + finally + { + if (handle.OneShot) + { + handle.Dispose(); + } + } + }, + null, + due, + period); + + _handles.Add(handle); + return handle; + } + } + + private void Unregister(Handle handle) + { + lock (_gate) + { + _handles.Remove(handle); + } + } + + public void Dispose() + { + Handle[] snapshot; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + snapshot = _handles.ToArray(); + _handles.Clear(); + } + + foreach (var handle in snapshot) + { + handle.DisposeTimerOnly(); + } + } + + private sealed class Handle(IntervalScheduler owner, string? name, bool oneShot) : IDisposable + { + public Timer? Timer { get; set; } + + public string? Name { get; } = name; + + public bool OneShot { get; } = oneShot; + + public void Dispose() + { + DisposeTimerOnly(); + owner.Unregister(this); + } + + public void DisposeTimerOnly() + { + Timer?.Dispose(); + Timer = null; + } + } +} diff --git a/src/MuClient.Core/Automation/Macro.cs b/src/MuClient.Core/Automation/Macro.cs new file mode 100644 index 0000000..686532d --- /dev/null +++ b/src/MuClient.Core/Automation/Macro.cs @@ -0,0 +1,53 @@ +namespace MuClient.Core.Automation; + +/// +/// A keybind: a normalised key descriptor (e.g. Ctrl+F1, Alt+k, F3) mapped +/// to a command to send, or a named script callback. The UI layer is responsible for +/// translating concrete key events into descriptor strings via . +/// +public sealed class Macro +{ + public string Name { get; init; } = string.Empty; + + /// The normalised key descriptor that triggers this macro. + public required string Key { get; init; } + + public bool Enabled { get; set; } = true; + + /// The command to send when the key is pressed. + public string Command { get; init; } = string.Empty; + + /// Optional named script callback (resolved by the scripting layer). + public string? ScriptCallback { get; init; } +} + +/// Builds and normalises key descriptor strings so bindings compare consistently. +public static class MacroKey +{ + /// + /// Produces a canonical descriptor from modifier flags and a base key name, e.g. + /// Ctrl+Shift+F1. Modifier order is always Ctrl, Alt, Shift. + /// + public static string Describe(string key, bool ctrl = false, bool alt = false, bool shift = false) + { + ArgumentException.ThrowIfNullOrEmpty(key); + var parts = new List(4); + if (ctrl) + { + parts.Add("Ctrl"); + } + + if (alt) + { + parts.Add("Alt"); + } + + if (shift) + { + parts.Add("Shift"); + } + + parts.Add(key); + return string.Join('+', parts); + } +} diff --git a/src/MuClient.Core/Automation/MacroEngine.cs b/src/MuClient.Core/Automation/MacroEngine.cs new file mode 100644 index 0000000..4636fb1 --- /dev/null +++ b/src/MuClient.Core/Automation/MacroEngine.cs @@ -0,0 +1,66 @@ +namespace MuClient.Core.Automation; + +/// Maps normalised key descriptors to bindings. +public sealed class MacroEngine +{ + private readonly Dictionary _byKey = new(StringComparer.OrdinalIgnoreCase); + private readonly object _gate = new(); + + public MacroEngine(IEnumerable? macros = null) + { + if (macros is not null) + { + foreach (var macro in macros) + { + _byKey[macro.Key] = macro; + } + } + } + + public IReadOnlyCollection Macros + { + get + { + lock (_gate) + { + return _byKey.Values.ToArray(); + } + } + } + + /// Adds or replaces the binding for a key. + public void Add(Macro macro) + { + ArgumentNullException.ThrowIfNull(macro); + lock (_gate) + { + _byKey[macro.Key] = macro; + } + } + + public bool Remove(string key) + { + lock (_gate) + { + return _byKey.Remove(key); + } + } + + public void Clear() + { + lock (_gate) + { + _byKey.Clear(); + } + } + + /// Returns the enabled macro bound to , or null. + public Macro? Resolve(string keyDescriptor) + { + ArgumentNullException.ThrowIfNull(keyDescriptor); + lock (_gate) + { + return _byKey.TryGetValue(keyDescriptor, out var macro) && macro.Enabled ? macro : null; + } + } +} diff --git a/src/MuClient.Core/Automation/Trigger.cs b/src/MuClient.Core/Automation/Trigger.cs new file mode 100644 index 0000000..5d21edd --- /dev/null +++ b/src/MuClient.Core/Automation/Trigger.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using MuClient.Core.Text; + +namespace MuClient.Core.Automation; + +/// The typed actions a matched performs. +public sealed class TriggerActions +{ + /// Suppress the line from output entirely. + public bool Gag { get; init; } + + /// Recolour the matched region's foreground. + public TerminalColor? HighlightForeground { get; init; } + + /// Recolour the matched region's background. + public TerminalColor? HighlightBackground { get; init; } + + /// Add these attributes to the matched region (e.g. bold). + public TextAttributes AddAttributes { get; init; } = TextAttributes.None; + + /// + /// Replace the whole line's text with this template (supports $1..$9 and + /// ${name} capture references). Rewritten text renders with the default style. + /// + public string? Rewrite { get; init; } + + /// Send this command back to the server (capture references supported). + public string? SendResponse { get; init; } + + /// Route the line to a named spawn window instead of the main output. + public string? SpawnTarget { get; init; } + + /// Invoke this named script callback (resolved by the scripting layer). + public string? ScriptCallback { get; init; } +} + +/// +/// A regex-driven trigger. Matching is done against the plain text of an output line; +/// actions may gag, highlight, rewrite, respond, spawn-route, or invoke a script. +/// +public sealed class Trigger +{ + private Regex? _compiled; + + public string Name { get; init; } = string.Empty; + + /// The .NET regular expression matched against a line's plain text. + public required string Pattern { get; init; } + + public bool Enabled { get; set; } = true; + + public bool CaseSensitive { get; init; } + + /// When true, later triggers are not evaluated once this one matches. + public bool StopProcessing { get; init; } + + public TriggerActions Actions { get; init; } = new(); + + /// The compiled regex (built once, lazily), with a match timeout guarding against ReDoS. + [JsonIgnore] + public Regex Regex => _compiled ??= new Regex( + Pattern, + RegexOptions.Compiled | (CaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase), + AutomationDefaults.RegexMatchTimeout); +} diff --git a/src/MuClient.Core/Automation/TriggerEngine.cs b/src/MuClient.Core/Automation/TriggerEngine.cs new file mode 100644 index 0000000..fee938f --- /dev/null +++ b/src/MuClient.Core/Automation/TriggerEngine.cs @@ -0,0 +1,214 @@ +using System.Text.RegularExpressions; +using MuClient.Core.Text; + +namespace MuClient.Core.Automation; + +/// A script callback requested by a matched trigger, with its capture groups. +public sealed record TriggerScriptInvocation(string Callback, Match Match); + +/// The outcome of running the trigger engine over one output line. +public sealed class TriggerResult +{ + public TriggerResult( + StyledLine line, + bool suppress, + IReadOnlyList responses, + IReadOnlyList spawnTargets, + IReadOnlyList scriptInvocations, + IReadOnlyList matched) + { + Line = line; + Suppress = suppress; + Responses = responses; + SpawnTargets = spawnTargets; + ScriptInvocations = scriptInvocations; + Matched = matched; + } + + /// The (possibly highlighted/rewritten) line to display. + public StyledLine Line { get; } + + /// True if the line should be gagged (not displayed in the main window). + public bool Suppress { get; } + + /// Commands to send back to the server, in order. + public IReadOnlyList Responses { get; } + + /// Named spawn windows this line should be routed to. + public IReadOnlyList SpawnTargets { get; } + + /// Script callbacks to invoke, with their match data. + public IReadOnlyList ScriptInvocations { get; } + + /// The triggers that matched, in evaluation order. + public IReadOnlyList Matched { get; } +} + +/// +/// Evaluates an ordered set of s against each output line and +/// accumulates their effects. UI-agnostic and fully deterministic. +/// +public sealed class TriggerEngine +{ + private readonly List _triggers = new(); + private readonly object _gate = new(); + + public TriggerEngine(IEnumerable? triggers = null) + { + if (triggers is not null) + { + _triggers.AddRange(triggers); + } + } + + public IReadOnlyList Triggers + { + get + { + lock (_gate) + { + return _triggers.ToArray(); + } + } + } + + public void Add(Trigger trigger) + { + ArgumentNullException.ThrowIfNull(trigger); + lock (_gate) + { + _triggers.Add(trigger); + } + } + + public bool Remove(Trigger trigger) + { + lock (_gate) + { + return _triggers.Remove(trigger); + } + } + + public void Clear() + { + lock (_gate) + { + _triggers.Clear(); + } + } + + /// Runs every enabled trigger against and returns the combined result. + public TriggerResult Process(StyledLine line) + { + ArgumentNullException.ThrowIfNull(line); + + Trigger[] snapshot; + lock (_gate) + { + snapshot = _triggers.ToArray(); + } + + var current = line; + var suppress = false; + List? responses = null; + List? spawns = null; + List? scripts = null; + List? matched = null; + + foreach (var trigger in snapshot) + { + if (!trigger.Enabled) + { + continue; + } + + Match match; + try + { + match = trigger.Regex.Match(current.Text); + } + catch (RegexMatchTimeoutException) + { + // A pathological pattern timed out on this line; skip it rather than block output. + continue; + } + + if (!match.Success) + { + continue; + } + + (matched ??= new List()).Add(trigger); + var actions = trigger.Actions; + + if (actions.Gag) + { + suppress = true; + } + + if (actions.HighlightForeground is not null || + actions.HighlightBackground is not null || + actions.AddAttributes != TextAttributes.None) + { + current = ApplyHighlight(current, match, actions); + } + + if (actions.Rewrite is not null) + { + var text = match.Result(actions.Rewrite); + current = StyledLine.FromText(text, TextStyle.Default); + } + + if (!string.IsNullOrEmpty(actions.SendResponse)) + { + (responses ??= new List()).Add(match.Result(actions.SendResponse)); + } + + if (!string.IsNullOrEmpty(actions.SpawnTarget)) + { + (spawns ??= new List()).Add(actions.SpawnTarget); + } + + if (!string.IsNullOrEmpty(actions.ScriptCallback)) + { + (scripts ??= new List()).Add(new TriggerScriptInvocation(actions.ScriptCallback, match)); + } + + if (trigger.StopProcessing) + { + break; + } + } + + return new TriggerResult( + current, + suppress, + (IReadOnlyList?)responses ?? Array.Empty(), + (IReadOnlyList?)spawns ?? Array.Empty(), + (IReadOnlyList?)scripts ?? Array.Empty(), + (IReadOnlyList?)matched ?? Array.Empty()); + } + + private static StyledLine ApplyHighlight(StyledLine line, Match match, TriggerActions actions) + { + return StyledText.Restyle(line, match.Index, match.Length, style => + { + if (actions.HighlightForeground is not null) + { + style = style.WithForeground(actions.HighlightForeground.Value); + } + + if (actions.HighlightBackground is not null) + { + style = style.WithBackground(actions.HighlightBackground.Value); + } + + if (actions.AddAttributes != TextAttributes.None) + { + style = style.AddAttribute(actions.AddAttributes); + } + + return style; + }); + } +} diff --git a/src/MuClient.Core/Configuration/AppConfiguration.cs b/src/MuClient.Core/Configuration/AppConfiguration.cs new file mode 100644 index 0000000..2c77609 --- /dev/null +++ b/src/MuClient.Core/Configuration/AppConfiguration.cs @@ -0,0 +1,33 @@ +using MuClient.Core.Theming; + +namespace MuClient.Core.Configuration; + +/// Top-level MuGlyph configuration: global preferences plus the saved worlds. +public sealed class AppConfiguration +{ + /// Schema version, for future migrations. + public int Version { get; set; } = 1; + + /// The name of the active built-in theme (see ). + public string ThemeName { get; set; } = "Dark"; + + /// + /// The active theme's colours. Defaults to the dark theme; editing these fields customises + /// the theme in place. Set to a built-in name to reset. + /// + public Theme Theme { get; set; } = ThemeLibrary.Dark(); + + /// Maximum scrollback lines retained per world. + public int ScrollbackLines { get; set; } = 20_000; + + /// + /// Forces a graphics protocol regardless of capability detection: one of + /// none, halfblock, sixel, kitty. Null means auto-detect. + /// + public string? GraphicsOverride { get; set; } + + /// Default charset preference order (IANA names), most-preferred first. + public List CharsetOrder { get; set; } = new() { "utf-8", "iso-8859-1" }; + + public List Worlds { get; set; } = new(); +} diff --git a/src/MuClient.Core/Configuration/BeipMuImporter.cs b/src/MuClient.Core/Configuration/BeipMuImporter.cs new file mode 100644 index 0000000..cc57fd7 --- /dev/null +++ b/src/MuClient.Core/Configuration/BeipMuImporter.cs @@ -0,0 +1,129 @@ +using System.Xml.Linq; +using MuClient.Core.Automation; + +namespace MuClient.Core.Configuration; + +/// +/// Best-effort importer for BeipMU's XML settings. BeipMU's schema is not formally documented +/// here, so this parser is deliberately tolerant: it scans for world-like elements and reads +/// name/host/port plus any nested triggers/aliases by common element and attribute names, +/// case-insensitively. Unrecognised data is ignored rather than failing the import. +/// +public static class BeipMuImporter +{ + public static IReadOnlyList Import(string xml) + { + ArgumentNullException.ThrowIfNull(xml); + XDocument doc; + try + { + doc = XDocument.Parse(xml); + } + catch (System.Xml.XmlException) + { + return Array.Empty(); + } + + var worlds = new List(); + foreach (var element in doc.Descendants().Where(e => LocalNameContains(e, "world"))) + { + var world = TryReadWorld(element); + if (world is not null) + { + worlds.Add(world); + } + } + + return worlds; + } + + public static IReadOnlyList ImportFile(string path) + { + ArgumentException.ThrowIfNullOrEmpty(path); + return Import(File.ReadAllText(path)); + } + + private static WorldDefinition? TryReadWorld(XElement element) + { + var host = Value(element, "host") ?? Value(element, "address") ?? Value(element, "server"); + var name = Value(element, "name") ?? Value(element, "title") ?? host; + var portText = Value(element, "port"); + + if (string.IsNullOrWhiteSpace(host)) + { + return null; + } + + var world = new WorldDefinition + { + Name = name ?? host, + Host = host, + Port = int.TryParse(portText, out var port) ? port : 4000, + UseTls = ParseBool(Value(element, "ssl") ?? Value(element, "tls") ?? Value(element, "secure")), + }; + + foreach (var t in element.Descendants().Where(e => LocalNameContains(e, "trigger"))) + { + var pattern = Value(t, "pattern") ?? Value(t, "match") ?? Value(t, "regex"); + if (string.IsNullOrWhiteSpace(pattern)) + { + continue; + } + + var send = Value(t, "send") ?? Value(t, "command") ?? Value(t, "response"); + world.Triggers.Add(new Trigger + { + Name = Value(t, "name") ?? string.Empty, + Pattern = pattern, + Actions = new TriggerActions + { + Gag = ParseBool(Value(t, "gag") ?? Value(t, "omit")), + SendResponse = string.IsNullOrWhiteSpace(send) ? null : send, + }, + }); + } + + foreach (var a in element.Descendants().Where(e => LocalNameContains(e, "alias"))) + { + var pattern = Value(a, "pattern") ?? Value(a, "match") ?? Value(a, "name"); + var substitution = Value(a, "send") ?? Value(a, "command") ?? Value(a, "expand"); + if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrWhiteSpace(substitution)) + { + continue; + } + + world.Aliases.Add(new Alias + { + Name = Value(a, "name") ?? string.Empty, + Pattern = pattern, + Substitution = substitution, + }); + } + + return world; + } + + private static bool LocalNameContains(XElement element, string fragment) => + element.Name.LocalName.Contains(fragment, StringComparison.OrdinalIgnoreCase); + + /// Reads a value from either a same-named attribute or a same-named child element. + private static string? Value(XElement element, string name) + { + var attribute = element.Attributes() + .FirstOrDefault(a => a.Name.LocalName.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (attribute is not null && !string.IsNullOrWhiteSpace(attribute.Value)) + { + return attribute.Value; + } + + var child = element.Elements() + .FirstOrDefault(e => e.Name.LocalName.Equals(name, StringComparison.OrdinalIgnoreCase)); + return child is not null && !string.IsNullOrWhiteSpace(child.Value) ? child.Value : null; + } + + private static bool ParseBool(string? value) => + value is not null && + (value.Equals("true", StringComparison.OrdinalIgnoreCase) || + value == "1" || + value.Equals("yes", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/MuClient.Core/Configuration/ConfigurationStore.cs b/src/MuClient.Core/Configuration/ConfigurationStore.cs new file mode 100644 index 0000000..c5a04eb --- /dev/null +++ b/src/MuClient.Core/Configuration/ConfigurationStore.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MuClient.Core.Configuration; + +/// Loads and saves as JSON. +public static class ConfigurationStore +{ + /// Shared serializer options: indented, string enums, and the colour converter. + public static JsonSerializerOptions SerializerOptions { get; } = CreateOptions(); + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + options.Converters.Add(new JsonStringEnumConverter()); + options.Converters.Add(new TerminalColorJsonConverter()); + options.Converters.Add(new RgbJsonConverter()); + return options; + } + + /// The default configuration path under the user's profile. + public static string DefaultPath + { + get + { + var baseDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrEmpty(baseDir)) + { + baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + } + + return Path.Combine(baseDir, "MuGlyph", "config.json"); + } + } + + public static AppConfiguration Load(string path) + { + ArgumentException.ThrowIfNullOrEmpty(path); + if (!File.Exists(path)) + { + return new AppConfiguration(); + } + + var json = File.ReadAllText(path); + return Deserialize(json); + } + + public static AppConfiguration Deserialize(string json) + { + ArgumentNullException.ThrowIfNull(json); + return JsonSerializer.Deserialize(json, SerializerOptions) ?? new AppConfiguration(); + } + + public static string Serialize(AppConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return JsonSerializer.Serialize(configuration, SerializerOptions); + } + + public static void Save(string path, AppConfiguration configuration) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(configuration); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + File.WriteAllText(path, Serialize(configuration)); + } +} diff --git a/src/MuClient.Core/Configuration/RgbJsonConverter.cs b/src/MuClient.Core/Configuration/RgbJsonConverter.cs new file mode 100644 index 0000000..aafaa4c --- /dev/null +++ b/src/MuClient.Core/Configuration/RgbJsonConverter.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MuClient.Core.Text; + +namespace MuClient.Core.Configuration; + +/// Serialises as a CSS-style hex string (#rrggbb). +public sealed class RgbJsonConverter : JsonConverter +{ + public override Rgb Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Parse(reader.GetString()); + + public static Rgb Parse(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return new Rgb(0, 0, 0); + } + + var hex = text.TrimStart('#'); + if (hex.Length == 6 && + byte.TryParse(hex.AsSpan(0, 2), System.Globalization.NumberStyles.HexNumber, null, out var r) && + byte.TryParse(hex.AsSpan(2, 2), System.Globalization.NumberStyles.HexNumber, null, out var g) && + byte.TryParse(hex.AsSpan(4, 2), System.Globalization.NumberStyles.HexNumber, null, out var b)) + { + return new Rgb(r, g, b); + } + + return new Rgb(0, 0, 0); + } + + public override void Write(Utf8JsonWriter writer, Rgb value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToHex()); +} diff --git a/src/MuClient.Core/Configuration/TerminalColorJsonConverter.cs b/src/MuClient.Core/Configuration/TerminalColorJsonConverter.cs new file mode 100644 index 0000000..1443028 --- /dev/null +++ b/src/MuClient.Core/Configuration/TerminalColorJsonConverter.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MuClient.Core.Text; + +namespace MuClient.Core.Configuration; + +/// +/// Serialises as a compact string: default, idx:N, +/// or rgb:R,G,B. +/// +public sealed class TerminalColorJsonConverter : JsonConverter +{ + public override TerminalColor Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var text = reader.GetString(); + return Parse(text); + } + + public static TerminalColor Parse(string? text) + { + if (string.IsNullOrWhiteSpace(text) || text.Equals("default", StringComparison.OrdinalIgnoreCase)) + { + return TerminalColor.Default; + } + + if (text.StartsWith("idx:", StringComparison.OrdinalIgnoreCase) && + int.TryParse(text.AsSpan(4), out var index)) + { + return TerminalColor.FromIndex(Math.Clamp(index, 0, 255)); + } + + if (text.StartsWith("rgb:", StringComparison.OrdinalIgnoreCase)) + { + var parts = text[4..].Split(','); + if (parts.Length == 3 && + byte.TryParse(parts[0], out var r) && + byte.TryParse(parts[1], out var g) && + byte.TryParse(parts[2], out var b)) + { + return TerminalColor.FromRgb(r, g, b); + } + } + + return TerminalColor.Default; + } + + public override void Write(Utf8JsonWriter writer, TerminalColor value, JsonSerializerOptions options) + { + writer.WriteStringValue(ToString(value)); + } + + public static string ToString(TerminalColor value) => value.Kind switch + { + TerminalColorKind.Indexed => $"idx:{value.Index}", + TerminalColorKind.Rgb => $"rgb:{value.R},{value.G},{value.B}", + _ => "default", + }; +} diff --git a/src/MuClient.Core/Configuration/WorldDefinition.cs b/src/MuClient.Core/Configuration/WorldDefinition.cs new file mode 100644 index 0000000..a25ec37 --- /dev/null +++ b/src/MuClient.Core/Configuration/WorldDefinition.cs @@ -0,0 +1,62 @@ +using MuClient.Core.Automation; +using MuClient.Core.Transport; + +namespace MuClient.Core.Configuration; + +/// Which log formats a world writes. +public enum LogFormat +{ + None, + Plain, + Html, + Both, +} + +/// Per-world logging configuration. +public sealed class LoggingSettings +{ + public LogFormat Format { get; set; } = LogFormat.None; + + /// Directory for log files. Defaults to a per-world folder under the config dir. + public string? Directory { get; set; } +} + +/// +/// A saved MU* world: connection parameters plus its automation (triggers/aliases/macros), +/// scripting, and logging. This is the primary unit persisted to JSON. +/// +public sealed class WorldDefinition +{ + public string Name { get; set; } = "New World"; + + public string Host { get; set; } = string.Empty; + + public int Port { get; set; } = 4000; + + public bool UseTls { get; set; } + + public bool AllowInvalidCertificates { get; set; } + + /// Echo typed commands locally into the output window. + public bool LocalEcho { get; set; } = true; + + public List Triggers { get; set; } = new(); + + public List Aliases { get; set; } = new(); + + public List Macros { get; set; } = new(); + + /// Lua script files (relative or absolute) loaded for this world. + public List ScriptFiles { get; set; } = new(); + + public LoggingSettings Logging { get; set; } = new(); + + /// Builds the transport-level options from this world. + public ConnectionOptions ToConnectionOptions() => new() + { + Host = Host, + Port = Port, + UseTls = UseTls, + AllowInvalidCertificates = AllowInvalidCertificates, + }; +} diff --git a/src/MuClient.Core/Logging/HtmlLogSink.cs b/src/MuClient.Core/Logging/HtmlLogSink.cs new file mode 100644 index 0000000..0e98df8 --- /dev/null +++ b/src/MuClient.Core/Logging/HtmlLogSink.cs @@ -0,0 +1,181 @@ +using System.Text; +using MuClient.Core.Text; + +namespace MuClient.Core.Logging; + +/// +/// Writes output as a self-contained HTML document, preserving colour and attributes via +/// inline-styled <span>s (BeipMU-style HTML logs). A preamble is emitted on +/// construction and the closing tags on . +/// +public sealed class HtmlLogSink : ILogSink +{ + private static readonly Rgb DefaultForeground = new(0xd0, 0xd0, 0xd0); + private static readonly Rgb DefaultBackground = new(0x1e, 0x1e, 0x1e); + + private readonly TextWriter _writer; + private readonly bool _ownsWriter; + private readonly object _gate = new(); + private bool _closed; + + public HtmlLogSink(TextWriter writer, string title = "MuGlyph session log", bool ownsWriter = true) + { + _writer = writer ?? throw new ArgumentNullException(nameof(writer)); + _ownsWriter = ownsWriter; + WritePreamble(title); + } + + /// Opens an HTML log file, creating parent directories as needed. + public static HtmlLogSink CreateFile(string path, string title = "MuGlyph session log") + { + ArgumentException.ThrowIfNullOrEmpty(path); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read); + return new HtmlLogSink(new StreamWriter(stream) { AutoFlush = false }, title); + } + + private void WritePreamble(string title) + { + _writer.WriteLine(""); + _writer.WriteLine(""); + _writer.WriteLine($"{Escape(title)}"); + _writer.WriteLine(""); + } + + public void WriteLine(StyledLine line) + { + ArgumentNullException.ThrowIfNull(line); + lock (_gate) + { + if (_closed) + { + return; + } + + var sb = new StringBuilder("
"); + foreach (var span in line.Spans) + { + AppendSpan(sb, span); + } + + sb.Append("
"); + _writer.WriteLine(sb.ToString()); + } + } + + public void WriteSystem(string text) + { + lock (_gate) + { + if (_closed) + { + return; + } + + _writer.WriteLine($"
{Escape(text)}
"); + } + } + + private static void AppendSpan(StringBuilder sb, StyledSpan span) + { + var style = span.Style; + var reverse = style.HasAttribute(TextAttributes.Reverse); + var fg = AnsiPalette.Resolve(style.Foreground, DefaultForeground); + var bg = AnsiPalette.Resolve(style.Background, DefaultBackground); + if (reverse) + { + (fg, bg) = (bg, fg); + } + + var css = new StringBuilder(); + if (style.Foreground.Kind != TerminalColorKind.Default || reverse) + { + css.Append($"color:{fg.ToHex()};"); + } + + if (style.Background.Kind != TerminalColorKind.Default || reverse) + { + css.Append($"background:{bg.ToHex()};"); + } + + if (style.HasAttribute(TextAttributes.Bold)) + { + css.Append("font-weight:bold;"); + } + + if (style.HasAttribute(TextAttributes.Faint)) + { + css.Append("opacity:0.7;"); + } + + if (style.HasAttribute(TextAttributes.Italic)) + { + css.Append("font-style:italic;"); + } + + var decorations = new List(); + if (style.HasAttribute(TextAttributes.Underline)) + { + decorations.Add("underline"); + } + + if (style.HasAttribute(TextAttributes.Strikethrough)) + { + decorations.Add("line-through"); + } + + if (decorations.Count > 0) + { + css.Append($"text-decoration:{string.Join(' ', decorations)};"); + } + + if (css.Length == 0) + { + sb.Append(Escape(span.Text)); + } + else + { + sb.Append($"{Escape(span.Text)}"); + } + } + + public void Flush() + { + lock (_gate) + { + if (!_closed) + { + _writer.Flush(); + } + } + } + + public void Dispose() + { + lock (_gate) + { + if (_closed) + { + return; + } + + _writer.WriteLine(""); + _writer.Flush(); + _closed = true; + if (_ownsWriter) + { + _writer.Dispose(); + } + } + } + + private static string Escape(string text) => text + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">"); +} diff --git a/src/MuClient.Core/Logging/ILogSink.cs b/src/MuClient.Core/Logging/ILogSink.cs new file mode 100644 index 0000000..0537b03 --- /dev/null +++ b/src/MuClient.Core/Logging/ILogSink.cs @@ -0,0 +1,16 @@ +using MuClient.Core.Text; + +namespace MuClient.Core.Logging; + +/// A destination for session output logging (plain text or HTML). +public interface ILogSink : IDisposable +{ + /// Writes one styled output line to the log. + void WriteLine(StyledLine line); + + /// Writes a client-generated informational line (e.g. "*** Connected"). + void WriteSystem(string text); + + /// Flushes buffered output to the underlying store. + void Flush(); +} diff --git a/src/MuClient.Core/Logging/PlainTextLogSink.cs b/src/MuClient.Core/Logging/PlainTextLogSink.cs new file mode 100644 index 0000000..0fb6dff --- /dev/null +++ b/src/MuClient.Core/Logging/PlainTextLogSink.cs @@ -0,0 +1,63 @@ +using MuClient.Core.Text; + +namespace MuClient.Core.Logging; + +/// Writes plain, unstyled output lines to a . +public sealed class PlainTextLogSink : ILogSink +{ + private readonly TextWriter _writer; + private readonly bool _ownsWriter; + private readonly object _gate = new(); + + public PlainTextLogSink(TextWriter writer, bool ownsWriter = true) + { + _writer = writer ?? throw new ArgumentNullException(nameof(writer)); + _ownsWriter = ownsWriter; + } + + /// Opens a plain-text log file, creating parent directories as needed. + public static PlainTextLogSink CreateFile(string path, bool append = true) + { + ArgumentException.ThrowIfNullOrEmpty(path); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + var stream = new FileStream(path, append ? FileMode.Append : FileMode.Create, FileAccess.Write, FileShare.Read); + return new PlainTextLogSink(new StreamWriter(stream) { AutoFlush = false }); + } + + public void WriteLine(StyledLine line) + { + ArgumentNullException.ThrowIfNull(line); + lock (_gate) + { + _writer.WriteLine(line.Text); + } + } + + public void WriteSystem(string text) + { + lock (_gate) + { + _writer.WriteLine(text); + } + } + + public void Flush() + { + lock (_gate) + { + _writer.Flush(); + } + } + + public void Dispose() + { + lock (_gate) + { + _writer.Flush(); + if (_ownsWriter) + { + _writer.Dispose(); + } + } + } +} diff --git a/src/MuClient.Core/MuClient.Core.csproj b/src/MuClient.Core/MuClient.Core.csproj new file mode 100644 index 0000000..54a2eb2 --- /dev/null +++ b/src/MuClient.Core/MuClient.Core.csproj @@ -0,0 +1,13 @@ + + + + MuClient.Core + MuClient.Core + + + + + + + + diff --git a/src/MuClient.Core/Session/SessionEvents.cs b/src/MuClient.Core/Session/SessionEvents.cs new file mode 100644 index 0000000..eced7cd --- /dev/null +++ b/src/MuClient.Core/Session/SessionEvents.cs @@ -0,0 +1,28 @@ +using MuClient.Core.Text; + +namespace MuClient.Core.Session; + +/// The lifecycle state of a . +public enum ConnectionState +{ + Disconnected, + Connecting, + Connected, + Faulted, +} + +/// A line routed to a named spawn window by a matching trigger. +public sealed class SpawnLineEventArgs(string target, StyledLine line) : EventArgs +{ + public string Target { get; } = target; + + public StyledLine Line { get; } = line; +} + +/// Raised when a session's changes. +public sealed class ConnectionStateChangedEventArgs(ConnectionState state, Exception? error) : EventArgs +{ + public ConnectionState State { get; } = state; + + public Exception? Error { get; } = error; +} diff --git a/src/MuClient.Core/Session/SessionManager.cs b/src/MuClient.Core/Session/SessionManager.cs new file mode 100644 index 0000000..d843185 --- /dev/null +++ b/src/MuClient.Core/Session/SessionManager.cs @@ -0,0 +1,77 @@ +using MuClient.Core.Configuration; + +namespace MuClient.Core.Session; + +/// Manages the set of open world sessions (the tabbed multi-world model). +public sealed class SessionManager : IAsyncDisposable +{ + private readonly List _sessions = new(); + private readonly object _gate = new(); + + public event EventHandler? SessionAdded; + + public event EventHandler? SessionRemoved; + + public IReadOnlyList Sessions + { + get + { + lock (_gate) + { + return _sessions.ToArray(); + } + } + } + + /// Creates and registers a session for a world (does not connect it). + public WorldSession Open(WorldDefinition world, int scrollbackCapacity = 20_000) + { + ArgumentNullException.ThrowIfNull(world); + var session = new WorldSession(world, scrollbackCapacity: scrollbackCapacity); + Add(session); + return session; + } + + /// Registers an already-constructed session (used by tests with a fake transport). + public void Add(WorldSession session) + { + ArgumentNullException.ThrowIfNull(session); + lock (_gate) + { + _sessions.Add(session); + } + + SessionAdded?.Invoke(this, session); + } + + public async Task CloseAsync(WorldSession session) + { + ArgumentNullException.ThrowIfNull(session); + bool removed; + lock (_gate) + { + removed = _sessions.Remove(session); + } + + if (removed) + { + await session.DisposeAsync().ConfigureAwait(false); + SessionRemoved?.Invoke(this, session); + } + } + + public async ValueTask DisposeAsync() + { + WorldSession[] snapshot; + lock (_gate) + { + snapshot = _sessions.ToArray(); + _sessions.Clear(); + } + + foreach (var session in snapshot) + { + await session.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/MuClient.Core/Session/WorldSession.cs b/src/MuClient.Core/Session/WorldSession.cs new file mode 100644 index 0000000..5ba616e --- /dev/null +++ b/src/MuClient.Core/Session/WorldSession.cs @@ -0,0 +1,261 @@ +using MuClient.Core.Automation; +using MuClient.Core.Configuration; +using MuClient.Core.Logging; +using MuClient.Core.Telnet; +using MuClient.Core.Text; +using MuClient.Core.Transport; + +namespace MuClient.Core.Session; + +/// +/// The runtime for a single connected world. Orchestrates the full pipeline: +/// transport → telnet → ANSI parse → trigger engine → scrollback / logging, and the outbound +/// path: user input → alias expansion → local echo → send. UI-agnostic; the view binds to its +/// events and its . +/// +public sealed class WorldSession : IAsyncDisposable +{ + private static readonly TextStyle EchoStyle = + new(TerminalColor.FromIndex(11), TerminalColor.Default, TextAttributes.None); + + private static readonly TextStyle SystemStyle = + new(TerminalColor.FromIndex(6), TerminalColor.Default, TextAttributes.Italic); + + private readonly Func _sessionFactory; + private readonly AnsiParser _parser = new(); + private readonly ILogSink? _log; + private ITelnetSession? _telnet; + + public WorldSession( + WorldDefinition world, + Func? sessionFactory = null, + ILogSink? log = null, + int scrollbackCapacity = 20_000) + { + World = world ?? throw new ArgumentNullException(nameof(world)); + _sessionFactory = sessionFactory ?? DefaultSessionFactory; + _log = log; + Scrollback = new ScrollbackBuffer(scrollbackCapacity); + Triggers = new TriggerEngine(world.Triggers); + Aliases = new AliasEngine(world.Aliases); + Macros = new MacroEngine(world.Macros); + } + + public WorldDefinition World { get; } + + public ScrollbackBuffer Scrollback { get; } + + public TriggerEngine Triggers { get; } + + public AliasEngine Aliases { get; } + + public MacroEngine Macros { get; } + + public IntervalScheduler Scheduler { get; } = new(); + + public ConnectionState State { get; private set; } = ConnectionState.Disconnected; + + /// The most recent prompt, or null if none is active. + public StyledLine? CurrentPrompt { get; private set; } + + public event EventHandler? LinePrinted; + + public event EventHandler? PromptChanged; + + public event EventHandler? StateChanged; + + public event EventHandler? GmcpReceived; + + public event EventHandler? MsdpReceived; + + public event EventHandler? MsspReceived; + + public event EventHandler? SpawnLine; + + /// Raised for each trigger-requested script callback (consumed by the scripting layer). + public event EventHandler? TriggerScriptRequested; + + public bool IsConnected => _telnet?.IsConnected == true; + + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + if (State is ConnectionState.Connecting or ConnectionState.Connected) + { + return; + } + + SetState(ConnectionState.Connecting, null); + PrintSystem($"*** Connecting to {World.Host}:{World.Port}..."); + + var telnet = _sessionFactory(World.ToConnectionOptions()); + _telnet = telnet; + telnet.OutputReceived += OnOutputReceived; + telnet.GmcpReceived += (_, e) => GmcpReceived?.Invoke(this, e); + telnet.MsdpReceived += (_, e) => MsdpReceived?.Invoke(this, e); + telnet.MsspReceived += (_, e) => MsspReceived?.Invoke(this, e); + telnet.Disconnected += OnDisconnected; + + try + { + await telnet.ConnectAsync(cancellationToken).ConfigureAwait(false); + SetState(ConnectionState.Connected, null); + PrintSystem("*** Connected."); + } + catch (Exception ex) + { + SetState(ConnectionState.Faulted, ex); + PrintSystem($"*** Connection failed: {ex.Message}"); + throw; + } + } + + private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) + { + if (e.IsPrompt) + { + _parser.Feed(e.Text); + var prompt = _parser.Flush() ?? StyledLine.Empty; + CurrentPrompt = prompt; + PromptChanged?.Invoke(this, prompt); + return; + } + + foreach (var completed in _parser.Feed(e.Text)) + { + ProcessOutputLine(completed); + } + + var tail = _parser.Flush(); + if (tail is not null) + { + ProcessOutputLine(tail); + } + } + + private void ProcessOutputLine(StyledLine line) + { + var result = Triggers.Process(line); + + foreach (var invocation in result.ScriptInvocations) + { + TriggerScriptRequested?.Invoke(this, invocation); + } + + foreach (var target in result.SpawnTargets) + { + SpawnLine?.Invoke(this, new SpawnLineEventArgs(target, result.Line)); + } + + foreach (var response in result.Responses) + { + _ = SendRawAsync(response); + } + + if (!result.Suppress) + { + Print(result.Line); + } + } + + /// Handles a line of user input: alias expansion, local echo, and send. + public async Task SendUserInputAsync(string input, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(input); + + var expansion = Aliases.Expand(input); + if (World.LocalEcho) + { + Print(StyledLine.FromText(input, EchoStyle)); + } + + if (expansion.Matched) + { + foreach (var command in expansion.Commands) + { + await SendRawAsync(command, cancellationToken).ConfigureAwait(false); + } + + return; + } + + await SendRawAsync(input, cancellationToken).ConfigureAwait(false); + } + + /// Sends a command verbatim (no alias expansion, no echo). + public async Task SendRawAsync(string command, CancellationToken cancellationToken = default) + { + var telnet = _telnet; + if (telnet is null || !telnet.IsConnected) + { + return; + } + + await telnet.SendLineAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// Resolves a key to a macro and sends its command. Returns the command sent, or null. + public async Task HandleKeyAsync(string keyDescriptor, CancellationToken cancellationToken = default) + { + var macro = Macros.Resolve(keyDescriptor); + if (macro is null || string.IsNullOrEmpty(macro.Command)) + { + return null; + } + + await SendRawAsync(macro.Command, cancellationToken).ConfigureAwait(false); + return macro.Command; + } + + public ValueTask SetWindowSizeAsync(int width, int height) => + _telnet?.SetWindowSizeAsync(width, height) ?? ValueTask.CompletedTask; + + /// Appends a client-generated informational line. + public void PrintSystem(string text) + { + var line = StyledLine.FromText(text, SystemStyle); + Scrollback.Append(line); + _log?.WriteSystem(text); + LinePrinted?.Invoke(this, line); + } + + private void Print(StyledLine line) + { + Scrollback.Append(line); + _log?.WriteLine(line); + LinePrinted?.Invoke(this, line); + } + + private void OnDisconnected(object? sender, SessionDisconnectedEventArgs e) + { + SetState(e.IsClean ? ConnectionState.Disconnected : ConnectionState.Faulted, e.Error); + PrintSystem(e.IsClean ? "*** Disconnected." : $"*** Connection lost: {e.Error?.Message}"); + } + + public async Task DisconnectAsync() + { + if (_telnet is not null) + { + await _telnet.DisconnectAsync().ConfigureAwait(false); + } + } + + private void SetState(ConnectionState state, Exception? error) + { + State = state; + StateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(state, error)); + } + + private static ITelnetSession DefaultSessionFactory(ConnectionOptions options) => + new TelnetSession(new TcpTransport(options)); + + public async ValueTask DisposeAsync() + { + Scheduler.Dispose(); + _log?.Dispose(); + if (_telnet is not null) + { + await _telnet.DisposeAsync().ConfigureAwait(false); + _telnet = null; + } + } +} diff --git a/src/MuClient.Core/Telnet/ITelnetSession.cs b/src/MuClient.Core/Telnet/ITelnetSession.cs new file mode 100644 index 0000000..f48fcd6 --- /dev/null +++ b/src/MuClient.Core/Telnet/ITelnetSession.cs @@ -0,0 +1,44 @@ +namespace MuClient.Core.Telnet; + +/// +/// A telnet session over a transport: option negotiation, charset decoding, prompt +/// detection, and out-of-band protocols (GMCP/MSSP/MSDP/MCCP). Emits decoded server +/// output as text; ANSI styling and scrollback live in higher layers. +/// +public interface ITelnetSession : IAsyncDisposable +{ + bool IsConnected { get; } + + /// Server output: completed lines and prompts. + event EventHandler? OutputReceived; + + /// GMCP messages routed from the server. + event EventHandler? GmcpReceived; + + /// MSDP messages routed from the server. + event EventHandler? MsdpReceived; + + /// MSSP server-status data. + event EventHandler? MsspReceived; + + /// Raised when the connection ends. + event EventHandler? Disconnected; + + /// Connects the transport and begins negotiation and the receive loop. + Task ConnectAsync(CancellationToken cancellationToken = default); + + /// Sends a command line to the server (a trailing CR/LF is appended). + ValueTask SendLineAsync(string text, CancellationToken cancellationToken = default); + + /// Sends raw bytes to the server (IAC bytes are escaped by the telnet layer). + ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default); + + /// Sends a GMCP message. + ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default); + + /// Reports the terminal window size to the server via NAWS. + ValueTask SetWindowSizeAsync(int width, int height); + + /// Closes the session. + Task DisconnectAsync(); +} diff --git a/src/MuClient.Core/Telnet/MsspConfigReader.cs b/src/MuClient.Core/Telnet/MsspConfigReader.cs new file mode 100644 index 0000000..756fd2e --- /dev/null +++ b/src/MuClient.Core/Telnet/MsspConfigReader.cs @@ -0,0 +1,78 @@ +using System.Collections; +using System.Reflection; +using TelnetNegotiationCore.Models; + +namespace MuClient.Core.Telnet; + +/// +/// Projects a into a flat string dictionary of the values the +/// server actually reported (non-null scalar and list properties), so consumers need not +/// depend on the library's model shape. +/// +internal static class MsspConfigReader +{ + private static readonly PropertyInfo[] Properties = typeof(MSSPConfig) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) + .ToArray(); + + public static IReadOnlyDictionary ToDictionary(MSSPConfig config) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (config is null) + { + return result; + } + + foreach (var property in Properties) + { + object? value; + try + { + value = property.GetValue(config); + } + catch + { + continue; + } + + if (value is null) + { + continue; + } + + var rendered = Render(value); + if (!string.IsNullOrEmpty(rendered)) + { + result[property.Name] = rendered; + } + } + + return result; + } + + private static string Render(object value) + { + if (value is string s) + { + return s; + } + + // Enumerable (e.g. a list of supported values) -> comma-joined. + if (value is IEnumerable enumerable and not string) + { + var parts = new List(); + foreach (var item in enumerable) + { + if (item is not null) + { + parts.Add(item.ToString() ?? string.Empty); + } + } + + return string.Join(", ", parts); + } + + return value.ToString() ?? string.Empty; + } +} diff --git a/src/MuClient.Core/Telnet/TelnetEvents.cs b/src/MuClient.Core/Telnet/TelnetEvents.cs new file mode 100644 index 0000000..af29971 --- /dev/null +++ b/src/MuClient.Core/Telnet/TelnetEvents.cs @@ -0,0 +1,42 @@ +namespace MuClient.Core.Telnet; + +/// A chunk of server output: a completed line, or an unterminated prompt. +public sealed class TelnetOutputEventArgs(string text, bool isPrompt) : EventArgs +{ + /// The decoded text, with ANSI escape sequences still embedded. + public string Text { get; } = text; + + /// True when this text was terminated by a GA/EOR prompt marker rather than a newline. + public bool IsPrompt { get; } = isPrompt; +} + +/// A GMCP (Generic MUD Communication Protocol) message. +public sealed class GmcpMessageEventArgs(string package, string json) : EventArgs +{ + /// The package name, e.g. Char.Vitals. + public string Package { get; } = package; + + /// The JSON payload (may be empty). + public string Json { get; } = json; +} + +/// An MSDP (Mud Server Data Protocol) message, delivered as JSON. +public sealed class MsdpMessageEventArgs(string json) : EventArgs +{ + public string Json { get; } = json; +} + +/// MSSP (Mud Server Status Protocol) key/value data reported by the server. +public sealed class MsspReceivedEventArgs(IReadOnlyDictionary values) : EventArgs +{ + public IReadOnlyDictionary Values { get; } = values; +} + +/// Raised when the session disconnects, cleanly or due to an error. +public sealed class SessionDisconnectedEventArgs(Exception? error) : EventArgs +{ + /// The error that caused the disconnect, or null for a clean close. + public Exception? Error { get; } = error; + + public bool IsClean => Error is null; +} diff --git a/src/MuClient.Core/Telnet/TelnetSession.cs b/src/MuClient.Core/Telnet/TelnetSession.cs new file mode 100644 index 0000000..42ededa --- /dev/null +++ b/src/MuClient.Core/Telnet/TelnetSession.cs @@ -0,0 +1,278 @@ +using System.Reflection; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MuClient.Core.Transport; +using TelnetNegotiationCore.Builders; +using TelnetNegotiationCore.Interpreters; +using TelnetNegotiationCore.Models; + +namespace MuClient.Core.Telnet; + +/// Tuning knobs for a . +public sealed class TelnetSessionOptions +{ + /// Preferred charsets, most-preferred first. Defaults to UTF-8 then Latin-1. + public Encoding[] CharsetOrder { get; init; } = + [ + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + Encoding.Latin1, + ]; + + /// Read buffer size in bytes. + public int ReceiveBufferSize { get; init; } = 8192; +} + +/// +/// Wraps (TelnetNegotiationCore) over an +/// . Negotiation output is written to the transport; inbound bytes +/// are fed to the interpreter, which strips telnet framing and hands back decoded data +/// bytes. Complete lines (newline) and prompts (GA/EOR) are surfaced via +/// . +/// +public sealed class TelnetSession : ITelnetSession +{ + // TelnetInterpreter.CallbackOnByteAsync is init-only and not exposed by the builder, so + // we assign it reflectively after building. This is the one seam where we reach past the + // library's public surface; a first-class OnByte builder hook is a candidate upstream PR. + private static readonly PropertyInfo ByteCallbackProperty = + typeof(TelnetInterpreter).GetProperty(nameof(TelnetInterpreter.CallbackOnByteAsync)) + ?? throw new InvalidOperationException("TelnetInterpreter.CallbackOnByteAsync not found."); + + private readonly ITransport _transport; + private readonly ILogger _logger; + private readonly TelnetSessionOptions _options; + + private readonly List _pending = new(); + private Encoding _currentEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + private TelnetInterpreter? _interpreter; + private CancellationTokenSource? _loopCts; + private Task? _readLoop; + private int _disconnected; + + public TelnetSession(ITransport transport, ILogger? logger = null, TelnetSessionOptions? options = null) + { + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _logger = logger ?? NullLogger.Instance; + _options = options ?? new TelnetSessionOptions(); + } + + public bool IsConnected => _transport.IsConnected && _interpreter is not null; + + public event EventHandler? OutputReceived; + public event EventHandler? GmcpReceived; + public event EventHandler? MsdpReceived; + public event EventHandler? MsspReceived; + public event EventHandler? Disconnected; + + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + if (_interpreter is not null) + { + throw new InvalidOperationException("Session is already connected."); + } + + // Transport must be open before building: the interpreter emits initial negotiation + // (e.g. WILL NAWS) during BuildAsync, which is written straight to the transport. + await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); + + _interpreter = await BuildInterpreterAsync().ConfigureAwait(false); + ByteCallbackProperty.SetValue(_interpreter, new Func(OnByteAsync)); + + _loopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _readLoop = Task.Run(() => ReadLoopAsync(_loopCts.Token), CancellationToken.None); + } + + private Task BuildInterpreterAsync() => + new TelnetInterpreterBuilder() + .UseMode(TelnetInterpreter.TelnetMode.Client) + .UseLogger(_logger) + .OnNegotiation(WriteToTransportAsync) + .OnSubmit(OnSubmitAsync) + .AddDefaultMUDProtocols( + onNAWS: static (_, _) => ValueTask.CompletedTask, + onGMCPMessage: OnGmcpAsync, + onMSSP: OnMsspAsync, + msspConfig: static () => new MSSPConfig(), + onMSDPMessage: OnMsdpAsync, + onPrompt: OnPromptAsync, + charsetOrder: _options.CharsetOrder, + onCompressionEnabled: OnCompressionAsync, + onMXPEnabled: static () => ValueTask.CompletedTask) + .BuildAsync(); + + private ValueTask WriteToTransportAsync(ReadOnlyMemory data) => + _transport.SendAsync(data); + + private ValueTask OnByteAsync(byte value, Encoding encoding) + { + if (encoding is not null) + { + _currentEncoding = encoding; + } + + _pending.Add(value); + return ValueTask.CompletedTask; + } + + private ValueTask OnSubmitAsync(byte[] bytes, Encoding? encoding, TelnetInterpreter interpreter) + { + // A newline-terminated line. Prefer the interpreter's own line bytes for the content. + var text = (encoding ?? _currentEncoding).GetString(bytes); + _pending.Clear(); + Emit(text, isPrompt: false); + return ValueTask.CompletedTask; + } + + private ValueTask OnPromptAsync() + { + // GA/EOR boundary: flush whatever data has accumulated as a prompt. + FlushPending(isPrompt: true); + return ValueTask.CompletedTask; + } + + private void FlushPending(bool isPrompt) + { + if (_pending.Count == 0) + { + return; + } + + var text = _currentEncoding.GetString(_pending.ToArray()); + _pending.Clear(); + Emit(text, isPrompt); + } + + private void Emit(string text, bool isPrompt) => + OutputReceived?.Invoke(this, new TelnetOutputEventArgs(text, isPrompt)); + + private ValueTask OnGmcpAsync((string Package, string Json) message) + { + GmcpReceived?.Invoke(this, new GmcpMessageEventArgs(message.Package, message.Json)); + return ValueTask.CompletedTask; + } + + private ValueTask OnMsdpAsync(TelnetInterpreter interpreter, string json) + { + MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); + return ValueTask.CompletedTask; + } + + private ValueTask OnMsspAsync(MSSPConfig config) + { + MsspReceived?.Invoke(this, new MsspReceivedEventArgs(MsspConfigReader.ToDictionary(config))); + return ValueTask.CompletedTask; + } + + private ValueTask OnCompressionAsync(int version, bool enabled) + { + _logger.LogInformation("MCCP{Version} compression {State}.", version, enabled ? "enabled" : "disabled"); + return ValueTask.CompletedTask; + } + + private async Task ReadLoopAsync(CancellationToken cancellationToken) + { + var buffer = new byte[_options.ReceiveBufferSize]; + Exception? error = null; + try + { + while (!cancellationToken.IsCancellationRequested) + { + var read = await _transport.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; // clean end of stream + } + + await _interpreter!.InterpretByteArrayAsync(buffer.AsMemory(0, read)).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // graceful shutdown + } + catch (Exception ex) + { + error = ex; + _logger.LogError(ex, "Telnet receive loop faulted."); + } + finally + { + RaiseDisconnected(error); + } + } + + public ValueTask SendLineAsync(string text, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(text); + var bytes = _currentEncoding.GetBytes(text + "\r\n"); + return SendAsync(bytes, cancellationToken); + } + + public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected."); + return interpreter.SendAsync(data.ToArray()); + } + + public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) + { + var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected."); + return interpreter.SendGMCPCommand(package, json ?? string.Empty); + } + + public ValueTask SetWindowSizeAsync(int width, int height) + { + var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected."); + return interpreter.SendNAWS((short)Math.Clamp(width, 0, short.MaxValue), (short)Math.Clamp(height, 0, short.MaxValue)); + } + + public async Task DisconnectAsync() + { + if (_loopCts is not null) + { + await _loopCts.CancelAsync().ConfigureAwait(false); + } + + await _transport.CloseAsync().ConfigureAwait(false); + + if (_readLoop is not null) + { + try + { + await _readLoop.ConfigureAwait(false); + } + catch + { + // already reported via Disconnected + } + } + + RaiseDisconnected(null); + } + + private void RaiseDisconnected(Exception? error) + { + if (Interlocked.Exchange(ref _disconnected, 1) != 0) + { + return; + } + + Disconnected?.Invoke(this, new SessionDisconnectedEventArgs(error)); + } + + public async ValueTask DisposeAsync() + { + await DisconnectAsync().ConfigureAwait(false); + _loopCts?.Dispose(); + + if (_interpreter is not null) + { + await _interpreter.DisposeAsync().ConfigureAwait(false); + _interpreter = null; + } + + await _transport.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/MuClient.Core/Text/AnsiPalette.cs b/src/MuClient.Core/Text/AnsiPalette.cs new file mode 100644 index 0000000..83e5178 --- /dev/null +++ b/src/MuClient.Core/Text/AnsiPalette.cs @@ -0,0 +1,63 @@ +namespace MuClient.Core.Text; + +/// An 8-bit-per-channel RGB triple. +public readonly record struct Rgb(byte R, byte G, byte B) +{ + /// Returns the colour as a CSS/HTML hex string, e.g. #1a2b3c. + public string ToHex() => $"#{R:x2}{G:x2}{B:x2}"; +} + +/// +/// Resolves ANSI palette indices (0-255) to concrete RGB values using the standard xterm +/// palette: 16 system colours, a 6×6×6 colour cube (16-231), and a 24-step greyscale ramp +/// (232-255). Used for HTML logging and any renderer that needs true RGB from an index. +/// +public static class AnsiPalette +{ + private static readonly Rgb[] System16 = + [ + new(0x00, 0x00, 0x00), new(0x80, 0x00, 0x00), new(0x00, 0x80, 0x00), new(0x80, 0x80, 0x00), + new(0x00, 0x00, 0x80), new(0x80, 0x00, 0x80), new(0x00, 0x80, 0x80), new(0xc0, 0xc0, 0xc0), + new(0x80, 0x80, 0x80), new(0xff, 0x00, 0x00), new(0x00, 0xff, 0x00), new(0xff, 0xff, 0x00), + new(0x00, 0x00, 0xff), new(0xff, 0x00, 0xff), new(0x00, 0xff, 0xff), new(0xff, 0xff, 0xff), + ]; + + private static readonly byte[] CubeLevels = [0, 95, 135, 175, 215, 255]; + + /// Maps a palette index (0-255) to its xterm RGB value. + public static Rgb ToRgb(int index) + { + if (index is < 0 or > 255) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "Palette index must be 0-255."); + } + + if (index < 16) + { + return System16[index]; + } + + if (index < 232) + { + var n = index - 16; + var r = CubeLevels[n / 36 % 6]; + var g = CubeLevels[n / 6 % 6]; + var b = CubeLevels[n % 6]; + return new Rgb(r, g, b); + } + + var grey = (byte)(8 + (index - 232) * 10); + return new Rgb(grey, grey, grey); + } + + /// + /// Resolves a to RGB. Default colours resolve to the supplied + /// fallback (typically the theme's foreground/background). + /// + public static Rgb Resolve(TerminalColor colour, Rgb fallback) => colour.Kind switch + { + TerminalColorKind.Rgb => new Rgb(colour.R, colour.G, colour.B), + TerminalColorKind.Indexed => ToRgb(colour.Index), + _ => fallback, + }; +} diff --git a/src/MuClient.Core/Text/AnsiParser.cs b/src/MuClient.Core/Text/AnsiParser.cs new file mode 100644 index 0000000..0403dc6 --- /dev/null +++ b/src/MuClient.Core/Text/AnsiParser.cs @@ -0,0 +1,440 @@ +using System.Text; + +namespace MuClient.Core.Text; + +/// +/// Incremental, line-oriented ANSI/VT parser. Feed it decoded text (any number of +/// chunks); it emits fully-terminated s and retains the +/// in-progress line plus the current across calls, so escape +/// sequences and colour state may span chunk boundaries. +/// +/// SGR handling covers the 16 base colours, the 256-colour palette (38;5;n), +/// 24-bit truecolour (38;2;r;g;b, including the colon-delimited ISO form), and +/// the common rendition attributes. Non-SGR CSI sequences (cursor movement, erase), +/// OSC strings, and other escapes are recognised and discarded rather than leaking +/// into the output as stray text. +/// +public sealed class AnsiParser +{ + private const int MaxSequenceLength = 128; + + private enum State + { + Ground, + Escape, + EscapeIntermediate, + Csi, + Osc, + OscEscape, + } + + private State _state = State.Ground; + private TextStyle _current = TextStyle.Default; + private readonly StringBuilder _run = new(); + private readonly List _lineSpans = new(); + private readonly StringBuilder _seq = new(); + + /// The rendition state that will apply to the next printed character. + public TextStyle CurrentStyle => _current; + + /// True when a partial line or escape sequence is buffered. + public bool HasPendingContent => _run.Length > 0 || _lineSpans.Count > 0 || _state != State.Ground; + + /// Feeds a chunk of text, returning every line completed by a newline within it. + public IReadOnlyList Feed(string text) + { + ArgumentNullException.ThrowIfNull(text); + return Feed(text.AsSpan()); + } + + /// Feeds a chunk of text, returning every line completed by a newline within it. + public IReadOnlyList Feed(ReadOnlySpan text) + { + List? lines = null; + foreach (var ch in text) + { + Process(ch, ref lines); + } + + return (IReadOnlyList?)lines ?? Array.Empty(); + } + + /// + /// Returns the buffered partial line (e.g. a prompt not terminated by a newline) and + /// clears it, or null if nothing is buffered. Colour state is preserved. + /// + public StyledLine? Flush() + { + FlushRun(); + if (_lineSpans.Count == 0) + { + return null; + } + + var line = new StyledLine(_lineSpans); + _lineSpans.Clear(); + return line; + } + + /// Resets all parser state, including the current style. + public void Reset() + { + _state = State.Ground; + _current = TextStyle.Default; + _run.Clear(); + _lineSpans.Clear(); + _seq.Clear(); + } + + private void Process(char ch, ref List? lines) + { + switch (_state) + { + case State.Ground: + ProcessGround(ch, ref lines); + break; + + case State.Escape: + ProcessEscape(ch); + break; + + case State.EscapeIntermediate: + // Consume the single trailing byte of e.g. ESC ( B and return to ground. + _state = State.Ground; + break; + + case State.Csi: + ProcessCsi(ch); + break; + + case State.Osc: + ProcessOsc(ch); + break; + + case State.OscEscape: + // Inside an OSC string we saw ESC; a following '\' is the ST terminator. + _state = ch == '\\' ? State.Ground : State.Osc; + break; + } + } + + private void ProcessGround(char ch, ref List? lines) + { + switch (ch) + { + case '\x1b': + _state = State.Escape; + break; + + case '\n': + CompleteLine(ref lines); + break; + + case '\r': + // Carriage returns are dropped; scrollback is line-based. + break; + + default: + _run.Append(ch); + break; + } + } + + private void ProcessEscape(char ch) + { + switch (ch) + { + case '[': + _seq.Clear(); + _state = State.Csi; + break; + + case ']': + _seq.Clear(); + _state = State.Osc; + break; + + case '(': + case ')': + case '*': + case '+': + case '#': + case '%': + _state = State.EscapeIntermediate; + break; + + case 'P': // DCS + case 'X': // SOS + case '^': // PM + case '_': // APC + // String sequences terminated by ST (ESC \) — consume like an OSC so their + // payloads never leak into the output as text. + _seq.Clear(); + _state = State.Osc; + break; + + default: + // Two-byte escape (ESC c, ESC M, ...) — consumed and ignored. + _state = State.Ground; + break; + } + } + + private void ProcessCsi(char ch) + { + // Final byte is in the range 0x40-0x7E. + if (ch is >= '\x40' and <= '\x7e') + { + if (ch == 'm') + { + ApplySgr(_seq.ToString()); + } + + // All other CSI sequences (cursor, erase, ...) are discarded. + _seq.Clear(); + _state = State.Ground; + return; + } + + // Parameter (0x30-0x3F) and intermediate (0x20-0x2F) bytes accumulate. + if (ch is >= '\x20' and <= '\x3f') + { + if (_seq.Length < MaxSequenceLength) + { + _seq.Append(ch); + } + + return; + } + + // Anything else (e.g. an embedded control char) aborts the malformed sequence. + _seq.Clear(); + _state = State.Ground; + } + + private void ProcessOsc(char ch) + { + switch (ch) + { + case '\x07': // BEL terminator + _state = State.Ground; + break; + + case '\x1b': // possible ST (ESC \) + _state = State.OscEscape; + break; + + default: + if (_seq.Length < MaxSequenceLength) + { + _seq.Append(ch); + } + + break; + } + } + + private void CompleteLine(ref List? lines) + { + FlushRun(); + var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans); + _lineSpans.Clear(); + (lines ??= new List()).Add(line); + } + + private void FlushRun() + { + if (_run.Length == 0) + { + return; + } + + _lineSpans.Add(new StyledSpan(_run.ToString(), _current)); + _run.Clear(); + } + + private void ApplySgr(string parameters) + { + // Text accumulated so far keeps the pre-change style. + FlushRun(); + + if (parameters.Length == 0) + { + _current = TextStyle.Default; + return; + } + + var tokens = parameters.Split(';'); + for (var i = 0; i < tokens.Length; i++) + { + var token = tokens[i]; + + // Colon-delimited extended colour, e.g. "38:5:196" or "38:2:255:0:0". + if (token.IndexOf(':') >= 0) + { + ApplyColonColor(token); + continue; + } + + if (!int.TryParse(token, out var code)) + { + code = 0; // An empty parameter is treated as 0 (reset). + } + + switch (code) + { + case 0: + _current = TextStyle.Default; + break; + case 1: + _current = _current.AddAttribute(TextAttributes.Bold); + break; + case 2: + _current = _current.AddAttribute(TextAttributes.Faint); + break; + case 3: + _current = _current.AddAttribute(TextAttributes.Italic); + break; + case 4: + _current = _current.AddAttribute(TextAttributes.Underline); + break; + case 5: + case 6: + _current = _current.AddAttribute(TextAttributes.Blink); + break; + case 7: + _current = _current.AddAttribute(TextAttributes.Reverse); + break; + case 8: + _current = _current.AddAttribute(TextAttributes.Conceal); + break; + case 9: + _current = _current.AddAttribute(TextAttributes.Strikethrough); + break; + case 21: + case 22: + _current = _current.RemoveAttribute(TextAttributes.Bold | TextAttributes.Faint); + break; + case 23: + _current = _current.RemoveAttribute(TextAttributes.Italic); + break; + case 24: + _current = _current.RemoveAttribute(TextAttributes.Underline); + break; + case 25: + _current = _current.RemoveAttribute(TextAttributes.Blink); + break; + case 27: + _current = _current.RemoveAttribute(TextAttributes.Reverse); + break; + case 28: + _current = _current.RemoveAttribute(TextAttributes.Conceal); + break; + case 29: + _current = _current.RemoveAttribute(TextAttributes.Strikethrough); + break; + case >= 30 and <= 37: + _current = _current.WithForeground(TerminalColor.FromIndex(code - 30)); + break; + case 38: + _current = _current.WithForeground(ParseExtendedColor(tokens, ref i) ?? _current.Foreground); + break; + case 39: + _current = _current.WithForeground(TerminalColor.Default); + break; + case >= 40 and <= 47: + _current = _current.WithBackground(TerminalColor.FromIndex(code - 40)); + break; + case 48: + _current = _current.WithBackground(ParseExtendedColor(tokens, ref i) ?? _current.Background); + break; + case 49: + _current = _current.WithBackground(TerminalColor.Default); + break; + case >= 90 and <= 97: + _current = _current.WithForeground(TerminalColor.FromIndex(code - 90 + 8)); + break; + case >= 100 and <= 107: + _current = _current.WithBackground(TerminalColor.FromIndex(code - 100 + 8)); + break; + default: + // Unknown SGR code — ignored. + break; + } + } + } + + /// + /// Parses the semicolon-form extended colour that follows a 38/48 code, advancing + /// past the consumed tokens. Returns null if malformed. + /// + private static TerminalColor? ParseExtendedColor(string[] tokens, ref int i) + { + if (i + 1 >= tokens.Length || !int.TryParse(tokens[i + 1], out var mode)) + { + return null; + } + + switch (mode) + { + case 5 when i + 2 < tokens.Length && int.TryParse(tokens[i + 2], out var idx) && idx is >= 0 and <= 255: + i += 2; + return TerminalColor.FromIndex(idx); + + case 2 when i + 4 < tokens.Length && + byte.TryParse(tokens[i + 2], out var r) && + byte.TryParse(tokens[i + 3], out var g) && + byte.TryParse(tokens[i + 4], out var b): + i += 4; + return TerminalColor.FromRgb(r, g, b); + + default: + return null; + } + } + + /// Parses a single colon-delimited extended colour token and applies it. + private void ApplyColonColor(string token) + { + var parts = token.Split(':'); + if (parts.Length < 3 || !int.TryParse(parts[0], out var target)) + { + return; + } + + var isForeground = target == 38; + if (target is not (38 or 48)) + { + return; + } + + if (!int.TryParse(parts[1], out var mode)) + { + return; + } + + TerminalColor? colour = null; + if (mode == 5 && int.TryParse(parts[2], out var idx) && idx is >= 0 and <= 255) + { + colour = TerminalColor.FromIndex(idx); + } + else if (mode == 2 && parts.Length >= 5) + { + // ISO form may carry a colour-space id at parts[2]; the RGB triple is the last three. + var baseIndex = parts.Length >= 6 ? parts.Length - 3 : 2; + if (byte.TryParse(parts[baseIndex], out var r) && + byte.TryParse(parts[baseIndex + 1], out var g) && + byte.TryParse(parts[baseIndex + 2], out var b)) + { + colour = TerminalColor.FromRgb(r, g, b); + } + } + + if (colour is null) + { + return; + } + + _current = isForeground ? _current.WithForeground(colour.Value) : _current.WithBackground(colour.Value); + } +} diff --git a/src/MuClient.Core/Text/ScrollbackBuffer.cs b/src/MuClient.Core/Text/ScrollbackBuffer.cs new file mode 100644 index 0000000..666b22d --- /dev/null +++ b/src/MuClient.Core/Text/ScrollbackBuffer.cs @@ -0,0 +1,140 @@ +namespace MuClient.Core.Text; + +/// Event payload describing lines appended to a . +public sealed class LinesAppendedEventArgs(IReadOnlyList lines) : EventArgs +{ + public IReadOnlyList Lines { get; } = lines; +} + +/// +/// A bounded, thread-safe ring buffer of terminal output lines. Old lines are evicted +/// once is exceeded. UI-agnostic: the view subscribes to +/// and reads windows via . +/// +public sealed class ScrollbackBuffer +{ + private readonly LinkedList _lines = new(); + private readonly object _gate = new(); + + public ScrollbackBuffer(int capacity = 20_000) + { + if (capacity < 1) + { + throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "Capacity must be at least 1."); + } + + Capacity = capacity; + } + + /// Maximum number of retained lines. + public int Capacity { get; } + + /// Raised after one or more lines are appended. + public event EventHandler? LinesAppended; + + public int Count + { + get + { + lock (_gate) + { + return _lines.Count; + } + } + } + + /// Appends a single line, evicting the oldest if at capacity. + public void Append(StyledLine line) + { + ArgumentNullException.ThrowIfNull(line); + lock (_gate) + { + AppendLocked(line); + } + + LinesAppended?.Invoke(this, new LinesAppendedEventArgs(new[] { line })); + } + + /// Appends a batch of lines, raising a single event. + public void AppendRange(IReadOnlyList lines) + { + ArgumentNullException.ThrowIfNull(lines); + if (lines.Count == 0) + { + return; + } + + lock (_gate) + { + foreach (var line in lines) + { + AppendLocked(line); + } + } + + LinesAppended?.Invoke(this, new LinesAppendedEventArgs(lines)); + } + + /// Returns a snapshot copy of a window of lines by absolute index from the oldest. + public IReadOnlyList GetRange(int start, int count) + { + if (start < 0) + { + throw new ArgumentOutOfRangeException(nameof(start)); + } + + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + lock (_gate) + { + var result = new List(Math.Min(count, Math.Max(0, _lines.Count - start))); + var index = 0; + foreach (var line in _lines) + { + if (index >= start + count) + { + break; + } + + if (index >= start) + { + result.Add(line); + } + + index++; + } + + return result; + } + } + + /// Returns a snapshot copy of every retained line. + public IReadOnlyList Snapshot() + { + lock (_gate) + { + return _lines.ToArray(); + } + } + + /// Removes all lines. + public void Clear() + { + lock (_gate) + { + _lines.Clear(); + } + } + + private void AppendLocked(StyledLine line) + { + _lines.AddLast(line); + while (_lines.Count > Capacity) + { + _lines.RemoveFirst(); + } + } +} diff --git a/src/MuClient.Core/Text/StyledLine.cs b/src/MuClient.Core/Text/StyledLine.cs new file mode 100644 index 0000000..88b02f3 --- /dev/null +++ b/src/MuClient.Core/Text/StyledLine.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace MuClient.Core.Text; + +/// +/// A single logical line of terminal output: an ordered list of s +/// plus a lazily-computed plain-text projection used by triggers, search, and logging. +/// +public sealed class StyledLine +{ + private readonly StyledSpan[] _spans; + private string? _text; + + public StyledLine(IEnumerable spans) + { + ArgumentNullException.ThrowIfNull(spans); + _spans = spans.Where(s => s.Length > 0).ToArray(); + } + + /// An empty line (a blank row of output). + public static StyledLine Empty { get; } = new(Array.Empty()); + + public IReadOnlyList Spans => _spans; + + /// The concatenated plain text of every span, with all styling removed. + public string Text + { + get + { + if (_text is not null) + { + return _text; + } + + if (_spans.Length == 0) + { + return _text = string.Empty; + } + + if (_spans.Length == 1) + { + return _text = _spans[0].Text; + } + + var sb = new StringBuilder(); + foreach (var span in _spans) + { + sb.Append(span.Text); + } + + return _text = sb.ToString(); + } + } + + public int Length => Text.Length; + + public bool IsEmpty => _spans.Length == 0; + + /// Builds a line from a single unstyled string of plain text. + public static StyledLine FromText(string text, TextStyle style) => + string.IsNullOrEmpty(text) ? Empty : new StyledLine(new[] { new StyledSpan(text, style) }); + + public override string ToString() => Text; +} diff --git a/src/MuClient.Core/Text/StyledSpan.cs b/src/MuClient.Core/Text/StyledSpan.cs new file mode 100644 index 0000000..7ec15b3 --- /dev/null +++ b/src/MuClient.Core/Text/StyledSpan.cs @@ -0,0 +1,25 @@ +namespace MuClient.Core.Text; + +/// A run of text sharing a single . +public readonly struct StyledSpan : IEquatable +{ + public StyledSpan(string text, TextStyle style) + { + Text = text ?? throw new ArgumentNullException(nameof(text)); + Style = style; + } + + public string Text { get; } + + public TextStyle Style { get; } + + public int Length => Text.Length; + + public bool Equals(StyledSpan other) => Text == other.Text && Style.Equals(other.Style); + + public override bool Equals(object? obj) => obj is StyledSpan other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Text, Style); + + public override string ToString() => Text; +} diff --git a/src/MuClient.Core/Text/StyledText.cs b/src/MuClient.Core/Text/StyledText.cs new file mode 100644 index 0000000..8ffe4f0 --- /dev/null +++ b/src/MuClient.Core/Text/StyledText.cs @@ -0,0 +1,76 @@ +namespace MuClient.Core.Text; + +/// Helpers for transforming s at character granularity. +public static class StyledText +{ + /// + /// Applies to the styles of the characters in the range + /// [, + ), returning + /// a new line with spans re-coalesced. Ranges outside the text are clamped. + /// + public static StyledLine Restyle(StyledLine line, int start, int length, Func transform) + { + ArgumentNullException.ThrowIfNull(line); + ArgumentNullException.ThrowIfNull(transform); + + if (line.IsEmpty || length <= 0) + { + return line; + } + + var text = line.Text; + var rangeStart = Math.Clamp(start, 0, text.Length); + var rangeEnd = Math.Clamp(start + length, 0, text.Length); + if (rangeStart >= rangeEnd) + { + return line; + } + + // Expand to per-character styles. + var styles = new TextStyle[text.Length]; + var offset = 0; + foreach (var span in line.Spans) + { + for (var i = 0; i < span.Text.Length; i++) + { + styles[offset++] = span.Style; + } + } + + for (var i = rangeStart; i < rangeEnd; i++) + { + styles[i] = transform(styles[i]); + } + + return Coalesce(text, styles); + } + + /// Rebuilds a line from a plain string and a parallel per-character style array. + public static StyledLine Coalesce(string text, TextStyle[] styles) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(styles); + if (text.Length == 0) + { + return StyledLine.Empty; + } + + if (styles.Length != text.Length) + { + throw new ArgumentException("Style array length must match text length.", nameof(styles)); + } + + var spans = new List(); + var runStart = 0; + for (var i = 1; i <= text.Length; i++) + { + if (i == text.Length || styles[i] != styles[runStart]) + { + spans.Add(new StyledSpan(text[runStart..i], styles[runStart])); + runStart = i; + } + } + + return new StyledLine(spans); + } +} diff --git a/src/MuClient.Core/Text/TerminalColor.cs b/src/MuClient.Core/Text/TerminalColor.cs new file mode 100644 index 0000000..5e7cb73 --- /dev/null +++ b/src/MuClient.Core/Text/TerminalColor.cs @@ -0,0 +1,92 @@ +namespace MuClient.Core.Text; + +/// How a value should be interpreted. +public enum TerminalColorKind : byte +{ + /// The terminal's default foreground/background colour. + Default, + + /// A palette index in the range 0-255 (16 ANSI + 216 cube + 24 greys). + Indexed, + + /// A 24-bit truecolour value. + Rgb, +} + +/// +/// A colour as expressed by an ANSI SGR sequence: the terminal default, a palette +/// index (0-255), or a 24-bit RGB triple. UI-agnostic — the renderer decides how to +/// realise an index against a concrete palette. +/// +public readonly struct TerminalColor : IEquatable +{ + private TerminalColor(TerminalColorKind kind, byte index, byte r, byte g, byte b) + { + Kind = kind; + Index = index; + R = r; + G = g; + B = b; + } + + /// How to interpret this colour. + public TerminalColorKind Kind { get; } + + /// Palette index (0-255), valid when is . + public byte Index { get; } + + /// Red channel, valid when is . + public byte R { get; } + + /// Green channel, valid when is . + public byte G { get; } + + /// Blue channel, valid when is . + public byte B { get; } + + /// The terminal default colour. + public static TerminalColor Default => new(TerminalColorKind.Default, 0, 0, 0, 0); + + /// A palette colour by index (0-255). + public static TerminalColor FromIndex(int index) + { + if (index is < 0 or > 255) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "Palette index must be 0-255."); + } + + return new TerminalColor(TerminalColorKind.Indexed, (byte)index, 0, 0, 0); + } + + /// A 24-bit truecolour value. + public static TerminalColor FromRgb(byte r, byte g, byte b) => new(TerminalColorKind.Rgb, 0, r, g, b); + + public bool Equals(TerminalColor other) => Kind switch + { + TerminalColorKind.Default => other.Kind == TerminalColorKind.Default, + TerminalColorKind.Indexed => other.Kind == TerminalColorKind.Indexed && Index == other.Index, + TerminalColorKind.Rgb => other.Kind == TerminalColorKind.Rgb && R == other.R && G == other.G && B == other.B, + _ => false, + }; + + public override bool Equals(object? obj) => obj is TerminalColor other && Equals(other); + + public override int GetHashCode() => Kind switch + { + TerminalColorKind.Indexed => HashCode.Combine(Kind, Index), + TerminalColorKind.Rgb => HashCode.Combine(Kind, R, G, B), + _ => HashCode.Combine(Kind), + }; + + public static bool operator ==(TerminalColor left, TerminalColor right) => left.Equals(right); + + public static bool operator !=(TerminalColor left, TerminalColor right) => !left.Equals(right); + + public override string ToString() => Kind switch + { + TerminalColorKind.Default => "default", + TerminalColorKind.Indexed => $"idx({Index})", + TerminalColorKind.Rgb => $"rgb({R},{G},{B})", + _ => "?", + }; +} diff --git a/src/MuClient.Core/Text/TextStyle.cs b/src/MuClient.Core/Text/TextStyle.cs new file mode 100644 index 0000000..6253426 --- /dev/null +++ b/src/MuClient.Core/Text/TextStyle.cs @@ -0,0 +1,64 @@ +namespace MuClient.Core.Text; + +/// Non-colour text rendition attributes carried by an SGR sequence. +[Flags] +public enum TextAttributes +{ + None = 0, + Bold = 1 << 0, + Faint = 1 << 1, + Italic = 1 << 2, + Underline = 1 << 3, + Blink = 1 << 4, + Reverse = 1 << 5, + Conceal = 1 << 6, + Strikethrough = 1 << 7, +} + +/// +/// An immutable snapshot of terminal rendition state: foreground, background, and +/// attribute flags. Produced by and consumed by the renderer. +/// +public readonly struct TextStyle : IEquatable +{ + public TextStyle(TerminalColor foreground, TerminalColor background, TextAttributes attributes) + { + Foreground = foreground; + Background = background; + Attributes = attributes; + } + + public TerminalColor Foreground { get; } + + public TerminalColor Background { get; } + + public TextAttributes Attributes { get; } + + /// The default rendition: default colours, no attributes. + public static TextStyle Default => new(TerminalColor.Default, TerminalColor.Default, TextAttributes.None); + + public bool HasAttribute(TextAttributes attribute) => (Attributes & attribute) == attribute; + + public TextStyle WithForeground(TerminalColor foreground) => new(foreground, Background, Attributes); + + public TextStyle WithBackground(TerminalColor background) => new(Foreground, background, Attributes); + + public TextStyle WithAttributes(TextAttributes attributes) => new(Foreground, Background, attributes); + + public TextStyle AddAttribute(TextAttributes attribute) => new(Foreground, Background, Attributes | attribute); + + public TextStyle RemoveAttribute(TextAttributes attribute) => new(Foreground, Background, Attributes & ~attribute); + + public bool Equals(TextStyle other) => + Foreground.Equals(other.Foreground) && + Background.Equals(other.Background) && + Attributes == other.Attributes; + + public override bool Equals(object? obj) => obj is TextStyle other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Foreground, Background, Attributes); + + public static bool operator ==(TextStyle left, TextStyle right) => left.Equals(right); + + public static bool operator !=(TextStyle left, TextStyle right) => !left.Equals(right); +} diff --git a/src/MuClient.Core/Theming/Theme.cs b/src/MuClient.Core/Theming/Theme.cs new file mode 100644 index 0000000..2303d22 --- /dev/null +++ b/src/MuClient.Core/Theming/Theme.cs @@ -0,0 +1,62 @@ +using MuClient.Core.Text; + +namespace MuClient.Core.Theming; + +/// +/// A colour theme: semantic UI colours plus an optional override of the 16 base ANSI palette +/// entries. Pure data (no UI dependency) so it can be serialised to the config and applied by +/// any renderer — the TUI maps it to Terminal.Gui attributes, logs map it to CSS. +/// +public sealed class Theme +{ + public string Name { get; set; } = "Dark"; + + /// Default text foreground (for foregrounds). + public Rgb Foreground { get; set; } = new(0xd0, 0xd0, 0xd0); + + /// Default text background. + public Rgb Background { get; set; } = new(0x1e, 0x1e, 0x1e); + + /// Status bar foreground. + public Rgb StatusForeground { get; set; } = new(0xe0, 0xe0, 0xe0); + + /// Status bar background. + public Rgb StatusBackground { get; set; } = new(0x33, 0x33, 0x44); + + /// Window border / chrome colour. + public Rgb Border { get; set; } = new(0x55, 0x55, 0x66); + + /// Colour of client system messages (e.g. *** Connected). + public Rgb SystemMessage { get; set; } = new(0x6a, 0x99, 0x55); + + /// Colour of locally-echoed user input. + public Rgb LocalEcho { get; set; } = new(0xdc, 0xdc, 0xaa); + + /// Colour of the prompt line. + public Rgb Prompt { get; set; } = new(0x56, 0x9c, 0xd6); + + /// + /// Optional override of the 16 base ANSI colours (indices 0-15). When null or the wrong + /// length, the standard xterm values are used. + /// + public List? Palette16 { get; set; } + + /// Resolves a palette index (0-255) honouring any override. + public Rgb ResolveIndex(int index) + { + if (index is >= 0 and < 16 && Palette16 is { Count: 16 }) + { + return Palette16[index]; + } + + return AnsiPalette.ToRgb(index); + } + + /// Resolves a to RGB, using theme defaults for default colours. + public Rgb Resolve(TerminalColor colour, bool isBackground) => colour.Kind switch + { + TerminalColorKind.Rgb => new Rgb(colour.R, colour.G, colour.B), + TerminalColorKind.Indexed => ResolveIndex(colour.Index), + _ => isBackground ? Background : Foreground, + }; +} diff --git a/src/MuClient.Core/Theming/ThemeLibrary.cs b/src/MuClient.Core/Theming/ThemeLibrary.cs new file mode 100644 index 0000000..01bc7e2 --- /dev/null +++ b/src/MuClient.Core/Theming/ThemeLibrary.cs @@ -0,0 +1,70 @@ +using MuClient.Core.Text; + +namespace MuClient.Core.Theming; + +/// Built-in themes and lookup by name (yazi-style named flavours). +public static class ThemeLibrary +{ + /// The default dark theme. + public static Theme Dark() => new() + { + Name = "Dark", + Foreground = new Rgb(0xd0, 0xd0, 0xd0), + Background = new Rgb(0x1e, 0x1e, 0x1e), + StatusForeground = new Rgb(0xe0, 0xe0, 0xe0), + StatusBackground = new Rgb(0x2d, 0x2d, 0x3f), + Border = new Rgb(0x55, 0x55, 0x66), + SystemMessage = new Rgb(0x6a, 0x99, 0x55), + LocalEcho = new Rgb(0xdc, 0xdc, 0xaa), + Prompt = new Rgb(0x56, 0x9c, 0xd6), + }; + + /// A light theme for bright terminals. + public static Theme Light() => new() + { + Name = "Light", + Foreground = new Rgb(0x2b, 0x2b, 0x2b), + Background = new Rgb(0xfa, 0xfa, 0xfa), + StatusForeground = new Rgb(0x1a, 0x1a, 0x1a), + StatusBackground = new Rgb(0xdc, 0xdc, 0xe6), + Border = new Rgb(0xb0, 0xb0, 0xc0), + SystemMessage = new Rgb(0x1a, 0x7f, 0x37), + LocalEcho = new Rgb(0x8a, 0x6d, 0x00), + Prompt = new Rgb(0x1f, 0x5f, 0xbf), + }; + + /// The classic Solarized Dark palette. + public static Theme SolarizedDark() => new() + { + Name = "Solarized Dark", + Foreground = new Rgb(0x83, 0x94, 0x96), + Background = new Rgb(0x00, 0x2b, 0x36), + StatusForeground = new Rgb(0x93, 0xa1, 0xa1), + StatusBackground = new Rgb(0x07, 0x36, 0x42), + Border = new Rgb(0x58, 0x6e, 0x75), + SystemMessage = new Rgb(0x85, 0x99, 0x00), + LocalEcho = new Rgb(0xb5, 0x89, 0x00), + Prompt = new Rgb(0x26, 0x8b, 0xd2), + Palette16 = + [ + new Rgb(0x07, 0x36, 0x42), new Rgb(0xdc, 0x32, 0x2f), new Rgb(0x85, 0x99, 0x00), new Rgb(0xb5, 0x89, 0x00), + new Rgb(0x26, 0x8b, 0xd2), new Rgb(0xd3, 0x36, 0x82), new Rgb(0x2a, 0xa1, 0x98), new Rgb(0xee, 0xe8, 0xd5), + new Rgb(0x00, 0x2b, 0x36), new Rgb(0xcb, 0x4b, 0x16), new Rgb(0x58, 0x6e, 0x75), new Rgb(0x65, 0x7b, 0x83), + new Rgb(0x83, 0x94, 0x96), new Rgb(0x6c, 0x71, 0xc4), new Rgb(0x93, 0xa1, 0xa1), new Rgb(0xfd, 0xf6, 0xe3), + ], + }; + + private static readonly Dictionary> Builtins = new(StringComparer.OrdinalIgnoreCase) + { + ["Dark"] = Dark, + ["Light"] = Light, + ["Solarized Dark"] = SolarizedDark, + ["SolarizedDark"] = SolarizedDark, + }; + + public static IReadOnlyCollection Names => new[] { "Dark", "Light", "Solarized Dark" }; + + /// Returns the built-in theme by name, or the dark default if unknown. + public static Theme Get(string? name) => + name is not null && Builtins.TryGetValue(name, out var factory) ? factory() : Dark(); +} diff --git a/src/MuClient.Core/Transport/ConnectionOptions.cs b/src/MuClient.Core/Transport/ConnectionOptions.cs new file mode 100644 index 0000000..6597a63 --- /dev/null +++ b/src/MuClient.Core/Transport/ConnectionOptions.cs @@ -0,0 +1,28 @@ +namespace MuClient.Core.Transport; + +/// Immutable connection parameters for a single MU* world. +public sealed class ConnectionOptions +{ + /// Hostname or IP literal (IPv4 or IPv6) of the server. + public required string Host { get; init; } + + /// TCP port. + public required int Port { get; init; } + + /// When true, wrap the socket in TLS via . + public bool UseTls { get; init; } + + /// + /// When true, accept server certificates that fail validation (self-signed certs are + /// common on hobbyist MU* servers). Off by default. + /// + public bool AllowInvalidCertificates { get; init; } + + /// SNI / certificate target host. Defaults to when null. + public string? TlsTargetHost { get; init; } + + /// Socket connect timeout. Defaults to 30 seconds. + public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(30); + + public override string ToString() => $"{(UseTls ? "telnets" : "telnet")}://{Host}:{Port}"; +} diff --git a/src/MuClient.Core/Transport/ITransport.cs b/src/MuClient.Core/Transport/ITransport.cs new file mode 100644 index 0000000..6112c0d --- /dev/null +++ b/src/MuClient.Core/Transport/ITransport.cs @@ -0,0 +1,29 @@ +namespace MuClient.Core.Transport; + +/// +/// A bidirectional byte transport (TCP, optionally TLS). Kept deliberately minimal so it +/// can be faked in unit tests; all telnet/ANSI logic sits above it. +/// +public interface ITransport : IAsyncDisposable +{ + /// True once has completed and the link is open. + bool IsConnected { get; } + + /// A human-readable description of the remote endpoint, once connected. + string? RemoteDescription { get; } + + /// Opens the connection (DNS resolution, TCP, and TLS handshake if configured). + Task ConnectAsync(CancellationToken cancellationToken = default); + + /// Writes bytes to the transport. + ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default); + + /// + /// Reads available bytes into . Returns the number of bytes read, + /// or 0 when the remote end has closed the connection. + /// + ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default); + + /// Closes the connection. + Task CloseAsync(); +} diff --git a/src/MuClient.Core/Transport/TcpTransport.cs b/src/MuClient.Core/Transport/TcpTransport.cs new file mode 100644 index 0000000..b14f3e1 --- /dev/null +++ b/src/MuClient.Core/Transport/TcpTransport.cs @@ -0,0 +1,129 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; + +namespace MuClient.Core.Transport; + +/// +/// A TCP transport with optional TLS. DNS resolution via makes it +/// dual-stack (IPv4 and IPv6). When is set, the +/// network stream is wrapped in an and authenticated as a client. +/// +public sealed class TcpTransport(ConnectionOptions options) : ITransport +{ + private readonly ConnectionOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly SemaphoreSlim _sendLock = new(1, 1); + private TcpClient? _client; + private Stream? _stream; + + public bool IsConnected => _client?.Connected == true; + + public string? RemoteDescription { get; private set; } + + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + if (_client is not null) + { + throw new InvalidOperationException("Transport is already connected."); + } + + var client = new TcpClient { NoDelay = true }; + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_options.ConnectTimeout); + await client.ConnectAsync(_options.Host, _options.Port, timeoutCts.Token).ConfigureAwait(false); + + Stream stream = client.GetStream(); + if (_options.UseTls) + { + stream = await AuthenticateTlsAsync(stream, cancellationToken).ConfigureAwait(false); + } + + _client = client; + _stream = stream; + RemoteDescription = client.Client.RemoteEndPoint?.ToString() ?? _options.ToString(); + } + catch + { + client.Dispose(); + throw; + } + } + + private async Task AuthenticateTlsAsync(Stream inner, CancellationToken cancellationToken) + { + var ssl = new SslStream(inner, leaveInnerStreamOpen: false, ValidateCertificate); + var sslOptions = new SslClientAuthenticationOptions + { + TargetHost = _options.TlsTargetHost ?? _options.Host, + EnabledSslProtocols = SslProtocols.None, // let the OS negotiate TLS 1.2/1.3 + }; + + await ssl.AuthenticateAsClientAsync(sslOptions, cancellationToken).ConfigureAwait(false); + return ssl; + } + + private bool ValidateCertificate( + object sender, + X509Certificate? certificate, + X509Chain? chain, + SslPolicyErrors errors) + => errors == SslPolicyErrors.None || _options.AllowInvalidCertificates; + + public async ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + var stream = _stream ?? throw new InvalidOperationException("Transport is not connected."); + + // Serialize writes: telnet negotiation, user commands, and trigger responses can all + // reach here concurrently, and overlapping WriteAsync calls on one stream corrupt framing. + await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await stream.WriteAsync(data, cancellationToken).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + public async ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var stream = _stream ?? throw new InvalidOperationException("Transport is not connected."); + try + { + return await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or SocketException) + { + // Treat a broken/closed socket as a clean end-of-stream. + return 0; + } + } + + public Task CloseAsync() + { + try + { + _stream?.Dispose(); + _client?.Close(); + } + catch + { + // Best-effort close. + } + + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await CloseAsync().ConfigureAwait(false); + _client?.Dispose(); + _client = null; + _stream = null; + _sendLock.Dispose(); + } +} diff --git a/src/MuClient.Graphics/CapabilityProbe.cs b/src/MuClient.Graphics/CapabilityProbe.cs new file mode 100644 index 0000000..4c8db87 --- /dev/null +++ b/src/MuClient.Graphics/CapabilityProbe.cs @@ -0,0 +1,141 @@ +using System.Collections; + +namespace MuClient.Graphics; + +/// +/// Detects the best available inline-graphics protocol from environment variables. +/// +/// A headless sandbox cannot perform interactive terminal DA/query handshakes, so we +/// rely on environment heuristics only. Detection is pure over an injected environment +/// so it can be unit-tested exhaustively; is the +/// convenience that reads the real process environment. +/// +public static class CapabilityProbe +{ + /// Explicit override: forces a specific protocol regardless of heuristics. + public const string OverrideVariable = "MUGLYPH_GRAPHICS"; + + // Terminals known to speak Sixel that do not otherwise advertise it via TERM. + private static readonly string[] KnownSixelTermPrograms = + { + "mlterm", + "yaft", + "foot", + "contour", + "mintty", + "wezterm", + }; + + /// + /// Detects capabilities from an explicit environment map (pure and testable). + /// Values may be null; lookups are case-sensitive on the key as POSIX env vars are. + /// + public static TerminalCapabilities Detect(IReadOnlyDictionary environment) + { + ArgumentNullException.ThrowIfNull(environment); + + var term = Get(environment, "TERM"); + var termProgram = Get(environment, "TERM_PROGRAM"); + var colorTerm = Get(environment, "COLORTERM"); + + var supportsTrueColor = + Equals(colorTerm, "truecolor") || Equals(colorTerm, "24bit"); + + var supportsKitty = + Contains(term, "kitty") || + !string.IsNullOrEmpty(Get(environment, "KITTY_WINDOW_ID")) || + Equals(termProgram, "ghostty") || + Equals(termProgram, "WezTerm"); + + var supportsSixel = + Contains(term, "sixel") || + Equals(Get(environment, "MUGLYPH_SIXEL"), "1") || + IsKnownSixelProgram(termProgram); + + // An explicit override wins over everything, but we still report the individual + // capability flags we detected so callers can reason about fallbacks. + var overrideValue = Get(environment, OverrideVariable); + if (TryParseOverride(overrideValue, out var forced)) + { + return new TerminalCapabilities(forced, supportsTrueColor, supportsKitty, supportsSixel); + } + + var protocol = supportsKitty + ? GraphicsProtocol.Kitty + : supportsSixel + ? GraphicsProtocol.Sixel + : supportsTrueColor + ? GraphicsProtocol.HalfBlock + : GraphicsProtocol.None; + + return new TerminalCapabilities(protocol, supportsTrueColor, supportsKitty, supportsSixel); + } + + /// Detects capabilities from the current process environment. + public static TerminalCapabilities DetectFromEnvironment() => Detect(ReadProcessEnvironment()); + + /// Snapshots the process environment into a plain dictionary. + public static IReadOnlyDictionary ReadProcessEnvironment() + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + if (entry.Key is string key) + { + result[key] = entry.Value as string; + } + } + + return result; + } + + private static bool TryParseOverride(string? value, out GraphicsProtocol protocol) + { + switch (value?.Trim().ToLowerInvariant()) + { + case "none": + protocol = GraphicsProtocol.None; + return true; + case "halfblock": + case "half-block": + protocol = GraphicsProtocol.HalfBlock; + return true; + case "sixel": + protocol = GraphicsProtocol.Sixel; + return true; + case "kitty": + protocol = GraphicsProtocol.Kitty; + return true; + default: + protocol = GraphicsProtocol.None; + return false; + } + } + + private static bool IsKnownSixelProgram(string? termProgram) + { + if (string.IsNullOrEmpty(termProgram)) + { + return false; + } + + foreach (var known in KnownSixelTermPrograms) + { + if (string.Equals(termProgram, known, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string? Get(IReadOnlyDictionary environment, string key) => + environment.TryGetValue(key, out var value) ? value : null; + + private static bool Contains(string? haystack, string needle) => + haystack is not null && haystack.Contains(needle, StringComparison.OrdinalIgnoreCase); + + private static bool Equals(string? value, string expected) => + string.Equals(value, expected, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/MuClient.Graphics/GraphicsProtocol.cs b/src/MuClient.Graphics/GraphicsProtocol.cs new file mode 100644 index 0000000..7891629 --- /dev/null +++ b/src/MuClient.Graphics/GraphicsProtocol.cs @@ -0,0 +1,20 @@ +namespace MuClient.Graphics; + +/// +/// An inline-graphics transport, ordered by ascending capability. A renderer should +/// pick the highest protocol the terminal actually supports and fall back downward. +/// +public enum GraphicsProtocol +{ + /// No inline graphics; only a textual placeholder can be shown. + None = 0, + + /// Universal fallback: two stacked pixels per cell via the ▀ half-block glyph. + HalfBlock = 1, + + /// DEC Sixel raster graphics. + Sixel = 2, + + /// The Kitty graphics protocol (best fidelity, cell-anchored placeholders). + Kitty = 3, +} diff --git a/src/MuClient.Graphics/HalfBlockRenderer.cs b/src/MuClient.Graphics/HalfBlockRenderer.cs new file mode 100644 index 0000000..6739989 --- /dev/null +++ b/src/MuClient.Graphics/HalfBlockRenderer.cs @@ -0,0 +1,110 @@ +using MuClient.Core.Text; + +namespace MuClient.Graphics; + +/// +/// Renders an to styled text using the upper-half-block glyph +/// (U+2580). Each character cell packs two vertically-stacked pixels: the +/// top pixel becomes the glyph's foreground colour, the bottom pixel the background colour. +/// This doubles vertical resolution and works in any truecolour terminal — the universal +/// fallback that also functions in a headless sandbox. +/// +public sealed class HalfBlockRenderer +{ + /// The upper-half-block glyph; foreground fills the top, background the bottom. + public const string UpperHalfBlock = "▀"; + + /// + /// Renders into at most columns and + /// character rows. One character row represents two pixel rows, + /// so the pixel grid is scaled to maxCols × (maxRows*2) preserving aspect ratio, + /// then sampled by nearest-neighbour. Returns one per character row. + /// + public IReadOnlyList Render(IImageSource image, int maxCols, int maxRows) + { + ArgumentNullException.ThrowIfNull(image); + + if (maxCols <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxCols), maxCols, "Column budget must be positive."); + } + + if (maxRows <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxRows), maxRows, "Row budget must be positive."); + } + + // Target pixel grid. Each character row holds two pixel rows, so the vertical pixel + // budget is maxRows*2. Preserve aspect ratio within the budget. + var maxPixelRows = maxRows * 2; + var (targetCols, targetPixelRows) = FitPreservingAspect( + image.Width, image.Height, maxCols, maxPixelRows); + + // Round up to an even number of pixel rows so every pixel row pairs into a cell. + var charRows = (targetPixelRows + 1) / 2; + + var lines = new StyledLine[charRows]; + for (var charRow = 0; charRow < charRows; charRow++) + { + var topPixelRow = charRow * 2; + var bottomPixelRow = topPixelRow + 1; + + var spans = new StyledSpan[targetCols]; + for (var col = 0; col < targetCols; col++) + { + var top = SampleNearest(image, col, topPixelRow, targetCols, targetPixelRows); + + // Odd total height: the final cell's bottom pixel does not exist. Fall back + // to the terminal default background so the block collapses to a solid top. + var bottom = bottomPixelRow < targetPixelRows + ? (TerminalColor?)SampleNearest(image, col, bottomPixelRow, targetCols, targetPixelRows) + : null; + + var style = new TextStyle( + top, + bottom ?? TerminalColor.Default, + TextAttributes.None); + spans[col] = new StyledSpan(UpperHalfBlock, style); + } + + lines[charRow] = new StyledLine(spans); + } + + return lines; + } + + private static TerminalColor SampleNearest( + IImageSource image, int col, int pixelRow, int targetCols, int targetPixelRows) + { + // Nearest-neighbour map from the target grid back into source pixels. + var srcX = targetCols == 1 ? 0 : col * image.Width / targetCols; + var srcY = targetPixelRows == 1 ? 0 : pixelRow * image.Height / targetPixelRows; + + if (srcX >= image.Width) + { + srcX = image.Width - 1; + } + + if (srcY >= image.Height) + { + srcY = image.Height - 1; + } + + var p = image.GetPixel(srcX, srcY); + return TerminalColor.FromRgb(p.R, p.G, p.B); + } + + private static (int Cols, int PixelRows) FitPreservingAspect( + int srcWidth, int srcHeight, int maxCols, int maxPixelRows) + { + // Scale so neither budget is exceeded, keeping aspect ratio; never upscale. + var scale = Math.Min( + Math.Min(1.0, (double)maxCols / srcWidth), + (double)maxPixelRows / srcHeight); + + var cols = Math.Max(1, (int)Math.Round(srcWidth * scale)); + var pixelRows = Math.Max(1, (int)Math.Round(srcHeight * scale)); + + return (Math.Min(cols, maxCols), Math.Min(pixelRows, maxPixelRows)); + } +} diff --git a/src/MuClient.Graphics/IImageSource.cs b/src/MuClient.Graphics/IImageSource.cs new file mode 100644 index 0000000..9137422 --- /dev/null +++ b/src/MuClient.Graphics/IImageSource.cs @@ -0,0 +1,107 @@ +namespace MuClient.Graphics; + +/// +/// A 32-bit non-premultiplied RGBA pixel. Keeps the graphics subsystem free of any +/// image-decoding dependency: callers decode into this and hand us a pixel source. +/// +public readonly struct Rgba32 : IEquatable +{ + public Rgba32(byte r, byte g, byte b, byte a = 255) + { + R = r; + G = g; + B = b; + A = a; + } + + public byte R { get; } + + public byte G { get; } + + public byte B { get; } + + public byte A { get; } + + public bool Equals(Rgba32 other) => R == other.R && G == other.G && B == other.B && A == other.A; + + public override bool Equals(object? obj) => obj is Rgba32 other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(R, G, B, A); + + public static bool operator ==(Rgba32 left, Rgba32 right) => left.Equals(right); + + public static bool operator !=(Rgba32 left, Rgba32 right) => !left.Equals(right); + + public override string ToString() => $"rgba({R},{G},{B},{A})"; +} + +/// +/// An abstract source of RGBA pixels. Implementations may be backed by a decoded +/// bitmap, a procedural generator, or a test fixture. UI-agnostic and allocation-free +/// to read. +/// +public interface IImageSource +{ + /// Image width in pixels (> 0). + int Width { get; } + + /// Image height in pixels (> 0). + int Height { get; } + + /// Returns the pixel at (, ), origin top-left. + Rgba32 GetPixel(int x, int y); +} + +/// +/// A simple in-memory backed by a row-major +/// array. Used by callers that already have raw pixels and by tests. +/// +public sealed class MemoryImageSource : IImageSource +{ + private readonly Rgba32[] _pixels; + + public MemoryImageSource(int width, int height, Rgba32[] pixels) + { + if (width <= 0) + { + throw new ArgumentOutOfRangeException(nameof(width), width, "Width must be positive."); + } + + if (height <= 0) + { + throw new ArgumentOutOfRangeException(nameof(height), height, "Height must be positive."); + } + + ArgumentNullException.ThrowIfNull(pixels); + + if (pixels.Length != width * height) + { + throw new ArgumentException( + $"Pixel array length {pixels.Length} does not match {width}x{height} = {width * height}.", + nameof(pixels)); + } + + Width = width; + Height = height; + _pixels = pixels; + } + + public int Width { get; } + + public int Height { get; } + + public Rgba32 GetPixel(int x, int y) + { + if ((uint)x >= (uint)Width) + { + throw new ArgumentOutOfRangeException(nameof(x), x, "X is outside the image bounds."); + } + + if ((uint)y >= (uint)Height) + { + throw new ArgumentOutOfRangeException(nameof(y), y, "Y is outside the image bounds."); + } + + return _pixels[(y * Width) + x]; + } +} diff --git a/src/MuClient.Graphics/InlineImage.cs b/src/MuClient.Graphics/InlineImage.cs new file mode 100644 index 0000000..ff20d80 --- /dev/null +++ b/src/MuClient.Graphics/InlineImage.cs @@ -0,0 +1,146 @@ +using MuClient.Core.Text; + +namespace MuClient.Graphics; + +/// How an should be consumed by the renderer. +public enum InlineImageKind +{ + /// No graphics available: show as plain text. + Text, + + /// Styled half-block cells in . + HalfBlock, + + /// A raw escape-sequence string in (Sixel). + Sixel, + + /// A raw escape-sequence string in (Kitty). + Kitty, +} + +/// +/// A discriminated result of rendering an inline image: exactly one payload is populated +/// according to . Escape-sequence kinds carry a string; half-block carries +/// styled lines; the text kind carries a plain placeholder. +/// +public sealed class InlineImageOutput +{ + private InlineImageOutput( + InlineImageKind kind, + string? escapeSequence, + IReadOnlyList? lines, + string? placeholderText) + { + Kind = kind; + EscapeSequence = escapeSequence; + Lines = lines; + PlaceholderText = placeholderText; + } + + public InlineImageKind Kind { get; } + + /// The escape sequence for /. + public string? EscapeSequence { get; } + + /// The styled rows for . + public IReadOnlyList? Lines { get; } + + /// The plain placeholder for . + public string? PlaceholderText { get; } + + public static InlineImageOutput ForKitty(string escapeSequence) => + new(InlineImageKind.Kitty, escapeSequence, null, null); + + public static InlineImageOutput ForSixel(string escapeSequence) => + new(InlineImageKind.Sixel, escapeSequence, null, null); + + public static InlineImageOutput ForHalfBlock(IReadOnlyList lines) => + new(InlineImageKind.HalfBlock, null, lines, null); + + public static InlineImageOutput ForText(string placeholderText) => + new(InlineImageKind.Text, null, null, placeholderText); +} + +/// +/// Picks the best inline-image encoding for a given and +/// renders an accordingly. This is the glue callers use; the +/// individual encoders remain independently usable. +/// +public sealed class InlineImageRenderer +{ + private readonly KittyGraphicsProtocol _kitty; + private readonly SixelEncoder _sixel; + private readonly HalfBlockRenderer _halfBlock; + + public InlineImageRenderer() + : this(new KittyGraphicsProtocol(), new SixelEncoder(), new HalfBlockRenderer()) + { + } + + public InlineImageRenderer( + KittyGraphicsProtocol kitty, + SixelEncoder sixel, + HalfBlockRenderer halfBlock) + { + _kitty = kitty ?? throw new ArgumentNullException(nameof(kitty)); + _sixel = sixel ?? throw new ArgumentNullException(nameof(sixel)); + _halfBlock = halfBlock ?? throw new ArgumentNullException(nameof(halfBlock)); + } + + /// + /// Renders using the highest protocol in . + /// The half-block fallback is bounded by /; + /// is used only for the Kitty transmission. + /// + public InlineImageOutput Render( + IImageSource image, + TerminalCapabilities capabilities, + int imageId = 1, + int maxCols = 80, + int maxRows = 24) + { + ArgumentNullException.ThrowIfNull(image); + ArgumentNullException.ThrowIfNull(capabilities); + + switch (capabilities.Protocol) + { + case GraphicsProtocol.Kitty: + var rgba = ToRgbaBytes(image); + var sequence = _kitty.TransmitAndDisplay( + imageId, rgba, image.Width, image.Height, KittyImageFormat.Rgba); + return InlineImageOutput.ForKitty(sequence); + + case GraphicsProtocol.Sixel: + return InlineImageOutput.ForSixel(_sixel.Encode(image)); + + case GraphicsProtocol.HalfBlock: + return InlineImageOutput.ForHalfBlock(_halfBlock.Render(image, maxCols, maxRows)); + + case GraphicsProtocol.None: + default: + return InlineImageOutput.ForText($"[image {image.Width}x{image.Height}]"); + } + } + + /// Flattens an image to a row-major RGBA byte buffer for Kitty transmission. + public static byte[] ToRgbaBytes(IImageSource image) + { + ArgumentNullException.ThrowIfNull(image); + + var bytes = new byte[image.Width * image.Height * 4]; + var offset = 0; + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < image.Width; x++) + { + var p = image.GetPixel(x, y); + bytes[offset++] = p.R; + bytes[offset++] = p.G; + bytes[offset++] = p.B; + bytes[offset++] = p.A; + } + } + + return bytes; + } +} diff --git a/src/MuClient.Graphics/KittyGraphicsProtocol.cs b/src/MuClient.Graphics/KittyGraphicsProtocol.cs new file mode 100644 index 0000000..b01f10c --- /dev/null +++ b/src/MuClient.Graphics/KittyGraphicsProtocol.cs @@ -0,0 +1,180 @@ +using System.Text; +using MuClient.Core.Text; + +namespace MuClient.Graphics; + +/// Pixel format of a payload handed to the Kitty encoder. +public enum KittyImageFormat +{ + /// Raw 32-bit RGBA pixels (Kitty f=32). + Rgba, + + /// A complete PNG file (Kitty f=100). + Png, +} + +/// +/// Deterministic encoder for the +/// Kitty graphics protocol. +/// +/// Every method returns the escape-sequence string(s); nothing is written to any console, +/// which keeps the encoder pure and golden-testable. The framing for one transmission is +/// ESC _G <controls> ; <base64-payload> ESC \. Large payloads are split into +/// chunks of at most 4096 base64 characters, each carrying m=1 except the final +/// chunk which carries m=0. +/// +public sealed class KittyGraphicsProtocol +{ + /// The Unicode placeholder code point (U+10EEEE) that anchors an image to text cells. + public const int PlaceholderCodePoint = 0x10EEEE; + + /// Maximum base64 characters per Kitty transmission chunk, per the spec. + public const int MaxChunkBase64Length = 4096; + + private const string ApcStart = "\u001b_G"; // ESC _ G (Application Programming Command) + private const string St = "\u001b\\"; // ESC \ (String Terminator) + + /// + /// The Kitty "row/column diacritics" table: combining marks whose position in this + /// list encodes a 0-based row or column index. This is the canonical ordering from + /// kitty's rowcolumn-diacritics.json; the first 64 entries are included, which + /// comfortably covers the first 32 rows/columns required. + /// + public static readonly int[] RowColumnDiacritics = + { + 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, + 0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, + 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, + 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, + 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, + 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, + 0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, + 0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658, + }; + + /// + /// Transmits an image and displays it in one action (a=T). Returns the full + /// escape sequence, split into m=1/m=0 chunks when the base64 payload + /// exceeds . + /// + /// Client-assigned image id (i=). + /// Raw RGBA pixels or a PNG file, per . + /// Source pixel width (s=). + /// Source pixel height (v=). + /// Payload format. + public string TransmitAndDisplay( + int imageId, + ReadOnlySpan payload, + int width, + int height, + KittyImageFormat format) + { + if (imageId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(imageId), imageId, "Image id must be positive."); + } + + var formatCode = format == KittyImageFormat.Png ? 100 : 32; + var base64 = Convert.ToBase64String(payload); + + var builder = new StringBuilder(); + var chunkCount = Math.Max(1, (base64.Length + MaxChunkBase64Length - 1) / MaxChunkBase64Length); + + for (var chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) + { + var start = chunkIndex * MaxChunkBase64Length; + var length = Math.Min(MaxChunkBase64Length, base64.Length - start); + var chunk = base64.Substring(start, length); + var isLast = chunkIndex == chunkCount - 1; + + builder.Append(ApcStart); + + if (chunkIndex == 0) + { + // The first chunk carries the full control set. + builder.Append("a=T,f=").Append(formatCode) + .Append(",i=").Append(imageId) + .Append(",s=").Append(width) + .Append(",v=").Append(height); + builder.Append(",m=").Append(isLast ? '0' : '1'); + } + else + { + // Continuation chunks only carry the more flag. + builder.Append("m=").Append(isLast ? '0' : '1'); + } + + builder.Append(';').Append(chunk).Append(St); + } + + return builder.ToString(); + } + + /// Deletes an image by id (a=d,i=<id>). + public string Delete(int imageId) + { + if (imageId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(imageId), imageId, "Image id must be positive."); + } + + return $"{ApcStart}a=d,i={imageId};{St}"; + } + + /// + /// Builds the Unicode-placeholder grid for a previously transmitted image. Each cell + /// is the placeholder rune U+10EEEE followed by two combining diacritics that + /// encode its (row, column); the image id is carried in the foreground colour of every + /// cell. Rendering this grid causes the terminal to composite the image over those + /// real text cells. Returns one per row. + /// + public IReadOnlyList BuildPlaceholder(int imageId, int cols, int rows) + { + if (imageId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(imageId), imageId, "Image id must be positive."); + } + + if (cols <= 0) + { + throw new ArgumentOutOfRangeException(nameof(cols), cols, "Columns must be positive."); + } + + if (rows <= 0) + { + throw new ArgumentOutOfRangeException(nameof(rows), rows, "Rows must be positive."); + } + + if (rows > RowColumnDiacritics.Length || cols > RowColumnDiacritics.Length) + { + throw new ArgumentOutOfRangeException( + nameof(rows), + $"Placeholder grid up to {RowColumnDiacritics.Length}x{RowColumnDiacritics.Length} is supported."); + } + + // Carry the 24-bit image id in the foreground colour, per the Kitty spec. + var idColor = TerminalColor.FromRgb( + (byte)((imageId >> 16) & 0xFF), + (byte)((imageId >> 8) & 0xFF), + (byte)(imageId & 0xFF)); + var style = TextStyle.Default.WithForeground(idColor); + + var lines = new StyledLine[rows]; + for (var row = 0; row < rows; row++) + { + var spans = new StyledSpan[cols]; + for (var col = 0; col < cols; col++) + { + var cell = new StringBuilder(4); + cell.Append(char.ConvertFromUtf32(PlaceholderCodePoint)); + cell.Append(char.ConvertFromUtf32(RowColumnDiacritics[row])); + cell.Append(char.ConvertFromUtf32(RowColumnDiacritics[col])); + spans[col] = new StyledSpan(cell.ToString(), style); + } + + lines[row] = new StyledLine(spans); + } + + return lines; + } +} diff --git a/src/MuClient.Graphics/MuClient.Graphics.csproj b/src/MuClient.Graphics/MuClient.Graphics.csproj new file mode 100644 index 0000000..92d8357 --- /dev/null +++ b/src/MuClient.Graphics/MuClient.Graphics.csproj @@ -0,0 +1,12 @@ + + + + MuClient.Graphics + MuClient.Graphics + + + + + + + diff --git a/src/MuClient.Graphics/SixelEncoder.cs b/src/MuClient.Graphics/SixelEncoder.cs new file mode 100644 index 0000000..697ce73 --- /dev/null +++ b/src/MuClient.Graphics/SixelEncoder.cs @@ -0,0 +1,130 @@ +using System.Text; + +namespace MuClient.Graphics; + +/// +/// Encodes an to a DEC Sixel data string +/// (ESC P q … ESC \). Colours are quantised to a 6-level-per-channel RGB cube +/// (≤216 registers, within Sixel's 256-register budget); each register is declared with +/// #n;2;r;g;b (channels scaled 0-100), then pixels are emitted in six-row bands. +/// +/// The implementation favours correctness and determinism (so it can be golden-tested) +/// over compactness — it performs no run-length compression. +/// +public sealed class SixelEncoder +{ + private const string Intro = "\u001bPq"; // ESC P q : start Sixel, default params. + private const string Terminator = "\u001b\\"; // ESC \ : String Terminator. + + /// Encodes the image to a complete Sixel data string. + public string Encode(IImageSource image) + { + ArgumentNullException.ThrowIfNull(image); + + var width = image.Width; + var height = image.Height; + + // Quantise every pixel to a palette index, assigning registers on first appearance. + var palette = new List<(int R, int G, int B)>(); + var paletteLookup = new Dictionary(); + var indices = new int[width * height]; + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var pixel = image.GetPixel(x, y); + var quant = Quantize(pixel); + var key = (quant.R << 16) | (quant.G << 8) | quant.B; + if (!paletteLookup.TryGetValue(key, out var index)) + { + index = palette.Count; + paletteLookup[key] = index; + palette.Add(quant); + } + + indices[(y * width) + x] = index; + } + } + + var sb = new StringBuilder(); + sb.Append(Intro); + + // Register declarations, in assignment order. + for (var i = 0; i < palette.Count; i++) + { + var (r, g, b) = palette[i]; + sb.Append('#').Append(i).Append(";2;").Append(r).Append(';').Append(g).Append(';').Append(b); + } + + var bandCount = (height + 5) / 6; + for (var band = 0; band < bandCount; band++) + { + var bandTop = band * 6; + var bandRows = Math.Min(6, height - bandTop); + + // Which registers actually appear in this band (in assignment order). + var colorsInBand = new SortedSet(); + for (var row = 0; row < bandRows; row++) + { + var y = bandTop + row; + for (var x = 0; x < width; x++) + { + colorsInBand.Add(indices[(y * width) + x]); + } + } + + var first = true; + foreach (var color in colorsInBand) + { + if (!first) + { + // Carriage return: overlay the next colour on the same band. + sb.Append('$'); + } + + first = false; + + sb.Append('#').Append(color); + for (var x = 0; x < width; x++) + { + var mask = 0; + for (var row = 0; row < bandRows; row++) + { + var y = bandTop + row; + if (indices[(y * width) + x] == color) + { + mask |= 1 << row; + } + } + + sb.Append((char)(63 + mask)); + } + } + + // Graphics newline between bands (not after the final band). + if (band < bandCount - 1) + { + sb.Append('-'); + } + } + + sb.Append(Terminator); + return sb.ToString(); + } + + /// + /// Quantises an RGBA pixel to the 6-level RGB cube and returns each channel scaled to + /// the Sixel 0-100 range (levels 0..5 → 0,20,40,60,80,100). + /// + private static (int R, int G, int B) Quantize(Rgba32 pixel) + { + static int Channel(byte value) + { + var level = value * 6 / 256; // 0..5 + return level * 20; // 0,20,40,60,80,100 + } + + return (Channel(pixel.R), Channel(pixel.G), Channel(pixel.B)); + } +} diff --git a/src/MuClient.Graphics/TerminalCapabilities.cs b/src/MuClient.Graphics/TerminalCapabilities.cs new file mode 100644 index 0000000..76bde78 --- /dev/null +++ b/src/MuClient.Graphics/TerminalCapabilities.cs @@ -0,0 +1,36 @@ +namespace MuClient.Graphics; + +/// +/// An immutable snapshot of what the host terminal can display. Produced by +/// . Never assumes more than the environment advertises — +/// a headless sandbox degrades cleanly to /. +/// +public sealed class TerminalCapabilities +{ + public TerminalCapabilities( + GraphicsProtocol protocol, + bool supportsTrueColor, + bool supportsKittyGraphics, + bool supportsSixel) + { + Protocol = protocol; + SupportsTrueColor = supportsTrueColor; + SupportsKittyGraphics = supportsKittyGraphics; + SupportsSixel = supportsSixel; + } + + /// The best inline-graphics protocol available. + public GraphicsProtocol Protocol { get; } + + /// True when the terminal advertises 24-bit colour (needed for good half-block output). + public bool SupportsTrueColor { get; } + + /// True when the Kitty graphics protocol is available. + public bool SupportsKittyGraphics { get; } + + /// True when Sixel raster graphics are available. + public bool SupportsSixel { get; } + + public override string ToString() => + $"{Protocol} (truecolor={SupportsTrueColor}, kitty={SupportsKittyGraphics}, sixel={SupportsSixel})"; +} diff --git a/src/MuClient.Scripting/IScriptWorld.cs b/src/MuClient.Scripting/IScriptWorld.cs new file mode 100644 index 0000000..a15b161 --- /dev/null +++ b/src/MuClient.Scripting/IScriptWorld.cs @@ -0,0 +1,38 @@ +namespace MuClient.Scripting; + +/// +/// The narrow surface a uses to talk to a world. Abstracted so the host +/// can be unit-tested without a live connection: production wires it to a real +/// MuClient.Core.Session.WorldSession via , while +/// tests supply a fake that captures the calls. +/// +public interface IScriptWorld +{ + /// Queues or sends a command to the server. + void Send(string command); + + /// Writes a line of text to the client output. + void Print(string text); + + /// + /// Registers a script-backed trigger. When the pattern matches, the world is expected to + /// deliver the fire back to the host (see ) using the + /// same . + /// + void AddTrigger(string pattern, string callbackId); + + /// + /// Registers an alias. When is non-empty the world expands it + /// directly; a non-empty marks the alias as script-backed. + /// + void AddAlias(string pattern, string substitution, string callbackId); + + /// Schedules a recurring callback; disposing the handle cancels it. + IDisposable ScheduleEvery(TimeSpan interval, Action callback); + + /// Schedules a one-shot callback; disposing the handle cancels it. + IDisposable ScheduleAfter(TimeSpan delay, Action callback); + + /// The display name of the world, exposed to scripts as world.name. + string WorldName { get; } +} diff --git a/src/MuClient.Scripting/MuClient.Scripting.csproj b/src/MuClient.Scripting/MuClient.Scripting.csproj new file mode 100644 index 0000000..5d1d62b --- /dev/null +++ b/src/MuClient.Scripting/MuClient.Scripting.csproj @@ -0,0 +1,16 @@ + + + + MuClient.Scripting + MuClient.Scripting + + + + + + + + + + + diff --git a/src/MuClient.Scripting/ScriptException.cs b/src/MuClient.Scripting/ScriptException.cs new file mode 100644 index 0000000..4650a02 --- /dev/null +++ b/src/MuClient.Scripting/ScriptException.cs @@ -0,0 +1,50 @@ +using MoonSharp.Interpreter; + +namespace MuClient.Scripting; + +/// +/// A clean, MoonSharp-free error surfaced by the scripting layer. Wraps the underlying Lua +/// runtime or syntax error so callers never see raw types. +/// +public sealed class ScriptException : Exception +{ + public ScriptException(string message, int? line = null, Exception? innerException = null) + : base(message, innerException) + { + Line = line; + } + + /// The 1-based Lua source line the error was reported on, if known. + public int? Line { get; } + + /// Wraps a MoonSharp interpreter exception, preferring its decorated message. + internal static ScriptException FromInterpreter(InterpreterException ex) + { + var message = string.IsNullOrEmpty(ex.DecoratedMessage) ? ex.Message : ex.DecoratedMessage; + return new ScriptException(message, TryExtractLine(ex), ex); + } + + private static int? TryExtractLine(InterpreterException ex) + { + // MoonSharp encodes location as "chunk:(fromLine,fromCol-toLine,toCol)" in DecoratedMessage. + var decorated = ex.DecoratedMessage; + if (string.IsNullOrEmpty(decorated)) + { + return null; + } + + var open = decorated.IndexOf('('); + if (open < 0 || open + 1 >= decorated.Length) + { + return null; + } + + var comma = decorated.IndexOf(',', open); + if (comma < 0) + { + return null; + } + + return int.TryParse(decorated.AsSpan(open + 1, comma - open - 1), out var line) ? line : null; + } +} diff --git a/src/MuClient.Scripting/ScriptHost.cs b/src/MuClient.Scripting/ScriptHost.cs new file mode 100644 index 0000000..be54ca9 --- /dev/null +++ b/src/MuClient.Scripting/ScriptHost.cs @@ -0,0 +1,478 @@ +using System.Globalization; +using System.Text; +using MoonSharp.Interpreter; + +namespace MuClient.Scripting; + +/// +/// Hosts a single sandboxed Lua environment for a world and bridges it to an +/// . Owns the MoonSharp , injects the client API +/// (world, output, trigger, alias, timer, gmcp, +/// log), and routes world events (trigger fires, GMCP messages) back into Lua callbacks. +/// +/// +/// Sandbox. The module set is hand-picked (see ): no +/// io, no os.execute/os.exit, and no require/dofile/loadfile +/// (the whole LoadMethods module is excluded). os.time/os.date, string, math, +/// table, coroutine, and json are available. +/// Calling conventions. A trigger callback is invoked as +/// fn(wholeMatch, group1, group2, ...) — the full matched text followed by each capture +/// group in order. An alias function callback uses the same shape. A GMCP handler is invoked as +/// fn(json) with the raw JSON payload string; a handler registered for package "Char" +/// also fires for sub-packages such as "Char.Vitals". +/// Threading. Timer callbacks arrive on thread-pool threads; every Lua invocation is +/// serialised under an internal gate since MoonSharp scripts are not thread-safe. +/// +public sealed class ScriptHost : IDisposable +{ + private const CoreModules SandboxModules = + CoreModules.Basic | + CoreModules.GlobalConsts | + CoreModules.TableIterators | + CoreModules.Metatables | + CoreModules.String | + CoreModules.Table | + CoreModules.ErrorHandling | + CoreModules.Math | + CoreModules.Coroutine | + CoreModules.Bit32 | + CoreModules.OS_Time | + CoreModules.Json; + + private readonly IScriptWorld _world; + private readonly object _gate = new(); + private readonly Dictionary _triggerCallbacks = new(StringComparer.Ordinal); + private readonly Dictionary _aliasCallbacks = new(StringComparer.Ordinal); + private readonly List<(string Package, DynValue Handler)> _gmcpHandlers = new(); + private readonly List _timers = new(); + private readonly List _loadedFiles = new(); + + private Script _script = null!; + private int _callbackSeed; + private bool _disposed; + + public ScriptHost(IScriptWorld world) + { + _world = world ?? throw new ArgumentNullException(nameof(world)); + ResetScript(); + } + + /// Raised when a Lua callback (trigger/alias/timer/gmcp) throws at runtime. + public event EventHandler? Error; + + /// The files currently tracked for hot-reload, in load order. + public IReadOnlyList LoadedFiles + { + get + { + lock (_gate) + { + return _loadedFiles.ToArray(); + } + } + } + + /// Runs a chunk of Lua for its side effects. Errors surface as . + public void Execute(string luaSource) + { + ArgumentNullException.ThrowIfNull(luaSource); + Evaluate(luaSource); + } + + /// Runs a chunk of Lua and returns its value. Errors surface as . + public DynValue Evaluate(string luaSource) + { + ArgumentNullException.ThrowIfNull(luaSource); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + try + { + return _script.DoString(luaSource); + } + catch (InterpreterException ex) + { + throw ScriptException.FromInterpreter(ex); + } + } + } + + /// Loads and executes a Lua file, tracking it for . + public void LoadFile(string path) + { + ArgumentException.ThrowIfNullOrEmpty(path); + var full = Path.GetFullPath(path); + string source; + try + { + source = File.ReadAllText(full); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + throw new ScriptException($"Could not read script file '{full}': {ex.Message}", null, ex); + } + + lock (_gate) + { + if (!_loadedFiles.Contains(full, StringComparer.Ordinal)) + { + _loadedFiles.Add(full); + } + } + + Execute(source); + } + + /// + /// Discards all script-registered callbacks and timers, rebuilds a fresh sandbox, and + /// re-executes every file previously passed to . Inline + /// state is not retained across a reload. + /// + public void Reload() + { + string[] files; + lock (_gate) + { + files = _loadedFiles.ToArray(); + ResetScript(); + } + + foreach (var file in files) + { + string source; + try + { + source = File.ReadAllText(file); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + throw new ScriptException($"Could not read script file '{file}': {ex.Message}", null, ex); + } + + Execute(source); + } + } + + /// Delivers a trigger fire into Lua. No-op if the callback id is unknown (e.g. post-reload). + public void DispatchTrigger(string callbackId, string wholeMatch, IReadOnlyList groups) + { + ArgumentNullException.ThrowIfNull(callbackId); + DynValue fn; + lock (_gate) + { + if (_disposed || !_triggerCallbacks.TryGetValue(callbackId, out fn!)) + { + return; + } + } + + var args = new object[1 + (groups?.Count ?? 0)]; + args[0] = wholeMatch ?? string.Empty; + if (groups is not null) + { + for (var i = 0; i < groups.Count; i++) + { + args[i + 1] = groups[i] ?? string.Empty; + } + } + + InvokeCallback(fn, args); + } + + /// Delivers an alias fire into Lua. No-op if the callback id is unknown. + public void DispatchAlias(string callbackId, string wholeMatch, IReadOnlyList groups) + { + ArgumentNullException.ThrowIfNull(callbackId); + DynValue fn; + lock (_gate) + { + if (_disposed || !_aliasCallbacks.TryGetValue(callbackId, out fn!)) + { + return; + } + } + + var args = new object[1 + (groups?.Count ?? 0)]; + args[0] = wholeMatch ?? string.Empty; + if (groups is not null) + { + for (var i = 0; i < groups.Count; i++) + { + args[i + 1] = groups[i] ?? string.Empty; + } + } + + InvokeCallback(fn, args); + } + + /// Delivers a GMCP message into Lua, firing every handler whose package matches. + public void DispatchGmcp(string package, string json) + { + ArgumentNullException.ThrowIfNull(package); + (string Package, DynValue Handler)[] handlers; + lock (_gate) + { + if (_disposed || _gmcpHandlers.Count == 0) + { + return; + } + + handlers = _gmcpHandlers.ToArray(); + } + + foreach (var (registered, handler) in handlers) + { + if (PackageMatches(registered, package)) + { + InvokeCallback(handler, json ?? string.Empty); + } + } + } + + private static bool PackageMatches(string registered, string incoming) => + string.Equals(registered, incoming, StringComparison.OrdinalIgnoreCase) || + incoming.StartsWith(registered + ".", StringComparison.OrdinalIgnoreCase); + + private void InvokeCallback(DynValue fn, params object[] args) + { + try + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _script.Call(fn, args); + } + } + catch (InterpreterException ex) + { + Error?.Invoke(this, ScriptException.FromInterpreter(ex)); + } + catch (Exception ex) + { + Error?.Invoke(this, new ScriptException(ex.Message, null, ex)); + } + } + + private void ResetScript() + { + foreach (var timer in _timers) + { + timer.Dispose(); + } + + _timers.Clear(); + _triggerCallbacks.Clear(); + _aliasCallbacks.Clear(); + _gmcpHandlers.Clear(); + + _script = new Script(SandboxModules); + InjectApi(_script); + } + + private string NextCallbackId(string prefix) + { + var n = Interlocked.Increment(ref _callbackSeed); + return $"{prefix}#{n.ToString(CultureInfo.InvariantCulture)}"; + } + + private void InjectApi(Script script) + { + var globals = script.Globals; + + // world.* + var world = new Table(script) + { + ["send"] = (Action)(cmd => _world.Send(cmd ?? string.Empty)), + ["print"] = (Action)(text => _world.Print(text ?? string.Empty)), + ["name"] = _world.WorldName, + }; + globals["world"] = world; + + // output.* + var output = new Table(script) + { + ["print"] = (Action)(text => _world.Print(text ?? string.Empty)), + // Styling is not yet modelled through IScriptWorld; the options table is ignored and + // the text is printed plain. See the limitations note in the type/PR summary. + ["printStyled"] = (Action)((text, _) => _world.Print(text ?? string.Empty)), + }; + globals["output"] = output; + + // trigger.* + var trigger = new Table(script) + { + ["add"] = (Action)AddTrigger, + }; + globals["trigger"] = trigger; + + // alias.* + var alias = new Table(script) + { + ["add"] = (Action)AddAlias, + }; + globals["alias"] = alias; + + // timer.* + var timer = new Table(script) + { + ["every"] = (Func)((ms, fn) => AddTimer(ms, fn, recurring: true)), + ["after"] = (Func)((ms, fn) => AddTimer(ms, fn, recurring: false)), + }; + globals["timer"] = timer; + + // gmcp.* + var gmcp = new Table(script) + { + ["on"] = (Action)OnGmcp, + }; + globals["gmcp"] = gmcp; + + // log.* + var log = new Table(script) + { + ["info"] = (Action)(msg => _world.Print("[info] " + (msg ?? string.Empty))), + ["warn"] = (Action)(msg => _world.Print("[warn] " + (msg ?? string.Empty))), + ["error"] = (Action)(msg => _world.Print("[error] " + (msg ?? string.Empty))), + }; + globals["log"] = log; + + // Override the default print (which writes to Console) so it routes to the client output. + globals["print"] = DynValue.NewCallback((_, callArgs) => + { + var sb = new StringBuilder(); + for (var i = 0; i < callArgs.Count; i++) + { + if (i > 0) + { + sb.Append('\t'); + } + + sb.Append(callArgs[i].ToPrintString()); + } + + _world.Print(sb.ToString()); + return DynValue.Nil; + }); + } + + private void AddTrigger(string pattern, DynValue fn) + { + RequireFunction(fn, "trigger.add"); + var id = NextCallbackId("trigger"); + lock (_gate) + { + _triggerCallbacks[id] = fn; + } + + _world.AddTrigger(pattern ?? string.Empty, id); + } + + private void AddAlias(string pattern, DynValue substitutionOrFn) + { + if (substitutionOrFn is null || substitutionOrFn.IsNil()) + { + throw new ScriptRuntimeException("alias.add: expected a substitution string or a function."); + } + + if (substitutionOrFn.Type == DataType.Function) + { + var id = NextCallbackId("alias"); + lock (_gate) + { + _aliasCallbacks[id] = substitutionOrFn; + } + + _world.AddAlias(pattern ?? string.Empty, string.Empty, id); + return; + } + + if (substitutionOrFn.Type == DataType.String) + { + _world.AddAlias(pattern ?? string.Empty, substitutionOrFn.String, string.Empty); + return; + } + + throw new ScriptRuntimeException( + $"alias.add: expected a string or function, got {substitutionOrFn.Type.ToLuaTypeString()}."); + } + + private DynValue AddTimer(double milliseconds, DynValue fn, bool recurring) + { + RequireFunction(fn, recurring ? "timer.every" : "timer.after"); + if (double.IsNaN(milliseconds) || milliseconds < 0) + { + throw new ScriptRuntimeException("timer interval must be a non-negative number of milliseconds."); + } + + // Interval schedulers reject a zero recurring period; clamp to a 1ms minimum. + var span = TimeSpan.FromMilliseconds(recurring && milliseconds <= 0 ? 1 : milliseconds); + void Fire() => InvokeCallback(fn); + + var handle = recurring ? _world.ScheduleEvery(span, Fire) : _world.ScheduleAfter(span, Fire); + lock (_gate) + { + _timers.Add(handle); + } + + var table = new Table(_script); + table["cancel"] = DynValue.NewCallback((_, _) => + { + handle.Dispose(); + lock (_gate) + { + _timers.Remove(handle); + } + + return DynValue.Nil; + }); + return DynValue.NewTable(table); + } + + private void OnGmcp(string package, DynValue fn) + { + RequireFunction(fn, "gmcp.on"); + if (string.IsNullOrEmpty(package)) + { + throw new ScriptRuntimeException("gmcp.on: package name must be a non-empty string."); + } + + lock (_gate) + { + _gmcpHandlers.Add((package, fn)); + } + } + + private static void RequireFunction(DynValue fn, string who) + { + if (fn is null || fn.Type != DataType.Function) + { + throw new ScriptRuntimeException($"{who}: expected a function callback."); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + foreach (var timer in _timers) + { + timer.Dispose(); + } + + _timers.Clear(); + _triggerCallbacks.Clear(); + _aliasCallbacks.Clear(); + _gmcpHandlers.Clear(); + } + } +} diff --git a/src/MuClient.Scripting/WorldSessionScriptBridge.cs b/src/MuClient.Scripting/WorldSessionScriptBridge.cs new file mode 100644 index 0000000..6f8186d --- /dev/null +++ b/src/MuClient.Scripting/WorldSessionScriptBridge.cs @@ -0,0 +1,97 @@ +using MuClient.Core.Automation; +using MuClient.Core.Session; +using MuClient.Core.Telnet; + +namespace MuClient.Scripting; + +/// +/// Adapts a live to and pumps the session's +/// script-relevant events (trigger fires, GMCP messages) into an attached . +/// +/// +/// Alias function callbacks are a known gap: the core does not +/// currently raise a script event for script-backed aliases, so a bridged +/// alias.add(pattern, function ... end) registers the alias but its callback is never +/// dispatched. String-substitution aliases and all trigger/GMCP callbacks work end to end. +/// +public sealed class WorldSessionScriptBridge : IScriptWorld, IDisposable +{ + private readonly WorldSession _session; + private ScriptHost? _host; + private bool _disposed; + + public WorldSessionScriptBridge(WorldSession session) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _session.TriggerScriptRequested += OnTriggerScriptRequested; + _session.GmcpReceived += OnGmcpReceived; + } + + /// Constructs a bridge and a host wired to each other for the given session. + public static ScriptHost CreateHost(WorldSession session) + { + var bridge = new WorldSessionScriptBridge(session); + var host = new ScriptHost(bridge); + bridge.Attach(host); + return host; + } + + /// Connects this bridge to the host that will receive dispatched events. + public void Attach(ScriptHost host) => _host = host ?? throw new ArgumentNullException(nameof(host)); + + public string WorldName => _session.World.Name; + + public void Send(string command) => _ = _session.SendRawAsync(command); + + public void Print(string text) => _session.PrintSystem(text); + + public void AddTrigger(string pattern, string callbackId) => + _session.Triggers.Add(new Trigger + { + Name = callbackId, + Pattern = pattern, + Actions = new TriggerActions { ScriptCallback = callbackId }, + }); + + public void AddAlias(string pattern, string substitution, string callbackId) => + _session.Aliases.Add(new Alias + { + Name = callbackId, + Pattern = pattern, + Substitution = substitution, + ScriptCallback = string.IsNullOrEmpty(callbackId) ? null : callbackId, + }); + + public IDisposable ScheduleEvery(TimeSpan interval, Action callback) => + _session.Scheduler.Every(interval, callback); + + public IDisposable ScheduleAfter(TimeSpan delay, Action callback) => + _session.Scheduler.After(delay, callback); + + private void OnTriggerScriptRequested(object? sender, TriggerScriptInvocation e) + { + var match = e.Match; + var groups = new string[Math.Max(0, match.Groups.Count - 1)]; + for (var i = 1; i < match.Groups.Count; i++) + { + groups[i - 1] = match.Groups[i].Value; + } + + _host?.DispatchTrigger(e.Callback, match.Value, groups); + } + + private void OnGmcpReceived(object? sender, GmcpMessageEventArgs e) => + _host?.DispatchGmcp(e.Package, e.Json); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _session.TriggerScriptRequested -= OnTriggerScriptRequested; + _session.GmcpReceived -= OnGmcpReceived; + } +} diff --git a/src/MuClient.Tui/ColorMapper.cs b/src/MuClient.Tui/ColorMapper.cs new file mode 100644 index 0000000..24a276d --- /dev/null +++ b/src/MuClient.Tui/ColorMapper.cs @@ -0,0 +1,38 @@ +using MuClient.Core.Text; +using MuClient.Core.Theming; +using TgAttribute = Terminal.Gui.Drawing.Attribute; +using TgColor = Terminal.Gui.Drawing.Color; + +namespace MuClient.Tui; + +/// Maps MuGlyph's UI-agnostic to Terminal.Gui attributes via a . +internal sealed class ColorMapper(Theme theme) +{ + private readonly Theme _theme = theme; + + public Theme Theme => _theme; + + public TgAttribute ToAttribute(TextStyle style) + { + var reverse = style.HasAttribute(TextAttributes.Reverse); + var fg = _theme.Resolve(style.Foreground, isBackground: false); + var bg = _theme.Resolve(style.Background, isBackground: true); + + // Bold on a base palette colour brightens it, matching common terminal behaviour. + if (style.HasAttribute(TextAttributes.Bold) && + style.Foreground.Kind == TerminalColorKind.Indexed && + style.Foreground.Index < 8) + { + fg = _theme.ResolveIndex(style.Foreground.Index + 8); + } + + if (reverse) + { + (fg, bg) = (bg, fg); + } + + return new TgAttribute(ToColor(fg), ToColor(bg)); + } + + public static TgColor ToColor(Rgb rgb) => new(rgb.R, rgb.G, rgb.B, 255); +} diff --git a/src/MuClient.Tui/MuClient.Tui.csproj b/src/MuClient.Tui/MuClient.Tui.csproj new file mode 100644 index 0000000..113f9dc --- /dev/null +++ b/src/MuClient.Tui/MuClient.Tui.csproj @@ -0,0 +1,25 @@ + + + + Exe + MuClient.Tui + muglyph + + $(NoWarn);CS0618 + + + + + + + + + + + + + diff --git a/src/MuClient.Tui/MuGlyphApp.cs b/src/MuClient.Tui/MuGlyphApp.cs new file mode 100644 index 0000000..ebb741c --- /dev/null +++ b/src/MuClient.Tui/MuGlyphApp.cs @@ -0,0 +1,195 @@ +using MuClient.Core.Configuration; +using MuClient.Core.Session; +using MuClient.Core.Theming; +using MuClient.Graphics; +using MuClient.Tui.Views; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace MuClient.Tui; + +/// +/// The top-level MuGlyph window: a status line, a truecolor , and a +/// . Binds a single active and marshals +/// its background events onto the UI thread. +/// +internal sealed class MuGlyphApp : IAsyncDisposable +{ + private readonly AppConfiguration _config; + private readonly SessionManager _sessions = new(); + private readonly TerminalCapabilities _capabilities; + private readonly Theme _theme; + + private readonly Window _window; + private readonly Label _status; + private readonly OutputView _output; + private readonly CommandInput _input; + + private WorldSession? _active; + + public MuGlyphApp(AppConfiguration config, TerminalCapabilities capabilities) + { + _config = config; + _capabilities = capabilities; + _theme = ResolveTheme(config); + + _window = new Window { Title = "MuGlyph — MU* client" }; + + _status = new Label + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = 1, + Text = "Not connected.", + }; + + _output = new OutputView + { + X = 0, + Y = 1, + Width = Dim.Fill(), + Height = Dim.Fill(1), // leave the last row for input + Mapper = new ColorMapper(_theme), + }; + + _input = new CommandInput + { + X = 0, + Y = Pos.AnchorEnd(1), + Width = Dim.Fill(), + Height = 1, + }; + _input.CommandEntered += OnCommandEntered; + + _window.Add(_status, _output, _input); + _window.KeyDown += OnGlobalKey; + } + + public Window Window => _window; + + /// Connects the given world and binds it to the UI. + public async Task StartAsync(WorldDefinition? world) + { + if (world is null) + { + SetStatus("No world configured. Pass a host/port on the command line."); + return; + } + + var session = _sessions.Open(world, _config.ScrollbackLines); + BindSession(session); + + session.PrintSystem($"*** MuGlyph — theme '{_theme.Name}', graphics: {_capabilities.Protocol}."); + + try + { + await session.ConnectAsync().ConfigureAwait(false); + var size = _output.Viewport; + await session.SetWindowSizeAsync(Math.Max(1, size.Width), Math.Max(1, size.Height)).ConfigureAwait(false); + } + catch + { + // WorldSession already surfaced the failure as a system line. + } + } + + private void BindSession(WorldSession session) + { + _active = session; + _output.Session = session; + _input.SetFocus(); + + session.LinePrinted += (_, _) => Application.Invoke(() => + { + if (_output.AtBottom) + { + _output.ScrollToBottom(); + } + + _output.SetNeedsDraw(); + }); + + session.PromptChanged += (_, _) => Application.Invoke(() => _output.SetNeedsDraw()); + session.StateChanged += (_, _) => Application.Invoke(UpdateStatus); + UpdateStatus(); + } + + private void OnCommandEntered(string command) + { + var session = _active; + if (session is null) + { + return; + } + + _ = session.SendUserInputAsync(command); + } + + private void OnGlobalKey(object? sender, Key key) + { + switch (key.KeyCode) + { + case KeyCode.PageUp: + _output.PageUp(); + key.Handled = true; + break; + + case KeyCode.PageDown: + _output.PageDown(); + key.Handled = true; + break; + + default: + // Terminal.Gui reports letter keys by their uppercase code (KeyCode.Q == 'Q'). + if (key.IsCtrl && key.KeyCode == KeyCode.Q) + { + Application.RequestStop(); + key.Handled = true; + } + + break; + } + } + + private void UpdateStatus() + { + var session = _active; + if (session is null) + { + SetStatus($"Not connected. Graphics: {_capabilities.Protocol}. Ctrl+Q to quit."); + return; + } + + SetStatus($"{session.World.Name} [{session.State}] {session.World.Host}:{session.World.Port} " + + $"Graphics: {_capabilities.Protocol}. PgUp/PgDn scroll · Ctrl+Q quit."); + } + + private void SetStatus(string text) + { + _status.Text = text; + _status.SetNeedsDraw(); + } + + /// + /// Resolves the active theme: a built-in by unless + /// the config carries a customised inline . + /// + private static Theme ResolveTheme(AppConfiguration config) + { + if (config.Theme is { } inline && !string.Equals(inline.Name, config.ThemeName, StringComparison.OrdinalIgnoreCase)) + { + return inline; + } + + return ThemeLibrary.Get(config.ThemeName); + } + + public async ValueTask DisposeAsync() + { + await _sessions.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/MuClient.Tui/Program.cs b/src/MuClient.Tui/Program.cs new file mode 100644 index 0000000..45e40d9 --- /dev/null +++ b/src/MuClient.Tui/Program.cs @@ -0,0 +1,109 @@ +using MuClient.Core.Configuration; +using MuClient.Graphics; +using Terminal.Gui.App; + +namespace MuClient.Tui; + +internal static class Program +{ + private static int Main(string[] args) + { + if (args.Contains("--help") || args.Contains("-h")) + { + PrintUsage(); + return 0; + } + + var config = LoadConfiguration(); + var world = ResolveWorld(args, config); + var capabilities = DetectCapabilities(config); + + Application.Init(null!); + try + { + var app = new MuGlyphApp(config, capabilities); + + // Kick off the connection once the main loop is running so event marshaling works. + Application.Invoke(() => _ = app.StartAsync(world)); + + Application.Run(app.Window, null!); + return 0; + } + finally + { + Application.Shutdown(); + } + } + + private static AppConfiguration LoadConfiguration() + { + try + { + return ConfigurationStore.Load(ConfigurationStore.DefaultPath); + } + catch + { + return new AppConfiguration(); + } + } + + private static TerminalCapabilities DetectCapabilities(AppConfiguration config) + { + // A config graphics override maps onto the same MUGLYPH_GRAPHICS mechanism the probe reads. + if (!string.IsNullOrEmpty(config.GraphicsOverride)) + { + Environment.SetEnvironmentVariable("MUGLYPH_GRAPHICS", config.GraphicsOverride); + } + + return CapabilityProbe.DetectFromEnvironment(); + } + + /// + /// Resolves the world to connect: explicit host[/port] from the command line, otherwise the + /// first configured world, otherwise none (the UI starts disconnected). + /// + private static WorldDefinition? ResolveWorld(string[] args, AppConfiguration config) + { + var positional = args.Where(a => !a.StartsWith('-')).ToArray(); + if (positional.Length >= 1) + { + var host = positional[0]; + var port = positional.Length >= 2 && int.TryParse(positional[1], out var p) ? p : 4000; + return new WorldDefinition + { + Name = GetOption(args, "--name") ?? host, + Host = host, + Port = port, + UseTls = args.Contains("--tls"), + AllowInvalidCertificates = args.Contains("--insecure"), + }; + } + + return config.Worlds.FirstOrDefault(); + } + + private static string? GetOption(string[] args, string name) + { + var index = Array.IndexOf(args, name); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; + } + + private static void PrintUsage() + { + Console.WriteLine("MuGlyph — a cross-platform TUI MU* client."); + Console.WriteLine(); + Console.WriteLine("Usage: muglyph [host] [port] [options]"); + Console.WriteLine(); + Console.WriteLine(" host Server hostname or IP (IPv4/IPv6)."); + Console.WriteLine(" port Server port (default 4000)."); + Console.WriteLine(" --tls Connect over TLS."); + Console.WriteLine(" --insecure Accept invalid TLS certificates."); + Console.WriteLine(" --name Display name for the world."); + Console.WriteLine(" -h, --help Show this help."); + Console.WriteLine(); + Console.WriteLine($"Config: {ConfigurationStore.DefaultPath}"); + Console.WriteLine("With no host, the first configured world is used (if any)."); + Console.WriteLine(); + Console.WriteLine("In-app: PgUp/PgDn scroll · Up/Down history · Tab complete · Ctrl+Q quit."); + } +} diff --git a/src/MuClient.Tui/Views/CommandInput.cs b/src/MuClient.Tui/Views/CommandInput.cs new file mode 100644 index 0000000..e76c9e3 --- /dev/null +++ b/src/MuClient.Tui/Views/CommandInput.cs @@ -0,0 +1,111 @@ +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Views; + +namespace MuClient.Tui.Views; + +/// +/// A single-line command entry with input history (Up/Down) and prefix tab-completion drawn +/// from prior commands. Enter raises . +/// +internal sealed class CommandInput : TextField +{ + private readonly List _history = new(); + private int _historyIndex; // == _history.Count means "new, empty line" + private string? _completionPrefix; + private int _completionIndex; + + public CommandInput() + { + KeyDown += OnKeyDown; + } + + /// Raised when the user presses Enter, carrying the entered command. + public event Action? CommandEntered; + + private void OnKeyDown(object? sender, Key key) + { + switch (key.KeyCode) + { + case KeyCode.Enter: + Submit(); + key.Handled = true; + break; + + case KeyCode.CursorUp: + NavigateHistory(-1); + key.Handled = true; + break; + + case KeyCode.CursorDown: + NavigateHistory(+1); + key.Handled = true; + break; + + case KeyCode.Tab: + Complete(); + key.Handled = true; + break; + + default: + _completionPrefix = null; // any other key cancels a completion cycle + break; + } + } + + private void Submit() + { + var command = Text ?? string.Empty; + if (command.Length > 0) + { + _history.Remove(command); + _history.Add(command); + } + + _historyIndex = _history.Count; + _completionPrefix = null; + SetText(string.Empty); + CommandEntered?.Invoke(command); + } + + private void NavigateHistory(int direction) + { + if (_history.Count == 0) + { + return; + } + + _historyIndex = Math.Clamp(_historyIndex + direction, 0, _history.Count); + SetText(_historyIndex >= _history.Count ? string.Empty : _history[_historyIndex]); + } + + private void Complete() + { + var text = Text ?? string.Empty; + _completionPrefix ??= text; + + if (string.IsNullOrEmpty(_completionPrefix)) + { + return; + } + + var matches = _history + .Where(h => h.StartsWith(_completionPrefix, StringComparison.OrdinalIgnoreCase) && h != _completionPrefix) + .Distinct() + .ToList(); + if (matches.Count == 0) + { + return; + } + + _completionIndex %= matches.Count; + SetText(matches[_completionIndex]); + _completionIndex++; + } + + private void SetText(string value) + { + Text = value; + InsertionPoint = value.Length; + } +} diff --git a/src/MuClient.Tui/Views/OutputView.cs b/src/MuClient.Tui/Views/OutputView.cs new file mode 100644 index 0000000..4cfefcc --- /dev/null +++ b/src/MuClient.Tui/Views/OutputView.cs @@ -0,0 +1,176 @@ +using System.Text; +using MuClient.Core.Session; +using MuClient.Core.Text; +using MuClient.Core.Theming; +using Terminal.Gui.ViewBase; + +namespace MuClient.Tui.Views; + +/// +/// Renders a 's scrollback (plus its current prompt) with truecolor +/// styling, word/character wrapping, and vertical scrolling. Custom-drawn because Terminal.Gui's +/// cell grid has no concept of our styled-span model. +/// +internal sealed class OutputView : View +{ + private WorldSession? _session; + private int _scrollOffset; // rows scrolled up from the bottom (0 = following the tail) + + public OutputView() + { + CanFocus = false; + } + + /// The theme-aware colour mapper used to render styled spans. + public ColorMapper Mapper { get; set; } = new(ThemeLibrary.Dark()); + + public WorldSession? Session + { + get => _session; + set + { + _session = value; + _scrollOffset = 0; + SetNeedsDraw(); + } + } + + /// True when the view is pinned to the newest output. + public bool AtBottom => _scrollOffset == 0; + + public void ScrollToBottom() + { + _scrollOffset = 0; + SetNeedsDraw(); + } + + public void ScrollLines(int delta) + { + _scrollOffset = Math.Max(0, _scrollOffset + delta); + SetNeedsDraw(); + } + + public void PageUp() => ScrollLines(Math.Max(1, Viewport.Height - 1)); + + public void PageDown() => ScrollLines(-Math.Max(1, Viewport.Height - 1)); + + protected override bool OnDrawingContent(DrawContext? context) + { + var viewport = Viewport; + var width = Math.Max(1, viewport.Width); + var height = Math.Max(1, viewport.Height); + + var rows = BuildVisualRows(width, height + _scrollOffset + 1); + if (rows.Count == 0) + { + return true; + } + + // Clamp scroll so we never page past the top. + var maxOffset = Math.Max(0, rows.Count - height); + if (_scrollOffset > maxOffset) + { + _scrollOffset = maxOffset; + } + + var bottom = rows.Count - 1 - _scrollOffset; + var top = Math.Max(0, bottom - height + 1); + + var screenRow = height - 1; + for (var i = bottom; i >= top && screenRow >= 0; i--, screenRow--) + { + DrawRow(rows[i], screenRow, width); + } + + return true; + } + + private void DrawRow(IReadOnlyList<(Rune Rune, TextStyle Style)> row, int screenRow, int width) + { + Move(0, screenRow); + var col = 0; + foreach (var (rune, style) in row) + { + if (col >= width) + { + break; + } + + SetAttribute(Mapper.ToAttribute(style)); + AddRune(col, screenRow, rune); + col++; + } + } + + /// + /// Builds up to visual (wrapped) rows from the tail of the + /// scrollback and the active prompt, newest last. + /// + private List> BuildVisualRows(int width, int maxRows) + { + var result = new List>(); + if (_session is null) + { + return result; + } + + var lines = _session.Scrollback.Snapshot(); + var logical = new List(lines); + if (_session.CurrentPrompt is { IsEmpty: false } prompt) + { + logical.Add(prompt); + } + + // Walk logical lines from the end, wrapping each and prepending, until we have enough. + for (var i = logical.Count - 1; i >= 0 && result.Count < maxRows; i--) + { + var wrapped = WrapLine(logical[i], width); + for (var r = wrapped.Count - 1; r >= 0; r--) + { + result.Insert(0, wrapped[r]); + } + } + + return result; + } + + private static List> WrapLine(StyledLine line, int width) + { + var rows = new List>(); + var current = new List<(Rune, TextStyle)>(); + + foreach (var span in line.Spans) + { + foreach (var rune in span.Text.EnumerateRunes()) + { + if (rune.Value == '\t') + { + // Expand tabs to the next multiple of 4 columns. + var stop = 4 - current.Count % 4; + for (var s = 0; s < stop && current.Count < width; s++) + { + current.Add((new Rune(' '), span.Style)); + } + } + else + { + current.Add((rune, span.Style)); + } + + if (current.Count >= width) + { + rows.Add(current); + current = new List<(Rune, TextStyle)>(); + } + } + } + + // Always emit at least one (possibly empty) row so blank lines occupy space. + if (current.Count > 0 || rows.Count == 0) + { + rows.Add(current); + } + + return rows; + } +} diff --git a/tests/MuClient.Core.Tests/Automation/AliasAndMacroTests.cs b/tests/MuClient.Core.Tests/Automation/AliasAndMacroTests.cs new file mode 100644 index 0000000..943a5f5 --- /dev/null +++ b/tests/MuClient.Core.Tests/Automation/AliasAndMacroTests.cs @@ -0,0 +1,94 @@ +using MuClient.Core.Automation; + +namespace MuClient.Core.Tests.Automation; + +public class AliasEngineTests +{ + [Test] + public async Task Expand_SubstitutesCaptureGroups() + { + var engine = new AliasEngine(); + engine.Add(new Alias { Pattern = @"^gt (.+)", Substitution = "say to group: $1" }); + var result = engine.Expand("gt hello team"); + await Assert.That(result.Matched).IsTrue(); + await Assert.That(result.Commands).HasSingleItem(); + await Assert.That(result.Commands[0]).IsEqualTo("say to group: hello team"); + } + + [Test] + public async Task Expand_ProducesMultipleCommands() + { + var engine = new AliasEngine(); + engine.Add(new Alias { Pattern = "^prep$", Substitution = "wield sword\nwear armor\nquaff potion" }); + var result = engine.Expand("prep"); + await Assert.That(result.Commands).Count().IsEqualTo(3); + await Assert.That(result.Commands[1]).IsEqualTo("wear armor"); + } + + [Test] + public async Task NoMatch_ReturnsNoMatch() + { + var engine = new AliasEngine(); + engine.Add(new Alias { Pattern = "^xyz$", Substitution = "nope" }); + var result = engine.Expand("look"); + await Assert.That(result.Matched).IsFalse(); + } + + [Test] + public async Task FirstMatchWins() + { + var engine = new AliasEngine(); + engine.Add(new Alias { Pattern = "^a", Substitution = "first" }); + engine.Add(new Alias { Pattern = "^a", Substitution = "second" }); + var result = engine.Expand("abc"); + await Assert.That(result.Commands[0]).IsEqualTo("first"); + } + + [Test] + public async Task DisabledAlias_IsSkipped() + { + var engine = new AliasEngine(); + engine.Add(new Alias { Pattern = "^a", Enabled = false, Substitution = "x" }); + var result = engine.Expand("abc"); + await Assert.That(result.Matched).IsFalse(); + } +} + +public class MacroEngineTests +{ + [Test] + public async Task Resolve_ReturnsBoundMacro() + { + var engine = new MacroEngine(); + engine.Add(new Macro { Key = "Ctrl+F1", Command = "north" }); + var macro = engine.Resolve("Ctrl+F1"); + await Assert.That(macro).IsNotNull(); + await Assert.That(macro!.Command).IsEqualTo("north"); + } + + [Test] + public async Task Resolve_IsCaseInsensitiveOnKey() + { + var engine = new MacroEngine(); + engine.Add(new Macro { Key = "Alt+K", Command = "kick" }); + await Assert.That(engine.Resolve("alt+k")).IsNotNull(); + } + + [Test] + public async Task Resolve_ReturnsNull_WhenDisabled() + { + var engine = new MacroEngine(); + engine.Add(new Macro { Key = "F2", Command = "flee", Enabled = false }); + await Assert.That(engine.Resolve("F2")).IsNull(); + } + + [Test] + [Arguments("F1", false, false, false, "F1")] + [Arguments("F1", true, false, false, "Ctrl+F1")] + [Arguments("k", true, true, true, "Ctrl+Alt+Shift+k")] + [Arguments("Enter", false, true, false, "Alt+Enter")] + public async Task Describe_ProducesCanonicalDescriptor(string key, bool ctrl, bool alt, bool shift, string expected) + { + await Assert.That(MacroKey.Describe(key, ctrl, alt, shift)).IsEqualTo(expected); + } +} diff --git a/tests/MuClient.Core.Tests/Automation/IntervalSchedulerTests.cs b/tests/MuClient.Core.Tests/Automation/IntervalSchedulerTests.cs new file mode 100644 index 0000000..c85b61a --- /dev/null +++ b/tests/MuClient.Core.Tests/Automation/IntervalSchedulerTests.cs @@ -0,0 +1,98 @@ +using MuClient.Core.Automation; + +namespace MuClient.Core.Tests.Automation; + +public class IntervalSchedulerTests +{ + private static async Task WaitAsync(Task task, int timeoutMs = 5000) => + await Task.WhenAny(task, Task.Delay(timeoutMs)).ConfigureAwait(false) == task; + + private static async Task PollAsync(Func condition, int timeoutMs = 5000) + { + var deadline = Environment.TickCount64 + timeoutMs; + while (!condition() && Environment.TickCount64 < deadline) + { + await Task.Delay(15).ConfigureAwait(false); + } + + return condition(); + } + + [Test] + public async Task After_FiresOnce() + { + using var scheduler = new IntervalScheduler(); + var count = 0; + var fired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + scheduler.After(TimeSpan.FromMilliseconds(20), () => + { + Interlocked.Increment(ref count); + fired.TrySetResult(); + }); + + await Assert.That(await WaitAsync(fired.Task)).IsTrue(); + // Give a one-shot timer a chance to (wrongly) fire again before asserting exactly one. + await Task.Delay(80); + await Assert.That(count).IsEqualTo(1); + } + + [Test] + public async Task After_RemovesHandleWhenDone() + { + using var scheduler = new IntervalScheduler(); + var fired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + scheduler.After(TimeSpan.FromMilliseconds(10), () => fired.TrySetResult()); + await WaitAsync(fired.Task); + await Assert.That(await PollAsync(() => scheduler.Count == 0)).IsTrue(); + } + + [Test] + public async Task Every_FiresRepeatedly() + { + using var scheduler = new IntervalScheduler(); + var count = 0; + var handle = scheduler.Every(TimeSpan.FromMilliseconds(20), () => Interlocked.Increment(ref count)); + await PollAsync(() => Volatile.Read(ref count) >= 2); + handle.Dispose(); + await Assert.That(count).IsGreaterThanOrEqualTo(2); + } + + [Test] + public async Task CallbackException_DoesNotEscapeOrStopScheduler() + { + using var scheduler = new IntervalScheduler(); + var survived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ticks = 0; + scheduler.Every(TimeSpan.FromMilliseconds(15), () => + { + if (Interlocked.Increment(ref ticks) == 1) + { + throw new InvalidOperationException("boom"); + } + + survived.TrySetResult(); + }); + + // A throwing callback must not crash the process nor prevent later ticks. + await Assert.That(await WaitAsync(survived.Task)).IsTrue(); + } + + [Test] + public async Task Dispose_CancelsSchedule() + { + var scheduler = new IntervalScheduler(); + var count = 0; + scheduler.Every(TimeSpan.FromMilliseconds(20), () => Interlocked.Increment(ref count)); + scheduler.Dispose(); + var after = Volatile.Read(ref count); + await Task.Delay(120); + await Assert.That(count).IsEqualTo(after); + } + + [Test] + public async Task Every_RejectsNonPositiveInterval() + { + using var scheduler = new IntervalScheduler(); + await Assert.That(() => scheduler.Every(TimeSpan.Zero, () => { })).Throws(); + } +} diff --git a/tests/MuClient.Core.Tests/Automation/TriggerEngineTests.cs b/tests/MuClient.Core.Tests/Automation/TriggerEngineTests.cs new file mode 100644 index 0000000..ff36c91 --- /dev/null +++ b/tests/MuClient.Core.Tests/Automation/TriggerEngineTests.cs @@ -0,0 +1,120 @@ +using MuClient.Core.Automation; +using MuClient.Core.Text; + +namespace MuClient.Core.Tests.Automation; + +public class TriggerEngineTests +{ + private static StyledLine Line(string text) => StyledLine.FromText(text, TextStyle.Default); + + [Test] + public async Task Gag_SuppressesLine() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "spam", Actions = new TriggerActions { Gag = true } }); + var result = engine.Process(Line("this is spam here")); + await Assert.That(result.Suppress).IsTrue(); + } + + [Test] + public async Task NoMatch_PassesLineThrough() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "nothing", Actions = new TriggerActions { Gag = true } }); + var result = engine.Process(Line("hello")); + await Assert.That(result.Suppress).IsFalse(); + await Assert.That(result.Matched).IsEmpty(); + } + + [Test] + public async Task Highlight_RecoloursMatchedRegion() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger + { + Pattern = "gold", + Actions = new TriggerActions { HighlightForeground = TerminalColor.FromIndex(11) }, + }); + var result = engine.Process(Line("you find gold today")); + + // The span covering "gold" must carry the highlight colour. + var goldSpan = result.Line.Spans.First(s => s.Text.Contains("gold")); + await Assert.That(goldSpan.Style.Foreground).IsEqualTo(TerminalColor.FromIndex(11)); + } + + [Test] + public async Task Rewrite_ReplacesLineText_WithCaptureGroups() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger + { + Pattern = @"(\w+) tells you: (.*)", + Actions = new TriggerActions { Rewrite = "[PM from $1] $2" }, + }); + var result = engine.Process(Line("Bob tells you: hello there")); + await Assert.That(result.Line.Text).IsEqualTo("[PM from Bob] hello there"); + } + + [Test] + public async Task SendResponse_ExpandsCaptureGroups() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger + { + Pattern = @"^(\w+) pokes you", + Actions = new TriggerActions { SendResponse = "poke $1" }, + }); + var result = engine.Process(Line("Alice pokes you")); + await Assert.That(result.Responses).HasSingleItem(); + await Assert.That(result.Responses[0]).IsEqualTo("poke Alice"); + } + + [Test] + public async Task SpawnTarget_IsCollected() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "chat", Actions = new TriggerActions { SpawnTarget = "Chat" } }); + var result = engine.Process(Line("[chat] hi")); + await Assert.That(result.SpawnTargets).Contains("Chat"); + } + + [Test] + public async Task ScriptCallback_IsCollectedWithMatch() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = @"hp: (\d+)", Actions = new TriggerActions { ScriptCallback = "onHp" } }); + var result = engine.Process(Line("hp: 42")); + await Assert.That(result.ScriptInvocations).HasSingleItem(); + await Assert.That(result.ScriptInvocations[0].Callback).IsEqualTo("onHp"); + await Assert.That(result.ScriptInvocations[0].Match.Groups[1].Value).IsEqualTo("42"); + } + + [Test] + public async Task DisabledTrigger_IsSkipped() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "x", Enabled = false, Actions = new TriggerActions { Gag = true } }); + var result = engine.Process(Line("xxx")); + await Assert.That(result.Suppress).IsFalse(); + } + + [Test] + public async Task StopProcessing_HaltsFurtherTriggers() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "foo", StopProcessing = true, Actions = new TriggerActions { SendResponse = "first" } }); + engine.Add(new Trigger { Pattern = "foo", Actions = new TriggerActions { SendResponse = "second" } }); + var result = engine.Process(Line("foo")); + await Assert.That(result.Responses).HasSingleItem(); + await Assert.That(result.Responses[0]).IsEqualTo("first"); + } + + [Test] + public async Task CaseInsensitive_ByDefault() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "HELLO", Actions = new TriggerActions { Gag = true } }); + var result = engine.Process(Line("hello world")); + await Assert.That(result.Suppress).IsTrue(); + } +} diff --git a/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs b/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs new file mode 100644 index 0000000..8d4bdb6 --- /dev/null +++ b/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs @@ -0,0 +1,114 @@ +using MuClient.Core.Automation; +using MuClient.Core.Configuration; +using MuClient.Core.Text; + +namespace MuClient.Core.Tests.Configuration; + +public class ConfigurationTests +{ + [Test] + public async Task RoundTrip_PreservesWorldsTriggersAndColors() + { + var config = new AppConfiguration + { + ScrollbackLines = 5000, + Worlds = + { + new WorldDefinition + { + Name = "Test MUSH", + Host = "mush.example.org", + Port = 4201, + UseTls = true, + Triggers = + { + new Trigger + { + Name = "gold", + Pattern = "gold", + Actions = new TriggerActions { HighlightForeground = TerminalColor.FromRgb(255, 215, 0) }, + }, + }, + Aliases = { new Alias { Pattern = "^gt (.+)", Substitution = "\"$1" } }, + Macros = { new Macro { Key = "F1", Command = "look" } }, + }, + }, + }; + + var json = ConfigurationStore.Serialize(config); + var restored = ConfigurationStore.Deserialize(json); + + await Assert.That(restored.ScrollbackLines).IsEqualTo(5000); + await Assert.That(restored.Worlds).HasSingleItem(); + var world = restored.Worlds[0]; + await Assert.That(world.Name).IsEqualTo("Test MUSH"); + await Assert.That(world.Port).IsEqualTo(4201); + await Assert.That(world.UseTls).IsTrue(); + await Assert.That(world.Triggers).HasSingleItem(); + await Assert.That(world.Triggers[0].Actions.HighlightForeground).IsEqualTo(TerminalColor.FromRgb(255, 215, 0)); + await Assert.That(world.Aliases[0].Substitution).IsEqualTo("\"$1"); + await Assert.That(world.Macros[0].Command).IsEqualTo("look"); + } + + [Test] + public async Task Deserialize_EmptyDefaultsGracefully() + { + var config = ConfigurationStore.Deserialize("{}"); + await Assert.That(config.Worlds).IsEmpty(); + await Assert.That(config.Version).IsEqualTo(1); + } + + [Test] + public async Task ColorConverter_RoundTripsAllKinds() + { + await Assert.That(TerminalColorJsonConverter.ToString(TerminalColor.Default)).IsEqualTo("default"); + await Assert.That(TerminalColorJsonConverter.ToString(TerminalColor.FromIndex(196))).IsEqualTo("idx:196"); + await Assert.That(TerminalColorJsonConverter.ToString(TerminalColor.FromRgb(1, 2, 3))).IsEqualTo("rgb:1,2,3"); + await Assert.That(TerminalColorJsonConverter.Parse("idx:196")).IsEqualTo(TerminalColor.FromIndex(196)); + await Assert.That(TerminalColorJsonConverter.Parse("rgb:1,2,3")).IsEqualTo(TerminalColor.FromRgb(1, 2, 3)); + await Assert.That(TerminalColorJsonConverter.Parse("garbage")).IsEqualTo(TerminalColor.Default); + } +} + +public class BeipMuImporterTests +{ + [Test] + public async Task Import_ReadsWorldsAndAutomation() + { + const string xml = """ + + + + + + + + + + + + + + """; + + var worlds = BeipMuImporter.Import(xml); + await Assert.That(worlds).Count().IsEqualTo(2); + + var muck = worlds[0]; + await Assert.That(muck.Name).IsEqualTo("Furry MUCK"); + await Assert.That(muck.Host).IsEqualTo("muck.example.net"); + await Assert.That(muck.Port).IsEqualTo(8888); + await Assert.That(muck.Triggers).HasSingleItem(); + await Assert.That(muck.Triggers[0].Actions.SendResponse).IsEqualTo("reply hi"); + await Assert.That(muck.Aliases[0].Substitution).IsEqualTo("grouptell $1"); + + await Assert.That(worlds[1].UseTls).IsTrue(); + } + + [Test] + public async Task Import_InvalidXml_ReturnsEmpty() + { + var worlds = BeipMuImporter.Import("not xml <<<"); + await Assert.That(worlds).IsEmpty(); + } +} diff --git a/tests/MuClient.Core.Tests/Logging/LogSinkTests.cs b/tests/MuClient.Core.Tests/Logging/LogSinkTests.cs new file mode 100644 index 0000000..45d3b6a --- /dev/null +++ b/tests/MuClient.Core.Tests/Logging/LogSinkTests.cs @@ -0,0 +1,69 @@ +using System.Text; +using MuClient.Core.Logging; +using MuClient.Core.Text; + +namespace MuClient.Core.Tests.Logging; + +public class LogSinkTests +{ + private static StyledLine Colored(string text, TerminalColor fg) => + new(new[] { new StyledSpan(text, new TextStyle(fg, TerminalColor.Default, TextAttributes.None)) }); + + [Test] + public async Task PlainText_WritesPlainLines() + { + var sb = new StringBuilder(); + using (var sink = new PlainTextLogSink(new StringWriter(sb), ownsWriter: false)) + { + sink.WriteLine(Colored("hello", TerminalColor.FromIndex(1))); + sink.WriteSystem("*** system"); + } + + var text = sb.ToString(); + await Assert.That(text).Contains("hello"); + await Assert.That(text).Contains("*** system"); + await Assert.That(text).DoesNotContain("\x1b"); + } + + [Test] + public async Task Html_EmitsDocumentWithColorSpans() + { + var sb = new StringBuilder(); + using (var sink = new HtmlLogSink(new StringWriter(sb), ownsWriter: false)) + { + sink.WriteLine(Colored("red text", TerminalColor.FromIndex(1))); + } + + var html = sb.ToString(); + await Assert.That(html).Contains(""); + await Assert.That(html).Contains(""); + await Assert.That(html).Contains("color:#800000;"); + await Assert.That(html).Contains("red text"); + } + + [Test] + public async Task Html_EscapesMarkup() + { + var sb = new StringBuilder(); + using (var sink = new HtmlLogSink(new StringWriter(sb), ownsWriter: false)) + { + sink.WriteLine(StyledLine.FromText("