Documentation
¶
Overview ¶
Package vimbubble adds vim-style modal editing to a charmbracelet/bubbles textarea.
The package is built to drop in alongside any Bubble Tea app already using bubbles/textarea for input. You keep your textarea; vimbubble adds a NORMAL/INSERT mode wrapper around it.
Quick start ¶
import (
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/guygrigsby/vimbubble"
)
type model struct {
ta textarea.Model
vim *vimbubble.Modal
}
func initialModel() model {
ta := textarea.New()
m := model{ta: ta}
m.vim = vimbubble.New(&m.ta) // disabled by default
m.vim.SetEnabled(true) // turn on; starts in NORMAL
return m
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok {
// Modal eats the key when it has a binding for it; otherwise
// the textarea handles it normally.
if consumed, cmds := m.vim.Update(k); consumed {
return m, tea.Batch(cmds...)
}
}
var cmd tea.Cmd
m.ta, cmd = m.ta.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.vim.ModeLabel() + "\n" + m.ta.View()
}
Supported commands ¶
NORMAL mode (text in the composer is not modified by ordinary keys — only the bindings below act):
h / l cursor left / right
j / k cursor down / up
w / b word forward / backward
0 / $ line start / end
gg / G buffer top / bottom
x delete character forward
D delete to end of line
~ toggle case of char under cursor + advance
r<x> replace char under cursor with <x>
dd delete whole line
dw delete word forward
d$ delete to end of line
d0 delete to start of line
cw change word (delete + INSERT)
c$ change to end of line (delete + INSERT)
c0 change to start of line (delete + INSERT)
cc empty the line + INSERT
ciw change inside word — works from any column within
the word + INSERT
caw change a word + its surrounding whitespace + INSERT
diw delete inside word (stays NORMAL)
daw delete a word + its surrounding whitespace
i / a INSERT at / after cursor
I / A INSERT at line start / end
o / O open new line below / above + INSERT
/ : switch to INSERT and insert the literal character
(lets host apps reach a slash-command palette
without first pressing `i`)
INSERT mode passes every key through to the textarea, except Esc which returns to NORMAL.
What's not (yet) implemented ¶
Visual mode, registers, the undo stack, search (/<query>), jump list, find-character motions (f / t / F / T), text objects (iw / aw / it / ...). Open an issue or PR.
Example ¶
Example shows the typical wiring inside a Bubble Tea Update loop: route key events through the Modal first, fall back to the textarea for anything it doesn't consume.
package main
import (
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/guygrigsby/vimbubble"
)
func main() {
ta := textarea.New()
vim := vimbubble.New(&ta)
vim.SetEnabled(true) // off by default; opt in.
update := func(msg tea.KeyMsg) tea.Cmd {
if consumed, cmds := vim.Update(msg); consumed {
return tea.Batch(cmds...)
}
var cmd tea.Cmd
ta, cmd = ta.Update(msg)
return cmd
}
_ = update
}
Output:
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Modal ¶
type Modal struct {
// contains filtered or unexported fields
}
Modal wraps a textarea.Model with modal editing. Caller owns the textarea — Modal doesn't allocate it and never replaces it. Modal only reads + mutates the textarea's value + cursor.
func New ¶
New returns a Modal attached to ta. Starts disabled.
The textarea's cursor row/col are read via reflect+unsafe (Bubbles doesn't expose them publicly), so a future bubbles release that renames those fields would silently break ~ / r / cw. The TestComposerCursorReadable test pins the contract.
Example ¶
ExampleNew shows that a Modal starts disabled. Every key flows through to the textarea untouched until SetEnabled(true).
package main
import (
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/guygrigsby/vimbubble"
)
func main() {
ta := textarea.New()
vim := vimbubble.New(&ta)
// Disabled by default: vim.Update never consumes, so the host
// textarea handles every key.
consumed, _ := vim.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
_ = consumed // always false until SetEnabled(true).
}
Output:
func (*Modal) IsEnabled ¶
IsEnabled reports whether vim is active. Equivalent to v.Mode() != Disabled. Provided as a convenience for callers that want to gate behaviour without typing the comparison.
func (*Modal) ModeLabel ¶
ModeLabel returns a styled label like "-- NORMAL --" suitable for rendering near the composer. Empty when disabled — callers can switch on the empty string to decide whether to render anything.
func (*Modal) SetEnabled ¶
SetEnabled flips vim mode on/off. Enabling lands the composer in Normal (matches the way vim itself opens). Disabling clears any pending chord/operator state.
Example ¶
ExampleModal_SetEnabled toggles vim mode on and off, e.g. backing a /vim slash command in the host app. Enabling lands the composer in NORMAL; disabling clears any pending chord/operator state.
package main
import (
"github.com/charmbracelet/bubbles/textarea"
"github.com/guygrigsby/vimbubble"
)
func main() {
ta := textarea.New()
vim := vimbubble.New(&ta)
vim.SetEnabled(true) // enters NORMAL.
vim.SetEnabled(false) // back to pass-through.
}
Output:
func (*Modal) SetMode ¶
SetMode forces the mode. Useful when a host app needs to bring the composer into Insert after running a command (e.g. dropping into edit-mode) or wants to drop back to Normal after a slash-command finishes. SetMode(Disabled) clears pending state the same way SetEnabled(false) does.
func (*Modal) Update ¶
Update routes a key event through vim. Returns (consumed, cmds):
- consumed=true → vim handled this key. The caller should NOT forward msg to the textarea. cmds is a slice of any commands vim needs the Bubble Tea runtime to run (today: usually nil).
- consumed=false → vim isn't interested in this key. The caller handles it as it would have without vim (e.g. textarea.Update).
Disabled mode never consumes — Modal is invisible until enabled.
Example ¶
ExampleModal_Update demonstrates the (consumed, cmds) contract. When consumed is true, the caller must NOT forward the key to its textarea — vim has already handled it (and possibly synthesised key events into the textarea on the caller's behalf).
package main
import (
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/guygrigsby/vimbubble"
)
func main() {
ta := textarea.New()
vim := vimbubble.New(&ta)
vim.SetEnabled(true)
// In NORMAL mode, 'h' translates to a KeyLeft inside vim.Update
// and the textarea's cursor moves. consumed=true so we stop here.
consumed, cmds := vim.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
if consumed {
// Run any commands vim emitted (rare today).
_ = tea.Batch(cmds...)
return
}
// Not consumed: hand the key to the textarea as usual.
ta, _ = ta.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
_ = ta
}
Output:
type Mode ¶
type Mode uint8
Mode is the current vim mode. The zero value is Disabled, meaning vimbubble passes every key through untouched.
const ( // Disabled is the off-switch: vimbubble.Update never consumes a // key, so the host textarea behaves like a plain text input. Disabled Mode = iota // Normal is vim's command mode: arrow-key cursor motion + the // vim verbs (x, dw, cw, ~, r, …). Letter keys are swallowed // rather than inserted so a stray "x" doesn't paste an x into // the buffer. Normal // Insert is plain typing: every key passes through to the // textarea except Esc, which returns the user to Normal. Insert )