vimbubble

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 19, 2026 License: MIT Imports: 7 Imported by: 0

README

vimbubble

ci Go Reference

Vim-style modal editing for charmbracelet/bubbles textareas.

Drop it next to an existing textarea.Model, route key events through it, get NORMAL/INSERT modes and the verbs you'd expect — i, a, o, x, ~, r, dw, cw, c$, cc, and friends.

go get github.com/guygrigsby/vimbubble

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 initial() model {
    ta := textarea.New()
    m := model{ta: ta}
    m.vim = vimbubble.New(&m.ta)
    m.vim.SetEnabled(true)   // off by default; opt in
    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; otherwise the
        // textarea handles it as usual.
        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()
}

vim.SetEnabled(false) disables vim mode entirely — every key passes through to the textarea unmodified, perfect for a /vim toggle in your app.

Supported commands

NORMAL mode
Key What it does
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 + advance
r<x> replace char under cursor with <x>
dd delete whole line
dw delete word forward
d$ d0 delete to end / start of line
cw change word (delete + INSERT)
c$ c0 change to end / start of line
cc empty the line + INSERT
ciw change inside word (any cursor position) + INSERT
caw change a word + its surrounding space + INSERT
diw delete inside word
daw delete a word + its surrounding space
i a INSERT at / after cursor
I A INSERT at line start / end
o O open new line below / above + INSERT
/ : INSERT mode + insert literal / (lets host apps reach a slash-command palette without first pressing i)
INSERT mode

Every key passes through to the textarea, except Esc which returns to NORMAL.

What's not (yet) here

  • Visual mode (v, V)
  • Registers ("a, "+)
  • Undo / redo
  • Search (/<query> as a search, not a passthrough)
  • Find-character motions (f, t, F, T)
  • Text objects (iw, it, i", …)
  • Jump list (Ctrl+O, Ctrl+I)

These are reasonable next chunks. Open an issue if you want one, or send a PR.

How it works

vimbubble works by translating NORMAL-mode commands into the key events the textarea already understands (so h becomes KeyLeft, w becomes Alt+f, etc.), plus a handful of direct manipulations for verbs the textarea doesn't expose (~, r, cw). The cursor row/col are read via reflect+unsafe because Bubbles doesn't export them — a regression test (TestComposerCursorReadable) pins this contract so a future Bubbles release that renames the fields fails loudly instead of silently no-op'ing.

License

MIT

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
}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

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

func New(ta *textarea.Model) *Modal

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).
}

func (*Modal) IsEnabled

func (v *Modal) IsEnabled() bool

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

func (v *Modal) Mode() Mode

Mode returns the current mode.

func (*Modal) ModeLabel

func (v *Modal) ModeLabel() string

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

func (v *Modal) SetEnabled(on bool)

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

func (*Modal) SetMode

func (v *Modal) SetMode(m Mode)

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

func (v *Modal) Update(msg tea.KeyMsg) (consumed bool, cmds []tea.Cmd)

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
}

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
)

func (Mode) String

func (m Mode) String() string

String returns a short human-readable label ("disabled", "normal", "insert"). For a styled footer label, see (*Modal).ModeLabel.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL