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