using System.Threading.Channels; using Microsoft.JSInterop; using Spectre.Console; using Spectre.Console.Rendering; namespace OpenTheBox.Web; /// /// Bridge between the game loop (C#) and xterm.js (browser). /// Handles output (ANSI strings → xterm.js) and input (xterm.js key events → C#). /// public sealed class WebTerminal { private static WebTerminal? _instance; private readonly IJSRuntime _js; /// /// Bounded key buffer — prevents held keys from accumulating minutes of input. /// FullMode.DropWrite silently drops new keys when the buffer is full. /// private readonly Channel _keyChannel = Channel.CreateBounded(new BoundedChannelOptions(8) { SingleReader = true, SingleWriter = true, FullMode = BoundedChannelFullMode.DropOldest }); public const int Width = 120; public const int Height = 30; public WebTerminal(IJSRuntime js) { _js = js; _instance = this; } /// /// Initializes the xterm.js terminal in the browser. /// public async Task InitAsync() { await _js.InvokeVoidAsync("terminalInterop.init"); } // ── Output ─────────────────────────────────────────────────────────── /// /// Writes raw text (including ANSI escape codes) to xterm.js. /// public async Task WriteAsync(string text) { await _js.InvokeVoidAsync("terminalInterop.write", text); } /// /// Writes a line of text followed by \r\n. /// public async Task WriteLineAsync(string text = "") { await WriteAsync(text + "\r\n"); } /// /// Clears the terminal screen. /// public async Task ClearAsync() { await _js.InvokeVoidAsync("terminalInterop.clear"); } /// /// Renders a Spectre.Console IRenderable to ANSI string using off-screen rendering. /// public static string RenderToAnsi(IRenderable renderable) { var writer = new StringWriter(); var console = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(writer), Ansi = AnsiSupport.Yes, ColorSystem = ColorSystemSupport.TrueColor }); console.Profile.Width = Width; console.Profile.Height = Height; console.Write(renderable); return writer.ToString(); } /// /// Renders a Spectre.Console IRenderable and writes it to xterm.js. /// public async Task WriteRenderableAsync(IRenderable renderable) { string ansi = RenderToAnsi(renderable); // Convert \n to \r\n for xterm.js ansi = ansi.Replace("\n", "\r\n"); await WriteAsync(ansi); } /// /// Writes Spectre.Console markup text to the terminal. /// public async Task WriteMarkupLineAsync(string markup) { var writer = new StringWriter(); var console = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(writer), Ansi = AnsiSupport.Yes, ColorSystem = ColorSystemSupport.TrueColor }); console.Profile.Width = Width; console.Profile.Height = Height; console.MarkupLine(markup); string ansi = writer.ToString().Replace("\n", "\r\n"); await WriteAsync(ansi); } // ── Input ──────────────────────────────────────────────────────────── /// /// Called from JavaScript when a key is pressed in xterm.js. /// [JSInvokable("OnTerminalInput")] public static void OnTerminalInput(string data) { if (_instance is null) return; // Parse xterm.js input data into ConsoleKeyInfo events var keys = ParseInput(data); foreach (var key in keys) { _instance._keyChannel.Writer.TryWrite(key); } } /// /// Waits for and returns the next key press from xterm.js. /// public async Task ReadKeyAsync() { return await _keyChannel.Reader.ReadAsync(); } /// /// Reads a full line of text input, echoing characters and handling backspace. /// public async Task ReadLineAsync() { var buffer = new List(); while (true) { var key = await ReadKeyAsync(); if (key.Key == ConsoleKey.Enter) { await WriteAsync("\r\n"); return new string(buffer.ToArray()); } if (key.Key == ConsoleKey.Backspace) { if (buffer.Count > 0) { buffer.RemoveAt(buffer.Count - 1); await WriteAsync("\b \b"); } continue; } if (key.KeyChar >= 32) // printable { buffer.Add(key.KeyChar); await WriteAsync(key.KeyChar.ToString()); } } } /// /// Waits for any key press. /// public async Task WaitForKeyAsync() { await ReadKeyAsync(); } // ── High-level input methods ───────────────────────────────────────── /// /// Shows a numbered selection prompt and waits for the user to choose. /// Supports arrow keys (when arrows are enabled) and number key shortcuts. /// public async Task ShowSelectionAsync(string prompt, List options, bool useArrows) { if (useArrows) return await ShowArrowSelectionAsync(prompt, options); // Numbered selection if (prompt.Length > 0) await WriteLineAsync(prompt); for (int i = 0; i < options.Count; i++) await WriteLineAsync($" {i + 1}. {options[i]}"); while (true) { await WriteAsync("> "); string input = await ReadLineAsync(); if (int.TryParse(input, out int choice) && choice >= 1 && choice <= options.Count) return choice - 1; await WriteLineAsync($"Please enter a number between 1 and {options.Count}."); } } /// /// Arrow-key selection with highlight and number key shortcuts. /// Mirrors SpectreRenderer.ShowArrowSelection. /// private async Task ShowArrowSelectionAsync(string prompt, List options) { int selected = 0; int pageSize = Math.Min(10, options.Count); while (true) { int scrollOffset = 0; if (selected >= pageSize) scrollOffset = selected - pageSize + 1; int visibleEnd = Math.Min(scrollOffset + pageSize, options.Count); // Render using Spectre off-screen for consistent styling var writer = new StringWriter(); var console = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(writer), Ansi = AnsiSupport.Yes, ColorSystem = ColorSystemSupport.TrueColor }); console.Profile.Width = Width; console.Profile.Height = Height; if (prompt.Length > 0) console.MarkupLine($"[bold]{Markup.Escape(prompt)}[/]"); for (int i = scrollOffset; i < visibleEnd; i++) { string num = $"{i + 1}."; string text = Markup.Escape(options[i]); if (i == selected) console.MarkupLine($" [bold cyan]► {num,-3}[/] [bold]{text}[/]"); else console.MarkupLine($" [dim]{num,-3}[/] {text}"); } if (scrollOffset > 0) console.MarkupLine("[dim] ▲ ...[/]"); if (visibleEnd < options.Count) console.MarkupLine("[dim] ▼ ...[/]"); string rendered = writer.ToString().Replace("\n", "\r\n"); // Count actual line breaks — Split('\n').Length overcounts by 1 due to trailing newline int lineCount = rendered.Count(c => c == '\n'); await WriteAsync(rendered); var key = await ReadKeyAsync(); switch (key.Key) { case ConsoleKey.UpArrow: selected = (selected - 1 + options.Count) % options.Count; break; case ConsoleKey.DownArrow: selected = (selected + 1) % options.Count; break; case ConsoleKey.Enter: // Clear the rendered selection before returning await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return selected; case ConsoleKey.D1 or ConsoleKey.NumPad1: if (options.Count >= 1) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 0; } break; case ConsoleKey.D2 or ConsoleKey.NumPad2: if (options.Count >= 2) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 1; } break; case ConsoleKey.D3 or ConsoleKey.NumPad3: if (options.Count >= 3) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 2; } break; case ConsoleKey.D4 or ConsoleKey.NumPad4: if (options.Count >= 4) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 3; } break; case ConsoleKey.D5 or ConsoleKey.NumPad5: if (options.Count >= 5) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 4; } break; case ConsoleKey.D6 or ConsoleKey.NumPad6: if (options.Count >= 6) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 5; } break; case ConsoleKey.D7 or ConsoleKey.NumPad7: if (options.Count >= 7) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 6; } break; case ConsoleKey.D8 or ConsoleKey.NumPad8: if (options.Count >= 8) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 7; } break; case ConsoleKey.D9 or ConsoleKey.NumPad9: if (options.Count >= 9) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 8; } break; } // Clear and re-render await WriteAsync($"\x1b[{lineCount}A\x1b[J"); } } // ── Key parsing ────────────────────────────────────────────────────── /// /// Parses xterm.js onData strings into ConsoleKeyInfo values. /// xterm.js sends raw character sequences: single chars, ANSI escape sequences, etc. /// private static List ParseInput(string data) { var keys = new List(); int i = 0; while (i < data.Length) { char c = data[i]; // ANSI escape sequence if (c == '\x1b' && i + 1 < data.Length && data[i + 1] == '[') { // CSI sequence i += 2; string seq = ""; while (i < data.Length && data[i] is >= '0' and <= '9' or ';') { seq += data[i]; i++; } if (i < data.Length) { char final = data[i]; i++; var key = (seq, final) switch { ("", 'A') => MakeKey(ConsoleKey.UpArrow), ("", 'B') => MakeKey(ConsoleKey.DownArrow), ("", 'C') => MakeKey(ConsoleKey.RightArrow), ("", 'D') => MakeKey(ConsoleKey.LeftArrow), ("5", '~') => MakeKey(ConsoleKey.PageUp), ("6", '~') => MakeKey(ConsoleKey.PageDown), ("3", '~') => MakeKey(ConsoleKey.Delete), _ => (ConsoleKeyInfo?)null }; if (key.HasValue) keys.Add(key.Value); } } else if (c == '\x1b') { // Bare escape keys.Add(MakeKey(ConsoleKey.Escape)); i++; } else if (c == '\r') { keys.Add(MakeKey(ConsoleKey.Enter, '\r')); i++; } else if (c == '\x7f' || c == '\b') { keys.Add(MakeKey(ConsoleKey.Backspace, '\b')); i++; } else if (c == '\t') { keys.Add(MakeKey(ConsoleKey.Tab, '\t')); i++; } else { // Regular character var consoleKey = c switch { >= '0' and <= '9' => ConsoleKey.D0 + (c - '0'), >= 'a' and <= 'z' => ConsoleKey.A + (c - 'a'), >= 'A' and <= 'Z' => ConsoleKey.A + (c - 'A'), ' ' => ConsoleKey.Spacebar, _ => ConsoleKey.NoName }; bool shift = c is >= 'A' and <= 'Z'; keys.Add(new ConsoleKeyInfo(c, consoleKey, shift, false, false)); i++; } } return keys; } private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar = '\0') { return new ConsoleKeyInfo(keyChar, key, false, false, false); } }