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:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user