diff --git a/src/Example/Program.cs b/src/Example/Program.cs
index cb166b3..942aeee 100644
--- a/src/Example/Program.cs
+++ b/src/Example/Program.cs
@@ -18,19 +18,42 @@ using var runtime = new TuiRuntime(terminalInput, parser, renderer);
var done = new ManualResetEventSlim();
var eventsText = new Text("最近事件:\n- 无");
var statusText = new Text();
+var loader = new Loader(textMeasurer) { Message = "运行中" };
+var selectPreview = new SelectList(textMeasurer) { Height = 4 };
+var markdownPreview = new Markdown(
+ "# Markdown 预览\n" +
+ "## 二级标题会转成大写\n" +
+ "- 列表项会转成星号前缀\n" +
+ "> 引用会转成竖线前缀\n" +
+ "支持 **粗体标记清理** 和 `行内代码标记清理`");
+using var loaderCancellation = new CancellationTokenSource();
var input = new TinyTUI.Components.Input(textMeasurer) { Prompt = "请输入: " };
+selectPreview.SetItems(["Text", "Input", "Editor", "SelectList", "Markdown", "Loader", "Overlay"]);
+
input.OnChanged = value => UpdateStatus(statusText, value, textMeasurer, renderer);
input.OnSubmitted = value =>
{
- if (value.Trim() == "/help")
+ switch (value.Trim())
{
- AddEvent(eventsText, "Overlay: help opened");
- runtime.ShowOverlay(new HelpOverlay(runtime, textMeasurer));
- }
- else
- {
- AddEvent(eventsText, $"Submitted: {value}");
+ case "/help":
+ AddEvent(eventsText, "Overlay: help opened");
+ runtime.ShowOverlay(new HelpOverlay(runtime, textMeasurer));
+ break;
+ case "/select":
+ AddEvent(eventsText, "Overlay: select opened");
+ runtime.ShowOverlay(new SelectOverlay(runtime, message => AddEvent(eventsText, message), textMeasurer));
+ break;
+ case "/edit":
+ AddEvent(eventsText, "Overlay: editor opened");
+ runtime.ShowOverlay(new EditorOverlay(runtime, message => AddEvent(eventsText, message), textMeasurer));
+ break;
+ case "":
+ AddEvent(eventsText, "Submitted empty input");
+ break;
+ default:
+ AddEvent(eventsText, $"Submitted: {value}");
+ break;
}
input.Clear();
@@ -40,20 +63,37 @@ 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 提交 /help 打开弹层 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 = "事件" });
+AddAll(
+ page,
+ new Text("TinyTUI 基础组件示例"),
+ new Text("======================"),
+ new Markdown(
+ "# 操作说明\n" +
+ "- Markdown Loader SelectList 预览会在页面启动时直接显示\n" +
+ "- 输入文本后 Enter 提交\n" +
+ "- /help 打开帮助弹层\n" +
+ "- /select 打开可交互选择列表\n" +
+ "- /edit 打开需要焦点的多行编辑器\n" +
+ "- Esc 退出"),
+ new Text(),
+ new Box(statusText, textMeasurer) { Title = "状态" },
+ new Text(),
+ new Box(markdownPreview, textMeasurer) { Title = "Markdown 预览" },
+ new Text(),
+ new Box(loader, textMeasurer) { Title = "Loader" },
+ new Text(),
+ new Box(selectPreview, textMeasurer) { Title = "SelectList 预览" },
+ new Text(),
+ new Box(input, textMeasurer) { Title = "Input" },
+ new Text(),
+ new Box(eventsText, textMeasurer) { Title = "事件" });
runtime.Add(page);
runtime.SetFocus(input);
terminalOutput.ShowCursor();
+var loaderTask = RunLoaderAsync(loader, runtime, loaderCancellation.Token);
+
try
{
runtime.Start();
@@ -61,12 +101,35 @@ try
}
finally
{
+ StopLoader(loaderCancellation, loaderTask);
runtime.Stop();
terminalOutput.ShowCursor();
terminalOutput.Write("\r\n");
terminalOutput.Flush();
}
+static async Task RunLoaderAsync(Loader loader, ITuiRuntime runtime, CancellationToken cancellationToken)
+{
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ await Task.Delay(180, cancellationToken).ConfigureAwait(false);
+ loader.Tick();
+ runtime.RequestRender();
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+}
+
+static void StopLoader(CancellationTokenSource cancellation, Task loaderTask)
+{
+ cancellation.Cancel();
+ loaderTask.Wait(TimeSpan.FromSeconds(1));
+}
+
static void UpdateStatus(
Text statusText,
string inputValue,
@@ -89,19 +152,21 @@ static void AddEvent(Text eventsText, string message)
eventsText.Value = "最近事件:\n" + string.Join('\n', lines);
}
-///
-/// 用于手动验证 overlay 的帮助弹层
-///
+static void AddAll(Container container, params IComponent[] components)
+{
+ foreach (var component in components) container.Add(component);
+}
+
file sealed class HelpOverlay(ITuiRuntime runtime, ITextMeasurer textMeasurer) : IInputComponent
{
private readonly Box _box = new(
- new Text(
- "Overlay 示例\n" +
+ new Markdown(
+ "# Overlay 示例\n" +
"\n" +
- "这个弹层由 Runtime 显示\n" +
- "OverlayManager 会把它合成到基础页面上\n" +
- "显示时焦点会切到弹层\n" +
- "关闭后焦点会恢复到输入框\n" +
+ "- 这个弹层由 Runtime 显示\n" +
+ "- OverlayManager 会把它合成到基础页面上\n" +
+ "- 显示时焦点会切到弹层\n" +
+ "- 关闭后焦点会恢复到输入框\n" +
"\n" +
"按 Enter 或 Esc 关闭"),
textMeasurer)
@@ -109,13 +174,8 @@ file sealed class HelpOverlay(ITuiRuntime runtime, ITextMeasurer textMeasurer) :
Title = "Help",
};
- ///
- public IReadOnlyList Render(int width)
- {
- return _box.Render(width);
- }
+ public IReadOnlyList Render(int width) => _box.Render(width);
- ///
public void HandleInput(TuiInputEvent input)
{
if (input is { Kind: TuiInputEventKind.Key, Value: KeyNames.Enter or KeyNames.Escape })
@@ -124,3 +184,70 @@ file sealed class HelpOverlay(ITuiRuntime runtime, ITextMeasurer textMeasurer) :
}
}
}
+
+file sealed class SelectOverlay : IInputComponent
+{
+ private readonly ITuiRuntime _runtime;
+ private readonly Action _addEvent;
+ private readonly SelectList _selectList;
+ private readonly Box _box;
+
+ public SelectOverlay(ITuiRuntime runtime, Action addEvent, ITextMeasurer textMeasurer)
+ {
+ _runtime = runtime;
+ _addEvent = addEvent;
+ _selectList = new SelectList(textMeasurer) { Height = 5 };
+ _selectList.SetItems(["Text", "Input", "Editor", "SelectList", "Markdown", "Loader", "Overlay"]);
+ _selectList.OnSubmitted = (item, _) =>
+ {
+ _addEvent($"Selected: {item}");
+ _runtime.HideOverlay();
+ };
+ _selectList.OnCanceled = _runtime.HideOverlay;
+ _box = new Box(_selectList, textMeasurer) { Title = "SelectList" };
+ }
+
+ public IReadOnlyList Render(int width) => _box.Render(width);
+
+ public void HandleInput(TuiInputEvent input) => _selectList.HandleInput(input);
+}
+
+file sealed class EditorOverlay : IInputComponent
+{
+ private readonly ITuiRuntime _runtime;
+ private readonly Action _addEvent;
+ private readonly Editor _editor;
+ private readonly Box _box;
+
+ public EditorOverlay(ITuiRuntime runtime, Action addEvent, ITextMeasurer textMeasurer)
+ {
+ _runtime = runtime;
+ _addEvent = addEvent;
+ _editor = new Editor(textMeasurer)
+ {
+ Height = 6,
+ Value = "这里可以输入多行文本\nEnter 换行 Esc 提交并关闭",
+ OnCanceled = SubmitAndClose,
+ };
+ _box = new Box(_editor, textMeasurer) { Title = "Editor" };
+ }
+
+ public IReadOnlyList Render(int width) => _box.Render(width);
+
+ public void HandleInput(TuiInputEvent input)
+ {
+ if (input is { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape })
+ {
+ SubmitAndClose();
+ return;
+ }
+
+ _editor.HandleInput(input);
+ }
+
+ private void SubmitAndClose()
+ {
+ _addEvent($"Edited {Math.Max(1, _editor.Value.Split('\n').Length)} line(s)");
+ _runtime.HideOverlay();
+ }
+}
diff --git a/src/TinyTUI/Components/Editor.cs b/src/TinyTUI/Components/Editor.cs
new file mode 100644
index 0000000..4ebe989
--- /dev/null
+++ b/src/TinyTUI/Components/Editor.cs
@@ -0,0 +1,316 @@
+using TinyTUI.Input;
+using TinyTUI.Rendering;
+using TinyTUI.Text;
+
+namespace TinyTUI.Components;
+
+///
+/// 支持多行文本编辑的基础编辑器组件
+///
+public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent
+{
+ private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
+ private readonly List _lines = [string.Empty];
+
+ private int _cursorRow;
+ private int _cursorColumn;
+
+ ///
+ /// 获取或设置编辑器高度
+ ///
+ public int Height { get; set; } = 5;
+
+ ///
+ /// 获取当前编辑器文本
+ ///
+ public string Value
+ {
+ get => string.Join('\n', _lines);
+ set => SetValue(value);
+ }
+
+ ///
+ /// 在文本变化时触发
+ ///
+ public Action? OnChanged { get; set; }
+
+ ///
+ /// 在用户按 Esc 取消编辑时触发
+ ///
+ public Action? OnCanceled { get; set; }
+
+ ///
+ public IReadOnlyList Render(int width)
+ {
+ var visibleHeight = Math.Max(1, Height);
+ var firstRow = Math.Clamp(_cursorRow - visibleHeight + 1, 0, Math.Max(0, _lines.Count - visibleHeight));
+ var rows = new List(visibleHeight);
+
+ for (var offset = 0; offset < visibleHeight; offset++)
+ {
+ var lineIndex = firstRow + offset;
+ if (lineIndex >= _lines.Count)
+ {
+ rows.Add(string.Empty);
+ continue;
+ }
+
+ var line = _lines[lineIndex];
+ if (lineIndex == _cursorRow)
+ // Renderer 会提取这个 marker 并移动硬件光标 所以这里不渲染可见光标字符
+ line = InsertCursorMarker(line, _cursorColumn);
+
+ rows.Add(_textMeasurer.Truncate(line, width));
+ }
+
+ return rows;
+ }
+
+ ///
+ public void HandleInput(TuiInputEvent input)
+ {
+ switch (input)
+ {
+ case { Kind: TuiInputEventKind.Text }:
+ InsertText(input.Value);
+ break;
+ case { Kind: TuiInputEventKind.Paste }:
+ InsertText(input.Value.Replace("\r\n", "\n"));
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Enter }:
+ SplitLine();
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Backspace }:
+ Backspace();
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Delete }:
+ Delete();
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Left }:
+ MoveLeft();
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Right }:
+ MoveRight();
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Up }:
+ MoveVertical(-1);
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Down }:
+ MoveVertical(1);
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Home }:
+ _cursorColumn = 0;
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.End }:
+ _cursorColumn = GetRuneCount(_lines[_cursorRow]);
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape }:
+ OnCanceled?.Invoke();
+ break;
+ }
+ }
+
+ ///
+ /// 清空编辑器内容
+ ///
+ public void Clear() => SetValue(string.Empty);
+
+ ///
+ /// 重置编辑器文本并把光标约束到有效行列
+ ///
+ private void SetValue(string value)
+ {
+ _lines.Clear();
+ _lines.AddRange(value.Replace("\r\n", "\n").Split('\n'));
+
+ if (_lines.Count == 0)
+ _lines.Add(string.Empty);
+
+ _cursorRow = Math.Min(_cursorRow, _lines.Count - 1);
+ _cursorColumn = Math.Min(_cursorColumn, GetRuneCount(_lines[_cursorRow]));
+ OnChanged?.Invoke(Value);
+ }
+
+ ///
+ /// 在当前光标处插入文本并按换行拆分编辑器行
+ ///
+ private void InsertText(string value)
+ {
+ foreach (var rune in value.EnumerateRunes())
+ {
+ if (rune.Value == '\r') continue;
+
+ if (rune.Value == '\n')
+ {
+ SplitLine(false);
+ continue;
+ }
+
+ // 按 Rune 插入可以避免把 emoji 或代理对拆成无效 UTF-16 片段
+ InsertAtCursor(rune.ToString());
+ }
+
+ OnChanged?.Invoke(Value);
+ }
+
+ ///
+ /// 在当前行的光标列插入一段不包含换行的文本
+ ///
+ private void InsertAtCursor(string value)
+ {
+ var line = _lines[_cursorRow];
+ var index = GetStringIndex(line, _cursorColumn);
+ _lines[_cursorRow] = line.Insert(index, value);
+ _cursorColumn += GetRuneCount(value);
+ }
+
+ ///
+ /// 将当前行按光标位置拆成上下两行
+ ///
+ private void SplitLine(bool notify = true)
+ {
+ var line = _lines[_cursorRow];
+ var index = GetStringIndex(line, _cursorColumn);
+ _lines[_cursorRow] = line[..index];
+ _lines.Insert(_cursorRow + 1, line[index..]);
+ _cursorRow++;
+ _cursorColumn = 0;
+
+ if (notify)
+ OnChanged?.Invoke(Value);
+ }
+
+ ///
+ /// 删除光标左侧一个 Rune 或在行首合并到上一行
+ ///
+ private void Backspace()
+ {
+ if (_cursorColumn > 0)
+ {
+ var line = _lines[_cursorRow];
+ var start = GetStringIndex(line, _cursorColumn - 1);
+ var end = GetStringIndex(line, _cursorColumn);
+ _lines[_cursorRow] = line.Remove(start, end - start);
+ _cursorColumn--;
+ OnChanged?.Invoke(Value);
+ return;
+ }
+
+ if (_cursorRow == 0)
+ return;
+
+ var previousLength = GetRuneCount(_lines[_cursorRow - 1]);
+ // 行首退格符合常见编辑器行为 会把当前行拼接到上一行末尾
+ _lines[_cursorRow - 1] += _lines[_cursorRow];
+ _lines.RemoveAt(_cursorRow);
+ _cursorRow--;
+ _cursorColumn = previousLength;
+ OnChanged?.Invoke(Value);
+ }
+
+ ///
+ /// 删除光标右侧一个 Rune 或在行尾合并下一行
+ ///
+ private void Delete()
+ {
+ var line = _lines[_cursorRow];
+ if (_cursorColumn < GetRuneCount(line))
+ {
+ var start = GetStringIndex(line, _cursorColumn);
+ var end = GetStringIndex(line, _cursorColumn + 1);
+ _lines[_cursorRow] = line.Remove(start, end - start);
+ OnChanged?.Invoke(Value);
+ return;
+ }
+
+ if (_cursorRow >= _lines.Count - 1)
+ return;
+
+ // 行尾 Delete 与 Backspace 的反向跨行合并保持一致
+ _lines[_cursorRow] += _lines[_cursorRow + 1];
+ _lines.RemoveAt(_cursorRow + 1);
+ OnChanged?.Invoke(Value);
+ }
+
+ ///
+ /// 将光标向左移动并在行首跳到上一行末尾
+ ///
+ private void MoveLeft()
+ {
+ if (_cursorColumn > 0)
+ {
+ _cursorColumn--;
+ return;
+ }
+
+ if (_cursorRow == 0)
+ return;
+
+ _cursorRow--;
+ _cursorColumn = GetRuneCount(_lines[_cursorRow]);
+ }
+
+ ///
+ /// 将光标向右移动并在行尾跳到下一行开头
+ ///
+ private void MoveRight()
+ {
+ if (_cursorColumn < GetRuneCount(_lines[_cursorRow]))
+ {
+ _cursorColumn++;
+ return;
+ }
+
+ if (_cursorRow >= _lines.Count - 1)
+ return;
+
+ _cursorRow++;
+ _cursorColumn = 0;
+ }
+
+ ///
+ /// 垂直移动光标并把列约束到目标行长度内
+ ///
+ private void MoveVertical(int delta)
+ {
+ _cursorRow = Math.Clamp(_cursorRow + delta, 0, _lines.Count - 1);
+ _cursorColumn = Math.Min(_cursorColumn, GetRuneCount(_lines[_cursorRow]));
+ }
+
+ ///
+ /// 在指定光标列插入硬件光标 marker
+ ///
+ private static string InsertCursorMarker(string line, int cursorColumn)
+ {
+ var index = GetStringIndex(line, cursorColumn);
+ return line.Insert(index, CursorMarker.Marker);
+ }
+
+ ///
+ /// 获取字符串包含的 Unicode Rune 数量
+ ///
+ private static int GetRuneCount(string value) => value.EnumerateRunes().Count();
+
+ ///
+ /// 将 Rune 下标转换为 UTF-16 字符串下标
+ ///
+ private static int GetStringIndex(string value, int runeIndex)
+ {
+ if (runeIndex <= 0)
+ return 0;
+
+ var current = 0;
+ var index = 0;
+ foreach (var rune in value.EnumerateRunes())
+ {
+ if (current == runeIndex)
+ return index;
+
+ current++;
+ // string 的 Insert Remove 使用 UTF-16 下标 因此不能直接把 Rune 下标当 char 下标
+ index += rune.Utf16SequenceLength;
+ }
+
+ return value.Length;
+ }
+}
diff --git a/src/TinyTUI/Components/Loader.cs b/src/TinyTUI/Components/Loader.cs
new file mode 100644
index 0000000..2956700
--- /dev/null
+++ b/src/TinyTUI/Components/Loader.cs
@@ -0,0 +1,42 @@
+using TinyTUI.Text;
+
+namespace TinyTUI.Components;
+
+///
+/// 渲染加载状态和旋转帧的状态组件
+///
+public sealed class Loader(ITextMeasurer? textMeasurer = null) : IComponent
+{
+ private static readonly string[] DefaultFrames = ["-", "\\", "|", "/"];
+
+ private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
+ private int _frameIndex;
+
+ ///
+ /// 获取或设置加载提示文本
+ ///
+ public string Message { get; set; } = "Loading";
+
+ ///
+ /// 获取或设置加载动画帧
+ ///
+ public IReadOnlyList Frames { get; set; } = DefaultFrames;
+
+ ///
+ /// 推进到下一帧
+ ///
+ public void Tick()
+ {
+ if (Frames.Count == 0)
+ return;
+
+ _frameIndex = (_frameIndex + 1) % Frames.Count;
+ }
+
+ ///
+ public IReadOnlyList Render(int width)
+ {
+ var frame = Frames.Count == 0 ? string.Empty : Frames[_frameIndex % Frames.Count];
+ return [_textMeasurer.Truncate($"{frame} {Message}", width)];
+ }
+}
diff --git a/src/TinyTUI/Components/Markdown.cs b/src/TinyTUI/Components/Markdown.cs
new file mode 100644
index 0000000..6dc4da0
--- /dev/null
+++ b/src/TinyTUI/Components/Markdown.cs
@@ -0,0 +1,53 @@
+using TinyTUI.Text;
+
+namespace TinyTUI.Components;
+
+///
+/// 渲染轻量 Markdown 文本的展示组件
+///
+public sealed class Markdown(string value = "", ITextMeasurer? textMeasurer = null) : IComponent
+{
+ private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
+
+ ///
+ /// 获取或设置 Markdown 源文本
+ ///
+ public string Value { get; set; } = value;
+
+ ///
+ public IReadOnlyList Render(int width)
+ {
+ return Value.Replace("\r\n", "\n").Split('\n')
+ .Select(RenderLine)
+ .Select(line => _textMeasurer.Truncate(line, width)).ToList();
+ }
+
+ ///
+ /// 将单行 Markdown 转成终端友好的纯文本行
+ ///
+ private static string RenderLine(string line)
+ {
+ var trimmed = line.TrimStart();
+ var indent = line.Length - trimmed.Length;
+ var prefix = new string(' ', indent);
+
+ // 先保留缩进再转换块级标记 避免嵌套列表或引用失去视觉层级
+ if (trimmed.StartsWith("### ", StringComparison.Ordinal))
+ return prefix + trimmed[4..].ToUpperInvariant();
+
+ if (trimmed.StartsWith("## ", StringComparison.Ordinal))
+ return prefix + trimmed[3..].ToUpperInvariant();
+
+ if (trimmed.StartsWith("# ", StringComparison.Ordinal))
+ return prefix + trimmed[2..].ToUpperInvariant();
+
+ if (trimmed.StartsWith("- ", StringComparison.Ordinal))
+ return prefix + "* " + trimmed[2..];
+
+ if (trimmed.StartsWith("> ", StringComparison.Ordinal))
+ return prefix + "| " + trimmed[2..];
+
+ // 当前版本只做轻量展示 不引入完整 Markdown AST
+ return line.Replace("**", string.Empty).Replace("`", string.Empty);
+ }
+}
diff --git a/src/TinyTUI/Components/SelectList.cs b/src/TinyTUI/Components/SelectList.cs
new file mode 100644
index 0000000..c7a012d
--- /dev/null
+++ b/src/TinyTUI/Components/SelectList.cs
@@ -0,0 +1,106 @@
+using TinyTUI.Input;
+using TinyTUI.Text;
+
+namespace TinyTUI.Components;
+
+///
+/// 支持方向键选择和回车确认的列表组件
+///
+public sealed class SelectList(ITextMeasurer? textMeasurer = null) : IInputComponent
+{
+ private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
+ private readonly List _items = [];
+
+ ///
+ /// 获取或设置列表可见高度
+ ///
+ public int Height { get; set; } = 6;
+
+ ///
+ /// 获取当前选中项索引
+ ///
+ public int SelectedIndex { get; private set; }
+
+ ///
+ /// 获取当前选中项
+ ///
+ public string? SelectedItem => _items.Count == 0 ? null : _items[SelectedIndex];
+
+ ///
+ /// 在用户确认选中项时触发
+ ///
+ public Action? OnSubmitted { get; set; }
+
+ ///
+ /// 在用户取消选择时触发
+ ///
+ public Action? OnCanceled { get; set; }
+
+ ///
+ /// 替换列表选项
+ ///
+ public void SetItems(IEnumerable items)
+ {
+ _items.Clear();
+ _items.AddRange(items);
+ SelectedIndex = Math.Clamp(SelectedIndex, 0, Math.Max(0, _items.Count - 1));
+ }
+
+ ///
+ public IReadOnlyList Render(int width)
+ {
+ if (_items.Count == 0)
+ return ["(empty)"];
+
+ var visibleHeight = Math.Max(1, Height);
+ var first = Math.Clamp(SelectedIndex - visibleHeight + 1, 0, Math.Max(0, _items.Count - visibleHeight));
+ var lines = new List(visibleHeight);
+
+ for (var offset = 0; offset < visibleHeight && first + offset < _items.Count; offset++)
+ {
+ var index = first + offset;
+ var prefix = index == SelectedIndex ? "> " : " ";
+ // 列表项可能包含中文或 emoji 截断必须按终端显示宽度计算
+ lines.Add(_textMeasurer.Truncate(prefix + _items[index], width));
+ }
+
+ return lines;
+ }
+
+ ///
+ public void HandleInput(TuiInputEvent input)
+ {
+ switch (input)
+ {
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Up }:
+ Move(-1);
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Down }:
+ Move(1);
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Home }:
+ SelectedIndex = 0;
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.End }:
+ SelectedIndex = Math.Max(0, _items.Count - 1);
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Enter } when SelectedItem is not null:
+ OnSubmitted?.Invoke(SelectedItem, SelectedIndex);
+ break;
+ case { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape }:
+ OnCanceled?.Invoke();
+ break;
+ }
+ }
+
+ ///
+ /// 按给定偏移移动选中项并限制在列表范围内
+ ///
+ private void Move(int delta)
+ {
+ if (_items.Count == 0)
+ return;
+
+ SelectedIndex = Math.Clamp(SelectedIndex + delta, 0, _items.Count - 1);
+ }
+}