clone

a lazygit-style terminal viewer for openspec repos: watch changes, tasks and spec deltas land as agents work

main
lazyopenspec / internal / ui / layout.go
layout.go 5.8K
1package ui23import (4 "strings"56 "charm.land/lipgloss/v2"7 "github.com/charmbracelet/x/ansi"8)910type rect struct{ x, y, w, h int }1112func (r rect) contains(x, y int) bool {13 return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h14}1516// geometry is the screen layout for one frame. Panel rects include borders.17type geometry struct {18 body rect // everything between the header and footer19 panels [numLists]rect20 main rect21 portrait bool // side panels stacked above the main pane22}2324const (25 headerRows = 126 footerRows = 127)2829// PortraitMode mirrors lazygit's gui.portraitMode: whether to stack the side30// panels on top of the main pane instead of beside it.31type PortraitMode string3233const (34 PortraitAuto PortraitMode = "auto"35 PortraitAlways PortraitMode = "always"36 PortraitNever PortraitMode = "never"37)3839// lazygit's defaults for gui.portraitModeAutoMaxWidth / portraitModeAutoMinHeight.40const (41 portraitAutoMaxWidth = 8442 portraitAutoMinHeight = 4643)4445// ParsePortraitMode validates a --portrait flag value.46func ParsePortraitMode(s string) (PortraitMode, bool) {47 switch p := PortraitMode(s); p {48 case PortraitAuto, PortraitAlways, PortraitNever:49 return p, true50 }51 return "", false52}5354func (m Model) portrait() bool {55 switch m.portraitMode {56 case PortraitAlways:57 return true58 case PortraitNever:59 return false60 default:61 return m.width <= portraitAutoMaxWidth && m.height >= portraitAutoMinHeight62 }63}6465func (m Model) geometry() geometry {66 g := geometry{portrait: m.portrait()}67 bodyH := max(m.height-headerRows-footerRows, 0)68 g.body = rect{0, headerRows, m.width, bodyH}6970 // Side section beside the main pane, or (portrait) above it taking a71 // third of the height, like lazygit's default sidePanelWidth of 0.3333.72 side := rect{0, headerRows, clamp(m.width*38/100, 34, 60), bodyH}73 if m.width < 80 {74 side.w = m.width / 275 }76 g.main = rect{side.w, headerRows, m.width - side.w, bodyH}77 if g.portrait {78 side = rect{0, headerRows, m.width, bodyH / 3}79 g.main = rect{0, headerRows + side.h, m.width, bodyH - side.h}80 }8182 y := side.y83 for i, h := range m.sideHeights(side.h) {84 g.panels[i] = rect{side.x, y, side.w, h}85 y += h86 }87 if m.zoom {88 g.main = g.body89 }90 return g91}9293// sideFocus is the side panel lazygit would treat as current: the focused94// list, or the one driving the main pane while the main pane has focus.95func (m Model) sideFocus() panelID {96 if m.focus < numLists {97 return m.focus98 }99 return m.lastList100}101102// sideHeights splits the side section between the list panels (heights103// include borders). With enough room each panel gets a share of the height;104// otherwise, as in lazygit, the focused panel takes the space and the others105// squash to a single row, or just their title when space is very tight.106func (m Model) sideHeights(total int) [numLists]int {107 // lazygit's thresholds (28 and 21 rows for five panels), scaled to four.108 const (109 minNormal = 28 * numLists / 5110 minTallSquash = 21 * numLists / 5111 squashedTall = 3 // border, selected row, border112 squashedShort = 1 // title only113 )114 var heights [numLists]int115 if total >= minNormal {116 want := [numLists]int{117 max(m.count(panelChanges), 1),118 max(m.count(panelSpecs), 1),119 max(m.count(panelArchive), 1),120 max(m.count(panelActivity), 1),121 }122 share := [numLists]int{35, 25, 15, 25}123 for i, rows := range allocate(want, share, total-2*numLists, m.focus) {124 heights[i] = rows + 2125 }126 return heights127 }128 squashed := squashedShort129 if total >= minTallSquash {130 squashed = squashedTall131 }132 focused := m.sideFocus()133 for i := range heights {134 heights[i] = squashed135 }136 heights[focused] = max(total-squashed*(numLists-1), 0)137 return heights138}139140// allocate splits total rows between panels: each gets its percentage share,141// unused share flows to panels that want more, and any slack goes to the142// focused panel.143func allocate(want, share [numLists]int, total int, focus panelID) [numLists]int {144 var rows [numLists]int145 left := total146 for i := range rows {147 rows[i] = min(max(total*share[i]/100, 1), want[i])148 left -= rows[i]149 }150 for left > 0 {151 best, gap := -1, 0152 for i := range rows {153 if g := want[i] - rows[i]; g > gap {154 best, gap = i, g155 }156 }157 if best < 0 {158 break159 }160 rows[best]++161 left--162 }163 if left > 0 && int(focus) < numLists {164 rows[focus] += left165 } else if left > 0 {166 rows[panelActivity] += left167 }168 return rows169}170171// box draws a rounded border around body with a title embedded in the top172// edge and an optional footer in the bottom edge, lazygit-style.173func box(t theme, title, footer, body string, w, h int, focused bool) string {174 if w < 2 || h < 1 {175 return ""176 }177 border := t.subtle178 if focused {179 border = lipgloss.NewStyle().Foreground(t.accent)180 }181 b := lipgloss.RoundedBorder()182 innerW := w - 2183184 edge := func(left, right, label string) string {185 label = ansi.Truncate(label, max(innerW-2, 0), "…")186 fill := innerW - lipgloss.Width(label)187 if label != "" {188 fill -= 2189 label = border.Render(b.Top) + label + border.Render(b.Top)190 }191 return border.Render(left) + label + border.Render(strings.Repeat(b.Top, max(fill, 0))) + border.Render(right)192 }193194 var sb strings.Builder195 sb.WriteString(edge(b.TopLeft, b.TopRight, title))196 if h == 1 {197 return sb.String() // squashed panel: title only198 }199 lines := strings.Split(body, "\n")200 for i := range h - 2 {201 line := ""202 if i < len(lines) {203 line = lines[i]204 }205 sb.WriteString("\n")206 sb.WriteString(border.Render(b.Left))207 sb.WriteString(fit(line, innerW))208 sb.WriteString(border.Render(b.Right))209 }210 sb.WriteString("\n")211 bottom := edge(b.BottomLeft, b.BottomRight, footer)212 sb.WriteString(bottom)213 return sb.String()214}215216// fit truncates or right-pads s to exactly w cells.217func fit(s string, w int) string {218 if w <= 0 {219 return ""220 }221 s = ansi.Truncate(s, w, "…")222 if pad := w - lipgloss.Width(s); pad > 0 {223 s += strings.Repeat(" ", pad)224 }225 return s226}227228func clamp(v, lo, hi int) int {229 return max(lo, min(v, hi))230}