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()
{