textual-vim-textarea 1.1.0__py3-none-any.whl
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.
- textual_vim_textarea/__init__.py +11 -0
- textual_vim_textarea/_modal.py +605 -0
- textual_vim_textarea/textarea_plus.py +154 -0
- textual_vim_textarea/vim_textarea.py +103 -0
- textual_vim_textarea-1.1.0.dist-info/METADATA +157 -0
- textual_vim_textarea-1.1.0.dist-info/RECORD +8 -0
- textual_vim_textarea-1.1.0.dist-info/WHEEL +4 -0
- textual_vim_textarea-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .vim_textarea import VimTextArea, Mode
|
|
2
|
+
|
|
3
|
+
__all__ = ["VimTextArea", "Mode"]
|
|
4
|
+
|
|
5
|
+
# VimTextAreaPlus is intentionally NOT imported here. It requires
|
|
6
|
+
# textual-textarea (the 'textarea-plus' extra), and importing it
|
|
7
|
+
# unconditionally would force that dependency on every install of this
|
|
8
|
+
# package, even for people only using plain VimTextArea. Import it
|
|
9
|
+
# explicitly if you need it:
|
|
10
|
+
#
|
|
11
|
+
# from textual_vim_textarea.textarea_plus import VimTextAreaPlus
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
"""
|
|
2
|
+
_modal.py
|
|
3
|
+
|
|
4
|
+
All of the actual vim-editing logic, factored out of any specific base
|
|
5
|
+
class. This is the piece VimTextArea (built on plain Textual TextArea)
|
|
6
|
+
and VimTextAreaPlus (built on textual-textarea's TextAreaPlus) share --
|
|
7
|
+
motions, operators, counts, registers, visual mode, the command line
|
|
8
|
+
state machine.
|
|
9
|
+
|
|
10
|
+
VimModalMixin deliberately does NOT hook on_key or _on_key, and does NOT
|
|
11
|
+
define any Message subclasses. Both of those have to live on the
|
|
12
|
+
*concrete* class (VimTextArea / VimTextAreaPlus), not here, for two
|
|
13
|
+
reasons:
|
|
14
|
+
|
|
15
|
+
1. Key routing genuinely differs between the two bases. Plain TextArea
|
|
16
|
+
only has `_on_key`, so entering INSERT mode there means manually
|
|
17
|
+
falling through to `super()._on_key(event)`. TextAreaPlus builds
|
|
18
|
+
its own features (autocomplete, bracket-closing, smart indent)
|
|
19
|
+
through the public `on_key` handler instead, and Textual calls
|
|
20
|
+
every class in the MRO that defines its own `_on_key`/`on_key` in
|
|
21
|
+
turn (see textual_vim_textarea.textarea_plus's module docstring for
|
|
22
|
+
the full mechanics) -- so VimTextAreaPlus has to hook `on_key` and
|
|
23
|
+
do nothing at all in INSERT mode, letting the event cascade through
|
|
24
|
+
TextAreaPlus's own handler naturally. Folding both strategies into
|
|
25
|
+
one method here would mean neither works correctly.
|
|
26
|
+
|
|
27
|
+
2. Textual derives a message's handler method name from the class it's
|
|
28
|
+
*nested inside* (`VimTextArea.StatusChanged` -> handler name
|
|
29
|
+
`on_vim_text_area_status_changed`), not from where the logic that
|
|
30
|
+
posts it happens to live. If `StatusChanged` were defined on this
|
|
31
|
+
mixin instead, every consumer's handler name would become
|
|
32
|
+
`on_vim_modal_mixin_status_changed` regardless of which concrete
|
|
33
|
+
class they're actually using -- and worse, it would've silently
|
|
34
|
+
changed already-shipped handler names for existing VimTextArea
|
|
35
|
+
users. So each concrete class defines its own copy of the Message
|
|
36
|
+
classes (a few lines of boilerplate); this mixin only ever
|
|
37
|
+
*references* them dynamically via `self.ModeChanged(...)` etc.,
|
|
38
|
+
which Python resolves against the instance's real class.
|
|
39
|
+
|
|
40
|
+
Any class mixing this in is expected to also provide (transitively, via
|
|
41
|
+
its other base) TextArea's own API: `.document`, `.selection`,
|
|
42
|
+
`.cursor_location`, `.get_line()`, `.get_text_range()`, `.delete()`,
|
|
43
|
+
`.insert()`, `.move_cursor()`, `.undo()`, `.redo()`,
|
|
44
|
+
`.get_cursor_line_start_location()`. It's also expected to define, on
|
|
45
|
+
itself, nested `ModeChanged`, `StatusChanged`, `CommandEntered`, and
|
|
46
|
+
`QuitRequested` Message classes, plus a `_do_save()` (async) and
|
|
47
|
+
`_do_quit()` method -- see VimTextArea / VimTextAreaPlus for the concrete
|
|
48
|
+
shape.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import re
|
|
54
|
+
from enum import Enum
|
|
55
|
+
from typing import Optional, Tuple
|
|
56
|
+
|
|
57
|
+
from textual import events
|
|
58
|
+
from textual.reactive import reactive
|
|
59
|
+
from textual.widgets.text_area import Selection
|
|
60
|
+
|
|
61
|
+
Location = Tuple[int, int]
|
|
62
|
+
|
|
63
|
+
# A "word" for w/b/e purposes: a run of keyword characters, OR a run of
|
|
64
|
+
# punctuation characters. Whitespace is always a separator. This mirrors
|
|
65
|
+
# vim's default (non-WORD) motion closely enough for everyday use.
|
|
66
|
+
_TOKEN_RE = re.compile(r"\w+|[^\w\s]+")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Mode(str, Enum):
|
|
70
|
+
NORMAL = "NORMAL"
|
|
71
|
+
INSERT = "INSERT"
|
|
72
|
+
VISUAL = "VISUAL"
|
|
73
|
+
VISUAL_LINE = "V-LINE"
|
|
74
|
+
COMMAND = "COMMAND"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class VimModalMixin:
|
|
78
|
+
"""Shared vim state machine: motions, operators, counts, registers,
|
|
79
|
+
visual mode, and the command line. See the module docstring for what
|
|
80
|
+
a concrete class mixing this in still has to provide itself.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
mode: reactive[Mode] = reactive(Mode.NORMAL)
|
|
84
|
+
|
|
85
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
86
|
+
super().__init__(*args, **kwargs)
|
|
87
|
+
self._count: str = ""
|
|
88
|
+
self._pending_op: Optional[str] = None
|
|
89
|
+
self._pending_g: bool = False
|
|
90
|
+
self._register: str = ""
|
|
91
|
+
self._register_linewise: bool = False
|
|
92
|
+
self._command_buffer: str = ""
|
|
93
|
+
self._pending_op_count: int = 1
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# status line helper -- host apps can poll this to render e.g. a
|
|
97
|
+
# bottom bar that looks like vim's own mode/command line.
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
@property
|
|
100
|
+
def status_text(self) -> str:
|
|
101
|
+
if self.mode is Mode.COMMAND:
|
|
102
|
+
return f":{self._command_buffer}"
|
|
103
|
+
labels = {
|
|
104
|
+
Mode.NORMAL: "NORMAL",
|
|
105
|
+
Mode.INSERT: "-- INSERT --",
|
|
106
|
+
Mode.VISUAL: "-- VISUAL --",
|
|
107
|
+
Mode.VISUAL_LINE: "-- VISUAL LINE --",
|
|
108
|
+
}
|
|
109
|
+
pending = f"{self._count}{self._pending_op or ''}"
|
|
110
|
+
text = labels[self.mode]
|
|
111
|
+
return f"{text} {pending}".rstrip()
|
|
112
|
+
|
|
113
|
+
def watch_mode(self, mode: Mode) -> None:
|
|
114
|
+
self.post_message(self.ModeChanged(mode))
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# mode transitions
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
def _enter_insert(self) -> None:
|
|
120
|
+
self.mode = Mode.INSERT
|
|
121
|
+
|
|
122
|
+
def _enter_normal_mode(self, shift_cursor_left: bool = False) -> None:
|
|
123
|
+
self._pending_op = None
|
|
124
|
+
self._pending_g = False
|
|
125
|
+
self._count = ""
|
|
126
|
+
if shift_cursor_left:
|
|
127
|
+
row, col = self.cursor_location
|
|
128
|
+
if col > 0:
|
|
129
|
+
self.move_cursor((row, col - 1))
|
|
130
|
+
else:
|
|
131
|
+
self.move_cursor(self.selection.end)
|
|
132
|
+
self.mode = Mode.NORMAL
|
|
133
|
+
|
|
134
|
+
def _enter_visual(self, linewise: bool) -> None:
|
|
135
|
+
self.mode = Mode.VISUAL_LINE if linewise else Mode.VISUAL
|
|
136
|
+
if linewise:
|
|
137
|
+
row, _ = self.cursor_location
|
|
138
|
+
self.selection = Selection((row, 0), (row, len(str(self.get_line(row)))))
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# command line (":w", ":q", ":wq", ":n", or anything else). Concrete
|
|
142
|
+
# classes plug in what "save" and "quit" actually mean by overriding
|
|
143
|
+
# _do_save()/_do_quit() -- see their docstrings.
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
async def _handle_command_key(self, event: events.Key) -> None:
|
|
146
|
+
if event.key == "escape":
|
|
147
|
+
self._command_buffer = ""
|
|
148
|
+
self.mode = Mode.NORMAL
|
|
149
|
+
return
|
|
150
|
+
if event.key == "enter":
|
|
151
|
+
command = self._command_buffer
|
|
152
|
+
self._command_buffer = ""
|
|
153
|
+
self.mode = Mode.NORMAL
|
|
154
|
+
await self._dispatch_command(command)
|
|
155
|
+
return
|
|
156
|
+
if event.key == "backspace":
|
|
157
|
+
self._command_buffer = self._command_buffer[:-1]
|
|
158
|
+
return
|
|
159
|
+
if event.character and event.is_printable:
|
|
160
|
+
self._command_buffer += event.character
|
|
161
|
+
|
|
162
|
+
async def _dispatch_command(self, command: str) -> None:
|
|
163
|
+
stripped = command.strip()
|
|
164
|
+
if stripped in ("w", "write"):
|
|
165
|
+
await self._do_save()
|
|
166
|
+
elif stripped in ("q", "q!", "quit"):
|
|
167
|
+
self._do_quit()
|
|
168
|
+
elif stripped in ("wq", "x"):
|
|
169
|
+
await self._do_save()
|
|
170
|
+
self._do_quit()
|
|
171
|
+
elif stripped.isdigit():
|
|
172
|
+
# ":n" -- jump to line n (1-indexed, like vim)
|
|
173
|
+
target_row = max(0, min(int(stripped) - 1, self.document.line_count - 1))
|
|
174
|
+
self.move_cursor((target_row, 0))
|
|
175
|
+
elif stripped:
|
|
176
|
+
self.post_message(self.CommandEntered(stripped))
|
|
177
|
+
|
|
178
|
+
async def _do_save(self) -> None:
|
|
179
|
+
"""Default ':w' behavior: post SaveRequested for a host app to
|
|
180
|
+
handle. Override this if your base class has a real, directly
|
|
181
|
+
callable save action to trigger instead (see VimTextAreaPlus)."""
|
|
182
|
+
self.post_message(self.SaveRequested())
|
|
183
|
+
|
|
184
|
+
def _do_quit(self) -> None:
|
|
185
|
+
"""Default ':q' behavior: post QuitRequested. Deliberately just a
|
|
186
|
+
message, not a direct app.exit() call -- what ':q' should mean
|
|
187
|
+
(close a buffer? close the whole app?) is a host-app decision."""
|
|
188
|
+
self.post_message(self.QuitRequested())
|
|
189
|
+
|
|
190
|
+
async def _trigger_ancestor_action(self, action_name: str) -> bool:
|
|
191
|
+
"""Find the nearest ancestor with an `action_<name>` method and
|
|
192
|
+
call it directly. Returns True if one was found and called.
|
|
193
|
+
|
|
194
|
+
Deliberately NOT `self.run_action(action_name)` -- Textual
|
|
195
|
+
resolves an un-prefixed action name against the *calling* widget
|
|
196
|
+
by default, not an ancestor, so it would silently look for the
|
|
197
|
+
action on this widget itself and do nothing if the action
|
|
198
|
+
actually lives on a parent container (verified by reading
|
|
199
|
+
`App._parse_action` -- there's no ancestor fallback there).
|
|
200
|
+
"""
|
|
201
|
+
import inspect
|
|
202
|
+
|
|
203
|
+
for node in self.ancestors:
|
|
204
|
+
method = getattr(node, f"action_{action_name}", None)
|
|
205
|
+
if callable(method):
|
|
206
|
+
result = method()
|
|
207
|
+
if inspect.isawaitable(result):
|
|
208
|
+
await result
|
|
209
|
+
return True
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
# NORMAL / VISUAL dispatch
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
def _handle_normal_key(self, event: events.Key) -> None:
|
|
216
|
+
key = event.key
|
|
217
|
+
char = event.character
|
|
218
|
+
|
|
219
|
+
# -- numeric count prefix (leading 0 is a motion, not a count) --
|
|
220
|
+
if char is not None and char.isdigit() and (char != "0" or self._count):
|
|
221
|
+
self._count += char
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
count = int(self._count) if self._count else 1
|
|
225
|
+
had_count = bool(self._count)
|
|
226
|
+
self._count = ""
|
|
227
|
+
|
|
228
|
+
if key == "escape":
|
|
229
|
+
if self.mode in (Mode.VISUAL, Mode.VISUAL_LINE):
|
|
230
|
+
self._enter_normal_mode()
|
|
231
|
+
else:
|
|
232
|
+
self._pending_op = None
|
|
233
|
+
self._pending_g = False
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
if char == ":":
|
|
237
|
+
self.mode = Mode.COMMAND
|
|
238
|
+
self._command_buffer = ""
|
|
239
|
+
self._pending_op = None
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
# -- "gg" two-key sequence (also combines with a pending
|
|
243
|
+
# operator, e.g. "dgg") --
|
|
244
|
+
if self._pending_g:
|
|
245
|
+
self._pending_g = False
|
|
246
|
+
if char == "g":
|
|
247
|
+
target_row = (
|
|
248
|
+
min(count - 1, self.document.line_count - 1) if had_count else 0
|
|
249
|
+
)
|
|
250
|
+
row, _ = self.cursor_location
|
|
251
|
+
if self._pending_op:
|
|
252
|
+
op = self._pending_op
|
|
253
|
+
self._pending_op = None
|
|
254
|
+
self._apply_operator_range(op, (row, 0), (target_row, 0), linewise=True)
|
|
255
|
+
else:
|
|
256
|
+
select = self.mode in (Mode.VISUAL, Mode.VISUAL_LINE)
|
|
257
|
+
self.move_cursor((target_row, 0), select=select)
|
|
258
|
+
if self.mode is Mode.VISUAL_LINE:
|
|
259
|
+
self._sync_visual_line_selection()
|
|
260
|
+
return
|
|
261
|
+
if char == "g":
|
|
262
|
+
self._pending_g = True
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# -- resolve an already-pending operator (d/c/y + motion) --
|
|
266
|
+
if self._pending_op:
|
|
267
|
+
op = self._pending_op
|
|
268
|
+
self._pending_op = None
|
|
269
|
+
# counts before the operator and before the motion multiply,
|
|
270
|
+
# e.g. "3dd" -> 3, "d3w" -> 3, "2d3w" -> 6
|
|
271
|
+
combined_count = self._pending_op_count * count
|
|
272
|
+
self._pending_op_count = 1
|
|
273
|
+
if char == op: # dd / cc / yy
|
|
274
|
+
row, _ = self.cursor_location
|
|
275
|
+
end_row = min(row + combined_count - 1, self.document.line_count - 1)
|
|
276
|
+
self._apply_operator_range(op, (row, 0), (end_row, 0), linewise=True)
|
|
277
|
+
return
|
|
278
|
+
# vim quirk: "cw"/"cW" act like "ce" -- they stop at the end
|
|
279
|
+
# of the current word rather than swallowing trailing
|
|
280
|
+
# whitespace the way "dw" does.
|
|
281
|
+
motion_char = "e" if (op == "c" and char == "w") else char
|
|
282
|
+
motion = self._resolve_motion(key, motion_char, combined_count, had_count)
|
|
283
|
+
if motion is None:
|
|
284
|
+
return
|
|
285
|
+
target, linewise, inclusive = motion
|
|
286
|
+
self._apply_operator_range(
|
|
287
|
+
op, self.cursor_location, target, linewise=linewise, inclusive=inclusive
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# -- visual mode: d/x/c/y act on the current selection --
|
|
292
|
+
if self.mode in (Mode.VISUAL, Mode.VISUAL_LINE) and char in ("d", "x", "c", "y"):
|
|
293
|
+
self._visual_operator(char)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# -- start a new operator --
|
|
297
|
+
if char in ("d", "c", "y") and self.mode is Mode.NORMAL:
|
|
298
|
+
self._pending_op = char
|
|
299
|
+
self._pending_op_count = count
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
# -- D / C / X: line-end / char shorthand operators --
|
|
303
|
+
if char == "D":
|
|
304
|
+
row, col = self.cursor_location
|
|
305
|
+
self._apply_operator_range("d", (row, col), self._end_of_line(row), inclusive=True)
|
|
306
|
+
return
|
|
307
|
+
if char == "C":
|
|
308
|
+
row, col = self.cursor_location
|
|
309
|
+
self._apply_operator_range("c", (row, col), self._end_of_line(row), inclusive=True)
|
|
310
|
+
return
|
|
311
|
+
if char == "X":
|
|
312
|
+
row, col = self.cursor_location
|
|
313
|
+
start_col = max(0, col - count)
|
|
314
|
+
if start_col < col:
|
|
315
|
+
self._apply_operator_range("d", (row, start_col), (row, col))
|
|
316
|
+
return
|
|
317
|
+
if char == "x":
|
|
318
|
+
row, col = self.cursor_location
|
|
319
|
+
line_len = len(str(self.get_line(row)))
|
|
320
|
+
end_col = min(col + count, line_len)
|
|
321
|
+
if end_col > col:
|
|
322
|
+
self._apply_operator_range("d", (row, col), (row, end_col))
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
# -- enter insert mode --
|
|
326
|
+
if char == "i":
|
|
327
|
+
self._enter_insert()
|
|
328
|
+
return
|
|
329
|
+
if char == "a":
|
|
330
|
+
row, col = self.cursor_location
|
|
331
|
+
line_len = len(str(self.get_line(row)))
|
|
332
|
+
self.move_cursor((row, min(line_len, col + 1)))
|
|
333
|
+
self._enter_insert()
|
|
334
|
+
return
|
|
335
|
+
if char == "I":
|
|
336
|
+
self.move_cursor(self.get_cursor_line_start_location(smart_home=True))
|
|
337
|
+
self._enter_insert()
|
|
338
|
+
return
|
|
339
|
+
if char == "A":
|
|
340
|
+
row, _ = self.cursor_location
|
|
341
|
+
self.move_cursor(self._end_of_line(row))
|
|
342
|
+
self._enter_insert()
|
|
343
|
+
return
|
|
344
|
+
if char == "o":
|
|
345
|
+
row, _ = self.cursor_location
|
|
346
|
+
end = self._end_of_line(row)
|
|
347
|
+
self.insert("\n", location=end)
|
|
348
|
+
self.move_cursor((row + 1, 0))
|
|
349
|
+
self._enter_insert()
|
|
350
|
+
return
|
|
351
|
+
if char == "O":
|
|
352
|
+
row, _ = self.cursor_location
|
|
353
|
+
self.insert("\n", location=(row, 0))
|
|
354
|
+
self.move_cursor((row, 0))
|
|
355
|
+
self._enter_insert()
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
# -- visual mode toggles --
|
|
359
|
+
if char == "v":
|
|
360
|
+
if self.mode is Mode.VISUAL:
|
|
361
|
+
self._enter_normal_mode()
|
|
362
|
+
else:
|
|
363
|
+
self._enter_visual(linewise=False)
|
|
364
|
+
return
|
|
365
|
+
if char == "V":
|
|
366
|
+
if self.mode is Mode.VISUAL_LINE:
|
|
367
|
+
self._enter_normal_mode()
|
|
368
|
+
else:
|
|
369
|
+
self._enter_visual(linewise=True)
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
# -- paste --
|
|
373
|
+
if char == "p":
|
|
374
|
+
self._paste(after=True)
|
|
375
|
+
return
|
|
376
|
+
if char == "P":
|
|
377
|
+
self._paste(after=False)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# -- undo / redo --
|
|
381
|
+
if char == "u":
|
|
382
|
+
for _ in range(count):
|
|
383
|
+
self.undo()
|
|
384
|
+
return
|
|
385
|
+
if key == "ctrl+r":
|
|
386
|
+
for _ in range(count):
|
|
387
|
+
self.redo()
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
# -- plain motions (also extend selection in visual modes) --
|
|
391
|
+
motion = self._resolve_motion(key, char, count, had_count)
|
|
392
|
+
if motion is not None:
|
|
393
|
+
target, _linewise, _inclusive = motion
|
|
394
|
+
select = self.mode in (Mode.VISUAL, Mode.VISUAL_LINE)
|
|
395
|
+
self.move_cursor(target, select=select)
|
|
396
|
+
if self.mode is Mode.VISUAL_LINE:
|
|
397
|
+
self._sync_visual_line_selection()
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
# motions -- return (target_location, linewise, inclusive) or None
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
def _end_of_line(self, row: int) -> Location:
|
|
403
|
+
return (row, len(str(self.get_line(row))))
|
|
404
|
+
|
|
405
|
+
def _resolve_motion(
|
|
406
|
+
self, key: str, char: Optional[str], count: int, had_count: bool
|
|
407
|
+
) -> Optional[Tuple[Location, bool, bool]]:
|
|
408
|
+
row, col = self.cursor_location
|
|
409
|
+
line_count = self.document.line_count
|
|
410
|
+
|
|
411
|
+
if char == "h" or key == "left":
|
|
412
|
+
return (row, max(0, col - count)), False, False
|
|
413
|
+
if char == "l" or key == "right":
|
|
414
|
+
line_len = len(str(self.get_line(row)))
|
|
415
|
+
return (row, min(line_len, col + count)), False, False
|
|
416
|
+
if char == "j" or key == "down":
|
|
417
|
+
end_row = min(row + count, line_count - 1)
|
|
418
|
+
return (end_row, col), True, False
|
|
419
|
+
if char == "k" or key == "up":
|
|
420
|
+
end_row = max(row - count, 0)
|
|
421
|
+
return (end_row, col), True, False
|
|
422
|
+
if char == "0":
|
|
423
|
+
return (row, 0), False, False
|
|
424
|
+
if char == "^":
|
|
425
|
+
return self.get_cursor_line_start_location(smart_home=True), False, False
|
|
426
|
+
if char == "$" or key == "end":
|
|
427
|
+
end_row = min(row + count - 1, line_count - 1)
|
|
428
|
+
return self._end_of_line(end_row), False, True
|
|
429
|
+
if char == "w":
|
|
430
|
+
return self._word_forward_location(count), False, False
|
|
431
|
+
if char == "b":
|
|
432
|
+
return self._word_backward_location(count), False, False
|
|
433
|
+
if char == "e":
|
|
434
|
+
return self._word_end_location(count), False, True
|
|
435
|
+
if char == "G":
|
|
436
|
+
target_row = min(count - 1, line_count - 1) if had_count else line_count - 1
|
|
437
|
+
return (target_row, 0), True, False
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
def _tokens(self, row: int):
|
|
441
|
+
text = str(self.get_line(row))
|
|
442
|
+
return [(m.start(), m.end()) for m in _TOKEN_RE.finditer(text)]
|
|
443
|
+
|
|
444
|
+
def _word_forward_location(self, count: int) -> Location:
|
|
445
|
+
row, col = self.cursor_location
|
|
446
|
+
for _ in range(count):
|
|
447
|
+
row, col = self._single_word_forward(row, col)
|
|
448
|
+
return row, col
|
|
449
|
+
|
|
450
|
+
def _single_word_forward(self, row: int, col: int) -> Location:
|
|
451
|
+
line_count = self.document.line_count
|
|
452
|
+
for start, _end in self._tokens(row):
|
|
453
|
+
if start > col:
|
|
454
|
+
return row, start
|
|
455
|
+
r = row
|
|
456
|
+
while r + 1 < line_count:
|
|
457
|
+
r += 1
|
|
458
|
+
toks = self._tokens(r)
|
|
459
|
+
if toks:
|
|
460
|
+
return r, toks[0][0]
|
|
461
|
+
return r, 0 # blank line is itself a word-stop
|
|
462
|
+
return self.document.end
|
|
463
|
+
|
|
464
|
+
def _word_backward_location(self, count: int) -> Location:
|
|
465
|
+
row, col = self.cursor_location
|
|
466
|
+
for _ in range(count):
|
|
467
|
+
row, col = self._single_word_backward(row, col)
|
|
468
|
+
return row, col
|
|
469
|
+
|
|
470
|
+
def _single_word_backward(self, row: int, col: int) -> Location:
|
|
471
|
+
r = row
|
|
472
|
+
while True:
|
|
473
|
+
if r == row:
|
|
474
|
+
candidates = [start for start, _end in self._tokens(r) if start < col]
|
|
475
|
+
else:
|
|
476
|
+
candidates = [start for start, _end in self._tokens(r)]
|
|
477
|
+
if candidates:
|
|
478
|
+
return r, candidates[-1]
|
|
479
|
+
if r == 0:
|
|
480
|
+
return 0, 0
|
|
481
|
+
r -= 1
|
|
482
|
+
toks = self._tokens(r)
|
|
483
|
+
if toks:
|
|
484
|
+
return r, toks[-1][0]
|
|
485
|
+
return r, 0
|
|
486
|
+
|
|
487
|
+
def _word_end_location(self, count: int) -> Location:
|
|
488
|
+
row, col = self.cursor_location
|
|
489
|
+
for _ in range(count):
|
|
490
|
+
row, col = self._single_word_end(row, col)
|
|
491
|
+
return row, col
|
|
492
|
+
|
|
493
|
+
def _single_word_end(self, row: int, col: int) -> Location:
|
|
494
|
+
line_count = self.document.line_count
|
|
495
|
+
r, c = row, col
|
|
496
|
+
while True:
|
|
497
|
+
for start, end in self._tokens(r):
|
|
498
|
+
if end - 1 > c:
|
|
499
|
+
return r, end - 1
|
|
500
|
+
if r + 1 >= line_count:
|
|
501
|
+
last_len = len(str(self.get_line(r)))
|
|
502
|
+
return r, max(0, last_len - 1)
|
|
503
|
+
r += 1
|
|
504
|
+
c = -1
|
|
505
|
+
|
|
506
|
+
# ------------------------------------------------------------------
|
|
507
|
+
# visual-line selection upkeep
|
|
508
|
+
# ------------------------------------------------------------------
|
|
509
|
+
def _sync_visual_line_selection(self) -> None:
|
|
510
|
+
start, end = self.selection
|
|
511
|
+
row_a, row_b = start[0], end[0]
|
|
512
|
+
lo, hi = min(row_a, row_b), max(row_a, row_b)
|
|
513
|
+
if row_a <= row_b:
|
|
514
|
+
new_start = (lo, 0)
|
|
515
|
+
new_end = (hi, len(str(self.get_line(hi))))
|
|
516
|
+
else:
|
|
517
|
+
new_start = (hi, len(str(self.get_line(hi))))
|
|
518
|
+
new_end = (lo, 0)
|
|
519
|
+
self.selection = Selection(new_start, new_end)
|
|
520
|
+
|
|
521
|
+
# ------------------------------------------------------------------
|
|
522
|
+
# operators
|
|
523
|
+
# ------------------------------------------------------------------
|
|
524
|
+
def _apply_operator_range(
|
|
525
|
+
self,
|
|
526
|
+
op: str,
|
|
527
|
+
a: Location,
|
|
528
|
+
b: Location,
|
|
529
|
+
linewise: bool = False,
|
|
530
|
+
inclusive: bool = False,
|
|
531
|
+
) -> None:
|
|
532
|
+
start, end = (a, b) if a <= b else (b, a)
|
|
533
|
+
|
|
534
|
+
if linewise:
|
|
535
|
+
start_row, end_row = start[0], end[0]
|
|
536
|
+
if op == "c":
|
|
537
|
+
content_start = (start_row, 0)
|
|
538
|
+
content_end = self._end_of_line(end_row)
|
|
539
|
+
text = self.get_text_range(content_start, content_end)
|
|
540
|
+
self.delete(content_start, content_end)
|
|
541
|
+
self._register, self._register_linewise = text, True
|
|
542
|
+
self.move_cursor((start_row, 0))
|
|
543
|
+
self._enter_insert()
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
full_text = self.get_text_range((start_row, 0), self._end_of_line(end_row))
|
|
547
|
+
self._register, self._register_linewise = full_text, True
|
|
548
|
+
if op == "d":
|
|
549
|
+
line_count = self.document.line_count
|
|
550
|
+
if end_row + 1 < line_count:
|
|
551
|
+
self.delete((start_row, 0), (end_row + 1, 0))
|
|
552
|
+
new_row = min(start_row, self.document.line_count - 1)
|
|
553
|
+
elif start_row > 0:
|
|
554
|
+
prev_end = self._end_of_line(start_row - 1)
|
|
555
|
+
self.delete(prev_end, self._end_of_line(end_row))
|
|
556
|
+
new_row = start_row - 1
|
|
557
|
+
else:
|
|
558
|
+
self.delete((start_row, 0), self._end_of_line(end_row))
|
|
559
|
+
new_row = 0
|
|
560
|
+
self.move_cursor((new_row, 0))
|
|
561
|
+
self._enter_normal_mode()
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
# charwise
|
|
565
|
+
if inclusive:
|
|
566
|
+
end = (end[0], end[1] + 1)
|
|
567
|
+
text = self.get_text_range(start, end)
|
|
568
|
+
self._register, self._register_linewise = text, False
|
|
569
|
+
if op in ("d", "c"):
|
|
570
|
+
self.delete(start, end)
|
|
571
|
+
self.move_cursor(start)
|
|
572
|
+
if op == "c":
|
|
573
|
+
self._enter_insert()
|
|
574
|
+
else:
|
|
575
|
+
self._enter_normal_mode()
|
|
576
|
+
|
|
577
|
+
def _visual_operator(self, char: str) -> None:
|
|
578
|
+
op = "d" if char == "x" else char
|
|
579
|
+
start, end = self.selection
|
|
580
|
+
start, end = (start, end) if start <= end else (end, start)
|
|
581
|
+
if self.mode is Mode.VISUAL_LINE:
|
|
582
|
+
self._apply_operator_range(op, (start[0], 0), (end[0], 0), linewise=True)
|
|
583
|
+
else:
|
|
584
|
+
self._apply_operator_range(op, start, end, inclusive=True)
|
|
585
|
+
|
|
586
|
+
# ------------------------------------------------------------------
|
|
587
|
+
# paste
|
|
588
|
+
# ------------------------------------------------------------------
|
|
589
|
+
def _paste(self, after: bool) -> None:
|
|
590
|
+
if not self._register:
|
|
591
|
+
return
|
|
592
|
+
row, col = self.cursor_location
|
|
593
|
+
if self._register_linewise:
|
|
594
|
+
text = self._register
|
|
595
|
+
if not text.endswith("\n"):
|
|
596
|
+
text += "\n"
|
|
597
|
+
insert_row = row + 1 if after else row
|
|
598
|
+
self.insert(text, location=(insert_row, 0))
|
|
599
|
+
self.move_cursor((insert_row, 0))
|
|
600
|
+
else:
|
|
601
|
+
line_len = len(str(self.get_line(row)))
|
|
602
|
+
insert_col = min(col + 1, line_len) if after else col
|
|
603
|
+
self.insert(self._register, location=(row, insert_col))
|
|
604
|
+
new_col = insert_col + len(self._register)
|
|
605
|
+
self.move_cursor((row, max(insert_col, new_col - 1)))
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
textarea_plus.py
|
|
3
|
+
|
|
4
|
+
Vim-modal editing for apps built on `textual-textarea`'s `TextEditor` /
|
|
5
|
+
`TextAreaPlus` rather than plain
|
|
6
|
+
Textual `TextArea`. Requires the `textarea-plus` extra:
|
|
7
|
+
|
|
8
|
+
pip install textual-vim-textarea[textarea-plus]
|
|
9
|
+
|
|
10
|
+
All the actual vim logic is shared with `vim_textarea.VimTextArea` via
|
|
11
|
+
`_modal.VimModalMixin`. This file only has what's specific to
|
|
12
|
+
TextAreaPlus: how keys get intercepted (genuinely different from plain
|
|
13
|
+
TextArea -- see below), the Message classes, and how ':w' triggers a
|
|
14
|
+
real save.
|
|
15
|
+
|
|
16
|
+
Why this isn't just VimTextArea reused
|
|
17
|
+
---------------------------------------
|
|
18
|
+
`TextAreaPlus(TextArea, inherit_bindings=False)` adds real editing
|
|
19
|
+
features on top of base TextArea through the *public* `on_key` handler,
|
|
20
|
+
not the *private* `_on_key` base TextArea itself uses for default
|
|
21
|
+
character insertion: bracket/quote auto-closing, smart enter (auto-
|
|
22
|
+
indent, jump inside brackets), tab/shift+tab indent handling, and an
|
|
23
|
+
autocomplete dropdown.
|
|
24
|
+
|
|
25
|
+
I verified Textual's actual dispatch order by reading
|
|
26
|
+
`MessagePump._get_dispatch_methods` / `_on_message` directly
|
|
27
|
+
(textual/message_pump.py), not by guessing from behavior:
|
|
28
|
+
|
|
29
|
+
- For a Key event, Textual walks the widget's class MRO from most-
|
|
30
|
+
derived to least-derived. For each class that defines its own
|
|
31
|
+
`_on_key` or `on_key` (checked via `cls.__dict__`, private preferred
|
|
32
|
+
over public *within that same class*), it calls that handler, then
|
|
33
|
+
moves on to the next class in the MRO -- unless prevented.
|
|
34
|
+
- `event.prevent_default()` sets `message._no_default_action = True`,
|
|
35
|
+
which makes that walk `break` -- so calling it stops every *later*
|
|
36
|
+
(more-base) class's handler from running at all.
|
|
37
|
+
- `event.stop()` is a separate mechanism -- it only stops the event
|
|
38
|
+
from *bubbling to the parent widget* afterwards (which is how
|
|
39
|
+
some TUI's ctrl+s / ctrl+f / ctrl+g bindings on the outer
|
|
40
|
+
TextEditor container end up firing even though the inner
|
|
41
|
+
TextAreaPlus has actual focus).
|
|
42
|
+
|
|
43
|
+
TextAreaPlus.on_key handles special keys (enter, tab, brackets, escape)
|
|
44
|
+
by calling prevent_default() itself, fully replacing default TextArea
|
|
45
|
+
behavior for those keys. For plain printable characters it does NOT
|
|
46
|
+
prevent_default -- it just triggers autocomplete side effects and lets
|
|
47
|
+
the event keep falling through to base TextArea._on_key, which does the
|
|
48
|
+
actual insert.
|
|
49
|
+
|
|
50
|
+
So the correct way to layer vim modes on top is to also hook `on_key`
|
|
51
|
+
(matching TextAreaPlus's own handler name, so we sit earlier in the
|
|
52
|
+
MRO): in INSERT mode, do nothing at all for any key other than Escape --
|
|
53
|
+
let the event fall through exactly as if this class didn't exist, so
|
|
54
|
+
every TextAreaPlus feature keeps working untouched. In NORMAL / VISUAL /
|
|
55
|
+
COMMAND mode, prevent_default() + stop() immediately, exactly like
|
|
56
|
+
VimTextArea does, so TextAreaPlus never sees the key while not
|
|
57
|
+
inserting. (VimTextArea's `super()._on_key()` approach would NOT work
|
|
58
|
+
here -- it's a direct Python method call via `super()`, which completely
|
|
59
|
+
bypasses Textual's own per-class MRO dispatch loop and would skip
|
|
60
|
+
TextAreaPlus.on_key's side effects entirely.)
|
|
61
|
+
|
|
62
|
+
A real maintenance risk worth knowing about: `TextAreaPlus` is NOT part
|
|
63
|
+
of textual_textarea's public API (only `TextEditor` is exported from
|
|
64
|
+
`textual_textarea/__init__.py`). Importing it means reaching into
|
|
65
|
+
`textual_textarea.text_editor`, a private module path. If a future
|
|
66
|
+
textual-textarea release restructures that file, this import breaks with
|
|
67
|
+
no deprecation warning. Pin your textual-textarea version and re-check
|
|
68
|
+
this import on every upgrade.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
from textual import events
|
|
74
|
+
from textual.message import Message
|
|
75
|
+
|
|
76
|
+
from ._modal import Mode, VimModalMixin
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
from textual_textarea.text_editor import TextAreaPlus
|
|
80
|
+
except ImportError as exc: # pragma: no cover
|
|
81
|
+
raise ImportError(
|
|
82
|
+
"VimTextAreaPlus requires the 'textarea-plus' extra: "
|
|
83
|
+
"pip install textual-vim-textarea[textarea-plus]"
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
__all__ = ["VimTextAreaPlus", "Mode"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class VimTextAreaPlus(VimModalMixin, TextAreaPlus):
|
|
90
|
+
"""TextAreaPlus with vim modes layered on top via `on_key`, so every
|
|
91
|
+
TextAreaPlus feature -- autocomplete, bracket/quote auto-closing,
|
|
92
|
+
smart enter/tab indent -- keeps working exactly as before while in
|
|
93
|
+
INSERT mode, and is fully bypassed while in NORMAL / VISUAL /
|
|
94
|
+
COMMAND mode.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
class ModeChanged(Message):
|
|
98
|
+
def __init__(self, mode: Mode) -> None:
|
|
99
|
+
self.mode = mode
|
|
100
|
+
super().__init__()
|
|
101
|
+
|
|
102
|
+
class StatusChanged(Message):
|
|
103
|
+
def __init__(self, status: str) -> None:
|
|
104
|
+
self.status = status
|
|
105
|
+
super().__init__()
|
|
106
|
+
|
|
107
|
+
class QuitRequested(Message):
|
|
108
|
+
"""Posted on ':q'. Deliberately NOT wired to anything by
|
|
109
|
+
default -- closing a buffer vs. quitting the whole app is a real
|
|
110
|
+
host-app product decision, not this widget's call to make."""
|
|
111
|
+
|
|
112
|
+
class CommandEntered(Message):
|
|
113
|
+
"""Posted for any ':' command this widget doesn't itself handle."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, command: str) -> None:
|
|
116
|
+
self.command = command
|
|
117
|
+
super().__init__()
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# key routing -- see the module docstring for exactly why this is
|
|
121
|
+
# structured the way it is.
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
async def on_key(self, event: events.Key) -> None:
|
|
124
|
+
if self.mode is Mode.INSERT:
|
|
125
|
+
if event.key == "escape":
|
|
126
|
+
# side effect only -- do NOT prevent_default/stop, so
|
|
127
|
+
# TextAreaPlus's own escape handler (hide completion
|
|
128
|
+
# list, collapse selection) still runs right after this
|
|
129
|
+
self._enter_normal_mode(shift_cursor_left=True)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if self.mode is Mode.COMMAND:
|
|
133
|
+
event.stop()
|
|
134
|
+
event.prevent_default()
|
|
135
|
+
await self._handle_command_key(event)
|
|
136
|
+
self.post_message(self.StatusChanged(self.status_text))
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# NORMAL / VISUAL / VISUAL_LINE: we own every key, TextAreaPlus
|
|
140
|
+
# never sees it.
|
|
141
|
+
event.stop()
|
|
142
|
+
event.prevent_default()
|
|
143
|
+
self._handle_normal_key(event)
|
|
144
|
+
self.post_message(self.StatusChanged(self.status_text))
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# ':w' triggers TextEditor's real save flow (the same
|
|
148
|
+
# footer path-input ctrl+s opens) by walking up to the TextEditor
|
|
149
|
+
# ancestor and calling its action_save directly -- see
|
|
150
|
+
# VimModalMixin._trigger_ancestor_action's docstring for why plain
|
|
151
|
+
# run_action() doesn't work for this.
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
async def _do_save(self) -> None:
|
|
154
|
+
await self._trigger_ancestor_action("save")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
vim_textarea.py
|
|
3
|
+
|
|
4
|
+
Modal (vim-style) editing on top of plain Textual `TextArea`. All the
|
|
5
|
+
actual vim logic (motions, operators, counts, registers, visual mode,
|
|
6
|
+
command line) lives in `_modal.VimModalMixin`, shared with
|
|
7
|
+
`textarea_plus.VimTextAreaPlus`. This file only has what's genuinely
|
|
8
|
+
specific to a plain `TextArea` base: how keys get intercepted, and the
|
|
9
|
+
Message classes host apps listen for.
|
|
10
|
+
|
|
11
|
+
Key routing
|
|
12
|
+
-----------
|
|
13
|
+
TextArea's default `_on_key` treats every printable key as "insert this
|
|
14
|
+
character". To get modal editing we override `_on_key`: in INSERT mode
|
|
15
|
+
we fall through to the default behavior via `super()._on_key(event)`,
|
|
16
|
+
and in NORMAL / VISUAL / COMMAND modes we swallow the event
|
|
17
|
+
(`event.stop()` + `event.prevent_default()`) and route it through the
|
|
18
|
+
shared dispatcher instead.
|
|
19
|
+
|
|
20
|
+
(If you're using `textual-textarea`'s `TextEditor`/`TextAreaPlus` instead
|
|
21
|
+
of plain `TextArea` -- use `textarea_plus.VimTextAreaPlus` instead. Its key
|
|
22
|
+
routing is different on purpose; see that module's docstring for why
|
|
23
|
+
this file's `super()._on_key()` approach would silently break
|
|
24
|
+
TextAreaPlus's own features.)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from textual import events
|
|
30
|
+
from textual.message import Message
|
|
31
|
+
from textual.widgets import TextArea
|
|
32
|
+
|
|
33
|
+
from ._modal import Mode, VimModalMixin
|
|
34
|
+
|
|
35
|
+
__all__ = ["VimTextArea", "Mode"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class VimTextArea(VimModalMixin, TextArea):
|
|
39
|
+
"""A TextArea with modal vim-style editing bound on top.
|
|
40
|
+
|
|
41
|
+
Drop-in replacement for `textual.widgets.TextArea`. Subclass it the
|
|
42
|
+
same way you would subclass TextArea; all of TextArea's own API
|
|
43
|
+
(text, document, selection, themes, syntax highlighting, etc.)
|
|
44
|
+
continues to work unchanged -- this class only intercepts key
|
|
45
|
+
handling while not in INSERT mode.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
class ModeChanged(Message):
|
|
49
|
+
"""Posted whenever the vim mode changes."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, mode: Mode) -> None:
|
|
52
|
+
self.mode = mode
|
|
53
|
+
super().__init__()
|
|
54
|
+
|
|
55
|
+
class StatusChanged(Message):
|
|
56
|
+
"""Posted after every NORMAL/VISUAL/COMMAND keystroke this widget
|
|
57
|
+
handles -- covers pending counts, pending operators, and the
|
|
58
|
+
command-line buffer changing, none of which necessarily change
|
|
59
|
+
`mode` itself. A host app's status bar should listen for this
|
|
60
|
+
(in addition to, or instead of, ModeChanged) to stay accurate."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, status: str) -> None:
|
|
63
|
+
self.status = status
|
|
64
|
+
super().__init__()
|
|
65
|
+
|
|
66
|
+
class SaveRequested(Message):
|
|
67
|
+
"""Posted on ':w' or ':wq'."""
|
|
68
|
+
|
|
69
|
+
class QuitRequested(Message):
|
|
70
|
+
"""Posted on ':q' or ':wq'."""
|
|
71
|
+
|
|
72
|
+
class CommandEntered(Message):
|
|
73
|
+
"""Posted for any ':' command this widget doesn't itself handle."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, command: str) -> None:
|
|
76
|
+
self.command = command
|
|
77
|
+
super().__init__()
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# key routing
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
async def _on_key(self, event: events.Key) -> None:
|
|
83
|
+
if self.mode is Mode.INSERT:
|
|
84
|
+
if event.key == "escape":
|
|
85
|
+
event.stop()
|
|
86
|
+
event.prevent_default()
|
|
87
|
+
self._enter_normal_mode(shift_cursor_left=True)
|
|
88
|
+
return
|
|
89
|
+
await super()._on_key(event)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if self.mode is Mode.COMMAND:
|
|
93
|
+
event.stop()
|
|
94
|
+
event.prevent_default()
|
|
95
|
+
await self._handle_command_key(event)
|
|
96
|
+
self.post_message(self.StatusChanged(self.status_text))
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# NORMAL / VISUAL / VISUAL_LINE: we own every key.
|
|
100
|
+
event.stop()
|
|
101
|
+
event.prevent_default()
|
|
102
|
+
self._handle_normal_key(event)
|
|
103
|
+
self.post_message(self.StatusChanged(self.status_text))
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: textual-vim-textarea
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: A Vim-style textarea widget for Textual
|
|
5
|
+
Project-URL: Homepage, https://github.com/Dking08/textual-vim-textarea
|
|
6
|
+
Project-URL: Repository, https://github.com/Dking08/textual-vim-textarea
|
|
7
|
+
Project-URL: Issues, https://github.com/Dking08/textual-vim-textarea/issues
|
|
8
|
+
Author-email: Dking08 <dastageer_foss@yahoo.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: editor,textarea,textual,tui,vim
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Topic :: Terminals
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: textual>=6.4.0
|
|
20
|
+
Provides-Extra: textarea-plus
|
|
21
|
+
Requires-Dist: textual-textarea<0.18,>=0.17; extra == 'textarea-plus'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# textual-vim-textarea
|
|
25
|
+
|
|
26
|
+
vim-style editing built on top of Textual's built-in `TextArea`. This is just a subclass that intercepts keys before they hit the default "every keypress inserts a character" behavior.
|
|
27
|
+
|
|
28
|
+
Built this to eventually wire vim keybindings into a TUI app, but split it out as its own module first so it could be tested properly on its own before touching real app code.
|
|
29
|
+
|
|
30
|
+
## Two flavors
|
|
31
|
+
|
|
32
|
+
- **`VimTextArea`** - for plain Textual `TextArea`. Zero extra dependencies beyond `textual` itself.
|
|
33
|
+
- **`VimTextAreaPlus`** - for apps built on [`textual-textarea`](https://github.com/tconbeer/textual-textarea)'s `TextEditor`/`TextAreaPlus` instead of plain `TextArea`.
|
|
34
|
+
|
|
35
|
+
Both share the same underlying vim logic (`_modal.VimModalMixin`) - same motions, operators, counts, everything. They only differ in how they hook into their respective base widget's key handling, because the two bases genuinely work differently under the hood (see `textarea_plus.py`'s module docstring if you're curious exactly why).
|
|
36
|
+
|
|
37
|
+
## What you get
|
|
38
|
+
|
|
39
|
+
- Modes: `NORMAL`, `INSERT`, `VISUAL`, `VISUAL LINE`, `COMMAND`
|
|
40
|
+
- Motions: `h j k l`, `0`, `^`, `$`, `w`, `b`, `e`, `gg`, `G` (arrow keys work too)
|
|
41
|
+
- Insert entry: `i a I A o O`
|
|
42
|
+
- Editing: `x X`, `dd dw db de`, `d$ / D`, `cc cw cb ce`, `c$ / C`,
|
|
43
|
+
`yy yw yb ye`, `y$`, `p P`, `u`, `ctrl+r`
|
|
44
|
+
- Visual mode: `v` (charwise) / `V` (linewise), then `d x c y` act on the selection
|
|
45
|
+
- Counts: `3j`, `3dd`, `2dw`, even `2d3w` (counts multiply, like real vim)
|
|
46
|
+
- Command line: `:w`, `:q`, `:wq`, `:n` to jump to line `n` - anything else
|
|
47
|
+
gets posted as a message so your app can handle its own commands
|
|
48
|
+
- Line numbers - this one's actually just `TextArea`'s built-in
|
|
49
|
+
`show_line_numbers=True`, not something this module adds
|
|
50
|
+
|
|
51
|
+
**Not** currently implemented: registers beyond the default one, macros, marks, `:s///` regex substitution, folds, splits.
|
|
52
|
+
|
|
53
|
+
> If you want to more vim features, you can contribute!
|
|
54
|
+
|
|
55
|
+
## Project layout
|
|
56
|
+
|
|
57
|
+
- `src/textual_vim_textarea/_modal.py` - shared vim logic, not meant to be used directly
|
|
58
|
+
- `src/textual_vim_textarea/vim_textarea.py` - `VimTextArea`, built on plain `TextArea`. Primary file.
|
|
59
|
+
- `src/textual_vim_textarea/textarea_plus.py` - `VimTextAreaPlus`, built on `textual-textarea`'s `TextAreaPlus`.
|
|
60
|
+
|
|
61
|
+
- `examples/dummy_app.py` - standalone playground for `VimTextArea`
|
|
62
|
+
- `examples/dummy_app_plus.py` + `examples/vim_text_editor.py` - standalone playground for `VimTextAreaPlus`, and the `TextEditor` subclass pattern to copy into a real app
|
|
63
|
+
|
|
64
|
+
- `tests/test_vim_textarea.py`, `tests/test_textarea_plus.py` - headless tests using Textual's `Pilot`
|
|
65
|
+
|
|
66
|
+
## Try it
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uv sync
|
|
70
|
+
python examples/dummy_app.py
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
> Bottom bar shows the mode/pending-command line, same idea as vim's own.
|
|
74
|
+
> `:q` quits, `:w` just fires a notification since there's no real file
|
|
75
|
+
> backing the dummy app.
|
|
76
|
+
|
|
77
|
+
For the `TextAreaPlus` flavor (needs the extra) - OPTIONAL:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv sync --extra textarea-plus
|
|
81
|
+
python examples/dummy_app_plus.py
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Using it in your own TUI
|
|
85
|
+
|
|
86
|
+
It's a drop-in replacement for `TextArea`. Use it exactly like you'd use `TextArea`, and listen for a couple of extra messages to keep a status bar in sync and to hook up `:w` / `:q`:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from textual_vim_textarea import VimTextArea
|
|
90
|
+
|
|
91
|
+
class MyApp(App):
|
|
92
|
+
def compose(self):
|
|
93
|
+
yield VimTextArea("some text", language="python", show_line_numbers=True, id="editor")
|
|
94
|
+
|
|
95
|
+
def on_vim_text_area_status_changed(self, message: VimTextArea.StatusChanged):
|
|
96
|
+
# fires on every NORMAL/VISUAL/COMMAND keystroke it handles -
|
|
97
|
+
# pending counts, pending operators, the command buffer, etc.
|
|
98
|
+
self.query_one("#status").update(message.status)
|
|
99
|
+
|
|
100
|
+
def on_vim_text_area_mode_changed(self, message: VimTextArea.ModeChanged):
|
|
101
|
+
# fires only when the mode itself actually changes
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
def on_vim_text_area_save_requested(self, message: VimTextArea.SaveRequested):
|
|
105
|
+
# ':w' or ':wq' - do your actual file save here
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
def on_vim_text_area_quit_requested(self, message: VimTextArea.QuitRequested):
|
|
109
|
+
# ':q' or ':wq'
|
|
110
|
+
self.exit()
|
|
111
|
+
|
|
112
|
+
def on_vim_text_area_command_entered(self, message: VimTextArea.CommandEntered):
|
|
113
|
+
# any ':' command that isn't w/q/wq/n - bring your own command palette
|
|
114
|
+
...
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Everything else - `.text`, `.document`, `.selection`, themes, syntax highlighting, `language=`, etc. - is untouched `TextArea` API, works exactly as it does normally. This class only takes over key handling while you're not in INSERT mode.**
|
|
118
|
+
|
|
119
|
+
### One gotcha if you're subclassing further
|
|
120
|
+
|
|
121
|
+
`VimTextArea` uses `_on_key` as its extension point. If you need to add your own key handling on top, don't fight the binding system - override `_on_key`, check mode, and either fall through to `super()._on_key(event)` or handle it yourself and call `event.stop()` + `event.prevent_default()`. Same pattern this module already uses internally.
|
|
122
|
+
|
|
123
|
+
`VimTextAreaPlus` is different on purpose - it hooks `on_key`, not `_on_key`, and in INSERT mode does **nothing at all** (not even calling `super()`) so the event cascades naturally through `TextAreaPlus`'s own `on_key` and then base `TextArea`'s `_on_key`. If you're subclassing `VimTextAreaPlus` further, match that: don't call `super().on_key()` manually, just decide whether to `prevent_default()`+`stop()` or leave the event alone entirely. See `textarea_plus.py`'s module docstring for the actual Textual dispatch mechanics this depends on - it's worth reading before you touch it, the ordering isn't obvious from the outside.
|
|
124
|
+
|
|
125
|
+
### Using VimTextAreaPlus
|
|
126
|
+
|
|
127
|
+
Same idea, but you also get to decide what `:w`/`:q` actually do, since `TextAreaPlus`'s real save flow lives on an ancestor `TextEditor` widget, not on the text area itself:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from textual_vim_textarea.textarea_plus import VimTextAreaPlus
|
|
131
|
+
|
|
132
|
+
class MyCodeEditor(TextEditor): # from textual_textarea
|
|
133
|
+
def compose(self):
|
|
134
|
+
self.text_input = VimTextAreaPlus(language="sql", text=self._initial_text)
|
|
135
|
+
... # rest of TextEditor's own compose() body, unchanged
|
|
136
|
+
|
|
137
|
+
def on_vim_text_area_plus_quit_requested(self, message):
|
|
138
|
+
# ':q' - deliberately just a message. Closing a buffer/tab vs.
|
|
139
|
+
# quitting the whole app is your call, not this widget's.
|
|
140
|
+
...
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`:w` already works out of the box - it walks up to whatever ancestor has `action_save` (the real `TextEditor` action ctrl+s also triggers) and calls it directly, since Textual's own `run_action()` won't resolve an un-prefixed action name against an ancestor by default (verified this the hard way, see the integration guide).
|
|
144
|
+
|
|
145
|
+
## Known rough edges
|
|
146
|
+
|
|
147
|
+
- Word motions (`w b e`) use a simplified vim "word" definition (keyword run vs punctuation run vs whitespace). Covers the common case, isn't byte-for-byte identical to vim in every corner case.
|
|
148
|
+
- `cw` intentionally behaves like `ce` (doesn't eat trailing whitespace).
|
|
149
|
+
- Only one register (the unnamed one). `dd` then `yy` then `p` pastes whatever you yanked/deleted most recently, same register for everything.
|
|
150
|
+
|
|
151
|
+
## Running the tests
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
uv run pytest tests -v --asyncio-mode=auto
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
39 tests, all green as of this writing - 25 for `VimTextArea`, 14 for `VimTextAreaPlus`
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
textual_vim_textarea/__init__.py,sha256=sM4D3P8kYLcSyZA70SRrCJcMDTRDzunPAFWIyna6Z10,460
|
|
2
|
+
textual_vim_textarea/_modal.py,sha256=9SVlGvGhfZOtpNFmaLPOR4lUKUdflVlW1Y612W6Mjac,24868
|
|
3
|
+
textual_vim_textarea/textarea_plus.py,sha256=LPRHmbDTIiN4NUJw1zy1Le3HKk8bNvexZkriSHxiww0,6961
|
|
4
|
+
textual_vim_textarea/vim_textarea.py,sha256=nMGpKU10krY8aqT0ahn8zlcyzx-yFiK-lfRcgqT4Xjk,3896
|
|
5
|
+
textual_vim_textarea-1.1.0.dist-info/METADATA,sha256=U0tHNaooAfBZmrpeI-PIza5W7xDhEfCqpnGh_z7R8-8,8112
|
|
6
|
+
textual_vim_textarea-1.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
textual_vim_textarea-1.1.0.dist-info/licenses/LICENSE,sha256=jwVsOs8SK3I2qbYI4Dv5XerasO-we-d5HmPQC95O3wI,1096
|
|
8
|
+
textual_vim_textarea-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dastageer Siddiqui
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|