From c820b03f285e976eae809bc37cbd9b82740e09a6 Mon Sep 17 00:00:00 2001 From: Matt Vinall Date: Fri, 1 May 2026 10:26:42 +0100 Subject: [PATCH 1/2] feat: new layout, diff on RHS screen it would be nice to be able to see the branch diff. This new layout will make it easier to do that in future. And also perhaps to show stashes/diff. --- README.md | 6 +- demo.tape | 2 +- ui/layout_limits.go | 8 +- ui/model.go | 5 +- ui/mouse_line_select.go | 12 +-- ui/mouse_line_select_test.go | 6 +- ui/mouse_resize.go | 156 +++++++++++++++++++++-------------- ui/mouse_resize_test.go | 45 ++++++---- ui/status_layout.go | 144 ++++++++++++++++++-------------- ui/status_layout_test.go | 45 +++++----- ui/update.go | 91 +++++++++++++++----- ui/update_view_test.go | 76 ++++++++++++++--- ui/view.go | 44 +++++----- ui/view_test.go | 53 ++++++------ 14 files changed, 435 insertions(+), 258 deletions(-) diff --git a/README.md b/README.md index f0fa808..af0e1de 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ sits below them. The mouse is enabled: **click** a pane to focus it, or a row in splits (unavailable when zoomed, scanning, on error, or with an overlay open). The Status table lists dirty files with **Worktree** and **Staged** columns (same left-to-right order as the Diff pane). The Diff pane runs `git diff` with basic colorization; -use **←** / **→** in Status or Diff to switch between **Worktree** and **Staged** views. +use **Space** in Status or Diff to toggle between **Worktree** and **Staged** views. With a file row selected, **a** runs `git add` and **r** runs `git reset` (unstage) on that path (from the Status or Diff pane), then the current repo is refreshed. **C** asks for confirmation, then runs `git checkout HEAD -- ` to restore that path to @@ -145,12 +145,12 @@ compresses each remote into a short status (`ok`, `missing`, `differs`, or | Key | Action | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | *Mouse* | Click to focus a pane; in Repositories or Status (when focused), select a row. Drag a border to resize splits (unavailable when zoomed, scanning, on error, or with an overlay open) | -| `Tab` / `Shift+Tab` | Next / previous pane; when zoomed, cycle which pane is fullscreen | +| `Tab` / `Shift+Tab` | Next / previous pane: Repositories, Status, Branches, Log (not Diff; click to focus); when zoomed, cycle fullscreen | | `Enter` | Zoom the focused pane; `Enter` again restores the split layout | | `Esc` | Exit zoom, or clear the Status file selection | | `↑` / `↓` | Move repo selection, or scroll Status / Diff / Log | | `Shift+↑` / `Shift+↓` | Same, in steps of 10 lines | -| `←` / `→` | In Status or Diff: Worktree vs Staged diff | +| `Space` | In Status or Diff: toggle Worktree vs Staged diff | | `a` / `r` | With a status file row selected (Status or Diff): `git add` / `git reset` that path | | `C` | With a status file row selected (Status or Diff): confirm, then `git checkout HEAD --` that path (restore to last commit) | | `s` | Scan or rescan | diff --git a/demo.tape b/demo.tape index a1ed8be..012fcf9 100644 --- a/demo.tape +++ b/demo.tape @@ -102,7 +102,7 @@ Sleep 200ms Tab Sleep 200ms -# tab to diff +# tab to log (Diff is not in the Tab cycle; click the Diff pane to focus it) Tab Sleep 200ms diff --git a/ui/layout_limits.go b/ui/layout_limits.go index 7320810..e06d534 100644 --- a/ui/layout_limits.go +++ b/ui/layout_limits.go @@ -18,10 +18,14 @@ const ( layoutMinTermWidth = 20 layoutMinTermHeight = 22 - // layoutMinStatusBranchesColumn is a minimum for either status or branches column - // when splits are computed or user-resized. + // layoutMinStatusBranchesColumn is a minimum width for either middle-row column + // (status+branches stack vs diff) when splits are computed or user-resized. layoutMinStatusBranchesColumn = 10 + // layoutDefaultMiddleDiffColumnPct is the default share of terminal width for the + // framed Diff column in the middle row (remainder goes to Status+Branches). + layoutDefaultMiddleDiffColumnPct = 70 + // layoutMinInnerContentWidth is a floor for content inside borders or viewports // (also used as a placeholder size before syncViewports runs). layoutMinInnerContentWidth = 8 diff --git a/ui/model.go b/ui/model.go index a702bbe..4fd8f47 100644 --- a/ui/model.go +++ b/ui/model.go @@ -131,13 +131,14 @@ type model struct { zoomTarget pane // which pane is fullscreen when zoomed // layoutUseCustomVertical is true after the user resizes a horizontal seam; - // layoutRepoBody, layoutStatusBody, and layoutLogBody are inner body heights. + // layoutRepoBody, layoutStatusBody, layoutBranchBody, and layoutLogBody are inner body heights. layoutUseCustomVertical bool layoutRepoBody int layoutStatusBody int + layoutBranchBody int layoutLogBody int - // layoutBranchesOuter is the framed Branches column width in cells (0 = automatic). + // layoutBranchesOuter is the framed Diff (right) column outer width in the middle row (0 = default pct). layoutBranchesOuter int resizeDrag resizeSplit diff --git a/ui/mouse_line_select.go b/ui/mouse_line_select.go index 38781ff..8a0c92a 100644 --- a/ui/mouse_line_select.go +++ b/ui/mouse_line_select.go @@ -47,8 +47,8 @@ func (m *model) repoPaneOuterHeight() (outerH int, ok bool) { if m.height < layoutMinTermHeight || m.width < layoutMinTermWidth { return 0, false } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { return 0, false } if m.zoomed { @@ -66,8 +66,8 @@ func (m *model) statusPaneFrame() (topY, outerH, outerW int, ok bool) { if m.height < layoutMinTermHeight || m.width < layoutMinTermWidth { return 0, 0, 0, false } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { return 0, 0, 0, false } if m.zoomed { @@ -78,8 +78,8 @@ func (m *model) statusPaneFrame() (topY, outerH, outerW int, ok bool) { } repoOuter := panelOuter(repoBody) statusOuter := panelOuter(statusBody) - statusW, _ := m.statusBranchesOuterWidths(m.width) - return repoOuter, statusOuter, statusW, true + leftW, _ := m.middleRowColumnOuterWidths(m.width) + return repoOuter, statusOuter, leftW, true } // handleMousePaneLineSelect handles left-click row selection when the repo or diff --git a/ui/mouse_line_select_test.go b/ui/mouse_line_select_test.go index f39a659..de35fd0 100644 --- a/ui/mouse_line_select_test.go +++ b/ui/mouse_line_select_test.go @@ -16,7 +16,7 @@ func TestMouseRepoLineSelect(t *testing.T) { m.cursor = 0 m.focus = paneRepo - rb, _, _, _ := m.layoutBodies() + rb, _, _, _, _ := m.layoutBodies() repoOuter := panelOuter(rb) if repoOuter < 4 { t.Fatalf("repoOuter=%d too small for test", repoOuter) @@ -57,7 +57,7 @@ func TestMouseStatusLineSelect(t *testing.T) { m.focus = paneStatus m.syncViewports() - rb, _, _, _ := m.layoutBodies() + rb, _, _, _, _ := m.layoutBodies() statusTop := panelOuter(rb) // First data row: inner starts at statusTop+1, then header, then row 0. y := statusTop + 1 + statusTableHeaderLines(m.statusTable) @@ -98,7 +98,7 @@ func TestMouseStatusHeaderClickConsumed(t *testing.T) { m.focus = paneStatus m.syncViewports() - rb, _, _, _ := m.layoutBodies() + rb, _, _, _, _ := m.layoutBodies() statusTop := panelOuter(rb) y := statusTop + 1 // first inner line = table header prev := m.statusTable.Cursor() diff --git a/ui/mouse_resize.go b/ui/mouse_resize.go index b52e172..d2a3336 100644 --- a/ui/mouse_resize.go +++ b/ui/mouse_resize.go @@ -10,9 +10,9 @@ type resizeSplit int const ( resizeNone resizeSplit = iota resizeRepoStatus - resizeStatusDiff - resizeDiffLog - resizeStatusBranches + resizeStatusBranch + resizeMiddleLog + resizeMiddleColumns ) // handleMousePaneResize handles click-drag on pane borders to resize splits. @@ -25,8 +25,8 @@ func (m *model) handleMousePaneResize(msg tea.MouseMsg) bool { return false } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { return false } @@ -68,10 +68,28 @@ func (m *model) handleMousePaneResize(msg tea.MouseMsg) bool { } } +func splitSumSBPreservingRatio(statusBody, branchBody, sumSB int) (st, br int) { + pair := statusBody + branchBody + if pair <= 0 || sumSB <= 0 { + st = sumSB / 2 + br = sumSB - st + return st, br + } + st = statusBody * sumSB / pair + if st < layoutMinBodyLines { + st = layoutMinBodyLines + } + if st > sumSB-layoutMinBodyLines { + st = sumSB - layoutMinBodyLines + } + br = sumSB - st + return st, br +} + func (m *model) applyResizeDrag(x, y int) { innerTotal := m.height - layoutFrameStackOuterRows - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { return } @@ -93,30 +111,30 @@ func (m *model) applyResizeDrag(x, y int) { if repo < layoutMinBodyLines { return } - available := innerTotal - repo - logBody + logAdj := logBody + available := innerTotal - repo - logAdj if available < layoutMinSpareForSplit { return } - st := statusBody - di := available - st - if di < layoutMinBodyLines { - di = layoutMinBodyLines - st = available - di - } - if st < layoutMinBodyLines { - st = layoutMinBodyLines - di = available - st + sumSB := available + if sumSB < 2*layoutMinBodyLines { + return } - if di < layoutMinBodyLines || st < layoutMinBodyLines { + st, br := splitSumSBPreservingRatio(statusBody, branchBody, sumSB) + di := diffBodyFromStackedStatusBranch(st, br) + if st < layoutMinBodyLines || br < layoutMinBodyLines || di < layoutMinBodyLines { return } m.layoutUseCustomVertical = true m.layoutRepoBody = repo m.layoutStatusBody = st - m.layoutLogBody = logBody + m.layoutBranchBody = br + m.layoutLogBody = logAdj - case resizeStatusDiff: + case resizeStatusBranch: + sumSB := statusBody + branchBody repoOuter := panelOuter(repoBody) + middleOuter := panelOuter(diffBody) prevStatusOuter := panelOuter(statusBody) var statusOuter int if y < repoOuter+prevStatusOuter { @@ -124,67 +142,75 @@ func (m *model) applyResizeDrag(x, y int) { } else { statusOuter = y - repoOuter } - statusOuterMax := innerTotal - repoBody - logBody - 1 + minBranchOuter := panelOuter(layoutMinBodyLines) + statusOuterMax := repoOuter + middleOuter - minBranchOuter if statusOuterMax < layoutMinPanelOuter { return } statusOuter = max(layoutMinPanelOuter, min(statusOuter, statusOuterMax)) - status := statusOuter - 2 - if status < layoutMinBodyLines { - return - } - available := innerTotal - repoBody - logBody - diff := available - status - if diff < layoutMinBodyLines { + st := statusOuter - 2 + br := sumSB - st + if br < layoutMinBodyLines || st < layoutMinBodyLines { return } m.layoutUseCustomVertical = true m.layoutRepoBody = repoBody - m.layoutStatusBody = status + m.layoutStatusBody = st + m.layoutBranchBody = br m.layoutLogBody = logBody - case resizeDiffLog: + case resizeMiddleLog: repoOuter := panelOuter(repoBody) - y0 := repoOuter + panelOuter(statusBody) - prevDiffOuter := panelOuter(diffBody) + y0 := repoOuter + prevMiddleOuter := panelOuter(diffBody) minLogOuter := panelOuter(layoutMinBodyLines) - maxDiffOuter := m.height - repoOuter - panelOuter(statusBody) - minLogOuter - if maxDiffOuter < minLogOuter { + minMiddleOuter := panelOuter(diffBodyFromStackedStatusBranch(layoutMinBodyLines, layoutMinBodyLines)) + maxMiddleOuter := m.height - repoOuter - minLogOuter + if maxMiddleOuter < minMiddleOuter { return } - var diffOuter int - if y <= y0+prevDiffOuter-1 { - diffOuter = y - y0 + 1 + var middleOuter int + if y <= y0+prevMiddleOuter-1 { + middleOuter = y - y0 + 1 } else { - diffOuter = y - y0 + middleOuter = y - y0 + } + middleOuter = max(minMiddleOuter, min(middleOuter, maxMiddleOuter)) + diff := middleOuter - 2 + sumSB := diff - 2 + if sumSB < 2*layoutMinBodyLines { + return } - diffOuter = max(minLogOuter, min(diffOuter, maxDiffOuter)) - diff := diffOuter - 2 - log := innerTotal - repoBody - statusBody - diff + st, br := splitSumSBPreservingRatio(statusBody, branchBody, sumSB) + if diffBodyFromStackedStatusBranch(st, br) < layoutMinBodyLines { + return + } + log := innerTotal - repoBody - st - br if log < layoutMinBodyLines { return } m.layoutUseCustomVertical = true m.layoutRepoBody = repoBody - m.layoutStatusBody = statusBody + m.layoutStatusBody = st + m.layoutBranchBody = br m.layoutLogBody = log - case resizeStatusBranches: + case resizeMiddleColumns: if m.width < layoutMinTermWidth { return } - statusOuter := x - branches := m.width - statusOuter - if branches < layoutMinStatusBranchesColumn { - branches = layoutMinStatusBranchesColumn + leftOuter := x + rightOuter := m.width - leftOuter + if rightOuter < layoutMinStatusBranchesColumn { + rightOuter = layoutMinStatusBranchesColumn } - if branches > m.width-layoutMinStatusBranchesColumn { - branches = m.width - layoutMinStatusBranchesColumn + if rightOuter > m.width-layoutMinStatusBranchesColumn { + rightOuter = m.width - layoutMinStatusBranchesColumn } - if branches < layoutMinStatusBranchesColumn || branches > m.width-layoutMinStatusBranchesColumn { + if rightOuter < layoutMinStatusBranchesColumn || rightOuter > m.width-layoutMinStatusBranchesColumn { return } - m.layoutBranchesOuter = branches + m.layoutBranchesOuter = rightOuter default: return @@ -193,29 +219,31 @@ func (m *model) applyResizeDrag(x, y int) { // resizeSplitAt returns which resize handle (if any) lies at (x, y). func (m *model) resizeSplitAt(x, y int) (resizeSplit, bool) { - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { return resizeNone, false } repoOuter := panelOuter(repoBody) statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) - statusW, _ := m.statusBranchesOuterWidths(m.width) + middleOuter := panelOuter(diffBody) + leftOuter, _ := m.middleRowColumnOuterWidths(m.width) if nearInt(y, repoOuter-1) || nearInt(y, repoOuter) { return resizeRepoStatus, true } - y1 := repoOuter + statusOuter - if nearInt(y, y1-1) || nearInt(y, y1) { - return resizeStatusDiff, true + yMidLog := repoOuter + middleOuter + if nearInt(y, yMidLog-1) || nearInt(y, yMidLog) { + return resizeMiddleLog, true } - y2 := repoOuter + statusOuter + diffOuter - if nearInt(y, y2-1) || nearInt(y, y2) { - return resizeDiffLog, true + if x >= 0 && x < leftOuter && y >= repoOuter && y < repoOuter+middleOuter { + yStatusBranch := repoOuter + statusOuter + if nearInt(y, yStatusBranch-1) || nearInt(y, yStatusBranch) { + return resizeStatusBranch, true + } } - if x >= 0 && x < m.width && y >= repoOuter && y < repoOuter+statusOuter { - if nearInt(x, statusW-1) || nearInt(x, statusW) { - return resizeStatusBranches, true + if y >= repoOuter && y < repoOuter+middleOuter && x >= 0 { + if nearInt(x, leftOuter-1) || nearInt(x, leftOuter) { + return resizeMiddleColumns, true } } return resizeNone, false diff --git a/ui/mouse_resize_test.go b/ui/mouse_resize_test.go index 70f8155..2e7b860 100644 --- a/ui/mouse_resize_test.go +++ b/ui/mouse_resize_test.go @@ -12,21 +12,16 @@ func TestResizeSplitAtHorizontalSeams(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, statusBody, diffBody, _ := m.layoutBodies() + repoBody, _, _, diffBody, _ := m.layoutBodies() repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) + middleOuter := panelOuter(diffBody) if k, ok := m.resizeSplitAt(0, repoOuter); !ok || k != resizeRepoStatus { t.Fatalf("resizeSplitAt repo seam y=%d = (%v,%v), want (resizeRepoStatus,true)", repoOuter, k, ok) } - y1 := repoOuter + statusOuter - if k, ok := m.resizeSplitAt(50, y1); !ok || k != resizeStatusDiff { - t.Fatalf("resizeSplitAt status/diff y=%d = (%v,%v), want (resizeStatusDiff,true)", y1, k, ok) - } - y2 := repoOuter + statusOuter + diffOuter - if k, ok := m.resizeSplitAt(50, y2); !ok || k != resizeDiffLog { - t.Fatalf("resizeSplitAt diff/log y=%d = (%v,%v), want (resizeDiffLog,true)", y2, k, ok) + y1 := repoOuter + middleOuter + if k, ok := m.resizeSplitAt(50, y1); !ok || k != resizeMiddleLog { + t.Fatalf("resizeSplitAt middle/log y=%d = (%v,%v), want (resizeMiddleLog,true)", y1, k, ok) } } @@ -36,12 +31,28 @@ func TestResizeSplitAtVerticalBetweenStatusAndBranches(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, _, _, _ := m.layoutBodies() + repoBody, statusBody, _, _, _ := m.layoutBodies() + repoOuter := panelOuter(repoBody) + statusOuter := panelOuter(statusBody) + leftW, _ := m.middleRowColumnOuterWidths(m.width) + y := repoOuter + statusOuter - 1 + if k, ok := m.resizeSplitAt(leftW/2, y); !ok || k != resizeStatusBranch { + t.Fatalf("resizeSplitAt status/branch seam = (%v,%v), want (resizeStatusBranch,true)", k, ok) + } +} + +func TestResizeSplitAtMiddleColumnDivider(t *testing.T) { + m := newTestModel() + m.width = 100 + m.height = 30 + m.repoList = []string{"a", "b", "c"} + + repoBody, _, _, diffBody, _ := m.layoutBodies() repoOuter := panelOuter(repoBody) - statusW, _ := m.statusBranchesOuterWidths(m.width) - y := repoOuter + 2 - if k, ok := m.resizeSplitAt(statusW, y); !ok || k != resizeStatusBranches { - t.Fatalf("resizeSplitAt vertical split = (%v,%v), want (resizeStatusBranches,true)", k, ok) + leftW, _ := m.middleRowColumnOuterWidths(m.width) + y := repoOuter + panelOuter(diffBody)/2 + if k, ok := m.resizeSplitAt(leftW, y); !ok || k != resizeMiddleColumns { + t.Fatalf("resizeSplitAt left/diff column = (%v,%v), want (resizeMiddleColumns,true)", k, ok) } } @@ -51,7 +62,7 @@ func TestMouseDragResizesRepoPane(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - before, _, _, _ := m.layoutBodies() + before, _, _, _, _ := m.layoutBodies() repoOuter := panelOuter(before) press := tea.MouseMsg{ X: 0, @@ -72,7 +83,7 @@ func TestMouseDragResizesRepoPane(t *testing.T) { } next, _ := mm0.Update(motion) mm := next.(*model) - after, _, _, _ := mm.layoutBodies() + after, _, _, _, _ := mm.layoutBodies() if after <= before { t.Fatalf("repo body after drag = %d, before = %d, expected larger", after, before) } diff --git a/ui/status_layout.go b/ui/status_layout.go index 2e27f70..b96893a 100644 --- a/ui/status_layout.go +++ b/ui/status_layout.go @@ -13,10 +13,18 @@ import ( "github.com/boyvinall/dirtygit/scanner" ) +// diffBodyFromStackedStatusBranch returns diff inner body height so the Diff pane outer height +// matches Status and Branches stacked (each pane contributes top+bottom borders). +func diffBodyFromStackedStatusBranch(statusBody, branchBody int) int { + return statusBody + branchBody + 2 +} + // autoLayoutBodies returns the default vertical split without user resize prefs. -func (m *model) autoLayoutBodies() (repoBody, statusBody, diffBody, logBody int) { +// Middle row: Status above Branches on the left, Diff on the right (same outer height as the stack). +// diffBody == statusBody+branchBody+2 so the Diff viewport fills the band; it is not added to the vertical body sum. +func (m *model) autoLayoutBodies() (repoBody, statusBody, branchBody, diffBody, logBody int) { if m.height < layoutMinTermHeight || m.width < layoutMinTermWidth { - return 0, 0, 0, 0 + return 0, 0, 0, 0, 0 } if m.zoomed { body := max( @@ -24,13 +32,15 @@ func (m *model) autoLayoutBodies() (repoBody, statusBody, diffBody, logBody int) m.height-2, layoutMinBodyLines) switch m.zoomTarget { case paneRepo: - return body, 0, 0, 0 - case paneStatus, paneBranches: - return 0, body, 0, 0 + return body, 0, 0, 0, 0 + case paneStatus: + return 0, body, 0, 0, 0 + case paneBranches: + return 0, 0, body, 0, 0 case paneDiff: - return 0, 0, body, 0 + return 0, 0, 0, body, 0 case paneLog: - return 0, 0, 0, body + return 0, 0, 0, 0, body } } effH := m.height @@ -52,44 +62,56 @@ func (m *model) autoLayoutBodies() (repoBody, statusBody, diffBody, logBody int) available = effH - layoutFrameStackOuterRows - repoBody - logBody } if available < layoutMinSpareForSplit { - return 0, 0, 0, 0 + return 0, 0, 0, 0, 0 } - // Give Status and Diff a 1:3 height split. - // Any remainder rows go to Diff to keep it as large as possible. - statusBody = available / 4 - diffBody = available - statusBody - if statusBody < layoutMinBodyLines || diffBody < layoutMinBodyLines || logBody < layoutMinBodyLines || repoBody < layoutMinBodyLines { - return 0, 0, 0, 0 + // Vertical budget: repo + log + status + branch == effH - layoutFrameStackOuterRows. + // Diff sits beside the status/branch stack (same outer height), so it does not add rows. + sumSB := available + statusBody = sumSB / 2 + branchBody = sumSB - statusBody + diffBody = diffBodyFromStackedStatusBranch(statusBody, branchBody) + if statusBody < layoutMinBodyLines || branchBody < layoutMinBodyLines || diffBody < layoutMinBodyLines || + logBody < layoutMinBodyLines || repoBody < layoutMinBodyLines || + statusBody+branchBody != available { + return 0, 0, 0, 0, 0 } - return repoBody, statusBody, diffBody, logBody + return repoBody, statusBody, branchBody, diffBody, logBody +} + +func (m *model) clearCustomVerticalLayout() { + m.layoutUseCustomVertical = false + m.layoutRepoBody, m.layoutStatusBody, m.layoutBranchBody, m.layoutLogBody = 0, 0, 0, 0 } -// layoutBodies returns inner content heights: repo list, status table, diff viewport, log viewport. +// layoutBodies returns inner content heights: repo list, status table, branch table, diff viewport, log viewport. // Each framed panel's total row count is panelOuter(body) (see framedBlock). -func (m *model) layoutBodies() (repoBody, statusBody, diffBody, logBody int) { - ar, as, ad, al := m.autoLayoutBodies() - if ar == 0 && as == 0 && ad == 0 && al == 0 { - return 0, 0, 0, 0 +func (m *model) layoutBodies() (repoBody, statusBody, branchBody, diffBody, logBody int) { + ar, as, ab, ad, al := m.autoLayoutBodies() + if ar == 0 && as == 0 && ab == 0 && ad == 0 && al == 0 { + return 0, 0, 0, 0, 0 } if m.zoomed || !m.layoutUseCustomVertical { - return ar, as, ad, al + return ar, as, ab, ad, al } innerTotal := m.height - layoutFrameStackOuterRows repoBody = m.layoutRepoBody statusBody = m.layoutStatusBody + branchBody = m.layoutBranchBody logBody = m.layoutLogBody - if repoBody < layoutMinBodyLines || statusBody < layoutMinBodyLines || logBody < layoutMinBodyLines { - return ar, as, ad, al + if repoBody < layoutMinBodyLines || statusBody < layoutMinBodyLines || branchBody < layoutMinBodyLines || logBody < layoutMinBodyLines { + m.clearCustomVerticalLayout() + return ar, as, ab, ad, al } available := innerTotal - repoBody - logBody - diffBody = available - statusBody - if available < layoutMinSpareForSplit || diffBody < layoutMinBodyLines || repoBody < layoutMinBodyLines || statusBody < layoutMinBodyLines || logBody < layoutMinBodyLines { - m.layoutUseCustomVertical = false - m.layoutRepoBody, m.layoutStatusBody, m.layoutLogBody = 0, 0, 0 - return ar, as, ad, al + diffBody = diffBodyFromStackedStatusBranch(statusBody, branchBody) + if available < layoutMinSpareForSplit || diffBody < layoutMinBodyLines || + statusBody+branchBody != available || + panelOuter(statusBody)+panelOuter(branchBody) != panelOuter(diffBody) { + m.clearCustomVerticalLayout() + return ar, as, ab, ad, al } - return repoBody, statusBody, diffBody, logBody + return repoBody, statusBody, branchBody, diffBody, logBody } // panelOuter converts an inner body height into full framed panel height. @@ -106,8 +128,8 @@ func (m *model) paneAtTerminalCell(x, y int) (pane, bool) { if x < 0 || y < 0 || x >= m.width || y >= m.height { return paneRepo, false } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { return paneRepo, false } if m.zoomed { @@ -115,25 +137,24 @@ func (m *model) paneAtTerminalCell(x, y int) (pane, bool) { } repoOuter := panelOuter(repoBody) statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) + middleOuter := panelOuter(diffBody) logOuter := panelOuter(logBody) + leftOuter, _ := m.middleRowColumnOuterWidths(m.width) if y < repoOuter { return paneRepo, true } y -= repoOuter - if y < statusOuter { - statusW, _ := m.statusBranchesOuterWidths(m.width) - if x < statusW { - return paneStatus, true + if y < middleOuter { + if x < leftOuter { + if y < statusOuter { + return paneStatus, true + } + return paneBranches, true } - return paneBranches, true - } - y -= statusOuter - if y < diffOuter { return paneDiff, true } - y -= diffOuter + y -= middleOuter if y < logOuter { return paneLog, true } @@ -149,25 +170,25 @@ func (m *model) innerWidth() int { return w } -// statusBranchesOuterWidths splits the row width between Status and Branches panes. -func (m *model) statusBranchesOuterWidths(total int) (statusOuter, branchesOuter int) { +// middleRowColumnOuterWidths splits the middle row: left (Status+Branches stack) vs right (Diff). +func (m *model) middleRowColumnOuterWidths(total int) (leftOuter, rightOuter int) { if m.layoutBranchesOuter > 0 { - bo := m.layoutBranchesOuter - bo = max(layoutMinStatusBranchesColumn, min(bo, total-layoutMinStatusBranchesColumn)) - return total - bo, bo + right := m.layoutBranchesOuter + right = max(layoutMinStatusBranchesColumn, min(right, total-layoutMinStatusBranchesColumn)) + return total - right, right } - // Default: equal outer width (remainder column goes to Branches when odd). - statusOuter = total / 2 - branchesOuter = total - statusOuter - return statusOuter, branchesOuter + rawRight := total * layoutDefaultMiddleDiffColumnPct / 100 + rightOuter = max(layoutMinStatusBranchesColumn, min(rawRight, total-layoutMinStatusBranchesColumn)) + leftOuter = total - rightOuter + return leftOuter, rightOuter } -// statusBranchesInnerWidths returns table content widths inside each pane's own border. -func (m *model) statusBranchesInnerWidths() (statusInnerW, branchInnerW int) { - statusOuterW, branchesOuterW := m.statusBranchesOuterWidths(m.width) - statusInnerW = max(layoutMinInnerContentWidth, statusOuterW-2) - branchInnerW = max(layoutMinInnerContentWidth, branchesOuterW-2) - return statusInnerW, branchInnerW +// middleRowColumnInnerWidths returns content widths inside the left stack panes and the Diff pane. +func (m *model) middleRowColumnInnerWidths() (leftInnerW, rightInnerW int) { + leftOuterW, rightOuterW := m.middleRowColumnOuterWidths(m.width) + leftInnerW = max(layoutMinInnerContentWidth, leftOuterW-2) + rightInnerW = max(layoutMinInnerContentWidth, rightOuterW-2) + return leftInnerW, rightInnerW } // syncViewports applies layout dimensions and refreshes all pane content, @@ -193,15 +214,17 @@ func (m *model) setLogVPContent() { // a loading line until a follow-up runDiffForGen is handled. Used when // the repository selection changes so scrolling stays responsive. func (m *model) applyViewportAndPanes(syncDiff bool) { - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { return } statusInnerW := m.innerWidth() branchInnerW := statusInnerW + diffInnerW := statusInnerW if !m.zoomed { - statusInnerW, branchInnerW = m.statusBranchesInnerWidths() + statusInnerW, diffInnerW = m.middleRowColumnInnerWidths() + branchInnerW = statusInnerW } m.statusTable.SetWidth(statusInnerW) @@ -209,10 +232,11 @@ func (m *model) applyViewportAndPanes(syncDiff bool) { m.statusTable.SetColumns(statusColumns(statusInnerW)) m.logVP.Width = m.innerWidth() m.logVP.Height = logBody - m.diffVP.Width = m.innerWidth() + m.diffVP.Width = diffInnerW m.diffVP.Height = diffBody m.refreshStatusContent() m.refreshBranchContent(branchInnerW) + m.branchTable.SetHeight(branchBody) if syncDiff { m.refreshDiffContent() } else if m.diffNeedsRefresh { diff --git a/ui/status_layout_test.go b/ui/status_layout_test.go index b2c838b..1ed2108 100644 --- a/ui/status_layout_test.go +++ b/ui/status_layout_test.go @@ -37,12 +37,12 @@ func TestPaneAtTerminalCell(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, statusBody, diffBody, logBody := m.layoutBodies() + repoBody, statusBody, _, diffBody, logBody := m.layoutBodies() repoOuter := panelOuter(repoBody) statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) + middleOuter := panelOuter(diffBody) logOuter := panelOuter(logBody) - statusW, _ := m.statusBranchesOuterWidths(m.width) + leftW, _ := m.middleRowColumnOuterWidths(m.width) tests := []struct { x, y int @@ -52,19 +52,20 @@ func TestPaneAtTerminalCell(t *testing.T) { {0, 0, paneRepo, true}, {99, repoOuter - 1, paneRepo, true}, {0, repoOuter, paneStatus, true}, - {statusW - 1, repoOuter, paneStatus, true}, - {statusW, repoOuter, paneBranches, true}, - {0, repoOuter + statusOuter, paneDiff, true}, - {0, repoOuter + statusOuter + diffOuter, paneLog, true}, - {0, repoOuter + statusOuter + diffOuter + logOuter - 1, paneLog, true}, + {leftW - 1, repoOuter, paneStatus, true}, + {leftW, repoOuter, paneDiff, true}, + {0, repoOuter + statusOuter, paneBranches, true}, + {leftW, repoOuter + statusOuter, paneDiff, true}, + {0, repoOuter + middleOuter, paneLog, true}, + {0, repoOuter + middleOuter + logOuter - 1, paneLog, true}, {-1, 0, paneRepo, false}, {0, m.height, paneRepo, false}, } for _, tc := range tests { got, ok := m.paneAtTerminalCell(tc.x, tc.y) if ok != tc.ok || got != tc.want { - t.Fatalf("paneAtTerminalCell(%d,%d) = (%v,%v), want (%v,%v) repoOuter=%d statusOuter=%d diffOuter=%d logOuter=%d statusW=%d", - tc.x, tc.y, got, ok, tc.want, tc.ok, repoOuter, statusOuter, diffOuter, logOuter, statusW) + t.Fatalf("paneAtTerminalCell(%d,%d) = (%v,%v), want (%v,%v) repoOuter=%d statusOuter=%d middleOuter=%d logOuter=%d leftW=%d", + tc.x, tc.y, got, ok, tc.want, tc.ok, repoOuter, statusOuter, middleOuter, logOuter, leftW) } } @@ -83,7 +84,7 @@ func TestMouseFocusClickUpdatesFocus(t *testing.T) { m.repoList = []string{"a", "b", "c"} m.focus = paneRepo - rb, sb, _, _ := m.layoutBodies() + rb, sb, _, _, _ := m.layoutBodies() click := tea.MouseMsg{ X: 0, Y: panelOuter(rb) + panelOuter(sb)/2, @@ -129,12 +130,12 @@ func TestLayoutBodies(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody < 3 || statusBody < 3 || diffBody < 3 || logBody < 3 { - t.Fatalf("layoutBodies() = (%d, %d, %d, %d), expected all >= 3", repoBody, statusBody, diffBody, logBody) + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody < 3 || statusBody < 3 || branchBody < 3 || diffBody < 3 || logBody < 3 { + t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), expected all >= 3", repoBody, statusBody, branchBody, diffBody, logBody) } - if diffBody < statusBody*3 { - t.Fatalf("layoutBodies() expected diff to be at least 3x status, got status=%d diff=%d", statusBody, diffBody) + if diffBody != statusBody+branchBody+2 { + t.Fatalf("layoutBodies() diff=%d want status+branch+2=%d", diffBody, statusBody+branchBody+2) } } @@ -144,9 +145,9 @@ func TestLayoutBodiesReturnsZerosOnSmallScreen(t *testing.T) { m.width = 10 m.height = 10 - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody != 0 || statusBody != 0 || diffBody != 0 || logBody != 0 { - t.Fatalf("layoutBodies() = (%d, %d, %d, %d), want (0,0,0,0)", repoBody, statusBody, diffBody, logBody) + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody != 0 || statusBody != 0 || branchBody != 0 || diffBody != 0 || logBody != 0 { + t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), want zeros", repoBody, statusBody, branchBody, diffBody, logBody) } } @@ -158,9 +159,9 @@ func TestLayoutBodiesZoomedPaneOnly(t *testing.T) { m.zoomed = true m.zoomTarget = paneStatus - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody != 0 || diffBody != 0 || logBody != 0 || statusBody == 0 { - t.Fatalf("layoutBodies() = (%d, %d, %d, %d), want repo=0 status>0 diff=0 log=0", repoBody, statusBody, diffBody, logBody) + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody != 0 || branchBody != 0 || diffBody != 0 || logBody != 0 || statusBody == 0 { + t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), want repo=0 status>0 branch=0 diff=0 log=0", repoBody, statusBody, branchBody, diffBody, logBody) } } diff --git a/ui/update.go b/ui/update.go index 5ecd60f..6f43ac2 100644 --- a/ui/update.go +++ b/ui/update.go @@ -277,24 +277,52 @@ func (m *model) toggleZoom() { m.zoomTarget = m.focus } -// cycleFocus moves focus across panes in forward or reverse order. +// tabFocusCycle is the Tab / Shift+Tab order. Diff is not included; focus it by clicking the pane. +var tabFocusCycle = []pane{paneRepo, paneStatus, paneBranches, paneLog} + +// cycleFocus moves focus across panes in forward or reverse order (skipping Diff). func (m *model) cycleFocus(forward bool) { - const paneCount = 5 + n := len(tabFocusCycle) + cur := m.focus if m.zoomed { + cur = m.zoomTarget + } + + var i int + found := false + for j, p := range tabFocusCycle { + if p == cur { + i = j + found = true + break + } + } + if !found { + if cur == paneDiff { + // Mouse-focused Diff: Tab continues the main layout order (Branches → Diff → Log). + if forward { + i = 3 // log + } else { + i = 2 // branches + } + } else { + i = 0 + } + } else { if forward { - m.zoomTarget = (m.zoomTarget + 1) % paneCount + i = (i + 1) % n } else { - m.zoomTarget = (m.zoomTarget - 1 + paneCount) % paneCount + i = (i - 1 + n) % n } - m.focus = m.zoomTarget - return } - if forward { - m.focus = (m.focus + 1) % paneCount + next := tabFocusCycle[i] + if m.zoomed { + m.zoomTarget = next + m.focus = next return } - m.focus = (m.focus - 1 + paneCount) % paneCount + m.focus = next } // handleCommandKey handles global command keys and focus controls. @@ -383,6 +411,21 @@ func (m *model) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { m.deleteConfirmYes = false return m, nil, true } + case " ": + if m.focus != paneDiff && m.focus != paneStatus { + return m, nil, false + } + prevMode := m.diffMode + if m.diffMode == diffModeWorktree { + m.diffMode = diffModeStaged + } else { + m.diffMode = diffModeWorktree + } + if m.diffMode != prevMode { + m.diffNeedsRefresh = true + m.syncViewports() + } + return m, nil, true default: if helpKey(msg) { m.helpOpen = true @@ -395,7 +438,8 @@ func (m *model) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { // listKeyScrollPage is the step size for Shift+↑/↓ in scrollable lists and viewports. const listKeyScrollPage = 10 -// handleArrowKey applies directional key behavior for the focused pane. +// handleArrowKey applies directional keys: scrolling / repo navigation, and +// ←/→ to move focus between Status and Diff. func (m *model) handleArrowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { switch msg.Type { case tea.KeyUp, tea.KeyDown, tea.KeyShiftUp, tea.KeyShiftDown: @@ -474,21 +518,24 @@ func (m *model) handleArrowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { m.logVP, cmd = m.logVP.Update(msg) return m, cmd, true } - case tea.KeyLeft, tea.KeyRight: - if m.focus != paneDiff && m.focus != paneStatus { - return m, nil, false - } - prevMode := m.diffMode - if msg.Type == tea.KeyLeft { - m.diffMode = diffModeWorktree - } else { - m.diffMode = diffModeStaged + case tea.KeyRight: + if m.focus == paneStatus { + m.focus = paneDiff + if m.zoomed { + m.zoomTarget = paneDiff + } + m.syncViewports() + return m, nil, true } - if m.diffMode != prevMode { - m.diffNeedsRefresh = true + case tea.KeyLeft: + if m.focus == paneDiff { + m.focus = paneStatus + if m.zoomed { + m.zoomTarget = paneStatus + } m.syncViewports() + return m, nil, true } - return m, nil, true } return m, nil, false } diff --git a/ui/update_view_test.go b/ui/update_view_test.go index aad1c5a..0b6c6c4 100644 --- a/ui/update_view_test.go +++ b/ui/update_view_test.go @@ -88,6 +88,26 @@ func TestHandleCommandKeyFocusAndZoom(t *testing.T) { t.Fatalf("shift+tab should move focus back to repo, got focus=%v handled=%v", m.focus, handled) } + // Full forward cycle: Repo → Status → Branches → Log → Repo (never Diff via Tab). + for _, want := range []pane{paneStatus, paneBranches, paneLog, paneRepo} { + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyTab}) + if !handled || m.focus != want { + t.Fatalf("tab full cycle want focus=%v got %v handled=%v", want, m.focus, handled) + } + } + + m.focus = paneDiff + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyTab}) + if !handled || m.focus != paneLog { + t.Fatalf("tab from mouse-focused Diff should go to Log, got focus=%v handled=%v", m.focus, handled) + } + m.focus = paneDiff + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyShiftTab}) + if !handled || m.focus != paneBranches { + t.Fatalf("shift+tab from Diff should go to Branches, got focus=%v handled=%v", m.focus, handled) + } + + m.focus = paneRepo _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyEnter}) if !handled || !m.zoomed || m.zoomTarget != paneRepo { t.Fatalf("enter should enable zoom on focused pane, got zoomed=%v zoomTarget=%v", m.zoomed, m.zoomTarget) @@ -117,6 +137,38 @@ func TestHandleArrowKeyRepoNavigation(t *testing.T) { } } +// TestHandleArrowKeyStatusDiffFocus verifies →/← moves focus between Status and Diff. +func TestHandleArrowKeyStatusDiffFocus(t *testing.T) { + m := newTestModel() + m.width = 120 + m.height = 40 + m.focus = paneStatus + + _, _, handled := m.handleArrowKey(tea.KeyMsg{Type: tea.KeyRight}) + if !handled || m.focus != paneDiff { + t.Fatalf("right from status should focus diff, got focus=%v handled=%v", m.focus, handled) + } + + _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyLeft}) + if !handled || m.focus != paneStatus { + t.Fatalf("left from diff should focus status, got focus=%v handled=%v", m.focus, handled) + } + + m.zoomed = true + m.zoomTarget = paneStatus + m.focus = paneStatus + _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyRight}) + if !handled || m.focus != paneDiff || m.zoomTarget != paneDiff { + t.Fatalf("zoomed right should move focus and zoom target to diff, got focus=%v zoomTarget=%v handled=%v", + m.focus, m.zoomTarget, handled) + } + _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyLeft}) + if !handled || m.focus != paneStatus || m.zoomTarget != paneStatus { + t.Fatalf("zoomed left should move focus and zoom target to status, got focus=%v zoomTarget=%v handled=%v", + m.focus, m.zoomTarget, handled) + } +} + // TestHandleArrowKeyRepoShiftStep verifies Shift+↓/↑ moves the repo cursor by 10 (clamped). func TestHandleArrowKeyRepoShiftStep(t *testing.T) { m := newTestModel() @@ -180,37 +232,37 @@ func TestRepoListScrollFollowsCursor(t *testing.T) { } } -// TestHandleArrowKeyDiffModeToggle verifies staged/worktree diff switching. -func TestHandleArrowKeyDiffModeToggle(t *testing.T) { +// TestHandleCommandKeySpaceDiffModeToggle verifies Space toggles staged/worktree diff in the Diff pane. +func TestHandleCommandKeySpaceDiffModeToggle(t *testing.T) { m := newTestModel() m.focus = paneDiff m.diffMode = diffModeWorktree - _, _, handled := m.handleArrowKey(tea.KeyMsg{Type: tea.KeyRight}) + _, _, handled := m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) if !handled || m.diffMode != diffModeStaged { - t.Fatalf("right should switch diff mode to staged, got mode=%v handled=%v", m.diffMode, handled) + t.Fatalf("space should switch diff mode to staged, got mode=%v handled=%v", m.diffMode, handled) } - _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyLeft}) + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) if !handled || m.diffMode != diffModeWorktree { - t.Fatalf("left should switch diff mode to worktree, got mode=%v handled=%v", m.diffMode, handled) + t.Fatalf("space should switch diff mode to worktree, got mode=%v handled=%v", m.diffMode, handled) } } -// TestHandleArrowKeyStatusPaneDiffModeToggle verifies Status pane uses the same ←/→ diff mode as Diff. -func TestHandleArrowKeyStatusPaneDiffModeToggle(t *testing.T) { +// TestHandleCommandKeyStatusPaneSpaceDiffModeToggle verifies Status pane uses the same Space diff toggle as Diff. +func TestHandleCommandKeyStatusPaneSpaceDiffModeToggle(t *testing.T) { m := newTestModel() m.focus = paneStatus m.diffMode = diffModeWorktree - _, _, handled := m.handleArrowKey(tea.KeyMsg{Type: tea.KeyRight}) + _, _, handled := m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) if !handled || m.diffMode != diffModeStaged { - t.Fatalf("right from status should switch diff mode to staged, got mode=%v handled=%v", m.diffMode, handled) + t.Fatalf("space from status should switch diff mode to staged, got mode=%v handled=%v", m.diffMode, handled) } - _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyLeft}) + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) if !handled || m.diffMode != diffModeWorktree { - t.Fatalf("left from status should switch diff mode to worktree, got mode=%v handled=%v", m.diffMode, handled) + t.Fatalf("space from status should switch diff mode to worktree, got mode=%v handled=%v", m.diffMode, handled) } } diff --git a/ui/view.go b/ui/view.go index e1a145d..1384e56 100644 --- a/ui/view.go +++ b/ui/view.go @@ -113,13 +113,14 @@ func (m *model) helpPanel() string { lines := []string{ "Click Focus a pane; in Repositories or Status (when focused), select a row", "Drag (border) Resize adjacent panes (unavailable when zoomed, scanning, on error, or with an overlay open)", - "Tab Next pane: Repositories → Status → Branches → Diff → Log; when zoomed, cycle which pane is fullscreen", + "Tab Next pane: Repositories → Status → Branches → Log (Diff: click or → from Status); when zoomed, cycle which pane is fullscreen", "Shift+Tab Previous pane; when zoomed, cycle backward", "Enter Zoom focused pane; Enter again restores the split layout", "Esc Exit zoom, or clear Status file selection; also closes this help", "↑ / ↓ Move repo selection or scroll Status / Diff / Log", + "← / → Status focused: → focuses Diff; Diff focused: ← focuses Status", "Shift+↑/↓ Same, in steps of 10 lines", - "← / → In Status or Diff: switch Worktree vs Staged diff", + "Space In Status or Diff: toggle Worktree vs Staged diff", "a r With a file row selected (Status or Diff): git add / git reset (unstage) that path", "C With a file row selected (Status or Diff): restore file to last commit (confirms git checkout HEAD -- path)", "s Scan / rescan", @@ -286,12 +287,18 @@ func (m *model) framedBlock(p pane, outerW, outerH int, title string, body strin return strings.Join(framed, "\n") } -// framedStatusBranchesRow draws Status and Branches as two framed panes side by side. -func (m *model) framedStatusBranchesRow(outerH int, statusBody, branchesBody string) string { - statusOuterW, branchesOuterW := m.statusBranchesOuterWidths(m.width) - statusBlock := m.framedBlock(paneStatus, statusOuterW, outerH, "Status", statusBody) - branchesBlock := m.framedBlock(paneBranches, branchesOuterW, outerH, "Branches", branchesBody) - return lipgloss.JoinHorizontal(lipgloss.Top, statusBlock, branchesBlock) +// framedMiddleRow draws the middle band: Status above Branches on the left, Diff on the right. +func (m *model) framedMiddleRow(statusBody, branchBody, diffBody int, statusView, branchView, diffView string) string { + leftW, rightW := m.middleRowColumnOuterWidths(m.width) + statusOuter := panelOuter(statusBody) + branchOuter := panelOuter(branchBody) + diffOuter := panelOuter(diffBody) + leftCol := lipgloss.JoinVertical(lipgloss.Left, + m.framedBlock(paneStatus, leftW, statusOuter, "Status", statusView), + m.framedBlock(paneBranches, leftW, branchOuter, "Branches", branchView), + ) + rightCol := m.framedBlock(paneDiff, rightW, diffOuter, "Diff", diffView) + return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) } // clampRepoScroll keeps repoScrollTop in range and ensures the cursor row is visible. @@ -332,8 +339,8 @@ func (m *model) clampRepoScroll(innerH int) { // syncRepoListScrollOnly updates repoScrollTop for the current cursor; it is // cheap and used while keyboard navigation debounces the rest of the UI. func (m *model) syncRepoListScrollOnly() { - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { return } m.clampRepoScroll(repoBody) @@ -407,20 +414,17 @@ func (m *model) renderZoomedPane(repoBody int) string { } } -// renderMainStack composes the standard four-pane vertical layout. -func (m *model) renderMainStack(repoBody, statusBody, diffBody, logBody int) string { +// renderMainStack composes the standard main layout: repo, middle row (status/branch + diff), log. +func (m *model) renderMainStack(repoBody, statusBody, branchBody, diffBody, logBody int) string { repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) logOuter := panelOuter(logBody) repoBlock := m.framedBlock(paneRepo, m.width, repoOuter, "Repositories", m.repoListView(repoBody)) - statusRow := m.framedStatusBranchesRow(statusOuter, m.statusTable.View(), m.branchTable.View()) - diffBlock := m.framedBlock(paneDiff, m.width, diffOuter, "Diff", m.diffVP.View()) + middleRow := m.framedMiddleRow(statusBody, branchBody, diffBody, m.statusTable.View(), m.branchTable.View(), m.diffVP.View()) m.setLogVPContent() logBlock := m.framedBlock(paneLog, m.width, logOuter, "Log", m.logVP.View()) - return lipgloss.JoinVertical(lipgloss.Left, repoBlock, statusRow, diffBlock, logBlock) + return lipgloss.JoinVertical(lipgloss.Left, repoBlock, middleRow, logBlock) } // renderErrorOverlay shows an error dialog with recovery hints. @@ -457,8 +461,8 @@ func (m *model) View() string { return m.renderScanOverlay() } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() + if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { return "" } @@ -466,7 +470,7 @@ func (m *model) View() string { if m.zoomed { stack = m.renderZoomedPane(repoBody) } else { - stack = m.renderMainStack(repoBody, statusBody, diffBody, logBody) + stack = m.renderMainStack(repoBody, statusBody, branchBody, diffBody, logBody) } if m.err != nil { return m.renderErrorOverlay() diff --git a/ui/view_test.go b/ui/view_test.go index d9f2487..93076a6 100644 --- a/ui/view_test.go +++ b/ui/view_test.go @@ -15,37 +15,40 @@ func init() { lipgloss.SetColorProfile(termenv.ANSI256) } -// TestStatusBranchesRowUsesFullWidth ensures outer pane widths sum to the terminal width. -func TestStatusBranchesRowUsesFullWidth(t *testing.T) { +// TestMiddleRowColumnWidthsSumToTermWidth ensures middle row columns fill the width. +func TestMiddleRowColumnWidthsSumToTermWidth(t *testing.T) { m := newTestModel() m.width = 100 - so, bo := m.statusBranchesOuterWidths(m.width) - if so+bo != m.width { - t.Fatalf("statusOuter(%d) + branchesOuter(%d) = %d, want terminal width %d", - so, bo, so+bo, m.width) + lo, ro := m.middleRowColumnOuterWidths(m.width) + if lo+ro != m.width { + t.Fatalf("leftOuter(%d) + rightOuter(%d) = %d, want terminal width %d", + lo, ro, lo+ro, m.width) } - if so != 50 || bo != 50 { - t.Fatalf("default horizontal split = (%d,%d), want (50,50)", so, bo) + if lo != 30 || ro != 70 { + t.Fatalf("default horizontal split = (%d,%d), want (30,70)", lo, ro) } } -func TestStatusBranchesOuterWidthsOddWidthPutsRemainderOnBranches(t *testing.T) { +func TestMiddleRowColumnOuterWidthsOddWidth(t *testing.T) { m := newTestModel() m.width = 81 - so, bo := m.statusBranchesOuterWidths(m.width) - if so != 40 || bo != 41 { - t.Fatalf("statusOuter, branchesOuter = (%d,%d), want (40,41)", so, bo) + lo, ro := m.middleRowColumnOuterWidths(m.width) + if lo+ro != m.width { + t.Fatalf("leftOuter(%d)+rightOuter(%d) != width %d", lo, ro, m.width) + } + if lo != 25 || ro != 56 { + t.Fatalf("leftOuter, rightOuter = (%d,%d), want (25,56)", lo, ro) } } // TestStatusTableViewFitsInnerWidth guards against table padding widening rows past the pane. func TestStatusTableViewFitsInnerWidth(t *testing.T) { m := newTestModel() - m.width = 80 + m.width = 100 m.height = 30 m.repoList = []string{"/repo"} m.syncViewports() - si, _ := m.statusBranchesInnerWidths() + si, _ := m.middleRowColumnInnerWidths() for _, line := range strings.Split(m.statusTable.View(), "\n") { if line == "" { continue @@ -73,7 +76,7 @@ func TestBranchTableViewFitsInnerWidth(t *testing.T) { }, }) m.syncViewports() - _, bi := m.statusBranchesInnerWidths() + _, bi := m.middleRowColumnInnerWidths() for _, line := range strings.Split(m.branchTable.View(), "\n") { if line == "" { continue @@ -84,11 +87,12 @@ func TestBranchTableViewFitsInnerWidth(t *testing.T) { } } -// blankStatusBranchesRow renders the Status/Branches row with empty table bodies for predictable ANSI. -func blankStatusBranchesRow(m *model, outerH int) string { +// blankMiddleRow renders the middle band with empty bodies for predictable ANSI. +func blankMiddleRow(m *model, statusBody, branchBody, diffBody int) string { m.syncViewports() - si, bi := m.statusBranchesInnerWidths() - return m.framedStatusBranchesRow(outerH, strings.Repeat(" ", si), strings.Repeat(" ", bi)) + li, ri := m.middleRowColumnInnerWidths() + return m.framedMiddleRow(statusBody, branchBody, diffBody, + strings.Repeat(" ", li), strings.Repeat(" ", li), strings.Repeat(" ", ri)) } // ansi214Count counts 256-color foreground sequences for lipgloss color 214 (focus accent). @@ -96,20 +100,21 @@ func ansi214Count(s string) int { return strings.Count(s, "38;5;214m") } -// TestFramedStatusBranchesBorderAccentIsolation checks that only the focused pane's border uses +// TestFramedMiddleRowBorderAccentIsolation checks that only the focused pane's border uses // the 214 accent when Status or Branches is focused (repo focus uses none). -func TestFramedStatusBranchesBorderAccentIsolation(t *testing.T) { +func TestFramedMiddleRowBorderAccentIsolation(t *testing.T) { m := newTestModel() m.width = 120 m.height = 28 m.repoList = []string{"/r"} + sb, bb, db := 4, 4, 10 m.focus = paneRepo - repo := blankStatusBranchesRow(m, 6) + repo := blankMiddleRow(m, sb, bb, db) m.focus = paneStatus - st := blankStatusBranchesRow(m, 6) + st := blankMiddleRow(m, sb, bb, db) m.focus = paneBranches - br := blankStatusBranchesRow(m, 6) + br := blankMiddleRow(m, sb, bb, db) if c := ansi214Count(repo); c != 0 { t.Fatalf("repo focus: expected no 214 border accents, got %d in %q", c, repo) From 78e65fbe9644d3ef2e1ef4f24601a32c72a617fb Mon Sep 17 00:00:00 2001 From: Matt Vinall Date: Fri, 1 May 2026 10:33:55 +0100 Subject: [PATCH 2/2] chore: refactor ui logic --- ui/layout_geometry.go | 95 +++++++++++++++++++++++++++ ui/mouse_line_select.go | 14 ++-- ui/mouse_line_select_test.go | 12 ++-- ui/mouse_resize.go | 35 ++++------ ui/mouse_resize_test.go | 28 ++++---- ui/status_layout.go | 120 ++++++++++++++--------------------- ui/status_layout_test.go | 36 +++++------ ui/view.go | 28 ++++---- 8 files changed, 214 insertions(+), 154 deletions(-) create mode 100644 ui/layout_geometry.go diff --git a/ui/layout_geometry.go b/ui/layout_geometry.go new file mode 100644 index 0000000..ad25e76 --- /dev/null +++ b/ui/layout_geometry.go @@ -0,0 +1,95 @@ +package ui + +// paneLayout holds inner body heights for the main stack (one pane may be +// non-zero in zoom mode; all zero means layout could not be computed). +type paneLayout struct { + repo, status, branch, diff, logBody int +} + +func (p paneLayout) isZero() bool { + return p.repo == 0 && p.status == 0 && p.branch == 0 && p.diff == 0 && p.logBody == 0 +} + +// innerVerticalBudget is total inner body lines available below the outer chrome +// (status bar, titles, etc.). +func innerVerticalBudget(termHeight int) int { + return termHeight - layoutFrameStackOuterRows +} + +// panelOuter converts an inner body height into full framed panel height. +func panelOuter(body int) int { + return body + 2 // top border (with title) + body + bottom border +} + +// diffBodyFromStackedStatusBranch returns diff inner body height so the Diff pane +// outer height matches Status and Branches stacked (each pane contributes top+bottom borders). +func diffBodyFromStackedStatusBranch(statusBody, branchBody int) int { + return statusBody + branchBody + 2 +} + +// tightenRepoAndLogForMiddleSpare shrinks log then repo until the middle row has at +// least layoutMinSpareForSplit lines, or returns ok=false. +func tightenRepoAndLogForMiddleSpare(effH, repoBody, logBody int) (repo, log int, available int, ok bool) { + repo, log = repoBody, logBody + available = effH - layoutFrameStackOuterRows - repo - log + for available < layoutMinSpareForSplit && log > layoutMinBodyLines { + log-- + available = effH - layoutFrameStackOuterRows - repo - log + } + for available < layoutMinSpareForSplit && repo > layoutMinBodyLines { + repo-- + available = effH - layoutFrameStackOuterRows - repo - log + } + if available < layoutMinSpareForSplit { + return 0, 0, 0, false + } + return repo, log, available, true +} + +// splitStatusBranchEvenly divides available inner lines between status and branch tables. +func splitStatusBranchEvenly(available int) (statusBody, branchBody int) { + statusBody = available / 2 + branchBody = available - statusBody + return statusBody, branchBody +} + +// nonZoomStackBodiesValid checks minimum inner heights and that status+branch fill +// the middle vertical budget (diff height is derived from the stack). +func nonZoomStackBodiesValid(repo, status, branch, diff, logBody, available int) bool { + if status < layoutMinBodyLines || branch < layoutMinBodyLines || diff < layoutMinBodyLines || + logBody < layoutMinBodyLines || repo < layoutMinBodyLines || + status+branch != available { + return false + } + return true +} + +// customVerticalLayoutOK validates user-resized vertical splits against the same +// invariants as layoutBodies. +func customVerticalLayoutOK(status, branch, diff, available int) bool { + if available < layoutMinSpareForSplit || diff < layoutMinBodyLines || + status+branch != available || + panelOuter(status)+panelOuter(branch) != panelOuter(diff) { + return false + } + return true +} + +// clampDiffColumnOuterWidth maps a mouse X (left column outer width) to a valid +// Diff column outer width for the middle row. +func clampDiffColumnOuterWidth(termWidth, leftOuter int) (rightOuter int, ok bool) { + if termWidth < layoutMinTermWidth { + return 0, false + } + rightOuter = termWidth - leftOuter + if rightOuter < layoutMinStatusBranchesColumn { + rightOuter = layoutMinStatusBranchesColumn + } + if rightOuter > termWidth-layoutMinStatusBranchesColumn { + rightOuter = termWidth - layoutMinStatusBranchesColumn + } + if rightOuter < layoutMinStatusBranchesColumn || rightOuter > termWidth-layoutMinStatusBranchesColumn { + return 0, false + } + return rightOuter, true +} diff --git a/ui/mouse_line_select.go b/ui/mouse_line_select.go index 8a0c92a..83a6ef8 100644 --- a/ui/mouse_line_select.go +++ b/ui/mouse_line_select.go @@ -47,8 +47,8 @@ func (m *model) repoPaneOuterHeight() (outerH int, ok bool) { if m.height < layoutMinTermHeight || m.width < layoutMinTermWidth { return 0, false } - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return 0, false } if m.zoomed { @@ -57,7 +57,7 @@ func (m *model) repoPaneOuterHeight() (outerH int, ok bool) { } return m.height, true } - return panelOuter(repoBody), true + return panelOuter(lay.repo), true } // statusPaneFrame returns the top Y of the status pane, its outer height, and @@ -66,8 +66,8 @@ func (m *model) statusPaneFrame() (topY, outerH, outerW int, ok bool) { if m.height < layoutMinTermHeight || m.width < layoutMinTermWidth { return 0, 0, 0, false } - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return 0, 0, 0, false } if m.zoomed { @@ -76,8 +76,8 @@ func (m *model) statusPaneFrame() (topY, outerH, outerW int, ok bool) { } return 0, m.height, m.width, true } - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) + repoOuter := panelOuter(lay.repo) + statusOuter := panelOuter(lay.status) leftW, _ := m.middleRowColumnOuterWidths(m.width) return repoOuter, statusOuter, leftW, true } diff --git a/ui/mouse_line_select_test.go b/ui/mouse_line_select_test.go index de35fd0..672ed1c 100644 --- a/ui/mouse_line_select_test.go +++ b/ui/mouse_line_select_test.go @@ -16,8 +16,8 @@ func TestMouseRepoLineSelect(t *testing.T) { m.cursor = 0 m.focus = paneRepo - rb, _, _, _, _ := m.layoutBodies() - repoOuter := panelOuter(rb) + lay := m.layoutBodies() + repoOuter := panelOuter(lay.repo) if repoOuter < 4 { t.Fatalf("repoOuter=%d too small for test", repoOuter) } @@ -57,8 +57,8 @@ func TestMouseStatusLineSelect(t *testing.T) { m.focus = paneStatus m.syncViewports() - rb, _, _, _, _ := m.layoutBodies() - statusTop := panelOuter(rb) + lay := m.layoutBodies() + statusTop := panelOuter(lay.repo) // First data row: inner starts at statusTop+1, then header, then row 0. y := statusTop + 1 + statusTableHeaderLines(m.statusTable) msg := tea.MouseMsg{ @@ -98,8 +98,8 @@ func TestMouseStatusHeaderClickConsumed(t *testing.T) { m.focus = paneStatus m.syncViewports() - rb, _, _, _, _ := m.layoutBodies() - statusTop := panelOuter(rb) + lay := m.layoutBodies() + statusTop := panelOuter(lay.repo) y := statusTop + 1 // first inner line = table header prev := m.statusTable.Cursor() msg := tea.MouseMsg{X: 2, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} diff --git a/ui/mouse_resize.go b/ui/mouse_resize.go index d2a3336..51593ed 100644 --- a/ui/mouse_resize.go +++ b/ui/mouse_resize.go @@ -25,8 +25,8 @@ func (m *model) handleMousePaneResize(msg tea.MouseMsg) bool { return false } - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return false } @@ -87,11 +87,12 @@ func splitSumSBPreservingRatio(statusBody, branchBody, sumSB int) (st, br int) { } func (m *model) applyResizeDrag(x, y int) { - innerTotal := m.height - layoutFrameStackOuterRows - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { + innerTotal := innerVerticalBudget(m.height) + lay := m.layoutBodies() + if lay.isZero() { return } + repoBody, statusBody, branchBody, diffBody, logBody := lay.repo, lay.status, lay.branch, lay.diff, lay.logBody switch m.resizeDrag { case resizeRepoStatus: @@ -196,18 +197,8 @@ func (m *model) applyResizeDrag(x, y int) { m.layoutLogBody = log case resizeMiddleColumns: - if m.width < layoutMinTermWidth { - return - } - leftOuter := x - rightOuter := m.width - leftOuter - if rightOuter < layoutMinStatusBranchesColumn { - rightOuter = layoutMinStatusBranchesColumn - } - if rightOuter > m.width-layoutMinStatusBranchesColumn { - rightOuter = m.width - layoutMinStatusBranchesColumn - } - if rightOuter < layoutMinStatusBranchesColumn || rightOuter > m.width-layoutMinStatusBranchesColumn { + rightOuter, ok := clampDiffColumnOuterWidth(m.width, x) + if !ok { return } m.layoutBranchesOuter = rightOuter @@ -219,13 +210,13 @@ func (m *model) applyResizeDrag(x, y int) { // resizeSplitAt returns which resize handle (if any) lies at (x, y). func (m *model) resizeSplitAt(x, y int) (resizeSplit, bool) { - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return resizeNone, false } - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - middleOuter := panelOuter(diffBody) + repoOuter := panelOuter(lay.repo) + statusOuter := panelOuter(lay.status) + middleOuter := panelOuter(lay.diff) leftOuter, _ := m.middleRowColumnOuterWidths(m.width) if nearInt(y, repoOuter-1) || nearInt(y, repoOuter) { diff --git a/ui/mouse_resize_test.go b/ui/mouse_resize_test.go index 2e7b860..b61cd3e 100644 --- a/ui/mouse_resize_test.go +++ b/ui/mouse_resize_test.go @@ -12,9 +12,9 @@ func TestResizeSplitAtHorizontalSeams(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, _, _, diffBody, _ := m.layoutBodies() - repoOuter := panelOuter(repoBody) - middleOuter := panelOuter(diffBody) + lay := m.layoutBodies() + repoOuter := panelOuter(lay.repo) + middleOuter := panelOuter(lay.diff) if k, ok := m.resizeSplitAt(0, repoOuter); !ok || k != resizeRepoStatus { t.Fatalf("resizeSplitAt repo seam y=%d = (%v,%v), want (resizeRepoStatus,true)", repoOuter, k, ok) @@ -31,9 +31,9 @@ func TestResizeSplitAtVerticalBetweenStatusAndBranches(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, statusBody, _, _, _ := m.layoutBodies() - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) + l := m.layoutBodies() + repoOuter := panelOuter(l.repo) + statusOuter := panelOuter(l.status) leftW, _ := m.middleRowColumnOuterWidths(m.width) y := repoOuter + statusOuter - 1 if k, ok := m.resizeSplitAt(leftW/2, y); !ok || k != resizeStatusBranch { @@ -47,10 +47,10 @@ func TestResizeSplitAtMiddleColumnDivider(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, _, _, diffBody, _ := m.layoutBodies() - repoOuter := panelOuter(repoBody) + l := m.layoutBodies() + repoOuter := panelOuter(l.repo) leftW, _ := m.middleRowColumnOuterWidths(m.width) - y := repoOuter + panelOuter(diffBody)/2 + y := repoOuter + panelOuter(l.diff)/2 if k, ok := m.resizeSplitAt(leftW, y); !ok || k != resizeMiddleColumns { t.Fatalf("resizeSplitAt left/diff column = (%v,%v), want (resizeMiddleColumns,true)", k, ok) } @@ -62,8 +62,8 @@ func TestMouseDragResizesRepoPane(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - before, _, _, _, _ := m.layoutBodies() - repoOuter := panelOuter(before) + before := m.layoutBodies() + repoOuter := panelOuter(before.repo) press := tea.MouseMsg{ X: 0, Y: repoOuter, @@ -83,9 +83,9 @@ func TestMouseDragResizesRepoPane(t *testing.T) { } next, _ := mm0.Update(motion) mm := next.(*model) - after, _, _, _, _ := mm.layoutBodies() - if after <= before { - t.Fatalf("repo body after drag = %d, before = %d, expected larger", after, before) + after := mm.layoutBodies() + if after.repo <= before.repo { + t.Fatalf("repo body after drag = %d, before = %d, expected larger", after.repo, before.repo) } if !mm.layoutUseCustomVertical { t.Fatal("expected layoutUseCustomVertical after resize") diff --git a/ui/status_layout.go b/ui/status_layout.go index b96893a..bc38219 100644 --- a/ui/status_layout.go +++ b/ui/status_layout.go @@ -13,18 +13,12 @@ import ( "github.com/boyvinall/dirtygit/scanner" ) -// diffBodyFromStackedStatusBranch returns diff inner body height so the Diff pane outer height -// matches Status and Branches stacked (each pane contributes top+bottom borders). -func diffBodyFromStackedStatusBranch(statusBody, branchBody int) int { - return statusBody + branchBody + 2 -} - // autoLayoutBodies returns the default vertical split without user resize prefs. // Middle row: Status above Branches on the left, Diff on the right (same outer height as the stack). // diffBody == statusBody+branchBody+2 so the Diff viewport fills the band; it is not added to the vertical body sum. -func (m *model) autoLayoutBodies() (repoBody, statusBody, branchBody, diffBody, logBody int) { +func (m *model) autoLayoutBodies() paneLayout { if m.height < layoutMinTermHeight || m.width < layoutMinTermWidth { - return 0, 0, 0, 0, 0 + return paneLayout{} } if m.zoomed { body := max( @@ -32,51 +26,38 @@ func (m *model) autoLayoutBodies() (repoBody, statusBody, branchBody, diffBody, m.height-2, layoutMinBodyLines) switch m.zoomTarget { case paneRepo: - return body, 0, 0, 0, 0 + return paneLayout{repo: body} case paneStatus: - return 0, body, 0, 0, 0 + return paneLayout{status: body} case paneBranches: - return 0, 0, body, 0, 0 + return paneLayout{branch: body} case paneDiff: - return 0, 0, 0, body, 0 + return paneLayout{diff: body} case paneLog: - return 0, 0, 0, 0, body + return paneLayout{logBody: body} + default: + return paneLayout{} } } effH := m.height - logBody = layoutDefaultLogBodyLines + logBody := layoutDefaultLogBodyLines n := len(m.repoList) if n == 0 { n = 1 } - repoBody = min(n+2, effH/3) + repoBody := min(n+2, effH/3) repoBody = max(layoutMinBodyLines, repoBody) - available := effH - layoutFrameStackOuterRows - repoBody - logBody - for available < layoutMinSpareForSplit && logBody > layoutMinBodyLines { - logBody-- - available = effH - layoutFrameStackOuterRows - repoBody - logBody - } - for available < layoutMinSpareForSplit && repoBody > layoutMinBodyLines { - repoBody-- - available = effH - layoutFrameStackOuterRows - repoBody - logBody - } - if available < layoutMinSpareForSplit { - return 0, 0, 0, 0, 0 + repoBody, logBody, available, ok := tightenRepoAndLogForMiddleSpare(effH, repoBody, logBody) + if !ok { + return paneLayout{} } - - // Vertical budget: repo + log + status + branch == effH - layoutFrameStackOuterRows. - // Diff sits beside the status/branch stack (same outer height), so it does not add rows. - sumSB := available - statusBody = sumSB / 2 - branchBody = sumSB - statusBody - diffBody = diffBodyFromStackedStatusBranch(statusBody, branchBody) - if statusBody < layoutMinBodyLines || branchBody < layoutMinBodyLines || diffBody < layoutMinBodyLines || - logBody < layoutMinBodyLines || repoBody < layoutMinBodyLines || - statusBody+branchBody != available { - return 0, 0, 0, 0, 0 + statusBody, branchBody := splitStatusBranchEvenly(available) + diffBody := diffBodyFromStackedStatusBranch(statusBody, branchBody) + if !nonZoomStackBodiesValid(repoBody, statusBody, branchBody, diffBody, logBody, available) { + return paneLayout{} } - return repoBody, statusBody, branchBody, diffBody, logBody + return paneLayout{repo: repoBody, status: statusBody, branch: branchBody, diff: diffBody, logBody: logBody} } func (m *model) clearCustomVerticalLayout() { @@ -86,37 +67,30 @@ func (m *model) clearCustomVerticalLayout() { // layoutBodies returns inner content heights: repo list, status table, branch table, diff viewport, log viewport. // Each framed panel's total row count is panelOuter(body) (see framedBlock). -func (m *model) layoutBodies() (repoBody, statusBody, branchBody, diffBody, logBody int) { - ar, as, ab, ad, al := m.autoLayoutBodies() - if ar == 0 && as == 0 && ab == 0 && ad == 0 && al == 0 { - return 0, 0, 0, 0, 0 +func (m *model) layoutBodies() paneLayout { + auto := m.autoLayoutBodies() + if auto.isZero() { + return paneLayout{} } if m.zoomed || !m.layoutUseCustomVertical { - return ar, as, ab, ad, al + return auto } - innerTotal := m.height - layoutFrameStackOuterRows - repoBody = m.layoutRepoBody - statusBody = m.layoutStatusBody - branchBody = m.layoutBranchBody - logBody = m.layoutLogBody + innerTotal := innerVerticalBudget(m.height) + repoBody := m.layoutRepoBody + statusBody := m.layoutStatusBody + branchBody := m.layoutBranchBody + logBody := m.layoutLogBody if repoBody < layoutMinBodyLines || statusBody < layoutMinBodyLines || branchBody < layoutMinBodyLines || logBody < layoutMinBodyLines { m.clearCustomVerticalLayout() - return ar, as, ab, ad, al + return auto } available := innerTotal - repoBody - logBody - diffBody = diffBodyFromStackedStatusBranch(statusBody, branchBody) - if available < layoutMinSpareForSplit || diffBody < layoutMinBodyLines || - statusBody+branchBody != available || - panelOuter(statusBody)+panelOuter(branchBody) != panelOuter(diffBody) { + diffBody := diffBodyFromStackedStatusBranch(statusBody, branchBody) + if !customVerticalLayoutOK(statusBody, branchBody, diffBody, available) { m.clearCustomVerticalLayout() - return ar, as, ab, ad, al + return auto } - return repoBody, statusBody, branchBody, diffBody, logBody -} - -// panelOuter converts an inner body height into full framed panel height. -func panelOuter(body int) int { - return body + 2 // top border (with title) + body + bottom border + return paneLayout{repo: repoBody, status: statusBody, branch: branchBody, diff: diffBody, logBody: logBody} } // paneAtTerminalCell maps a 0-based terminal cell (from Bubble Tea mouse events) @@ -128,17 +102,17 @@ func (m *model) paneAtTerminalCell(x, y int) (pane, bool) { if x < 0 || y < 0 || x >= m.width || y >= m.height { return paneRepo, false } - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return paneRepo, false } if m.zoomed { return m.zoomTarget, true } - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - middleOuter := panelOuter(diffBody) - logOuter := panelOuter(logBody) + repoOuter := panelOuter(lay.repo) + statusOuter := panelOuter(lay.status) + middleOuter := panelOuter(lay.diff) + logOuter := panelOuter(lay.logBody) leftOuter, _ := m.middleRowColumnOuterWidths(m.width) if y < repoOuter { @@ -214,8 +188,8 @@ func (m *model) setLogVPContent() { // a loading line until a follow-up runDiffForGen is handled. Used when // the repository selection changes so scrolling stays responsive. func (m *model) applyViewportAndPanes(syncDiff bool) { - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return } @@ -228,15 +202,15 @@ func (m *model) applyViewportAndPanes(syncDiff bool) { } m.statusTable.SetWidth(statusInnerW) - m.statusTable.SetHeight(statusBody) + m.statusTable.SetHeight(lay.status) m.statusTable.SetColumns(statusColumns(statusInnerW)) m.logVP.Width = m.innerWidth() - m.logVP.Height = logBody + m.logVP.Height = lay.logBody m.diffVP.Width = diffInnerW - m.diffVP.Height = diffBody + m.diffVP.Height = lay.diff m.refreshStatusContent() m.refreshBranchContent(branchInnerW) - m.branchTable.SetHeight(branchBody) + m.branchTable.SetHeight(lay.branch) if syncDiff { m.refreshDiffContent() } else if m.diffNeedsRefresh { @@ -244,7 +218,7 @@ func (m *model) applyViewportAndPanes(syncDiff bool) { } m.diffVP.SetContent(m.diffContent) m.setLogVPContent() - m.clampRepoScroll(repoBody) + m.clampRepoScroll(lay.repo) } // newStatusTable builds the status pane table with default styling. diff --git a/ui/status_layout_test.go b/ui/status_layout_test.go index 1ed2108..674d6bb 100644 --- a/ui/status_layout_test.go +++ b/ui/status_layout_test.go @@ -37,11 +37,11 @@ func TestPaneAtTerminalCell(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, statusBody, _, diffBody, logBody := m.layoutBodies() - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - middleOuter := panelOuter(diffBody) - logOuter := panelOuter(logBody) + lay := m.layoutBodies() + repoOuter := panelOuter(lay.repo) + statusOuter := panelOuter(lay.status) + middleOuter := panelOuter(lay.diff) + logOuter := panelOuter(lay.logBody) leftW, _ := m.middleRowColumnOuterWidths(m.width) tests := []struct { @@ -84,10 +84,10 @@ func TestMouseFocusClickUpdatesFocus(t *testing.T) { m.repoList = []string{"a", "b", "c"} m.focus = paneRepo - rb, sb, _, _, _ := m.layoutBodies() + l := m.layoutBodies() click := tea.MouseMsg{ X: 0, - Y: panelOuter(rb) + panelOuter(sb)/2, + Y: panelOuter(l.repo) + panelOuter(l.status)/2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, } @@ -130,12 +130,12 @@ func TestLayoutBodies(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody < 3 || statusBody < 3 || branchBody < 3 || diffBody < 3 || logBody < 3 { - t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), expected all >= 3", repoBody, statusBody, branchBody, diffBody, logBody) + lay := m.layoutBodies() + if lay.repo < 3 || lay.status < 3 || lay.branch < 3 || lay.diff < 3 || lay.logBody < 3 { + t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), expected all >= 3", lay.repo, lay.status, lay.branch, lay.diff, lay.logBody) } - if diffBody != statusBody+branchBody+2 { - t.Fatalf("layoutBodies() diff=%d want status+branch+2=%d", diffBody, statusBody+branchBody+2) + if lay.diff != lay.status+lay.branch+2 { + t.Fatalf("layoutBodies() diff=%d want status+branch+2=%d", lay.diff, lay.status+lay.branch+2) } } @@ -145,9 +145,9 @@ func TestLayoutBodiesReturnsZerosOnSmallScreen(t *testing.T) { m.width = 10 m.height = 10 - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody != 0 || statusBody != 0 || branchBody != 0 || diffBody != 0 || logBody != 0 { - t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), want zeros", repoBody, statusBody, branchBody, diffBody, logBody) + lay := m.layoutBodies() + if !lay.isZero() { + t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), want zeros", lay.repo, lay.status, lay.branch, lay.diff, lay.logBody) } } @@ -159,9 +159,9 @@ func TestLayoutBodiesZoomedPaneOnly(t *testing.T) { m.zoomed = true m.zoomTarget = paneStatus - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody != 0 || branchBody != 0 || diffBody != 0 || logBody != 0 || statusBody == 0 { - t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), want repo=0 status>0 branch=0 diff=0 log=0", repoBody, statusBody, branchBody, diffBody, logBody) + lay := m.layoutBodies() + if lay.repo != 0 || lay.branch != 0 || lay.diff != 0 || lay.logBody != 0 || lay.status == 0 { + t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), want repo=0 status>0 branch=0 diff=0 log=0", lay.repo, lay.status, lay.branch, lay.diff, lay.logBody) } } diff --git a/ui/view.go b/ui/view.go index 1384e56..594c17c 100644 --- a/ui/view.go +++ b/ui/view.go @@ -339,11 +339,11 @@ func (m *model) clampRepoScroll(innerH int) { // syncRepoListScrollOnly updates repoScrollTop for the current cursor; it is // cheap and used while keyboard navigation debounces the rest of the UI. func (m *model) syncRepoListScrollOnly() { - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return } - m.clampRepoScroll(repoBody) + m.clampRepoScroll(lay.repo) } // repoListView renders the repository list with current selection styling. @@ -396,10 +396,10 @@ func (m *model) renderScanOverlay() string { } // renderZoomedPane draws only the active pane in fullscreen mode. -func (m *model) renderZoomedPane(repoBody int) string { +func (m *model) renderZoomedPane(lay paneLayout) string { switch m.zoomTarget { case paneRepo: - return m.framedBlock(paneRepo, m.width, m.height, "Repositories", m.repoListView(repoBody)) + return m.framedBlock(paneRepo, m.width, m.height, "Repositories", m.repoListView(lay.repo)) case paneStatus: return m.framedBlock(paneStatus, m.width, m.height, "Status", m.statusTable.View()) case paneBranches: @@ -415,12 +415,12 @@ func (m *model) renderZoomedPane(repoBody int) string { } // renderMainStack composes the standard main layout: repo, middle row (status/branch + diff), log. -func (m *model) renderMainStack(repoBody, statusBody, branchBody, diffBody, logBody int) string { - repoOuter := panelOuter(repoBody) - logOuter := panelOuter(logBody) +func (m *model) renderMainStack(lay paneLayout) string { + repoOuter := panelOuter(lay.repo) + logOuter := panelOuter(lay.logBody) - repoBlock := m.framedBlock(paneRepo, m.width, repoOuter, "Repositories", m.repoListView(repoBody)) - middleRow := m.framedMiddleRow(statusBody, branchBody, diffBody, m.statusTable.View(), m.branchTable.View(), m.diffVP.View()) + repoBlock := m.framedBlock(paneRepo, m.width, repoOuter, "Repositories", m.repoListView(lay.repo)) + middleRow := m.framedMiddleRow(lay.status, lay.branch, lay.diff, m.statusTable.View(), m.branchTable.View(), m.diffVP.View()) m.setLogVPContent() logBlock := m.framedBlock(paneLog, m.width, logOuter, "Log", m.logVP.View()) @@ -461,16 +461,16 @@ func (m *model) View() string { return m.renderScanOverlay() } - repoBody, statusBody, branchBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && branchBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return "" } stack := "" if m.zoomed { - stack = m.renderZoomedPane(repoBody) + stack = m.renderZoomedPane(lay) } else { - stack = m.renderMainStack(repoBody, statusBody, branchBody, diffBody, logBody) + stack = m.renderMainStack(lay) } if m.err != nil { return m.renderErrorOverlay()