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
This commit is contained in:
chuan
2026-06-04 03:05:38 +08:00
Unverified
parent d84ca3a313
commit e78ce2badf
5 changed files with 601 additions and 19 deletions
+31 -5
View File
@@ -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 keybase 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. 渲染管线和视口模型
目标:把当前按行差分刷新升级成能长期运行、低闪烁、能处理滚动区域和复杂内容的渲染管线
+456 -13
View File
@@ -1,3 +1,5 @@
using System.Globalization;
namespace TinyTUI.Input;
/// <summary>
@@ -5,6 +7,12 @@ namespace TinyTUI.Input;
/// </summary>
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<char, string> CsiFinalKeyMap = new()
{
['A'] = KeyNames.Up,
@@ -16,6 +24,14 @@ public sealed class DefaultInputParser : IInputParser
['Z'] = KeyNames.Tab,
};
private static readonly Dictionary<char, int> KittyArrowCodepoints = new()
{
['A'] = -1,
['B'] = -2,
['C'] = -3,
['D'] = -4,
};
private static readonly Dictionary<int, string> CsiTildeKeyMap = new()
{
[1] = KeyNames.Home,
@@ -36,6 +52,82 @@ public sealed class DefaultInputParser : IInputParser
[24] = "f12",
};
private static readonly Dictionary<int, int> KittyTildeFunctionalCodepoints = new()
{
[2] = -11,
[3] = -10,
[5] = -12,
[6] = -13,
[7] = -14,
[8] = -15,
};
private static readonly Dictionary<int, int> 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<int> SymbolKeyCodepoints =
[
'`',
'-',
'=',
'[',
']',
'\\',
';',
'\'',
',',
'.',
'/',
'!',
'@',
'#',
'$',
'%',
'^',
'&',
'*',
'(',
')',
'_',
'+',
'|',
'~',
'{',
'}',
':',
'<',
'>',
'?',
];
private readonly StdinBuffer _stdinBuffer = new(emitSingleEscapeImmediately: true);
private readonly Dictionary<string, string> _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));
}
/// <summary>
/// 解析带协议元数据的 Kitty CSI-u 和 modifyOtherKeys 序列
/// </summary>
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;
}
/// <summary>
/// 将 ASCII 控制字符解析成 Ctrl 组合键
/// </summary>
@@ -142,7 +275,33 @@ public sealed class DefaultInputParser : IInputParser
}
/// <summary>
/// 解析带修饰键参数的 CSI 序列
/// 解析 legacy Alt 前缀形式 ESC + 单个可打印字符或控制字符
/// </summary>
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;
}
/// <summary>
/// 解析带修饰键参数的 legacy CSI 序列
/// </summary>
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
}
/// <summary>
/// 解析以波浪号结尾的 CSI 功能键序列
/// 解析以波浪号结尾的 legacy CSI 功能键序列
/// </summary>
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;
}
/// <summary>
/// 解析 Kitty CSI-u 以及带 Kitty event type 的方向键和功能键序列
/// </summary>
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);
}
/// <summary>
/// 解析 Kitty CSI-u 主格式 codepoint[:shifted[:base]][;modifier[:event]]
/// </summary>
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;
}
/// <summary>
/// 解析 Kitty 扩展的方向键 Home End 等 final-byte 序列
/// </summary>
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;
}
/// <summary>
/// 解析 Kitty 扩展的功能键 tilde 序列
/// </summary>
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;
}
/// <summary>
/// 解析 xterm modifyOtherKeys 格式 CSI 27 ; modifier ; codepoint ~
/// </summary>
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;
}
/// <summary>
/// 将协议 codepoint 和 modifier 转成框架使用的 key name
/// </summary>
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;
}
/// <summary>
/// 将终端 codepoint 映射为不带修饰键的标准 key name
/// </summary>
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,
};
/// <summary>
/// 格式化 Kitty alternate key 元数据 保留可打印字符原始大小写
/// </summary>
private static string? FormatAlternateKeyName(int codepoint)
=> FormatCodepointKeyName(codepoint) ?? (codepoint >= 32 ? char.ConvertFromUtf32(codepoint) : null);
/// <summary>
/// 按 Kitty keypad 等价表归一化功能键 codepoint
/// </summary>
private static int NormalizeKittyFunctionalCodepoint(int codepoint)
=> KittyFunctionalKeyEquivalents.GetValueOrDefault(codepoint, codepoint);
/// <summary>
/// Shift+大写字母在协议中可能直接报告大写 codepoint 这里恢复成逻辑小写键
/// </summary>
private static int NormalizeShiftedLetterIdentityCodepoint(int codepoint, int modifier)
{
var effectiveModifier = modifier & ~LockModifierMask;
return (effectiveModifier & ShiftModifier) != 0 && codepoint is >= 'A' and <= 'Z'
? codepoint + 32
: codepoint;
}
/// <summary>
/// 给协议解析出的基础键名添加修饰键前缀
/// </summary>
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;
}
/// <summary>
/// 解析 Kitty modifier[:event] 字段并转换为零基 modifier bitmask
/// </summary>
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;
}
/// <summary>
/// 解析 Kitty event type 数字 1=press 2=repeat 3=release
/// </summary>
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;
/// <summary>
/// 解析可选 alternate key codepoint 空字段表示没有该值
/// </summary>
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);
}
+3
View File
@@ -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));
}
+27 -1
View File
@@ -26,7 +26,33 @@ public enum TuiInputEventKind
Resize,
}
/// <summary>
/// 标识终端键盘协议报告的按键阶段
/// </summary>
public enum TuiKeyEventType
{
/// <summary>
/// 按键按下
/// </summary>
Press,
/// <summary>
/// 按键自动重复
/// </summary>
Repeat,
/// <summary>
/// 按键释放
/// </summary>
Release,
}
/// <summary>
/// 表示原始终端数据解析后的标准化输入事件
/// </summary>
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);
@@ -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));
}
}