feat: add tui runtime

- wire terminal input parsing to focused components

- render the root component tree through the renderer

- update example to exercise runtime dispatch
This commit is contained in:
chuan
2026-06-03 22:00:24 +08:00
parent 0fd4465aa8
commit ac1985bc5c
2 changed files with 230 additions and 63 deletions
+97 -63
View File
@@ -1,90 +1,124 @@
using TinyTUI;
using TinyTUI.Components;
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Runtime;
using TinyTUI.Stdio;
using TinyTUI.Stdout;
using var input = new ConsoleTerminalInput();
var output = new ConsoleTerminalOutput();
using var terminalInput = new ConsoleTerminalInput();
var terminalOutput = new ConsoleTerminalOutput();
var parser = new DefaultInputParser();
var done = new ManualResetEventSlim();
var latestSize = input.CurrentSize;
var renderer = new DemoRenderer(terminalOutput);
using var runtime = new TuiRuntime(terminalInput, parser, renderer);
output.ClearScreen();
output.HideCursor();
Render(output, latestSize, "等待输入", []);
var component = new EchoComponent(runtime);
runtime.Add(component);
runtime.SetFocus(component);
input.DataReceived += (_, data) =>
{
var events = parser.Parse(data);
if (events.Any(static e => e is { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape }))
{
done.Set();
return;
}
Render(output, latestSize, $"原始输入: {FormatInput(data)}", events);
};
input.Resized += (_, size) =>
{
latestSize = size;
Render(output, latestSize, "窗口尺寸已变化", [new TuiInputEvent(TuiInputEventKind.Resize, $"{size.Columns}x{size.Rows}")]);
};
terminalOutput.HideCursor();
try
{
input.Start();
done.Wait();
runtime.Start();
component.WaitForExit();
}
finally
{
input.Stop();
output.ShowCursor();
output.Write("\r\n");
output.Flush();
runtime.Stop();
terminalOutput.ShowCursor();
terminalOutput.Write("\r\n");
terminalOutput.Flush();
}
static void Render(ConsoleTerminalOutput output, TerminalSize size, string message, IReadOnlyList<TuiInputEvent> events)
/// <summary>
/// 用于手动验证 Runtime 的示例组件
/// </summary>
file sealed class EchoComponent(ITuiRuntime runtime) : IInputComponent
{
output.ClearScreen();
output.Write("TinyTUI Input 示例\r\n");
output.Write("===================\r\n");
output.Write($"尺寸: {size.Columns} x {size.Rows}\r\n");
output.Write($"{message}\r\n");
output.Write("\r\n");
output.Write("解析事件:\r\n");
private readonly ManualResetEventSlim _done = new();
private readonly List<string> _events = [];
if (events.Count == 0)
/// <inheritdoc />
public IReadOnlyList<string> Render(int width)
{
output.Write("- 无\r\n");
}
else
{
foreach (var inputEvent in events)
var lines = new List<string>
{
output.Write($"- {inputEvent.Kind}: {FormatInput(inputEvent.Value)}\r\n");
}
"TinyTUI Runtime 示例",
"====================",
"测试方式:",
"- 输入普通字符 验证 Text 事件会进入焦点组件",
"- 按方向键 Enter Backspace Tab Delete 验证 Key 事件会进入焦点组件",
"- 调整窗口大小 验证 Resize 事件会进入焦点组件并触发重渲染",
"- 按 Esc 退出",
string.Empty,
"最近事件:",
};
lines.AddRange(_events.TakeLast(10));
return lines;
}
output.Write("\r\n");
output.Write("测试方式:\r\n");
output.Write("- 输入普通字符 应解析为 Text\r\n");
output.Write("- 按方向键 Home End Delete F1-F12 应解析为 Key\r\n");
output.Write("- 改变终端窗口大小 应解析为 Resize\r\n");
output.Write("- 按 Esc 退出\r\n");
output.Flush();
/// <inheritdoc />
public void HandleInput(TuiInputEvent input)
{
if (input is { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape })
{
_done.Set();
return;
}
_events.Add($"{input.Kind}: {FormatInput(input.Value)}");
runtime.RequestRender();
}
/// <summary>
/// 等待用户退出示例
/// </summary>
public void WaitForExit()
{
_done.Wait();
}
private static string FormatInput(string data)
{
return string.Join(' ', data.Select(static c => c switch
{
'\r' => "\\r",
'\n' => "\\n",
'\t' => "\\t",
'\b' => "\\b",
'\e' => "\\e",
_ when char.IsControl(c) => $"\\x{(int)c:x2}",
_ => c.ToString(),
}));
}
}
static string FormatInput(string data)
/// <summary>
/// 用于手动验证 Runtime 的临时全量渲染器
/// </summary>
file sealed class DemoRenderer(ConsoleTerminalOutput output) : IRenderer
{
return string.Join(' ', data.Select(static c => c switch
/// <inheritdoc />
public void Render(IReadOnlyList<string> lines, TerminalSize size)
{
'\r' => "\\r",
'\n' => "\\n",
'\t' => "\\t",
'\b' => "\\b",
'\e' => "\\e",
_ when char.IsControl(c) => $"\\x{(int)c:x2}",
_ => c.ToString(),
}));
output.ClearScreen();
output.Write($"尺寸: {size.Columns} x {size.Rows}\r\n");
output.Write("\r\n");
foreach (var line in lines)
{
output.Write(line);
output.Write("\r\n");
}
output.Flush();
}
/// <inheritdoc />
public void Reset()
{
output.ClearScreen();
}
}
+133
View File
@@ -0,0 +1,133 @@
using TinyTUI.Components;
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Stdio;
namespace TinyTUI.Runtime;
/// <summary>
/// 默认 TUI 运行时实现
/// </summary>
public sealed class TuiRuntime : ITuiRuntime
{
private readonly ITerminalInput _terminalInput;
private readonly IInputParser _inputParser;
private readonly IRenderer _renderer;
private readonly Container _root = new();
private readonly Lock _renderLock = new();
private IComponent? _focusedComponent;
private bool _started;
/// <summary>
/// 创建默认 TUI 运行时
/// </summary>
public TuiRuntime(ITerminalInput terminalInput, IInputParser inputParser, IRenderer renderer)
{
_terminalInput = terminalInput;
_inputParser = inputParser;
_renderer = renderer;
}
/// <inheritdoc />
public void Add(IComponent component)
{
_root.Add(component);
RequestRender();
}
/// <inheritdoc />
public void Remove(IComponent component)
{
_root.Remove(component);
if (_focusedComponent == component)
{
_focusedComponent = null;
}
RequestRender();
}
/// <inheritdoc />
public void SetFocus(IComponent? component)
{
_focusedComponent = component;
}
/// <inheritdoc />
public void RequestRender()
{
if (!_started)
{
return;
}
lock (_renderLock)
{
var size = _terminalInput.CurrentSize;
var lines = _root.Render(size.Columns);
_renderer.Render(lines, size);
}
}
/// <inheritdoc />
public void Start()
{
if (_started)
{
return;
}
_started = true;
_terminalInput.DataReceived += OnDataReceived;
_terminalInput.Resized += OnResized;
_terminalInput.Start();
_renderer.Reset();
RequestRender();
}
/// <inheritdoc />
public void Stop()
{
if (!_started)
{
return;
}
_started = false;
_terminalInput.DataReceived -= OnDataReceived;
_terminalInput.Resized -= OnResized;
_terminalInput.Stop();
}
/// <inheritdoc />
public void Dispose()
{
Stop();
_terminalInput.Dispose();
}
private void OnDataReceived(object? sender, string data)
{
foreach (var inputEvent in _inputParser.Parse(data))
{
Dispatch(inputEvent);
}
}
private void OnResized(object? sender, TerminalSize size)
{
Dispatch(new TuiInputEvent(TuiInputEventKind.Resize, $"{size.Columns}x{size.Rows}"));
}
private void Dispatch(TuiInputEvent inputEvent)
{
if (_focusedComponent is IInputComponent inputComponent)
{
inputComponent.HandleInput(inputEvent);
}
RequestRender();
}
}