|
| 1 | +# Split panes: host wiring plan |
| 2 | + |
| 3 | +Status: design. The geometry foundation (`cmd/nook/internal/splitlayout`) |
| 4 | +is built and unit-tested but not referenced by the host. This note plans |
| 5 | +the wiring so the feature lands as a sequence of complete, green slices |
| 6 | +instead of one multi-hour change that risks half-landing in the central |
| 7 | +editor path. |
| 8 | + |
| 9 | +## Where the host is today |
| 10 | + |
| 11 | +The host renders exactly one editor viewport. |
| 12 | + |
| 13 | +- `model.View()` assembles the body as `tree + renderMainColumn() + right`, |
| 14 | + joined horizontally (`main.go`, the `pieces` assembly). |
| 15 | +- `renderMainColumn()` is `m.bufs.Active().View()` — the single active |
| 16 | + buffer, full editor rect. |
| 17 | +- `editorSize()` computes that one rect (tree width on the left, right |
| 18 | + pane on the right, a 20-column floor). |
| 19 | +- Every editing path keys off `m.bufs.Active()` (cursor moves, saves, |
| 20 | + completion, LSP requests, find/replace — grep `m.bufs.Active(` shows |
| 21 | + ~60 call sites). |
| 22 | + |
| 23 | +`bufman.Manager` is a single-active-buffer model. The tab bar cycles the |
| 24 | +active buffer; it never shows two buffers at once. |
| 25 | + |
| 26 | +## The central entanglement |
| 27 | + |
| 28 | +Two facts make split panes more than a render change: |
| 29 | + |
| 30 | +1. **One size for all buffers.** `bufman.WithSize(w, h)` loops over every |
| 31 | + open buffer and sets them all to the same `w, h`: |
| 32 | + |
| 33 | + ```go |
| 34 | + func (m *Manager) WithSize(w, h int) { |
| 35 | + m.width, m.height = w, h |
| 36 | + for i := range m.panes { |
| 37 | + m.panes[i] = m.panes[i].WithSize(w, h) |
| 38 | + } |
| 39 | + } |
| 40 | + ``` |
| 41 | + |
| 42 | + `editor.Pane.View()` takes no arguments; it renders at its stored |
| 43 | + size. So two on-screen panes need two different stored sizes, which |
| 44 | + the blanket `WithSize` actively fights. The wiring must size each |
| 45 | + *bound* buffer to its own pane rect, and the `WindowSizeMsg` handler |
| 46 | + must stop calling the blanket setter once a split is live. |
| 47 | + |
| 48 | +2. **Editing routes through the active buffer.** Rewriting all ~60 |
| 49 | + `m.bufs.Active()` call sites to take a "which pane" argument would be a |
| 50 | + huge, error-prone diff. The cheap win: **make pane focus drive |
| 51 | + `m.bufs.Switch(idx)`.** The focused pane's buffer *is* the active |
| 52 | + buffer, so every existing handler operates on the focused pane with |
| 53 | + zero changes. Focus and active-buffer become one concept. |
| 54 | + |
| 55 | +## Hard decisions, pinned now |
| 56 | + |
| 57 | +- **One buffer per pane, distinct, in v1.** Each split pane binds to a |
| 58 | + different `bufman` buffer index. Showing the *same* buffer in two panes |
| 59 | + with independent scroll/cursor needs detachable viewport state on |
| 60 | + `editor.Pane` and is explicitly deferred (it is the harder half and not |
| 61 | + required for the headline Zed-parity win of "see two files at once"). |
| 62 | +- **Focus == active buffer.** Single source of truth. No per-pane cursor |
| 63 | + bookkeeping in the host; `bufman` already owns each buffer's cursor. |
| 64 | +- **Dividers are free geometry.** `Tree.Rects()` already reserves a |
| 65 | + 1-cell gap between panes; `Tree.Dividers()` returns the line positions. |
| 66 | + Rendering is lipgloss, no new geometry. |
| 67 | +- **First-paint rule holds.** `splitlayout.New()` is constant-time and does |
| 68 | + no I/O; adding the tree to `newModel` does not touch startup latency. |
| 69 | +- **Recursive pump is unaffected.** Split panes add no streaming source. |
| 70 | + The search and terminal pumps target the active buffer, which is the |
| 71 | + focused pane — they keep working unchanged. |
| 72 | + |
| 73 | +## Slices |
| 74 | + |
| 75 | +Each slice is complete on its own, ships with green tests, leaves no dead |
| 76 | +keybinding, and keeps the binary starting instantly. |
| 77 | + |
| 78 | +### Slice 1 — bind the tree, no behavior change |
| 79 | + |
| 80 | +- Add `split *splitlayout.Tree` and `paneBuf map[splitlayout.PaneID]int` |
| 81 | + (pane → `bufman` index) to `model`. In `newModel`, `New()` yields one |
| 82 | + pane bound to buffer 0. |
| 83 | +- Route `renderMainColumn()` and `editorSize()` through |
| 84 | + `split.Rects(w, h)`. With one pane the rect equals the full editor area, |
| 85 | + so output is byte-identical to today. |
| 86 | +- No split keybinding yet, so no dead keys. |
| 87 | +- Tests: for a matrix of widths, heights, tree-shown, and right-pane |
| 88 | + states, the single-pane rect equals the legacy `editorSize()` result. |
| 89 | + |
| 90 | +This slice is the integration milestone — it reconciles `splitlayout` |
| 91 | +geometry with the existing tree/right-pane sizing math, which is the real |
| 92 | +risk, while changing nothing the user sees. |
| 93 | + |
| 94 | +### Slice 2 — split, close, render two panes |
| 95 | + |
| 96 | +- `ctrl+w v` (Columns / split right) and `ctrl+w s` (Rows / split down), |
| 97 | + matching vim and Zed muscle memory. On split: `SplitFocused`, bind the |
| 98 | + new pane to the next open buffer (or the current one if only a single |
| 99 | + buffer is open), then `bufs.Switch` to it. |
| 100 | +- `renderMainColumn()` iterates `split.Rects()`, sizes each bound buffer |
| 101 | + to its rect individually (not `bufman.WithSize`), renders each |
| 102 | + `buffer.View()` into its rect, and composes the panes with the lines |
| 103 | + from `split.Dividers()`. |
| 104 | +- `ctrl+w c` closes the focused pane: `CloseFocused`, drop the binding, |
| 105 | + collapse. `CloseFocused` already refuses the last pane. |
| 106 | +- `WindowSizeMsg` recomputes rects and sizes every bound buffer. |
| 107 | +- Tests: split raises pane count and binds a buffer; close refuses the |
| 108 | + last pane; rects partition the area with no overlap; the binding map |
| 109 | + stays consistent across split/close cycles. |
| 110 | + |
| 111 | +### Slice 3 — focus routing |
| 112 | + |
| 113 | +- `ctrl+w h/j/k/l` → `FocusDir`, then `bufs.Switch` to the focused pane's |
| 114 | + bound buffer. `ctrl+w w` → `FocusNext`. |
| 115 | +- Active-pane affordance: brighten the focused pane's divider/border so |
| 116 | + the user can see which split has the cursor. |
| 117 | +- `ctrl+w <` / `ctrl+w >` → `ResizeFocused` to shift the divider. |
| 118 | +- If mouse is wired: click → `PaneAt` → focus. |
| 119 | +- Tests: directional focus selects the correct neighbor; focusing a pane |
| 120 | + changes `bufs.ActiveIndex()` to that pane's bound buffer. |
| 121 | + |
| 122 | +### Slice 4 — reconciliation polish |
| 123 | + |
| 124 | +- Opening a file (picker, finder, go-to-def, tree) targets the focused |
| 125 | + pane's binding rather than always the active tab. |
| 126 | +- Closing a *buffer* (not a pane) reconciles `paneBuf` indices so no pane |
| 127 | + points at a freed slot. |
| 128 | +- Per-empty-pane welcome card when a pane's buffer is closed out from |
| 129 | + under it. |
| 130 | + |
| 131 | +## Out of scope (record so it is not rediscovered) |
| 132 | + |
| 133 | +- Same buffer in two panes with independent viewports — needs detachable |
| 134 | + viewport state on `editor.Pane`. Revisit only after slices 1–4. |
| 135 | +- Collab / liveshare — out of scope for nook entirely (see ROADMAP). |
| 136 | + |
| 137 | +## Order of operations |
| 138 | + |
| 139 | +Land 1 first; it is pure de-risking and unlocks the rest. 2 and 3 are |
| 140 | +each a single visible feature. 4 is cleanup that can trail. None of the |
| 141 | +four needs the deferred independent-viewport work. |
0 commit comments