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; }