package ui import ( "strings" "charm.land/lipgloss/v2" ) // sizeClass buckets the terminal width into the breakpoints every screen's // view function reads to decide how much chrome it can afford. type sizeClass int const ( sizeTiny sizeClass = iota // < tinyWidth columns: single column, minimal chrome sizeCompact // < wideWidth columns: single column, full chrome sizeWide // >= wideWidth columns: room for a side panel ) const ( minWidth, minHeight = 44, 12 // below this, render the "too small" notice instead tinyWidth = 60 wideWidth = 100 // Panel border(1)+padding(0,1) on each side: 2 cols border, 2 cols // padding, 2 rows border, 0 rows padding. Every screen that renders // inside styles.Panel/PanelActive/SidePanel must size its content to // panelInner of the outer box it's about to be wrapped in. panelBorderW, panelBorderH = 2, 2 panelPadW, panelPadH = 2, 0 ) // layout is the single source of truth for how the terminal's cells are // divided, recomputed once per tea.WindowSizeMsg and read by every view // function instead of each screen re-deriving its own magic numbers. type layout struct { w, h int class sizeClass tooSmall bool headerH, footerH int bodyW, bodyH int // full-width body area below the header, above the footer // Two-column split of bodyW, valid only when class == sizeWide and a // screen opts into it (mainW+sideW+1 == bodyW; the extra column is a // one-cell gutter between the two panels). mainW, sideW int } func computeLayout(w, h int) layout { l := layout{w: w, h: h} if w < minWidth || h < minHeight { l.tooSmall = true return l } switch { case w < tinyWidth: l.class = sizeTiny case w < wideWidth: l.class = sizeCompact default: l.class = sizeWide } l.headerH = 1 l.footerH = 2 if l.class == sizeTiny { l.footerH = 1 } l.bodyH = h - l.headerH - l.footerH if l.bodyH < 3 { l.bodyH = 3 } l.bodyW = w if l.class == sizeWide { l.sideW = l.bodyW * 2 / 5 if l.sideW > 48 { l.sideW = 48 } l.mainW = l.bodyW - l.sideW - 1 } else { l.mainW = l.bodyW } return l } // panelInner returns the content area available inside a bordered, // (0,1)-padded panel of the given outer size — what a screen must wrap its // text to before handing it to styles.Panel/PanelActive/SidePanel.Render. func panelInner(outerW, outerH int) (w, h int) { w = outerW - panelBorderW - panelPadW h = outerH - panelBorderH - panelPadH if w < 1 { w = 1 } if h < 1 { h = 1 } return } // fit pads/truncates s to exactly h lines of at most w cells each, so a // panel's rendered size never depends on its content — no reflow, no // leftover scrollback in the alt-screen buffer. func fit(s string, w, h int) string { if w < 1 { w = 1 } if h < 1 { h = 1 } lines := strings.Split(s, "\n") out := make([]string, h) for i := 0; i < h; i++ { if i >= len(lines) { out[i] = "" continue } line := lines[i] lw := lipgloss.Width(line) switch { case lw > w: out[i] = lipgloss.NewStyle().MaxWidth(w).Render(line) case lw < w: out[i] = line + strings.Repeat(" ", w-lw) default: out[i] = line } } return strings.Join(out, "\n") } // renderPanel wraps content (already fit to panelInner(outerW, outerH)) in // style, sized so the rendered result is exactly outerW x outerH. // // Deliberately does NOT call style.Width()/Height(): lipgloss.Style.Render // treats a set Width as a word-wrap target of width-minus-border-minus- // padding and re-wraps anything wider, which corrupts content we've // already sized to the exact cell grid ourselves via fit(). Handing it // pre-fit, uniform-width content and leaving Width/Height unset gets the // same result (border+padding add their fixed, known cost on top) without // that reprocessing. func renderPanel(style lipgloss.Style, outerW, outerH int, content string) string { iw, ih := panelInner(outerW, outerH) return style.Render(fit(content, iw, ih)) }