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

feat: mouse select, toggle checkbox, move list items.

abf7406ce11ebadb5350112a960195db492e8d7b
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 21:30

parent a985cb79

16 files changed

README.md +1 −0
@@ -49,6 +49,7 @@ | --- | --- |
 | type / arrows / `Enter` / `Backspace` / `Del` | edit and move (Up/Down move by visual line) |
 | `Enter` on a list item | continue the list (`-`/`*`/`+`, `N.`/`N)`, `- [ ]` checkboxes; numbers increment); empty item exits the list |
 | `Tab` / `Shift+Tab` | indent / outdent the current list item |
+| `Alt+↑` / `Alt+↓` | move the current line up / down (reorder list items; the cursor follows, so repeats walk it; one undo step per move) |
 | `Alt+x` · click the box | toggle the checkbox on the current line `- [ ]` ⟷ `- [x]` (no-op off a checkbox line; key works with the cursor anywhere on the line) |
 | mouse click | move the cursor |
 | mouse drag | select text (press to anchor, drag to extend, release to keep) |
- → Toggle-checkbox-state-with-a-key-x.md +23 −7
@@ -1,10 +1,10 @@
 ---
 id: TASK-023
 title: 'Toggle checkbox state with a key (- [ ] <-> - [x])'
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 21:08'
-updated_date: '2026-06-30 00:21'
+updated_date: '2026-06-30 04:40'
 labels:
   - feature
   - release-1
@@ -21,12 +21,28 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 A keybind toggles the current line's checkbox [ ] <-> [x]
-- [ ] #2 No-op on lines without a checkbox
-- [ ] #3 README/help document the key
-- [ ] #4 Mouse click on the checkbox glyph toggles it
-- [ ] #5 The toggle keybind works with the cursor anywhere on the line (not just on the brackets)
+- [x] #1 A keybind toggles the current line's checkbox [ ] <-> [x]
+- [x] #2 No-op on lines without a checkbox
+- [x] #3 README/help document the key
+- [x] #4 Mouse click on the checkbox glyph toggles it
+- [x] #5 The toggle keybind works with the cursor anywhere on the line (not just on the brackets)
 <!-- AC:END -->
+
+
+
+
+
+
+
+
+
+
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. RED+GREEN editor.ToggleCheckbox() (lists.go): flip [ ]<->[x] on cursor line any col; no-op non-checkbox. checkboxRange helper via parseListItem boxStart. 2. OnCheckboxBracket() for mouse-glyph hit. 3. Wire Alt+x keybind (dispatch Alt-rune switch + editKindOf kindStructural=undoable). 4. app.handleMouse: press on checkbox glyph -> PushUndo+ToggleCheckbox. 5. Docs README+help.go.
+<!-- SECTION:PLAN:END -->
 
 ## Implementation Notes
 
- → Move-line-up-down-reorder-list-items-cursor-follows.md +7 −7
@@ -1,10 +1,10 @@
 ---
 id: TASK-024
 title: 'Move line up / down (reorder list items, cursor follows)'
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 21:43'
-updated_date: '2026-06-30 00:21'
+updated_date: '2026-06-30 04:46'
 labels:
   - feature
   - release-1
@@ -21,9 +21,9 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 A keybind moves the current line up, swapping with the line above; cursor stays on the moved line
-- [ ] #2 A keybind moves the current line down, swapping with the line below; cursor stays on the moved line
-- [ ] #3 No-op at the top (move up) / bottom (move down) of the document
-- [ ] #4 Each move is one undo step; cursor column is preserved
-- [ ] #5 README/help document the keys
+- [x] #1 A keybind moves the current line up, swapping with the line above; cursor stays on the moved line
+- [x] #2 A keybind moves the current line down, swapping with the line below; cursor stays on the moved line
+- [x] #3 No-op at the top (move up) / bottom (move down) of the document
+- [x] #4 Each move is one undo step; cursor column is preserved
+- [x] #5 README/help document the keys
 <!-- AC:END -->
- → Mouse-drag-select-in-the-editor.md +20 −6
@@ -1,10 +1,10 @@
 ---
 id: TASK-027
 title: Mouse drag-select in the editor
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 23:21'
-updated_date: '2026-06-30 00:21'
+updated_date: '2026-06-30 04:40'
 labels:
   - feature
   - release-1
@@ -21,8 +21,22 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Left press anchors a selection at the click position
-- [ ] #2 Dragging extends the selection to the pointer (soft-wrap aware)
-- [ ] #3 Release leaves the selection active; a plain click with no drag clears it and just moves the cursor
-- [ ] #4 README/help mouse-select claims match actual behavior
+- [x] #1 Left press anchors a selection at the click position
+- [x] #2 Dragging extends the selection to the pointer (soft-wrap aware)
+- [x] #3 Release leaves the selection active; a plain click with no drag clears it and just moves the cursor
+- [x] #4 README/help mouse-select claims match actual behavior
 <!-- AC:END -->
+
+
+
+
+
+
+
+
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. RED+GREEN editor mouse-select methods: MouseAnchor(vi,col)=MoveToVisual+ClearSelection; MouseExtendTo(vi,col)=startSelection+MoveToVisual (anchor persists across motions). 2. app.handleMouse: Press->MouseAnchor; Motion->MouseExtendTo; plain click no drag leaves no selection. 3. Docs README+help.go mouse drag-select.
+<!-- SECTION:PLAN:END -->
internal/app/app.go +30 −8
@@ -83,6 +83,8 @@ 	height     int
 
 	quitArmed bool           // true after a dirty Ctrl+Q, awaiting confirm
 	pending   pendingDiscard // armed open-while-dirty confirmation
+
+	mouseDragged bool // a drag motion happened since the last left-press (TASK-027)
 }
 
 // New builds an App with an empty editor.
@@ -206,14 +208,34 @@ // handleMouse moves the cursor on a left click and scrolls on wheel events.
 func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
 	const step = 3
 	if msg.Button == tea.MouseButtonLeft {
-		if msg.Action == tea.MouseActionPress && a.mode == ModeEditor {
-			// The first editor visual row sits at screen row topPad; a click at
-			// screen row Y is that many rows into the viewport.
-			vi := a.editor.Scroll + msg.Y - a.topPad() + 1
-			col := msg.X - a.leftMargin()
-			a.editor.MoveToVisual(vi, col)
-			// Clicking a flagged (underlined) word opens its suggestion popup.
-			a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col)
+		if a.mode != ModeEditor {
+			return a, nil
+		}
+		// The first editor visual row sits at screen row topPad; a click at
+		// screen row Y is that many rows into the viewport.
+		vi := a.editor.Scroll + msg.Y - a.topPad() + 1
+		col := msg.X - a.leftMargin()
+		switch msg.Action {
+		case tea.MouseActionPress:
+			// Anchor the cursor (dropping any prior selection) for a click or
+			// the start of a drag (TASK-027).
+			a.mouseDragged = false
+			a.editor.MouseAnchor(vi, col)
+			// A press on a checkbox glyph toggles it, undoably (TASK-023).
+			if a.editor.OnCheckboxBracket() {
+				a.editor.PushUndo()
+				a.editor.ToggleCheckbox()
+			}
+		case tea.MouseActionMotion:
+			// Dragging extends the selection from the press anchor (TASK-027).
+			a.mouseDragged = true
+			a.editor.MouseExtendTo(vi, col)
+		case tea.MouseActionRelease:
+			// A plain click (no drag) on a flagged word opens its suggestion
+			// popup; a drag selects instead, so only pop on a clean click.
+			if !a.mouseDragged {
+				a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col)
+			}
 		}
 		return a, nil
 	}
internal/app/app_test.go +67 −0
@@ -689,3 +689,70 @@ 	if a.editor.FindCount() != 0 {
 		t.Errorf("Esc should clear find, got %d", a.editor.FindCount())
 	}
 }
+
+// --- TASK-027: mouse drag-select ---
+
+func TestMouseDragSelectsText(t *testing.T) {
+	a := newApp()
+	a.theme = theme.FlexokiDark()
+	a.editor.SetTheme(theme.FlexokiDark())
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
+	a.editor.SetContent([]byte("hello world"))
+	lm := a.leftMargin()
+	y := a.topPad() - 1 // first editor row (vi=0)
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 2, Y: y})
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: lm + 7, Y: y})
+	if got := a.editor.SelectedText(); got != "llo w" {
+		t.Fatalf("drag selection = %q, want %q", got, "llo w")
+	}
+}
+
+func TestMousePlainClickLeavesNoSelection(t *testing.T) {
+	a := newApp()
+	a.theme = theme.FlexokiDark()
+	a.editor.SetTheme(theme.FlexokiDark())
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
+	a.editor.SetContent([]byte("hello world"))
+	lm := a.leftMargin()
+	y := a.topPad() - 1
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 4, Y: y})
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease, X: lm + 4, Y: y})
+	if a.editor.HasSelection() {
+		t.Fatal("a plain click left a selection active")
+	}
+}
+
+// --- TASK-023: mouse click on the checkbox glyph toggles it ---
+
+func TestMouseClickOnCheckboxToggles(t *testing.T) {
+	a := newApp()
+	a.theme = theme.FlexokiDark()
+	a.editor.SetTheme(theme.FlexokiDark())
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
+	a.editor.SetContent([]byte("- [ ] task"))
+	lm := a.leftMargin()
+	y := a.topPad() - 1
+	// Box glyph occupies cols 2..4; click col 3 (inside the brackets).
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 3, Y: y})
+	if got := a.editor.Lines[0]; got != "- [x] task" {
+		t.Fatalf("after box click line = %q, want %q", got, "- [x] task")
+	}
+	a.editor.Undo()
+	if got := a.editor.Lines[0]; got != "- [ ] task" {
+		t.Fatalf("box click not undoable: line = %q", got)
+	}
+}
+
+func TestMouseClickPastCheckboxDoesNotToggle(t *testing.T) {
+	a := newApp()
+	a.theme = theme.FlexokiDark()
+	a.editor.SetTheme(theme.FlexokiDark())
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
+	a.editor.SetContent([]byte("- [ ] task"))
+	lm := a.leftMargin()
+	y := a.topPad() - 1
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 8, Y: y})
+	if got := a.editor.Lines[0]; got != "- [ ] task" {
+		t.Fatalf("click on content toggled the box: line = %q", got)
+	}
+}
internal/app/spell_test.go +33 −0
@@ -107,3 +107,36 @@ 	if !a.editor.SpellEnabled() {
 		t.Error("toggle did not re-enable spellcheck")
 	}
 }
+
+// A plain click (press then release) on a misspelled word opens its popup
+// (TASK-027: the popup must wait for release so a drag can win instead).
+func TestMouseClickOnMisspellingOpensPopup(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("a recieve here"))
+	lm := a.leftMargin()
+	y := a.topPad() - 1
+	x := lm + 4 // inside "recieve"
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: x, Y: y})
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease, X: x, Y: y})
+	if a.mode != ModeSpell {
+		t.Fatalf("plain click on a misspelling: mode = %d, want ModeSpell", a.mode)
+	}
+}
+
+// Dragging from a misspelled word selects text and must NOT pop the spell popup.
+func TestMouseDragFromMisspellingSelectsNotPopup(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("a recieve here"))
+	lm := a.leftMargin()
+	y := a.topPad() - 1
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 4, Y: y})
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: lm + 9, Y: y})
+	if a.mode == ModeSpell {
+		t.Fatal("drag from a misspelling opened the spell popup")
+	}
+	if !a.editor.HasSelection() {
+		t.Fatal("drag from a misspelling did not select")
+	}
+}
internal/editor/editor.go +10 −0
@@ -597,6 +597,8 @@ 			case "c": // inline code
 				e.WrapCode()
 			case "k": // link
 				e.WrapLink()
+			case "x": // toggle checkbox (TASK-023)
+				e.ToggleCheckbox()
 			}
 			return
 		}
@@ -680,9 +682,17 @@ 		} else {
 			e.MoveRight()
 		}
 	case tea.KeyUp:
+		if k.Alt {
+			e.MoveLineUp() // reorder the current line (TASK-024)
+			return
+		}
 		e.ClearSelection()
 		e.MoveUp()
 	case tea.KeyDown:
+		if k.Alt {
+			e.MoveLineDown() // reorder the current line (TASK-024)
+			return
+		}
 		e.ClearSelection()
 		e.MoveDown()
 	case tea.KeyHome:
internal/editor/lists.go +32 −0
@@ -24,6 +24,7 @@ 	delim    string // "" for bullets; "." or ")" for ordered
 	ordered  bool
 	checkbox bool
 	checked  bool
+	boxStart int    // rune index of '[' when checkbox; otherwise 0
 	prefix   string // indent + marker + delim + space (+ "[ ] ")
 }
 
@@ -72,10 +73,12 @@ 		switch {
 		case i+3 < len(rs) && rs[i+3] == ' ':
 			it.checkbox = true
 			it.checked = rs[i+1] != ' '
+			it.boxStart = i
 			i += 4
 		case i+3 == len(rs):
 			it.checkbox = true
 			it.checked = rs[i+1] != ' '
+			it.boxStart = i
 			i += 3
 		}
 	}
@@ -149,6 +152,35 @@ 	e.Dirty = true
 	e.setGoal()
 	e.followCursor()
 	return true
+}
+
+// ToggleCheckbox flips the checkbox on the cursor's line between [ ] and [x]
+// (TASK-023). The cursor's column on the line doesn't matter. Returns false
+// (no-op) on lines without a checkbox.
+func (e *Editor) ToggleCheckbox() bool {
+	it, ok := parseListItem(e.Lines[e.Cursor.Row])
+	if !ok || !it.checkbox {
+		return false
+	}
+	rs := []rune(e.Lines[e.Cursor.Row])
+	if it.checked {
+		rs[it.boxStart+1] = ' '
+	} else {
+		rs[it.boxStart+1] = 'x'
+	}
+	e.Lines[e.Cursor.Row] = string(rs)
+	e.Dirty = true
+	return true
+}
+
+// OnCheckboxBracket reports whether the cursor sits on the "[ ]"/"[x]" glyph of
+// a checkbox list line — used to toggle on a mouse click of the box (TASK-023).
+func (e *Editor) OnCheckboxBracket() bool {
+	it, ok := parseListItem(e.Lines[e.Cursor.Row])
+	if !ok || !it.checkbox {
+		return false
+	}
+	return e.Cursor.Col >= it.boxStart && e.Cursor.Col <= it.boxStart+2
 }
 
 // IndentLine prepends one indent unit to the current line (Tab on a list item).
internal/editor/lists_test.go +56 −0
@@ -199,3 +199,59 @@ 			t.Errorf("parseListItem(%q) = ok, want not a list", s)
 		}
 	}
 }
+
+// --- TASK-023: checkbox toggle ---
+
+func TestToggleCheckboxFlipsUnchecked(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- [ ] task"))
+	e.Cursor = Position{Row: 0, Col: 8} // cursor mid-content, not on brackets
+	if !e.ToggleCheckbox() {
+		t.Fatal("ToggleCheckbox returned false on a checkbox line")
+	}
+	if got := e.Lines[0]; got != "- [x] task" {
+		t.Fatalf("line = %q, want %q", got, "- [x] task")
+	}
+	if !e.Dirty {
+		t.Fatal("toggle did not mark the buffer dirty")
+	}
+}
+
+func TestToggleCheckboxFlipsChecked(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("  1. [X] done"))
+	e.Cursor = Position{Row: 0, Col: 0}
+	e.ToggleCheckbox()
+	if got := e.Lines[0]; got != "  1. [ ] done" {
+		t.Fatalf("line = %q, want %q", got, "  1. [ ] done")
+	}
+}
+
+func TestToggleCheckboxNoOpOnPlainLine(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- a bullet, no box"))
+	e.Cursor = Position{Row: 0, Col: 3}
+	if e.ToggleCheckbox() {
+		t.Fatal("ToggleCheckbox returned true on a non-checkbox line")
+	}
+	if got := e.Lines[0]; got != "- a bullet, no box" {
+		t.Fatalf("line mutated to %q", got)
+	}
+	if e.Dirty {
+		t.Fatal("no-op toggle marked the buffer dirty")
+	}
+}
+
+func TestOnCheckboxBracketDetectsGlyph(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- [ ] task"))
+	// "- [ ] task": brackets at rune cols 2,3,4.
+	e.Cursor = Position{Row: 0, Col: 3}
+	if !e.OnCheckboxBracket() {
+		t.Fatal("cursor on the box glyph not detected")
+	}
+	e.Cursor = Position{Row: 0, Col: 8}
+	if e.OnCheckboxBracket() {
+		t.Fatal("cursor in the content wrongly detected as on the box")
+	}
+}
internal/editor/mouseselect_test.go +63 −0
@@ -0,0 +1,63 @@
+package editor
+
+import (
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+// --- TASK-027: mouse drag-select ---
+
+func TestMouseAnchorMovesCursorNoSelection(t *testing.T) {
+	e := New()
+	e.SetSize(80, 10)
+	e.SetContent([]byte("hello world"))
+	e.MouseExtendTo(0, 5) // pretend a stale selection exists
+	e.MouseAnchor(0, 3)
+	if e.HasSelection() {
+		t.Fatal("MouseAnchor left a selection active")
+	}
+	if e.Cursor.Col != 3 {
+		t.Fatalf("cursor col = %d, want 3", e.Cursor.Col)
+	}
+}
+
+func TestMouseExtendCreatesSelectionFromAnchor(t *testing.T) {
+	e := New()
+	e.SetSize(80, 10)
+	e.SetContent([]byte("hello world"))
+	e.MouseAnchor(0, 2)
+	e.MouseExtendTo(0, 7)
+	if got := e.SelectedText(); got != "llo w" {
+		t.Fatalf("selection = %q, want %q", got, "llo w")
+	}
+}
+
+func TestMouseExtendKeepsOriginalAnchorAcrossMotions(t *testing.T) {
+	e := New()
+	e.SetSize(80, 10)
+	e.SetContent([]byte("hello world"))
+	e.MouseAnchor(0, 2)
+	e.MouseExtendTo(0, 4)
+	e.MouseExtendTo(0, 9) // a second drag motion must not re-anchor at col 4
+	if got := e.SelectedText(); got != "llo wor" {
+		t.Fatalf("selection = %q, want %q", got, "llo wor")
+	}
+}
+
+// --- TASK-023: Alt+x keybind toggles + is undoable ---
+
+func TestAltXTogglesCheckboxUndoable(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- [ ] task"))
+	e.Cursor = Position{Row: 0, Col: 9}
+	altX := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x"), Alt: true}
+	e.HandleKey(altX)
+	if got := e.Lines[0]; got != "- [x] task" {
+		t.Fatalf("after Alt+x line = %q, want %q", got, "- [x] task")
+	}
+	e.Undo()
+	if got := e.Lines[0]; got != "- [ ] task" {
+		t.Fatalf("after undo line = %q, want %q", got, "- [ ] task")
+	}
+}
internal/editor/moveline.go +39 −0
@@ -0,0 +1,39 @@
+package editor
+
+// MoveLineUp swaps the current line with the line above, keeping the cursor on
+// the moved line (same column). No-op at the top of the document. Returns true
+// if a swap happened (TASK-024).
+func (e *Editor) MoveLineUp() bool {
+	r := e.Cursor.Row
+	if r <= 0 {
+		return false
+	}
+	e.Lines[r-1], e.Lines[r] = e.Lines[r], e.Lines[r-1]
+	e.Cursor.Row = r - 1
+	e.afterMoveLine()
+	return true
+}
+
+// MoveLineDown swaps the current line with the line below, keeping the cursor on
+// the moved line (same column). No-op at the bottom of the document. Returns
+// true if a swap happened (TASK-024).
+func (e *Editor) MoveLineDown() bool {
+	r := e.Cursor.Row
+	if r >= len(e.Lines)-1 {
+		return false
+	}
+	e.Lines[r+1], e.Lines[r] = e.Lines[r], e.Lines[r+1]
+	e.Cursor.Row = r + 1
+	e.afterMoveLine()
+	return true
+}
+
+// afterMoveLine clamps the column to the moved line and refreshes editor state.
+func (e *Editor) afterMoveLine() {
+	if n := len([]rune(e.Lines[e.Cursor.Row])); e.Cursor.Col > n {
+		e.Cursor.Col = n
+	}
+	e.Dirty = true
+	e.setGoal()
+	e.followCursor()
+}
internal/editor/moveline_test.go +106 −0
@@ -0,0 +1,106 @@
+package editor
+
+import (
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func altUp(e *Editor)   { e.HandleKey(tea.KeyMsg{Type: tea.KeyUp, Alt: true}) }
+func altDown(e *Editor) { e.HandleKey(tea.KeyMsg{Type: tea.KeyDown, Alt: true}) }
+
+func TestMoveLineUpSwapsWithLineAbove(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a\nb\nc"))
+	e.Cursor = Position{Row: 1, Col: 1}
+	if !e.MoveLineUp() {
+		t.Fatal("MoveLineUp returned false")
+	}
+	if e.Lines[0] != "b" || e.Lines[1] != "a" {
+		t.Fatalf("lines = %q, want [b a c]", e.Lines)
+	}
+	if e.Cursor.Row != 0 || e.Cursor.Col != 1 {
+		t.Fatalf("cursor = %+v, want row 0 col 1 (follows the moved line)", e.Cursor)
+	}
+}
+
+func TestMoveLineDownSwapsWithLineBelow(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a\nb\nc"))
+	e.Cursor = Position{Row: 1, Col: 1}
+	if !e.MoveLineDown() {
+		t.Fatal("MoveLineDown returned false")
+	}
+	if e.Lines[1] != "c" || e.Lines[2] != "b" {
+		t.Fatalf("lines = %q, want [a c b]", e.Lines)
+	}
+	if e.Cursor.Row != 2 || e.Cursor.Col != 1 {
+		t.Fatalf("cursor = %+v, want row 2 col 1", e.Cursor)
+	}
+}
+
+func TestMoveLineUpNoOpAtTop(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a\nb"))
+	e.Cursor = Position{Row: 0, Col: 0}
+	if e.MoveLineUp() {
+		t.Fatal("MoveLineUp at top should be a no-op")
+	}
+	if e.Lines[0] != "a" || e.Lines[1] != "b" {
+		t.Fatalf("lines = %q, want unchanged", e.Lines)
+	}
+}
+
+func TestMoveLineDownNoOpAtBottom(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a\nb"))
+	e.Cursor = Position{Row: 1, Col: 0}
+	if e.MoveLineDown() {
+		t.Fatal("MoveLineDown at bottom should be a no-op")
+	}
+	if e.Lines[0] != "a" || e.Lines[1] != "b" {
+		t.Fatalf("lines = %q, want unchanged", e.Lines)
+	}
+}
+
+// Repeated presses walk a line several positions in one direction.
+func TestMoveLineUpRepeatsWalkUp(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a\nb\nc\nd"))
+	e.Cursor = Position{Row: 3, Col: 0} // "d"
+	altUp(e)
+	altUp(e)
+	altUp(e)
+	if e.Lines[0] != "d" {
+		t.Fatalf("lines = %q, want d walked to top", e.Lines)
+	}
+	if e.Cursor.Row != 0 {
+		t.Fatalf("cursor row = %d, want 0", e.Cursor.Row)
+	}
+}
+
+// Each move is one undo step that reverts exactly one swap.
+func TestMoveLineDownIsOneUndoStep(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a\nb\nc"))
+	e.Cursor = Position{Row: 0, Col: 0}
+	altDown(e)
+	if e.Lines[0] != "b" || e.Lines[1] != "a" {
+		t.Fatalf("after move lines = %q, want [b a c]", e.Lines)
+	}
+	e.Undo()
+	if e.Lines[0] != "a" || e.Lines[1] != "b" || e.Lines[2] != "c" {
+		t.Fatalf("after undo lines = %q, want [a b c]", e.Lines)
+	}
+}
+
+// A move past the document edge records no undo checkpoint (no-op key).
+func TestMoveLineUpAtTopRecordsNoUndo(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a\nb"))
+	e.Cursor = Position{Row: 0, Col: 0}
+	altUp(e)
+	if len(e.undo) != 0 {
+		t.Fatalf("undo stack = %d, want 0 (no-op move records nothing)", len(e.undo))
+	}
+}
internal/editor/selection.go +15 −0
@@ -59,6 +59,21 @@
 // ClearSelection drops any active selection.
 func (e *Editor) ClearSelection() { e.anchor = nil }
 
+// MouseAnchor moves the cursor to a visual click position and drops any active
+// selection — the press that begins a click or a drag (TASK-027).
+func (e *Editor) MouseAnchor(vi, col int) {
+	e.MoveToVisual(vi, col)
+	e.ClearSelection()
+}
+
+// MouseExtendTo extends a mouse selection to the drag point, anchoring at the
+// press position on the first motion and holding that anchor across subsequent
+// motions (TASK-027). Soft-wrap aware via MoveToVisual.
+func (e *Editor) MouseExtendTo(vi, col int) {
+	e.startSelection()
+	e.MoveToVisual(vi, col)
+}
+
 // startSelection anchors a selection at the current cursor if none is active.
 func (e *Editor) startSelection() {
 	if e.anchor == nil {
internal/editor/undo.go +7 −0
@@ -128,6 +128,8 @@ 			case "d": // Alt+D deletes the next word
 				return kindStructural, true
 			case "s", "i", "c", "k": // inline markdown formatting (TASK-009)
 				return kindStructural, true
+			case "x": // toggle the checkbox on the current line (TASK-023)
+				return kindStructural, true
 			}
 			return kindNone, false // Alt+b/f are word motions
 		}
@@ -136,6 +138,11 @@ 	case tea.KeySpace:
 		return kindType, true
 	case tea.KeyEnter, tea.KeyTab, tea.KeyShiftTab:
 		return kindStructural, true
+	case tea.KeyUp, tea.KeyDown:
+		if k.Alt { // Alt+Up/Down move (reorder) the current line (TASK-024)
+			return kindStructural, true
+		}
+		return kindNone, false
 	case tea.KeyBackspace, tea.KeyDelete, tea.KeyCtrlU, tea.KeyCtrlK, tea.KeyCtrlW:
 		return kindStructural, true
 	default:
internal/help/help.go +2 −0
@@ -29,6 +29,8 @@   Tab / Shift+Tab       indent / outdent the current list item
   Alt+x                 toggle the checkbox on the current line (- [ ] / - [x]);
                         clicking the box glyph toggles it too
   mouse drag            select text (press to anchor, drag to extend)
+  Alt+Up / Alt+Down     move the current line up / down (reorder list items;
+                        the cursor follows the line, so repeats walk it)
   Ctrl+S                save (an unnamed buffer prompts for a name)
   Ctrl+E                export a printable HTML doc (house style) and open it in
                         the browser → Print → Save as PDF