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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.