feat: mouse-wheel scrolls the view (editor/preview/picker)
e6085a1f0df7171c726ada9e4a593c826e506abf
humdrum <me@humdrum.me> · 2026-06-28 18:37
parent 497744e2
feat: mouse-wheel scrolls the view (editor/preview/picker) Enable bubbletea mouse support and treat wheel events as a viewport scroll instead of cursor movement: the editor scrolls by visual rows without moving the cursor, the preview forwards to its viewport, and the picker moves the selection. Previously the trackpad sent arrow keys and just moved the cursor.
7 files changed
README.md +1 −0
@@ -38,6 +38,7 @@
| Key | Action |
| --- | --- |
| type / arrows / `Enter` / `Backspace` / `Del` | edit and move (Up/Down move by visual line) |
+| mouse wheel | scroll the view (hold `Option`/`Shift` to select text while mouse mode is on) |
| `Ctrl+U` / `Ctrl+K` | delete to start / end of line (map `Cmd+Delete`→`Ctrl+U` in your terminal) |
| `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
| `Ctrl+P` | toggle the Glamour read preview |
- → Wrapped-line-vertical-move-can-skip-a-row-at-a-trailing-space-boundary.md +2 −1
@@ -1,9 +1,10 @@
---
id: TASK-003
title: Wrapped-line vertical move can skip a row at a trailing-space boundary
-status: "\U0001F7E6 Backlog"
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-28 18:38'
+updated_date: '2026-06-29 01:18'
labels:
- bug
dependencies: []
internal/app/app.go +31 −0
@@ -126,6 +126,37 @@ a.setSize(msg.Width, msg.Height)
return a, nil
case tea.KeyMsg:
return a.handleKey(msg)
+ case tea.MouseMsg:
+ return a.handleMouse(msg)
+ }
+ return a, nil
+}
+
+// handleMouse scrolls the active view on wheel events without moving the cursor.
+func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
+ const step = 3
+ var delta int
+ switch msg.Button {
+ case tea.MouseButtonWheelUp:
+ delta = -step
+ case tea.MouseButtonWheelDown:
+ delta = step
+ default:
+ return a, nil
+ }
+ switch a.mode {
+ case ModePreview:
+ return a, a.preview.Update(msg) // the viewport handles wheel scrolling
+ case ModePicker:
+ key := tea.KeyMsg{Type: tea.KeyDown}
+ if delta < 0 {
+ key.Type = tea.KeyUp
+ }
+ for i := 0; i < step; i++ {
+ a.picker.Update(key)
+ }
+ default: // editor / save-as
+ a.editor.ScrollBy(delta)
}
return a, nil
}
internal/app/app_test.go +16 −0
@@ -530,3 +530,19 @@ if sel := a.picker.Selected(); !strings.HasPrefix(sel, vault) {
t.Errorf("vault picker selected %q, want a file under the vault %q", sel, vault)
}
}
+
+func TestMouseWheelScrollsEditorNotCursor(t *testing.T) {
+ a := newApp()
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 8})
+ for i := 0; i < 40; i++ {
+ a.editor.Lines = append(a.editor.Lines, "line")
+ }
+ a.editor.Cursor.Row, a.editor.Cursor.Col = 0, 0
+ a.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown})
+ if a.editor.Scroll == 0 {
+ t.Error("wheel down should scroll the editor viewport")
+ }
+ if a.editor.Cursor.Row != 0 || a.editor.Cursor.Col != 0 {
+ t.Errorf("wheel should not move the cursor; got %+v", a.editor.Cursor)
+ }
+}
internal/editor/editor.go +16 −0
@@ -84,6 +84,22 @@ e.setGoal()
e.followCursor()
}
+// ScrollBy moves the viewport by delta visual rows (negative = up) without
+// moving the cursor, clamped so it never scrolls past the content.
+func (e *Editor) ScrollBy(delta int) {
+ max := len(e.buildVisual()) - e.Height
+ if max < 0 {
+ max = 0
+ }
+ e.Scroll += delta
+ if e.Scroll < 0 {
+ e.Scroll = 0
+ }
+ if e.Scroll > max {
+ e.Scroll = max
+ }
+}
+
// KillToLineEnd deletes from the cursor to the end of the line (Ctrl+K).
func (e *Editor) KillToLineEnd() {
rs := e.curLine()
internal/editor/editor_test.go +18 −0
@@ -342,3 +342,21 @@ if e.Cursor.Row != 1 {
t.Errorf("Cursor.Row = %d, want 1", e.Cursor.Row)
}
}
+
+func TestScrollByClamps(t *testing.T) {
+ e := New()
+ for i := 0; i < 20; i++ {
+ e.Lines = append(e.Lines, "x")
+ }
+ e.SetSize(80, 5)
+ e.Cursor = Position{Row: 0, Col: 0}
+ e.ScrollBy(-5) // can't go below 0
+ if e.Scroll != 0 {
+ t.Errorf("Scroll = %d, want 0", e.Scroll)
+ }
+ e.ScrollBy(100) // clamps to max
+ max := len(e.buildVisual()) - e.Height
+ if e.Scroll != max {
+ t.Errorf("Scroll = %d, want %d (clamped)", e.Scroll, max)
+ }
+}
main.go +1 −1
@@ -68,7 +68,7 @@ }
// run drives the Bubbletea program in the alternate screen.
func run(a *app.App) {
- if _, err := tea.NewProgram(a, tea.WithAltScreen()).Run(); err != nil {
+ if _, err := tea.NewProgram(a, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run(); err != nil {
fmt.Fprintln(os.Stderr, "glint:", err)
os.Exit(1)
}