feat: share runtime keybindings with components

- add component-level keybinding injection contract

- inject runtime keybinding registry when components enter runtime or overlay

- cover focused component behavior with shared runtime keybindings
This commit is contained in:
chuan
2026-06-04 02:54:07 +08:00
parent 60bce3c12c
commit d84ca3a313
8 changed files with 183 additions and 9 deletions
+28 -1
View File
@@ -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()` 全局 managerC# 侧已有 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. 渲染管线和视口模型
目标:把当前按行差分刷新升级成能长期运行、低闪烁、能处理滚动区域和复杂内容的渲染管线
+6 -2
View File
@@ -5,11 +5,11 @@ namespace TinyTUI.Components;
/// <summary>
/// 支持 Escape 取消并暴露 CancellationToken 的加载组件
/// </summary>
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;
/// <summary>
/// 创建可取消加载组件
@@ -46,6 +46,10 @@ public sealed class CancellableLoader : IInputComponent, IDisposable
/// <inheritdoc />
public void Invalidate() => ((IComponent)_loader).Invalidate();
/// <inheritdoc />
public void SetKeybindings(KeybindingRegistry keybindings)
=> _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings));
/// <inheritdoc />
public void HandleInput(TuiInputEvent input)
{
@@ -0,0 +1,14 @@
using TinyTUI.Input;
namespace TinyTUI.Components;
/// <summary>
/// 定义可以接收 Runtime 级共享快捷键注册表的组件
/// </summary>
public interface IKeybindingComponent : IComponent
{
/// <summary>
/// 设置组件后续处理输入时使用的快捷键注册表
/// </summary>
void SetKeybindings(KeybindingRegistry keybindings);
}
+11 -2
View File
@@ -6,15 +6,15 @@ namespace TinyTUI.Components;
/// <summary>
/// 支持多行文本编辑的基础编辑器组件
/// </summary>
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<string> _lines = [string.Empty];
private readonly Stack<EditorSnapshot> _undoStack = [];
private readonly Stack<EditorSnapshot> _redoStack = [];
private readonly List<string> _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
/// </summary>
public Action<string>? OnSubmitted { get; set; }
/// <inheritdoc />
public void SetKeybindings(KeybindingRegistry keybindings)
{
_keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings));
// 补全列表和编辑器共享同一套快捷键 否则 Runtime 覆盖后内部导航仍会走默认绑定
AutocompleteList.SetKeybindings(_keybindings);
}
/// <inheritdoc />
public void HandleInput(TuiInputEvent input)
{
+6 -2
View File
@@ -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();
/// <summary>
/// 获取或设置输入框前缀
@@ -56,6 +56,10 @@ public sealed class Input(
return [_textMeasurer.Truncate(visible, width)];
}
/// <inheritdoc />
public void SetKeybindings(KeybindingRegistry keybindings)
=> _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings));
/// <inheritdoc />
public void HandleInput(TuiInputEvent input)
{
+6 -2
View File
@@ -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<SelectItem> _items = [];
private readonly List<SelectItem> _filteredItems = [];
private KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
private string _filter = string.Empty;
/// <summary>
@@ -118,6 +118,10 @@ public sealed class SelectList(
NotifySelectionChanged();
}
/// <inheritdoc />
public void SetKeybindings(KeybindingRegistry keybindings)
=> _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings));
/// <inheritdoc />
public IReadOnlyList<string> Render(int width)
{
+24
View File
@@ -59,6 +59,7 @@ public sealed class TuiRuntime : ITuiRuntime
/// <inheritdoc />
public void Add(IComponent component)
{
ApplyRuntimeKeybindings(component);
_root.Add(component);
component.Invalidate();
RequestRender();
@@ -128,6 +129,8 @@ public sealed class TuiRuntime : ITuiRuntime
/// <inheritdoc />
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;
}
/// <summary>
/// 将 Runtime 级共享快捷键注册表递归写入内置输入组件
/// </summary>
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;
}
}
/// <summary>
/// 注销 Runtime 输入监听器的可释放句柄
/// </summary>
@@ -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()
{