jvim 0.6.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jvim-0.6.0 → jvim-0.7.0}/.claude/CLAUDE.md +1 -0
- {jvim-0.6.0 → jvim-0.7.0}/PKG-INFO +2 -1
- {jvim-0.6.0 → jvim-0.7.0}/README.kr.md +1 -0
- {jvim-0.6.0 → jvim-0.7.0}/README.md +1 -0
- {jvim-0.6.0 → jvim-0.7.0}/pyproject.toml +1 -1
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/__init__.py +1 -1
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/clipboard.py +38 -11
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/data/help.json +10 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/normal.py +194 -43
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/widget.py +5 -2
- {jvim-0.6.0 → jvim-0.7.0}/tests/test_editor.py +355 -0
- {jvim-0.6.0 → jvim-0.7.0}/uv.lock +1 -1
- {jvim-0.6.0 → jvim-0.7.0}/.claude/settings.local.json +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.gitignore +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/.git +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/.gitignore +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/CLAUDE.md +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/README.kr.md +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/README.md +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/pyproject.toml +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/__init__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/__main__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/data/help.json +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/data/sample.json +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/diff.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/differ.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/differ.tcss +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/editor.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/editor.tcss +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/py.typed +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/widget.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/tests/__init__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/tests/test_diff.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/tests/test_editor.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/.git +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/.gitignore +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/CLAUDE.md +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/README.kr.md +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/README.md +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/docs/jvim.svg +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/docs/jvimdiff.svg +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/pyproject.toml +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/__init__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/__main__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/data/help.json +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/data/sample.json +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/diff.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/differ.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/differ.tcss +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/editor.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/editor.tcss +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/py.typed +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/widget.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/tests/__init__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/tests/test_diff.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/tests/test_editor.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/CLAUDE.md +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/docs/jvim.svg +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/docs/jvimdiff.svg +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/scripts/benchmark.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/scripts/screenshots.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/__main__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/__init__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/folding.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/jsonpath.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/navigation.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/substitute.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/visual.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/data/sample.json +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/diff.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/differ.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/editor.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/__init__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/command.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/insert.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/search.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/py.typed +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/styles/differ.tcss +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/src/jvim/styles/editor.tcss +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/tests/__init__.py +0 -0
- {jvim-0.6.0 → jvim-0.7.0}/tests/test_diff.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jvim
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: JSON editor with vim-style keybindings
|
|
5
5
|
Project-URL: Homepage, https://github.com/k2hyun/jvim
|
|
6
6
|
Project-URL: Repository, https://github.com/k2hyun/jvim
|
|
@@ -50,6 +50,7 @@ JSON editor with vim-style keybindings, built with [Textual](https://github.com/
|
|
|
50
50
|
- **JSONL support** - Edit JSON Lines files with smart formatting
|
|
51
51
|
- **Embedded JSON editing** - Edit JSON strings within JSON with nested level support
|
|
52
52
|
- **Visual mode** - Character-wise (`v`) and line-wise (`V`) selection with `d`/`y`/`c` operators
|
|
53
|
+
- **Count prefix** - Vim-style numeric prefixes (`5j`, `3dd`, `d3d`) with fold-aware line operations
|
|
53
54
|
- **Folding** - Collapse/expand JSON blocks and long string values
|
|
54
55
|
- **Bracket matching** - Jump to matching brackets with `%`
|
|
55
56
|
- **Undo/Redo** - Full undo history
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- **JSONL 지원** - JSON Lines 파일을 스마트하게 포맷팅하여 편집
|
|
20
20
|
- **내장 JSON 편집** - JSON 내 문자열로 저장된 JSON을 중첩 레벨까지 편집
|
|
21
21
|
- **Visual 모드** - 문자 단위(`v`) 및 줄 단위(`V`) 선택 후 `d`/`y`/`c` 연산자 지원
|
|
22
|
+
- **숫자 접두사(Count prefix)** - Vim 스타일 숫자 접두사(`5j`, `3dd`, `d3d`) 및 fold-aware 줄 조작
|
|
22
23
|
- **접기(Folding)** - JSON 블록과 긴 문자열 값을 접기/펼치기
|
|
23
24
|
- **괄호 매칭** - `%`로 짝이 맞는 괄호로 이동
|
|
24
25
|
- **Undo/Redo** - 전체 실행 취소 기록 지원
|
|
@@ -21,6 +21,7 @@ JSON editor with vim-style keybindings, built with [Textual](https://github.com/
|
|
|
21
21
|
- **JSONL support** - Edit JSON Lines files with smart formatting
|
|
22
22
|
- **Embedded JSON editing** - Edit JSON strings within JSON with nested level support
|
|
23
23
|
- **Visual mode** - Character-wise (`v`) and line-wise (`V`) selection with `d`/`y`/`c` operators
|
|
24
|
+
- **Count prefix** - Vim-style numeric prefixes (`5j`, `3dd`, `d3d`) with fold-aware line operations
|
|
24
25
|
- **Folding** - Collapse/expand JSON blocks and long string values
|
|
25
26
|
- **Bracket matching** - Jump to matching brackets with `%`
|
|
26
27
|
- **Undo/Redo** - Full undo history
|
|
@@ -45,11 +45,16 @@ class ClipboardMixin:
|
|
|
45
45
|
self.cursor_row += inserted
|
|
46
46
|
self.cursor_col = len(parts[-1]) - 1
|
|
47
47
|
return
|
|
48
|
+
# fold-aware: fold 헤더이면 fold end 뒤에 삽입
|
|
49
|
+
insert_after = self.cursor_row
|
|
50
|
+
fold_end = self._folds.get(self.cursor_row)
|
|
51
|
+
if fold_end is not None:
|
|
52
|
+
insert_after = fold_end
|
|
48
53
|
inserted = len(self.yank_buffer)
|
|
49
54
|
for i, line in enumerate(self.yank_buffer):
|
|
50
|
-
self.lines.insert(
|
|
51
|
-
self._adjust_line_indices(
|
|
52
|
-
self.cursor_row
|
|
55
|
+
self.lines.insert(insert_after + 1 + i, line)
|
|
56
|
+
self._adjust_line_indices(insert_after + 1, inserted)
|
|
57
|
+
self.cursor_row = insert_after + 1
|
|
53
58
|
self.cursor_col = 0
|
|
54
59
|
|
|
55
60
|
def _paste_before(self) -> None:
|
|
@@ -88,11 +93,33 @@ class ClipboardMixin:
|
|
|
88
93
|
def _join_lines(self) -> None:
|
|
89
94
|
if self.cursor_row >= len(self.lines) - 1:
|
|
90
95
|
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
# fold-aware: fold 헤더이면 fold 전체를 제거한 뒤 다음 보이는 줄과 join
|
|
97
|
+
fold_end = self._folds.get(self.cursor_row)
|
|
98
|
+
if fold_end is not None:
|
|
99
|
+
next_row = fold_end + 1
|
|
100
|
+
if next_row >= len(self.lines):
|
|
101
|
+
return
|
|
102
|
+
self._save_undo()
|
|
103
|
+
cur = self.lines[self.cursor_row].rstrip()
|
|
104
|
+
# fold 내부 행 제거 (cursor_row+1 ~ fold_end)
|
|
105
|
+
del self.lines[self.cursor_row + 1 : next_row]
|
|
106
|
+
removed = fold_end - self.cursor_row
|
|
107
|
+
del self._folds[self.cursor_row]
|
|
108
|
+
self._folded_lines_dirty = True
|
|
109
|
+
self._adjust_line_indices(self.cursor_row + 1, -removed)
|
|
110
|
+
# 이제 cursor_row+1 = 원래 next_row (fold 뒤 첫 줄)
|
|
111
|
+
nxt = self.lines[self.cursor_row + 1].lstrip()
|
|
112
|
+
self.cursor_col = len(cur)
|
|
113
|
+
self.lines[self.cursor_row] = cur + " " + nxt
|
|
114
|
+
deleted_at = self.cursor_row + 1
|
|
115
|
+
self.lines.pop(deleted_at)
|
|
116
|
+
self._adjust_line_indices(deleted_at, -1)
|
|
117
|
+
else:
|
|
118
|
+
self._save_undo()
|
|
119
|
+
cur = self.lines[self.cursor_row].rstrip()
|
|
120
|
+
nxt = self.lines[self.cursor_row + 1].lstrip()
|
|
121
|
+
self.cursor_col = len(cur)
|
|
122
|
+
self.lines[self.cursor_row] = cur + " " + nxt
|
|
123
|
+
deleted_at = self.cursor_row + 1
|
|
124
|
+
self.lines.pop(deleted_at)
|
|
125
|
+
self._adjust_line_indices(deleted_at, -1)
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
+
"Count Prefix": {
|
|
3
|
+
"{N}motion": "repeat motion N times (e.g., 5j)",
|
|
4
|
+
"{N}G": "go to line N",
|
|
5
|
+
"{N}dd": "delete N lines (fold-aware)",
|
|
6
|
+
"{N}yy": "yank N lines (fold-aware)",
|
|
7
|
+
"d{N}d": "delete N lines (e.g., d3d)",
|
|
8
|
+
"{N}x": "delete N chars",
|
|
9
|
+
"{N}p {N}P": "paste N times",
|
|
10
|
+
"{N}J": "join N times"
|
|
11
|
+
},
|
|
2
12
|
"Movement": {
|
|
3
13
|
"h j k l": "left/down/up/right",
|
|
4
14
|
"w b": "word forward/backward",
|
|
@@ -14,23 +14,40 @@ class NormalMixin:
|
|
|
14
14
|
self._mode = EditorMode.INSERT
|
|
15
15
|
self.status_msg = "-- INSERT --"
|
|
16
16
|
|
|
17
|
+
def _consume_count(self) -> int:
|
|
18
|
+
"""_count_buf를 소비하고 정수 반환. 비어 있으면 1."""
|
|
19
|
+
if self._count_buf:
|
|
20
|
+
n = int(self._count_buf)
|
|
21
|
+
self._count_buf = ""
|
|
22
|
+
return max(1, n)
|
|
23
|
+
return 1
|
|
24
|
+
|
|
17
25
|
def _handle_normal(self, event) -> None:
|
|
18
26
|
EditorMode = self._mode.__class__
|
|
19
27
|
key = event.key
|
|
20
28
|
char = event.character or ""
|
|
21
29
|
|
|
22
|
-
# Escape: visual mode 해제 (pending보다 우선)
|
|
23
|
-
if key == "escape"
|
|
24
|
-
self.
|
|
25
|
-
self.
|
|
30
|
+
# Escape: visual mode 해제 (pending보다 우선) + count 리셋
|
|
31
|
+
if key == "escape":
|
|
32
|
+
self._count_buf = ""
|
|
33
|
+
if self._visual_mode:
|
|
34
|
+
self._visual_mode = ""
|
|
35
|
+
self.status_msg = ""
|
|
26
36
|
return
|
|
27
37
|
|
|
28
38
|
if self.pending:
|
|
29
39
|
self._handle_pending(char, key)
|
|
30
40
|
return
|
|
31
41
|
|
|
42
|
+
# 숫자 접두사 수집: 1-9로 시작, 이후 0-9 누적
|
|
43
|
+
if char and char.isdigit():
|
|
44
|
+
if char != "0" or self._count_buf:
|
|
45
|
+
self._count_buf += char
|
|
46
|
+
return
|
|
47
|
+
|
|
32
48
|
# Visual mode 진입/전환/해제
|
|
33
49
|
if char == "v":
|
|
50
|
+
self._count_buf = ""
|
|
34
51
|
if self._visual_mode == "v":
|
|
35
52
|
self._visual_mode = ""
|
|
36
53
|
self.status_msg = ""
|
|
@@ -41,6 +58,7 @@ class NormalMixin:
|
|
|
41
58
|
self.status_msg = "-- VISUAL --"
|
|
42
59
|
return
|
|
43
60
|
if char == "V":
|
|
61
|
+
self._count_buf = ""
|
|
44
62
|
if self._visual_mode == "V":
|
|
45
63
|
self._visual_mode = ""
|
|
46
64
|
self.status_msg = ""
|
|
@@ -51,40 +69,61 @@ class NormalMixin:
|
|
|
51
69
|
self.status_msg = "-- VISUAL LINE --"
|
|
52
70
|
return
|
|
53
71
|
|
|
54
|
-
# movement
|
|
72
|
+
# movement (count 적용)
|
|
55
73
|
if char == "h" or key == "left":
|
|
56
|
-
self.
|
|
74
|
+
count = self._consume_count()
|
|
75
|
+
self.cursor_col -= count
|
|
57
76
|
elif char == "j" or key == "down":
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
77
|
+
count = self._consume_count()
|
|
78
|
+
for _ in range(count):
|
|
79
|
+
self.cursor_row = (
|
|
80
|
+
self._next_visible_line(self.cursor_row, 1)
|
|
81
|
+
if self._folds
|
|
82
|
+
else self.cursor_row + 1
|
|
83
|
+
)
|
|
63
84
|
elif char == "k" or key == "up":
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
count = self._consume_count()
|
|
86
|
+
for _ in range(count):
|
|
87
|
+
self.cursor_row = (
|
|
88
|
+
self._next_visible_line(self.cursor_row, -1)
|
|
89
|
+
if self._folds
|
|
90
|
+
else self.cursor_row - 1
|
|
91
|
+
)
|
|
69
92
|
elif char == "l" or key == "right":
|
|
70
|
-
self.
|
|
93
|
+
count = self._consume_count()
|
|
94
|
+
self.cursor_col += count
|
|
71
95
|
elif char == "w":
|
|
72
|
-
self.
|
|
96
|
+
count = self._consume_count()
|
|
97
|
+
for _ in range(count):
|
|
98
|
+
self._move_word_forward()
|
|
73
99
|
elif char == "b":
|
|
74
|
-
self.
|
|
100
|
+
count = self._consume_count()
|
|
101
|
+
for _ in range(count):
|
|
102
|
+
self._move_word_backward()
|
|
75
103
|
elif char == "0":
|
|
104
|
+
# bare 0 (count_buf가 비었을 때만 여기 도달)
|
|
76
105
|
self.cursor_col = 0
|
|
77
106
|
elif char == "$" or key == "end":
|
|
107
|
+
self._count_buf = ""
|
|
78
108
|
self.cursor_col = max(0, len(self.lines[self.cursor_row]) - 1)
|
|
79
109
|
elif char == "^" or key == "home":
|
|
110
|
+
self._count_buf = ""
|
|
80
111
|
line = self.lines[self.cursor_row]
|
|
81
112
|
self.cursor_col = len(line) - len(line.lstrip())
|
|
82
113
|
elif char == "G":
|
|
83
|
-
|
|
114
|
+
if self._count_buf:
|
|
115
|
+
# count가 있으면 해당 줄로 이동 (1-indexed)
|
|
116
|
+
count = self._consume_count()
|
|
117
|
+
self.cursor_row = count - 1
|
|
118
|
+
else:
|
|
119
|
+
# count 없으면 마지막 줄
|
|
120
|
+
self.cursor_row = len(self.lines) - 1
|
|
84
121
|
self._scroll_cursor_to_top()
|
|
85
122
|
elif char == "%":
|
|
123
|
+
self._count_buf = ""
|
|
86
124
|
self._jump_matching_bracket()
|
|
87
125
|
elif key == "pagedown" or key == "ctrl+f":
|
|
126
|
+
self._count_buf = ""
|
|
88
127
|
if self._folds:
|
|
89
128
|
self.cursor_row = self._skip_visible_lines(
|
|
90
129
|
self.cursor_row, self._visible_height(), 1
|
|
@@ -92,6 +131,7 @@ class NormalMixin:
|
|
|
92
131
|
else:
|
|
93
132
|
self.cursor_row += self._visible_height()
|
|
94
133
|
elif key == "pageup" or key == "ctrl+b":
|
|
134
|
+
self._count_buf = ""
|
|
95
135
|
if self._folds:
|
|
96
136
|
self.cursor_row = self._skip_visible_lines(
|
|
97
137
|
self.cursor_row, self._visible_height(), -1
|
|
@@ -99,6 +139,7 @@ class NormalMixin:
|
|
|
99
139
|
else:
|
|
100
140
|
self.cursor_row -= self._visible_height()
|
|
101
141
|
elif key == "ctrl+d":
|
|
142
|
+
self._count_buf = ""
|
|
102
143
|
if self._folds:
|
|
103
144
|
self.cursor_row = self._skip_visible_lines(
|
|
104
145
|
self.cursor_row, self._visible_height() // 2, 1
|
|
@@ -106,6 +147,7 @@ class NormalMixin:
|
|
|
106
147
|
else:
|
|
107
148
|
self.cursor_row += self._visible_height() // 2
|
|
108
149
|
elif key == "ctrl+u":
|
|
150
|
+
self._count_buf = ""
|
|
109
151
|
if self._folds:
|
|
110
152
|
self.cursor_row = self._skip_visible_lines(
|
|
111
153
|
self.cursor_row, self._visible_height() // 2, -1
|
|
@@ -113,6 +155,7 @@ class NormalMixin:
|
|
|
113
155
|
else:
|
|
114
156
|
self.cursor_row -= self._visible_height() // 2
|
|
115
157
|
elif key == "ctrl+e":
|
|
158
|
+
self._count_buf = ""
|
|
116
159
|
nxt = (
|
|
117
160
|
self._next_visible_line(self._scroll_top, 1)
|
|
118
161
|
if self._folds
|
|
@@ -120,6 +163,7 @@ class NormalMixin:
|
|
|
120
163
|
)
|
|
121
164
|
self._scroll_top = min(nxt, len(self.lines) - 1)
|
|
122
165
|
elif key == "ctrl+y":
|
|
166
|
+
self._count_buf = ""
|
|
123
167
|
prev = (
|
|
124
168
|
self._next_visible_line(self._scroll_top, -1)
|
|
125
169
|
if self._folds
|
|
@@ -127,6 +171,7 @@ class NormalMixin:
|
|
|
127
171
|
)
|
|
128
172
|
self._scroll_top = max(prev, 0)
|
|
129
173
|
elif key == "ctrl+g":
|
|
174
|
+
self._count_buf = ""
|
|
130
175
|
total = len(self.lines)
|
|
131
176
|
pct = (self.cursor_row + 1) * 100 // total if total else 0
|
|
132
177
|
self.status_msg = (
|
|
@@ -135,40 +180,60 @@ class NormalMixin:
|
|
|
135
180
|
|
|
136
181
|
# enter insert mode
|
|
137
182
|
elif char == "i":
|
|
183
|
+
self._count_buf = ""
|
|
138
184
|
if not self.read_only:
|
|
139
185
|
self._dot_start(event)
|
|
140
186
|
self._enter_insert()
|
|
141
187
|
elif char == "I":
|
|
188
|
+
self._count_buf = ""
|
|
142
189
|
if not self.read_only:
|
|
143
190
|
self._dot_start(event)
|
|
144
191
|
line = self.lines[self.cursor_row]
|
|
145
192
|
self.cursor_col = len(line) - len(line.lstrip())
|
|
146
193
|
self._enter_insert()
|
|
147
194
|
elif char == "a":
|
|
195
|
+
self._count_buf = ""
|
|
148
196
|
if not self.read_only:
|
|
149
197
|
self._dot_start(event)
|
|
150
198
|
self.cursor_col += 1
|
|
151
199
|
self._enter_insert()
|
|
152
200
|
elif char == "A":
|
|
201
|
+
self._count_buf = ""
|
|
153
202
|
if not self.read_only:
|
|
154
203
|
self._dot_start(event)
|
|
155
204
|
self.cursor_col = len(self.lines[self.cursor_row])
|
|
156
205
|
self._enter_insert()
|
|
157
206
|
elif char == "o":
|
|
207
|
+
self._count_buf = ""
|
|
158
208
|
if self.read_only:
|
|
159
209
|
self.status_msg = "[readonly]"
|
|
160
210
|
else:
|
|
161
211
|
self._dot_start(event)
|
|
162
212
|
self._save_undo()
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
213
|
+
# fold-aware: fold 헤더이면 fold end 뒤에 삽입
|
|
214
|
+
fold_end = self._folds.get(self.cursor_row)
|
|
215
|
+
if fold_end is not None:
|
|
216
|
+
insert_row = fold_end + 1
|
|
217
|
+
# fold end 행의 들여쓰기 기준
|
|
218
|
+
end_line = self.lines[fold_end]
|
|
219
|
+
indent = (
|
|
220
|
+
len(end_line) - len(end_line.lstrip())
|
|
221
|
+
if end_line.strip()
|
|
222
|
+
else 0
|
|
223
|
+
)
|
|
224
|
+
extra = ""
|
|
225
|
+
else:
|
|
226
|
+
insert_row = self.cursor_row + 1
|
|
227
|
+
indent = self._current_indent()
|
|
228
|
+
before = self.lines[self.cursor_row].rstrip()
|
|
229
|
+
extra = " " if before.endswith(("{", "[")) else ""
|
|
230
|
+
self.cursor_row = insert_row
|
|
167
231
|
self.lines.insert(self.cursor_row, " " * indent + extra)
|
|
168
232
|
self._adjust_line_indices(self.cursor_row, 1)
|
|
169
233
|
self.cursor_col = indent + len(extra)
|
|
170
234
|
self._enter_insert()
|
|
171
235
|
elif char == "O":
|
|
236
|
+
self._count_buf = ""
|
|
172
237
|
if self.read_only:
|
|
173
238
|
self.status_msg = "[readonly]"
|
|
174
239
|
else:
|
|
@@ -180,63 +245,78 @@ class NormalMixin:
|
|
|
180
245
|
self.cursor_col = indent
|
|
181
246
|
self._enter_insert()
|
|
182
247
|
|
|
183
|
-
# single-key edits
|
|
248
|
+
# single-key edits (count 적용)
|
|
184
249
|
elif char == "x":
|
|
185
250
|
if self.read_only:
|
|
251
|
+
self._count_buf = ""
|
|
186
252
|
self.status_msg = "[readonly]"
|
|
187
253
|
else:
|
|
254
|
+
count = self._consume_count()
|
|
188
255
|
self._dot_start(event)
|
|
189
256
|
self._dot_stop()
|
|
190
257
|
self._save_undo()
|
|
191
258
|
line = self.lines[self.cursor_row]
|
|
192
259
|
if line and self.cursor_col < len(line):
|
|
193
|
-
self.
|
|
194
|
-
|
|
195
|
-
)
|
|
260
|
+
end = min(self.cursor_col + count, len(line))
|
|
261
|
+
self.lines[self.cursor_row] = line[: self.cursor_col] + line[end:]
|
|
196
262
|
elif char == "p":
|
|
197
263
|
if self.read_only:
|
|
264
|
+
self._count_buf = ""
|
|
198
265
|
self.status_msg = "[readonly]"
|
|
199
266
|
else:
|
|
267
|
+
count = self._consume_count()
|
|
200
268
|
self._dot_start(event)
|
|
201
269
|
self._dot_stop()
|
|
202
|
-
|
|
270
|
+
for _ in range(count):
|
|
271
|
+
self._paste_after()
|
|
203
272
|
elif char == "P":
|
|
204
273
|
if self.read_only:
|
|
274
|
+
self._count_buf = ""
|
|
205
275
|
self.status_msg = "[readonly]"
|
|
206
276
|
else:
|
|
277
|
+
count = self._consume_count()
|
|
207
278
|
self._dot_start(event)
|
|
208
279
|
self._dot_stop()
|
|
209
|
-
|
|
280
|
+
for _ in range(count):
|
|
281
|
+
self._paste_before()
|
|
210
282
|
elif char == "u":
|
|
283
|
+
self._count_buf = ""
|
|
211
284
|
if self.read_only:
|
|
212
285
|
self.status_msg = "[readonly]"
|
|
213
286
|
else:
|
|
214
287
|
self._undo()
|
|
215
288
|
elif key == "ctrl+r":
|
|
289
|
+
self._count_buf = ""
|
|
216
290
|
if self.read_only:
|
|
217
291
|
self.status_msg = "[readonly]"
|
|
218
292
|
else:
|
|
219
293
|
self._redo()
|
|
220
294
|
elif char == "J":
|
|
221
295
|
if self.read_only:
|
|
296
|
+
self._count_buf = ""
|
|
222
297
|
self.status_msg = "[readonly]"
|
|
223
298
|
else:
|
|
299
|
+
count = self._consume_count()
|
|
224
300
|
self._dot_start(event)
|
|
225
301
|
self._dot_stop()
|
|
226
|
-
|
|
302
|
+
for _ in range(count):
|
|
303
|
+
self._join_lines()
|
|
227
304
|
|
|
228
305
|
# dot repeat
|
|
229
306
|
elif char == ".":
|
|
307
|
+
self._count_buf = ""
|
|
230
308
|
if not self.read_only:
|
|
231
309
|
self._dot_replay()
|
|
232
310
|
|
|
233
|
-
# multi-key starters
|
|
311
|
+
# multi-key starters (count는 pending에서 계속 누적 가능)
|
|
234
312
|
elif char in ("d", "c", "y", "r", "g", "e", "z"):
|
|
235
313
|
# Visual mode 연산자 인터셉트
|
|
236
314
|
if self._visual_mode and char in ("d", "y", "c"):
|
|
315
|
+
self._count_buf = ""
|
|
237
316
|
self._execute_visual_operator(char)
|
|
238
317
|
return
|
|
239
318
|
if self.read_only and char not in ("y", "g", "e", "z"):
|
|
319
|
+
self._count_buf = ""
|
|
240
320
|
self.status_msg = "[readonly]"
|
|
241
321
|
else:
|
|
242
322
|
if char not in ("y", "g", "e", "z"):
|
|
@@ -245,24 +325,29 @@ class NormalMixin:
|
|
|
245
325
|
|
|
246
326
|
# search mode
|
|
247
327
|
elif char == "/":
|
|
328
|
+
self._count_buf = ""
|
|
248
329
|
self._visual_mode = ""
|
|
249
330
|
self._mode = EditorMode.SEARCH
|
|
250
331
|
self._search_buffer = ""
|
|
251
332
|
self._search_forward = True
|
|
252
333
|
self.status_msg = ""
|
|
253
334
|
elif char == "?":
|
|
335
|
+
self._count_buf = ""
|
|
254
336
|
self._visual_mode = ""
|
|
255
337
|
self._mode = EditorMode.SEARCH
|
|
256
338
|
self._search_buffer = ""
|
|
257
339
|
self._search_forward = False
|
|
258
340
|
self.status_msg = ""
|
|
259
341
|
elif char == "n":
|
|
342
|
+
self._count_buf = ""
|
|
260
343
|
self._goto_next_match()
|
|
261
344
|
elif char == "N":
|
|
345
|
+
self._count_buf = ""
|
|
262
346
|
self._goto_prev_match()
|
|
263
347
|
|
|
264
348
|
# command mode
|
|
265
349
|
elif char == ":":
|
|
350
|
+
self._count_buf = ""
|
|
266
351
|
self._visual_mode = ""
|
|
267
352
|
self._mode = EditorMode.COMMAND
|
|
268
353
|
self.command_buffer = ""
|
|
@@ -273,45 +358,82 @@ class NormalMixin:
|
|
|
273
358
|
def _handle_pending(self, char: str, key: str) -> None:
|
|
274
359
|
if key == "escape" or not char:
|
|
275
360
|
self.pending = ""
|
|
361
|
+
self._count_buf = ""
|
|
276
362
|
self.status_msg = ""
|
|
277
363
|
self._dot_stop()
|
|
278
364
|
return
|
|
279
365
|
|
|
366
|
+
# pending 상태에서 숫자 입력 → count에 누적 (d3d 패턴)
|
|
367
|
+
if char.isdigit() and self.pending in ("d", "y", "c"):
|
|
368
|
+
self._count_buf += char
|
|
369
|
+
return
|
|
370
|
+
|
|
280
371
|
combo = self.pending + char
|
|
281
372
|
self.pending = ""
|
|
282
373
|
|
|
283
374
|
if self.read_only and combo not in ("yy", "gg", "ej"):
|
|
375
|
+
self._count_buf = ""
|
|
284
376
|
self.status_msg = "[readonly]"
|
|
285
377
|
return
|
|
286
378
|
|
|
287
379
|
if combo == "dd":
|
|
380
|
+
count = self._consume_count()
|
|
288
381
|
self._save_undo()
|
|
289
382
|
self._yank_type = "line"
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
383
|
+
yanked: list[str] = []
|
|
384
|
+
for _ in range(count):
|
|
385
|
+
if len(self.lines) <= 1 and not yanked:
|
|
386
|
+
yanked.append(self.lines[0])
|
|
387
|
+
self.lines[0] = ""
|
|
388
|
+
break
|
|
389
|
+
if self.cursor_row >= len(self.lines):
|
|
390
|
+
break
|
|
391
|
+
# fold-aware: fold 헤더이면 전체 블록 삭제
|
|
392
|
+
fold_end = self._folds.get(self.cursor_row)
|
|
393
|
+
if fold_end is not None:
|
|
394
|
+
block = self.lines[self.cursor_row : fold_end + 1]
|
|
395
|
+
yanked.extend(block)
|
|
396
|
+
del self._folds[self.cursor_row]
|
|
397
|
+
self._folded_lines_dirty = True
|
|
398
|
+
block_len = len(block)
|
|
399
|
+
del self.lines[self.cursor_row : self.cursor_row + block_len]
|
|
400
|
+
if not self.lines:
|
|
401
|
+
self.lines = [""]
|
|
402
|
+
self._adjust_line_indices(self.cursor_row, -block_len)
|
|
403
|
+
else:
|
|
404
|
+
yanked.append(self.lines[self.cursor_row])
|
|
405
|
+
if len(self.lines) > 1:
|
|
406
|
+
deleted_at = self.cursor_row
|
|
407
|
+
self.lines.pop(self.cursor_row)
|
|
408
|
+
self._adjust_line_indices(deleted_at, -1)
|
|
409
|
+
else:
|
|
410
|
+
self.lines[0] = ""
|
|
411
|
+
break
|
|
295
412
|
if self.cursor_row >= len(self.lines):
|
|
296
413
|
self.cursor_row = len(self.lines) - 1
|
|
297
|
-
|
|
298
|
-
|
|
414
|
+
self.yank_buffer = yanked
|
|
415
|
+
if self.cursor_row >= len(self.lines):
|
|
416
|
+
self.cursor_row = len(self.lines) - 1
|
|
299
417
|
self.cursor_col = 0
|
|
300
|
-
|
|
418
|
+
n = len(yanked)
|
|
419
|
+
self.status_msg = f"{n} line{'s' if n > 1 else ''} deleted"
|
|
301
420
|
self._dot_stop()
|
|
302
421
|
|
|
303
422
|
elif combo == "dw":
|
|
423
|
+
self._count_buf = ""
|
|
304
424
|
self._save_undo()
|
|
305
425
|
self._delete_word()
|
|
306
426
|
self._dot_stop()
|
|
307
427
|
|
|
308
428
|
elif combo == "d$":
|
|
429
|
+
self._count_buf = ""
|
|
309
430
|
self._save_undo()
|
|
310
431
|
line = self.lines[self.cursor_row]
|
|
311
432
|
self.lines[self.cursor_row] = line[: self.cursor_col]
|
|
312
433
|
self._dot_stop()
|
|
313
434
|
|
|
314
435
|
elif combo == "d0":
|
|
436
|
+
self._count_buf = ""
|
|
315
437
|
self._save_undo()
|
|
316
438
|
line = self.lines[self.cursor_row]
|
|
317
439
|
self.lines[self.cursor_row] = line[self.cursor_col :]
|
|
@@ -319,12 +441,14 @@ class NormalMixin:
|
|
|
319
441
|
self._dot_stop()
|
|
320
442
|
|
|
321
443
|
elif combo == "cw":
|
|
444
|
+
self._count_buf = ""
|
|
322
445
|
self._save_undo()
|
|
323
446
|
self._delete_word()
|
|
324
447
|
self._enter_insert()
|
|
325
448
|
# recording continues into insert mode
|
|
326
449
|
|
|
327
450
|
elif combo == "cc":
|
|
451
|
+
self._count_buf = ""
|
|
328
452
|
self._save_undo()
|
|
329
453
|
self._yank_type = "line"
|
|
330
454
|
indent = self._current_indent()
|
|
@@ -335,16 +459,36 @@ class NormalMixin:
|
|
|
335
459
|
# recording continues into insert mode
|
|
336
460
|
|
|
337
461
|
elif combo == "yy":
|
|
462
|
+
count = self._consume_count()
|
|
338
463
|
self._yank_type = "line"
|
|
339
|
-
|
|
340
|
-
|
|
464
|
+
yanked = []
|
|
465
|
+
row = self.cursor_row
|
|
466
|
+
for _ in range(count):
|
|
467
|
+
if row >= len(self.lines):
|
|
468
|
+
break
|
|
469
|
+
# fold-aware: fold 헤더이면 전체 블록 yank
|
|
470
|
+
fold_end = self._folds.get(row)
|
|
471
|
+
if fold_end is not None:
|
|
472
|
+
yanked.extend(self.lines[row : fold_end + 1])
|
|
473
|
+
row = fold_end + 1
|
|
474
|
+
else:
|
|
475
|
+
yanked.append(self.lines[row])
|
|
476
|
+
row += 1
|
|
477
|
+
self.yank_buffer = yanked
|
|
478
|
+
n = len(yanked)
|
|
479
|
+
self.status_msg = f"{n} line{'s' if n > 1 else ''} yanked"
|
|
341
480
|
|
|
342
481
|
elif combo == "gg":
|
|
343
|
-
|
|
482
|
+
count = self._consume_count()
|
|
483
|
+
if count > 1:
|
|
484
|
+
self.cursor_row = count - 1
|
|
485
|
+
else:
|
|
486
|
+
self.cursor_row = 0
|
|
344
487
|
self.cursor_col = 0
|
|
345
488
|
self._scroll_cursor_to_top()
|
|
346
489
|
|
|
347
490
|
elif len(combo) == 2 and combo[0] == "r":
|
|
491
|
+
self._count_buf = ""
|
|
348
492
|
self._save_undo()
|
|
349
493
|
line = self.lines[self.cursor_row]
|
|
350
494
|
if self.cursor_col < len(line):
|
|
@@ -354,20 +498,27 @@ class NormalMixin:
|
|
|
354
498
|
self._dot_stop()
|
|
355
499
|
|
|
356
500
|
elif combo == "ej":
|
|
501
|
+
self._count_buf = ""
|
|
357
502
|
self._edit_embedded_json()
|
|
358
503
|
|
|
359
504
|
# fold 명령어
|
|
360
505
|
elif combo == "za":
|
|
506
|
+
self._count_buf = ""
|
|
361
507
|
self._toggle_fold(self.cursor_row)
|
|
362
508
|
elif combo == "zo":
|
|
509
|
+
self._count_buf = ""
|
|
363
510
|
self._open_fold(self.cursor_row)
|
|
364
511
|
elif combo == "zc":
|
|
512
|
+
self._count_buf = ""
|
|
365
513
|
self._close_fold(self.cursor_row)
|
|
366
514
|
elif combo == "zM":
|
|
515
|
+
self._count_buf = ""
|
|
367
516
|
self._fold_all()
|
|
368
517
|
elif combo == "zR":
|
|
518
|
+
self._count_buf = ""
|
|
369
519
|
self._unfold_all()
|
|
370
520
|
|
|
371
521
|
else:
|
|
522
|
+
self._count_buf = ""
|
|
372
523
|
self._dot_stop()
|
|
373
524
|
self.status_msg = f"unknown: {combo}"
|