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:
@@ -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. 渲染管线和视口模型
|
||||
|
||||
目标:把当前按行差分刷新升级成能长期运行、低闪烁、能处理滚动区域和复杂内容的渲染管线
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user