From 31766b2a7837e04b4c3108fcc5d122703e5adcfa Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 01:24:22 +0800 Subject: [PATCH] feat: improve editor layout flow - split editor implementation by responsibility - add editor submit events and soft wrap layout - support modified enter input for editor submission --- TODO.md | 14 +- src/Example/Program.cs | 20 +- src/TinyTUI/Components/Editor.cs | 616 ------------------ .../Components/Editor/Editor.Editing.cs | 298 +++++++++ .../Components/Editor/Editor.History.cs | 108 +++ .../Components/Editor/Editor.Rendering.cs | 199 ++++++ src/TinyTUI/Components/Editor/Editor.Text.cs | 116 ++++ src/TinyTUI/Components/Editor/Editor.cs | 149 +++++ src/TinyTUI/Input/DefaultInputParser.cs | 1 + src/TinyTUI/Stdio/ConsoleTerminalInput.cs | 1 + 10 files changed, 892 insertions(+), 630 deletions(-) delete mode 100644 src/TinyTUI/Components/Editor.cs create mode 100644 src/TinyTUI/Components/Editor/Editor.Editing.cs create mode 100644 src/TinyTUI/Components/Editor/Editor.History.cs create mode 100644 src/TinyTUI/Components/Editor/Editor.Rendering.cs create mode 100644 src/TinyTUI/Components/Editor/Editor.Text.cs create mode 100644 src/TinyTUI/Components/Editor/Editor.cs diff --git a/TODO.md b/TODO.md index e6632d8..f34b45f 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ TinyTUI 现在已经不是空项目,已经具备一个最小可运行的 C# TU - 已实现输入缓冲器 `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 +- 已补充 Editor 的历史记录浏览、Home/End、词级移动、词级删除、undo / redo、Ctrl+C 清空、提交取消事件和软换行 - 已补充 SelectList 的 item model、过滤、描述列、循环选择、滚动提示和 selection changed - 已更新 Example,启动后能直接验证 Loader、Input、overlay 定位、non-capturing overlay、选择列表过滤和编辑器快捷键 @@ -30,7 +30,7 @@ TinyTUI 现在已经不是空项目,已经具备一个最小可运行的 C# TU - 输入层还缺 Kitty keyboard protocol / modifyOtherKeys 这类高级键盘协议协商,组合键识别还不够完整 - Overlay 合成还比较简单,需要继续处理 ANSI/OSC 样式文本、宽字符边界、终端边缘覆盖和底层样式泄漏 -- Editor 还缺提交事件和取消事件的清晰语义、kill ring、自动补全、粘贴摘要、软换行和更完整的光标布局 +- Editor 还缺自动补全、粘贴摘要和更完整的光标布局 - SelectList 还缺主题样式、更多布局配置和更完整的键位策略 - Markdown 目前只是轻量纯文本转换,还缺真正 token 解析、行内样式、代码块、链接、引用块、列表缩进、文本换行和缓存 - Text Width 还需要补 grapheme cluster 级别处理,尤其是 ZWJ emoji、regional indicator、variation selector、tab、ANSI 包裹换行 @@ -71,16 +71,14 @@ Overlay 是菜单、提示、选择列表、编辑器弹窗的基础 ### 3. Editor 升级 -状态:已完成历史记录、Ctrl+A/E、词级移动删除、undo / redo +状态:已完成历史记录、Home/End、词级移动删除、undo / redo、Ctrl+C 清空、提交取消事件和软换行 Editor 是后续交互体验的核心组件 -- 增加提交事件和取消事件的清晰语义 -- 支持 kill ring -- 支持软换行和按终端宽度布局 +- 继续细化软换行下的光标边界处理 - 支持粘贴大段文本时生成 paste marker 或摘要 - 支持 autocomplete provider 和补全列表 overlay -- 参考 `tmp/tui/src/components/editor.ts`、`tmp/tui/src/undo-stack.ts`、`tmp/tui/src/kill-ring.ts`、`tmp/tui/src/word-navigation.ts`、`tmp/tui/src/autocomplete.ts` +- 参考 `tmp/tui/src/components/editor.ts`、`tmp/tui/src/undo-stack.ts`、`tmp/tui/src/word-navigation.ts`、`tmp/tui/src/autocomplete.ts` ### 4. SelectList 升级 @@ -141,7 +139,7 @@ Markdown 暂时只做展示,不要急着做完整渲染器,但需要比当 ## 近期执行顺序 -1. 继续补 Editor 的 kill ring、软换行和补全 overlay +1. 继续补 Editor 的粘贴摘要和补全 overlay 2. 升级 Markdown 和 Text Width 3. 处理 Overlay 合成的 ANSI/OSC 样式文本、宽字符边界和底层样式泄漏 4. 继续完善 SelectList 主题样式和布局配置 diff --git a/src/Example/Program.cs b/src/Example/Program.cs index c0c99a7..5a2d13f 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -290,7 +290,8 @@ file sealed class EditorOverlay : IInputComponent { Height = 6, Placeholder = "在这里输入内容", - OnCanceled = SubmitAndClose, + OnSubmitted = SubmitAndClose, + OnCanceled = CancelAndClose, }; _editor.SetHistory(_history); _box = new Box( @@ -302,7 +303,8 @@ file sealed class EditorOverlay : IInputComponent "Home/End 行首行尾 Ctrl+Left/Right 按词移动\n" + "Ctrl+Backspace/Delete 按词删除 Ctrl+Z/Y 撤销重做\n" + "Ctrl+C 清空当前内容但保留历史记录\n" + - "空内容时 Up/Down 浏览历史 Esc 提交并关闭"), + "Enter 换行 Ctrl+Enter 或 Esc 提交并关闭\n" + + "长行会按弹层宽度软换行 Up/Down 按视觉行移动"), new Text(), _editor, }, @@ -319,23 +321,29 @@ file sealed class EditorOverlay : IInputComponent { if (input is { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape }) { - SubmitAndClose(); + _editor.Submit(); return; } _editor.HandleInput(input); } - private void SubmitAndClose() + private void SubmitAndClose(string text) { - var value = _editor.Value.Trim(); + var value = text.Trim(); if (value.Length > 0) { _history.Remove(value); _history.Insert(0, value); } - _addEvent($"Edited {Math.Max(1, _editor.Value.Split('\n').Length)} line(s)"); + _addEvent($"Editor submitted {Math.Max(1, text.Split('\n').Length)} line(s)"); + _runtime.HideOverlay(); + } + + private void CancelAndClose() + { + _addEvent("Editor canceled"); _runtime.HideOverlay(); } } diff --git a/src/TinyTUI/Components/Editor.cs b/src/TinyTUI/Components/Editor.cs deleted file mode 100644 index 2a36ca3..0000000 --- a/src/TinyTUI/Components/Editor.cs +++ /dev/null @@ -1,616 +0,0 @@ -using System.Text; -using TinyTUI.Input; -using TinyTUI.Rendering; -using TinyTUI.Text; - -namespace TinyTUI.Components; - -/// -/// 支持多行文本编辑的基础编辑器组件 -/// -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; - - /// - /// 获取当前编辑器文本 - /// - public string Value - { - get => string.Join('\n', _lines); - set => SetValue(value); - } - - /// - /// 在文本变化时触发 - /// - public Action? OnChanged { get; set; } - - /// - /// 在用户按 Esc 取消编辑时触发 - /// - 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) - { - var visibleHeight = Math.Max(1, Height); - var firstRow = Math.Clamp(_cursorRow - visibleHeight + 1, 0, Math.Max(0, _lines.Count - visibleHeight)); - var rows = new List(visibleHeight); - - for (var offset = 0; offset < visibleHeight; offset++) - { - var lineIndex = firstRow + offset; - if (lineIndex >= _lines.Count) - { - rows.Add(string.Empty); - continue; - } - - var line = _lines[lineIndex]; - if (lineIndex == _cursorRow) - { - if (IsEmpty && Placeholder.Length > 0) - line = CursorMarker.Marker + Placeholder; - else - // Renderer 会提取这个 marker 并移动硬件光标 所以这里不渲染可见光标字符 - line = InsertCursorMarker(line, _cursorColumn); - } - - rows.Add(_textMeasurer.Truncate(line, width)); - } - - return rows; - } - - /// - public void HandleInput(TuiInputEvent input) - { - switch (input) - { - case { Kind: TuiInputEventKind.Text }: - Edit(() => InsertText(input.Value)); - break; - case { Kind: TuiInputEventKind.Paste }: - 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("c"): - Edit(ClearText); - 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 }: - Edit(SplitLine); - break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.Backspace }: - Edit(Backspace); - break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.Delete }: - Edit(Delete); - break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.Left }: - MoveLeft(); - break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.Right }: - MoveRight(); - break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.Up }: - if (!TryNavigateHistory(-1)) MoveVertical(-1); - break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.Down }: - if (!TryNavigateHistory(1)) MoveVertical(1); - break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.Home }: - MoveToLineStart(); - break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.End }: - MoveToLineEnd(); - break; - case { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape }: - OnCanceled?.Invoke(); - break; - } - } - - /// - /// 清空编辑器内容 - /// - 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); - } - - /// - /// 重置编辑器文本并把光标约束到有效行列 - /// - private void SetValue(string value) - { - _lines.Clear(); - _lines.AddRange(value.Replace("\r\n", "\n").Split('\n')); - - if (_lines.Count == 0) - _lines.Add(string.Empty); - - _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); - } - - /// - /// 在当前光标处插入文本并按换行拆分编辑器行 - /// - private void InsertText(string value) - { - foreach (var rune in value.EnumerateRunes()) - { - if (rune.Value == '\r') continue; - - if (rune.Value == '\n') - { - SplitLine(); - continue; - } - - // 按 Rune 插入可以避免把 emoji 或代理对拆成无效 UTF-16 片段 - InsertAtCursor(rune.ToString()); - } - - } - - /// - /// 在当前行的光标列插入一段不包含换行的文本 - /// - private void InsertAtCursor(string value) - { - var line = _lines[_cursorRow]; - var index = GetStringIndex(line, _cursorColumn); - _lines[_cursorRow] = line.Insert(index, value); - _cursorColumn += GetRuneCount(value); - } - - /// - /// 将当前行按光标位置拆成上下两行 - /// - private void SplitLine() - { - var line = _lines[_cursorRow]; - var index = GetStringIndex(line, _cursorColumn); - _lines[_cursorRow] = line[..index]; - _lines.Insert(_cursorRow + 1, line[index..]); - _cursorRow++; - _cursorColumn = 0; - - } - - /// - /// 清空当前编辑文本但保留历史记录 - /// - private void ClearText() - { - _lines.Clear(); - _lines.Add(string.Empty); - _cursorRow = 0; - _cursorColumn = 0; - _historyIndex = -1; - _historyDraft = string.Empty; - } - - /// - /// 删除光标左侧一个 Rune 或在行首合并到上一行 - /// - private void Backspace() - { - if (_cursorColumn > 0) - { - var line = _lines[_cursorRow]; - var start = GetStringIndex(line, _cursorColumn - 1); - var end = GetStringIndex(line, _cursorColumn); - _lines[_cursorRow] = line.Remove(start, end - start); - _cursorColumn--; - return; - } - - if (_cursorRow == 0) - return; - - var previousLength = GetRuneCount(_lines[_cursorRow - 1]); - // 行首退格符合常见编辑器行为 会把当前行拼接到上一行末尾 - _lines[_cursorRow - 1] += _lines[_cursorRow]; - _lines.RemoveAt(_cursorRow); - _cursorRow--; - _cursorColumn = previousLength; - } - - /// - /// 删除光标右侧一个 Rune 或在行尾合并下一行 - /// - private void Delete() - { - var line = _lines[_cursorRow]; - if (_cursorColumn < GetRuneCount(line)) - { - var start = GetStringIndex(line, _cursorColumn); - var end = GetStringIndex(line, _cursorColumn + 1); - _lines[_cursorRow] = line.Remove(start, end - start); - return; - } - - if (_cursorRow >= _lines.Count - 1) - return; - - // 行尾 Delete 与 Backspace 的反向跨行合并保持一致 - _lines[_cursorRow] += _lines[_cursorRow + 1]; - _lines.RemoveAt(_cursorRow + 1); - } - - /// - /// 将光标向左移动并在行首跳到上一行末尾 - /// - private void MoveLeft() - { - if (_cursorColumn > 0) - { - _cursorColumn--; - return; - } - - if (_cursorRow == 0) - return; - - _cursorRow--; - _cursorColumn = GetRuneCount(_lines[_cursorRow]); - } - - /// - /// 将光标向右移动并在行尾跳到下一行开头 - /// - private void MoveRight() - { - if (_cursorColumn < GetRuneCount(_lines[_cursorRow])) - { - _cursorColumn++; - return; - } - - if (_cursorRow >= _lines.Count - 1) - return; - - _cursorRow++; - _cursorColumn = 0; - } - - /// - /// 垂直移动光标并把列约束到目标行长度内 - /// - private void MoveVertical(int delta) - { - _cursorRow = Math.Clamp(_cursorRow + delta, 0, _lines.Count - 1); - _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 bool IsEmpty => _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 - /// - private static string InsertCursorMarker(string line, int cursorColumn) - { - var index = GetStringIndex(line, cursorColumn); - return line.Insert(index, CursorMarker.Marker); - } - - /// - /// 获取字符串包含的 Unicode Rune 数量 - /// - private static int GetRuneCount(string value) => value.EnumerateRunes().Count(); - - /// - /// 将 Rune 下标转换为 UTF-16 字符串下标 - /// - private static int GetStringIndex(string value, int runeIndex) - { - if (runeIndex <= 0) - return 0; - - var current = 0; - var index = 0; - foreach (var rune in value.EnumerateRunes()) - { - if (current == runeIndex) - return index; - - current++; - // string 的 Insert Remove 使用 UTF-16 下标 因此不能直接把 Rune 下标当 char 下标 - index += rune.Utf16SequenceLength; - } - - 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/Components/Editor/Editor.Editing.cs b/src/TinyTUI/Components/Editor/Editor.Editing.cs new file mode 100644 index 0000000..eb85cbe --- /dev/null +++ b/src/TinyTUI/Components/Editor/Editor.Editing.cs @@ -0,0 +1,298 @@ +using System.Text; + +namespace TinyTUI.Components; + +public sealed partial class Editor +{ + /// + /// 对一次文本修改建立撤销快照 + /// + private void Edit(Action action) + { + var before = CaptureSnapshot(); + _historyIndex = -1; + _historyDraft = string.Empty; + _preferredVisualColumn = null; + + action(); + + if (before.Value == Value) + return; + + _undoStack.Push(before); + _redoStack.Clear(); + OnChanged?.Invoke(Value); + } + + /// + /// 重置编辑器文本并把光标约束到有效行列 + /// + private void SetValue(string value) + { + _lines.Clear(); + _lines.AddRange(value.Replace("\r\n", "\n").Split('\n')); + + if (_lines.Count == 0) + _lines.Add(string.Empty); + + _cursorRow = Math.Min(_cursorRow, _lines.Count - 1); + _cursorColumn = Math.Min(_cursorColumn, GetRuneCount(_lines[_cursorRow])); + _undoStack.Clear(); + _redoStack.Clear(); + _historyIndex = -1; + _historyDraft = string.Empty; + _scrollOffset = 0; + _preferredVisualColumn = null; + OnChanged?.Invoke(Value); + } + + /// + /// 在当前光标处插入文本并按换行拆分编辑器行 + /// + private void InsertText(string value) + { + foreach (var rune in value.EnumerateRunes()) + { + if (rune.Value == '\r') continue; + + if (rune.Value == '\n') + { + SplitLine(); + continue; + } + + // 按 Rune 插入可以避免把 emoji 或代理对拆成无效 UTF-16 片段 + InsertAtCursor(rune.ToString()); + } + } + + /// + /// 在当前行的光标列插入一段不包含换行的文本 + /// + private void InsertAtCursor(string value) + { + var line = _lines[_cursorRow]; + var index = GetStringIndex(line, _cursorColumn); + _lines[_cursorRow] = line.Insert(index, value); + _cursorColumn += GetRuneCount(value); + _preferredVisualColumn = null; + } + + /// + /// 将当前行按光标位置拆成上下两行 + /// + private void SplitLine() + { + var line = _lines[_cursorRow]; + var index = GetStringIndex(line, _cursorColumn); + _lines[_cursorRow] = line[..index]; + _lines.Insert(_cursorRow + 1, line[index..]); + _cursorRow++; + _cursorColumn = 0; + _preferredVisualColumn = null; + } + + /// + /// 清空当前编辑文本但保留历史记录 + /// + private void ClearText() + { + _lines.Clear(); + _lines.Add(string.Empty); + _cursorRow = 0; + _cursorColumn = 0; + _historyIndex = -1; + _historyDraft = string.Empty; + _scrollOffset = 0; + _preferredVisualColumn = null; + } + + /// + /// 删除光标左侧一个 Rune 或在行首合并到上一行 + /// + private void Backspace() + { + if (_cursorColumn > 0) + { + var line = _lines[_cursorRow]; + var start = GetStringIndex(line, _cursorColumn - 1); + var end = GetStringIndex(line, _cursorColumn); + _lines[_cursorRow] = line.Remove(start, end - start); + _cursorColumn--; + _preferredVisualColumn = null; + return; + } + + if (_cursorRow == 0) + return; + + var previousLength = GetRuneCount(_lines[_cursorRow - 1]); + // 行首退格符合常见编辑器行为 会把当前行拼接到上一行末尾 + _lines[_cursorRow - 1] += _lines[_cursorRow]; + _lines.RemoveAt(_cursorRow); + _cursorRow--; + _cursorColumn = previousLength; + _preferredVisualColumn = null; + } + + /// + /// 删除光标右侧一个 Rune 或在行尾合并下一行 + /// + private void Delete() + { + var line = _lines[_cursorRow]; + if (_cursorColumn < GetRuneCount(line)) + { + var start = GetStringIndex(line, _cursorColumn); + var end = GetStringIndex(line, _cursorColumn + 1); + _lines[_cursorRow] = line.Remove(start, end - start); + _preferredVisualColumn = null; + return; + } + + if (_cursorRow >= _lines.Count - 1) + return; + + // 行尾 Delete 与 Backspace 的反向跨行合并保持一致 + _lines[_cursorRow] += _lines[_cursorRow + 1]; + _lines.RemoveAt(_cursorRow + 1); + _preferredVisualColumn = null; + } + + /// + /// 将光标向左移动并在行首跳到上一行末尾 + /// + private void MoveLeft() + { + if (_cursorColumn > 0) + { + _cursorColumn--; + _preferredVisualColumn = null; + return; + } + + if (_cursorRow == 0) + return; + + _cursorRow--; + _cursorColumn = GetRuneCount(_lines[_cursorRow]); + _preferredVisualColumn = null; + } + + /// + /// 将光标向右移动并在行尾跳到下一行开头 + /// + private void MoveRight() + { + if (_cursorColumn < GetRuneCount(_lines[_cursorRow])) + { + _cursorColumn++; + _preferredVisualColumn = null; + return; + } + + if (_cursorRow >= _lines.Count - 1) + return; + + _cursorRow++; + _cursorColumn = 0; + _preferredVisualColumn = null; + } + + /// + /// 垂直移动光标并把列约束到目标行长度内 + /// + private void MoveVertical(int delta) + { + if (SoftWrap) + { + MoveVisualVertical(delta); + return; + } + + _cursorRow = Math.Clamp(_cursorRow + delta, 0, _lines.Count - 1); + _cursorColumn = Math.Min(_cursorColumn, GetRuneCount(_lines[_cursorRow])); + } + + /// + /// 移动到当前行行首 + /// + private void MoveToLineStart() + { + _cursorColumn = 0; + _preferredVisualColumn = null; + } + + /// + /// 移动到当前行行尾 + /// + private void MoveToLineEnd() + { + _cursorColumn = GetRuneCount(_lines[_cursorRow]); + _preferredVisualColumn = null; + } + + /// + /// 向左移动一个词或标点片段 + /// + private void MoveWordLeft() + { + if (_cursorColumn == 0) + { + MoveLeft(); + return; + } + + _cursorColumn = FindWordStart(_lines[_cursorRow], _cursorColumn); + _preferredVisualColumn = null; + } + + /// + /// 向右移动一个词或标点片段 + /// + private void MoveWordRight() + { + if (_cursorColumn == GetRuneCount(_lines[_cursorRow])) + { + MoveRight(); + return; + } + + _cursorColumn = FindWordEnd(_lines[_cursorRow], _cursorColumn); + _preferredVisualColumn = null; + } + + /// + /// 删除光标左侧一个词或标点片段 + /// + private void DeleteWordBackward() + { + if (_cursorColumn == 0) + { + Backspace(); + return; + } + + var line = _lines[_cursorRow]; + var targetColumn = FindWordStart(line, _cursorColumn); + RemoveRuneRange(_cursorRow, targetColumn, _cursorColumn); + _cursorColumn = targetColumn; + _preferredVisualColumn = null; + } + + /// + /// 删除光标右侧一个词或标点片段 + /// + private void DeleteWordForward() + { + var line = _lines[_cursorRow]; + if (_cursorColumn == GetRuneCount(line)) + { + Delete(); + return; + } + + RemoveRuneRange(_cursorRow, _cursorColumn, FindWordEnd(line, _cursorColumn)); + _preferredVisualColumn = null; + } +} diff --git a/src/TinyTUI/Components/Editor/Editor.History.cs b/src/TinyTUI/Components/Editor/Editor.History.cs new file mode 100644 index 0000000..b37401b --- /dev/null +++ b/src/TinyTUI/Components/Editor/Editor.History.cs @@ -0,0 +1,108 @@ +namespace TinyTUI.Components; + +public sealed partial class Editor +{ + /// + /// 添加一条历史记录用于上下方向键浏览 + /// + 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; + } + + /// + /// 浏览历史记录并在离开历史时恢复原草稿 + /// + 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])); + _preferredVisualColumn = null; + OnChanged?.Invoke(Value); + return true; + } + + private bool IsSingleEmptyLineOrBrowsing() + => _historyIndex >= 0 || (_lines.Count == 1 && _lines[0].Length == 0); + + private bool IsEmpty => _lines.Count == 1 && _lines[0].Length == 0; + + /// + /// 恢复上一个编辑快照 + /// + private void Undo() + { + if (!_undoStack.TryPop(out var snapshot)) + return; + + _redoStack.Push(CaptureSnapshot()); + ApplySnapshot(snapshot); + _preferredVisualColumn = null; + OnChanged?.Invoke(Value); + } + + /// + /// 恢复刚刚撤销的编辑快照 + /// + private void Redo() + { + if (!_redoStack.TryPop(out var snapshot)) + return; + + _undoStack.Push(CaptureSnapshot()); + ApplySnapshot(snapshot); + _preferredVisualColumn = null; + 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 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])); + } + } +} diff --git a/src/TinyTUI/Components/Editor/Editor.Rendering.cs b/src/TinyTUI/Components/Editor/Editor.Rendering.cs new file mode 100644 index 0000000..d6e06c5 --- /dev/null +++ b/src/TinyTUI/Components/Editor/Editor.Rendering.cs @@ -0,0 +1,199 @@ +using System.Text; +using TinyTUI.Rendering; + +namespace TinyTUI.Components; + +public sealed partial class Editor +{ + /// + public IReadOnlyList Render(int width) + { + var visibleHeight = Math.Max(1, Height); + _lastRenderWidth = Math.Max(1, width); + + var visualRows = BuildVisualRows(_lastRenderWidth); + var cursorVisualRow = FindCursorVisualRow(visualRows); + if (cursorVisualRow < _scrollOffset) + _scrollOffset = cursorVisualRow; + else if (cursorVisualRow >= _scrollOffset + visibleHeight) + _scrollOffset = cursorVisualRow - visibleHeight + 1; + + _scrollOffset = Math.Clamp(_scrollOffset, 0, Math.Max(0, visualRows.Count - visibleHeight)); + + var rows = new List(visibleHeight); + for (var offset = 0; offset < visibleHeight; offset++) + { + var visualIndex = _scrollOffset + offset; + if (visualIndex >= visualRows.Count) + { + rows.Add(string.Empty); + continue; + } + + rows.Add(RenderVisualRow(visualRows[visualIndex], IsLastVisualRowOfLogicalLine(visualRows, visualIndex))); + } + + return rows; + } + + /// + /// 构建当前宽度下的视觉行 + /// + private List BuildVisualRows(int width) + { + if (!SoftWrap) + { + return _lines + .Select((line, row) => new EditorVisualRow(row, 0, GetRuneCount(line), _textMeasurer.Truncate(line, width))) + .ToList(); + } + + var rows = new List(); + for (var row = 0; row < _lines.Count; row++) + { + var line = _lines[row]; + if (line.Length == 0) + { + rows.Add(new EditorVisualRow(row, 0, 0, string.Empty)); + continue; + } + + AddWrappedRows(rows, row, line, width); + } + + return rows.Count == 0 ? [new EditorVisualRow(0, 0, 0, string.Empty)] : rows; + } + + /// + /// 将一个逻辑行按终端宽度拆成多个视觉行 + /// + private void AddWrappedRows(List rows, int row, string line, int width) + { + var builder = new StringBuilder(); + var startColumn = 0; + var currentColumn = 0; + var currentWidth = 0; + + foreach (var rune in line.EnumerateRunes()) + { + var text = rune.ToString(); + var runeWidth = _textMeasurer.GetWidth(text); + + if (currentWidth > 0 && currentWidth + runeWidth > width) + { + rows.Add(new EditorVisualRow(row, startColumn, currentColumn, builder.ToString())); + builder.Clear(); + startColumn = currentColumn; + currentWidth = 0; + } + + builder.Append(text); + currentColumn++; + currentWidth += runeWidth; + } + + rows.Add(new EditorVisualRow(row, startColumn, currentColumn, builder.ToString())); + } + + /// + /// 渲染单个视觉行并在当前光标所在行插入硬件光标 marker + /// + private string RenderVisualRow(EditorVisualRow row, bool isLastRow) + { + if (IsEmpty && row.LogicalRow == _cursorRow && Placeholder.Length > 0) + return CursorMarker.Marker + _textMeasurer.Truncate(Placeholder, _lastRenderWidth); + + if (row.LogicalRow != _cursorRow || !IsCursorInVisualRow(row, isLastRow)) + return row.Text; + + // Renderer 会提取这个 marker 并移动硬件光标 所以这里不渲染可见光标字符 + return InsertCursorMarker(row.Text, _cursorColumn - row.StartColumn); + } + + /// + /// 查找当前光标所在的视觉行下标 + /// + private int FindCursorVisualRow(IReadOnlyList rows) + { + for (var index = 0; index < rows.Count; index++) + { + if (rows[index].LogicalRow != _cursorRow) + continue; + + if (IsCursorInVisualRow(rows[index], IsLastVisualRowOfLogicalLine(rows, index))) + return index; + } + + return Math.Clamp(_cursorRow, 0, Math.Max(0, rows.Count - 1)); + } + + /// + /// 判断当前光标是否属于指定视觉行 + /// + private bool IsCursorInVisualRow(EditorVisualRow row, bool isLastRow = true) + { + if (_cursorColumn < row.StartColumn) + return false; + + return _cursorColumn < row.EndColumn || isLastRow && _cursorColumn == row.EndColumn; + } + + /// + /// 判断视觉行是否是所属逻辑行的最后一段 + /// + private static bool IsLastVisualRowOfLogicalLine(IReadOnlyList rows, int index) + => index >= rows.Count - 1 || rows[index + 1].LogicalRow != rows[index].LogicalRow; + + /// + /// 按软换行后的视觉行移动光标 + /// + private void MoveVisualVertical(int delta) + { + var rows = BuildVisualRows(_lastRenderWidth); + var currentIndex = FindCursorVisualRow(rows); + var targetIndex = currentIndex + delta; + + if (targetIndex < 0 || targetIndex >= rows.Count) + return; + + var currentColumn = _preferredVisualColumn ?? GetVisualColumn(rows[currentIndex], _cursorColumn); + var target = rows[targetIndex]; + + _cursorRow = target.LogicalRow; + _cursorColumn = GetLogicalColumnAtVisualColumn(target, currentColumn); + _preferredVisualColumn = currentColumn; + } + + /// + /// 获取光标在视觉行内占用的显示列 + /// + private int GetVisualColumn(EditorVisualRow row, int logicalColumn) + { + var start = row.StartColumn; + var end = Math.Clamp(logicalColumn, row.StartColumn, row.EndColumn); + return _textMeasurer.GetWidth(GetTextByRuneRange(_lines[row.LogicalRow], start, end)); + } + + /// + /// 将视觉列转换回逻辑行内的 Rune 列 + /// + private int GetLogicalColumnAtVisualColumn(EditorVisualRow row, int visualColumn) + { + var width = 0; + var column = row.StartColumn; + + foreach (var rune in row.Text.EnumerateRunes()) + { + var runeWidth = _textMeasurer.GetWidth(rune.ToString()); + if (width + runeWidth > visualColumn) + return column; + + width += runeWidth; + column++; + } + + return row.EndColumn; + } + + private readonly record struct EditorVisualRow(int LogicalRow, int StartColumn, int EndColumn, string Text); +} diff --git a/src/TinyTUI/Components/Editor/Editor.Text.cs b/src/TinyTUI/Components/Editor/Editor.Text.cs new file mode 100644 index 0000000..6abe0f4 --- /dev/null +++ b/src/TinyTUI/Components/Editor/Editor.Text.cs @@ -0,0 +1,116 @@ +using System.Text; +using TinyTUI.Rendering; + +namespace TinyTUI.Components; + +public sealed partial class Editor +{ + 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); + } + + /// + /// 按 Rune 范围截取文本 + /// + private static string GetTextByRuneRange(string value, int startColumn, int endColumn) + { + var start = GetStringIndex(value, startColumn); + var end = GetStringIndex(value, endColumn); + return value[start..end]; + } + + 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 + /// + private static string InsertCursorMarker(string line, int cursorColumn) + { + var index = GetStringIndex(line, Math.Clamp(cursorColumn, 0, GetRuneCount(line))); + return line.Insert(index, CursorMarker.Marker); + } + + /// + /// 获取字符串包含的 Unicode Rune 数量 + /// + private static int GetRuneCount(string value) => value.EnumerateRunes().Count(); + + /// + /// 将 Rune 下标转换为 UTF-16 字符串下标 + /// + private static int GetStringIndex(string value, int runeIndex) + { + if (runeIndex <= 0) + return 0; + + var current = 0; + var index = 0; + foreach (var rune in value.EnumerateRunes()) + { + if (current == runeIndex) + return index; + + current++; + // string 的 Insert Remove 使用 UTF-16 下标 因此不能直接把 Rune 下标当 char 下标 + index += rune.Utf16SequenceLength; + } + + return value.Length; + } + + private enum EditorWordKind + { + Whitespace, + Word, + Punctuation, + } +} diff --git a/src/TinyTUI/Components/Editor/Editor.cs b/src/TinyTUI/Components/Editor/Editor.cs new file mode 100644 index 0000000..1de5c54 --- /dev/null +++ b/src/TinyTUI/Components/Editor/Editor.cs @@ -0,0 +1,149 @@ +using TinyTUI.Input; +using TinyTUI.Text; + +namespace TinyTUI.Components; + +/// +/// 支持多行文本编辑的基础编辑器组件 +/// +public sealed partial 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; + private int _lastRenderWidth = 80; + private int _scrollOffset; + private int? _preferredVisualColumn; + + /// + /// 获取或设置编辑器高度 + /// + public int Height { get; set; } = 5; + + /// + /// 获取或设置是否按可用宽度软换行 + /// + public bool SoftWrap { get; set; } = true; + + /// + /// 获取或设置空编辑器的输入提示 + /// + public string Placeholder { get; set; } = string.Empty; + + /// + /// 获取当前编辑器文本 + /// + public string Value + { + get => string.Join('\n', _lines); + set => SetValue(value); + } + + /// + /// 在文本变化时触发 + /// + public Action? OnChanged { get; set; } + + /// + /// 在用户取消编辑时触发 + /// + public Action? OnCanceled { get; set; } + + /// + /// 在用户提交编辑内容时触发 + /// + public Action? OnSubmitted { get; set; } + + /// + public void HandleInput(TuiInputEvent input) + { + switch (input) + { + case { Kind: TuiInputEventKind.Text }: + Edit(() => InsertText(input.Value)); + break; + case { Kind: TuiInputEventKind.Paste }: + 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("c"): + Edit(ClearText); + 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 }: + Edit(SplitLine); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl(KeyNames.Enter): + Submit(); + break; + case { Kind: TuiInputEventKind.Key, Value: var key } when key == KeyNames.Ctrl("s"): + Submit(); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Backspace }: + Edit(Backspace); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Delete }: + Edit(Delete); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Left }: + MoveLeft(); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Right }: + MoveRight(); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Up }: + if (!TryNavigateHistory(-1)) MoveVertical(-1); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Down }: + if (!TryNavigateHistory(1)) MoveVertical(1); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Home }: + MoveToLineStart(); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.End }: + MoveToLineEnd(); + break; + case { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape }: + Cancel(); + break; + } + } + + /// + /// 提交当前编辑内容 + /// + public void Submit() => OnSubmitted?.Invoke(Value); + + /// + /// 取消当前编辑 + /// + public void Cancel() => OnCanceled?.Invoke(); + + /// + /// 清空编辑器内容 + /// + public void Clear() => SetValue(string.Empty); +} diff --git a/src/TinyTUI/Input/DefaultInputParser.cs b/src/TinyTUI/Input/DefaultInputParser.cs index a93ac22..b7e2736 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, + [13] = KeyNames.Enter, [127] = KeyNames.Backspace, [15] = "f5", [17] = "f6", diff --git a/src/TinyTUI/Stdio/ConsoleTerminalInput.cs b/src/TinyTUI/Stdio/ConsoleTerminalInput.cs index 7be92f7..7ae7595 100644 --- a/src/TinyTUI/Stdio/ConsoleTerminalInput.cs +++ b/src/TinyTUI/Stdio/ConsoleTerminalInput.cs @@ -137,6 +137,7 @@ public sealed class ConsoleTerminalInput : ITerminalInput return key.Key switch { + ConsoleKey.Enter when modifier > 1 => $"\e[13;{modifier}~", ConsoleKey.Enter => "\r", ConsoleKey.Backspace when modifier > 1 => $"\e[127;{modifier}~", ConsoleKey.Backspace => "\b",