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