From e78ce2badf3cd23008c1b0f3e7e8a7e0975740e8 Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 03:05:38 +0800 Subject: [PATCH] feat: expand terminal key protocol parsing - add key event phase metadata for parsed input events - parse Kitty CSI-u and modifyOtherKeys style key sequences - filter release events from keybinding dispatch by default - cover Shift+Tab and Kitty key parsing with xUnit regressions --- TODO.md | 36 +- src/TinyTUI/Input/DefaultInputParser.cs | 469 +++++++++++++++++- src/TinyTUI/Input/KeybindingRegistry.cs | 3 + src/TinyTUI/Input/TuiInputEvent.cs | 28 +- .../Input/InputInfrastructureTests.cs | 84 ++++ 5 files changed, 601 insertions(+), 19 deletions(-) diff --git a/TODO.md b/TODO.md index 766f8ea..a0b8078 100644 --- a/TODO.md +++ b/TODO.md @@ -80,11 +80,11 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入 后续仍需补齐: -- `KeyId` 目前仍基于已解析的 key name 匹配,还没有像 `tmp/tui/src/keys.ts` 那样直接识别 raw escape sequence、Kitty CSI-u、modifyOtherKeys 和 alternate key -- 还没有解析 Kitty keyboard protocol 的 press、repeat、release 事件类型,也没有为组件提供是否接收 release 事件的声明能力 +- `KeyId` 目前仍基于已解析的 key name 匹配,还没有像 `tmp/tui/src/keys.ts` 那样直接识别 raw escape sequence 或完整协议元数据 +- Kitty keyboard protocol 已有基础 press、repeat、release 和 alternate key 解析,但还没有为组件提供是否接收 release 事件的声明能力 - `KeybindingRegistry` 目前只支持组件级注入,尚未接入 Runtime 统一持有,也没有全局 input listener、拦截、consume 和 debug 快捷键入口 - 冲突检测当前对用户覆盖生效,后续可以增加“默认绑定冲突是否允许按上下文共存”的策略,比如 editor/select 都使用 Enter 但不会同时处理 -- `DefaultInputParser` 还需要继续补 `parseKey` 级能力:空格、Alt+字母、Ctrl+符号、Shift+Tab、Alt+方向键、Windows Ctrl+Backspace 等特殊兼容路径 +- `DefaultInputParser` 还需要继续补 `parseKey` 级能力:空格、Ctrl+符号、Alt+方向键、Windows Ctrl+Backspace 等特殊兼容路径 本次 Runtime 级推进: @@ -111,7 +111,7 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入 - 还没有补 `removeInputListener(listener)` 形式的 API;当前以 `IDisposable` 句柄为主,若需要和参考实现更一致可再增加等价注销方法 - listener 当前是同步 bool 契约,尚未支持异步命令、取消令牌、异常隔离和按优先级/作用域管理监听器 -- 输入协议仍未扩大到 Kitty keyboard protocol 的 press、repeat、release、alternate key,也没有 release 事件过滤和组件声明能力 +- 输入协议已有 Kitty keyboard protocol 的基础 press、repeat、release、alternate key 和 release 过滤,还没有组件声明能力或完整 raw matching - 仍缺 Runtime + virtual terminal 的端到端屏幕断言,本次只覆盖输入派发路径和渲染请求计数 本次 Runtime 共享注入推进: @@ -139,9 +139,35 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入 - Runtime 只知道内置 `Container` 和 `Box` 的子组件结构,第三方复合组件若要递归注入仍需要自己实现 `IKeybindingComponent` 并转发给子组件 - `Container.Add` 在容器已经挂到 Runtime 之后再动态添加子组件时,目前不会自动触发 Runtime 注入;后续可以引入组件挂载上下文或 Runtime-aware container -- 仍未实现 `tmp/tui/src/keys.ts` 里的 Kitty press、repeat、release、alternate key 解析,也没有组件声明是否接收 release 事件 +- 已补 `tmp/tui/src/keys.ts` 里的 Kitty press、repeat、release、alternate key 基础解析,仍缺组件声明是否接收 release 事件和完整 raw matching - keybinding 冲突检测仍主要针对用户覆盖集合,后续需要补作用域或上下文策略,避免全局配置复杂后误报或漏报 +本次协议解析推进: + +- 新增 `TuiKeyEventType`,让 `TuiInputEvent` 可以承载 Press、Repeat、Release 三种按键阶段,并保留现有两参数构造默认 Press 的兼容行为 +- `TuiInputEvent` 增加 `AlternateKey` 和 `RawSequence` 元数据,用于承载 Kitty CSI-u alternate key 和原始协议序列,现有组件仍继续读取 `Value` +- `DefaultInputParser` 增加 Kitty CSI-u 基础解析,覆盖普通 codepoint、Ctrl/Alt/Shift/Super modifier、event type、shifted alternate key、base layout key 和 Kitty keypad functional codepoint 归一化 +- `DefaultInputParser` 增加带 event type 的 Kitty final-byte 功能键解析,并保留无 event type 的 legacy modified CSI 行为,避免 `ESC[1;2A` 这类旧序列因为新增元数据影响现有相等断言和组件行为 +- `DefaultInputParser` 增加 xterm modifyOtherKeys `CSI 27 ; modifier ; codepoint ~` 解析,覆盖 Ctrl+字母、Shift+Tab、Alt+Enter 等组合键 +- 补齐 `ESC[Z` Shift+Tab 和 legacy Alt+字母解析,减少组件或测试直接识别 raw escape sequence 的需求 +- release 事件默认在 parser 层不派发,同时 `KeybindingRegistry.Matches` 对手工构造的 release 事件也默认不匹配,避免释放阶段误触发组件动作 +- 新增 xUnit 回归测试,覆盖 Shift+Tab、Alt+字母、Kitty 普通字符、Ctrl/Shift 组合、repeat 阶段、alternate key 元数据、modifyOtherKeys 组合键和 release 过滤 + +本次当前更好的点: + +- C# 侧把按键阶段作为 `TuiInputEvent` 的强类型字段,不依赖类似 `isKeyRelease(data)` 的全局最近解析状态,Runtime listener 和组件拿到事件后可以直接判断来源阶段 +- release 过滤同时放在 parser 和 keybinding registry 两层,真实终端输入不会派发 release,测试或外部代码手工构造 release 事件时也不会误匹配默认动作 +- 协议解析后仍输出原有 `Value` key name,组件和 keybinding registry 不需要立刻迁移到 raw sequence 匹配模型 +- Kitty final-byte 解析只接管带 `:event` 的新协议形态,旧 xterm modified CSI 仍走原逻辑,避免为了新增元数据破坏已有 record 相等和 legacy 行为 + +本次后续仍需补齐: + +- 还没有实现组件声明是否接收 release 事件的 API;当前策略是框架默认过滤,后续若需要 key-up 交互要给 Runtime listener 或组件增加 opt-in 通道 +- `AlternateKey` 目前只承载 shifted alternate key,base layout key 用于解析匹配但没有单独暴露字段;后续如果要完整支持非 Latin 布局调试和用户配置,应扩展为结构化元数据 +- modifyOtherKeys 目前覆盖基础 `CSI 27 ; modifier ; codepoint ~`,还没有处理所有参考实现中的 printable duplicate drop、Windows Ctrl+Backspace heuristic、legacy Ctrl+符号兼容路径 +- parser 仍不是完整 `tmp/tui/src/keys.ts` 的 `matchesKey` raw sequence 模型,`KeyId` 还不能直接匹配 raw Kitty/modifyOtherKeys 序列,只是依赖解析后的 `Value` +- 终端协议协商仍属于 TODO 1,没有在本轮启用 Kitty keyboard protocol 或 fallback 到 modifyOtherKeys,也没有处理退出时恢复协议状态 + ### 3. 渲染管线和视口模型 目标:把当前按行差分刷新升级成能长期运行、低闪烁、能处理滚动区域和复杂内容的渲染管线 diff --git a/src/TinyTUI/Input/DefaultInputParser.cs b/src/TinyTUI/Input/DefaultInputParser.cs index b7e2736..aab86db 100644 --- a/src/TinyTUI/Input/DefaultInputParser.cs +++ b/src/TinyTUI/Input/DefaultInputParser.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace TinyTUI.Input; /// @@ -5,6 +7,12 @@ namespace TinyTUI.Input; /// public sealed class DefaultInputParser : IInputParser { + private const int ShiftModifier = 1; + private const int AltModifier = 2; + private const int CtrlModifier = 4; + private const int SuperModifier = 8; + private const int LockModifierMask = 64 + 128; + private static readonly Dictionary CsiFinalKeyMap = new() { ['A'] = KeyNames.Up, @@ -16,6 +24,14 @@ public sealed class DefaultInputParser : IInputParser ['Z'] = KeyNames.Tab, }; + private static readonly Dictionary KittyArrowCodepoints = new() + { + ['A'] = -1, + ['B'] = -2, + ['C'] = -3, + ['D'] = -4, + }; + private static readonly Dictionary CsiTildeKeyMap = new() { [1] = KeyNames.Home, @@ -36,6 +52,82 @@ public sealed class DefaultInputParser : IInputParser [24] = "f12", }; + private static readonly Dictionary KittyTildeFunctionalCodepoints = new() + { + [2] = -11, + [3] = -10, + [5] = -12, + [6] = -13, + [7] = -14, + [8] = -15, + }; + + private static readonly Dictionary KittyFunctionalKeyEquivalents = new() + { + [57399] = '0', + [57400] = '1', + [57401] = '2', + [57402] = '3', + [57403] = '4', + [57404] = '5', + [57405] = '6', + [57406] = '7', + [57407] = '8', + [57408] = '9', + [57409] = '.', + [57410] = '/', + [57411] = '*', + [57412] = '-', + [57413] = '+', + [57415] = '=', + [57416] = ',', + [57417] = -4, + [57418] = -3, + [57419] = -1, + [57420] = -2, + [57421] = -12, + [57422] = -13, + [57423] = -14, + [57424] = -15, + [57425] = -11, + [57426] = -10, + }; + + private static readonly HashSet SymbolKeyCodepoints = + [ + '`', + '-', + '=', + '[', + ']', + '\\', + ';', + '\'', + ',', + '.', + '/', + '!', + '@', + '#', + '$', + '%', + '^', + '&', + '*', + '(', + ')', + '_', + '+', + '|', + '~', + '{', + '}', + ':', + '<', + '>', + '?', + ]; + private readonly StdinBuffer _stdinBuffer = new(emitSingleEscapeImmediately: true); private readonly Dictionary _keyMap = new() { @@ -49,6 +141,13 @@ public sealed class DefaultInputParser : IInputParser ["\e[B"] = KeyNames.Down, ["\e[C"] = KeyNames.Right, ["\e[D"] = KeyNames.Left, + ["\e[Z"] = KeyNames.Shift(KeyNames.Tab), + ["\eOA"] = KeyNames.Up, + ["\eOB"] = KeyNames.Down, + ["\eOC"] = KeyNames.Right, + ["\eOD"] = KeyNames.Left, + ["\eOH"] = KeyNames.Home, + ["\eOF"] = KeyNames.End, ["\e[H"] = KeyNames.Home, ["\e[F"] = KeyNames.End, ["\e[2~"] = KeyNames.Insert, @@ -103,26 +202,60 @@ public sealed class DefaultInputParser : IInputParser if (_keyMap.TryGetValue(data, out var keyName)) { - events.Add(new TuiInputEvent(TuiInputEventKind.Key, keyName)); + events.Add(CreateKeyEvent(keyName)); return; } - if (TryParseControlCharacter(data, out keyName) || TryParseModifiedCsi(data, out keyName)) + if (TryParseProtocolKey(data, out var protocolEvent)) { - events.Add(new TuiInputEvent(TuiInputEventKind.Key, keyName)); + // Kitty release 事件默认不派发给组件 避免按下动作在释放阶段再次触发 + if (protocolEvent.EventType != TuiKeyEventType.Release) + events.Add(protocolEvent); + return; + } + + if (TryParseAltPrefixedKey(data, out keyName) || + TryParseControlCharacter(data, out keyName) || + TryParseModifiedCsi(data, out keyName)) + { + events.Add(CreateKeyEvent(keyName)); return; } if (data.StartsWith('\e')) { // 未识别的 escape 序列仍按 Key 透传 方便后续组件或更高层扩展处理 - events.Add(new TuiInputEvent(TuiInputEventKind.Key, data)); + events.Add(CreateKeyEvent(data, rawSequence: data)); return; } events.Add(new TuiInputEvent(TuiInputEventKind.Text, data)); } + /// + /// 解析带协议元数据的 Kitty CSI-u 和 modifyOtherKeys 序列 + /// + private static bool TryParseProtocolKey(string data, out TuiInputEvent inputEvent) + { + inputEvent = default!; + + if (!TryParseKittySequence(data, out var parsed) && + !TryParseModifyOtherKeys(data, out parsed)) + { + return false; + } + + if (!TryFormatParsedKey(parsed.Codepoint, parsed.Modifier, parsed.BaseLayoutKey, out var keyName)) + return false; + + var alternateKey = parsed.AlternateCodepoint is { } alternateCodepoint + ? FormatAlternateKeyName(NormalizeKittyFunctionalCodepoint(alternateCodepoint)) + : null; + + inputEvent = CreateKeyEvent(keyName, parsed.EventType, alternateKey, data); + return true; + } + /// /// 将 ASCII 控制字符解析成 Ctrl 组合键 /// @@ -142,7 +275,33 @@ public sealed class DefaultInputParser : IInputParser } /// - /// 解析带修饰键参数的 CSI 序列 + /// 解析 legacy Alt 前缀形式 ESC + 单个可打印字符或控制字符 + /// + private static bool TryParseAltPrefixedKey(string data, out string keyName) + { + keyName = string.Empty; + + if (data.Length != 2 || data[0] != '\e') + return false; + + var ch = data[1]; + if (ch is >= '\x01' and <= '\x1a') + { + keyName = KeyNames.Alt(KeyNames.Ctrl(((char)('a' + ch - 1)).ToString())); + return true; + } + + if (ch is >= ' ' and <= '~') + { + keyName = KeyNames.Alt(ch.ToString()); + return true; + } + + return false; + } + + /// + /// 解析带修饰键参数的 legacy CSI 序列 /// private static bool TryParseModifiedCsi(string data, out string keyName) { @@ -164,8 +323,8 @@ public sealed class DefaultInputParser : IInputParser if (parts.Length == 0) return false; - var modifier = parts.Length >= 2 ? parts[^1] : final == 'Z' ? "2" : string.Empty; - if (!int.TryParse(modifier, out var modifierCode)) + var modifier = parts.Length >= 2 ? parts[^1].Split(':')[0] : final == 'Z' ? "2" : string.Empty; + if (!int.TryParse(modifier, NumberStyles.None, CultureInfo.InvariantCulture, out var modifierCode)) return false; keyName = ApplyModifier(baseKey, modifierCode); @@ -173,15 +332,18 @@ public sealed class DefaultInputParser : IInputParser } /// - /// 解析以波浪号结尾的 CSI 功能键序列 + /// 解析以波浪号结尾的 legacy CSI 功能键序列 /// private static bool TryParseTildeCsi(string payload, out string keyName) { keyName = string.Empty; var parts = payload.Split(';'); - if (!int.TryParse(parts[0], out var keyCode) || !CsiTildeKeyMap.TryGetValue(keyCode, out var baseKey)) + if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var keyCode) || + !CsiTildeKeyMap.TryGetValue(keyCode, out var baseKey)) + { return false; + } if (parts.Length == 1) { @@ -189,7 +351,8 @@ public sealed class DefaultInputParser : IInputParser return true; } - if (!int.TryParse(parts[^1], out var modifierCode)) + var modifier = parts[^1].Split(':')[0]; + if (!int.TryParse(modifier, NumberStyles.None, CultureInfo.InvariantCulture, out var modifierCode)) return false; keyName = ApplyModifier(baseKey, modifierCode); @@ -208,10 +371,290 @@ public sealed class DefaultInputParser : IInputParser var key = baseKey; // xterm modifier 位: 1=Shift 2=Alt 4=Ctrl - if ((modifier & 4) != 0) key = KeyNames.Ctrl(key); - if ((modifier & 2) != 0) key = KeyNames.Alt(key); - if ((modifier & 1) != 0) key = KeyNames.Shift(key); + if ((modifier & CtrlModifier) != 0) key = KeyNames.Ctrl(key); + if ((modifier & AltModifier) != 0) key = KeyNames.Alt(key); + if ((modifier & ShiftModifier) != 0) key = KeyNames.Shift(key); return key; } + + /// + /// 解析 Kitty CSI-u 以及带 Kitty event type 的方向键和功能键序列 + /// + private static bool TryParseKittySequence(string data, out ParsedProtocolKey parsed) + { + parsed = default; + + if (!data.StartsWith("\e[", StringComparison.Ordinal) || data.Length < 4) + return false; + + var final = data[^1]; + var payload = data[2..^1]; + + if (final == 'u') + return TryParseKittyCsiU(payload, out parsed); + + if (final is 'A' or 'B' or 'C' or 'D') + return TryParseKittyModifiedFinal(payload, KittyArrowCodepoints[final], out parsed); + + if (final is 'H' or 'F') + { + var codepoint = final == 'H' ? -14 : -15; + return TryParseKittyModifiedFinal(payload, codepoint, out parsed); + } + + return final == '~' && TryParseKittyTilde(payload, out parsed); + } + + /// + /// 解析 Kitty CSI-u 主格式 codepoint[:shifted[:base]][;modifier[:event]] + /// + private static bool TryParseKittyCsiU(string payload, out ParsedProtocolKey parsed) + { + parsed = default; + + var parts = payload.Split(';'); + if (parts.Length is < 1 or > 2) + return false; + + var codepointParts = parts[0].Split(':'); + if (codepointParts.Length is < 1 or > 3 || + !TryParseInt(codepointParts[0], out var codepoint)) + { + return false; + } + + var alternateCodepoint = TryParseOptionalCodepoint(codepointParts, 1); + var baseLayoutKey = TryParseOptionalCodepoint(codepointParts, 2); + var modifier = 0; + var eventType = TuiKeyEventType.Press; + + if (parts.Length == 2 && !TryParseModifierAndEvent(parts[1], out modifier, out eventType)) + return false; + + parsed = new ParsedProtocolKey(codepoint, modifier, eventType, alternateCodepoint, baseLayoutKey); + return true; + } + + /// + /// 解析 Kitty 扩展的方向键 Home End 等 final-byte 序列 + /// + private static bool TryParseKittyModifiedFinal(string payload, int codepoint, out ParsedProtocolKey parsed) + { + parsed = default; + + var parts = payload.Split(';'); + if (parts.Length != 2 || parts[0] != "1" || !parts[1].Contains(':', StringComparison.Ordinal)) + return false; + + if (!TryParseModifierAndEvent(parts[1], out var modifier, out var eventType)) + return false; + + parsed = new ParsedProtocolKey(codepoint, modifier, eventType); + return true; + } + + /// + /// 解析 Kitty 扩展的功能键 tilde 序列 + /// + private static bool TryParseKittyTilde(string payload, out ParsedProtocolKey parsed) + { + parsed = default; + + var parts = payload.Split(';'); + if (parts.Length is < 1 or > 2 || + !TryParseInt(parts[0], out var keyCode) || + !KittyTildeFunctionalCodepoints.TryGetValue(keyCode, out var codepoint)) + { + return false; + } + + if (parts.Length == 2 && !parts[1].Contains(':', StringComparison.Ordinal)) + return false; + + var modifier = 0; + var eventType = TuiKeyEventType.Press; + + if (parts.Length == 2 && !TryParseModifierAndEvent(parts[1], out modifier, out eventType)) + return false; + + parsed = new ParsedProtocolKey(codepoint, modifier, eventType); + return true; + } + + /// + /// 解析 xterm modifyOtherKeys 格式 CSI 27 ; modifier ; codepoint ~ + /// + private static bool TryParseModifyOtherKeys(string data, out ParsedProtocolKey parsed) + { + parsed = default; + + if (!data.StartsWith("\e[27;", StringComparison.Ordinal) || !data.EndsWith('~')) + return false; + + var parts = data[2..^1].Split(';'); + if (parts.Length != 3 || + parts[0] != "27" || + !TryParseInt(parts[1], out var modifierCode) || + !TryParseInt(parts[2], out var codepoint)) + { + return false; + } + + parsed = new ParsedProtocolKey(codepoint, Math.Max(0, modifierCode - 1), TuiKeyEventType.Press); + return true; + } + + /// + /// 将协议 codepoint 和 modifier 转成框架使用的 key name + /// + private static bool TryFormatParsedKey(int codepoint, int modifier, int? baseLayoutKey, out string keyName) + { + keyName = string.Empty; + + var normalizedCodepoint = NormalizeKittyFunctionalCodepoint(codepoint); + var identityCodepoint = NormalizeShiftedLetterIdentityCodepoint(normalizedCodepoint, modifier); + + // Latin 字母 数字和已知符号以实际 codepoint 为准 避免 physical base key 导致 Dvorak 等布局误匹配 + var usePrimaryCodepoint = IsLatinLetter(identityCodepoint) || + IsDigit(identityCodepoint) || + SymbolKeyCodepoints.Contains(identityCodepoint); + var effectiveCodepoint = usePrimaryCodepoint ? identityCodepoint : baseLayoutKey ?? identityCodepoint; + + var baseKey = FormatCodepointKeyName(effectiveCodepoint); + if (baseKey is null) + return false; + + keyName = FormatKeyNameWithModifiers(baseKey, modifier); + return keyName.Length > 0; + } + + /// + /// 将终端 codepoint 映射为不带修饰键的标准 key name + /// + private static string? FormatCodepointKeyName(int codepoint) + => codepoint switch + { + 27 => KeyNames.Escape, + 9 => KeyNames.Tab, + 13 or 57414 => KeyNames.Enter, + 32 => "space", + 127 => KeyNames.Backspace, + -10 => KeyNames.Delete, + -11 => KeyNames.Insert, + -12 => KeyNames.PageUp, + -13 => KeyNames.PageDown, + -14 => KeyNames.Home, + -15 => KeyNames.End, + -1 => KeyNames.Up, + -2 => KeyNames.Down, + -3 => KeyNames.Right, + -4 => KeyNames.Left, + >= '0' and <= '9' => ((char)codepoint).ToString(), + >= 'a' and <= 'z' => ((char)codepoint).ToString(), + _ when SymbolKeyCodepoints.Contains(codepoint) => ((char)codepoint).ToString(), + _ => null, + }; + + /// + /// 格式化 Kitty alternate key 元数据 保留可打印字符原始大小写 + /// + private static string? FormatAlternateKeyName(int codepoint) + => FormatCodepointKeyName(codepoint) ?? (codepoint >= 32 ? char.ConvertFromUtf32(codepoint) : null); + + /// + /// 按 Kitty keypad 等价表归一化功能键 codepoint + /// + private static int NormalizeKittyFunctionalCodepoint(int codepoint) + => KittyFunctionalKeyEquivalents.GetValueOrDefault(codepoint, codepoint); + + /// + /// Shift+大写字母在协议中可能直接报告大写 codepoint 这里恢复成逻辑小写键 + /// + private static int NormalizeShiftedLetterIdentityCodepoint(int codepoint, int modifier) + { + var effectiveModifier = modifier & ~LockModifierMask; + return (effectiveModifier & ShiftModifier) != 0 && codepoint is >= 'A' and <= 'Z' + ? codepoint + 32 + : codepoint; + } + + /// + /// 给协议解析出的基础键名添加修饰键前缀 + /// + private static string FormatKeyNameWithModifiers(string baseKey, int modifier) + { + var effectiveModifier = modifier & ~LockModifierMask; + if ((effectiveModifier & ~(ShiftModifier | AltModifier | CtrlModifier | SuperModifier)) != 0) + return string.Empty; + + var key = baseKey; + if ((effectiveModifier & SuperModifier) != 0) key = $"super+{key}"; + if ((effectiveModifier & AltModifier) != 0) key = KeyNames.Alt(key); + if ((effectiveModifier & CtrlModifier) != 0) key = KeyNames.Ctrl(key); + if ((effectiveModifier & ShiftModifier) != 0) key = KeyNames.Shift(key); + return key; + } + + /// + /// 解析 Kitty modifier[:event] 字段并转换为零基 modifier bitmask + /// + private static bool TryParseModifierAndEvent(string value, out int modifier, out TuiKeyEventType eventType) + { + modifier = 0; + eventType = TuiKeyEventType.Press; + + var parts = value.Split(':'); + if (parts.Length is < 1 or > 2 || !TryParseInt(parts[0], out var modifierCode)) + return false; + + modifier = Math.Max(0, modifierCode - 1); + + if (parts.Length == 2) + eventType = ParseEventType(parts[1]); + + return true; + } + + /// + /// 解析 Kitty event type 数字 1=press 2=repeat 3=release + /// + private static TuiKeyEventType ParseEventType(string value) + => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var eventType) + ? eventType switch + { + 2 => TuiKeyEventType.Repeat, + 3 => TuiKeyEventType.Release, + _ => TuiKeyEventType.Press, + } + : TuiKeyEventType.Press; + + /// + /// 解析可选 alternate key codepoint 空字段表示没有该值 + /// + private static int? TryParseOptionalCodepoint(string[] parts, int index) + => index < parts.Length && parts[index].Length > 0 && TryParseInt(parts[index], out var value) + ? value + : null; + + private static bool TryParseInt(string value, out int result) + => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result); + + private static bool IsLatinLetter(int codepoint) => codepoint is >= 'a' and <= 'z'; + + private static bool IsDigit(int codepoint) => codepoint is >= '0' and <= '9'; + + private static TuiInputEvent CreateKeyEvent( + string keyName, + TuiKeyEventType eventType = TuiKeyEventType.Press, + string? alternateKey = null, + string? rawSequence = null) + => new(TuiInputEventKind.Key, keyName, eventType, alternateKey, rawSequence); + + private readonly record struct ParsedProtocolKey( + int Codepoint, + int Modifier, + TuiKeyEventType EventType, + int? AlternateCodepoint = null, + int? BaseLayoutKey = null); } diff --git a/src/TinyTUI/Input/KeybindingRegistry.cs b/src/TinyTUI/Input/KeybindingRegistry.cs index ec1c2ac..75ea8f7 100644 --- a/src/TinyTUI/Input/KeybindingRegistry.cs +++ b/src/TinyTUI/Input/KeybindingRegistry.cs @@ -37,6 +37,9 @@ public sealed class KeybindingRegistry if (input.Kind != TuiInputEventKind.Key) return false; + if (input.EventType == TuiKeyEventType.Release) + return false; + return _resolvedBindings.TryGetValue(action, out var keys) && keys.Any(key => key.Matches(input)); } diff --git a/src/TinyTUI/Input/TuiInputEvent.cs b/src/TinyTUI/Input/TuiInputEvent.cs index eb9aa35..744249e 100644 --- a/src/TinyTUI/Input/TuiInputEvent.cs +++ b/src/TinyTUI/Input/TuiInputEvent.cs @@ -26,7 +26,33 @@ public enum TuiInputEventKind Resize, } +/// +/// 标识终端键盘协议报告的按键阶段 +/// +public enum TuiKeyEventType +{ + /// + /// 按键按下 + /// + Press, + + /// + /// 按键自动重复 + /// + Repeat, + + /// + /// 按键释放 + /// + Release, +} + /// /// 表示原始终端数据解析后的标准化输入事件 /// -public sealed record TuiInputEvent(TuiInputEventKind Kind, string Value); +public sealed record TuiInputEvent( + TuiInputEventKind Kind, + string Value, + TuiKeyEventType EventType = TuiKeyEventType.Press, + string? AlternateKey = null, + string? RawSequence = null); diff --git a/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs b/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs index d537882..acc718c 100644 --- a/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs +++ b/test/TinyTUI.Tests/Input/InputInfrastructureTests.cs @@ -58,4 +58,88 @@ public sealed class InputInfrastructureTests Assert.Contains(TuiKeybindings.InputSubmit, conflict.Actions); Assert.Contains(TuiKeybindings.InputCancel, conflict.Actions); } + + [Fact] + public void DefaultInputParserParsesShiftTabAndAltLetters() + { + var parser = new DefaultInputParser(); + + Assert.Equal( + [new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Shift(KeyNames.Tab))], + parser.Parse("\e[Z")); + Assert.Equal( + [new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Alt("a"))], + parser.Parse("\ea")); + } + + [Fact] + public void DefaultInputParserParsesKittyCsiUKeys() + { + var parser = new DefaultInputParser(); + + Assert.Equal( + [new TuiInputEvent(TuiInputEventKind.Key, "a", RawSequence: "\e[97u")], + parser.Parse("\e[97u")); + Assert.Equal( + [new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Ctrl("a"), RawSequence: "\e[97;5u")], + parser.Parse("\e[97;5u")); + Assert.Equal( + [new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Shift("e"), RawSequence: "\e[69;2u")], + parser.Parse("\e[69;2u")); + + var repeat = Assert.Single(parser.Parse("\e[97;1:2u")); + Assert.Equal(TuiInputEventKind.Key, repeat.Kind); + Assert.Equal("a", repeat.Value); + Assert.Equal(TuiKeyEventType.Repeat, repeat.EventType); + Assert.Equal("\e[97;1:2u", repeat.RawSequence); + } + + [Fact] + public void DefaultInputParserCarriesKittyAlternateKeyMetadata() + { + var parser = new DefaultInputParser(); + + var input = Assert.Single(parser.Parse("\e[1089::99;5u")); + + Assert.Equal(TuiInputEventKind.Key, input.Kind); + Assert.Equal(KeyNames.Ctrl("c"), input.Value); + Assert.Null(input.AlternateKey); + Assert.Equal("\e[1089::99;5u", input.RawSequence); + + var shifted = Assert.Single(parser.Parse("\e[99:67:99;2u")); + Assert.Equal(KeyNames.Shift("c"), shifted.Value); + Assert.Equal("C", shifted.AlternateKey); + } + + [Fact] + public void DefaultInputParserParsesModifyOtherKeysCombinations() + { + var parser = new DefaultInputParser(); + + Assert.Equal( + [new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Ctrl("c"), RawSequence: "\e[27;5;99~")], + parser.Parse("\e[27;5;99~")); + Assert.Equal( + [new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Shift(KeyNames.Tab), RawSequence: "\e[27;2;9~")], + parser.Parse("\e[27;2;9~")); + Assert.Equal( + [new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Alt(KeyNames.Enter), RawSequence: "\e[27;3;13~")], + parser.Parse("\e[27;3;13~")); + } + + [Fact] + public void ReleaseEventsDoNotDispatchOrMatchKeybindingsByDefault() + { + var parser = new DefaultInputParser(); + Assert.Empty(parser.Parse("\e[97;1:3u")); + + var registry = KeybindingRegistry.CreateDefault(); + var release = new TuiInputEvent( + TuiInputEventKind.Key, + KeyNames.Enter, + TuiKeyEventType.Release, + RawSequence: "\e[13;1:3u"); + + Assert.False(registry.Matches(release, TuiKeybindings.InputSubmit)); + } }