A Vim-style textarea widget for Textual
Project description
textual-vim-textarea
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.
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.
Two flavors
VimTextArea- for plain TextualTextArea. Zero extra dependencies beyondtextualitself.VimTextAreaPlus- for apps built ontextual-textarea'sTextEditor/TextAreaPlusinstead of plainTextArea.
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).
What you get
- Modes:
NORMAL,INSERT,VISUAL,VISUAL LINE,COMMAND - Motions:
h j k l,0,^,$,w,b,e,gg,G(arrow keys work too) - Insert entry:
i a I A o O - Editing:
x X,dd dw db de,d$ / D,cc cw cb ce,c$ / C,yy yw yb ye,y$,p P,u,ctrl+r - Visual mode:
v(charwise) /V(linewise), thend x c yact on the selection - Counts:
3j,3dd,2dw, even2d3w(counts multiply, like real vim) - Command line:
:w,:q,:wq,:nto jump to linen- anything else gets posted as a message so your app can handle its own commands - Line numbers - this one's actually just
TextArea's built-inshow_line_numbers=True, not something this module adds
Not currently implemented: registers beyond the default one, macros, marks, :s/// regex substitution, folds, splits.
If you want to more vim features, you can contribute!
Project layout
-
src/textual_vim_textarea/_modal.py- shared vim logic, not meant to be used directly -
src/textual_vim_textarea/vim_textarea.py-VimTextArea, built on plainTextArea. Primary file. -
src/textual_vim_textarea/textarea_plus.py-VimTextAreaPlus, built ontextual-textarea'sTextAreaPlus. -
examples/dummy_app.py- standalone playground forVimTextArea -
examples/dummy_app_plus.py+examples/vim_text_editor.py- standalone playground forVimTextAreaPlus, and theTextEditorsubclass pattern to copy into a real app -
tests/test_vim_textarea.py,tests/test_textarea_plus.py- headless tests using Textual'sPilot
Try it
uv sync
python examples/dummy_app.py
Bottom bar shows the mode/pending-command line, same idea as vim's own.
:qquits,:wjust fires a notification since there's no real file backing the dummy app.
For the TextAreaPlus flavor (needs the extra) - OPTIONAL:
uv sync --extra textarea-plus
python examples/dummy_app_plus.py
Using it in your own TUI
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:
from textual_vim_textarea import VimTextArea
class MyApp(App):
def compose(self):
yield VimTextArea("some text", language="python", show_line_numbers=True, id="editor")
def on_vim_text_area_status_changed(self, message: VimTextArea.StatusChanged):
# fires on every NORMAL/VISUAL/COMMAND keystroke it handles -
# pending counts, pending operators, the command buffer, etc.
self.query_one("#status").update(message.status)
def on_vim_text_area_mode_changed(self, message: VimTextArea.ModeChanged):
# fires only when the mode itself actually changes
...
def on_vim_text_area_save_requested(self, message: VimTextArea.SaveRequested):
# ':w' or ':wq' - do your actual file save here
...
def on_vim_text_area_quit_requested(self, message: VimTextArea.QuitRequested):
# ':q' or ':wq'
self.exit()
def on_vim_text_area_command_entered(self, message: VimTextArea.CommandEntered):
# any ':' command that isn't w/q/wq/n - bring your own command palette
...
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.
One gotcha if you're subclassing further
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.
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.
Using VimTextAreaPlus
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:
from textual_vim_textarea.textarea_plus import VimTextAreaPlus
class MyCodeEditor(TextEditor): # from textual_textarea
def compose(self):
self.text_input = VimTextAreaPlus(language="sql", text=self._initial_text)
... # rest of TextEditor's own compose() body, unchanged
def on_vim_text_area_plus_quit_requested(self, message):
# ':q' - deliberately just a message. Closing a buffer/tab vs.
# quitting the whole app is your call, not this widget's.
...
: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).
Known rough edges
- 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. cwintentionally behaves likece(doesn't eat trailing whitespace).- Only one register (the unnamed one).
ddthenyythenppastes whatever you yanked/deleted most recently, same register for everything.
Running the tests
uv run pytest tests -v --asyncio-mode=auto
39 tests, all green as of this writing - 25 for VimTextArea, 14 for VimTextAreaPlus
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file textual_vim_textarea-1.1.0.tar.gz.
File metadata
- Download URL: textual_vim_textarea-1.1.0.tar.gz
- Upload date:
- Size: 48.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bed82334b2fc19c93a45dee750748f5620d7057c684fdc6e455a46af69f1e7a9
|
|
| MD5 |
bdc282c00734e4374588ff312f329a26
|
|
| BLAKE2b-256 |
7fe83c0027e0075f9714aadddcd8a2bf5c82fb720529247714dc13c0540c5e18
|
File details
Details for the file textual_vim_textarea-1.1.0-py3-none-any.whl.
File metadata
- Download URL: textual_vim_textarea-1.1.0-py3-none-any.whl
- Upload date:
- Size: 16.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c4e610b1b1f9b045be8a09b31fc026e1861e91dff70ced96b26ad74268dcc439
|
|
| MD5 |
d7ee67a58fa8173115b8eec83ef148e3
|
|
| BLAKE2b-256 |
e489651f42f0e95b6b4db8dfc7c974b9307b5e0f3c23f4f5fa182e55342844c7
|