▍ humdrum codex / glint v1.0.2
license AGPL-3.0

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