From ac1985bc5c4016914c41132cac413f488ccef2f5 Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 3 Jun 2026 22:00:24 +0800 Subject: [PATCH] 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 --- src/Example/Program.cs | 160 ++++++++++++++++++------------ src/TinyTUI/Runtime/TuiRuntime.cs | 133 +++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 63 deletions(-) create mode 100644 src/TinyTUI/Runtime/TuiRuntime.cs diff --git a/src/Example/Program.cs b/src/Example/Program.cs index bb879a6..00a04c1 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -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 events) +/// +/// 用于手动验证 Runtime 的示例组件 +/// +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 _events = []; - if (events.Count == 0) + /// + public IReadOnlyList Render(int width) { - output.Write("- 无\r\n"); - } - else - { - foreach (var inputEvent in events) + var lines = new List { - 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(); + /// + 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(); + } + + /// + /// 等待用户退出示例 + /// + 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) +/// +/// 用于手动验证 Runtime 的临时全量渲染器 +/// +file sealed class DemoRenderer(ConsoleTerminalOutput output) : IRenderer { - return string.Join(' ', data.Select(static c => c switch + /// + public void Render(IReadOnlyList 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(); + } + + /// + public void Reset() + { + output.ClearScreen(); + } } diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs new file mode 100644 index 0000000..3dc8261 --- /dev/null +++ b/src/TinyTUI/Runtime/TuiRuntime.cs @@ -0,0 +1,133 @@ +using TinyTUI.Components; +using TinyTUI.Input; +using TinyTUI.Rendering; +using TinyTUI.Stdio; + +namespace TinyTUI.Runtime; + +/// +/// 默认 TUI 运行时实现 +/// +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; + + /// + /// 创建默认 TUI 运行时 + /// + public TuiRuntime(ITerminalInput terminalInput, IInputParser inputParser, IRenderer renderer) + { + _terminalInput = terminalInput; + _inputParser = inputParser; + _renderer = renderer; + } + + /// + public void Add(IComponent component) + { + _root.Add(component); + RequestRender(); + } + + /// + public void Remove(IComponent component) + { + _root.Remove(component); + + if (_focusedComponent == component) + { + _focusedComponent = null; + } + + RequestRender(); + } + + /// + public void SetFocus(IComponent? component) + { + _focusedComponent = component; + } + + /// + public void RequestRender() + { + if (!_started) + { + return; + } + + lock (_renderLock) + { + var size = _terminalInput.CurrentSize; + var lines = _root.Render(size.Columns); + _renderer.Render(lines, size); + } + } + + /// + public void Start() + { + if (_started) + { + return; + } + + _started = true; + _terminalInput.DataReceived += OnDataReceived; + _terminalInput.Resized += OnResized; + _terminalInput.Start(); + _renderer.Reset(); + RequestRender(); + } + + /// + public void Stop() + { + if (!_started) + { + return; + } + + _started = false; + _terminalInput.DataReceived -= OnDataReceived; + _terminalInput.Resized -= OnResized; + _terminalInput.Stop(); + } + + /// + 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(); + } +}