openthebox/src/OpenTheBox.Web/WebTerminal.cs

395 lines
14 KiB
C#
Raw Normal View History

using System.Threading.Channels;
using Microsoft.JSInterop;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace OpenTheBox.Web;
/// <summary>
/// Bridge between the game loop (C#) and xterm.js (browser).
/// Handles output (ANSI strings → xterm.js) and input (xterm.js key events → C#).
/// </summary>
public sealed class WebTerminal
{
private static WebTerminal? _instance;
private readonly IJSRuntime _js;
/// <summary>
/// Bounded key buffer — prevents held keys from accumulating minutes of input.
/// FullMode.DropWrite silently drops new keys when the buffer is full.
/// </summary>
private readonly Channel<ConsoleKeyInfo> _keyChannel =
Channel.CreateBounded<ConsoleKeyInfo>(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;
}
/// <summary>
/// Initializes the xterm.js terminal in the browser.
/// </summary>
public async Task InitAsync()
{
await _js.InvokeVoidAsync("terminalInterop.init");
}
// ── Output ───────────────────────────────────────────────────────────
/// <summary>
/// Writes raw text (including ANSI escape codes) to xterm.js.
/// </summary>
public async Task WriteAsync(string text)
{
await _js.InvokeVoidAsync("terminalInterop.write", text);
}
/// <summary>
/// Writes a line of text followed by \r\n.
/// </summary>
public async Task WriteLineAsync(string text = "")
{
await WriteAsync(text + "\r\n");
}
/// <summary>
/// Clears the terminal screen.
/// </summary>
public async Task ClearAsync()
{
await _js.InvokeVoidAsync("terminalInterop.clear");
}
/// <summary>
/// Renders a Spectre.Console IRenderable to ANSI string using off-screen rendering.
/// </summary>
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();
}
/// <summary>
/// Renders a Spectre.Console IRenderable and writes it to xterm.js.
/// </summary>
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);
}
/// <summary>
/// Writes Spectre.Console markup text to the terminal.
/// </summary>
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 ────────────────────────────────────────────────────────────
/// <summary>
/// Called from JavaScript when a key is pressed in xterm.js.
/// </summary>
[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);
}
}
/// <summary>
/// Waits for and returns the next key press from xterm.js.
/// </summary>
public async Task<ConsoleKeyInfo> ReadKeyAsync()
{
return await _keyChannel.Reader.ReadAsync();
}
/// <summary>
/// Reads a full line of text input, echoing characters and handling backspace.
/// </summary>
public async Task<string> ReadLineAsync()
{
var buffer = new List<char>();
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());
}
}
}
/// <summary>
/// Waits for any key press.
/// </summary>
public async Task WaitForKeyAsync()
{
await ReadKeyAsync();
}
// ── High-level input methods ─────────────────────────────────────────
/// <summary>
/// Shows a numbered selection prompt and waits for the user to choose.
/// Supports arrow keys (when arrows are enabled) and number key shortcuts.
/// </summary>
public async Task<int> ShowSelectionAsync(string prompt, List<string> 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}.");
}
}
/// <summary>
/// Arrow-key selection with highlight and number key shortcuts.
/// Mirrors SpectreRenderer.ShowArrowSelection.
/// </summary>
private async Task<int> ShowArrowSelectionAsync(string prompt, List<string> 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 ──────────────────────────────────────────────────────
/// <summary>
/// Parses xterm.js onData strings into ConsoleKeyInfo values.
/// xterm.js sends raw character sequences: single chars, ANSI escape sequences, etc.
/// </summary>
private static List<ConsoleKeyInfo> ParseInput(string data)
{
var keys = new List<ConsoleKeyInfo>();
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);
}
}