From 2ab090d72423209ab4d13af8ede6224ad95b18fe Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 00:37:20 +0800 Subject: [PATCH] feat: buffer terminal input sequences - add stdin buffer for escape sequences and bracketed paste - parse control keys and CSI modifier combinations - simplify example to focus on interactive components --- src/Example/Program.cs | 26 +-- src/TinyTUI/Input/DefaultInputParser.cs | 188 ++++++++++++++++------ src/TinyTUI/Input/KeyNames.cs | 15 ++ src/TinyTUI/Input/StdinBuffer.cs | 203 ++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 70 deletions(-) create mode 100644 src/TinyTUI/Input/StdinBuffer.cs diff --git a/src/Example/Program.cs b/src/Example/Program.cs index 942aeee..4bc4a8d 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -19,18 +19,9 @@ var done = new ManualResetEventSlim(); var eventsText = new Text("最近事件:\n- 无"); var statusText = new Text(); var loader = new Loader(textMeasurer) { Message = "运行中" }; -var selectPreview = new SelectList(textMeasurer) { Height = 4 }; -var markdownPreview = new Markdown( - "# Markdown 预览\n" + - "## 二级标题会转成大写\n" + - "- 列表项会转成星号前缀\n" + - "> 引用会转成竖线前缀\n" + - "支持 **粗体标记清理** 和 `行内代码标记清理`"); using var loaderCancellation = new CancellationTokenSource(); var input = new TinyTUI.Components.Input(textMeasurer) { Prompt = "请输入: " }; -selectPreview.SetItems(["Text", "Input", "Editor", "SelectList", "Markdown", "Loader", "Overlay"]); - input.OnChanged = value => UpdateStatus(statusText, value, textMeasurer, renderer); input.OnSubmitted = value => { @@ -67,9 +58,8 @@ AddAll( page, new Text("TinyTUI 基础组件示例"), new Text("======================"), - new Markdown( - "# 操作说明\n" + - "- Markdown Loader SelectList 预览会在页面启动时直接显示\n" + + new Text( + "操作说明\n" + "- 输入文本后 Enter 提交\n" + "- /help 打开帮助弹层\n" + "- /select 打开可交互选择列表\n" + @@ -78,13 +68,7 @@ AddAll( new Text(), new Box(statusText, textMeasurer) { Title = "状态" }, new Text(), - new Box(markdownPreview, textMeasurer) { Title = "Markdown 预览" }, - new Text(), - new Box(loader, textMeasurer) { Title = "Loader" }, - new Text(), - new Box(selectPreview, textMeasurer) { Title = "SelectList 预览" }, - new Text(), - new Box(input, textMeasurer) { Title = "Input" }, + new Box(new Container { Children = { loader, input } }, textMeasurer) { Title = "Input" }, new Text(), new Box(eventsText, textMeasurer) { Title = "事件" }); @@ -160,8 +144,8 @@ static void AddAll(Container container, params IComponent[] components) file sealed class HelpOverlay(ITuiRuntime runtime, ITextMeasurer textMeasurer) : IInputComponent { private readonly Box _box = new( - new Markdown( - "# Overlay 示例\n" + + new Text( + "Overlay 示例\n" + "\n" + "- 这个弹层由 Runtime 显示\n" + "- OverlayManager 会把它合成到基础页面上\n" + diff --git a/src/TinyTUI/Input/DefaultInputParser.cs b/src/TinyTUI/Input/DefaultInputParser.cs index 0b68375..f8b8b75 100644 --- a/src/TinyTUI/Input/DefaultInputParser.cs +++ b/src/TinyTUI/Input/DefaultInputParser.cs @@ -5,9 +5,36 @@ namespace TinyTUI.Input; /// public sealed class DefaultInputParser : IInputParser { - private const string PasteStart = "\e[200~"; - private const string PasteEnd = "\e[201~"; + private static readonly Dictionary CsiFinalKeyMap = new() + { + ['A'] = KeyNames.Up, + ['B'] = KeyNames.Down, + ['C'] = KeyNames.Right, + ['D'] = KeyNames.Left, + ['H'] = KeyNames.Home, + ['F'] = KeyNames.End, + ['Z'] = KeyNames.Tab, + }; + private static readonly Dictionary CsiTildeKeyMap = new() + { + [1] = KeyNames.Home, + [2] = KeyNames.Insert, + [3] = KeyNames.Delete, + [4] = KeyNames.End, + [5] = KeyNames.PageUp, + [6] = KeyNames.PageDown, + [15] = "f5", + [17] = "f6", + [18] = "f7", + [19] = "f8", + [20] = "f9", + [21] = "f10", + [23] = "f11", + [24] = "f12", + }; + + private readonly StdinBuffer _stdinBuffer = new(emitSingleEscapeImmediately: true); private readonly Dictionary _keyMap = new() { ["\r"] = KeyNames.Enter, @@ -40,8 +67,6 @@ public sealed class DefaultInputParser : IInputParser ["\e[24~"] = "f12", }; - private string? _pasteBuffer; - /// public IReadOnlyList Parse(string data) { @@ -51,54 +76,21 @@ public sealed class DefaultInputParser : IInputParser } var events = new List(); - ParseInto(data, events); + + foreach (var bufferEvent in _stdinBuffer.Process(data)) + { + if (bufferEvent.Kind == StdinBufferEventKind.Paste) + { + events.Add(new TuiInputEvent(TuiInputEventKind.Paste, bufferEvent.Value)); + continue; + } + + ParseRegular(bufferEvent.Value, events); + } + return events; } - /// - /// 根据当前是否处于粘贴模式分派解析流程 - /// - private void ParseInto(string data, List events) - { - if (_pasteBuffer is not null) - { - AppendPaste(data, events); - return; - } - - var pasteStart = data.IndexOf(PasteStart, StringComparison.Ordinal); - if (pasteStart >= 0) - { - // 粘贴开始标记之前可能还有普通按键 需要先解析掉 - ParseRegular(data[..pasteStart], events); - _pasteBuffer = string.Empty; - AppendPaste(data[(pasteStart + PasteStart.Length)..], events); - return; - } - - ParseRegular(data, events); - } - - /// - /// 累积 bracketed paste 内容直到遇到结束标记 - /// - private void AppendPaste(string data, List events) - { - var pasteEnd = data.IndexOf(PasteEnd, StringComparison.Ordinal); - if (pasteEnd < 0) - { - // stdin 可能把一次粘贴拆成多个 chunk 因此需要跨 Parse 调用保存内容 - _pasteBuffer += data; - return; - } - - events.Add(new TuiInputEvent(TuiInputEventKind.Paste, _pasteBuffer + data[..pasteEnd])); - _pasteBuffer = null; - - // 粘贴结束标记之后可能继续跟着普通输入 递归回主流程处理剩余部分 - ParseInto(data[(pasteEnd + PasteEnd.Length)..], events); - } - /// /// 解析非粘贴输入为文本事件或按键事件 /// @@ -113,6 +105,12 @@ public sealed class DefaultInputParser : IInputParser return; } + if (TryParseControlCharacter(data, out keyName) || TryParseModifiedCsi(data, out keyName)) + { + events.Add(new TuiInputEvent(TuiInputEventKind.Key, keyName)); + return; + } + if (data.StartsWith('\e')) { // 未识别的 escape 序列仍按 Key 透传 方便后续组件或更高层扩展处理 @@ -122,4 +120,96 @@ public sealed class DefaultInputParser : IInputParser events.Add(new TuiInputEvent(TuiInputEventKind.Text, data)); } + + /// + /// 将 ASCII 控制字符解析成 Ctrl 组合键 + /// + private static bool TryParseControlCharacter(string data, out string keyName) + { + keyName = string.Empty; + + if (data.Length != 1) + return false; + + var ch = data[0]; + if (ch is < '\x01' or > '\x1a') + return false; + + keyName = KeyNames.Ctrl(((char)('a' + ch - 1)).ToString()); + return true; + } + + /// + /// 解析带修饰键参数的 CSI 序列 + /// + private static bool TryParseModifiedCsi(string data, out string keyName) + { + keyName = string.Empty; + + if (!data.StartsWith("\e[", StringComparison.Ordinal) || data.Length < 4) + return false; + + var final = data[^1]; + var payload = data[2..^1]; + + if (final == '~') + return TryParseTildeCsi(payload, out keyName); + + if (!CsiFinalKeyMap.TryGetValue(final, out var baseKey)) + return false; + + var parts = payload.Split(';'); + 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)) + return false; + + keyName = ApplyModifier(baseKey, modifierCode); + return keyName.Length > 0; + } + + /// + /// 解析以波浪号结尾的 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)) + return false; + + if (parts.Length == 1) + { + keyName = baseKey; + return true; + } + + if (!int.TryParse(parts[^1], out var modifierCode)) + return false; + + keyName = ApplyModifier(baseKey, modifierCode); + return keyName.Length > 0; + } + + /// + /// 按 xterm modifier 编码给基础键名添加修饰键前缀 + /// + private static string ApplyModifier(string baseKey, int modifierCode) + { + var modifier = modifierCode - 1; + if (modifier <= 0) + return baseKey; + + 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); + + return key; + } } diff --git a/src/TinyTUI/Input/KeyNames.cs b/src/TinyTUI/Input/KeyNames.cs index b01e50d..7e90e59 100644 --- a/src/TinyTUI/Input/KeyNames.cs +++ b/src/TinyTUI/Input/KeyNames.cs @@ -74,4 +74,19 @@ public static class KeyNames /// 下翻页键 /// public const string PageDown = "page-down"; + + /// + /// 创建 Ctrl 组合键名称 + /// + public static string Ctrl(string key) => $"ctrl+{key}"; + + /// + /// 创建 Alt 组合键名称 + /// + public static string Alt(string key) => $"alt+{key}"; + + /// + /// 创建 Shift 组合键名称 + /// + public static string Shift(string key) => $"shift+{key}"; } diff --git a/src/TinyTUI/Input/StdinBuffer.cs b/src/TinyTUI/Input/StdinBuffer.cs new file mode 100644 index 0000000..522426b --- /dev/null +++ b/src/TinyTUI/Input/StdinBuffer.cs @@ -0,0 +1,203 @@ +namespace TinyTUI.Input; + +/// +/// 表示输入缓冲器拆出的完整输入片段类型 +/// +public enum StdinBufferEventKind +{ + /// + /// 普通输入片段或完整终端控制序列 + /// + Data, + + /// + /// bracketed paste 包裹的一整段粘贴内容 + /// + Paste, +} + +/// +/// 表示输入缓冲器拆出的完整输入片段 +/// +public sealed record StdinBufferEvent(StdinBufferEventKind Kind, string Value); + +/// +/// 将 stdin chunk 缓冲并拆成完整终端输入序列 +/// +public sealed class StdinBuffer(bool emitSingleEscapeImmediately = false) +{ + private const char Escape = '\e'; + private const string PasteStart = "\e[200~"; + private const string PasteEnd = "\e[201~"; + + private string _buffer = string.Empty; + private string? _pasteBuffer; + + /// + /// 处理新的输入 chunk 并返回当前已经完整的输入片段 + /// + public IReadOnlyList Process(string data) + { + if (data.Length == 0) + return []; + + _buffer += data; + + var events = new List(); + Drain(events); + return events; + } + + /// + /// 强制吐出当前缓冲区中尚未完整的输入片段 + /// + public IReadOnlyList Flush() + { + var events = new List(); + + if (_pasteBuffer is not null) + { + events.Add(new StdinBufferEvent(StdinBufferEventKind.Paste, _pasteBuffer + _buffer)); + _pasteBuffer = null; + _buffer = string.Empty; + return events; + } + + if (_buffer.Length > 0) + { + events.Add(new StdinBufferEvent(StdinBufferEventKind.Data, _buffer)); + _buffer = string.Empty; + } + + return events; + } + + /// + /// 从缓冲区连续读取完整片段 直到遇到不完整 escape 序列 + /// + private void Drain(List events) + { + while (_buffer.Length > 0) + { + if (_pasteBuffer is not null) + { + DrainPaste(events); + continue; + } + + if (_buffer.StartsWith(PasteStart, StringComparison.Ordinal)) + { + _buffer = _buffer[PasteStart.Length..]; + _pasteBuffer = string.Empty; + continue; + } + + if (_buffer[0] == Escape) + { + if (!TryReadEscapeSequence(_buffer, out var sequence, out var incomplete)) + break; + + if (incomplete) + break; + + events.Add(new StdinBufferEvent(StdinBufferEventKind.Data, sequence)); + _buffer = _buffer[sequence.Length..]; + continue; + } + + var nextEscape = _buffer.IndexOf(Escape); + var text = nextEscape < 0 ? _buffer : _buffer[..nextEscape]; + events.Add(new StdinBufferEvent(StdinBufferEventKind.Data, text)); + _buffer = nextEscape < 0 ? string.Empty : _buffer[nextEscape..]; + } + } + + /// + /// 继续收集 bracketed paste 内容直到结束标记出现 + /// + private void DrainPaste(List events) + { + var pasteEnd = _buffer.IndexOf(PasteEnd, StringComparison.Ordinal); + if (pasteEnd < 0) + { + _pasteBuffer += _buffer; + _buffer = string.Empty; + return; + } + + events.Add(new StdinBufferEvent(StdinBufferEventKind.Paste, _pasteBuffer + _buffer[..pasteEnd])); + _pasteBuffer = null; + _buffer = _buffer[(pasteEnd + PasteEnd.Length)..]; + } + + /// + /// 尝试从缓冲区开头读取一个完整 escape 序列 + /// + private bool TryReadEscapeSequence(string value, out string sequence, out bool incomplete) + { + sequence = string.Empty; + incomplete = false; + + if (value.Length == 1) + { + if (emitSingleEscapeImmediately) + { + // ConsoleTerminalInput 已经按键级编码 单独 ESC 应立即成为 Escape 键 + sequence = value; + return true; + } + + incomplete = true; + return false; + } + + var status = value[1] switch + { + '[' => ReadCsi(value), + ']' or '_' or 'P' => ReadStringEscape(value), + 'O' => value.Length >= 3 ? 3 : -1, + _ => 2, + }; + + if (status < 0) + { + incomplete = true; + return false; + } + + sequence = value[..status]; + return true; + } + + /// + /// 读取 CSI 序列 ESC [ ... final-byte + /// + private static int ReadCsi(string value) + { + for (var index = 2; index < value.Length; index++) + { + var ch = value[index]; + if (ch is >= '@' and <= '~') + return index + 1; + } + + return -1; + } + + /// + /// 读取 OSC APC DCS 这类字符串型 escape 序列 + /// + private static int ReadStringEscape(string value) + { + for (var index = 2; index < value.Length; index++) + { + if (value[index] == '\a') + return index + 1; + + if (value[index] == Escape && index + 1 < value.Length && value[index + 1] == '\\') + return index + 2; + } + + return -1; + } +}