feat: wrap selection in markdown formatting (TASK-009)
c69bdbcade3275f8eebfa174c8e3339f4b2d3465
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 15:28
parent 85134254
feat: wrap selection in markdown formatting (TASK-009) Alt+s/i/c/k wrap the selection in bold/italic/code/link, toggling off when already wrapped; no selection inserts the empty pair with the cursor between. Markers go into the buffer (markup-visible invariant). Single undo group. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
6 files changed
README.md +1 −0
@@ -51,6 +51,7 @@ | mouse wheel | scroll the view |
| `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) |
| `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 |
- → Wrap-selection-in-markdown-formatting.md +10 −4
@@ -4,7 +4,7 @@ title: Wrap selection in markdown formatting
status: "\U0001F7E2 In progress"
assignee: []
created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-29 22:27'
labels:
- feature
- release-1
@@ -21,7 +21,13 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 Selection wraps in **/_/code/link via shortcuts; toggles off when already wrapped
-- [ ] #2 No selection inserts the pair with the cursor inside
-- [ ] #3 Bindings chosen to not clash with existing keys; documented
+- [x] #1 Selection wraps in **/_/code/link via shortcuts; toggles off when already wrapped
+- [x] #2 No selection inserts the pair with the cursor inside
+- [x] #3 Bindings chosen to not clash with existing keys; documented
<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Impl editor inline-format ops (internal/editor/format.go): WrapBold/WrapItalic/WrapCode (symmetric toggle, markers inside or outside selection) + WrapLink ([sel]() cursor in parens, toggle off full link). No selection inserts empty pair, cursor between. Bound Alt+s(bold/strong) Alt+i(italic) Alt+c(code) Alt+k(link) — no clash with Alt+b/f/d or Ctrl set; routed via editor dispatch, single undo group (editKindOf structural). Documented in README Keys table. 10 TDD tests in format_test.go; full suite green.
+<!-- SECTION:NOTES:END -->
internal/editor/editor.go +8 −0
@@ -478,6 +478,14 @@ case "f":
e.MoveWordRight()
case "d":
e.DeleteWordRight()
+ case "s": // bold (strong)
+ e.WrapBold()
+ case "i": // italic
+ e.WrapItalic()
+ case "c": // inline code
+ e.WrapCode()
+ case "k": // link
+ e.WrapLink()
}
return
}
internal/editor/format.go +138 −0
@@ -0,0 +1,138 @@
+package editor
+
+import "strings"
+
+// Inline markdown formatting: wrap (or unwrap) the selection in markers, keeping
+// the markup visible in the buffer (TASK-009). With no selection a marker pair is
+// inserted with the cursor between. Symmetric markers (bold/italic/code) toggle
+// off when the selection is already wrapped — either with the markers inside the
+// selection or sitting immediately outside it.
+
+// WrapBold wraps the selection in ** (toggles off if already bold).
+func (e *Editor) WrapBold() { e.toggleWrap("**") }
+
+// WrapItalic wraps the selection in _ (toggles off if already italic).
+func (e *Editor) WrapItalic() { e.toggleWrap("_") }
+
+// WrapCode wraps the selection in ` (toggles off if already inline code).
+func (e *Editor) WrapCode() { e.toggleWrap("`") }
+
+func (e *Editor) toggleWrap(marker string) {
+ if !e.HasSelection() {
+ e.insertPair(marker, marker, len([]rune(marker)))
+ return
+ }
+ start, end, _ := e.selRange()
+ n := len([]rune(marker))
+ inner := e.SelectedText()
+ // Toggle off: markers inside the selection.
+ if len([]rune(inner)) >= 2*n && strings.HasPrefix(inner, marker) && strings.HasSuffix(inner, marker) {
+ stripped := string([]rune(inner)[n : len([]rune(inner))-n])
+ e.replaceRange(start, end, stripped)
+ newEnd := Position{Row: start.Row, Col: start.Col + len([]rune(stripped))}
+ e.setSelection(start, newEnd)
+ return
+ }
+ // Toggle off: markers immediately outside the selection (single line).
+ if start.Row == end.Row && e.markersOutside(start, end, marker) {
+ line := []rune(e.Lines[start.Row])
+ newLine := string(line[:start.Col-n]) + string(line[start.Col:end.Col]) + string(line[end.Col+n:])
+ e.Lines[start.Row] = newLine
+ e.Dirty = true
+ ns := Position{Row: start.Row, Col: start.Col - n}
+ ne := Position{Row: end.Row, Col: end.Col - n}
+ e.setSelection(ns, ne)
+ return
+ }
+ // Wrap: insert marker after the selection, then before it.
+ e.insertAt(end, marker)
+ e.insertAt(start, marker)
+ ns := Position{Row: start.Row, Col: start.Col + n}
+ var ne Position
+ if start.Row == end.Row {
+ ne = Position{Row: end.Row, Col: end.Col + n}
+ } else {
+ ne = end
+ }
+ e.setSelection(ns, ne)
+}
+
+// WrapLink turns the selection into [sel](), cursor inside the parens; with no
+// selection inserts [](), cursor inside the brackets. Toggles a full
+// [text](url) selection back to its label text.
+func (e *Editor) WrapLink() {
+ if !e.HasSelection() {
+ e.insertPair("[", "]()", 1) // cursor between [ and ]
+ return
+ }
+ start, end, _ := e.selRange()
+ inner := e.SelectedText()
+ // Toggle off: selection is a complete [label](url) — unwrap to the label.
+ if start.Row == end.Row && strings.HasPrefix(inner, "[") && strings.HasSuffix(inner, ")") {
+ if i := strings.Index(inner, "]("); i >= 0 {
+ label := inner[1:i]
+ e.replaceRange(start, end, label)
+ e.setSelection(start, Position{Row: start.Row, Col: start.Col + len([]rune(label))})
+ return
+ }
+ }
+ e.insertAt(end, "]()")
+ e.insertAt(start, "[")
+ // Park the cursor inside the parens, just before the closing ).
+ if start.Row == end.Row {
+ e.Cursor = Position{Row: end.Row, Col: end.Col + 3} // +"[" +"]("
+ } else {
+ e.Cursor = Position{Row: end.Row, Col: end.Col + 2}
+ }
+ e.anchor = nil
+ e.setGoal()
+ e.followCursor()
+}
+
+// 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) {
+ pos := e.Cursor
+ line := []rune(e.Lines[pos.Row])
+ newLine := string(line[:pos.Col]) + left + right + string(line[pos.Col:])
+ e.Lines[pos.Row] = newLine
+ e.Dirty = true
+ e.Cursor = Position{Row: pos.Row, Col: pos.Col + offset}
+ e.anchor = nil
+ e.setGoal()
+ e.followCursor()
+}
+
+// insertAt inserts s at position p (single-line column splice).
+func (e *Editor) insertAt(p Position, s string) {
+ line := []rune(e.Lines[p.Row])
+ e.Lines[p.Row] = string(line[:p.Col]) + s + string(line[p.Col:])
+ e.Dirty = true
+}
+
+// markersOutside reports whether marker sits immediately before start and after
+// end on the same line.
+func (e *Editor) markersOutside(start, end Position, marker string) bool {
+ n := len([]rune(marker))
+ line := []rune(e.Lines[start.Row])
+ if start.Col < n || end.Col+n > len(line) {
+ return false
+ }
+ return string(line[start.Col-n:start.Col]) == marker && string(line[end.Col:end.Col+n]) == marker
+}
+
+// replaceRange replaces the text in [start,end) (same row) with s.
+func (e *Editor) replaceRange(start, end Position, s string) {
+ line := []rune(e.Lines[start.Row])
+ e.Lines[start.Row] = string(line[:start.Col]) + s + string(line[end.Col:])
+ e.Dirty = true
+}
+
+// setSelection anchors at a and parks the cursor at b.
+func (e *Editor) setSelection(a, b Position) {
+ c := a
+ e.anchor = &c
+ e.Cursor = b
+ e.setGoal()
+ e.followCursor()
+}
internal/editor/format_test.go +128 −0
@@ -0,0 +1,128 @@
+package editor
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// sel sets a selection from (ar,ac) to (cr,cc) (anchor → cursor).
+func sel(e *Editor, ar, ac, cr, cc int) {
+ a := Position{Row: ar, Col: ac}
+ e.anchor = &a
+ e.Cursor = Position{Row: cr, Col: cc}
+}
+
+func TestWrapBoldNoSelectionInsertsPair(t *testing.T) {
+ e := newEditorWith("")
+ e.WrapBold()
+ if e.Lines[0] != "****" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "****")
+ }
+ if e.Cursor.Col != 2 {
+ t.Fatalf("Cursor.Col = %d, want 2 (between markers)", e.Cursor.Col)
+ }
+}
+
+func TestWrapBoldWrapsSelection(t *testing.T) {
+ e := newEditorWith("word")
+ sel(e, 0, 0, 0, 4)
+ e.WrapBold()
+ if e.Lines[0] != "**word**" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "**word**")
+ }
+ if got := e.SelectedText(); got != "word" {
+ t.Fatalf("selection = %q, want inner %q", got, "word")
+ }
+}
+
+func TestWrapBoldTogglesOffMarkersInsideSelection(t *testing.T) {
+ e := newEditorWith("**word**")
+ sel(e, 0, 0, 0, 8)
+ e.WrapBold()
+ if e.Lines[0] != "word" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "word")
+ }
+ if got := e.SelectedText(); got != "word" {
+ t.Fatalf("selection = %q, want %q", got, "word")
+ }
+}
+
+func TestWrapBoldTogglesOffMarkersOutsideSelection(t *testing.T) {
+ e := newEditorWith("**word**")
+ sel(e, 0, 2, 0, 6) // select just "word", markers sit outside
+ e.WrapBold()
+ if e.Lines[0] != "word" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "word")
+ }
+ if got := e.SelectedText(); got != "word" {
+ t.Fatalf("selection = %q, want %q", got, "word")
+ }
+}
+
+func TestWrapItalicUsesUnderscore(t *testing.T) {
+ e := newEditorWith("hi")
+ sel(e, 0, 0, 0, 2)
+ e.WrapItalic()
+ if e.Lines[0] != "_hi_" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "_hi_")
+ }
+}
+
+func TestWrapCodeUsesBacktick(t *testing.T) {
+ e := newEditorWith("x")
+ sel(e, 0, 0, 0, 1)
+ e.WrapCode()
+ if e.Lines[0] != "`x`" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "`x`")
+ }
+}
+
+func TestWrapLinkWrapsSelectionCursorInParens(t *testing.T) {
+ e := newEditorWith("text")
+ sel(e, 0, 0, 0, 4)
+ e.WrapLink()
+ if e.Lines[0] != "[text]()" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "[text]()")
+ }
+ // cursor parked inside the () — just before the closing paren
+ if e.Cursor.Col != 7 {
+ t.Fatalf("Cursor.Col = %d, want 7 (inside parens)", e.Cursor.Col)
+ }
+ if e.HasSelection() {
+ t.Fatal("link wrap should collapse selection")
+ }
+}
+
+func TestWrapLinkNoSelectionCursorInBrackets(t *testing.T) {
+ e := newEditorWith("")
+ e.WrapLink()
+ if e.Lines[0] != "[]()" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "[]()")
+ }
+ if e.Cursor.Col != 1 {
+ t.Fatalf("Cursor.Col = %d, want 1 (inside brackets)", e.Cursor.Col)
+ }
+}
+
+func TestWrapLinkTogglesOff(t *testing.T) {
+ e := newEditorWith("[text](url)")
+ sel(e, 0, 0, 0, 11)
+ e.WrapLink()
+ if e.Lines[0] != "text" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "text")
+ }
+}
+
+func TestAltSKeyBoldsAsOneUndoGroup(t *testing.T) {
+ e := newEditorWith("word")
+ sel(e, 0, 0, 0, 4)
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s"), Alt: true})
+ if e.Lines[0] != "**word**" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "**word**")
+ }
+ e.Undo()
+ if e.Lines[0] != "word" {
+ t.Fatalf("after undo Lines[0] = %q, want %q", e.Lines[0], "word")
+ }
+}
internal/editor/undo.go +4 −1
@@ -123,7 +123,10 @@ func editKindOf(k tea.KeyMsg) (editKind, bool) {
switch k.Type {
case tea.KeyRunes:
if k.Alt {
- if string(k.Runes) == "d" { // Alt+D deletes the next word
+ switch string(k.Runes) {
+ case "d": // Alt+D deletes the next word
+ return kindStructural, true
+ case "s", "i", "c", "k": // inline markdown formatting (TASK-009)
return kindStructural, true
}
return kindNone, false // Alt+b/f are word motions