From a93233456f838486d88deb4b00b126e32fed5286 Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 09:43:42 +0800 Subject: [PATCH] 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 --- TODO.md | 26 +++++++ .../Components/Core/IKeyReleaseComponent.cs | 10 +++ src/TinyTUI/Input/DefaultInputParser.cs | 4 +- src/TinyTUI/Runtime/TuiRuntime.cs | 16 ++++- .../Input/InputInfrastructureTests.cs | 16 ++++- .../Runtime/TuiRuntimeInputListenerTests.cs | 68 +++++++++++++++++++ 6 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 src/TinyTUI/Components/Core/IKeyReleaseComponent.cs diff --git a/TODO.md b/TODO.md index 92c3d96..13a1b0a 100644 --- a/TODO.md +++ b/TODO.md @@ -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. 渲染管线和视口模型 目标:把当前按行差分刷新升级成能长期运行、低闪烁、能处理滚动区域和复杂内容的渲染管线 diff --git a/src/TinyTUI/Components/Core/IKeyReleaseComponent.cs b/src/TinyTUI/Components/Core/IKeyReleaseComponent.cs new file mode 100644 index 0000000..561a7ae --- /dev/null +++ b/src/TinyTUI/Components/Core/IKeyReleaseComponent.cs @@ -0,0 +1,10 @@ +using TinyTUI.Input; + +namespace TinyTUI.Components; + +/// +/// 标记聚焦后需要接收按键释放事件的输入组件 +/// +public interface IKeyReleaseComponent : IInputComponent +{ +} diff --git a/src/TinyTUI/Input/DefaultInputParser.cs b/src/TinyTUI/Input/DefaultInputParser.cs index aab86db..e5dd4ab 100644 --- a/src/TinyTUI/Input/DefaultInputParser.cs +++ b/src/TinyTUI/Input/DefaultInputParser.cs @@ -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; } diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs index 4cb53b1..f6884a8 100644 --- a/src/TinyTUI/Runtime/TuiRuntime.cs +++ b/src/TinyTUI/Runtime/TuiRuntime.cs @@ -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(); } + /// + /// 判断输入事件是否应该继续派发给焦点组件 + /// + 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; + } + /// /// 按注册顺序调用全局输入监听器 任一监听器消费后立即停止后续派发 /// diff --git a/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs b/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs index acc718c..8483b30 100644 --- a/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs +++ b/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs @@ -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, diff --git a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs index 116ee9e..d45297c 100644 --- a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs +++ b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs @@ -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(); + + 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 Inputs { get; } = []; + + public IReadOnlyList Render(int width) => []; + + public void HandleInput(TuiInputEvent input) => Inputs.Add(input); + } + private sealed class RecordingThemeComponent : IThemeComponent { public ITuiTheme? Theme { get; private set; }