Skip to content

Commit 39bf14b

Browse files
committed
nook: focus a split pane by clicking it
Dispatch left-button presses to routeMouse, the mouse twin of the alt+w focus chords: a click inside a pane focuses it and carries the active buffer along. It acts only on a left press while a split is live with no overlay floating; wheel, drag, and clicks outside the editor region fall through. The screen-to- region mapping is the inverse of View(): treeWidth() is extracted from editorRegion so the tree's column count has a single definition, and editorOrigin() adds the divider column and tab-bar row. A click on the divider gap or the focused pane is a no-op. Tests cover per-pane selection, a tree click ignored, single-pane inert, and a wheel event ignored. Split panes is now navigable from both keyboard and mouse.
1 parent 71a9fdc commit 39bf14b

3 files changed

Lines changed: 189 additions & 17 deletions

File tree

cmd/nook/main.go

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,6 +1200,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12001200
case tea.KeyMsg:
12011201
return m.routeKey(msg)
12021202

1203+
case tea.MouseMsg:
1204+
return m.routeMouse(msg)
1205+
12031206
case filesLoadedMsg:
12041207
m.files = msg.files
12051208
items := make([]picker.Item, len(msg.files))
@@ -4057,6 +4060,33 @@ func (m model) closePane() (tea.Model, tea.Cmd) {
40574060
return m, nil
40584061
}
40594062

4063+
// routeMouse focuses the pane under a left click. It is the mouse twin of the
4064+
// alt+w h/j/k/l/w chords: a click is the most direct way to say "work here".
4065+
// Only a left-button press is acted on, and only when a split is live and no
4066+
// overlay is floating; everything else (wheel, drag, clicks in the tree or tab
4067+
// bar or on the divider gap) falls through untouched so existing behaviour and
4068+
// future mouse uses stay open.
4069+
func (m model) routeMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
4070+
if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress {
4071+
return m, nil
4072+
}
4073+
if m.overlay != overlayNone || m.split == nil || m.split.Count() < 2 {
4074+
return m, nil
4075+
}
4076+
ox, oy := m.editorOrigin()
4077+
rx, ry := msg.X-ox, msg.Y-oy
4078+
w, h := m.editorRegion()
4079+
if rx < 0 || ry < 0 || rx >= w || ry >= h {
4080+
return m, nil // click landed in the tree, tab bar, or right pane
4081+
}
4082+
pid, ok := m.split.PaneAt(rx, ry, w, h)
4083+
if !ok || pid == m.split.Focused() {
4084+
return m, nil // the divider gap, or the already-focused pane
4085+
}
4086+
m.split.Focus(pid)
4087+
return m.syncFocusedPane(true)
4088+
}
4089+
40604090
// focusDir moves split focus to the nearest pane in direction d (alt+w h/j/k/l)
40614091
// and re-syncs the editor onto it. With no live split the chord is a no-op.
40624092
func (m model) focusDir(d splitlayout.Direction) (tea.Model, tea.Cmd) {
@@ -4875,17 +4905,41 @@ func (m model) shouldShowWelcome() bool {
48754905
// single pane it IS the pane. Must mirror resize() exactly: tree on the left
48764906
// when visible, right pane on the right when active, and a minimum-20 editor
48774907
// floor that shrinks the tree when necessary.
4878-
func (m model) editorRegion() (int, int) {
4879-
treeW := 0
4880-
if m.showTree {
4881-
treeW = m.width / 5
4882-
if treeW < 22 {
4883-
treeW = 22
4884-
}
4885-
if treeW > 40 {
4886-
treeW = 40
4887-
}
4908+
// treeWidth is the column count the file tree occupies, or 0 when it is
4909+
// hidden. editorRegion and editorOrigin both go through here so the editor's
4910+
// width and its on-screen left edge can never disagree about where the tree
4911+
// ends.
4912+
func (m model) treeWidth() int {
4913+
if !m.showTree {
4914+
return 0
4915+
}
4916+
treeW := m.width / 5
4917+
if treeW < 22 {
4918+
treeW = 22
48884919
}
4920+
if treeW > 40 {
4921+
treeW = 40
4922+
}
4923+
return treeW
4924+
}
4925+
4926+
// editorOrigin is the screen coordinate of the editor region's top-left cell:
4927+
// past the tree and its divider on the left, and below the tab bar on top.
4928+
// It is the inverse of the View() composition, used to turn a mouse click into
4929+
// a point inside the editor region.
4930+
func (m model) editorOrigin() (x, y int) {
4931+
x = m.treeWidth()
4932+
if x > 0 {
4933+
x++ // the one-cell divider drawn between the tree and the editor
4934+
}
4935+
if m.bufs.Count() > 0 {
4936+
y = 1 // the tab bar sits above the body whenever a buffer is open
4937+
}
4938+
return x, y
4939+
}
4940+
4941+
func (m model) editorRegion() (int, int) {
4942+
treeW := m.treeWidth()
48894943
leftW := m.width - treeW
48904944
if treeW > 0 {
48914945
leftW--

cmd/nook/main_splitpane_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,3 +467,104 @@ func TestWindowLeaderResizeNoSplitIsNoOp(t *testing.T) {
467467
t.Errorf("resize chord created a split: count = %d", m.split.Count())
468468
}
469469
}
470+
471+
// clickAt presses the left mouse button at screen coordinate (x, y).
472+
func clickAt(m model, x, y int) model {
473+
u, _ := m.Update(tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
474+
return u.(model)
475+
}
476+
477+
// paneCenter returns a screen point inside pane pid's rectangle, mapping the
478+
// region-local rect back through the editor origin the way View() lays it out.
479+
func paneCenter(m model, pid splitlayout.PaneID) (int, int) {
480+
w, h := m.editorRegion()
481+
r := m.split.Rects(w, h)[pid]
482+
ox, oy := m.editorOrigin()
483+
return ox + r.X + r.W/2, oy + r.Y + r.H/2
484+
}
485+
486+
// TestMouseClickFocusesPaneUnderCursor is the headline of slice 3c: clicking
487+
// inside a pane makes it the focused pane and drags the active buffer with it,
488+
// the mouse twin of alt+w w. A columns split puts two panes side by side; a
489+
// click in each must select that pane.
490+
func TestMouseClickFocusesPaneUnderCursor(t *testing.T) {
491+
m := openTwo(t)
492+
m = altW(m)
493+
m = runeKey(m, 'v') // columns split; focus lands on the new (right) pane
494+
ids := m.split.Panes()
495+
if len(ids) != 2 {
496+
t.Fatalf("want 2 panes, got %d", len(ids))
497+
}
498+
left, right := ids[0], ids[1]
499+
500+
// Click the left pane: focus moves there and the active buffer follows.
501+
lx, ly := paneCenter(m, left)
502+
m = clickAt(m, lx, ly)
503+
if m.split.Focused() != left {
504+
t.Fatalf("click in left pane focused %v, want %v", m.split.Focused(), left)
505+
}
506+
if m.bufs.ActiveIndex() != m.paneBuf[left] {
507+
t.Errorf("focus==active broken after click: active=%d, pane binds %d",
508+
m.bufs.ActiveIndex(), m.paneBuf[left])
509+
}
510+
511+
// Click the right pane: focus moves back.
512+
rx, ry := paneCenter(m, right)
513+
m = clickAt(m, rx, ry)
514+
if m.split.Focused() != right {
515+
t.Errorf("click in right pane focused %v, want %v", m.split.Focused(), right)
516+
}
517+
}
518+
519+
// TestMouseClickInTreeIgnored guards the origin math: a click to the left of
520+
// the editor region (in the file tree) must not change split focus.
521+
func TestMouseClickInTreeIgnored(t *testing.T) {
522+
m := openTwo(t)
523+
m.showTree = true
524+
m = m.resize()
525+
m = altW(m)
526+
m = runeKey(m, 'v')
527+
before := m.split.Focused()
528+
529+
// x=0 is inside the tree column, left of the editor origin.
530+
m = clickAt(m, 0, 5)
531+
if m.split.Focused() != before {
532+
t.Error("a click in the file tree moved split focus")
533+
}
534+
}
535+
536+
// TestMouseClickNoSplitIsNoOp confirms a click is inert with one pane: nothing
537+
// to refocus, no panic, no split created.
538+
func TestMouseClickNoSplitIsNoOp(t *testing.T) {
539+
m := openTwo(t) // two buffers, no split
540+
before := m.bufs.ActiveIndex()
541+
m = clickAt(m, 40, 10)
542+
if m.split.Count() != 1 {
543+
t.Errorf("a click created a split: count = %d", m.split.Count())
544+
}
545+
if m.bufs.ActiveIndex() != before {
546+
t.Errorf("a click with no split changed the active buffer: %d -> %d",
547+
before, m.bufs.ActiveIndex())
548+
}
549+
}
550+
551+
// TestMouseWheelIgnored guards that non-left-press events (here a wheel-up)
552+
// never reroute focus: only a deliberate left click selects a pane.
553+
func TestMouseWheelIgnored(t *testing.T) {
554+
m := openTwo(t)
555+
m = altW(m)
556+
m = runeKey(m, 'v')
557+
before := m.split.Focused()
558+
ids := m.split.Panes()
559+
other := ids[0]
560+
if other == before {
561+
other = ids[1]
562+
}
563+
// Aim the wheel event at the non-focused pane; it must still be ignored.
564+
x, y := paneCenter(m, other)
565+
u, _ := m.Update(tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonWheelUp, Action: tea.MouseActionPress})
566+
m = u.(model)
567+
if m.split.Focused() != before {
568+
t.Error("a mouse wheel event moved split focus")
569+
}
570+
}

docs/nook/design/01-split-panes-host-wiring.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,30 @@ pane to zero width (clamp proof); the chord is a no-op with one pane.
194194
The mouse split was deferred to keep this slice complete and green. Keyboard
195195
resize is the interactive capstone; mouse needs its own screen-coordinate map.
196196

197-
#### Slice 3c — mouse focus — NEXT
198-
199-
- Mouse is already enabled (`tea.WithMouseCellMotion()` at `main.go`).
200-
- Click → map screen coords (tree-width left offset, header rows) to an editor
201-
region point → `split.PaneAt``Focus``syncFocusedPane`.
202-
- Tests: a click in each pane's rectangle selects that pane; a click in the
203-
tree or header is ignored.
197+
#### Slice 3c — mouse focus — LANDED
198+
199+
Done. A left-button press is dispatched to `routeMouse`, the mouse twin of the
200+
alt+w focus chords. It acts only on `MouseButtonLeft` + `MouseActionPress`,
201+
only when a split is live and no overlay floats; wheel, drag, and clicks
202+
outside the editor region fall through untouched so future mouse uses stay
203+
open. The screen→region mapping is the inverse of `View()`'s composition,
204+
factored into two helpers so it can never drift from the layout:
205+
206+
- `treeWidth()` — extracted from `editorRegion` so the tree's column count has
207+
one definition that both width and origin read.
208+
- `editorOrigin()` — the editor region's top-left screen cell: `treeWidth()`
209+
plus one divider column on the left, plus one tab-bar row on top when a
210+
buffer is open.
211+
212+
`routeMouse` subtracts the origin from the click, bounds-checks against the
213+
region, then `split.PaneAt``Focus``syncFocusedPane` (keeping
214+
focus==active). A click on the divider gap or the already-focused pane is a
215+
no-op. Tests (`main_splitpane_test.go`): a click in each pane selects it and
216+
drags the active buffer; a click in the file tree is ignored; a click with no
217+
split is inert; a wheel event never reroutes focus.
218+
219+
**Split panes is now complete from both keyboard and mouse.** Remaining work
220+
is Slice 4 reconciliation polish, a separate concern.
204221

205222
### Slice 4 — reconciliation polish
206223

0 commit comments

Comments
 (0)