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');
+}