Skip to main content

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 Textual TextArea. Zero extra dependencies beyond textual itself.
  • VimTextAreaPlus - for apps built on textual-textarea's TextEditor/TextAreaPlus instead of plain TextArea.

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), then d x c y act on the selection
  • Counts: 3j, 3dd, 2dw, even 2d3w (counts multiply, like real vim)
  • Command line: :w, :q, :wq, :n to jump to line n - 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-in show_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 plain TextArea. Primary file.

  • src/textual_vim_textarea/textarea_plus.py - VimTextAreaPlus, built on textual-textarea's TextAreaPlus.

  • examples/dummy_app.py - standalone playground for VimTextArea

  • 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

  • tests/test_vim_textarea.py, tests/test_textarea_plus.py - headless tests using Textual's Pilot

Try it

uv sync
python examples/dummy_app.py

Bottom bar shows the mode/pending-command line, same idea as vim's own. :q quits, :w just 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.
  • cw intentionally behaves like ce (doesn't eat trailing whitespace).
  • Only one register (the unnamed one). dd then yy then p pastes 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)

Source distribution for textual-vim-textarea 1.2.0
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)

Table of built distributions (wheels) for textual-vim-textarea 1.2.0
File Interpreter ABI Platform Actions
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.

Release history Release notifications | RSS feed

This release

1.2.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page