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:
@@ -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. 渲染管线和视口模型
|
||||
|
||||
目标:把当前按行差分刷新升级成能长期运行、低闪烁、能处理滚动区域和复杂内容的渲染管线
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user