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.
Files changed (81) hide show
  1. {jvim-0.6.0 → jvim-0.7.0}/.claude/CLAUDE.md +1 -0
  2. {jvim-0.6.0 → jvim-0.7.0}/PKG-INFO +2 -1
  3. {jvim-0.6.0 → jvim-0.7.0}/README.kr.md +1 -0
  4. {jvim-0.6.0 → jvim-0.7.0}/README.md +1 -0
  5. {jvim-0.6.0 → jvim-0.7.0}/pyproject.toml +1 -1
  6. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/__init__.py +1 -1
  7. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/clipboard.py +38 -11
  8. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/data/help.json +10 -0
  9. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/normal.py +194 -43
  10. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/widget.py +5 -2
  11. {jvim-0.6.0 → jvim-0.7.0}/tests/test_editor.py +355 -0
  12. {jvim-0.6.0 → jvim-0.7.0}/uv.lock +1 -1
  13. {jvim-0.6.0 → jvim-0.7.0}/.claude/settings.local.json +0 -0
  14. {jvim-0.6.0 → jvim-0.7.0}/.gitignore +0 -0
  15. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/.git +0 -0
  16. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/.gitignore +0 -0
  17. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/CLAUDE.md +0 -0
  18. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/README.kr.md +0 -0
  19. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/README.md +0 -0
  20. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/pyproject.toml +0 -0
  21. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/__init__.py +0 -0
  22. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/__main__.py +0 -0
  23. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/data/help.json +0 -0
  24. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/data/sample.json +0 -0
  25. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/diff.py +0 -0
  26. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/differ.py +0 -0
  27. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/differ.tcss +0 -0
  28. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/editor.py +0 -0
  29. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/editor.tcss +0 -0
  30. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/py.typed +0 -0
  31. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/src/jvim/widget.py +0 -0
  32. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/tests/__init__.py +0 -0
  33. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/tests/test_diff.py +0 -0
  34. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-fold/tests/test_editor.py +0 -0
  35. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/.git +0 -0
  36. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/.gitignore +0 -0
  37. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/CLAUDE.md +0 -0
  38. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/README.kr.md +0 -0
  39. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/README.md +0 -0
  40. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/docs/jvim.svg +0 -0
  41. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/docs/jvimdiff.svg +0 -0
  42. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/pyproject.toml +0 -0
  43. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/__init__.py +0 -0
  44. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/__main__.py +0 -0
  45. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/data/help.json +0 -0
  46. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/data/sample.json +0 -0
  47. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/diff.py +0 -0
  48. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/differ.py +0 -0
  49. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/differ.tcss +0 -0
  50. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/editor.py +0 -0
  51. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/editor.tcss +0 -0
  52. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/py.typed +0 -0
  53. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/src/jvim/widget.py +0 -0
  54. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/tests/__init__.py +0 -0
  55. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/tests/test_diff.py +0 -0
  56. {jvim-0.6.0 → jvim-0.7.0}/.worktrees/codex-fix-subst/tests/test_editor.py +0 -0
  57. {jvim-0.6.0 → jvim-0.7.0}/CLAUDE.md +0 -0
  58. {jvim-0.6.0 → jvim-0.7.0}/docs/jvim.svg +0 -0
  59. {jvim-0.6.0 → jvim-0.7.0}/docs/jvimdiff.svg +0 -0
  60. {jvim-0.6.0 → jvim-0.7.0}/scripts/benchmark.py +0 -0
  61. {jvim-0.6.0 → jvim-0.7.0}/scripts/screenshots.py +0 -0
  62. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/__main__.py +0 -0
  63. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/__init__.py +0 -0
  64. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/folding.py +0 -0
  65. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/jsonpath.py +0 -0
  66. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/navigation.py +0 -0
  67. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/substitute.py +0 -0
  68. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/action/visual.py +0 -0
  69. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/data/sample.json +0 -0
  70. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/diff.py +0 -0
  71. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/differ.py +0 -0
  72. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/editor.py +0 -0
  73. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/__init__.py +0 -0
  74. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/command.py +0 -0
  75. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/insert.py +0 -0
  76. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/mode/search.py +0 -0
  77. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/py.typed +0 -0
  78. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/styles/differ.tcss +0 -0
  79. {jvim-0.6.0 → jvim-0.7.0}/src/jvim/styles/editor.tcss +0 -0
  80. {jvim-0.6.0 → jvim-0.7.0}/tests/__init__.py +0 -0
  81. {jvim-0.6.0 → jvim-0.7.0}/tests/test_diff.py +0 -0
@@ -1,6 +1,7 @@
1
1
 
2
2
  키 바인딩이 추가되면 help.json 파일을 업데이트한다.
3
3
  푸시할 때 버전의 업그레이드 여부를 분석해서 보고한다.
4
+ 푸시할 때 변경 사항에 대해 위키를 업데이트한다.
4
5
  버전이 업그레이드되는 경우 README.md 파일에 추가할 만한 항목을 분석한다.
5
6
  README.md 파일이 수정되면 README.kr.md 파일도 동일한 내용으로 수정한다.
6
7
  jvim에 적용되는 기능 중에서 jvimdiff에도 적용 가능한 기능은 적용 여부를 확인한다.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jvim
3
- Version: 0.6.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "jvim"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "JSON editor with vim-style keybindings"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -3,4 +3,4 @@
3
3
  from .widget import EditorMode, JsonEditor
4
4
 
5
5
  __all__ = ["EditorMode", "JsonEditor"]
6
- __version__ = "0.6.0"
6
+ __version__ = "0.7.0"
@@ -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(self.cursor_row + 1 + i, line)
51
- self._adjust_line_indices(self.cursor_row + 1, inserted)
52
- self.cursor_row += 1
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
- self._save_undo()
92
- cur = self.lines[self.cursor_row].rstrip()
93
- nxt = self.lines[self.cursor_row + 1].lstrip()
94
- self.cursor_col = len(cur)
95
- self.lines[self.cursor_row] = cur + " " + nxt
96
- deleted_at = self.cursor_row + 1
97
- self.lines.pop(deleted_at)
98
- self._adjust_line_indices(deleted_at, -1)
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" and self._visual_mode:
24
- self._visual_mode = ""
25
- self.status_msg = ""
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.cursor_col -= 1
74
+ count = self._consume_count()
75
+ self.cursor_col -= count
57
76
  elif char == "j" or key == "down":
58
- self.cursor_row = (
59
- self._next_visible_line(self.cursor_row, 1)
60
- if self._folds
61
- else self.cursor_row + 1
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
- self.cursor_row = (
65
- self._next_visible_line(self.cursor_row, -1)
66
- if self._folds
67
- else self.cursor_row - 1
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.cursor_col += 1
93
+ count = self._consume_count()
94
+ self.cursor_col += count
71
95
  elif char == "w":
72
- self._move_word_forward()
96
+ count = self._consume_count()
97
+ for _ in range(count):
98
+ self._move_word_forward()
73
99
  elif char == "b":
74
- self._move_word_backward()
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
- self.cursor_row = len(self.lines) - 1
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
- indent = self._current_indent()
164
- before = self.lines[self.cursor_row].rstrip()
165
- extra = " " if before.endswith(("{", "[")) else ""
166
- self.cursor_row += 1
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.lines[self.cursor_row] = (
194
- line[: self.cursor_col] + line[self.cursor_col + 1 :]
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
- self._paste_after()
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
- self._paste_before()
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
- self._join_lines()
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
- self.yank_buffer = [self.lines[self.cursor_row]]
291
- if len(self.lines) > 1:
292
- deleted_at = self.cursor_row
293
- self.lines.pop(self.cursor_row)
294
- self._adjust_line_indices(deleted_at, -1)
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
- else:
298
- self.lines[0] = ""
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
- self.status_msg = "line deleted"
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
- self.yank_buffer = [self.lines[self.cursor_row]]
340
- self.status_msg = "line yanked"
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
- self.cursor_row = 0
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}"