diff --git a/TODO.md b/TODO.md index 318dc46..72e22d7 100644 --- a/TODO.md +++ b/TODO.md @@ -269,6 +269,38 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入 参考:`tmp/tui/src/autocomplete.ts`、`tmp/tui/src/components/editor.ts` +本次增量已完成: + +- 新增 `TinyTUI.Autocomplete` 模块,定义 `IAutocompleteProvider`、`AutocompleteRequest`、`AutocompleteSuggestions`、`AutocompleteApplyContext` 和 `AutocompleteApplyResult`,先把“查询候选”和“应用候选”从 Editor 中拆成可复用契约 +- 新增 `AutocompleteProviderBase`,提供默认的前缀替换实现,后续文件路径、变量、命令参数等 provider 可以只覆盖查询或特殊应用逻辑 +- 新增 `SlashCommandAutocompleteProvider` 和 `SlashCommand`,支持行首 `/` 命令名候选,确认后自动写回 `/{command} ` 并把光标放到参数位置 +- `Editor` 增加 `AutocompleteProvider`、`AutocompleteList`、`IsAutocompleteActive`、`StartAutocomplete`、`ConfirmAutocomplete`、`CancelAutocomplete` 和 `RenderAutocomplete`,补全列表复用现有 `SelectList` 的选择、滚动和渲染能力 +- 新增 `tui.autocomplete.trigger`、`tui.autocomplete.confirm`、`tui.autocomplete.cancel` 默认动作,Tab 触发或确认补全,Enter 在补全激活时确认,Escape 取消补全 +- 扩展 `TinyTUI.ComponentChecks` 覆盖 slash command 候选渲染、方向键切换候选、Tab 确认应用和 Escape 取消 + +这样设计的原因: + +- provider 契约返回 `items + prefix`,与 `tmp/tui/src/autocomplete.ts` 的核心模型一致,Editor 不需要知道候选来自命令、路径还是特殊前缀 +- 应用候选独立成 `ApplyCompletion`,是为了保留 slash command 自动补空格、路径目录不补空格、引号去重等后续差异,不把这些规则硬塞进 Editor +- Editor 内部持有 `SelectList`,先把补全选择和确认流程跑通,后续 overlay 只需要挂载 `RenderAutocomplete` 或直接挂载同一个列表组件 +- 补全确认通过现有 `Edit` 包装写回文本,所以撤销栈、redo 清理和 `OnChanged` 行为与普通编辑保持一致 + +怎么看懂当前代码: + +- 先看 `Autocomplete/IAutocompleteProvider.cs`,它定义自动补全框架的两个关键动作:查询候选和应用候选 +- 再看 `Autocomplete/SlashCommandAutocompleteProvider.cs`,它展示了一个最小 provider 如何根据当前行和光标返回候选,以及如何把候选写回文本 +- 最后看 `Components/Editor/Editor.Autocomplete.cs`,这里是 Editor 的补全状态机:启动查询、用 `SelectList` 展示候选、确认应用、取消清理 +- `Components/Editor/Editor.cs` 的 `HandleInput` 入口可以看到补全输入优先级:活动补全先消费导航和确认,普通 Tab 强制触发补全,普通文本输入后尝试自然补全 + +对比 `tmp/tui` 后续仍需补齐: + +- C# 侧 provider 契约已用 `ValueTask` 支持异步形态,但 Editor 当前 `HandleInput` 仍是同步接口,会阻塞等待 provider;后续需要 Runtime 级 invalidate 和取消令牌来实现真正非阻塞异步补全 +- 还没有移植文件路径 provider、`@` 附件前缀、引号路径、`~/` 展开、目录优先排序和 fd fuzzy 搜索 +- 还没有实现 slash command 参数补全,参考实现里的 `getArgumentCompletions` 需要在 C# 侧扩展到 `SlashCommand` +- 还没有把补全列表通过 `OverlayManager` 自动挂到光标附近,目前只提供 `RenderAutocomplete` 和内部 `SelectList` 供后续 overlay 接线 +- 还没有做补全预览文本、候选变化 debounce、过期请求丢弃和 AbortController 等请求竞争处理 +- 当前默认前缀替换基类按字符串长度处理 prefix,后续支持宽字符或跨行补全时需要把 provider 光标协议统一成 Rune 列或专门的文本范围类型 + ### 8. 图像和特殊终端内容 目标:为 Kitty / iTerm2 图像和特殊终端序列预留完整框架入口 diff --git a/src/TinyTUI/Autocomplete/AutocompleteApplyContext.cs b/src/TinyTUI/Autocomplete/AutocompleteApplyContext.cs new file mode 100644 index 0000000..65bf02b --- /dev/null +++ b/src/TinyTUI/Autocomplete/AutocompleteApplyContext.cs @@ -0,0 +1,11 @@ +namespace TinyTUI.Autocomplete; + +/// +/// 描述应用一个补全候选时的编辑器上下文 +/// +public sealed record AutocompleteApplyContext( + IReadOnlyList Lines, + int CursorLine, + int CursorColumn, + AutocompleteItem Item, + string Prefix); diff --git a/src/TinyTUI/Autocomplete/AutocompleteApplyResult.cs b/src/TinyTUI/Autocomplete/AutocompleteApplyResult.cs new file mode 100644 index 0000000..f0db0b9 --- /dev/null +++ b/src/TinyTUI/Autocomplete/AutocompleteApplyResult.cs @@ -0,0 +1,6 @@ +namespace TinyTUI.Autocomplete; + +/// +/// 表示补全候选应用后的编辑器文本和光标位置 +/// +public sealed record AutocompleteApplyResult(IReadOnlyList Lines, int CursorLine, int CursorColumn); diff --git a/src/TinyTUI/Autocomplete/AutocompleteItem.cs b/src/TinyTUI/Autocomplete/AutocompleteItem.cs new file mode 100644 index 0000000..a2f49c8 --- /dev/null +++ b/src/TinyTUI/Autocomplete/AutocompleteItem.cs @@ -0,0 +1,12 @@ +namespace TinyTUI.Autocomplete; + +/// +/// 表示自动补全列表中的一个候选项 +/// +public sealed record AutocompleteItem(string Value, string Label, string? Description = null) +{ + /// + /// 使用同一段文本创建值和标签一致的候选项 + /// + public static AutocompleteItem FromText(string text) => new(text, text); +} diff --git a/src/TinyTUI/Autocomplete/AutocompleteProviderBase.cs b/src/TinyTUI/Autocomplete/AutocompleteProviderBase.cs new file mode 100644 index 0000000..5fdbf99 --- /dev/null +++ b/src/TinyTUI/Autocomplete/AutocompleteProviderBase.cs @@ -0,0 +1,28 @@ +namespace TinyTUI.Autocomplete; + +/// +/// 提供通用前缀替换逻辑的自动补全 provider 基类 +/// +public abstract class AutocompleteProviderBase : IAutocompleteProvider +{ + /// + public abstract ValueTask GetSuggestionsAsync(AutocompleteRequest request); + + /// + public virtual AutocompleteApplyResult ApplyCompletion(AutocompleteApplyContext context) + { + var lines = context.Lines.ToArray(); + if (context.CursorLine < 0 || context.CursorLine >= lines.Length) + return new AutocompleteApplyResult(lines, context.CursorLine, context.CursorColumn); + + var line = lines[context.CursorLine]; + var prefixStart = Math.Clamp(context.CursorColumn - context.Prefix.Length, 0, line.Length); + var cursor = Math.Clamp(context.CursorColumn, 0, line.Length); + lines[context.CursorLine] = line[..prefixStart] + context.Item.Value + line[cursor..]; + + return new AutocompleteApplyResult( + lines, + context.CursorLine, + prefixStart + context.Item.Value.Length); + } +} diff --git a/src/TinyTUI/Autocomplete/AutocompleteRequest.cs b/src/TinyTUI/Autocomplete/AutocompleteRequest.cs new file mode 100644 index 0000000..ba87112 --- /dev/null +++ b/src/TinyTUI/Autocomplete/AutocompleteRequest.cs @@ -0,0 +1,11 @@ +namespace TinyTUI.Autocomplete; + +/// +/// 描述一次自动补全查询所需的编辑器上下文 +/// +public sealed record AutocompleteRequest( + IReadOnlyList Lines, + int CursorLine, + int CursorColumn, + bool Force, + CancellationToken CancellationToken = default); diff --git a/src/TinyTUI/Autocomplete/AutocompleteSuggestions.cs b/src/TinyTUI/Autocomplete/AutocompleteSuggestions.cs new file mode 100644 index 0000000..084eb69 --- /dev/null +++ b/src/TinyTUI/Autocomplete/AutocompleteSuggestions.cs @@ -0,0 +1,6 @@ +namespace TinyTUI.Autocomplete; + +/// +/// 表示 provider 返回的自动补全候选和需要替换的前缀 +/// +public sealed record AutocompleteSuggestions(IReadOnlyList Items, string Prefix); diff --git a/src/TinyTUI/Autocomplete/IAutocompleteProvider.cs b/src/TinyTUI/Autocomplete/IAutocompleteProvider.cs new file mode 100644 index 0000000..4e2f22d --- /dev/null +++ b/src/TinyTUI/Autocomplete/IAutocompleteProvider.cs @@ -0,0 +1,17 @@ +namespace TinyTUI.Autocomplete; + +/// +/// 为编辑器或命令输入提供自动补全候选 +/// +public interface IAutocompleteProvider +{ + /// + /// 获取当前光标位置可用的候选项 + /// + ValueTask GetSuggestionsAsync(AutocompleteRequest request); + + /// + /// 将用户选中的候选项应用到文本模型 + /// + AutocompleteApplyResult ApplyCompletion(AutocompleteApplyContext context); +} diff --git a/src/TinyTUI/Autocomplete/SlashCommand.cs b/src/TinyTUI/Autocomplete/SlashCommand.cs new file mode 100644 index 0000000..e82f540 --- /dev/null +++ b/src/TinyTUI/Autocomplete/SlashCommand.cs @@ -0,0 +1,6 @@ +namespace TinyTUI.Autocomplete; + +/// +/// 描述一个可由斜杠命令补全的命令 +/// +public sealed record SlashCommand(string Name, string? Description = null, string? ArgumentHint = null); diff --git a/src/TinyTUI/Autocomplete/SlashCommandAutocompleteProvider.cs b/src/TinyTUI/Autocomplete/SlashCommandAutocompleteProvider.cs new file mode 100644 index 0000000..278b5b3 --- /dev/null +++ b/src/TinyTUI/Autocomplete/SlashCommandAutocompleteProvider.cs @@ -0,0 +1,66 @@ +namespace TinyTUI.Autocomplete; + +/// +/// 为行首斜杠命令提供候选项 +/// +public sealed class SlashCommandAutocompleteProvider(IEnumerable commands) : AutocompleteProviderBase +{ + private readonly IReadOnlyList _commands = [.. commands]; + + /// + public override ValueTask GetSuggestionsAsync(AutocompleteRequest request) + { + if (request.CursorLine < 0 || request.CursorLine >= request.Lines.Count) + return ValueTask.FromResult(null); + + var line = request.Lines[request.CursorLine]; + var cursor = Math.Clamp(request.CursorColumn, 0, line.Length); + var beforeCursor = line[..cursor]; + if (!beforeCursor.StartsWith('/')) + return ValueTask.FromResult(null); + + // 当前增量只处理命令名补全 命令参数补全后续由组合 provider 扩展 + if (beforeCursor.Contains(' ', StringComparison.Ordinal)) + return ValueTask.FromResult(null); + + var query = beforeCursor[1..]; + var items = _commands + .Where(command => command.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) + .OrderBy(command => command.Name.StartsWith(query, StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(command => command.Name, StringComparer.OrdinalIgnoreCase) + .Select(command => new AutocompleteItem(command.Name, command.Name, BuildDescription(command))) + .ToArray(); + + return ValueTask.FromResult( + items.Length == 0 ? null : new AutocompleteSuggestions(items, beforeCursor)); + } + + /// + public override AutocompleteApplyResult ApplyCompletion(AutocompleteApplyContext context) + { + var lines = context.Lines.ToArray(); + if (context.CursorLine < 0 || context.CursorLine >= lines.Length) + return new AutocompleteApplyResult(lines, context.CursorLine, context.CursorColumn); + + var line = lines[context.CursorLine]; + var prefixStart = Math.Clamp(context.CursorColumn - context.Prefix.Length, 0, line.Length); + var cursor = Math.Clamp(context.CursorColumn, 0, line.Length); + var replacement = "/" + context.Item.Value + " "; + lines[context.CursorLine] = line[..prefixStart] + replacement + line[cursor..]; + + return new AutocompleteApplyResult(lines, context.CursorLine, prefixStart + replacement.Length); + } + + /// + /// 合并参数提示和描述用于 SelectList 的说明列 + /// + private static string? BuildDescription(SlashCommand command) + { + if (command.ArgumentHint is null) + return command.Description; + + return command.Description is null + ? command.ArgumentHint + : $"{command.ArgumentHint} - {command.Description}"; + } +} diff --git a/src/TinyTUI/Components/Editor/Editor.Autocomplete.cs b/src/TinyTUI/Components/Editor/Editor.Autocomplete.cs new file mode 100644 index 0000000..dc4a4d8 --- /dev/null +++ b/src/TinyTUI/Components/Editor/Editor.Autocomplete.cs @@ -0,0 +1,125 @@ +using TinyTUI.Autocomplete; +using TinyTUI.Input; + +namespace TinyTUI.Components; + +public sealed partial class Editor +{ + /// + /// 获取或设置当前编辑器使用的自动补全 provider + /// + public IAutocompleteProvider? AutocompleteProvider { get; set; } + + /// + /// 获取当前是否正在显示补全候选 + /// + public bool IsAutocompleteActive => _autocompleteSuggestions is not null; + + private AutocompleteSuggestions? _autocompleteSuggestions; + + /// + /// 渲染当前补全候选列表供 overlay 或外部布局挂载 + /// + public IReadOnlyList RenderAutocomplete(int width) + => IsAutocompleteActive ? AutocompleteList.Render(width) : []; + + /// + /// 触发补全查询并刷新内部 SelectList + /// + public bool StartAutocomplete(bool force = false) + { + if (AutocompleteProvider is not { } provider) + return false; + + var request = new AutocompleteRequest([.. _lines], _cursorRow, _cursorColumn, force); + var suggestions = provider.GetSuggestionsAsync(request).AsTask().GetAwaiter().GetResult(); + if (suggestions is null || suggestions.Items.Count == 0) + { + CancelAutocomplete(); + return false; + } + + _autocompleteSuggestions = suggestions; + AutocompleteList.SetItems(suggestions.Items.Select(static item => new SelectItem(item.Value, item.Label, item.Description))); + AutocompleteList.SetFilter(string.Empty); + AutocompleteList.SetSelectedIndex(0); + return true; + } + + /// + /// 取消当前补全状态 + /// + public void CancelAutocomplete() + { + _autocompleteSuggestions = null; + AutocompleteList.SetItems(Array.Empty()); + } + + /// + /// 将当前选中的补全候选应用到编辑器文本 + /// + public bool ConfirmAutocomplete() + { + if (_autocompleteSuggestions is not { } suggestions || + AutocompleteProvider is not { } provider || + AutocompleteList.SelectedItemModel is not { } selected) + { + return false; + } + + var item = new AutocompleteItem(selected.Value, selected.Label, selected.Description); + var context = new AutocompleteApplyContext([.. _lines], _cursorRow, _cursorColumn, item, suggestions.Prefix); + + Edit(() => + { + var result = provider.ApplyCompletion(context); + ApplyAutocompleteResult(result); + }); + + CancelAutocomplete(); + return true; + } + + /// + /// 把补全结果写回编辑器内部文本模型并约束光标位置 + /// + private void ApplyAutocompleteResult(AutocompleteApplyResult result) + { + _lines.Clear(); + _lines.AddRange(result.Lines.Count == 0 ? [string.Empty] : result.Lines); + + _cursorRow = Math.Clamp(result.CursorLine, 0, _lines.Count - 1); + _cursorColumn = Math.Clamp(result.CursorColumn, 0, GetRuneCount(_lines[_cursorRow])); + _preferredVisualColumn = null; + } + + /// + /// 活动补全优先消费选择和确认相关快捷键 + /// + private bool TryHandleAutocompleteInput(TuiInputEvent input) + { + if (!IsAutocompleteActive) + return false; + + if (_keybindings.Matches(input, TuiKeybindings.AutocompleteCancel)) + { + CancelAutocomplete(); + return true; + } + + if (_keybindings.Matches(input, TuiKeybindings.AutocompleteConfirm)) + return ConfirmAutocomplete(); + + // 候选列表继续复用 SelectList 的导航逻辑 保持滚动和选择事件一致 + if (_keybindings.Matches(input, TuiKeybindings.SelectUp) || + _keybindings.Matches(input, TuiKeybindings.SelectDown) || + _keybindings.Matches(input, TuiKeybindings.SelectHome) || + _keybindings.Matches(input, TuiKeybindings.SelectEnd)) + { + AutocompleteList.HandleInput(input); + return true; + } + + return false; + } +} diff --git a/src/TinyTUI/Components/Editor/Editor.cs b/src/TinyTUI/Components/Editor/Editor.cs index 0c481bc..b206520 100644 --- a/src/TinyTUI/Components/Editor/Editor.cs +++ b/src/TinyTUI/Components/Editor/Editor.cs @@ -6,10 +6,10 @@ namespace TinyTUI.Components; /// /// 支持多行文本编辑的基础编辑器组件 /// -public sealed partial class Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent, IFocusableComponent +public sealed partial class Editor : IInputComponent, IFocusableComponent { - private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); - private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); + private readonly ITextMeasurer _textMeasurer; + private readonly KeybindingRegistry _keybindings; private readonly List _lines = [string.Empty]; private readonly Stack _undoStack = []; private readonly Stack _redoStack = []; @@ -23,6 +23,20 @@ public sealed partial class Editor(ITextMeasurer? textMeasurer = null, Keybindin private int _scrollOffset; private int? _preferredVisualColumn; + /// + /// 创建多行编辑器组件 + /// + public Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) + { + _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); + _keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); + AutocompleteList = new SelectList(_textMeasurer, _keybindings) + { + Height = 5, + Focused = true, + }; + } + /// /// 获取或设置编辑器高度 /// @@ -41,6 +55,11 @@ public sealed partial class Editor(ITextMeasurer? textMeasurer = null, Keybindin /// public bool Focused { get; set; } + /// + /// 获取编辑器内部复用的补全列表组件 + /// + public SelectList AutocompleteList { get; } + /// /// 获取当前编辑器文本 /// @@ -68,15 +87,26 @@ public sealed partial class Editor(ITextMeasurer? textMeasurer = null, Keybindin /// public void HandleInput(TuiInputEvent input) { + if (TryHandleAutocompleteInput(input)) + return; + switch (input) { case { Kind: TuiInputEventKind.Text }: Edit(() => InsertText(input.Value)); + StartAutocomplete(force: false); break; case { Kind: TuiInputEventKind.Paste }: Edit(() => InsertText(input.Value.Replace("\r\n", "\n"))); + CancelAutocomplete(); break; case { Kind: TuiInputEventKind.Key }: + if (_keybindings.Matches(input, TuiKeybindings.AutocompleteTrigger)) + { + StartAutocomplete(force: true); + return; + } + HandleKey(input); break; } diff --git a/src/TinyTUI/Input/TuiKeybindings.cs b/src/TinyTUI/Input/TuiKeybindings.cs index fa0b8df..4314390 100644 --- a/src/TinyTUI/Input/TuiKeybindings.cs +++ b/src/TinyTUI/Input/TuiKeybindings.cs @@ -22,6 +22,9 @@ public static class TuiKeybindings public const string EditorClear = "tui.editor.clear"; public const string EditorNewLine = "tui.editor.newLine"; public const string EditorSubmit = "tui.editor.submit"; + public const string AutocompleteTrigger = "tui.autocomplete.trigger"; + public const string AutocompleteConfirm = "tui.autocomplete.confirm"; + public const string AutocompleteCancel = "tui.autocomplete.cancel"; public const string InputNewLine = "tui.input.newLine"; public const string InputSubmit = "tui.input.submit"; public const string InputCancel = "tui.input.cancel"; @@ -54,6 +57,9 @@ public static class TuiKeybindings Define(EditorClear, [KeyNames.Ctrl("c")], "Clear current input"), Define(EditorNewLine, [KeyNames.Enter], "Insert editor newline"), Define(EditorSubmit, [KeyNames.Ctrl(KeyNames.Enter), KeyNames.Ctrl("s")], "Submit editor input"), + Define(AutocompleteTrigger, [KeyNames.Tab], "Trigger autocomplete"), + Define(AutocompleteConfirm, [KeyNames.Tab, KeyNames.Enter], "Confirm autocomplete"), + Define(AutocompleteCancel, [KeyNames.Escape], "Cancel autocomplete"), Define(InputNewLine, [KeyNames.Shift(KeyNames.Enter)], "Insert newline"), Define(InputSubmit, [KeyNames.Enter], "Submit input"), Define(InputCancel, [KeyNames.Escape], "Cancel input"), diff --git a/test/TinyTUI.ComponentChecks/Program.cs b/test/TinyTUI.ComponentChecks/Program.cs index 41987d6..e2e48da 100644 --- a/test/TinyTUI.ComponentChecks/Program.cs +++ b/test/TinyTUI.ComponentChecks/Program.cs @@ -1,4 +1,5 @@ using TinyTUI.Components; +using TinyTUI.Autocomplete; using TinyTUI.Input; using TinyTUI.Rendering; using TinyTUI.Text; @@ -41,6 +42,29 @@ loader.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape)); AssertTrue(loader.IsCanceled, "cancellable loader token"); AssertTrue(canceled, "cancellable loader callback"); +var autocompleteEditor = new Editor(measurer) +{ + AutocompleteProvider = new SlashCommandAutocompleteProvider( + [ + new SlashCommand("help", "show help"), + new SlashCommand("history", "show history"), + ]), +}; +autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Text, "/h")); +AssertTrue(autocompleteEditor.IsAutocompleteActive, "slash command autocomplete active"); +AssertTrue(autocompleteEditor.RenderAutocomplete(40).Any(line => line.Contains("help", StringComparison.Ordinal)), "slash command autocomplete render"); +autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Down)); +autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Tab)); +AssertEqual("/history ", autocompleteEditor.Value, "slash command autocomplete confirm"); +AssertFalse(autocompleteEditor.IsAutocompleteActive, "slash command autocomplete closes after confirm"); + +autocompleteEditor.Value = "/h"; +autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Tab)); +AssertTrue(autocompleteEditor.IsAutocompleteActive, "forced autocomplete active"); +autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape)); +AssertFalse(autocompleteEditor.IsAutocompleteActive, "autocomplete cancel"); +AssertEqual("/h", autocompleteEditor.Value, "autocomplete cancel keeps text"); + Console.WriteLine("TinyTUI component checks passed"); static void AssertEqual(T expected, T actual, string name)