Comprehensive Vim-style modal editing widget for Textual with operator+motion, text objects, and visual mode
Project description
VimKeys Input
A comprehensive Vim-style modal editing widget for Textual, bringing the power of Vim keybindings to your terminal applications.
Features
Core Vim Functionality
- Modal Editing: Full INSERT, COMMAND, and VISUAL mode support
- Operator + Motion System: Complete implementation of vim's composable commands (e.g.,
dw,c$,y3j) - Count Support: Numeric prefixes for commands (e.g.,
5j,3dd,2d3w) - Text Objects: Semantic editing with
iw,i",i(,da{, etc. - Marks: Position bookmarks with
ma,'a,`a - Visual Selection: Character-wise selection with operators
Navigation
- Basic Movement:
hjklfor directional navigation - Word Motion:
w,b,efor word-based navigation - Line Navigation:
0,$,^for line start/end/first non-whitespace - Document Navigation:
gg,Gfor document start/end - Count Support: All motions support counts (e.g.,
5j,3w)
Editing
- Delete:
x,dd,d<motion>(e.g.,dw,d$,d3e) - Change:
cc,c<motion>(e.g.,cw,c$,cb) - Yank (Copy):
yy,y<motion>(e.g.,y3j,yw) - Paste:
p,Pfor paste after/before cursor - Undo/Redo:
u,Ctrl+rwith full history - Insert Modes:
i,a,I,A,o,Ofor various insert positions
Advanced Features
- Search:
/for forward search,n/Nfor next/previous - Replace:
rfor single character replacement - Join Lines:
Jto join current line with next - Visual Mode:
vfor character-wise selection, operators work in visual mode - Case Change:
~to toggle case (planned) - Line Change:
S,Cfor line/tail change (planned)
Visual Feedback
- Mode Indicators: Border colors change based on current mode
- Green: INSERT mode
- Blue: COMMAND mode
- Yellow: VISUAL mode
- CSS Customization: Full control over mode styling via CSS classes
Events
- Submitted: Fires when user presses Enter in INSERT mode
- ModeChanged: Fires whenever vim mode changes
Installation
From PyPI (when published)
pip install vimkeys-input
From Source (Development)
# Clone the repository
git clone https://github.com/yourusername/vimkeys-input.git
cd vimkeys-input
# Using uv (recommended)
uv venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
uv pip install -e ".[dev]"
# Or using pip
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
Quick Start
Basic Example
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer
from vimkeys_input import VimTextArea
class MyApp(App):
"""A simple app with vim-style input."""
def compose(self) -> ComposeResult:
yield Header()
yield VimTextArea(id="input")
yield Footer()
def on_vim_text_area_submitted(self, event: VimTextArea.Submitted):
"""Handle text submission."""
self.notify(f"You entered: {event.text}")
event.text_area.clear()
if __name__ == "__main__":
app = MyApp()
app.run()
Chat Application Example
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, RichLog
from textual.containers import Vertical
from vimkeys_input import VimTextArea
class ChatApp(App):
"""A chat application with vim-style input."""
CSS = """
#history {
height: 1fr;
border: solid $primary;
}
#input {
height: auto;
max-height: 10;
margin: 1;
}
"""
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
yield RichLog(id="history", markup=True, wrap=True)
yield VimTextArea(id="input")
yield Footer()
def on_mount(self):
history = self.query_one("#history", RichLog)
history.write("Welcome to the chat!")
def on_vim_text_area_submitted(self, event: VimTextArea.Submitted):
if not event.text.strip():
return
history = self.query_one("#history", RichLog)
history.write(f"[bold cyan]You:[/bold cyan] {event.text}")
event.text_area.clear()
if __name__ == "__main__":
app = ChatApp()
app.run()
Complete Keybinding Reference
Mode Switching
| Key | Action | Description |
|---|---|---|
ESC |
Enter COMMAND mode | Exit INSERT/VISUAL mode |
i |
Insert at cursor | Enter INSERT mode at cursor |
a |
Append after cursor | Enter INSERT mode after cursor |
I |
Insert at line start | Enter INSERT mode at first non-whitespace |
A |
Append at line end | Enter INSERT mode at end of line |
o |
Open line below | Create new line below and enter INSERT mode |
O |
Open line above | Create new line above and enter INSERT mode |
v |
Visual mode | Enter character-wise VISUAL mode |
Navigation (COMMAND Mode)
| Key | Action | Count Support | Description |
|---|---|---|---|
h |
Left | ✓ | Move cursor left |
j |
Down | ✓ | Move cursor down |
k |
Up | ✓ | Move cursor up |
l |
Right | ✓ | Move cursor right |
w |
Next word | ✓ | Move to start of next word |
b |
Previous word | ✓ | Move to start of previous word |
e |
End of word | ✓ | Move to end of current/next word |
0 |
Line start | - | Move to beginning of line |
$ |
Line end | - | Move to end of line |
^ |
First non-blank | - | Move to first non-whitespace character |
gg |
Document start | - | Move to first line |
G |
Document end | - | Move to last line |
Operators (COMMAND Mode)
Operators can be combined with motions and counts for powerful editing:
| Operator | Description | Example | Result |
|---|---|---|---|
d |
Delete | dw |
Delete word |
d$ |
Delete to end of line | ||
d3j |
Delete 3 lines down | ||
dd |
Delete current line | ||
c |
Change (delete + INSERT) | cw |
Change word |
c$ |
Change to end of line | ||
cb |
Change previous word | ||
cc |
Change entire line | ||
y |
Yank (copy) | yw |
Yank word |
y$ |
Yank to end of line | ||
y3j |
Yank 3 lines down | ||
yy |
Yank current line |
Text Objects (COMMAND Mode)
Use with operators for semantic editing:
| Text Object | Description | Example | Result |
|---|---|---|---|
iw |
Inner word | diw |
Delete word under cursor |
aw |
A word (with space) | daw |
Delete word + surrounding space |
i" |
Inside quotes | di" |
Delete text inside quotes |
a" |
Around quotes | da" |
Delete text + quotes |
i( / i) |
Inside parens | di( |
Delete text inside parentheses |
a( / a) |
Around parens | da( |
Delete text + parentheses |
i{ / i} |
Inside braces | di{ |
Delete text inside braces |
a{ / a} |
Around braces | da{ |
Delete text + braces |
i[ / i] |
Inside brackets | di[ |
Delete text inside brackets |
a[ / a] |
Around brackets | da[ |
Delete text + brackets |
Editing (COMMAND Mode)
| Key | Action | Count Support | Description |
|---|---|---|---|
x |
Delete char | ✓ | Delete character under cursor |
r |
Replace char | - | Replace character under cursor |
p |
Paste after | ✓ | Paste after cursor/line |
P |
Paste before | ✓ | Paste before cursor/line |
u |
Undo | - | Undo last change |
Ctrl+r |
Redo | - | Redo last undone change |
J |
Join lines | - | Join current line with next |
~ |
Toggle case | ✓ | Toggle case of character(s) |
Search (COMMAND Mode)
| Key | Action | Description |
|---|---|---|
/ |
Search forward | Enter search pattern |
n |
Next match | Jump to next search result |
N |
Previous match | Jump to previous search result |
Marks (COMMAND Mode)
| Key | Action | Description |
|---|---|---|
m{a-z} |
Set mark | Set mark at cursor position (e.g., ma) |
'{a-z} |
Jump to mark line | Jump to line of mark (e.g., 'a) |
`{a-z} |
Jump to mark pos | Jump to exact position of mark (e.g., `a) |
Visual Mode
| Key | Action | Description |
|---|---|---|
v |
Enter visual | Start character-wise selection |
h/j/k/l |
Extend selection | Extend selection with movement |
w/b/e |
Extend by word | Extend selection by words |
d |
Delete selection | Delete selected text |
c |
Change selection | Delete selected text and enter INSERT |
y |
Yank selection | Copy selected text |
ESC |
Exit visual | Return to COMMAND mode |
Count System
Vim counts work in multiple ways:
{count}{motion} Example: 5j (move down 5 lines)
{count}{operator} Example: 3dd (delete 3 lines)
{operator}{count}{motion} Example: d3w (delete 3 words)
{count1}{operator}{count2}{motion} Example: 2d3w (delete 6 words total)
INSERT Mode
| Key | Action | Description |
|---|---|---|
Enter |
Submit | Fire Submitted event (customizable) |
ESC |
Exit INSERT | Return to COMMAND mode |
| All standard keys | Insert text | Normal text input |
| Arrow keys, Backspace, Delete, etc. | Edit | Standard editing operations |
Examples
The package includes several example applications demonstrating different use cases:
01_spike.py - Basic Functionality
uv run python examples/01_spike.py
Demonstrates basic vim modal editing with a simple text input.
02_simple_chat.py - Chat Bot
uv run python examples/02_simple_chat.py
A simple chat bot application showing how to integrate VimTextArea in a conversational UI.
03_streaming_chat.py - Advanced Chat
uv run python examples/03_streaming_chat.py
An advanced chat application featuring:
- Token-by-token streaming responses
- Animated "thinking" indicator
- Input history navigation (up/down arrows)
- Text wrapping
- Command palette integration
API Reference
VimTextArea
The main widget class extending Textual's TextArea.
Constructor
VimTextArea(
text: str = "",
language: str | None = None,
theme: str = "css",
*,
id: str | None = None,
classes: str | None = None,
)
All parameters from Textual's TextArea are supported.
Properties
@property
def mode(self) -> VimMode:
"""Get current vim mode."""
@mode.setter
def mode(self, new_mode: VimMode) -> None:
"""Set vim mode and update visual feedback."""
@property
def yank_register(self) -> str:
"""Get contents of yank (copy) register."""
Methods
def clear(self) -> None:
"""Clear the text area and reset to INSERT mode."""
def get_line(self, row: int) -> Text:
"""Get the text content of a specific line."""
def set_cursor(self, row: int, col: int) -> None:
"""Set cursor position."""
Events
class Submitted(Message):
"""Posted when user presses Enter in INSERT mode."""
@property
def text(self) -> str:
"""The submitted text."""
@property
def text_area(self) -> VimTextArea:
"""The VimTextArea that was submitted."""
class ModeChanged(Message):
"""Posted when vim mode changes."""
@property
def mode(self) -> VimMode:
"""The new mode."""
@property
def previous_mode(self) -> VimMode:
"""The previous mode."""
VimMode
Enum representing vim modes:
class VimMode(Enum):
INSERT = "insert" # Normal text input
COMMAND = "command" # Vim command mode
VISUAL = "visual" # Visual selection mode
CSS Classes
VimTextArea automatically applies CSS classes based on mode:
.insert-mode- Applied in INSERT mode.command-mode- Applied in COMMAND mode.visual-mode- Applied in VISUAL mode
Customization
Styling Modes
Customize the appearance of different modes with CSS:
class MyApp(App):
CSS = """
VimTextArea.insert-mode {
border: solid green;
background: $surface;
}
VimTextArea.command-mode {
border: solid blue;
background: $surface;
}
VimTextArea.visual-mode {
border: solid yellow;
background: $boost;
}
VimTextArea {
height: auto;
max-height: 20;
min-height: 3;
}
"""
Handling Events
Listen to vim-specific events:
def on_vim_text_area_mode_changed(self, event: VimTextArea.ModeChanged):
"""React to mode changes."""
if event.mode == VimMode.INSERT:
self.sub_title = "Editing"
elif event.mode == VimMode.COMMAND:
self.sub_title = "Command"
def on_vim_text_area_submitted(self, event: VimTextArea.Submitted):
"""Handle text submission."""
self.process_input(event.text)
event.text_area.clear()
Extending VimTextArea
Subclass to add custom functionality:
from vimkeys_input import VimTextArea
from textual import events
class HistoryVimTextArea(VimTextArea):
"""VimTextArea with input history navigation."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.input_history = []
self.history_index = -1
def add_to_history(self, text: str):
"""Add entry to history."""
if text.strip():
self.input_history.append(text)
def on_key(self, event: events.Key) -> None:
"""Handle up/down for history navigation."""
if event.key == "up" and self.cursor_location == (0, 0):
# Load previous history entry
if self.input_history and self.history_index < len(self.input_history) - 1:
self.history_index += 1
self.text = self.input_history[-(self.history_index + 1)]
event.prevent_default()
return
return super().on_key(event)
Architecture
VimTextArea is built using a modular mixin architecture:
VimTextArea (vim_textarea.py)
├── NavigationMixin (operations/navigation.py)
│ └── Handles h, j, k, l, w, b, e, 0, $, gg, G
├── EditingMixin (operations/editing.py)
│ └── Handles x, r, p, P, u, Ctrl+r, J
├── VisualMixin (operations/visual.py)
│ └── Handles visual mode selection and operations
├── SearchMixin (operations/search.py)
│ └── Handles /, n, N search operations
├── TextObjectMixin (operations/text_objects.py)
│ └── Handles iw, aw, i", a", i(, a(, etc.
├── CountHandler (count_handler.py)
│ └── Manages numeric count state
├── OperatorPendingState (operator_pending.py)
│ └── Manages operator+motion combinations
└── MarksManager (marks.py)
└── Manages position bookmarks
All built on Textual's TextArea widget
This modular design keeps the codebase maintainable and extensible.
Development
Project Structure
vimkeys-input/
├── vimkeys_input/ # Main package
│ ├── __init__.py # Public API exports
│ ├── vim_modes.py # VimMode enum
│ ├── vim_textarea.py # Main widget class
│ ├── count_handler.py # Count support
│ ├── operator_pending.py # Operator+motion system
│ ├── marks.py # Position bookmarks
│ └── operations/ # Modular operation mixins
│ ├── __init__.py
│ ├── navigation.py # Movement commands
│ ├── editing.py # Edit commands
│ ├── visual.py # Visual mode
│ ├── search.py # Search functionality
│ └── text_objects.py # Text object support
├── examples/ # Example applications
│ ├── 01_spike.py
│ ├── 02_simple_chat.py
│ └── 03_streaming_chat.py
├── tests/ # Test suite (124 tests)
├── docs/ # Additional documentation
├── plans/ # Development planning docs
├── pyproject.toml # Package configuration
└── README.md # This file
Running Tests
# Install development dependencies
uv pip install -e ".[dev]"
# Run all tests
pytest tests/
# Run specific test file
pytest tests/test_navigation.py
# Run with coverage
pytest --cov=vimkeys_input tests/
# Run with verbose output
pytest -v tests/
Development Mode
Use Textual's dev mode for live reload during development:
textual run --dev examples/01_spike.py
# Or with console for debugging
textual console
textual run --dev examples/01_spike.py
Code Quality
The project uses:
- pytest for testing
- ruff for linting and formatting
- mypy for type checking (planned)
# Format code
ruff format vimkeys_input/
# Lint code
ruff check vimkeys_input/
# Type check
mypy vimkeys_input/
Implementation Status
✅ Phase 0: Spike (Complete)
- Basic structure and mode switching
- Core navigation (hjkl)
- Basic editing (x, dd, yy, p)
- Visual selection
- Example applications
✅ Phase 1: Refactoring (Complete)
- Modular mixin architecture
- Comprehensive test suite (124 tests, 82% passing)
- All navigation commands
- All mode transitions
- Undo/redo integration
✅ Phase 2: Advanced Features (Complete)
- Operator + motion system (dw, c$, y3j)
- Count support (5j, 3dd, 2d3w)
- Text objects (diw, ci", da()
- Marks system (ma, 'a, `a)
- Search functionality (/, n, N)
- Join lines (J)
- Replace character (r)
🚧 Phase 3: Polish (In Progress)
- Comprehensive documentation
- Additional examples
- Performance optimization
- PyPI package preparation
- Case toggle (~)
- Line operations (S, C)
- Visual line mode (V)
Contributing
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
Quick Contribution Guide
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes
- Add tests for new functionality
- Ensure tests pass (
pytest tests/) - Commit with descriptive messages
- Push to your fork
- Open a Pull Request
Roadmap
- Visual line mode (V)
- Visual block mode (Ctrl+v)
- Case change operations (~, gu, gU)
- Line operations (S, C)
- Repeat command (.)
- Macros (q, @)
- More text objects (sentences, paragraphs)
- Ex commands (
:w,:q) - Configuration system
- Plugin architecture
FAQ
Q: Does this support all vim commands? A: No, this implements core vim modal editing and the most common commands. It's designed for text input in terminal apps, not as a full vim replacement.
Q: Can I use this with Textual's built-in TextArea features? A: Yes! VimTextArea extends TextArea, so features like syntax highlighting, themes, and language support all work.
Q: How do I disable vim mode temporarily?
A: Set text_area.mode = VimMode.INSERT to keep the widget in INSERT mode, or subclass and override key handling.
Q: Does this work on Windows? A: Yes! VimTextArea works on all platforms supported by Textual (Linux, macOS, Windows).
Q: Can I customize which keys trigger which commands? A: Currently no, but this is planned for a future release. For now, you can subclass and override key handlers.
License
MIT License - see LICENSE file for details.
Credits
- Built with Textual by Textualize
- Inspired by Vim by Bram Moolenaar
- Created and maintained by contributors
Links
- Documentation: Full documentation
- Examples: Example applications
- Issues: Bug reports and feature requests
- Discussions: Community discussions
Made with ❤️ for the Textual community
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_vimkeys_input-0.3.2.tar.gz.
File metadata
- Download URL: textual_vimkeys_input-0.3.2.tar.gz
- Upload date:
- Size: 116.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4edb1d0af5a4d107c4cb15de2279449b2e8a47f717eeb1b87a8ce2c940e9f0d8
|
|
| MD5 |
7bd6d260c21571e80f780a847bd588af
|
|
| BLAKE2b-256 |
8a0fc32bf6fdcf8bcef554d4366f3a360297bc362a64d858074d24b534310d75
|
Provenance
The following attestation bundles were made for textual_vimkeys_input-0.3.2.tar.gz:
Publisher:
cd.yml on jcronq/textual-vimkeys-input
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
textual_vimkeys_input-0.3.2.tar.gz -
Subject digest:
4edb1d0af5a4d107c4cb15de2279449b2e8a47f717eeb1b87a8ce2c940e9f0d8 - Sigstore transparency entry: 660845871
- Sigstore integration time:
-
Permalink:
jcronq/textual-vimkeys-input@1b0a42795fc1f9641688919a46413b28ca02e905 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/jcronq
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cd.yml@1b0a42795fc1f9641688919a46413b28ca02e905 -
Trigger Event:
push
-
Statement type:
File details
Details for the file textual_vimkeys_input-0.3.2-py3-none-any.whl.
File metadata
- Download URL: textual_vimkeys_input-0.3.2-py3-none-any.whl
- Upload date:
- Size: 29.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8fad9616fdafe6e38065c72c87b2766957f573c694051a2990e7a2297b6210ee
|
|
| MD5 |
d41b6f45bb10c907f635d47e0463c639
|
|
| BLAKE2b-256 |
60ccb1207ece30359ecd8c72a2d25cc5ee5f37c81dd8774e16b16b656f847e7c
|
Provenance
The following attestation bundles were made for textual_vimkeys_input-0.3.2-py3-none-any.whl:
Publisher:
cd.yml on jcronq/textual-vimkeys-input
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
textual_vimkeys_input-0.3.2-py3-none-any.whl -
Subject digest:
8fad9616fdafe6e38065c72c87b2766957f573c694051a2990e7a2297b6210ee - Sigstore transparency entry: 660845880
- Sigstore integration time:
-
Permalink:
jcronq/textual-vimkeys-input@1b0a42795fc1f9641688919a46413b28ca02e905 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/jcronq
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cd.yml@1b0a42795fc1f9641688919a46413b28ca02e905 -
Trigger Event:
push
-
Statement type: