feat: support key release opt-in dispatch

- keep parsed Kitty release events available to runtime listeners

- filter key release before focused component dispatch unless component opts in

- cover default filtering, opt-in dispatch, and keybinding non-match behavior
This commit is contained in:
chuan
2026-06-04 09:43:42 +08:00
Unverified
parent 334e98f596
commit a93233456f
6 changed files with 134 additions and 6 deletions
+26
View File
@@ -224,6 +224,32 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入
- parser 仍不是完整 `tmp/tui/src/keys.ts``matchesKey` raw sequence 模型,`KeyId` 还不能直接匹配 raw Kitty/modifyOtherKeys 序列,只是依赖解析后的 `Value`
- 终端协议协商仍属于 TODO 1,没有在本轮启用 Kitty keyboard protocol 或 fallback 到 modifyOtherKeys,也没有处理退出时恢复协议状态
本次 release opt-in 推进:
- `DefaultInputParser` 不再在解析层丢弃 Kitty release 事件,`TuiInputEvent.EventType` 会保留 `Release` 让 Runtime 和全局 listener 有机会观察
- 新增 `IKeyReleaseComponent`,聚焦输入组件只有显式实现该接口时才接收 key release,普通 `IInputComponent` 默认继续过滤 release
- `TuiRuntime.Dispatch` 保持先派发全局 input listener,再判断是否进入焦点组件;因此 listener 可以记录或消费 release,组件侧不会默认触发 key-up 行为
- `KeybindingRegistry.Matches` 继续拒绝 `TuiKeyEventType.Release`,避免 release 触发快捷键动作
- 新增 xUnit 回归测试,覆盖 parser 保留 release、listener 可见 release、普通组件默认过滤、opt-in 组件接收和 keybinding 不匹配 release
为什么这样做:
- 对齐 `tmp/tui/src/tui.ts` 的边界:input listener 先看到输入,焦点组件派发前再按 `wantsKeyRelease` 决定是否过滤 release
- C# 侧用 marker interface 表达 opt-in,比参考实现的可选布尔属性更类型安全,组件是否接收 release 能从类型声明直接看出
- 过滤放在 Runtime 而不是 parser,可以让 debug listener、全局快捷层或测试基础设施看到完整协议事件,同时不改变普通组件的默认行为
当前 C# 版本更好的点:
- `TuiInputEvent.EventType` 是强类型字段,Runtime 不需要像 `tmp/tui/src/keys.ts` 一样重新解析原始字符串或依赖最近一次解析状态判断 release
- `IKeyReleaseComponent` 复用现有 `IInputComponent.HandleInput(TuiInputEvent)`,不需要引入新的回调形状,也不会影响已有组件构造和 keybinding 注入
- release 过滤和 keybinding 过滤是两层防线:组件默认不收到 release,手工构造的 release 事件也不会匹配动作
仍存在的问题和后续建议:
- `IKeyReleaseComponent` 目前是 marker interface,没有提供更细粒度的按键级 release opt-in;如果后续有拖拽、长按或组合键释放逻辑,可以扩展为属性或策略接口
- Runtime 仍是同步 listener 和同步组件派发,若 release 事件用于异步状态机,后续需要补异常隔离、取消令牌和 listener 作用域
- raw matching 仍未实现,`KeyId` 还不能直接匹配 Kitty 原始序列;本轮只保证 release 事件不被过早丢弃并按 opt-in 分发
### 3. 渲染管线和视口模型
目标:把当前按行差分刷新升级成能长期运行、低闪烁、能处理滚动区域和复杂内容的渲染管线
@@ -0,0 +1,10 @@
using TinyTUI.Input;
namespace TinyTUI.Components;
/// <summary>
/// 标记聚焦后需要接收按键释放事件的输入组件
/// </summary>
public interface IKeyReleaseComponent : IInputComponent
{
}
+1 -3
View File
@@ -208,9 +208,7 @@ public sealed class DefaultInputParser : IInputParser
if (TryParseProtocolKey(data, out var protocolEvent))
{
// Kitty release 事件默认不派发给组件 避免按下动作在释放阶段再次触发
if (protocolEvent.EventType != TuiKeyEventType.Release)
events.Add(protocolEvent);
events.Add(protocolEvent);
return;
}
+15 -1
View File
@@ -293,12 +293,26 @@ public sealed class TuiRuntime : ITuiRuntime
{
var consumed = DispatchToInputListeners(inputEvent);
if (!consumed && _focusedComponent is IInputComponent inputComponent)
if (!consumed && _focusedComponent is IInputComponent inputComponent && ShouldDispatchToFocusedComponent(inputEvent, inputComponent))
inputComponent.HandleInput(inputEvent);
RequestRender();
}
/// <summary>
/// 判断输入事件是否应该继续派发给焦点组件
/// </summary>
private static bool ShouldDispatchToFocusedComponent(TuiInputEvent inputEvent, IInputComponent inputComponent)
{
if (inputEvent is { Kind: TuiInputEventKind.Key, EventType: TuiKeyEventType.Release })
{
// release 默认只给全局 listener 观察 组件必须显式 opt-in 才能处理 key-up 行为
return inputComponent is IKeyReleaseComponent;
}
return true;
}
/// <summary>
/// 按注册顺序调用全局输入监听器 任一监听器消费后立即停止后续派发
/// </summary>
@@ -128,12 +128,24 @@ public sealed class InputInfrastructureTests
}
[Fact]
public void ReleaseEventsDoNotDispatchOrMatchKeybindingsByDefault()
public void DefaultInputParserKeepsKittyReleaseEvents()
{
var parser = new DefaultInputParser();
Assert.Empty(parser.Parse("\e[97;1:3u"));
var release = Assert.Single(parser.Parse("\e[97;1:3u"));
Assert.Equal(TuiInputEventKind.Key, release.Kind);
Assert.Equal("a", release.Value);
Assert.Equal(TuiKeyEventType.Release, release.EventType);
Assert.Equal("\e[97;1:3u", release.RawSequence);
}
[Fact]
public void KeybindingRegistryDoesNotMatchReleaseEvents()
{
var registry = KeybindingRegistry.CreateDefault();
registry.SetUserBinding(TuiKeybindings.InputSubmit, KeyNames.Enter);
var release = new TuiInputEvent(
TuiInputEventKind.Key,
KeyNames.Enter,
@@ -76,6 +76,65 @@ public sealed class TuiRuntimeInputListenerTests
Assert.Equal([new TuiInputEvent(TuiInputEventKind.Text, "a")], component.Inputs);
}
[Fact]
public void ListenerSeesReleaseBeforeFocusedComponentFiltering()
{
var terminal = new TestTerminalSession();
using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer());
var component = new RecordingInputComponent();
var observed = new List<TuiInputEvent>();
runtime.Add(component);
runtime.SetFocus(component);
runtime.AddInputListener(context =>
{
observed.Add(context.Input);
return false;
});
runtime.Start();
terminal.Receive("\e[97;1:3u");
var release = Assert.Single(observed);
Assert.Equal(TuiKeyEventType.Release, release.EventType);
Assert.Empty(component.Inputs);
}
[Fact]
public void FocusedComponentDoesNotReceiveReleaseByDefault()
{
var terminal = new TestTerminalSession();
using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer());
var component = new RecordingInputComponent();
runtime.Add(component);
runtime.SetFocus(component);
runtime.Start();
terminal.Receive("\e[97;1:3u");
Assert.Empty(component.Inputs);
}
[Fact]
public void FocusedKeyReleaseComponentReceivesRelease()
{
var terminal = new TestTerminalSession();
using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer());
var component = new RecordingKeyReleaseComponent();
runtime.Add(component);
runtime.SetFocus(component);
runtime.Start();
terminal.Receive("\e[97;1:3u");
var release = Assert.Single(component.Inputs);
Assert.Equal(TuiInputEventKind.Key, release.Kind);
Assert.Equal("a", release.Value);
Assert.Equal(TuiKeyEventType.Release, release.EventType);
}
[Fact]
public void ListenerCanUseRuntimeKeybindingRegistry()
{
@@ -747,6 +806,15 @@ public sealed class TuiRuntimeInputListenerTests
public void HandleInput(TuiInputEvent input) => Inputs.Add(input);
}
private sealed class RecordingKeyReleaseComponent : IKeyReleaseComponent
{
public List<TuiInputEvent> Inputs { get; } = [];
public IReadOnlyList<string> Render(int width) => [];
public void HandleInput(TuiInputEvent input) => Inputs.Add(input);
}
private sealed class RecordingThemeComponent : IThemeComponent
{
public ITuiTheme? Theme { get; private set; }