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",