fix: checkbox mouse-click toggle (row map, callouts, stale render)
af9053189bfea7a7159061d0ca95bed300fc7e1c
humdrum <me@humdrum.me> · 2026-07-06 12:31
parent 0ec536da
fix: checkbox mouse-click toggle (row map, callouts, stale render) Mouse clicks failed to check off boxes for three separate reasons, all fixed here: - Off-by-one row map: paintCanvas turned the body's trailing newline into a phantom blank row, so View emitted height+1 lines and the alt-screen scrolled up one — clicks landed a row off. Trim the trailing newline so the canvas is exactly height-1 rows (TASK-038). - Stale render: ToggleCheckbox mutated the buffer but never dropped the memoized visual cache, so the flip never repainted on the mouse path (Alt+x worked only because HandleKey invalidates). ToggleCheckbox now invalidates itself (TASK-038). - Callouts: parseListItem now recognizes a leading '>' blockquote/callout prefix (incl. nested '> >'), so "> - [ ]" toggles via Alt+x and click, and list continuation preserves the "> " prefix (TASK-039). Tests: rendered-view toggle regression, mid-document row map, blockquote parse/toggle/continuation, callout box click. Help + README + CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9 files changed
CHANGELOG.md +10 −0
@@ -7,6 +7,9 @@
## [Unreleased]
### Added
+- Checkboxes inside `>` blockquotes and Obsidian callouts (`> - [ ] task`,
+ including nested `> >`) now toggle with `Alt+x` and a box click, and list
+ continuation preserves the `> ` prefix (TASK-039).
- Inline spellcheck: a red curly underline (undercurl) under misspelled prose,
backed by an embedded ~60k common-English dictionary (pure Go, no cgo). Code,
inline code, URLs, wikilinks, link targets, and frontmatter are never flagged;
@@ -18,6 +21,13 @@
### Changed
- Vertical cursor moves (Up/Down) and clicks rebuild the visual model once per
keystroke instead of twice.
+
+### Fixed
+- Mouse clicks landed one visual row off: the canvas painted one row too many
+ (a body's trailing newline became a phantom blank row), so `View` emitted
+ height+1 lines and the alt-screen scrolled up by one. The canvas is now
+ exactly height-1 rows, so clicks — including checkbox toggles — map to the row
+ under the pointer (TASK-038).
## [0.1.0] — 2026-06-28
README.md +1 −1
@@ -52,7 +52,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) |
+| `Alt+x` · click the box | toggle the checkbox on the current line `- [ ]` ⟷ `- [x]`, including checkboxes inside `>` blockquotes/callouts (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) |
| mouse wheel | scroll the view |
- → Fix-mouse-checkbox-toggle-off-by-one-click-lands-one-row-below.md +25 −0
@@ -0,0 +1,25 @@
+---
+id: TASK-038
+title: 'Fix: mouse checkbox toggle off-by-one (click lands one row below)'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-06 18:51'
+updated_date: '2026-07-06 18:55'
+labels:
+ - bug
+dependencies: []
+priority: high
+ordinal: 37000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+handleMouse vi = Scroll + msg.Y - topPad + 1 has a spurious +1. Every left click maps one visual row too low; checkbox toggle only worked when the box was the last visual row (MoveToVisual clamp rescued it). Drop the +1.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 Clicking a checkbox glyph on any row toggles that row's box
+- [x] #2 Regression test covers a mid-document checkbox with rows below it
+<!-- AC:END -->
- → Feat-checkboxes-inside-blockquotes-callouts-toggle-via-Altx-and-mouse.md +28 −0
@@ -0,0 +1,28 @@
+---
+id: TASK-039
+title: >-
+ Feat: checkboxes inside blockquotes/callouts (> - [ ]) toggle via Alt+x and
+ mouse
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-06 18:51'
+updated_date: '2026-07-06 18:55'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 38000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+parseListItem does not recognize a leading '>' so checkboxes inside Obsidian callouts (> [!Tasks]) never toggle. Teach parseListItem an optional blockquote prefix (one or more '>' each optionally followed by a space); Alt+x and mouse click both inherit it via parseListItem.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 Alt+x toggles a '> - [ ] x' checkbox line
+- [x] #2 Mouse click on the box glyph inside a callout toggles it
+- [x] #3 List continuation preserves the '> ' prefix
+<!-- AC:END -->
internal/app/app.go +7 −2
@@ -233,7 +233,7 @@ 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
+ vi := a.editor.Scroll + msg.Y - a.topPad()
col := msg.X - a.leftMargin()
switch msg.Action {
case tea.MouseActionPress:
@@ -1029,7 +1029,12 @@ rows := make([]string, 0, a.height)
for p := 0; p < a.topPad(); p++ {
rows = append(rows, bg.Render(""))
}
- for _, ln := range strings.Split(body, "\n") {
+ // A body that ends in "\n" (editor.View always does) would split into a
+ // trailing empty field and paint one blank row too many — pushing the whole
+ // canvas to `height` rows, so View emits height+1 lines and the alt-screen
+ // scrolls up by one (which threw mouse-Y mapping off by a row). Trim the one
+ // trailing newline so the canvas is exactly height-1 rows.
+ for _, ln := range strings.Split(strings.TrimSuffix(body, "\n"), "\n") {
if ln == "" {
rows = append(rows, bg.Render(""))
} else {
internal/app/app_test.go +53 −4
@@ -554,10 +554,10 @@ a.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
a.editor.SetContent([]byte("first line\nsecond line\nthird line"))
a.editor.Cursor.Row, a.editor.Cursor.Col = 0, 0
lm := a.leftMargin()
- // Click the 2nd editor row, column 3. vi = Scroll + Y - topPad + 1, so the
- // 2nd row (vi=1, Scroll=0) is at screen Y = topPad.
+ // Click the 2nd editor row, column 3. Body row 0 renders at screen Y =
+ // topPad, so row 1 (vi=1, Scroll=0) is at screen Y = topPad+1.
a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
- X: lm + 3, Y: a.topPad()})
+ X: lm + 3, Y: a.topPad() + 1})
if a.editor.Cursor.Row != 1 {
t.Errorf("click row → cursor row %d, want 1", a.editor.Cursor.Row)
}
@@ -730,7 +730,7 @@ 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
+ y := a.topPad() // the first body row renders at screen row topPad
// 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" {
@@ -739,6 +739,55 @@ }
a.editor.Undo()
if got := a.editor.Lines[0]; got != "- [ ] task" {
t.Fatalf("box click not undoable: line = %q", got)
+ }
+}
+
+// TASK-038: a click must map to the exact row clicked, not one row below. The
+// checkbox here is mid-document with rows beneath it, so the MoveToVisual clamp
+// cannot mask an off-by-one.
+func TestMouseClickChecksCorrectRow(t *testing.T) {
+ a := newApp()
+ a.theme = theme.FlexokiDark()
+ a.editor.SetTheme(theme.FlexokiDark())
+ a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+ a.editor.SetContent([]byte("line one\n- [ ] task two\nline three\nline four"))
+ lm := a.leftMargin()
+ y := a.topPad() + 1 // checkbox is body row 1
+ a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 3, Y: y})
+ if got := a.editor.Lines[1]; got != "- [x] task two" {
+ t.Fatalf("mid-document box click: row 1 = %q, want %q", got, "- [x] task two")
+ }
+}
+
+// TASK-038: a box click must actually re-render — the toggle mutates the buffer
+// but must also drop the memoized visual cache, or the flip never shows.
+func TestMouseClickCheckboxUpdatesRenderedView(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"))
+ _ = a.View() // prime the visual cache with the unchecked box
+ lm := a.leftMargin()
+ a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 3, Y: a.topPad()})
+ if got := a.View(); !strings.Contains(got, "[x]") || strings.Contains(got, "[ ]") {
+ t.Fatalf("rendered view did not reflect the toggle (still shows unchecked box)")
+ }
+}
+
+// TASK-039: a checkbox inside a callout toggles on a box click.
+func TestMouseClickChecksBlockquoteCheckbox(t *testing.T) {
+ a := newApp()
+ a.theme = theme.FlexokiDark()
+ a.editor.SetTheme(theme.FlexokiDark())
+ a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+ a.editor.SetContent([]byte("> [!Tasks]-\n> - [ ] Call Costco"))
+ lm := a.leftMargin()
+ y := a.topPad() + 1 // the "> - [ ] ..." line is body row 1
+ // "> - [ ] Call Costco": '[' at rune col 4; click col 5 (inside the box).
+ a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 5, Y: y})
+ if got := a.editor.Lines[1]; got != "> - [x] Call Costco" {
+ t.Fatalf("callout box click: row 1 = %q, want %q", got, "> - [x] Call Costco")
}
}
internal/editor/lists.go +17 −1
@@ -19,13 +19,14 @@ // listItem is a parsed list line: its leading whitespace, marker, and the full
// prefix (everything up to the start of the content).
type listItem struct {
indent string // leading spaces/tabs
+ quote string // blockquote/callout prefix ("> ", "> > ", …); "" if none
marker string // "-", "*", "+", or the ordered number text ("1")
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 (+ "[ ] ")
+ prefix string // indent + quote + marker + delim + space (+ "[ ] ")
}
func isDigit(r rune) bool { return r >= '0' && r <= '9' }
@@ -41,6 +42,19 @@ i++
}
var it listItem
it.indent = string(rs[:i])
+
+ // Optional blockquote / callout prefix: one or more '>' each optionally
+ // followed by a single space, so "> - [ ] x" and "> > 1. [ ]" inside an
+ // Obsidian callout still parse as list items (TASK-039).
+ q := i
+ for i < len(rs) && rs[i] == '>' {
+ i++
+ if i < len(rs) && rs[i] == ' ' {
+ i++
+ }
+ }
+ it.quote = string(rs[q:i])
+
if i >= len(rs) {
return listItem{}, false
}
@@ -92,6 +106,7 @@ // bullet, the next number for ordered lists, and an unchecked box for checkboxes.
func (it listItem) continuationPrefix() string {
var b strings.Builder
b.WriteString(it.indent)
+ b.WriteString(it.quote)
if it.ordered {
n, _ := strconv.Atoi(it.marker)
b.WriteString(strconv.Itoa(n + 1))
@@ -170,6 +185,7 @@ rs[it.boxStart+1] = 'x'
}
e.Lines[e.Cursor.Row] = string(rs)
e.Dirty = true
+ e.invalidate() // drop the memoized visual model so the flip actually renders
return true
}
internal/editor/lists_test.go +55 −0
@@ -255,3 +255,58 @@ if e.OnCheckboxBracket() {
t.Fatal("cursor in the content wrongly detected as on the box")
}
}
+
+// --- TASK-039: checkboxes inside blockquotes / callouts (> - [ ]) ---
+
+func TestToggleCheckboxInBlockquote(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("> - [ ] Call Costco"))
+ e.Cursor = Position{Row: 0, Col: 10} // mid-content
+ if !e.ToggleCheckbox() {
+ t.Fatal("ToggleCheckbox returned false on a blockquote checkbox line")
+ }
+ if got := e.Lines[0]; got != "> - [x] Call Costco" {
+ t.Fatalf("line = %q, want %q", got, "> - [x] Call Costco")
+ }
+}
+
+func TestToggleCheckboxNestedBlockquote(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("> > 1. [x] deep"))
+ e.Cursor = Position{Row: 0, Col: 0}
+ if !e.ToggleCheckbox() {
+ t.Fatal("ToggleCheckbox returned false on a nested blockquote checkbox")
+ }
+ if got := e.Lines[0]; got != "> > 1. [ ] deep" {
+ t.Fatalf("line = %q, want %q", got, "> > 1. [ ] deep")
+ }
+}
+
+func TestOnCheckboxBracketInBlockquote(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("> - [ ] task"))
+ // "> - [ ] task": '[' at rune col 4.
+ e.Cursor = Position{Row: 0, Col: 5}
+ if !e.OnCheckboxBracket() {
+ t.Fatal("cursor on the box glyph inside a blockquote not detected")
+ }
+}
+
+func TestBlockquoteListContinuationKeepsQuote(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("> - first"))
+ e.Cursor = Position{Row: 0, Col: len("> - first")}
+ enter(e)
+ if got := e.Lines[1]; got != "> - " {
+ t.Fatalf("continued line = %q, want %q", got, "> - ")
+ }
+}
+
+func TestCalloutHeaderIsNotAListItem(t *testing.T) {
+ // The callout header line itself has no marker after the '>' — not a list.
+ for _, s := range []string{"> [!Tasks]-", "> just a quote", ">"} {
+ if _, ok := parseListItem(s); ok {
+ t.Errorf("parseListItem(%q) = ok, want not a list", s)
+ }
+ }
+}
internal/help/help.go +2 −2
@@ -29,8 +29,8 @@ EDITOR KEYS
Enter (on a list) continue the list marker (numbers increment, checkboxes
reset); Enter on an empty item exits the list
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
+ Alt+x toggle the checkbox on the current line (- [ ] / - [x],
+ incl. inside > callouts); clicking the box glyph works 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)