diff --git a/TODO.md b/TODO.md index abf24a5..766f8ea 100644 --- a/TODO.md +++ b/TODO.md @@ -109,12 +109,39 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入 后续仍需补齐: -- 组件构造函数目前仍各自默认 `KeybindingRegistry.CreateDefault()`,还没有 Runtime 自动向组件注入共享 registry;后续需要设计组件工厂、上下文注入或显式传参策略 - 还没有补 `removeInputListener(listener)` 形式的 API;当前以 `IDisposable` 句柄为主,若需要和参考实现更一致可再增加等价注销方法 - listener 当前是同步 bool 契约,尚未支持异步命令、取消令牌、异常隔离和按优先级/作用域管理监听器 - 输入协议仍未扩大到 Kitty keyboard protocol 的 press、repeat、release、alternate key,也没有 release 事件过滤和组件声明能力 - 仍缺 Runtime + virtual terminal 的端到端屏幕断言,本次只覆盖输入派发路径和渲染请求计数 +本次 Runtime 共享注入推进: + +- 新增 `IKeybindingComponent`,让内置输入组件可以在进入 Runtime 后接收共享 `KeybindingRegistry` +- `Input`、`SelectList`、`Editor`、`CancellableLoader` 改为实现该接口,保留构造函数显式传入 registry 的兼容性 +- `TuiRuntime.Add` 和 `ShowOverlay` 会在组件进入根树或 overlay 栈前注入 `Runtime.Keybindings` +- Runtime 注入会递归处理内置 `Container.Children` 和 `Box.Child`,避免容器里的输入组件继续使用各自默认 registry +- `Editor.SetKeybindings` 会同步更新内部 `AutocompleteList`,确保补全候选列表导航也走 Runtime 共享配置 +- 新增 xUnit 回归测试,覆盖根输入组件、overlay 列表、容器子组件和 Editor 内部补全列表使用 Runtime 自定义快捷键 + +为什么这样做: + +- 对比 `tmp/tui/src/keybindings.ts` 的 `getKeybindings()` 全局 manager,C# 侧已有 Runtime 实例级 `Keybindings`,显式注入更适合测试隔离和多 Runtime 场景 +- 注入点放在 `Add` 和 `ShowOverlay`,是因为这是组件真正进入运行时输入派发范围的边界,能让组件离线构造时仍保持原有默认行为 +- 递归只覆盖当前内置容器类型,不引入反射扫描,减少第三方组件属性命名或生命周期被 Runtime 误触碰的风险 + +当前更好的点: + +- 自定义 keybinding 现在能真实影响焦点组件行为,而不只是 Runtime listener 可见 +- C# 侧可以给每个 Runtime 持有独立 registry,比参考实现的模块级全局 keybindings 更容易做并发测试和多终端实例 +- Editor 内部补全列表与编辑器共享同一 registry,避免外层动作覆盖生效但内层选择列表仍用默认键的割裂行为 + +后续仍需补齐: + +- Runtime 只知道内置 `Container` 和 `Box` 的子组件结构,第三方复合组件若要递归注入仍需要自己实现 `IKeybindingComponent` 并转发给子组件 +- `Container.Add` 在容器已经挂到 Runtime 之后再动态添加子组件时,目前不会自动触发 Runtime 注入;后续可以引入组件挂载上下文或 Runtime-aware container +- 仍未实现 `tmp/tui/src/keys.ts` 里的 Kitty press、repeat、release、alternate key 解析,也没有组件声明是否接收 release 事件 +- keybinding 冲突检测仍主要针对用户覆盖集合,后续需要补作用域或上下文策略,避免全局配置复杂后误报或漏报 + ### 3. 渲染管线和视口模型 目标:把当前按行差分刷新升级成能长期运行、低闪烁、能处理滚动区域和复杂内容的渲染管线 diff --git a/src/TinyTUI/Components/CancellableLoader.cs b/src/TinyTUI/Components/CancellableLoader.cs index 568ffa0..fcca661 100644 --- a/src/TinyTUI/Components/CancellableLoader.cs +++ b/src/TinyTUI/Components/CancellableLoader.cs @@ -5,11 +5,11 @@ namespace TinyTUI.Components; /// /// 支持 Escape 取消并暴露 CancellationToken 的加载组件 /// -public sealed class CancellableLoader : IInputComponent, IDisposable +public sealed class CancellableLoader : IInputComponent, IKeybindingComponent, IDisposable { - private readonly KeybindingRegistry _keybindings; private readonly CancellationTokenSource _cancellation = new(); private readonly Loader _loader; + private KeybindingRegistry _keybindings; /// /// 创建可取消加载组件 @@ -46,6 +46,10 @@ public sealed class CancellableLoader : IInputComponent, IDisposable /// public void Invalidate() => ((IComponent)_loader).Invalidate(); + /// + public void SetKeybindings(KeybindingRegistry keybindings) + => _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings)); + /// public void HandleInput(TuiInputEvent input) { diff --git a/src/TinyTUI/Components/Core/IKeybindingComponent.cs b/src/TinyTUI/Components/Core/IKeybindingComponent.cs new file mode 100644 index 0000000..4545a5f --- /dev/null +++ b/src/TinyTUI/Components/Core/IKeybindingComponent.cs @@ -0,0 +1,14 @@ +using TinyTUI.Input; + +namespace TinyTUI.Components; + +/// +/// 定义可以接收 Runtime 级共享快捷键注册表的组件 +/// +public interface IKeybindingComponent : IComponent +{ + /// + /// 设置组件后续处理输入时使用的快捷键注册表 + /// + void SetKeybindings(KeybindingRegistry keybindings); +} diff --git a/src/TinyTUI/Components/Editor/Editor.cs b/src/TinyTUI/Components/Editor/Editor.cs index b206520..63a1f34 100644 --- a/src/TinyTUI/Components/Editor/Editor.cs +++ b/src/TinyTUI/Components/Editor/Editor.cs @@ -6,15 +6,15 @@ namespace TinyTUI.Components; /// /// 支持多行文本编辑的基础编辑器组件 /// -public sealed partial class Editor : IInputComponent, IFocusableComponent +public sealed partial class Editor : IInputComponent, IFocusableComponent, IKeybindingComponent { private readonly ITextMeasurer _textMeasurer; - private readonly KeybindingRegistry _keybindings; private readonly List _lines = [string.Empty]; private readonly Stack _undoStack = []; private readonly Stack _redoStack = []; private readonly List _history = []; + private KeybindingRegistry _keybindings; private int _cursorRow; private int _cursorColumn; private int _historyIndex = -1; @@ -84,6 +84,15 @@ public sealed partial class Editor : IInputComponent, IFocusableComponent /// public Action? OnSubmitted { get; set; } + /// + public void SetKeybindings(KeybindingRegistry keybindings) + { + _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings)); + + // 补全列表和编辑器共享同一套快捷键 否则 Runtime 覆盖后内部导航仍会走默认绑定 + AutocompleteList.SetKeybindings(_keybindings); + } + /// public void HandleInput(TuiInputEvent input) { diff --git a/src/TinyTUI/Components/Input.cs b/src/TinyTUI/Components/Input.cs index 59599af..c50fec0 100644 --- a/src/TinyTUI/Components/Input.cs +++ b/src/TinyTUI/Components/Input.cs @@ -12,11 +12,11 @@ namespace TinyTUI.Components; public sealed class Input( ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null, - ITuiTheme? theme = null) : IInputComponent, IFocusableComponent + ITuiTheme? theme = null) : IInputComponent, IFocusableComponent, IKeybindingComponent { private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); - private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); private readonly ITuiTheme _theme = theme ?? TuiTheme.Default; + private KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); /// /// 获取或设置输入框前缀 @@ -56,6 +56,10 @@ public sealed class Input( return [_textMeasurer.Truncate(visible, width)]; } + /// + public void SetKeybindings(KeybindingRegistry keybindings) + => _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings)); + /// public void HandleInput(TuiInputEvent input) { diff --git a/src/TinyTUI/Components/SelectList.cs b/src/TinyTUI/Components/SelectList.cs index 9059315..ce939e5 100644 --- a/src/TinyTUI/Components/SelectList.cs +++ b/src/TinyTUI/Components/SelectList.cs @@ -11,17 +11,17 @@ namespace TinyTUI.Components; public sealed class SelectList( ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null, - ITuiTheme? theme = null) : IInputComponent, IFocusableComponent + ITuiTheme? theme = null) : IInputComponent, IFocusableComponent, IKeybindingComponent { private const int PrimaryColumnGap = 2; private const int MinDescriptionWidth = 10; private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); - private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); private readonly ITuiTheme _theme = theme ?? TuiTheme.Default; private readonly List _items = []; private readonly List _filteredItems = []; + private KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); private string _filter = string.Empty; /// @@ -118,6 +118,10 @@ public sealed class SelectList( NotifySelectionChanged(); } + /// + public void SetKeybindings(KeybindingRegistry keybindings) + => _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings)); + /// public IReadOnlyList Render(int width) { diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs index f789500..86caa3a 100644 --- a/src/TinyTUI/Runtime/TuiRuntime.cs +++ b/src/TinyTUI/Runtime/TuiRuntime.cs @@ -59,6 +59,7 @@ public sealed class TuiRuntime : ITuiRuntime /// public void Add(IComponent component) { + ApplyRuntimeKeybindings(component); _root.Add(component); component.Invalidate(); RequestRender(); @@ -128,6 +129,8 @@ public sealed class TuiRuntime : ITuiRuntime /// public IOverlayHandle ShowOverlay(IComponent component, OverlayOptions? options = null) { + ApplyRuntimeKeybindings(component); + // 记录显示 overlay 前的焦点 关闭 overlay 时恢复用户原来的输入位置 _overlayFocusRestore[component] = _focusedComponent; var overlayOptions = options ?? new OverlayOptions(); @@ -287,6 +290,27 @@ public sealed class TuiRuntime : ITuiRuntime focusable.Focused = focused; } + /// + /// 将 Runtime 级共享快捷键注册表递归写入内置输入组件 + /// + private void ApplyRuntimeKeybindings(IComponent component) + { + if (component is IKeybindingComponent keybindingComponent) + keybindingComponent.SetKeybindings(Keybindings); + + // 内置容器会在添加到 Runtime 前组装子树 这里递归注入避免子组件继续使用各自默认 registry + switch (component) + { + case Container container: + foreach (var child in container.Children) + ApplyRuntimeKeybindings(child); + break; + case Box box: + ApplyRuntimeKeybindings(box.Child); + break; + } + } + /// /// 注销 Runtime 输入监听器的可释放句柄 /// diff --git a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs index 948858f..08d87b6 100644 --- a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs +++ b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs @@ -1,3 +1,4 @@ +using TinyTUI.Autocomplete; using TinyTUI.Components; using TinyTUI.Input; using TinyTUI.Rendering; @@ -99,6 +100,93 @@ public sealed class TuiRuntimeInputListenerTests Assert.Empty(component.Inputs); } + [Fact] + public void RuntimeInjectsSharedKeybindingsIntoAddedInputComponent() + { + var terminal = new TestTerminalSession(); + var keybindings = KeybindingRegistry.CreateDefault(); + keybindings.SetUserBinding(TuiKeybindings.InputSubmit, KeyNames.Tab); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), keybindings); + var input = new TinyTUI.Components.Input(); + var submitted = false; + + input.OnSubmitted = _ => submitted = true; + runtime.Add(input); + runtime.SetFocus(input); + + runtime.Start(); + terminal.Receive("\t"); + + Assert.True(submitted); + } + + [Fact] + public void RuntimeInjectsSharedKeybindingsIntoOverlayComponent() + { + var terminal = new TestTerminalSession(); + var keybindings = KeybindingRegistry.CreateDefault(); + keybindings.SetUserBinding(TuiKeybindings.SelectDown, KeyNames.Tab); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), keybindings); + var list = new SelectList(); + + list.SetItems(["one", "two"]); + runtime.ShowOverlay(list); + + runtime.Start(); + terminal.Receive("\t"); + + Assert.Equal(1, list.SelectedIndex); + } + + [Fact] + public void RuntimeInjectsSharedKeybindingsIntoRecursiveContainerChildren() + { + var terminal = new TestTerminalSession(); + var keybindings = KeybindingRegistry.CreateDefault(); + keybindings.SetUserBinding(TuiKeybindings.InputSubmit, KeyNames.Tab); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), keybindings); + var input = new TinyTUI.Components.Input(); + var container = new Container(); + var submitted = false; + + input.OnSubmitted = _ => submitted = true; + container.Add(new Box(input)); + runtime.Add(container); + runtime.SetFocus(input); + + runtime.Start(); + terminal.Receive("\t"); + + Assert.True(submitted); + } + + [Fact] + public void RuntimeInjectedEditorKeybindingsReachAutocompleteList() + { + var terminal = new TestTerminalSession(); + var keybindings = KeybindingRegistry.CreateDefault(); + keybindings.SetUserBinding(TuiKeybindings.SelectDown, KeyNames.PageDown); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), keybindings); + var editor = new Editor + { + AutocompleteProvider = new SlashCommandAutocompleteProvider( + [ + new SlashCommand("help", "show help"), + new SlashCommand("history", "show history"), + ]), + }; + + runtime.Add(editor); + runtime.SetFocus(editor); + + runtime.Start(); + terminal.Receive("/h"); + terminal.Receive("\e[6~"); + terminal.Receive("\r"); + + Assert.Equal("/history ", editor.Value); + } + [Fact] public void DisposingListenerRegistrationRemovesListener() {