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!
Installation
pip install textual-vim-textarea
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
44 tests, all green as of this writing - 27 for VimTextArea, 17 for VimTextAreaPlus
Metadata
Release files for textual-vim-textarea 1.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| textual_vim_textarea-1.2.0.tar.gz | 49.7 kB | Without provenance | Details for textual_vim_textarea-1.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| textual_vim_textarea-1.2.0-py3-none-any.whl | Python 3 | none | any | Details for textual_vim_textarea-1.2.0-py3-none-any.whl |
Total release size: 66.9 kB
Release files / textual_vim_textarea-1.2.0.tar.gz
| Download URL | textual_vim_textarea-1.2.0.tar.gz |
|---|---|
| Size | 49.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4ed2400beb45b6d58f019c1e975e06407bcd2246c65ed78f601655525ca95b0b
|
|
BLAKE2b-256 checksum How to use checksums |
266eb6a0b5c3786c96f506904e5d2c0f9cf1b1e0aaf79558a31e40b25f91a1e5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.0
|
Release files / textual_vim_textarea-1.2.0-py3-none-any.whl
| Download URL | textual_vim_textarea-1.2.0-py3-none-any.whl |
|---|---|
| Size | 17.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0bcef47971a6353eece98793b822be1dc453d31980989de00a833e21790809f2
|
|
BLAKE2b-256 checksum How to use checksums |
10c75851006ae56b6ae3a36a45b97343a86a94f84b3f033b3006332e822eed84
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.0
|
Security for textual-vim-textarea 1.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.