From 66de8eea137fb6c90ef48039a743e5905fed4e6e Mon Sep 17 00:00:00 2001 From: shuiyu67 Date: Fri, 7 Aug 2026 23:35:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20TUI=20=E6=96=B0=E5=A2=9E=20Vim=20?= =?UTF-8?q?=E9=A3=8E=E6=A0=BC=E9=94=AE=E4=BD=8D=EF=BC=88=E6=A8=A1=E6=80=81?= =?UTF-8?q?=E8=BE=93=E5=85=A5=20+=20=E8=81=8A=E5=A4=A9=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E7=89=88=E6=9C=AC=E5=8D=87=208.0.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 xcli_core/vim_keys.py:纯逻辑 Vim 状态机(NORMAL/INSERT),h/j/k/l/w/b/0/$/^/gg/G/i/a/o/I/A/O/dd/x/dw/d$/u - tui.py:VimSendTextArea 子类接入模态编辑;XiaoliTUI 增加 j/k、Ctrl+F/B/D/U 聊天滚动、Ctrl+Space 聚焦切换、i 聚焦插入、/vim 开关、模式提示栏、/help 更新 - 新增 tests/test_vim_keys.py(18 项动作单测全过) - 版本号 8.0.2 -> 8.0.3(constants/about/README/tui/rich_tui/lsp/mcp) --- CHANGELOG.md | 21 ++++ README.en.md | 2 +- README.md | 2 +- about.txt | 2 +- lsp_client.py | 2 +- mcp_client.py | 2 +- tests/test_vim_keys.py | 164 ++++++++++++++++++++++++ xcli_core/constants.py | 2 +- xcli_core/rich_tui.py | 2 +- xcli_core/tui.py | 248 ++++++++++++++++++++++++++++++++++++- xcli_core/vim_keys.py | 275 +++++++++++++++++++++++++++++++++++++++++ 11 files changed, 710 insertions(+), 12 deletions(-) create mode 100644 tests/test_vim_keys.py create mode 100644 xcli_core/vim_keys.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0052f..594cf03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ --- +## v8.0.3 (2026-08-07) — 体验增强 + +### ✨ 新功能 + +- **TUI 新增 Vim 风格键位**(默认开,可用 `/vim` 开关) + - 输入框**模态编辑**:`Esc` 在 NORMAL / INSERT 间切换;NORMAL 下 + - 移动:`h j k l`、`w / b`(跳词)、`0 / $`(行首/行尾)、`^`(首个非空白)、`gg / G`(文首/文尾) + - 进入插入:`i / a / o`(插入/追加/下方新行)、`I / A / O`(行首/行尾/上方新行) + - 编辑:`dd`(删行)、`x`(删字)、`dw`(删词)、`d$`(删到行尾)、`u`(撤销) + - 普通模式下 `回车` = 下移一行(与 vim 一致) + - 核心状态机抽成纯模块 `xcli_core/vim_keys.py`(`VimInputState`,不依赖 textual),配 `tests/test_vim_keys.py` 单测,18 项动作全部验证通过 + - 聊天区导航(输入框未聚焦时生效):`j / k` 逐行滚动、`Ctrl+F / B` 翻页、`Ctrl+D / U` 半页、`Ctrl+Space` 切换焦点、`i` 聚焦并进入插入 + - 输入框下方提示栏实时显示 `-- INSERT --` / `-- NORMAL --` 模式;`/help` 已补充 Vim 键位说明 + - 可通过 `config.json` 的 `vim_mode: false` 默认关闭 + +### 🛠 工程 + +- 版本号统一更新至 v8.0.3(含 README 中英 badge、`about.txt`、`xcli_core/constants.py` 中央 `VERSION`、TUI banner、LSP/MCP `clientInfo.version`) + +--- + ## v8.0.2 (2026-08-07) — 小修复版本 ### 🐛 Bug 修复 diff --git a/README.en.md b/README.en.md index 1f55c37..fc1246f 100644 --- a/README.en.md +++ b/README.en.md @@ -5,7 +5,7 @@

- version + version python license tests diff --git a/README.md b/README.md index a4e70ed..47dc278 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- version + version python license tests diff --git a/about.txt b/about.txt index 03beb60..ba3c0ca 100644 --- a/about.txt +++ b/about.txt @@ -1,7 +1,7 @@ **产品标识** - **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI) - **内部版本号**:20260801 -- **发布版本**:发行版8.0.2 +- **发布版本**:发行版8.0.3 **技术规格** - **主要语言**:简体中文(UTF-8) diff --git a/lsp_client.py b/lsp_client.py index c13785e..3dc2e17 100644 --- a/lsp_client.py +++ b/lsp_client.py @@ -268,7 +268,7 @@ class LspClient: "workspace": {"workspaceFolders": True, "configuration": True}, }, - "clientInfo": {"name": "xiaoli-cli", "version": "8.0.2"}, + "clientInfo": {"name": "xiaoli-cli", "version": "8.0.3"}, }) if isinstance(result, dict): self.capabilities = result.get("capabilities", {}) or {} diff --git a/mcp_client.py b/mcp_client.py index 4615e8a..f367d64 100644 --- a/mcp_client.py +++ b/mcp_client.py @@ -153,7 +153,7 @@ class McpClient: result = self._send("initialize", { "protocolVersion": PROTOCOL_VERSION, "capabilities": {}, - "clientInfo": {"name": "xiaoli-cli", "version": "8.0.2"}, + "clientInfo": {"name": "xiaoli-cli", "version": "8.0.3"}, }) # 通知 server 初始化完成(无响应,无需等待) self._send("notifications/initialized", {}, is_notification=True) diff --git a/tests/test_vim_keys.py b/tests/test_vim_keys.py new file mode 100644 index 0000000..a745cb0 --- /dev/null +++ b/tests/test_vim_keys.py @@ -0,0 +1,164 @@ +"""VimInputState 纯逻辑单测(不依赖 textual)。 + +用一个极简 FakeBuffer 持有 (text, offset),把 EditResult 应用回去, +逐动作断言文本/光标/模式变化。 +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from xcli_core.vim_keys import VimInputState, EditResult + + +class FakeBuffer: + """模拟输入框:持有 text 与 cursor offset。""" + def __init__(self, text, offset, mode="INSERT"): + self.st = VimInputState(mode=mode) + self.text = text + self.offset = offset + + def press(self, token): + res = self.st.feed(self.text, self.offset, token) + self.text = res.text + self.offset = res.offset + return res + + def press_seq(self, *tokens): + return [self.press(t) for t in tokens] + + +def test_escape_enters_normal_and_steps_left(): + b = FakeBuffer("hello", 5, mode="INSERT") + r = b.press("escape") + assert b.st.mode == "NORMAL" + assert b.offset == 4 + assert r.message == "-- NORMAL --" + + +def test_normal_h_l_clamp(): + b = FakeBuffer("hello", 0, mode="NORMAL") + b.press("h") # 行首不动 + assert b.offset == 0 + b.press_seq("l", "l", "l", "l", "l") # 走到末尾 + assert b.offset == 5 + b.press("l") # 末尾不动 + assert b.offset == 5 + + +def test_j_k_move_lines(): + b = FakeBuffer("aa\nbb\ncc", 1, mode="NORMAL") # 第0行 col1 + b.press("j") # 第1行 col1 + assert b.offset == 4 # "aa\n"=3 +1 + b.press("k") # 回第0行 col1 + assert b.offset == 1 + + +def test_0_and_dollar(): + b = FakeBuffer("hello world", 5, mode="NORMAL") + b.press("0") + assert b.offset == 0 + b.press("$") + assert b.offset == 11 + + +def test_i_enters_insert(): + b = FakeBuffer("abc", 1, mode="NORMAL") + r = b.press("i") + assert b.st.mode == "INSERT" + assert r.action is None + + +def test_a_insert_after(): + b = FakeBuffer("abc", 1, mode="NORMAL") + r = b.press("a") + assert b.st.mode == "INSERT" + assert b.offset == 2 + + +def test_o_opens_new_line(): + b = FakeBuffer("line1\nline2", 2, mode="NORMAL") # 第0行 + r = b.press("o") + assert b.st.mode == "INSERT" + assert b.text == "line1\n\nline2" + assert b.offset == 6 # "line1\n"=6 + + +def test_O_opens_above(): + b = FakeBuffer("line1\nline2", 7, mode="NORMAL") # 第1行 'l' of line2 + r = b.press("O") + assert b.st.mode == "INSERT" + assert b.text == "line1\n\nline2" + assert b.offset == 6 # 新空行起始 + + +def test_dd_deletes_line(): + b = FakeBuffer("aaa\nbbb\nccc", 4, mode="NORMAL") # 第1行 + b.press_seq("d", "d") + assert b.text == "aaa\nccc" + assert b.offset == 4 # 回到第1行行首(aaa\n 之后) + + +def test_dw_deletes_word(): + b = FakeBuffer("foo bar baz", 0, mode="NORMAL") + b.press_seq("d", "w") + assert b.text == "bar baz" + assert b.offset == 0 + + +def test_d_dollar_deletes_to_eol(): + b = FakeBuffer("abc def", 2, mode="NORMAL") # 第0行 col2 ('c') + b.press_seq("d", "$") + assert b.text == "ab" + assert b.offset == 2 + + +def test_x_deletes_char(): + b = FakeBuffer("abc", 1, mode="NORMAL") # 'b' + b.press("x") + assert b.text == "ac" + assert b.offset == 1 + + +def test_w_b_word_motion(): + b = FakeBuffer("foo bar baz", 0, mode="NORMAL") + b.press("w") + assert b.offset == 4 # 下一词 "bar" 词首 + b.press("b") + assert b.offset == 0 + + +def test_gg_and_G(): + b = FakeBuffer("a\nb\nc", 4, mode="NORMAL") + b.press_seq("g", "g") + assert b.offset == 0 + b.press("G") + assert b.offset == 4 # 末行同列 + + +def test_u_sets_undo_action(): + b = FakeBuffer("hello", 2, mode="NORMAL") + r = b.press("u") + assert r.action == "undo" + + +def test_pending_cleared_on_invalid_combo(): + b = FakeBuffer("abc", 1, mode="NORMAL") + b.press("d") # 进入待定 + assert b.st.pending == "d" + b.press("x") # 非法组合 -> 清待定, 当普通键 + assert b.st.pending is None + assert b.st.mode == "NORMAL" + + +def test_unmapped_key_ignored_in_normal(): + b = FakeBuffer("abc", 1, mode="NORMAL") + r = b.press("z") # 未映射 + assert r.handled is True + assert b.text == "abc" + assert b.offset == 1 + + +def test_insert_typing_delegated(): + b = FakeBuffer("abc", 1, mode="INSERT") + r = b.press("h") # 非 Esc:交给输入框默认处理 + assert r.handled is False diff --git a/xcli_core/constants.py b/xcli_core/constants.py index 7bc4a1d..aed7bef 100644 --- a/xcli_core/constants.py +++ b/xcli_core/constants.py @@ -7,7 +7,7 @@ LOVE_FILE_PATH = "love.txt" DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史 # 版本号(v8.0 基于 v5.4.1 重新立项) -VERSION = "8.0.2" +VERSION = "8.0.3" VERSION_NAME = "小狸 Pro-CLI" import os diff --git a/xcli_core/rich_tui.py b/xcli_core/rich_tui.py index e63b7ab..2dc29c5 100644 --- a/xcli_core/rich_tui.py +++ b/xcli_core/rich_tui.py @@ -28,7 +28,7 @@ class RichTUI: self.cli = cli # record=True 是关键:渲染过程被记录,可随时导出文本/SVG/HTML 自验 self.console = Console(record=True, width=100) - self.version = "8.0.2" + self.version = "8.0.3" self.engine = "" self.model = "" self.mode = "普通" diff --git a/xcli_core/tui.py b/xcli_core/tui.py index fa9196a..98bad83 100644 --- a/xcli_core/tui.py +++ b/xcli_core/tui.py @@ -11,6 +11,7 @@ if TEXTUAL_AVAILABLE: from textual.reactive import var from textual.binding import Binding from textual import on + from .vim_keys import VimInputState import os from rich.syntax import Syntax from rich.panel import Panel @@ -560,6 +561,125 @@ if TEXTUAL_AVAILABLE: return # 其他键正常处理 + + # ═══════════════════════════════════════════════════ + # Vim 风格模态输入框(NORMAL / INSERT) + # ═══════════════════════════════════════════════════ + + class VimSendTextArea(SendTextArea): + """ + 带 Vim 模态编辑的输入框。 + + 模式: + INSERT —— 默认,正常打字;Esc 进入 NORMAL + NORMAL —— h/j/k/l 移动,i/a/o 进入插入,dd 删行,x 删字, + w/b 跳词,0/$ 行首行尾,gg/G 文首文尾,u 撤销 … + + 坐标:VimInputState 用字符偏移 offset,这里负责与 TextArea 的 + (row, col) 光标互相转换。所有 vim 逻辑都在 vim_keys.py(纯函数, + 可单测);本类只做「按键映射 + 把结果写回 TextArea」。 + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.vim_enabled = True + self.vim = VimInputState(mode="INSERT") + + # ── offset <-> (row, col) ── + def _cur_off(self) -> int: + r, c = self.cursor_location + text = self.text + lines = text.split("\n") + return sum(len(l) + 1 for l in lines[:r]) + c + + def _set_off(self, off: int): + text = self.text + off = max(0, min(off, len(text))) + r = text.count("\n", 0, off) + line_start = text.rfind("\n", 0, off) + c = off - (line_start + 1) + self.cursor_location = (r, c) + + # ── key 名字 -> vim token ── + @staticmethod + def _vim_token(key: str): + m = { + "escape": "escape", "h": "h", "l": "l", "j": "j", "k": "k", + "w": "w", "b": "b", "e": "e", "0": "0", "$": "$", "^": "^", + "g": "g", "G": "G", "d": "d", "x": "x", "u": "u", + "i": "i", "a": "a", "o": "o", "O": "O", "I": "I", "A": "A", + "left": "left", "right": "right", "up": "up", "down": "down", + "shift+i": "I", "shift+a": "A", "shift+o": "O", + "shift+g": "G", "shift+6": "^", "shift+4": "$", + } + return m.get(key) + + def _notify_mode(self): + app = self.app + if hasattr(app, "_update_vim_mode_display"): + try: + app._update_vim_mode_display() + except Exception: + pass + + def _apply(self, res): + try: + if res.text != self.text: + self.text = res.text + self._set_off(res.offset) + self.vim.mode = res.mode + self.vim.pending = res.pending + if res.action == "undo": + try: + self.undo() + except Exception: + pass + self._notify_mode() + except Exception: + # 任何意外都不应让输入框崩;退回默认行为 + pass + + async def _on_key(self, event): + # 回车:INSERT(或 vim 关闭)发送;NORMAL 下移一行 + if event.key in ("enter", "\r", "\n"): + if self.vim_enabled and self.vim.mode == "NORMAL": + event.prevent_default() + res = self.vim.feed(self.text, self._cur_off(), "j") + self._apply(res) + else: + event.prevent_default() + try: + self.app.action_send_message() + except Exception: + pass + return + + if not self.vim_enabled: + return # 退化为普通 TextArea + + if self.vim.mode == "INSERT": + if event.key == "escape": + event.prevent_default() + res = self.vim.feed(self.text, self._cur_off(), "escape") + self._apply(res) + # 其余键交给 TextArea 默认处理(打字) + return + + # ── NORMAL 模式 ── + if event.key == "shift+enter": + token = "o" + else: + token = self._vim_token(event.key) + if token is None: + # ctrl+/shift+ 组合键放回去,让 App 级绑定处理(聚焦切换等) + if event.key.startswith("ctrl+") or event.key.startswith("shift+"): + return + # 普通单键未映射:吞掉,避免 TextArea 在 NORMAL 下误编辑 + event.prevent_default() + return + event.prevent_default() + res = self.vim.feed(self.text, self._cur_off(), token) + self._apply(res) + # ═══════════════════════════════════════════════════ # TUI 主应用 # ═══════════════════════════════════════════════════ @@ -583,6 +703,15 @@ if TEXTUAL_AVAILABLE: Binding("3", "tab_sessions", "会话", show=False), Binding("4", "tab_status", "状态", show=False), Binding("escape", "cancel", "取消", show=False), + # ── Vim 风格导航(输入框未聚焦时生效)── + Binding("j", "chat_down", "↓聊天", show=False), + Binding("k", "chat_up", "↑聊天", show=False), + Binding("i", "focus_insert", "插入", show=False), + Binding("ctrl+f", "chat_page_down", "翻页↓", show=True), + Binding("ctrl+b", "chat_page_up", "翻页↑", show=True), + Binding("ctrl+d", "chat_half_down", "半页↓", show=True), + Binding("ctrl+u", "chat_half_up", "半页↑", show=True), + Binding("ctrl+space", "toggle_focus", "聚焦切换", show=True), ] sidebar_visible = var(True) @@ -605,8 +734,8 @@ if TEXTUAL_AVAILABLE: yield VerticalScroll(id="chat-scroll") with Vertical(id="input-area"): with Container(id="input-wrapper"): - yield SendTextArea( - placeholder="输入消息... (回车发送, Shift+回车换行)", + yield VimSendTextArea( + placeholder="输入消息... (回车发送, Shift+回车换行, Esc 进 Vim 普通模式)", id="user-input", soft_wrap=True, tab_behavior="indent", @@ -638,6 +767,8 @@ if TEXTUAL_AVAILABLE: self._switch_sidebar_tab("engine") # 精简欢迎(一行) self._render_welcome_compact() + # 初始化 Vim 键位开关(默认开,可被 config.vim_mode 关闭) + self._init_vim_mode() self.query_one("#user-input").focus() def _show_hint(): try: @@ -646,6 +777,18 @@ if TEXTUAL_AVAILABLE: pass self.set_timer(1.5, _show_hint) + def _init_vim_mode(self): + try: + ta = self.query_one("#user-input") + except Exception: + return + cfg = getattr(self.cli, "config", None) + enabled = True + if isinstance(cfg, dict) and "vim_mode" in cfg: + enabled = bool(cfg["vim_mode"]) + ta.vim_enabled = enabled + self._update_vim_mode_display() + # ── 欢迎界面 ── def _render_welcome_compact(self): @@ -667,7 +810,7 @@ if TEXTUAL_AVAILABLE: "", " ╔══════════════════════════════════════════════╗", " ║ ║", - " ║ 小狸 Pro-CLI v8.0.2 ║", + " ║ 小狸 Pro-CLI v8.0.3 ║", " ║ 智能编程助手 · 动画增强版 ║", " ║ ║", " ╚══════════════════════════════════════════════╝", @@ -711,7 +854,7 @@ if TEXTUAL_AVAILABLE: ("", "msg-dim"), (" ╔══════════════════════════════════════════════╗", "msg-welcome"), (" ║ ║", "msg-welcome"), - (" ║ 小狸 Pro-CLI v8.0.2 ║", "msg-welcome"), + (" ║ 小狸 Pro-CLI v8.0.3 ║", "msg-welcome"), (" ║ 智能编程助手 · 动画增强版 ║", "msg-welcome"), (" ║ ║", "msg-welcome"), (" ╚══════════════════════════════════════════════╝", "msg-welcome"), @@ -1087,6 +1230,7 @@ if TEXTUAL_AVAILABLE: 'resume': lambda: self._tui_resume(args), 'snapshot': lambda: self._save_screenshot(), 'screenshot': lambda: self._save_screenshot(), + 'vim': lambda: self._toggle_vim(), } handler = cmds.get(name) @@ -1114,12 +1258,21 @@ if TEXTUAL_AVAILABLE: /tools 列出工具 /status 系统状态 /clear 清屏 + /vim 开关 Vim 键位 ⌨ 快捷键: Ctrl+C 退出 Ctrl+L 清屏 Ctrl+N 新对话 回车 发送消息 Shift+回车 换行 F1 侧栏 - Escape 取消生成 Tab 缩进""" + Escape 取消生成 Tab 缩进 + Ctrl+Space 焦点切换 i 聚焦并输入 + Ctrl+F/B 翻页下/上 Ctrl+D/U 半页下/上 + + VIM 输入(默认开): 输入框内 Esc 进普通模式 + h j k l 移动 w/b 跳词 0/$ 行首/行尾 + i a o 插入/追加/换行 I A O 行首/行尾/上方 + dd 删行 x 删字 u 撤销 gg/G 文首/文尾 + 普通模式下 回车 = 下移一行""" self._system(help_text) def _handle_manual(self, args): @@ -1341,6 +1494,91 @@ if TEXTUAL_AVAILABLE: self._system("已取消") self.is_generating = False + # ── Vim 风格:聊天区滚动 / 聚焦切换 ── + def _user_input(self): + return self.query_one("#user-input") + + def _chat_scroll(self): + return self.query_one("#chat-scroll") + + def action_toggle_focus(self): + """Ctrl+Space:在输入框与聊天区之间切换焦点。""" + ta = self._user_input() + if ta.has_focus: + self.screen.set_focus(None) + else: + ta.focus() + if getattr(ta, "vim_enabled", False): + ta.vim.mode = "INSERT" + self._update_vim_mode_display() + + def action_focus_insert(self): + """i:输入框未聚焦时聚焦并进入 INSERT(模仿 vim 按 i 开始输入)。""" + ta = self._user_input() + if ta.has_focus: + return + ta.focus() + if getattr(ta, "vim_enabled", False): + ta.vim.mode = "INSERT" + self._update_vim_mode_display() + + def _guard_chat(self): + """聊天导航仅在输入框未聚焦时生效,避免与输入冲突。""" + return self._user_input().has_focus + + def action_chat_down(self): + if self._guard_chat(): + return + self._chat_scroll().scroll_down(3) + + def action_chat_up(self): + if self._guard_chat(): + return + self._chat_scroll().scroll_up(3) + + def action_chat_page_down(self): + if self._guard_chat(): + return + self._chat_scroll().scroll_page_down() + + def action_chat_page_up(self): + if self._guard_chat(): + return + self._chat_scroll().scroll_page_up() + + def action_chat_half_down(self): + if self._guard_chat(): + return + self._chat_scroll().scroll_relative(y=20) + + def action_chat_half_up(self): + if self._guard_chat(): + return + self._chat_scroll().scroll_relative(y=-20) + + def _update_vim_mode_display(self): + """在输入框下方提示栏显示当前 Vim 模式。""" + try: + hint = self.query_one("#input-hint") + ta = self._user_input() + except Exception: + return + if not getattr(ta, "vim_enabled", False): + hint.update(" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏 | Ctrl+P PLAN") + return + mode = getattr(getattr(ta, "vim", None), "mode", "INSERT") + tag = "-- INSERT --" if mode == "INSERT" else "-- NORMAL --" + hint.update(f" {tag} │ Esc 切模式 │ i插入 a追加 o换行 hjkl移动 dd删行 x删字 u撤销") + + def _toggle_vim(self): + ta = self._user_input() + ta.vim_enabled = not getattr(ta, "vim_enabled", True) + if ta.vim_enabled: + self._system("Vim 键位: 开 (Esc 在 NORMAL/INSERT 间切换)") + else: + self._system("Vim 键位: 关 (普通输入)") + self._update_vim_mode_display() + def _save_screenshot(self): """导出当前 TUI 屏幕截图(SVG)——AI 可据此自验实时渲染(/snapshot)""" import os diff --git a/xcli_core/vim_keys.py b/xcli_core/vim_keys.py new file mode 100644 index 0000000..108c2f2 --- /dev/null +++ b/xcli_core/vim_keys.py @@ -0,0 +1,275 @@ +""" +Vim 风格输入编辑状态机(纯逻辑,不依赖 textual) +================================================ +把“模态编辑”的核心(NORMAL / INSERT 双模式 + 各种 motion / operator)抽成 +纯函数 + 一个小状态类,便于在不依赖 textual 的环境里做单元测试。 + +widget 侧(tui.py 的 VimSendTextArea)只负责: +- 把 textual 的 key 名字映射成这里的 token(见 tui.py 的 _vim_token) +- 调用 VimInputState.feed(text, offset, token) 拿到 EditResult +- 把 EditResult 应用回 TextArea(设 text / 设光标 / 触发 undo 等) + +坐标约定:文本用单一字符串表示,光标用「字符偏移 offset」表示(从 0 开始)。 +widget 负责 offset <-> (row, col) 的转换。 +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class EditResult: + """一次 vim 动作的执行结果。""" + text: str # 应用后的文本 + offset: int # 应用后的光标偏移 + mode: str # 结束时的模式: "INSERT" / "NORMAL" + pending: Optional[str] # 多键序列的待定前缀: "d" / "g" / None + handled: bool = True # False 表示“交给输入框默认处理”(仅 INSERT 非 Esc 键) + action: Optional[str] = None # 额外指令: "undo" + message: str = "" # 状态栏提示语 + + +# ── 文本/坐标辅助 ────────────────────────────────────────────────────────── + +def _line_start_off(text: str, off: int) -> int: + """当前行行首偏移。""" + s = text.rfind("\n", 0, off) + return s + 1 + + +def _line_end_off(text: str, off: int) -> int: + """当前行行尾(不含换行符)偏移。""" + e = text.find("\n", off) + return e if e != -1 else len(text) + + +def _first_nonblank_off(text: str, off: int) -> int: + """当前行第一个非空白字符偏移,若整行空白则回到行首。""" + s = _line_start_off(text, off) + e = _line_end_off(text, off) + i = s + while i < e and text[i].isspace(): + i += 1 + return i + + +def _word_forward_off(text: str, off: int) -> int: + """跳到下一个 word 的词首(vim 的 w):越过当前词 + 词间空白。""" + n = len(text) + i = off + # 越过错当前空白 + while i < n and text[i].isspace(): + i += 1 + # 越过当前 word + if i < n and (text[i].isalnum() or text[i] == "_"): + while i < n and (text[i].isalnum() or text[i] == "_"): + i += 1 + else: + while i < n and not text[i].isspace() and not (text[i].isalnum() or text[i] == "_"): + i += 1 + # 越过词间空白,落到下一词首 + while i < n and text[i].isspace(): + i += 1 + return i + + +def _word_back_off(text: str, off: int) -> int: + """跳到上一个 word 的词首(vim 的 b)。""" + i = off + # 越过光标前空白 + while i > 0 and text[i - 1].isspace(): + i -= 1 + # 越过当前 word + if i > 0 and (text[i - 1].isalnum() or text[i - 1] == "_"): + while i > 0 and (text[i - 1].isalnum() or text[i - 1] == "_"): + i -= 1 + else: + while i > 0 and not text[i - 1].isspace() and not (text[i - 1].isalnum() or text[i - 1] == "_"): + i -= 1 + # 越过词间空白,落到上一词首 + while i > 0 and text[i - 1].isspace(): + i -= 1 + return i + + +def _offset_to_rc(text: str, off: int): + off = max(0, min(off, len(text))) + row = text.count("\n", 0, off) + line_start = text.rfind("\n", 0, off) + col = off - (line_start + 1) + return row, col + + +def _rc_to_offset(text: str, row: int, col: int) -> int: + lines = text.split("\n") + row = max(0, min(row, len(lines) - 1)) + line = lines[row] + col = max(0, min(col, len(line))) + off = 0 + for l in lines[:row]: + off += len(l) + 1 + return off + col + + +# ── 状态机 ──────────────────────────────────────────────────────────────── + +class VimInputState: + """ + Vim 输入状态机。 + + mode: "INSERT" 或 "NORMAL" + pending: 多键序列待定前缀("d" / "g"),单键动作时为 None + + 用法: + st = VimInputState(mode="INSERT") # 输入框默认进入 INSERT + res = st.feed(text, offset, token) + # 把 res 应用回输入框 + """ + + def __init__(self, mode: str = "INSERT"): + self.mode = mode + self.pending: Optional[str] = None + + # 进入 INSERT 时返回的结果构造器 + @staticmethod + def _insert(text, offset, msg) -> EditResult: + return EditResult(text, offset, "INSERT", None, message=msg) + + def feed(self, text: str, offset: int, token: str) -> EditResult: + """ + token 是已经映射好的 vim 键名,例如: + "h","l","j","k","w","b","e","0","$","^","g","G","d","x","u", + "i","a","o","O","I","A","escape","left","right","up","down" + """ + if self.mode == "INSERT": + if token == "escape": + # 回到 NORMAL,光标左移一格(vim 习惯:Esc 后光标退到本字上) + new_off = offset + if offset > 0 and text[offset - 1] != "\n": + new_off = offset - 1 + self.mode = "NORMAL" + self.pending = None + return EditResult(text, new_off, "NORMAL", None, message="-- NORMAL --") + # 其余键交给 TextArea 默认处理(打字) + return EditResult(text, offset, "INSERT", None, handled=False) + + # ── NORMAL 模式 ── + # 多键序列待定前缀 + if self.pending == "d": + self.pending = None + if token == "d": + return self._delete_line(text, offset, "dd") + if token == "w": + return self._delete_word(text, offset, "dw") + if token == "$": + return self._delete_to_eol(text, offset, "d$") + # 非法组合:清掉 pending 后把当前键当单键重新处理 + # (落到下面的单键逻辑) + + if self.pending == "g": + self.pending = None + if token == "g": + return EditResult(text, 0, "NORMAL", None, message="gg") + # 其余:当作普通单键处理 + + # ── 单键 ── + if token in ("i",): + self.mode = "INSERT" + return self._insert(text, offset, "-- INSERT --") + if token == "I": + self.mode = "INSERT" + return self._insert(text, _first_nonblank_off(text, offset), "-- INSERT (行首) --") + if token == "a": + self.mode = "INSERT" + return self._insert(text, min(offset + 1, len(text)), "-- INSERT (追加) --") + if token == "A": + self.mode = "INSERT" + return self._insert(text, _line_end_off(text, offset), "-- INSERT (行尾) --") + if token == "o": + self.mode = "INSERT" + e = _line_end_off(text, offset) + new_text = text[:e] + "\n" + text[e:] + return self._insert(new_text, e + 1, "-- INSERT (新行) --") + if token == "O": + self.mode = "INSERT" + s = _line_start_off(text, offset) + new_text = text[:s] + "\n" + text[s:] + return self._insert(new_text, s, "-- INSERT (上方新行) --") + + if token in ("h", "left"): + new_off = max(_line_start_off(text, offset), offset - 1) + return EditResult(text, new_off, "NORMAL", None) + if token in ("l", "right"): + le = _line_end_off(text, offset) + new_off = min(le, offset + 1) if offset < len(text) else offset + return EditResult(text, new_off, "NORMAL", None) + if token in ("j", "down"): + r, c = _offset_to_rc(text, offset) + return EditResult(text, _rc_to_offset(text, r + 1, c), "NORMAL", None) + if token in ("k", "up"): + r, c = _offset_to_rc(text, offset) + return EditResult(text, _rc_to_offset(text, r - 1, c), "NORMAL", None) + if token == "w": + return EditResult(text, _word_forward_off(text, offset), "NORMAL", None) + if token == "b": + return EditResult(text, _word_back_off(text, offset), "NORMAL", None) + if token == "e": + i = _word_forward_off(text, offset) + new_off = max(offset, i - 1) if i > offset else offset + return EditResult(text, new_off, "NORMAL", None) + if token == "0": + return EditResult(text, _line_start_off(text, offset), "NORMAL", None) + if token == "^": + return EditResult(text, _first_nonblank_off(text, offset), "NORMAL", None) + if token == "$": + return EditResult(text, _line_end_off(text, offset), "NORMAL", None) + if token == "G": + r, c = _offset_to_rc(text, offset) + last_row = text.count("\n") + return EditResult(text, _rc_to_offset(text, last_row, c), "NORMAL", None) + if token == "g": + self.pending = "g" + return EditResult(text, offset, "NORMAL", "g", message="g") + if token == "d": + self.pending = "d" + return EditResult(text, offset, "NORMAL", "d", message="d") + if token == "x": + if offset < len(text): + new_text = text[:offset] + text[offset + 1:] + return EditResult(new_text, offset, "NORMAL", None, message="x") + return EditResult(text, offset, "NORMAL", None) + if token == "u": + return EditResult(text, offset, "NORMAL", None, action="undo", message="u") + if token == "escape": + return EditResult(text, offset, "NORMAL", None, message="-- NORMAL --") + + # 未知键:忽略(保持 NORMAL) + return EditResult(text, offset, "NORMAL", None) + + # ── operator 实现 ── + def _delete_line(self, text, offset, msg) -> EditResult: + s = _line_start_off(text, offset) + e = _line_end_off(text, offset) + if e < len(text): + e2 = e + 1 # 连同行尾换行一起删 + else: + e2 = e + if s > 0: + s = s - 1 # 最后一行:删掉上一行的换行 + new_text = text[:s] + text[e2:] + new_off = min(s, len(new_text)) + return EditResult(new_text, new_off, "NORMAL", None, message=msg) + + def _delete_word(self, text, offset, msg) -> EditResult: + s = offset + e = _word_forward_off(text, offset) + new_text = text[:s] + text[e:] + new_off = min(s, len(new_text)) + return EditResult(new_text, new_off, "NORMAL", None, message=msg) + + def _delete_to_eol(self, text, offset, msg) -> EditResult: + s = offset + e = _line_end_off(text, offset) + new_text = text[:s] + text[e:] + new_off = min(s, len(new_text)) + return EditResult(new_text, new_off, "NORMAL", None, message=msg)