package ui import ( "strings" "thehouseoficarus/internal/color" ) type tableGlyphs struct { topLeft, topSep, topRight rune side, colSep rune sepLeft, sepCross, sepRight rune botLeft, botSep, botRight rune fillH, fillHSep rune titleSep rune } func tableGlyphSet(unicode bool) tableGlyphs { if unicode { return tableGlyphs{ topLeft: '╔', topSep: '╤', topRight: '╗', side: '║', colSep: '│', sepLeft: '╟', sepCross: '┼', sepRight: '╢', botLeft: '╚', botSep: '╧', botRight: '╝', fillH: '═', fillHSep: '─', titleSep: '┬', } } return tableGlyphs{ topLeft: '+', topSep: '+', topRight: '+', side: '|', colSep: '|', sepLeft: '+', sepCross: '+', sepRight: '+', botLeft: '+', botSep: '+', botRight: '+', fillH: '-', fillHSep: '-', titleSep: '+', } } type Table struct { Title string Columns []string Rows [][]string } // FitWidths shrinks colWidths in place so their sum fits budget, using the // same fair-share algorithm Table.Render uses. Exported so callers that // pre-wrap cells can size them consistently instead of reimplementing it. func FitWidths(colWidths []int, budget int) { rebalanceWidths(colWidths, budget) } // TableBudget returns the total cell width available to nCols columns in a // table rendered at maxWidth. Callers that pre-wrap cells must use this so // their shrink target matches Render's padding/separator arithmetic. func TableBudget(nCols, maxWidth int) int { budget := maxWidth - (3*nCols + 1) if budget < nCols { budget = nCols } return budget } // rebalanceWidths shrinks column widths in place so their sum fits budget. // Rather than crushing only the last column, every column wider than the // fair share (budget/nCols) is capped first, then columns are trimmed one // rune at a time from the widest until the table fits. Leftover slack is // handed back round-robin to columns that lost width so wide layouts still // use the full line. func rebalanceWidths(colWidths []int, budget int) { if len(colWidths) == 0 { return } original := make([]int, len(colWidths)) copy(original, colWidths) fair := budget / len(colWidths) if fair < 1 { fair = 1 } for i, w := range colWidths { if w > fair { colWidths[i] = fair } } sum := func() int { t := 0 for _, w := range colWidths { t += w } return t } // Tight tables: trim the widest column until we fit (or hit 1). for sum() > budget { widest := 0 for i, w := range colWidths { if w > colWidths[widest] { widest = i } } if colWidths[widest] <= 1 { break } colWidths[widest]-- } // Give unused width back to the columns that were reduced. for { extra := budget - sum() if extra <= 0 { break } progress := false for i := 0; i < len(colWidths) && extra > 0; i++ { if colWidths[i] < original[i] { colWidths[i]++ extra-- progress = true } } if !progress { break } } } func (t *Table) Render(unicode bool, maxWidth int) []string { g := tableGlyphSet(unicode) nCols := len(t.Columns) if nCols == 0 && len(t.Rows) > 0 { nCols = len(t.Rows[0]) } if nCols == 0 { return nil } colWidths := make([]int, nCols) for i, h := range t.Columns { colWidths[i] = color.VisibleLen(h) } for _, row := range t.Rows { for i, cell := range row { if i >= nCols { break } vl := color.VisibleLen(cell) if vl > colWidths[i] { colWidths[i] = vl } } } headerCells := t.Columns bodyRows := t.Rows if maxWidth > 0 { budget := TableBudget(nCols, maxWidth) total := 0 for _, w := range colWidths { total += w } if total > budget { rebalanceWidths(colWidths, budget) // Truncate every cell to its (possibly reduced) column width. // Work on copies so rendering never mutates the table. headerCells = make([]string, len(t.Columns)) copy(headerCells, t.Columns) for i := range headerCells { headerCells[i] = color.TruncateVisible(headerCells[i], colWidths[i], unicode) } bodyRows = make([][]string, len(t.Rows)) for i := range t.Rows { bodyRows[i] = make([]string, len(t.Rows[i])) copy(bodyRows[i], t.Rows[i]) for j := 0; j < len(bodyRows[i]) && j < nCols; j++ { bodyRows[i][j] = color.TruncateVisible(bodyRows[i][j], colWidths[j], unicode) } } } } makeSep := func(left, cross, right rune, fill rune) string { var b strings.Builder b.WriteRune(left) for i := 0; i < nCols; i++ { if i > 0 { b.WriteRune(cross) } b.WriteString(strings.Repeat(string(fill), colWidths[i]+2)) } b.WriteRune(right) return b.String() } makeRow := func(cells []string) string { var b strings.Builder b.WriteRune(g.side) for i := 0; i < nCols; i++ { if i > 0 { b.WriteRune(g.colSep) } cell := "" if i < len(cells) { cell = cells[i] } pad := colWidths[i] - color.VisibleLen(cell) if pad < 0 { pad = 0 } b.WriteString(" " + cell + strings.Repeat(" ", pad) + " ") } b.WriteRune(g.side) return b.String() } hasTitle := t.Title != "" hasCols := len(t.Columns) > 0 var out []string if hasTitle { innerWidth := 0 for _, w := range colWidths { innerWidth += w + 2 } innerWidth += nCols - 1 var tb strings.Builder tb.WriteRune(g.topLeft) tb.WriteString(strings.Repeat(string(g.fillH), innerWidth)) tb.WriteRune(g.topRight) out = append(out, tb.String()) var tr strings.Builder tr.WriteRune(g.side) tr.WriteString(" ") title := t.Title if max := innerWidth - 2; max > 0 && color.VisibleLen(title) > max { title = color.TruncateVisible(title, max, unicode) } tr.WriteString(title) padding := innerWidth - 2 - color.VisibleLen(title) if padding < 0 { padding = 0 } tr.WriteString(strings.Repeat(" ", padding)) tr.WriteString(" ") tr.WriteRune(g.side) out = append(out, tr.String()) out = append(out, makeSep(g.sepLeft, g.titleSep, g.sepRight, g.fillHSep)) } else { out = append(out, makeSep(g.topLeft, g.topSep, g.topRight, g.fillH)) } if hasCols { out = append(out, makeRow(headerCells)) out = append(out, makeSep(g.sepLeft, g.sepCross, g.sepRight, g.fillHSep)) } for _, row := range bodyRows { out = append(out, makeRow(row)) } out = append(out, makeSep(g.botLeft, g.botSep, g.botRight, g.fillH)) return out }