feat: surround selection by typing markdown punctuation (TASK-009)
466af178186f08d6be8ddc647f44323073a77693
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 16:11
parent 4254a57c
feat: surround selection by typing markdown punctuation (TASK-009)
With a selection, typing * _ ` [ ( { < " ' or ~ now wraps the selection in
that pair instead of replacing it, keeping the selection so repeats nest
(* then * → **). Complements the Alt+s/i/c/k bindings. Documented in README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj 4 files changed
README.md +1 −0
@@ -52,6 +52,7 @@ | `Home` / `End` | start / end of line |
| `Shift`+arrows · `Shift+Home/End` | select text (`Ctrl+Shift+←/→` selects by word) |
| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | copy / cut / paste (system clipboard) |
| `Alt+s` / `Alt+i` / `Alt+c` / `Alt+k` | wrap the selection: **bold** `**` · _italic_ `_` · `code` `` ` `` · link `[sel]()` (toggles off if already wrapped; no selection inserts the empty pair) |
+| type `*` `_` `` ` `` `[` `(` `{` `<` `"` `'` with a selection | surround the selection with that punctuation (repeat to nest, e.g. `*`→`**` for bold) |
| `Alt+←` / `Alt+→` | move by word (also `Alt+b` / `Alt+f`) |
| `Alt+Backspace` / `Ctrl+W` | delete the word before the cursor (`Alt+d` deletes the word after) |
| `Ctrl+U` / `Ctrl+K` | delete to start / end of line |
internal/editor/editor.go +8 −0
@@ -489,6 +489,14 @@ e.WrapLink()
}
return
}
+ // With a selection, typing a wrap punctuation surrounds it (nests on
+ // repeat) instead of replacing it.
+ if e.HasSelection() && len(k.Runes) == 1 {
+ if rc, ok := wrapPair(k.Runes[0]); ok {
+ e.surroundSelection(string(k.Runes[0]), string(rc))
+ return
+ }
+ }
e.replaceSelection()
for _, r := range k.Runes {
e.InsertRune(r)
internal/editor/format.go +37 −0
@@ -89,6 +89,43 @@ e.setGoal()
e.followCursor()
}
+// wrapPair maps an opening punctuation rune to its closing rune for
+// surround-on-type: with a selection, typing one of these wraps the selection
+// instead of replacing it (repeated presses nest, e.g. * then * → **).
+func wrapPair(r rune) (rune, bool) {
+ switch r {
+ case '*', '_', '`', '"', '\'', '~':
+ return r, true
+ case '(':
+ return ')', true
+ case '[':
+ return ']', true
+ case '{':
+ return '}', true
+ case '<':
+ return '>', true
+ }
+ return 0, false
+}
+
+// surroundSelection wraps the active selection in left/right, leaving the
+// selection on the inner text so repeated wraps nest.
+func (e *Editor) surroundSelection(left, right string) {
+ start, end, ok := e.selRange()
+ if !ok {
+ return
+ }
+ e.insertAt(end, right)
+ e.insertAt(start, left)
+ nL := len([]rune(left))
+ ns := Position{Row: start.Row, Col: start.Col + nL}
+ ne := end
+ if start.Row == end.Row {
+ ne = Position{Row: end.Row, Col: end.Col + nL}
+ }
+ e.setSelection(ns, ne)
+}
+
// insertPair inserts left+right at the cursor and parks the cursor `offset`
// runes past the original position (i.e. between the two halves).
func (e *Editor) insertPair(left, right string, offset int) {
internal/editor/format_test.go +58 −0
@@ -149,3 +149,61 @@ if _, _, ok := e.SelectionStats(); ok {
t.Fatal("SelectionStats ok should be false with no selection")
}
}
+
+func TestTypingStarWrapsSelection(t *testing.T) {
+ e := newEditorWith("word")
+ sel(e, 0, 0, 0, 4)
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("*")})
+ if e.Lines[0] != "*word*" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "*word*")
+ }
+ if e.SelectedText() != "word" {
+ t.Fatalf("selection = %q, want inner %q", e.SelectedText(), "word")
+ }
+}
+
+func TestTypingStarTwiceNestsToBold(t *testing.T) {
+ e := newEditorWith("word")
+ sel(e, 0, 0, 0, 4)
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("*")})
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("*")})
+ if e.Lines[0] != "**word**" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "**word**")
+ }
+}
+
+func TestTypingBracketWrapsSelection(t *testing.T) {
+ e := newEditorWith("word")
+ sel(e, 0, 0, 0, 4)
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[")})
+ if e.Lines[0] != "[word]" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "[word]")
+ }
+}
+
+func TestTypingParenWrapsSelection(t *testing.T) {
+ e := newEditorWith("word")
+ sel(e, 0, 0, 0, 4)
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("(")})
+ if e.Lines[0] != "(word)" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "(word)")
+ }
+}
+
+func TestTypingWrapCharNoSelectionInsertsNormally(t *testing.T) {
+ e := newEditorWith("ab")
+ e.Cursor = Position{Row: 0, Col: 1}
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("*")})
+ if e.Lines[0] != "a*b" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "a*b")
+ }
+}
+
+func TestTypingNonWrapCharReplacesSelection(t *testing.T) {
+ e := newEditorWith("word")
+ sel(e, 0, 0, 0, 4)
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
+ if e.Lines[0] != "x" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "x")
+ }
+}