Skip to content

Commit a7be342

Browse files
committed
nook: split/close pane keybindings via alt+w window leader
Layers the split keybindings onto the 2a render compositor. alt+w arms a one-shot window-command state; the next key runs a window op (v splits into columns, s into stacked rows, c closes the focused pane) or disarms. The leader is read at the top of routeKey, ahead of the global switch, so the chord's second key is consumed instead of typed into the buffer. alt+w rather than the vim/tmux ctrl+w: ctrl+w is already close-tab and several tests press a bare ctrl+w expecting an immediate close, so a prefix state would break both them and the close-tab muscle memory. alt+w is free and fits nook's existing alt-leader idiom. splitPane requires two open buffers (a lone-buffer split would show the same content twice, which per-pane sizing can't honor) and v1 caps at one split. closePane drops only the pane binding; the buffer stays open as a tab, so no bufman reindex is needed and the focus==active invariant holds.
1 parent feface1 commit a7be342

3 files changed

Lines changed: 240 additions & 22 deletions

File tree

cmd/nook/main.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ type model struct {
180180
split *splitlayout.Tree
181181
paneBuf map[splitlayout.PaneID]int
182182

183+
// awaitingWindowKey is the one-shot "window command" leader state. alt+w
184+
// arms it; the next key runs a window op (v/s split, c close pane) or
185+
// disarms. alt+w is used rather than the vim/tmux ctrl+w so the historical
186+
// ctrl+w = close-tab binding stays intact.
187+
awaitingWindowKey bool
188+
183189
gitPane git.Pane
184190
termPane term.Pane
185191
picker picker.Picker
@@ -2127,6 +2133,27 @@ func (m model) routeKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
21272133
return m.routeOutline(km)
21282134
}
21292135

2136+
// Window-command leader: alt+w arms awaitingWindowKey, and the very next
2137+
// key either runs a window op or disarms. This sits ahead of the global
2138+
// switch so the chord's second key (a plain rune like v/s/c) is consumed
2139+
// here instead of being typed into the buffer.
2140+
if m.awaitingWindowKey {
2141+
m.awaitingWindowKey = false
2142+
if km.Type == tea.KeyRunes && len(km.Runes) == 1 && !km.Alt {
2143+
switch km.Runes[0] {
2144+
case 'v':
2145+
return m.splitPane(splitlayout.Columns)
2146+
case 's':
2147+
return m.splitPane(splitlayout.Rows)
2148+
case 'c':
2149+
return m.closePane()
2150+
}
2151+
}
2152+
// Any other key cancels the leader without acting on the workspace.
2153+
m.status = ""
2154+
return m, nil
2155+
}
2156+
21302157
// Global keys
21312158
switch km.Type {
21322159
case tea.KeyCtrlQ:
@@ -2392,6 +2419,15 @@ func (m model) routeKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
23922419
// is reserved by most terminal emulators for paste, so alt+v is
23932420
// the portable surface.
23942421
return m.toggleMdPreview()
2422+
case 'w':
2423+
// Alt+w is the window-command leader (mnemonic: window). It arms a
2424+
// one-shot state read at the top of routeKey: the next key runs a
2425+
// window op. v splits the focused pane into side-by-side columns, s
2426+
// splits it into stacked rows, c closes the focused pane. Kept off
2427+
// ctrl+w so the historical close-tab binding survives unchanged.
2428+
m.awaitingWindowKey = true
2429+
m.status = "window: v split right · s split down · c close pane"
2430+
return m, nil
23952431
case 'y':
23962432
// Alt+y toggles gopls inlay hints (type annotations + parameter
23972433
// names) for every open buffer. Hints are visual-only; the
@@ -3953,6 +3989,59 @@ func (m model) closeActiveTab() (tea.Model, tea.Cmd) {
39533989
return m, cmd
39543990
}
39553991

3992+
// splitPane divides the focused pane along o and binds the new pane to a
3993+
// different open buffer, so the split immediately shows two files. v1 caps the
3994+
// layout at two panes (one split) and needs at least two open buffers:
3995+
// splitting a lone buffer would show the same content twice, which the
3996+
// per-pane sizing can't honor. The fresh pane takes focus, and its buffer is
3997+
// made active so every existing editing path operates on the focused pane.
3998+
func (m model) splitPane(o splitlayout.Orientation) (tea.Model, tea.Cmd) {
3999+
if m.split == nil {
4000+
return m, nil
4001+
}
4002+
if m.split.Count() > 1 {
4003+
m.status = "nook shows two panes in this version — close one with alt+w c"
4004+
return m, nil
4005+
}
4006+
if m.bufs.Count() < 2 {
4007+
m.status = "open another file to split (ctrl+P)"
4008+
return m, nil
4009+
}
4010+
old := m.split.Focused()
4011+
m.paneBuf[old] = m.bufs.ActiveIndex()
4012+
other := (m.bufs.ActiveIndex() + 1) % m.bufs.Count()
4013+
newID := m.split.SplitFocused(o)
4014+
m.paneBuf[newID] = other
4015+
m.bufs.Switch(other)
4016+
m = m.resize()
4017+
m = m.applyDiagnosticsToActive()
4018+
m.status = "split — alt+w c to close the pane"
4019+
return m, nil
4020+
}
4021+
4022+
// closePane closes the focused split pane; its space collapses into the
4023+
// sibling. The buffer the pane showed stays open as a tab, so only the pane
4024+
// binding is dropped. Closing the sole remaining pane is refused by the layout
4025+
// tree. After a close the surviving focused pane's buffer becomes active.
4026+
func (m model) closePane() (tea.Model, tea.Cmd) {
4027+
if m.split == nil {
4028+
return m, nil
4029+
}
4030+
closed := m.split.Focused()
4031+
if !m.split.CloseFocused() {
4032+
m.status = "only one pane open"
4033+
return m, nil
4034+
}
4035+
delete(m.paneBuf, closed)
4036+
if idx, ok := m.paneBuf[m.split.Focused()]; ok {
4037+
m.bufs.Switch(idx)
4038+
}
4039+
m = m.resize()
4040+
m = m.applyDiagnosticsToActive()
4041+
m.status = "pane closed"
4042+
return m, nil
4043+
}
4044+
39564045
func min(a, b int) int {
39574046
if a < b {
39584047
return a

cmd/nook/main_splitpane_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"strings"
66
"testing"
77

8+
tea "github.com/charmbracelet/bubbletea"
89
"github.com/charmbracelet/lipgloss"
910
"github.com/truffle-dev/glyph/cmd/nook/internal/splitlayout"
1011
)
@@ -209,3 +210,120 @@ func TestRenderSinglePaneUnchanged(t *testing.T) {
209210
t.Error("single-pane renderMainColumn diverged from the active buffer view")
210211
}
211212
}
213+
214+
// altW presses the window-command leader (alt+w) and returns the updated model.
215+
func altW(m model) model {
216+
u, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}, Alt: true})
217+
return u.(model)
218+
}
219+
220+
// runeKey presses a single plain rune (no modifiers) and returns the model.
221+
func runeKey(m model, r rune) model {
222+
u, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
223+
return u.(model)
224+
}
225+
226+
// openTwo seeds a model sized for split work with two real buffers open. The
227+
// keybinding tests start here and then drive the alt+w leader so the split /
228+
// close handlers are exercised through the same key path a user takes.
229+
func openTwo(t *testing.T) model {
230+
t.Helper()
231+
root := fixtureRepo(t)
232+
m := newModel(root)
233+
m.width = 120
234+
m.height = 32
235+
m = m.resize()
236+
m.bufs.OpenOrSwitch(filepath.Join(root, "a.go"))
237+
m.bufs.OpenOrSwitch(filepath.Join(root, "sub", "b.go"))
238+
if m.bufs.Count() != 2 {
239+
t.Fatalf("want 2 buffers, got %d", m.bufs.Count())
240+
}
241+
return m
242+
}
243+
244+
// TestWindowLeaderSplitColumns drives alt+w v and asserts a second column pane
245+
// appears bound to the other buffer, leaving the leader state disarmed.
246+
func TestWindowLeaderSplitColumns(t *testing.T) {
247+
m := openTwo(t)
248+
m = altW(m)
249+
if !m.awaitingWindowKey {
250+
t.Fatal("alt+w did not arm the window leader")
251+
}
252+
m = runeKey(m, 'v')
253+
if m.awaitingWindowKey {
254+
t.Error("window leader still armed after running a window op")
255+
}
256+
if m.split.Count() != 2 {
257+
t.Fatalf("split count = %d, want 2", m.split.Count())
258+
}
259+
divs := m.split.Dividers(m.editorRegion())
260+
if len(divs) != 1 || divs[0].Orient != splitlayout.Columns {
261+
t.Errorf("want a single columns divider, got %+v", divs)
262+
}
263+
}
264+
265+
// TestWindowLeaderSplitRows drives alt+w s and asserts a stacked-row split.
266+
func TestWindowLeaderSplitRows(t *testing.T) {
267+
m := openTwo(t)
268+
m = altW(m)
269+
m = runeKey(m, 's')
270+
if m.split.Count() != 2 {
271+
t.Fatalf("split count = %d, want 2", m.split.Count())
272+
}
273+
divs := m.split.Dividers(m.editorRegion())
274+
if len(divs) != 1 || divs[0].Orient != splitlayout.Rows {
275+
t.Errorf("want a single rows divider, got %+v", divs)
276+
}
277+
}
278+
279+
// TestWindowLeaderClosePane splits then closes via alt+w c, returning to one
280+
// pane while both buffers stay open as tabs.
281+
func TestWindowLeaderClosePane(t *testing.T) {
282+
m := openTwo(t)
283+
m = altW(m)
284+
m = runeKey(m, 'v')
285+
if m.split.Count() != 2 {
286+
t.Fatalf("split count = %d after split, want 2", m.split.Count())
287+
}
288+
m = altW(m)
289+
m = runeKey(m, 'c')
290+
if m.split.Count() != 1 {
291+
t.Fatalf("split count = %d after close, want 1", m.split.Count())
292+
}
293+
if m.bufs.Count() != 2 {
294+
t.Errorf("closing a pane dropped a buffer: count = %d, want 2", m.bufs.Count())
295+
}
296+
}
297+
298+
// TestWindowLeaderCancels guards the disarm path: alt+w then an unrelated key
299+
// runs no window op and clears the leader, never splitting.
300+
func TestWindowLeaderCancels(t *testing.T) {
301+
m := openTwo(t)
302+
m = altW(m)
303+
m = runeKey(m, 'z')
304+
if m.awaitingWindowKey {
305+
t.Error("unrelated key left the window leader armed")
306+
}
307+
if m.split.Count() != 1 {
308+
t.Errorf("unrelated key after alt+w changed the split: count = %d, want 1", m.split.Count())
309+
}
310+
}
311+
312+
// TestWindowLeaderRefusesSingleBuffer guards the v1 rule that a split needs a
313+
// second buffer to show; with one buffer open alt+w v is a no-op with a hint.
314+
func TestWindowLeaderRefusesSingleBuffer(t *testing.T) {
315+
root := fixtureRepo(t)
316+
m := newModel(root)
317+
m.width = 120
318+
m.height = 32
319+
m = m.resize()
320+
m.bufs.OpenOrSwitch(filepath.Join(root, "a.go"))
321+
if m.bufs.Count() != 1 {
322+
t.Fatalf("want 1 buffer, got %d", m.bufs.Count())
323+
}
324+
m = altW(m)
325+
m = runeKey(m, 'v')
326+
if m.split.Count() != 1 {
327+
t.Errorf("split with one buffer should be refused, count = %d", m.split.Count())
328+
}
329+
}

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

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -123,33 +123,44 @@ the region and show both buffers; a mutation-proof confirms the width guard
123123
goes red on a doubled divider. **v1 caps at two panes (one split).** A general
124124
N-pane compositor is deferred until a real use case needs it.
125125

126-
#### Slice 2b — split / close keybindings — NEXT
127-
128-
- **ctrl+w conflict, pinned.** `ctrl+w` is already bound to
129-
`closeActiveTab()` (`main.go`, the `tea.KeyCtrlW` case in the global
130-
switch). The vim/Zed `ctrl+w <motion>` chord prefix cannot be added
131-
naively without stealing that binding. Resolution for 2b: introduce a
132-
pending-`ctrl+w` prefix state — the first `ctrl+w` arms it, a following
133-
`v`/`s`/`c`/`h`/`j`/`k`/`l`/`w`/`<`/`>` consumes the chord, and any other
134-
key (or a second `ctrl+w`) falls back to `closeActiveTab()`. This keeps
135-
the existing close-tab muscle memory (bare `ctrl+w`) while layering the
136-
window chords on top, exactly as vim does with its own `ctrl+w` prefix.
137-
- `ctrl+w v` (Columns / split right) and `ctrl+w s` (Rows / split down).
138-
On split: `SplitFocused`, bind the new pane to the next open buffer (or
139-
the current one if only a single buffer is open), then `bufs.Switch` to it.
140-
- `ctrl+w c` closes the focused pane: `CloseFocused`, drop the binding,
141-
collapse. `CloseFocused` already refuses the last pane.
142-
- Tests: the prefix arms and disarms correctly; bare `ctrl+w` still closes
143-
a tab; split raises pane count and binds a buffer; close refuses the last
144-
pane; the binding map stays consistent across split/close cycles.
126+
#### Slice 2b — split / close keybindings — LANDED
127+
128+
Done, with one decision changed from the plan below. **The window leader is
129+
`alt+w`, not a pending-`ctrl+w` prefix.** The pinned ctrl+w-prefix idea was
130+
abandoned because `ctrl+w` is already bound to `closeActiveTab()` and several
131+
existing tests (`TestTabFlow`, `TestTabFlowDirtyBlocksClose`) press a single
132+
`ctrl+w` expecting an immediate tab close — a prefix state would have made
133+
the first `ctrl+w` swallow that close, breaking both the tests and the
134+
committed promise to preserve close-tab muscle memory. `alt+w` is free, reads
135+
as "window," and fits nook's existing alt-leader idiom (alt+v markdown
136+
preview, alt+] / alt+[ tab cycle). `ctrl+w` stays exactly as it was.
137+
138+
- **`alt+w` arms a one-shot `awaitingWindowKey` state.** A handler at the top
139+
of `routeKey` reads it before the global switch, so the chord's second key
140+
(a plain rune) is consumed there instead of being typed into the buffer.
141+
The next key runs a window op or disarms.
142+
- `alt+w v` (Columns / split right) and `alt+w s` (Rows / split down).
143+
`splitPane` requires ≥2 open buffers — a lone-buffer split would show the
144+
same content twice, which per-pane sizing cannot honor — and v1 caps at one
145+
split (two panes). It binds the new pane to the next open buffer and
146+
`bufs.Switch`es to it, keeping the focus==active invariant.
147+
- `alt+w c` closes the focused pane: `CloseFocused`, drop the `paneBuf`
148+
binding, re-switch the active buffer to the surviving pane's binding. The
149+
buffer stays open as a tab; only the pane binding drops, so no bufman
150+
reindex is needed. `CloseFocused` already refuses the last pane.
151+
- Tests (`main_splitpane_test.go`): `alt+w v` / `alt+w s` raise pane count and
152+
produce the right divider orientation; `alt+w c` returns to one pane with
153+
both buffers still open; `alt+w` + an unrelated key disarms without
154+
splitting; split is refused with a single buffer; existing `TestTabFlow`
155+
confirms bare `ctrl+w` still closes a tab.
145156

146157
### Slice 3 — focus routing
147158

148-
- `ctrl+w h/j/k/l``FocusDir`, then `bufs.Switch` to the focused pane's
149-
bound buffer. `ctrl+w w``FocusNext`.
159+
- `alt+w h/j/k/l``FocusDir`, then `bufs.Switch` to the focused pane's
160+
bound buffer. `alt+w w``FocusNext`.
150161
- Active-pane affordance: brighten the focused pane's divider/border so
151162
the user can see which split has the cursor.
152-
- `ctrl+w <` / `ctrl+w >``ResizeFocused` to shift the divider.
163+
- `alt+w <` / `alt+w >``ResizeFocused` to shift the divider.
153164
- If mouse is wired: click → `PaneAt` → focus.
154165
- Tests: directional focus selects the correct neighbor; focusing a pane
155166
changes `bufs.ActiveIndex()` to that pane's bound buffer.

0 commit comments

Comments
 (0)