Skip to main content

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 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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

textual_vim_textarea-1.2.0.tar.gz (49.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

textual_vim_textarea-1.2.0-py3-none-any.whl (17.1 kB view details)

Uploaded Python 3

File details

Details for the file textual_vim_textarea-1.2.0.tar.gz.

File metadata

  • Download URL: textual_vim_textarea-1.2.0.tar.gz
  • Upload date:
  • Size: 49.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.0

File hashes

Hashes for textual_vim_textarea-1.2.0.tar.gz
Algorithm Hash digest
SHA256 4ed2400beb45b6d58f019c1e975e06407bcd2246c65ed78f601655525ca95b0b
MD5 f2fe1a13e55a4ca1fc0f5132ac96efb6
BLAKE2b-256 266eb6a0b5c3786c96f506904e5d2c0f9cf1b1e0aaf79558a31e40b25f91a1e5

See more details on using hashes here.

File details

Details for the file textual_vim_textarea-1.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for textual_vim_textarea-1.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0bcef47971a6353eece98793b822be1dc453d31980989de00a833e21790809f2
MD5 6ae5705557d9f29f8b97ef6ca00a826f
BLAKE2b-256 10c75851006ae56b6ae3a36a45b97343a86a94f84b3f033b3006332e822eed84

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page