From 18e1f90f628678d23bec59542bf3ef6bc7ad7114 Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 01:01:28 +0800 Subject: [PATCH] feat: upgrade select list items - add SelectItem model with labels and descriptions - support filtering, wrap-around, scroll info, and selection change - demonstrate select list filtering in Example --- TODO.md | 26 ++-- src/Example/Program.cs | 42 +++++- src/TinyTUI/Components/SelectItem.cs | 12 ++ src/TinyTUI/Components/SelectList.cs | 203 ++++++++++++++++++++++++--- 4 files changed, 244 insertions(+), 39 deletions(-) create mode 100644 src/TinyTUI/Components/SelectItem.cs diff --git a/TODO.md b/TODO.md index 4da4b65..e6632d8 100644 --- a/TODO.md +++ b/TODO.md @@ -23,14 +23,15 @@ TinyTUI 现在已经不是空项目,已经具备一个最小可运行的 C# TU - 已扩展组合键解析,支持 `ctrl+x`、`alt+x`、`shift+tab`、`ctrl+left`、`ctrl+right` 这类按键名称 - 已实现可配置 Overlay 管理器,支持宽度、最小宽度、最大高度、anchor、row、column、margin、visible、non-capturing 和 overlay handle 焦点控制 - 已补充 Editor 的历史记录浏览、Ctrl+A/E、词级移动、词级删除、undo / redo -- 已更新 Example,启动后能直接验证 Loader、Input、overlay 定位、non-capturing overlay、选择列表和编辑器快捷键 +- 已补充 SelectList 的 item model、过滤、描述列、循环选择、滚动提示和 selection changed +- 已更新 Example,启动后能直接验证 Loader、Input、overlay 定位、non-capturing overlay、选择列表过滤和编辑器快捷键 ## 当前缺口 - 输入层还缺 Kitty keyboard protocol / modifyOtherKeys 这类高级键盘协议协商,组合键识别还不够完整 - Overlay 合成还比较简单,需要继续处理 ANSI/OSC 样式文本、宽字符边界、终端边缘覆盖和底层样式泄漏 - Editor 还缺提交事件和取消事件的清晰语义、kill ring、自动补全、粘贴摘要、软换行和更完整的光标布局 -- SelectList 目前只是字符串列表,还缺 value/label/description、过滤、描述列、滚动提示、wrap-around、选择变化事件和主题样式 +- SelectList 还缺主题样式、更多布局配置和更完整的键位策略 - Markdown 目前只是轻量纯文本转换,还缺真正 token 解析、行内样式、代码块、链接、引用块、列表缩进、文本换行和缓存 - Text Width 还需要补 grapheme cluster 级别处理,尤其是 ZWJ emoji、regional indicator、variation selector、tab、ANSI 包裹换行 - 渲染层还缺虚拟终端测试和复杂回归场景,比如 viewport 覆盖、短内容覆盖长内容、样式泄漏、宽字符边界 @@ -83,16 +84,13 @@ Editor 是后续交互体验的核心组件 ### 4. SelectList 升级 +状态:已完成 item model、filter、描述列、滚动提示、循环选择和选择变化事件 + SelectList 应该从简单字符串列表升级成可用于命令菜单 -- 引入 `SelectItem`,包含 `Value`、`Label`、`Description` -- 支持 filter -- 支持 selected index 外部设置 -- 支持 selection changed 回调 -- 支持描述列布局 -- 支持滚动窗口和滚动提示 -- 支持上下循环选择 -- 支持空结果展示 +- 继续补主题样式 +- 继续补更多布局配置 +- 继续补更完整的键位策略 - 参考 `tmp/tui/src/components/select-list.ts`、`tmp/tui/test/select-list.test.ts` ### 5. Markdown 升级 @@ -143,8 +141,8 @@ Markdown 暂时只做展示,不要急着做完整渲染器,但需要比当 ## 近期执行顺序 -1. 继续升级 `SelectList` 的 item model、filter 和描述列 -2. 继续补 Editor 的 kill ring、软换行和补全 overlay -3. 升级 Markdown 和 Text Width -4. 处理 Overlay 合成的 ANSI/OSC 样式文本、宽字符边界和底层样式泄漏 +1. 继续补 Editor 的 kill ring、软换行和补全 overlay +2. 升级 Markdown 和 Text Width +3. 处理 Overlay 合成的 ANSI/OSC 样式文本、宽字符边界和底层样式泄漏 +4. 继续完善 SelectList 主题样式和布局配置 5. 功能形态稳定后再引入测试项目 diff --git a/src/Example/Program.cs b/src/Example/Program.cs index e99bf57..c712d32 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -54,10 +54,11 @@ input.OnSubmitted = value => new SelectOverlay(runtime, message => AddEvent(eventsText, message), textMeasurer), new OverlayOptions { - Width = 34, + Width = OverlaySize.Percent(62), + MinWidth = 56, Anchor = OverlayAnchor.LeftCenter, Margin = new OverlayMargin(1), - MaxHeight = 9, + MaxHeight = 12, }); break; case "/edit": @@ -229,15 +230,42 @@ file sealed class SelectOverlay : IInputComponent { _runtime = runtime; _addEvent = addEvent; - _selectList = new SelectList(textMeasurer) { Height = 5 }; - _selectList.SetItems(["Text", "Input", "Editor", "SelectList", "Markdown", "Loader", "Overlay"]); - _selectList.OnSubmitted = (item, _) => + _selectList = new SelectList(textMeasurer) { - _addEvent($"Selected: {item}"); + Height = 6, + EmptyText = "没有匹配项", + }; + _selectList.SetItems( + [ + new SelectItem("text", "Text", "渲染纯文本和多行内容"), + new SelectItem("input", "Input", "单行输入 提交 取消和光标定位"), + new SelectItem("editor", "Editor", "多行编辑 历史记录 词级移动和撤销"), + new SelectItem("select-list", "SelectList", "过滤 描述列 循环选择和提交"), + new SelectItem("markdown", "Markdown", "轻量 Markdown 文本展示"), + new SelectItem("loader", "Loader", "自动 tick 的加载动画"), + new SelectItem("overlay", "Overlay", "定位 尺寸 焦点和 non-capturing 提示"), + ]); + _selectList.OnSelectionChanged = (item, _) => _addEvent($"Selection: {item.Label}"); + _selectList.OnItemSubmitted = (item, _) => + { + _addEvent($"Selected: {item.Label} ({item.Value})"); _runtime.HideOverlay(); }; _selectList.OnCanceled = _runtime.HideOverlay; - _box = new Box(_selectList, textMeasurer) { Title = "SelectList" }; + _box = new Box( + new Container + { + Children = + { + new Text("输入文本过滤 Backspace 删除过滤 Up/Down 循环选择 Enter 提交"), + new Text(), + _selectList, + }, + }, + textMeasurer) + { + Title = "SelectList", + }; } public IReadOnlyList Render(int width) => _box.Render(width); diff --git a/src/TinyTUI/Components/SelectItem.cs b/src/TinyTUI/Components/SelectItem.cs new file mode 100644 index 0000000..f71a39d --- /dev/null +++ b/src/TinyTUI/Components/SelectItem.cs @@ -0,0 +1,12 @@ +namespace TinyTUI.Components; + +/// +/// 表示 SelectList 中可显示和提交的选项 +/// +public sealed record SelectItem(string Value, string Label, string? Description = null) +{ + /// + /// 使用同一段文本创建值和标签一致的选项 + /// + public static SelectItem FromText(string text) => new(text, text); +} diff --git a/src/TinyTUI/Components/SelectList.cs b/src/TinyTUI/Components/SelectList.cs index c7a012d..a5ce43c 100644 --- a/src/TinyTUI/Components/SelectList.cs +++ b/src/TinyTUI/Components/SelectList.cs @@ -1,4 +1,5 @@ using TinyTUI.Input; +using TinyTUI.Rendering; using TinyTUI.Text; namespace TinyTUI.Components; @@ -8,8 +9,14 @@ namespace TinyTUI.Components; /// public sealed class SelectList(ITextMeasurer? textMeasurer = null) : IInputComponent { + private const int PrimaryColumnGap = 2; + private const int MinDescriptionWidth = 10; + private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); - private readonly List _items = []; + private readonly List _items = []; + private readonly List _filteredItems = []; + + private string _filter = string.Empty; /// /// 获取或设置列表可见高度 @@ -22,15 +29,45 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null) : IInputCompo public int SelectedIndex { get; private set; } /// - /// 获取当前选中项 + /// 获取当前选中项文本 /// - public string? SelectedItem => _items.Count == 0 ? null : _items[SelectedIndex]; + public string? SelectedItem => SelectedItemModel?.Value; + + /// + /// 获取当前选中项模型 + /// + public SelectItem? SelectedItemModel => _filteredItems.Count == 0 ? null : _filteredItems[SelectedIndex]; + + /// + /// 获取或设置是否允许上下移动时首尾循环 + /// + public bool WrapAround { get; set; } = true; + + /// + /// 获取或设置无匹配结果时显示的文本 + /// + public string EmptyText { get; set; } = "No matching items"; + + /// + /// 获取或设置描述列最小可见宽度 + /// + public int DescriptionMinWidth { get; set; } = MinDescriptionWidth; /// /// 在用户确认选中项时触发 /// public Action? OnSubmitted { get; set; } + /// + /// 在用户确认选中项时触发并传出完整选项模型 + /// + public Action? OnItemSubmitted { get; set; } + + /// + /// 在选中项变化时触发 + /// + public Action? OnSelectionChanged { get; set; } + /// /// 在用户取消选择时触发 /// @@ -40,30 +77,65 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null) : IInputCompo /// 替换列表选项 /// public void SetItems(IEnumerable items) + { + SetItems(items.Select(SelectItem.FromText)); + } + + /// + /// 替换列表选项模型 + /// + public void SetItems(IEnumerable items) { _items.Clear(); _items.AddRange(items); - SelectedIndex = Math.Clamp(SelectedIndex, 0, Math.Max(0, _items.Count - 1)); + ApplyFilter(resetSelection: true); + } + + /// + /// 设置过滤文本并重置选择位置 + /// + public void SetFilter(string filter) + { + _filter = filter; + ApplyFilter(resetSelection: true); + } + + /// + /// 外部设置当前选中项索引 + /// + public void SetSelectedIndex(int index) + { + SelectedIndex = Math.Clamp(index, 0, Math.Max(0, _filteredItems.Count - 1)); + NotifySelectionChanged(); } /// public IReadOnlyList Render(int width) { - if (_items.Count == 0) - return ["(empty)"]; + var lines = new List(); + + lines.Add(_textMeasurer.Truncate($"Filter: {_filter}{CursorMarker.Marker}", width)); + + if (_filteredItems.Count == 0) + { + lines.Add(_textMeasurer.Truncate($" {EmptyText}", width)); + return lines; + } 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); + var first = Math.Clamp(SelectedIndex - visibleHeight / 2, 0, Math.Max(0, _filteredItems.Count - visibleHeight)); + var primaryWidth = ResolvePrimaryColumnWidth(width); - for (var offset = 0; offset < visibleHeight && first + offset < _items.Count; offset++) + for (var offset = 0; offset < visibleHeight && first + offset < _filteredItems.Count; offset++) { var index = first + offset; - var prefix = index == SelectedIndex ? "> " : " "; - // 列表项可能包含中文或 emoji 截断必须按终端显示宽度计算 - lines.Add(_textMeasurer.Truncate(prefix + _items[index], width)); + lines.Add(RenderItem(_filteredItems[index], index == SelectedIndex, width, primaryWidth)); } + var end = Math.Min(first + visibleHeight, _filteredItems.Count); + if (first > 0 || end < _filteredItems.Count) + lines.Add(_textMeasurer.Truncate($" ({SelectedIndex + 1}/{_filteredItems.Count})", width)); + return lines; } @@ -79,17 +151,23 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null) : IInputCompo Move(1); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Home }: - SelectedIndex = 0; + SetSelectedIndex(0); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.End }: - SelectedIndex = Math.Max(0, _items.Count - 1); + SetSelectedIndex(Math.Max(0, _filteredItems.Count - 1)); break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.Enter } when SelectedItem is not null: - OnSubmitted?.Invoke(SelectedItem, SelectedIndex); + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Enter }: + Submit(); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Backspace } when _filter.Length > 0: + SetFilter(_filter[..^1]); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape }: OnCanceled?.Invoke(); break; + case { Kind: TuiInputEventKind.Text }: + SetFilter(_filter + input.Value); + break; } } @@ -98,9 +176,98 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null) : IInputCompo /// private void Move(int delta) { - if (_items.Count == 0) + if (_filteredItems.Count == 0) return; - SelectedIndex = Math.Clamp(SelectedIndex + delta, 0, _items.Count - 1); + var next = SelectedIndex + delta; + if (WrapAround) + next = (next + _filteredItems.Count) % _filteredItems.Count; + + SetSelectedIndex(Math.Clamp(next, 0, _filteredItems.Count - 1)); } + + /// + /// 根据当前过滤文本刷新可见项 + /// + private void ApplyFilter(bool resetSelection) + { + _filteredItems.Clear(); + _filteredItems.AddRange(_items.Where(MatchesFilter)); + SelectedIndex = resetSelection ? 0 : Math.Clamp(SelectedIndex, 0, Math.Max(0, _filteredItems.Count - 1)); + NotifySelectionChanged(); + } + + /// + /// 判断选项是否匹配过滤文本 + /// + private bool MatchesFilter(SelectItem item) + { + if (_filter.Length == 0) + return true; + + return item.Value.Contains(_filter, StringComparison.OrdinalIgnoreCase) + || item.Label.Contains(_filter, StringComparison.OrdinalIgnoreCase) + || (item.Description?.Contains(_filter, StringComparison.OrdinalIgnoreCase) ?? false); + } + + /// + /// 渲染单个选项并在空间足够时展示描述列 + /// + private string RenderItem(SelectItem item, bool selected, int width, int primaryWidth) + { + var prefix = selected ? "> " : " "; + var label = NormalizeSingleLine(item.Label.Length == 0 ? item.Value : item.Label); + var description = NormalizeSingleLine(item.Description ?? string.Empty); + var prefixWidth = _textMeasurer.GetWidth(prefix); + + if (description.Length > 0 && width > 40) + { + var effectivePrimaryWidth = Math.Clamp(primaryWidth, 1, Math.Max(1, width - prefixWidth - 4)); + var labelWidth = Math.Max(1, effectivePrimaryWidth - PrimaryColumnGap); + var renderedLabel = _textMeasurer.Truncate(label, labelWidth); + var spacing = new string(' ', Math.Max(1, effectivePrimaryWidth - _textMeasurer.GetWidth(renderedLabel))); + var remainingWidth = width - prefixWidth - _textMeasurer.GetWidth(renderedLabel) - spacing.Length; + + if (remainingWidth >= Math.Max(1, DescriptionMinWidth)) + return _textMeasurer.Truncate(prefix + renderedLabel + spacing + description, width); + } + + return _textMeasurer.Truncate(prefix + label, width); + } + + /// + /// 根据当前选项计算主列宽度 + /// + private int ResolvePrimaryColumnWidth(int width) + { + var maxLabelWidth = _filteredItems + .Select(item => _textMeasurer.GetWidth(NormalizeSingleLine(item.Label.Length == 0 ? item.Value : item.Label))) + .DefaultIfEmpty(1) + .Max(); + + return Math.Clamp(maxLabelWidth + PrimaryColumnGap, 1, Math.Max(1, width / 2)); + } + + /// + /// 提交当前选项 + /// + private void Submit() + { + if (SelectedItemModel is not { } item) + return; + + OnItemSubmitted?.Invoke(item, SelectedIndex); + OnSubmitted?.Invoke(item.Value, SelectedIndex); + } + + /// + /// 通知外部当前选择变化 + /// + private void NotifySelectionChanged() + { + if (SelectedItemModel is { } item) + OnSelectionChanged?.Invoke(item, SelectedIndex); + } + + private static string NormalizeSingleLine(string value) => value.Replace("\r\n", " ").Replace('\n', ' ').Trim(); }