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:
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user