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
This commit is contained in:
chuan
2026-06-04 01:01:28 +08:00
Unverified
parent ea8a5b08d7
commit 18e1f90f62
4 changed files with 244 additions and 39 deletions
+12 -14
View File
@@ -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. 功能形态稳定后再引入测试项目
+35 -7
View File
@@ -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<string> Render(int width) => _box.Render(width);
+12
View File
@@ -0,0 +1,12 @@
namespace TinyTUI.Components;
/// <summary>
/// 表示 SelectList 中可显示和提交的选项
/// </summary>
public sealed record SelectItem(string Value, string Label, string? Description = null)
{
/// <summary>
/// 使用同一段文本创建值和标签一致的选项
/// </summary>
public static SelectItem FromText(string text) => new(text, text);
}
+185 -18
View File
@@ -1,4 +1,5 @@
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Text;
namespace TinyTUI.Components;
@@ -8,8 +9,14 @@ namespace TinyTUI.Components;
/// </summary>
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<string> _items = [];
private readonly List<SelectItem> _items = [];
private readonly List<SelectItem> _filteredItems = [];
private string _filter = string.Empty;
/// <summary>
/// 获取或设置列表可见高度
@@ -22,15 +29,45 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null) : IInputCompo
public int SelectedIndex { get; private set; }
/// <summary>
/// 获取当前选中项
/// 获取当前选中项文本
/// </summary>
public string? SelectedItem => _items.Count == 0 ? null : _items[SelectedIndex];
public string? SelectedItem => SelectedItemModel?.Value;
/// <summary>
/// 获取当前选中项模型
/// </summary>
public SelectItem? SelectedItemModel => _filteredItems.Count == 0 ? null : _filteredItems[SelectedIndex];
/// <summary>
/// 获取或设置是否允许上下移动时首尾循环
/// </summary>
public bool WrapAround { get; set; } = true;
/// <summary>
/// 获取或设置无匹配结果时显示的文本
/// </summary>
public string EmptyText { get; set; } = "No matching items";
/// <summary>
/// 获取或设置描述列最小可见宽度
/// </summary>
public int DescriptionMinWidth { get; set; } = MinDescriptionWidth;
/// <summary>
/// 在用户确认选中项时触发
/// </summary>
public Action<string, int>? OnSubmitted { get; set; }
/// <summary>
/// 在用户确认选中项时触发并传出完整选项模型
/// </summary>
public Action<SelectItem, int>? OnItemSubmitted { get; set; }
/// <summary>
/// 在选中项变化时触发
/// </summary>
public Action<SelectItem, int>? OnSelectionChanged { get; set; }
/// <summary>
/// 在用户取消选择时触发
/// </summary>
@@ -40,30 +77,65 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null) : IInputCompo
/// 替换列表选项
/// </summary>
public void SetItems(IEnumerable<string> items)
{
SetItems(items.Select(SelectItem.FromText));
}
/// <summary>
/// 替换列表选项模型
/// </summary>
public void SetItems(IEnumerable<SelectItem> items)
{
_items.Clear();
_items.AddRange(items);
SelectedIndex = Math.Clamp(SelectedIndex, 0, Math.Max(0, _items.Count - 1));
ApplyFilter(resetSelection: true);
}
/// <summary>
/// 设置过滤文本并重置选择位置
/// </summary>
public void SetFilter(string filter)
{
_filter = filter;
ApplyFilter(resetSelection: true);
}
/// <summary>
/// 外部设置当前选中项索引
/// </summary>
public void SetSelectedIndex(int index)
{
SelectedIndex = Math.Clamp(index, 0, Math.Max(0, _filteredItems.Count - 1));
NotifySelectionChanged();
}
/// <inheritdoc />
public IReadOnlyList<string> Render(int width)
{
if (_items.Count == 0)
return ["(empty)"];
var lines = new List<string>();
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<string>(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
/// </summary>
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));
}
/// <summary>
/// 根据当前过滤文本刷新可见项
/// </summary>
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();
}
/// <summary>
/// 判断选项是否匹配过滤文本
/// </summary>
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);
}
/// <summary>
/// 渲染单个选项并在空间足够时展示描述列
/// </summary>
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);
}
/// <summary>
/// 根据当前选项计算主列宽度
/// </summary>
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));
}
/// <summary>
/// 提交当前选项
/// </summary>
private void Submit()
{
if (SelectedItemModel is not { } item)
return;
OnItemSubmitted?.Invoke(item, SelectedIndex);
OnSubmitted?.Invoke(item.Value, SelectedIndex);
}
/// <summary>
/// 通知外部当前选择变化
/// </summary>
private void NotifySelectionChanged()
{
if (SelectedItemModel is { } item)
OnSelectionChanged?.Invoke(item, SelectedIndex);
}
private static string NormalizeSingleLine(string value) => value.Replace("\r\n", " ").Replace('\n', ' ').Trim();
}