openthebox/src/OpenTheBox.Web/WebTerminal.cs
Samuel Bouchet fcd270861c Eliminate flicker on arrow key navigation in inventory and menus
Replace clear-then-write pattern with overwrite-in-place: move cursor
up to start of rendered area and write new content directly over old
lines. Only clear trailing lines if the new render is shorter. This
removes the visible blank frame between clear and re-render.
2026-03-16 19:50:39 +01:00

408 lines
14 KiB
C#

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 int Height { get; private set; } = 30;
public WebTerminal(IJSRuntime js)
{
_js = js;
_instance = this;
}
/// <summary>
/// Initializes the xterm.js terminal in the browser and reads actual dimensions.
/// </summary>
public async Task InitAsync()
{
await _js.InvokeVoidAsync("terminalInterop.init");
// Read actual terminal dimensions after fitAddon has sized the terminal
try
{
var dims = await _js.InvokeAsync<TerminalDimensions>("terminalInterop.getDimensions");
if (dims.rows >= 30)
Height = dims.rows;
}
catch { /* fallback to 30 */ }
}
private record TerminalDimensions(int cols, int rows);
// ── 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 = _instance?.Height ?? 50;
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);
int previousLineCount = 0;
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");
int lineCount = rendered.Count(c => c == '\n');
// Overwrite in place: move cursor up, write over old lines (no flicker).
// Only clear leftover lines if the new render is shorter than previous.
if (previousLineCount > 0)
await WriteAsync($"\x1b[{previousLineCount}A\r");
await WriteAsync(rendered);
if (previousLineCount > lineCount)
await WriteAsync("\x1b[J");
previousLineCount = lineCount;
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:
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;
}
}
}
// ── 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);
}
}