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

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)