From a9297ba5cd03ac193e305829b02fc6920679977b Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 00:12:01 +0800 Subject: [PATCH] feat: add advanced components - add editor select list markdown and loader components - update example to preview advanced components and overlay interactions --- src/Example/Program.cs | 189 +++++++++++++--- src/TinyTUI/Components/Editor.cs | 316 +++++++++++++++++++++++++++ src/TinyTUI/Components/Loader.cs | 42 ++++ src/TinyTUI/Components/Markdown.cs | 53 +++++ src/TinyTUI/Components/SelectList.cs | 106 +++++++++ 5 files changed, 675 insertions(+), 31 deletions(-) create mode 100644 src/TinyTUI/Components/Editor.cs create mode 100644 src/TinyTUI/Components/Loader.cs create mode 100644 src/TinyTUI/Components/Markdown.cs create mode 100644 src/TinyTUI/Components/SelectList.cs 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); + } +}