package app import ( "charm.land/lipgloss/v2" "git.kebcloud.no/keb/lazysecrets/internal/store" ) // Layout: the left column stacks Sources, Vaults and Secrets; the detail // pane fills the rest. The user can move the divider between them, change // the left panes' heights, and zoom the focused pane. type zoom int const ( zoomNormal zoom = iota zoomHalf // the focused pane gets most of the screen zoomFull // only the focused pane zoomCount ) const ( // Divider between the left column and the detail pane. splitStep = 4 minLeftW = 20 minDetailW = 30 defaultMinW = 28 defaultMaxW = 48 // Heights of the left panes. heightStep = 2 minPaneH = 4 // border, title and two rows tinyPaneH = 3 // a squeezed pane in half zoom: border and one row ) func (m Model) defaultLeftWidth() int { return max(defaultMinW, min(defaultMaxW, m.w/3)) } // leftWidth is the default width moved by the user's divider offset, // kept within bounds that leave both sides usable. func (m Model) leftWidth() int { return max(minLeftW, min(m.w-minDetailW, m.defaultLeftWidth()+m.split)) } // moveSplit moves the divider by delta columns. The offset is stored as // what actually applied, so after hitting a limit the other direction // responds immediately. func (m *Model) moveSplit(delta int) { m.split += delta m.split = m.leftWidth() - m.defaultLeftWidth() } func defaultHeights(avail int) [3]int { src, cont := max(minPaneH, avail/4), max(minPaneH, avail/3) return [3]int{src, cont, avail - src - cont} } // leftHeights applies the user's height changes to the default split. func (m Model) leftHeights(avail int) [3]int { h := defaultHeights(avail) for i := range h { h[i] += m.heights[i] } return fitHeights(h, avail, minPaneH) } // fitHeights makes the heights sum to avail with none below lo, taking // from or giving to the tallest panes. func fitHeights(h [3]int, avail, lo int) [3]int { for i := range h { h[i] = max(lo, h[i]) } for sum(h) > avail { i := tallest(h) if h[i] <= lo { break // the terminal is too short; the layout check catches it } h[i]-- } for sum(h) < avail { h[tallest(h)]++ } return h } func sum(h [3]int) int { return h[0] + h[1] + h[2] } func tallest(h [3]int) int { i := 0 for j := range h { if h[j] > h[i] { i = j } } return i } // resizeFocused makes the focused left pane taller (delta > 0) or shorter, // trading rows with the pane below it, or above it for the bottom pane. // It reports false if the detail pane is focused. func (m *Model) resizeFocused(delta int) bool { if m.focus > paneSecrets { return false } avail := m.leftAvail() h := m.leftHeights(avail) i := int(m.focus) j := i + 1 if i == int(paneSecrets) { j = i - 1 } delta = max(delta, minPaneH-h[i]) // don't shrink below the minimum delta = min(delta, h[j]-minPaneH) // or squeeze the neighbour below it h[i] += delta h[j] -= delta base := defaultHeights(avail) for k := range h { m.heights[k] = h[k] - base[k] } return true } // saveLayout remembers the divider and heights for the next run. func (m *Model) saveLayout() { if err := m.store.SetLayout(store.Layout{Split: m.split, Heights: m.heights}); err != nil { m.flash = flash{text: "saving layout: " + err.Error(), isErr: true} } } func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) } func (m *Model) cycleZoom(delta int) { m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount } func (m *Model) layout() { if m.w < minWidth || m.h < minHeight { return } m.help.SetWidth(max(0, m.w-1)) // one column in, like messages avail := m.leftAvail() leftW, h := m.leftWidth(), m.leftHeights(avail) switch m.zoom { case zoomHalf: if m.focus == paneDetail { leftW = minLeftW } else { leftW = max(m.leftWidth(), m.w/2) h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH} h[m.focus] = avail h = fitHeights(h, avail, tinyPaneH) } case zoomFull: // Only the focused pane is drawn; it gets the whole screen. switch m.focus { case paneDetail: leftW = 0 default: leftW = m.w h = [3]int{} h[m.focus] = avail } } m.sources.SetSize(leftW, h[0]) m.containers.SetSize(leftW, h[1]) m.secrets.SetSize(leftW, h[2]) m.detail.SetSize(m.w-leftW, avail) } // active reports whether p is drawn focused: it has focus and the terminal // does too. Like lazygit, nothing is highlighted while you're in another // window, so the focus is easy to spot when you come back. func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred } func (m Model) panesView() string { spin := m.spin.View() views := [paneCount]func() string{ func() string { return m.sources.View(m.st, m.active(paneSources), spin) }, func() string { return m.containers.View(m.st, m.active(paneContainers), spin) }, func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) }, func() string { return m.detail.View(m.st, m.active(paneDetail), spin) }, } if m.zoom == zoomFull { return views[m.focus]() } left := lipgloss.JoinVertical(lipgloss.Left, views[0](), views[1](), views[2]()) return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]()) }