A keyboard-first, soft-wrapping multi-line text editor component for Bubble Tea.
ripple is a self-contained editing surface — selection, undo/redo,
system-clipboard copy/cut/paste, word movement, and grapheme-width-aware soft
wrap — with no opinion about how the host frames or styles it. It was extracted
from TideMail's compose editor.
go get github.com/allisonhere/rippleEmbed ripple.Model in your own model, size it, route messages through
Update, and render with View:
type app struct{ ed ripple.Model }
func (a app) Init() tea.Cmd { return nil }
func (a app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.ed.SetSize(msg.Width, msg.Height)
return a, nil
case tea.KeyMsg:
if msg.Type == tea.KeyEsc {
return a, tea.Quit
}
}
var cmd tea.Cmd
a.ed, cmd = a.ed.Update(msg)
return a, cmd
}
func (a app) View() string {
return a.ed.View(ripple.Options{Cursor: "█"})
}Update returns the updated model and a command, like any Bubble Tea component.
If your host has textarea-style separate sizing hooks, use SetWidth and
SetHeight; they preserve the other dimension and avoid temporary one-row
viewport jumps while a compose surface is being laid out. Prefer SetSize when
both dimensions are known together.
Copy/cut/paste emit their clipboard side effect as a tea.Cmd, so it travels
back with the model. Provide a backend with SetClipboard; without one those
keys are inert:
type osClipboard struct{}
func (osClipboard) Read() (string, error) { return clipboard.ReadAll() }
func (osClipboard) Write(s string) error { return clipboard.WriteAll(s) }
ed.SetClipboard(osClipboard{})ctrl+v reads the clipboard and feeds the text back through Update as a
ripple.PasteMsg; forward all messages to the focused editor and it is handled
for you.
The editor owns wrapping: it fits every visual line to the width passed to
SetSize. Do not re-wrap the string returned by View — measuring it with
a different width model can disagree on wide runes and overflow. Clip or pad
each line to the configured width instead.
| Action | Default |
|---|---|
| Move | arrows; ctrl+arrows by word; home/end; ctrl+home/end |
| Select | shift+movement; ctrl+shift+arrows by word |
| Select all | ctrl+a |
| Undo / redo | ctrl+z / ctrl+y |
| Copy / cut / paste | ctrl+c / ctrl+x / ctrl+v |
| Page | pgup / pgdn |
Editing commands are configurable via KeyMap and SetKeyMap. Text input and
cursor movement are fixed.
Modal (vim-style) editing is opt-in — the default is conventional insert-only editing, so existing callers are unaffected:
ed.SetInputMode(ripple.ModeVim) // ModePlain (default) restores insert-onlyIt adds Normal, Insert, Visual, Visual-line, and a : command line. Mode()
returns the active sub-mode label ("NORMAL", "INSERT", …) for a status
indicator, and CommandLine() returns the : text being typed.
| Group | Keys |
|---|---|
| Modes | i a o O I A enter Insert; v / V visual; : command; Esc leaves |
| Motions | h j k l, w b e, 0 ^ $, gg G, counts (3j); arrows also move |
| Edits | x, dd, yy, p/P, D, C, s; d/c/y + motion (dw, cw, d$, …) |
| History | u undo, ctrl+r redo |
Host intents. The editor stays content-agnostic: :w / :wq / :x emit a
ripple.SubmitMsg and :q (or a second Esc from Normal mode) emits a
ripple.CancelMsg, both delivered as ordinary messages via the command Update
returns. The host decides what "submit" and "cancel" mean:
case ripple.SubmitMsg:
return a, a.send() // e.g. send the message
case ripple.CancelMsg:
return a, a.close() // e.g. confirm-and-closeClipboard. In vim mode y/d/c/x mirror to the wired clipboard and
p/P read it (vim's unnamedplus), so yank/paste and ctrl+c/ctrl+v share
one buffer; the in-editor register is the fallback when no clipboard is wired.
Cursor. Set Options.CursorRune to style the character under the caret in
place (a block cursor that still shows its glyph) for Normal/Visual mode, while
Insert can keep a bar via Options.Cursor.
A runnable harness lives in cmd/rippletest (go run ./cmd/rippletest; F2
toggles vim, Ctrl+Q quits).
ripple doesn't know about any particular language, but a host that does can
color the document with Options.StyleKey and Options.Style. StyleKey
classifies the rune at a document offset — token kind, color name, whatever the
host's tokenizer produces — and View batches consecutive same-keyed runes
into a single Style call, exactly the way Options.Selected already receives
a whole run instead of one rune at a time:
ed.View(ripple.Options{
Cursor: "█",
StyleKey: func(offset int) string {
return tokenKindAt(doc, offset) // e.g. "key", "string", "comment", ""
},
Style: func(key, text string) string {
switch key {
case "key":
return keyStyle.Render(text)
case "string":
return stringStyle.Render(text)
case "comment":
return commentStyle.Render(text)
default:
return text
}
},
})Runs also split wherever the selection state changes, so a Style call always
wraps a self-contained, uniformly-styled fragment — a full SGR reset per call
(what lipgloss.Style.Render already does) is safe and won't clobber a
selection or cursor layered on top of highlighted text.
MIT (see repository).