Files
xiaoli-cli/xcli_core/vim_keys.py
T
shuiyu67 66de8eea13 feat: TUI 新增 Vim 风格键位(模态输入 + 聊天导航),版本升 8.0.3
- 新增 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)
2026-08-07 23:35:42 +08:00

276 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)