From 47ce4433279aff336480137e3608c5d7c1da1ff2 Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 00:56:38 +0800 Subject: [PATCH] feat: improve editor interactions - add editor history, word navigation, and undo redo - preserve overlay cursor focus when editor is open - demonstrate editor shortcuts in Example --- TODO.md | 35 +- src/Example/Program.cs | 35 +- src/TinyTUI/Components/Editor.cs | 325 +++++++++++++++++- src/TinyTUI/Input/DefaultInputParser.cs | 1 + src/TinyTUI/Overlay/OverlayManager.cs | 13 + src/TinyTUI/Rendering/DifferentialRenderer.cs | 6 +- src/TinyTUI/Rendering/FullScreenRenderer.cs | 1 + src/TinyTUI/Stdio/ConsoleTerminalInput.cs | 89 +++-- 8 files changed, 433 insertions(+), 72 deletions(-) diff --git a/TODO.md b/TODO.md index 07a4b09..4da4b65 100644 --- a/TODO.md +++ b/TODO.md @@ -19,17 +19,17 @@ TinyTUI 现在已经不是空项目,已经具备一个最小可运行的 C# TU - 已实现文本宽度模块,能处理 ANSI、OSC、APC、中文、emoji、截断 - 已实现基础组件:`Text`、`Container`、`Box`、`Input` - 已实现第一版高级组件:`Editor`、`SelectList`、`Markdown`、`Loader` -- 已实现第一版 Overlay 管理器,支持弹层显示、隐藏、合成和焦点恢复 -- 已更新 Example,启动后能直接展示 Markdown、Loader、SelectList,并通过命令验证 overlay、选择列表和编辑器 +- 已实现输入缓冲器 `StdinBuffer`,并接入默认输入解析器 +- 已扩展组合键解析,支持 `ctrl+x`、`alt+x`、`shift+tab`、`ctrl+left`、`ctrl+right` 这类按键名称 +- 已实现可配置 Overlay 管理器,支持宽度、最小宽度、最大高度、anchor、row、column、margin、visible、non-capturing 和 overlay handle 焦点控制 +- 已补充 Editor 的历史记录浏览、Ctrl+A/E、词级移动、词级删除、undo / redo +- 已更新 Example,启动后能直接验证 Loader、Input、overlay 定位、non-capturing overlay、选择列表和编辑器快捷键 ## 当前缺口 -- 输入层还缺 `tmp/tui/src/stdin-buffer.ts` 这种输入缓冲器,真实终端可能把 escape sequence 拆成多个 chunk - 输入层还缺 Kitty keyboard protocol / modifyOtherKeys 这类高级键盘协议协商,组合键识别还不够完整 -- Runtime 的焦点模型比较简单,还没有焦点栈、非捕获 overlay、overlay focus/unfocus 这类能力 -- Overlay 目前固定居中,缺少 `width`、`minWidth`、`maxHeight`、`anchor`、`row`、`col`、`margin`、`visible`、`nonCapturing` 等选项 - Overlay 合成还比较简单,需要继续处理 ANSI/OSC 样式文本、宽字符边界、终端边缘覆盖和底层样式泄漏 -- Editor 目前只是基础多行输入,还缺历史记录、撤销重做、kill ring、词级移动、自动补全、粘贴摘要、软换行和更完整的光标布局 +- Editor 还缺提交事件和取消事件的清晰语义、kill ring、自动补全、粘贴摘要、软换行和更完整的光标布局 - SelectList 目前只是字符串列表,还缺 value/label/description、过滤、描述列、滚动提示、wrap-around、选择变化事件和主题样式 - Markdown 目前只是轻量纯文本转换,还缺真正 token 解析、行内样式、代码块、链接、引用块、列表缩进、文本换行和缓存 - Text Width 还需要补 grapheme cluster 级别处理,尤其是 ZWJ emoji、regional indicator、variation selector、tab、ANSI 包裹换行 @@ -41,6 +41,8 @@ TinyTUI 现在已经不是空项目,已经具备一个最小可运行的 C# TU ### 1. 输入缓冲和按键解析 +状态:已完成第一阶段,后续只保留高级键盘协议 + 先做这个,因为后续 Editor、SelectList、Overlay 都依赖稳定输入 - 新增 `StdinBuffer`,负责把 stdin chunk 拆成完整输入序列 @@ -52,6 +54,8 @@ TinyTUI 现在已经不是空项目,已经具备一个最小可运行的 C# TU ### 2. Overlay 选项和焦点策略 +状态:已完成第一阶段,后续继续补合成正确性 + Overlay 是菜单、提示、选择列表、编辑器弹窗的基础 - 新增 `OverlayOptions` @@ -66,13 +70,11 @@ Overlay 是菜单、提示、选择列表、编辑器弹窗的基础 ### 3. Editor 升级 +状态:已完成历史记录、Ctrl+A/E、词级移动删除、undo / redo + Editor 是后续交互体验的核心组件 - 增加提交事件和取消事件的清晰语义 -- 支持历史记录浏览 -- 支持 Home / End / Ctrl+A / Ctrl+E -- 支持词级移动和删除 -- 支持 undo / redo - 支持 kill ring - 支持软换行和按终端宽度布局 - 支持粘贴大段文本时生成 paste marker 或摘要 @@ -141,11 +143,8 @@ Markdown 暂时只做展示,不要急着做完整渲染器,但需要比当 ## 近期执行顺序 -1. 实现 `StdinBuffer` 并接入 `ConsoleTerminalInput` -2. 扩展 `DefaultInputParser` 的组合键解析 -3. 升级 `OverlayManager` 的 `OverlayOptions` -4. 用 Example 验证输入拆包、粘贴、overlay 定位和焦点恢复 -5. 升级 `Editor` 的历史记录和词级移动 -6. 升级 `SelectList` 的 item model、filter 和描述列 -7. 升级 Markdown 和 Text Width -8. 功能形态稳定后再引入测试项目 +1. 继续升级 `SelectList` 的 item model、filter 和描述列 +2. 继续补 Editor 的 kill ring、软换行和补全 overlay +3. 升级 Markdown 和 Text Width +4. 处理 Overlay 合成的 ANSI/OSC 样式文本、宽字符边界和底层样式泄漏 +5. 功能形态稳定后再引入测试项目 diff --git a/src/Example/Program.cs b/src/Example/Program.cs index 3644caf..e99bf57 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -18,6 +18,7 @@ using var runtime = new TuiRuntime(terminalInput, parser, renderer); var done = new ManualResetEventSlim(); var eventsText = new Text("最近事件:\n- 无"); var statusText = new Text(); +var editorHistory = new List { "history: first command", "history: second command" }; var loader = new Loader(textMeasurer) { Message = "运行中" }; using var loaderCancellation = new CancellationTokenSource(); var input = new TinyTUI.Components.Input(textMeasurer) { Prompt = "请输入: " }; @@ -62,7 +63,7 @@ input.OnSubmitted = value => case "/edit": AddEvent(eventsText, "Overlay: editor opened"); runtime.ShowOverlay( - new EditorOverlay(runtime, message => AddEvent(eventsText, message), textMeasurer), + new EditorOverlay(runtime, editorHistory, message => AddEvent(eventsText, message), textMeasurer), new OverlayOptions { Width = OverlaySize.Percent(70), @@ -247,21 +248,40 @@ file sealed class SelectOverlay : IInputComponent file sealed class EditorOverlay : IInputComponent { private readonly ITuiRuntime _runtime; + private readonly List _history; private readonly Action _addEvent; private readonly Editor _editor; private readonly Box _box; - public EditorOverlay(ITuiRuntime runtime, Action addEvent, ITextMeasurer textMeasurer) + public EditorOverlay(ITuiRuntime runtime, List history, Action addEvent, ITextMeasurer textMeasurer) { _runtime = runtime; + _history = history; _addEvent = addEvent; _editor = new Editor(textMeasurer) { Height = 6, - Value = "这里可以输入多行文本\nEnter 换行 Esc 提交并关闭", + Placeholder = "在这里输入内容", OnCanceled = SubmitAndClose, }; - _box = new Box(_editor, textMeasurer) { Title = "Editor" }; + _editor.SetHistory(_history); + _box = new Box( + new Container + { + Children = + { + new Text( + "Ctrl+A/E 行首行尾 Ctrl+Left/Right 按词移动\n" + + "Ctrl+Backspace/Delete 按词删除 Ctrl+Z/Y 撤销重做\n" + + "空内容时 Up/Down 浏览历史 Esc 提交并关闭"), + new Text(), + _editor, + }, + }, + textMeasurer) + { + Title = "Editor", + }; } public IReadOnlyList Render(int width) => _box.Render(width); @@ -279,6 +299,13 @@ file sealed class EditorOverlay : IInputComponent private void SubmitAndClose() { + var value = _editor.Value.Trim(); + if (value.Length > 0) + { + _history.Remove(value); + _history.Insert(0, value); + } + _addEvent($"Edited {Math.Max(1, _editor.Value.Split('\n').Length)} line(s)"); _runtime.HideOverlay(); } diff --git a/src/TinyTUI/Components/Editor.cs b/src/TinyTUI/Components/Editor.cs index 4ebe989..4ca28a1 100644 --- a/src/TinyTUI/Components/Editor.cs +++ b/src/TinyTUI/Components/Editor.cs @@ -1,3 +1,4 @@ +using System.Text; using TinyTUI.Input; using TinyTUI.Rendering; using TinyTUI.Text; @@ -11,15 +12,25 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent { private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); private readonly List _lines = [string.Empty]; + private readonly Stack _undoStack = []; + private readonly Stack _redoStack = []; + private readonly List _history = []; private int _cursorRow; private int _cursorColumn; + private int _historyIndex = -1; + private string _historyDraft = string.Empty; /// /// 获取或设置编辑器高度 /// public int Height { get; set; } = 5; + /// + /// 获取或设置空编辑器的输入提示 + /// + public string Placeholder { get; set; } = string.Empty; + /// /// 获取当前编辑器文本 /// @@ -39,6 +50,31 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent /// public Action? OnCanceled { get; set; } + /// + /// 添加一条历史记录用于上下方向键浏览 + /// + public void AddHistory(string value) + { + var trimmed = value.Trim(); + if (trimmed.Length == 0 || (_history.Count > 0 && _history[0] == trimmed)) + return; + + _history.Insert(0, trimmed); + if (_history.Count > 100) + _history.RemoveAt(_history.Count - 1); + } + + /// + /// 使用外部历史记录初始化编辑器历史 + /// + public void SetHistory(IEnumerable values) + { + _history.Clear(); + _history.AddRange(values.Where(static value => value.Trim().Length > 0).Take(100)); + _historyIndex = -1; + _historyDraft = string.Empty; + } + /// public IReadOnlyList Render(int width) { @@ -57,8 +93,13 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent var line = _lines[lineIndex]; if (lineIndex == _cursorRow) + { + if (line.Length == 0 && Placeholder.Length > 0) + line = CursorMarker.Marker + Placeholder; + else // Renderer 会提取这个 marker 并移动硬件光标 所以这里不渲染可见光标字符 - line = InsertCursorMarker(line, _cursorColumn); + line = InsertCursorMarker(line, _cursorColumn); + } rows.Add(_textMeasurer.Truncate(line, width)); } @@ -72,19 +113,43 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent switch (input) { case { Kind: TuiInputEventKind.Text }: - InsertText(input.Value); + Edit(() => InsertText(input.Value)); break; case { Kind: TuiInputEventKind.Paste }: - InsertText(input.Value.Replace("\r\n", "\n")); + Edit(() => InsertText(input.Value.Replace("\r\n", "\n"))); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl("z"): + Undo(); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl("y"): + Redo(); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl("a"): + MoveToLineStart(); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl("e"): + MoveToLineEnd(); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl(KeyNames.Left): + MoveWordLeft(); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl(KeyNames.Right): + MoveWordRight(); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl(KeyNames.Backspace): + Edit(DeleteWordBackward); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl(KeyNames.Delete): + Edit(DeleteWordForward); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Enter }: - SplitLine(); + Edit(SplitLine); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Backspace }: - Backspace(); + Edit(Backspace); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Delete }: - Delete(); + Edit(Delete); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Left }: MoveLeft(); @@ -93,16 +158,16 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent MoveRight(); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Up }: - MoveVertical(-1); + if (!TryNavigateHistory(-1)) MoveVertical(-1); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Down }: - MoveVertical(1); + if (!TryNavigateHistory(1)) MoveVertical(1); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Home }: - _cursorColumn = 0; + MoveToLineStart(); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.End }: - _cursorColumn = GetRuneCount(_lines[_cursorRow]); + MoveToLineEnd(); break; case { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape }: OnCanceled?.Invoke(); @@ -115,6 +180,25 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent /// public void Clear() => SetValue(string.Empty); + /// + /// 对一次文本修改建立撤销快照 + /// + private void Edit(Action action) + { + var before = CaptureSnapshot(); + _historyIndex = -1; + _historyDraft = string.Empty; + + action(); + + if (before.Value == Value) + return; + + _undoStack.Push(before); + _redoStack.Clear(); + OnChanged?.Invoke(Value); + } + /// /// 重置编辑器文本并把光标约束到有效行列 /// @@ -128,6 +212,10 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent _cursorRow = Math.Min(_cursorRow, _lines.Count - 1); _cursorColumn = Math.Min(_cursorColumn, GetRuneCount(_lines[_cursorRow])); + _undoStack.Clear(); + _redoStack.Clear(); + _historyIndex = -1; + _historyDraft = string.Empty; OnChanged?.Invoke(Value); } @@ -142,7 +230,7 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent if (rune.Value == '\n') { - SplitLine(false); + SplitLine(); continue; } @@ -150,7 +238,6 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent InsertAtCursor(rune.ToString()); } - OnChanged?.Invoke(Value); } /// @@ -167,7 +254,7 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent /// /// 将当前行按光标位置拆成上下两行 /// - private void SplitLine(bool notify = true) + private void SplitLine() { var line = _lines[_cursorRow]; var index = GetStringIndex(line, _cursorColumn); @@ -176,8 +263,6 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent _cursorRow++; _cursorColumn = 0; - if (notify) - OnChanged?.Invoke(Value); } /// @@ -192,7 +277,6 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent var end = GetStringIndex(line, _cursorColumn); _lines[_cursorRow] = line.Remove(start, end - start); _cursorColumn--; - OnChanged?.Invoke(Value); return; } @@ -205,7 +289,6 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent _lines.RemoveAt(_cursorRow); _cursorRow--; _cursorColumn = previousLength; - OnChanged?.Invoke(Value); } /// @@ -219,7 +302,6 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent var start = GetStringIndex(line, _cursorColumn); var end = GetStringIndex(line, _cursorColumn + 1); _lines[_cursorRow] = line.Remove(start, end - start); - OnChanged?.Invoke(Value); return; } @@ -229,7 +311,6 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent // 行尾 Delete 与 Backspace 的反向跨行合并保持一致 _lines[_cursorRow] += _lines[_cursorRow + 1]; _lines.RemoveAt(_cursorRow + 1); - OnChanged?.Invoke(Value); } /// @@ -277,6 +358,193 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent _cursorColumn = Math.Min(_cursorColumn, GetRuneCount(_lines[_cursorRow])); } + /// + /// 移动到当前行行首 + /// + private void MoveToLineStart() => _cursorColumn = 0; + + /// + /// 移动到当前行行尾 + /// + private void MoveToLineEnd() => _cursorColumn = GetRuneCount(_lines[_cursorRow]); + + /// + /// 向左移动一个词或标点片段 + /// + private void MoveWordLeft() + { + if (_cursorColumn == 0) + { + MoveLeft(); + return; + } + + _cursorColumn = FindWordStart(_lines[_cursorRow], _cursorColumn); + } + + /// + /// 向右移动一个词或标点片段 + /// + private void MoveWordRight() + { + if (_cursorColumn == GetRuneCount(_lines[_cursorRow])) + { + MoveRight(); + return; + } + + _cursorColumn = FindWordEnd(_lines[_cursorRow], _cursorColumn); + } + + /// + /// 删除光标左侧一个词或标点片段 + /// + private void DeleteWordBackward() + { + if (_cursorColumn == 0) + { + Backspace(); + return; + } + + var line = _lines[_cursorRow]; + var targetColumn = FindWordStart(line, _cursorColumn); + RemoveRuneRange(_cursorRow, targetColumn, _cursorColumn); + _cursorColumn = targetColumn; + } + + /// + /// 删除光标右侧一个词或标点片段 + /// + private void DeleteWordForward() + { + var line = _lines[_cursorRow]; + if (_cursorColumn == GetRuneCount(line)) + { + Delete(); + return; + } + + RemoveRuneRange(_cursorRow, _cursorColumn, FindWordEnd(line, _cursorColumn)); + } + + /// + /// 浏览历史记录并在离开历史时恢复原草稿 + /// + private bool TryNavigateHistory(int direction) + { + if (_history.Count == 0 || !IsSingleEmptyLineOrBrowsing()) + return false; + + var nextIndex = direction < 0 ? _historyIndex + 1 : _historyIndex - 1; + if (nextIndex < -1 || nextIndex >= _history.Count) + return false; + + if (_historyIndex == -1) + _historyDraft = Value; + + _historyIndex = nextIndex; + ApplySnapshot(_historyIndex == -1 + ? EditorSnapshot.FromText(_historyDraft) + : EditorSnapshot.FromText(_history[_historyIndex])); + OnChanged?.Invoke(Value); + return true; + } + + private bool IsSingleEmptyLineOrBrowsing() + => _historyIndex >= 0 || (_lines.Count == 1 && _lines[0].Length == 0); + + /// + /// 恢复上一个编辑快照 + /// + private void Undo() + { + if (!_undoStack.TryPop(out var snapshot)) + return; + + _redoStack.Push(CaptureSnapshot()); + ApplySnapshot(snapshot); + OnChanged?.Invoke(Value); + } + + /// + /// 恢复刚刚撤销的编辑快照 + /// + private void Redo() + { + if (!_redoStack.TryPop(out var snapshot)) + return; + + _undoStack.Push(CaptureSnapshot()); + ApplySnapshot(snapshot); + OnChanged?.Invoke(Value); + } + + private EditorSnapshot CaptureSnapshot() => new([.. _lines], _cursorRow, _cursorColumn); + + private void ApplySnapshot(EditorSnapshot snapshot) + { + _lines.Clear(); + _lines.AddRange(snapshot.Lines.Length == 0 ? [string.Empty] : snapshot.Lines); + _cursorRow = Math.Clamp(snapshot.CursorRow, 0, _lines.Count - 1); + _cursorColumn = Math.Clamp(snapshot.CursorColumn, 0, GetRuneCount(_lines[_cursorRow])); + } + + private void RemoveRuneRange(int row, int startColumn, int endColumn) + { + if (endColumn <= startColumn) + return; + + var line = _lines[row]; + var start = GetStringIndex(line, startColumn); + var end = GetStringIndex(line, endColumn); + _lines[row] = line.Remove(start, end - start); + } + + private static int FindWordStart(string line, int cursorColumn) + { + var runes = line.EnumerateRunes().ToArray(); + var index = Math.Clamp(cursorColumn, 0, runes.Length); + + while (index > 0 && Rune.IsWhiteSpace(runes[index - 1])) + index--; + + if (index == 0) + return 0; + + var kind = GetWordKind(runes[index - 1]); + while (index > 0 && GetWordKind(runes[index - 1]) == kind) + index--; + + return index; + } + + private static int FindWordEnd(string line, int cursorColumn) + { + var runes = line.EnumerateRunes().ToArray(); + var index = Math.Clamp(cursorColumn, 0, runes.Length); + + while (index < runes.Length && Rune.IsWhiteSpace(runes[index])) + index++; + + if (index >= runes.Length) + return runes.Length; + + var kind = GetWordKind(runes[index]); + while (index < runes.Length && GetWordKind(runes[index]) == kind) + index++; + + return index; + } + + private static EditorWordKind GetWordKind(Rune rune) + { + if (Rune.IsLetterOrDigit(rune) || rune.Value == '_') + return EditorWordKind.Word; + + return Rune.IsWhiteSpace(rune) ? EditorWordKind.Whitespace : EditorWordKind.Punctuation; + } + /// /// 在指定光标列插入硬件光标 marker /// @@ -313,4 +581,23 @@ public sealed class Editor(ITextMeasurer? textMeasurer = null) : IInputComponent return value.Length; } + + private readonly record struct EditorSnapshot(string[] Lines, int CursorRow, int CursorColumn) + { + public string Value => string.Join('\n', Lines); + + public static EditorSnapshot FromText(string value) + { + var lines = value.Replace("\r\n", "\n").Split('\n'); + var row = Math.Max(0, lines.Length - 1); + return new EditorSnapshot(lines, row, GetRuneCount(lines[row])); + } + } + + private enum EditorWordKind + { + Whitespace, + Word, + Punctuation, + } } diff --git a/src/TinyTUI/Input/DefaultInputParser.cs b/src/TinyTUI/Input/DefaultInputParser.cs index f8b8b75..a93ac22 100644 --- a/src/TinyTUI/Input/DefaultInputParser.cs +++ b/src/TinyTUI/Input/DefaultInputParser.cs @@ -24,6 +24,7 @@ public sealed class DefaultInputParser : IInputParser [4] = KeyNames.End, [5] = KeyNames.PageUp, [6] = KeyNames.PageDown, + [127] = KeyNames.Backspace, [15] = "f5", [17] = "f6", [18] = "f7", diff --git a/src/TinyTUI/Overlay/OverlayManager.cs b/src/TinyTUI/Overlay/OverlayManager.cs index f83cd6d..0114d1a 100644 --- a/src/TinyTUI/Overlay/OverlayManager.cs +++ b/src/TinyTUI/Overlay/OverlayManager.cs @@ -1,4 +1,5 @@ using TinyTUI.Components; +using TinyTUI.Rendering; using TinyTUI.Text; namespace TinyTUI.Overlay; @@ -74,6 +75,9 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla while (result.Count < size.Rows) result.Add(string.Empty); + if (visibleEntries.Any(static entry => !entry.Options.NonCapturing)) + RemoveBaseCursorMarkers(result); + foreach (var entry in visibleEntries) ComposeOne(result, entry, size); @@ -114,6 +118,15 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla return _textMeasurer.Truncate(merged, totalWidth); } + /// + /// 捕获型 overlay 显示时底层组件不再拥有输入焦点 因此需要移除底层光标标记 + /// + private static void RemoveBaseCursorMarkers(List lines) + { + for (var index = 0; index < lines.Count; index++) + lines[index] = lines[index].Replace(CursorMarker.Marker, string.Empty, StringComparison.Ordinal); + } + /// /// 临时切换 overlay 是否参与渲染 /// diff --git a/src/TinyTUI/Rendering/DifferentialRenderer.cs b/src/TinyTUI/Rendering/DifferentialRenderer.cs index d4dc785..809f74a 100644 --- a/src/TinyTUI/Rendering/DifferentialRenderer.cs +++ b/src/TinyTUI/Rendering/DifferentialRenderer.cs @@ -119,6 +119,10 @@ public sealed class DifferentialRenderer(ITerminalOutput output, ITextMeasurer? /// private void MoveCursor(CursorPosition? cursor) { - if (cursor is { } position) output.MoveCursorTo(position.Row, position.Column); + if (cursor is not { } position) + return; + + output.ShowCursor(); + output.MoveCursorTo(position.Row, position.Column); } } diff --git a/src/TinyTUI/Rendering/FullScreenRenderer.cs b/src/TinyTUI/Rendering/FullScreenRenderer.cs index c93fd84..339f605 100644 --- a/src/TinyTUI/Rendering/FullScreenRenderer.cs +++ b/src/TinyTUI/Rendering/FullScreenRenderer.cs @@ -49,6 +49,7 @@ public sealed class FullScreenRenderer(ITerminalOutput output, ITextMeasurer? te { if (cursor is { } position) { + output.ShowCursor(); output.MoveCursorTo(position.Row, position.Column); } } diff --git a/src/TinyTUI/Stdio/ConsoleTerminalInput.cs b/src/TinyTUI/Stdio/ConsoleTerminalInput.cs index 2450494..7be92f7 100644 --- a/src/TinyTUI/Stdio/ConsoleTerminalInput.cs +++ b/src/TinyTUI/Stdio/ConsoleTerminalInput.cs @@ -131,35 +131,64 @@ public sealed class ConsoleTerminalInput : ITerminalInput /// /// 将 ConsoleKeyInfo 映射为后续输入解析器可识别的终端序列 /// - private static string EncodeKey(ConsoleKeyInfo key) => key.Key switch + private static string EncodeKey(ConsoleKeyInfo key) { - ConsoleKey.Enter => "\r", - ConsoleKey.Backspace => "\b", - ConsoleKey.Tab => "\t", - ConsoleKey.Escape => "\e", - ConsoleKey.UpArrow => "\e[A", - ConsoleKey.DownArrow => "\e[B", - ConsoleKey.RightArrow => "\e[C", - ConsoleKey.LeftArrow => "\e[D", - ConsoleKey.Home => "\e[H", - ConsoleKey.End => "\e[F", - ConsoleKey.Insert => "\e[2~", - ConsoleKey.Delete => "\e[3~", - ConsoleKey.PageUp => "\e[5~", - ConsoleKey.PageDown => "\e[6~", - ConsoleKey.F1 => "\eOP", - ConsoleKey.F2 => "\eOQ", - ConsoleKey.F3 => "\eOR", - ConsoleKey.F4 => "\eOS", - ConsoleKey.F5 => "\e[15~", - ConsoleKey.F6 => "\e[17~", - ConsoleKey.F7 => "\e[18~", - ConsoleKey.F8 => "\e[19~", - ConsoleKey.F9 => "\e[20~", - ConsoleKey.F10 => "\e[21~", - ConsoleKey.F11 => "\e[23~", - ConsoleKey.F12 => "\e[24~", - _ when key.KeyChar != '\0' => key.KeyChar.ToString(), - _ => string.Empty, - }; + var modifier = EncodeModifier(key.Modifiers); + + return key.Key switch + { + ConsoleKey.Enter => "\r", + ConsoleKey.Backspace when modifier > 1 => $"\e[127;{modifier}~", + ConsoleKey.Backspace => "\b", + ConsoleKey.Tab => "\t", + ConsoleKey.Escape => "\e", + ConsoleKey.UpArrow when modifier > 1 => $"\e[1;{modifier}A", + ConsoleKey.UpArrow => "\e[A", + ConsoleKey.DownArrow when modifier > 1 => $"\e[1;{modifier}B", + ConsoleKey.DownArrow => "\e[B", + ConsoleKey.RightArrow when modifier > 1 => $"\e[1;{modifier}C", + ConsoleKey.RightArrow => "\e[C", + ConsoleKey.LeftArrow when modifier > 1 => $"\e[1;{modifier}D", + ConsoleKey.LeftArrow => "\e[D", + ConsoleKey.Home when modifier > 1 => $"\e[1;{modifier}H", + ConsoleKey.Home => "\e[H", + ConsoleKey.End when modifier > 1 => $"\e[1;{modifier}F", + ConsoleKey.End => "\e[F", + ConsoleKey.Insert when modifier > 1 => $"\e[2;{modifier}~", + ConsoleKey.Insert => "\e[2~", + ConsoleKey.Delete when modifier > 1 => $"\e[3;{modifier}~", + ConsoleKey.Delete => "\e[3~", + ConsoleKey.PageUp when modifier > 1 => $"\e[5;{modifier}~", + ConsoleKey.PageUp => "\e[5~", + ConsoleKey.PageDown when modifier > 1 => $"\e[6;{modifier}~", + ConsoleKey.PageDown => "\e[6~", + ConsoleKey.F1 => "\eOP", + ConsoleKey.F2 => "\eOQ", + ConsoleKey.F3 => "\eOR", + ConsoleKey.F4 => "\eOS", + ConsoleKey.F5 => "\e[15~", + ConsoleKey.F6 => "\e[17~", + ConsoleKey.F7 => "\e[18~", + ConsoleKey.F8 => "\e[19~", + ConsoleKey.F9 => "\e[20~", + ConsoleKey.F10 => "\e[21~", + ConsoleKey.F11 => "\e[23~", + ConsoleKey.F12 => "\e[24~", + _ when key.KeyChar != '\0' => key.KeyChar.ToString(), + _ => string.Empty, + }; + } + + /// + /// 将 Console 修饰键转换为 xterm CSI modifier 编码 + /// + private static int EncodeModifier(ConsoleModifiers modifiers) + { + var value = 1; + if ((modifiers & ConsoleModifiers.Shift) != 0) value += 1; + if ((modifiers & ConsoleModifiers.Alt) != 0) value += 2; + if ((modifiers & ConsoleModifiers.Control) != 0) value += 4; + + return value; + } }