From e2311f6e99bf355b5a36b875df8e6e085ac6619a Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 3 Jun 2026 22:46:18 +0800 Subject: [PATCH] feat: add basic components - add text, box, and single-line input components - support input change, submit, and cancel callbacks - update example to compose the page from library components --- src/Example/Program.cs | 124 +++++++++++++------------------- src/TinyTUI/Components/Box.cs | 57 +++++++++++++++ src/TinyTUI/Components/Input.cs | 93 ++++++++++++++++++++++++ src/TinyTUI/Components/Text.cs | 15 ++++ 4 files changed, 213 insertions(+), 76 deletions(-) create mode 100644 src/TinyTUI/Components/Box.cs create mode 100644 src/TinyTUI/Components/Input.cs create mode 100644 src/TinyTUI/Components/Text.cs diff --git a/src/Example/Program.cs b/src/Example/Program.cs index 63465d3..d409639 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -14,16 +14,41 @@ var textMeasurer = new TerminalTextMeasurer(); var renderer = new DifferentialRenderer(terminalOutput, textMeasurer); using var runtime = new TuiRuntime(terminalInput, parser, renderer); -var component = new EchoComponent(runtime, textMeasurer, renderer); -runtime.Add(component); -runtime.SetFocus(component); +var done = new ManualResetEventSlim(); +var eventsText = new Text("最近事件:\n- 无"); +var statusText = new Text(); +var input = new TinyTUI.Components.Input(textMeasurer) { Prompt = "请输入: " }; + +input.OnChanged = value => UpdateStatus(statusText, value, textMeasurer, renderer); +input.OnSubmitted = value => +{ + AddEvent(eventsText, $"Submitted: {value}"); + input.Clear(); +}; +input.OnCanceled = () => done.Set(); + +UpdateStatus(statusText, input.Value, textMeasurer, renderer); + +var page = new Container(); +page.Add(new Text("TinyTUI 基础组件示例")); +page.Add(new Text("======================")); +page.Add(new Text("测试方式: 输入文本 Backspace 删除 Enter 提交 Esc 退出")); +page.Add(new Text(string.Empty)); +page.Add(new Box(statusText, textMeasurer) { Title = "状态" }); +page.Add(new Text(string.Empty)); +page.Add(new Box(input, textMeasurer) { Title = "Input" }); +page.Add(new Text(string.Empty)); +page.Add(new Box(eventsText, textMeasurer) { Title = "事件" }); + +runtime.Add(page); +runtime.SetFocus(input); terminalOutput.HideCursor(); try { runtime.Start(); - component.WaitForExit(); + done.Wait(); } finally { @@ -33,77 +58,24 @@ finally terminalOutput.Flush(); } -/// -/// 用于手动验证 Runtime 的示例组件 -/// -file sealed class EchoComponent(ITuiRuntime runtime, ITextMeasurer textMeasurer, DifferentialRenderer renderer) : IInputComponent +static void UpdateStatus( + Text statusText, + string inputValue, + ITextMeasurer textMeasurer, + DifferentialRenderer renderer) { - private readonly ManualResetEventSlim _done = new(); - private readonly List _events = []; - - /// - public IReadOnlyList Render(int width) - { - var lines = new List - { - "TinyTUI Runtime 示例", - "====================", - $"当前渲染宽度: {width}", - $"全量重绘次数: {renderer.FullRedrawCount}", - $"差分重绘次数: {renderer.DifferentialRedrawCount}", - string.Empty, - "测试方式:", - "- 输入普通字符 验证 Text 事件会进入焦点组件", - "- 按方向键 Enter Backspace Tab Delete 验证 Key 事件会进入焦点组件", - "- 调整窗口大小 验证 Resize 事件会进入焦点组件并触发重渲染", - "- 按 Esc 退出", - string.Empty, - "宽度示例:", - $"字符串 \"abc\" 的终端宽度 = {textMeasurer.GetWidth("abc")}", - $"字符串 \"中文\" 的终端宽度 = {textMeasurer.GetWidth("中文")}", - $"字符串 \"😀\" 的终端宽度 = {textMeasurer.GetWidth("😀")}", - $"截断前 \"abc中文😀xyz\" 的终端宽度 = {textMeasurer.GetWidth("abc中文😀xyz")}", - $"截断到 8 列后的结果 = \"{textMeasurer.Truncate("abc中文😀xyz", 8)}\"", - string.Empty, - "最近事件:", - }; - - lines.AddRange(_events.TakeLast(10)); - return lines; - } - - /// - 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(), - })); - } + statusText.Value = string.Join( + '\n', + $"当前输入 = \"{inputValue}\"", + $"当前输入宽度 = {textMeasurer.GetWidth(inputValue)}", + $"字符串 \"abc中文😀xyz\" 宽度 = {textMeasurer.GetWidth("abc中文😀xyz")}", + $"截断到 8 列 = \"{textMeasurer.Truncate("abc中文😀xyz", 8)}\"", + $"全量重绘次数 = {renderer.FullRedrawCount}", + $"差分重绘次数 = {renderer.DifferentialRedrawCount}"); +} + +static void AddEvent(Text eventsText, string message) +{ + var lines = eventsText.Value.Split('\n').Skip(1).Append($"- {message}").TakeLast(8); + eventsText.Value = "最近事件:\n" + string.Join('\n', lines); } diff --git a/src/TinyTUI/Components/Box.cs b/src/TinyTUI/Components/Box.cs new file mode 100644 index 0000000..310d75c --- /dev/null +++ b/src/TinyTUI/Components/Box.cs @@ -0,0 +1,57 @@ +using TinyTUI.Text; + +namespace TinyTUI.Components; + +/// +/// 为子组件渲染简单边框的容器组件 +/// +public sealed class Box(IComponent child, ITextMeasurer? textMeasurer = null) : IComponent +{ + private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); + + /// + /// 获取被包裹的子组件 + /// + public IComponent Child { get; } = child; + + /// + /// 获取或设置边框标题 + /// + public string Title { get; set; } = string.Empty; + + /// + public IReadOnlyList Render(int width) + { + if (width < 4) + return Child.Render(width); + + var innerWidth = width - 4; + var lines = new List { RenderTop(width) }; + + foreach (var childLine in Child.Render(innerWidth)) + { + var content = _textMeasurer.Truncate(childLine, innerWidth); + var padding = Math.Max(0, innerWidth - _textMeasurer.GetWidth(content)); + lines.Add($"| {content}{new string(' ', padding)} |"); + } + + lines.Add($"+{new string('-', width - 2)}+"); + return lines; + } + + /// + public void Invalidate() + { + Child.Invalidate(); + } + + private string RenderTop(int width) + { + if (Title.Length == 0) + return $"+{new string('-', width - 2)}+"; + + var title = _textMeasurer.Truncate($" {Title} ", width - 2); + var padding = Math.Max(0, width - 2 - _textMeasurer.GetWidth(title)); + return $"+{title}{new string('-', padding)}+"; + } +} diff --git a/src/TinyTUI/Components/Input.cs b/src/TinyTUI/Components/Input.cs new file mode 100644 index 0000000..a490725 --- /dev/null +++ b/src/TinyTUI/Components/Input.cs @@ -0,0 +1,93 @@ +using System.Text; +using TinyTUI.Input; +using TinyTUI.Text; + +namespace TinyTUI.Components; + +/// +/// 支持文本输入 提交和取消的单行输入组件 +/// +public sealed class Input(ITextMeasurer? textMeasurer = null) : IInputComponent +{ + private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); + + /// + /// 获取或设置输入框前缀 + /// + public string Prompt { get; set; } = "> "; + + /// + /// 获取当前输入值 + /// + public string Value { get; private set; } = string.Empty; + + /// + /// 在输入值变化时触发 + /// + public Action? OnChanged { get; set; } + + /// + /// 在用户提交输入时触发 + /// + public Action? OnSubmitted { get; set; } + + /// + /// 在用户取消输入时触发 + /// + public Action? OnCanceled { get; set; } + + /// + public IReadOnlyList Render(int width) + { + var cursor = "█"; + var visible = $"{Prompt}{Value}{cursor}"; + return [_textMeasurer.Truncate(visible, width)]; + } + + /// + public void HandleInput(TuiInputEvent input) + { + switch (input) + { + case { Kind: TuiInputEventKind.Text }: + SetValue(Value + input.Value); + break; + case { Kind: TuiInputEventKind.Paste }: + SetValue(Value + input.Value.Replace("\r\n", "\n").Replace('\n', ' ')); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Backspace }: + Backspace(); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Enter }: + OnSubmitted?.Invoke(Value); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape }: + OnCanceled?.Invoke(); + break; + } + } + + /// + /// 清空当前输入值 + /// + public void Clear() => SetValue(string.Empty); + + private void SetValue(string value) + { + Value = value; + OnChanged?.Invoke(Value); + } + + private void Backspace() + { + if (Value.Length == 0) + return; + + var builder = new StringBuilder(); + var runes = Value.EnumerateRunes().ToArray(); + + foreach (var rune in runes[..^1]) builder.Append(rune); + + SetValue(builder.ToString()); + } +} diff --git a/src/TinyTUI/Components/Text.cs b/src/TinyTUI/Components/Text.cs new file mode 100644 index 0000000..3cc68cf --- /dev/null +++ b/src/TinyTUI/Components/Text.cs @@ -0,0 +1,15 @@ +namespace TinyTUI.Components; + +/// +/// 渲染一段纯文本的基础组件 +/// +public sealed class Text(string value = "") : IComponent +{ + /// + /// 获取或设置当前文本内容 + /// + public string Value { get; set; } = value; + + /// + public IReadOnlyList Render(int width) => Value.Replace("\r\n", "\n").Split('\n'); +}