1package table
2
3import (
4 "math"
5 "strings"
6
7 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/x/ansi"
9)
10
11// resize resizes the table to fit the specified width.
12//
13// Given a user defined table width, we must ensure the table is exactly that
14// width. This must account for all borders, column, separators, and column
15// data.
16//
17// In the case where the table is narrower than the specified table width,
18// we simply expand the columns evenly to fit the width.
19// For example, a table with 3 columns takes up 50 characters total, and the
20// width specified is 80, we expand each column by 10 characters, adding 30
21// to the total width.
22//
23// In the case where the table is wider than the specified table width, we
24// _could_ simply shrink the columns evenly but this would result in data
25// being truncated (perhaps unnecessarily). The naive approach could result
26// in very poor cropping of the table data. So, instead of shrinking columns
27// evenly, we calculate the median non-whitespace length of each column, and
28// shrink the columns based on the largest median.
29//
30// For example,
31//
32// ┌──────┬───────────────┬──────────┐
33// │ Name │ Age of Person │ Location │
34// ├──────┼───────────────┼──────────┤
35// │ Kini │ 40 │ New York │
36// │ Eli │ 30 │ London │
37// │ Iris │ 20 │ Paris │
38// └──────┴───────────────┴──────────┘
39//
40// Median non-whitespace length vs column width of each column:
41//
42// Name: 4 / 5
43// Age of Person: 2 / 15
44// Location: 6 / 10
45//
46// The biggest difference is 15 - 2, so we can shrink the 2nd column by 13.
47func (t *Table) resize() {
48 hasHeaders := len(t.headers) > 0
49 rows := DataToMatrix(t.data)
50 r := newResizer(t.width, t.height, t.headers, rows)
51 r.wrap = t.wrap
52 r.borderColumn = t.borderColumn
53 r.yPaddings = make([][]int, len(r.allRows))
54
55 r.yOffset = t.yOffset
56 r.useManualHeight = t.useManualHeight
57 r.borderTop = t.borderTop
58 r.borderBottom = t.borderBottom
59 r.borderLeft = t.borderLeft
60 r.borderRight = t.borderRight
61 r.borderHeader = t.borderHeader
62 r.borderRow = t.borderRow
63
64 var allRows [][]string
65 if hasHeaders {
66 allRows = append([][]string{t.headers}, rows...)
67 } else {
68 allRows = rows
69 }
70
71 styleFunc := t.styleFunc
72 if t.styleFunc == nil {
73 styleFunc = DefaultStyles
74 }
75
76 r.rowHeights = r.defaultRowHeights()
77
78 for i, row := range allRows {
79 r.yPaddings[i] = make([]int, len(row))
80
81 for j := range row {
82 column := &r.columns[j]
83
84 // Making sure we're passing the right index to `styleFunc`. The header row should be `-1` and
85 // the others should start from `0`.
86 rowIndex := i
87 if hasHeaders {
88 rowIndex--
89 }
90 style := styleFunc(rowIndex, j)
91
92 column.xPadding = max(column.xPadding, style.GetHorizontalFrameSize())
93 column.fixedWidth = max(column.fixedWidth, style.GetWidth())
94
95 r.rowHeights[i] = max(r.rowHeights[i], style.GetHeight())
96 r.yPaddings[i][j] = style.GetVerticalFrameSize()
97 }
98 }
99
100 // A table width wasn't specified. In this case, detect according to
101 // content width.
102 if r.tableWidth <= 0 {
103 r.tableWidth = r.detectTableWidth()
104 }
105
106 t.widths, t.heights = r.optimizedWidths()
107 t.firstVisibleRowIndex, t.lastVisibleRowIndex = r.visibleRowIndexes()
108}
109
110// resizerColumn is a column in the resizer.
111type resizerColumn struct {
112 index int
113 min int
114 max int
115 median int
116 rows [][]string
117 xPadding int // horizontal padding
118 fixedWidth int
119}
120
121// resizer is a table resizer.
122type resizer struct {
123 tableWidth int
124 tableHeight int
125 headers []string
126 allRows [][]string
127 rowHeights []int
128 columns []resizerColumn
129
130 wrap bool
131 borderColumn bool
132 yPaddings [][]int // vertical paddings
133
134 yOffset int
135 useManualHeight bool
136 borderTop bool
137 borderBottom bool
138 borderLeft bool
139 borderRight bool
140 borderHeader bool
141 borderRow bool
142}
143
144// newResizer creates a new resizer.
145func newResizer(tableWidth, tableHeight int, headers []string, rows [][]string) *resizer {
146 r := &resizer{
147 tableWidth: tableWidth,
148 tableHeight: tableHeight,
149 headers: headers,
150 }
151
152 if len(headers) > 0 {
153 r.allRows = append([][]string{headers}, rows...)
154 } else {
155 r.allRows = rows
156 }
157
158 for _, row := range r.allRows {
159 for i, cell := range row {
160 cellLen := lipgloss.Width(cell)
161
162 // Header or first row. Just add as is.
163 if len(r.columns) <= i {
164 r.columns = append(r.columns, resizerColumn{
165 index: i,
166 min: cellLen,
167 max: cellLen,
168 median: cellLen,
169 })
170 continue
171 }
172
173 r.columns[i].rows = append(r.columns[i].rows, row)
174 r.columns[i].min = min(r.columns[i].min, cellLen)
175 r.columns[i].max = max(r.columns[i].max, cellLen)
176 }
177 }
178 for j := range r.columns {
179 widths := make([]int, len(r.columns[j].rows))
180 for i, row := range r.columns[j].rows {
181 widths[i] = lipgloss.Width(row[j])
182 }
183 r.columns[j].median = median(widths)
184 }
185
186 return r
187}
188
189// optimizedWidths returns the optimized column widths and row heights.
190func (r *resizer) optimizedWidths() (colWidths, rowHeights []int) {
191 if r.maxTotal() <= r.tableWidth {
192 return r.expandTableWidth()
193 }
194 return r.shrinkTableWidth()
195}
196
197// detectTableWidth detects the table width.
198func (r *resizer) detectTableWidth() int {
199 return r.maxCharCount() + r.totalHorizontalPadding() + r.totalHorizontalBorder()
200}
201
202// expandTableWidth expands the table width.
203func (r *resizer) expandTableWidth() (colWidths, rowHeights []int) {
204 colWidths = r.maxColumnWidths()
205
206 for {
207 totalWidth := sum(colWidths) + r.totalHorizontalBorder()
208 if totalWidth >= r.tableWidth {
209 break
210 }
211
212 shorterColumnIndex := 0
213 shorterColumnWidth := math.MaxInt32
214
215 for j, width := range colWidths {
216 if width == r.columns[j].fixedWidth {
217 continue
218 }
219 if width < shorterColumnWidth {
220 shorterColumnWidth = width
221 shorterColumnIndex = j
222 }
223 }
224
225 colWidths[shorterColumnIndex]++
226 }
227
228 rowHeights = r.expandRowHeigths(colWidths)
229 return
230}
231
232// shrinkTableWidth shrinks the table width.
233func (r *resizer) shrinkTableWidth() (colWidths, rowHeights []int) {
234 colWidths = r.maxColumnWidths()
235
236 // Cut width of columns that are way too big.
237 shrinkBiggestColumns := func(veryBigOnly bool) {
238 for {
239 totalWidth := sum(colWidths) + r.totalHorizontalBorder()
240 if totalWidth <= r.tableWidth {
241 break
242 }
243
244 bigColumnIndex := -math.MaxInt32
245 bigColumnWidth := -math.MaxInt32
246
247 for j, width := range colWidths {
248 if width == r.columns[j].fixedWidth {
249 continue
250 }
251 if veryBigOnly {
252 if width >= (r.tableWidth/2) && width > bigColumnWidth { //nolint:mnd
253 bigColumnWidth = width
254 bigColumnIndex = j
255 }
256 } else {
257 if width > bigColumnWidth {
258 bigColumnWidth = width
259 bigColumnIndex = j
260 }
261 }
262 }
263
264 if bigColumnIndex < 0 || colWidths[bigColumnIndex] == 0 {
265 break
266 }
267 colWidths[bigColumnIndex]--
268 }
269 }
270
271 // Cut width of columns that differ the most from the median.
272 shrinkToMedian := func() {
273 for {
274 totalWidth := sum(colWidths) + r.totalHorizontalBorder()
275 if totalWidth <= r.tableWidth {
276 break
277 }
278
279 biggestDiffToMedian := -math.MaxInt32
280 biggestDiffToMedianIndex := -math.MaxInt32
281
282 for j, width := range colWidths {
283 if width == r.columns[j].fixedWidth {
284 continue
285 }
286 diffToMedian := width - r.columns[j].median
287 if diffToMedian > 0 && diffToMedian > biggestDiffToMedian {
288 biggestDiffToMedian = diffToMedian
289 biggestDiffToMedianIndex = j
290 }
291 }
292
293 if biggestDiffToMedianIndex <= 0 || colWidths[biggestDiffToMedianIndex] == 0 {
294 break
295 }
296 colWidths[biggestDiffToMedianIndex]--
297 }
298 }
299
300 shrinkBiggestColumns(true)
301 shrinkToMedian()
302 shrinkBiggestColumns(false)
303
304 return colWidths, r.expandRowHeigths(colWidths)
305}
306
307// expandRowHeigths expands the row heights.
308func (r *resizer) expandRowHeigths(colWidths []int) (rowHeights []int) {
309 rowHeights = r.defaultRowHeights()
310 if !r.wrap {
311 return rowHeights
312 }
313 hasHeaders := len(r.headers) > 0
314
315 for i, row := range r.allRows {
316 for j, cell := range row {
317 // NOTE(@andreynering): Headers always have a height of 1 (+ padding), even when wrap is enabled.
318 if hasHeaders && i == 0 {
319 rowHeights[i] = 1 + r.yPaddingForCell(i, j)
320 continue
321 }
322 height := r.detectContentHeight(cell, colWidths[j]-r.xPaddingForCol(j)) + r.yPaddingForCell(i, j)
323 rowHeights[i] = max(rowHeights[i], height)
324 }
325 }
326 return
327}
328
329// defaultRowHeights returns the default row heights.
330func (r *resizer) defaultRowHeights() (rowHeights []int) {
331 rowHeights = make([]int, len(r.allRows))
332 for i := range rowHeights {
333 if i < len(r.rowHeights) {
334 rowHeights[i] = r.rowHeights[i]
335 }
336 rowHeights[i] = max(rowHeights[i], 1)
337 }
338 return
339}
340
341// maxColumnWidths returns the maximum column widths.
342func (r *resizer) maxColumnWidths() []int {
343 maxColumnWidths := make([]int, len(r.columns))
344 for i, col := range r.columns {
345 if col.fixedWidth > 0 {
346 maxColumnWidths[i] = col.fixedWidth
347 } else {
348 maxColumnWidths[i] = col.max + r.xPaddingForCol(col.index)
349 }
350 }
351 return maxColumnWidths
352}
353
354// columnCount returns the column count.
355func (r *resizer) columnCount() int {
356 return len(r.columns)
357}
358
359// maxCharCount returns the maximum character count.
360func (r *resizer) maxCharCount() int {
361 var count int
362 for _, col := range r.columns {
363 if col.fixedWidth > 0 {
364 count += col.fixedWidth - r.xPaddingForCol(col.index)
365 } else {
366 count += col.max
367 }
368 }
369 return count
370}
371
372// maxTotal returns the maximum total width.
373func (r *resizer) maxTotal() (maxTotal int) {
374 for j, column := range r.columns {
375 if column.fixedWidth > 0 {
376 maxTotal += column.fixedWidth
377 } else {
378 maxTotal += column.max + r.xPaddingForCol(j)
379 }
380 }
381 return
382}
383
384// totalHorizontalPadding returns the total padding.
385func (r *resizer) totalHorizontalPadding() (totalHorizontalPadding int) {
386 for _, col := range r.columns {
387 totalHorizontalPadding += col.xPadding
388 }
389 return
390}
391
392// xPaddingForCol returns the horizontal padding for a column.
393func (r *resizer) xPaddingForCol(j int) int {
394 if j >= len(r.columns) {
395 return 0
396 }
397 return r.columns[j].xPadding
398}
399
400// yPaddingForCell returns the horizontal padding for a cell.
401func (r *resizer) yPaddingForCell(i, j int) int {
402 if i >= len(r.yPaddings) || j >= len(r.yPaddings[i]) {
403 return 0
404 }
405 return r.yPaddings[i][j]
406}
407
408// totalHorizontalBorder returns the total border.
409func (r *resizer) totalHorizontalBorder() int {
410 return btoi(r.borderLeft) + btoi(r.borderRight) + (r.columnCount()-1)*btoi(r.borderColumn)
411}
412
413// detectContentHeight detects the content height.
414func (r *resizer) detectContentHeight(content string, width int) (height int) {
415 if width == 0 {
416 return 1
417 }
418 content = strings.ReplaceAll(content, "\r\n", "\n")
419 for _, line := range strings.Split(content, "\n") {
420 height += strings.Count(ansi.Wrap(line, width, ""), "\n") + 1
421 }
422 return
423}
424
425// visibleRowIndexes detects the last visible row, shown as overflow, when a
426// fixed table height was set.
427// If the table height is not fixed or it's enough to show the whole table, it
428// returns -2.
429// Keep in mind that it'll return -1 for the header, and start from 0 for the
430// data rows.
431func (r *resizer) visibleRowIndexes() (firstVisibleRowIndex, lastVisibleRowIndex int) {
432 if !r.useManualHeight {
433 return 0, -2
434 }
435
436 hasHeaders := len(r.headers) > 0
437
438 // XXX(@andreynering): There are known edge cases where this won't work
439 // 100% correctly, in particular for cells with padding and/or wrapped
440 // content. This will cover the most common scenarios, though.
441 firstVisibleRowIndex = r.yOffset
442 bordersHeight := (btoi(r.borderTop) +
443 btoi(r.borderBottom) +
444 btoi(hasHeaders && r.borderHeader) +
445 bton(hasHeaders, r.yPaddingForCell(0, 0)) +
446 (btoi(r.borderRow) * (len(r.allRows) - btoi(hasHeaders) - 1)))
447 if firstVisibleRowIndex > 0 && len(r.allRows)+bordersHeight-firstVisibleRowIndex < r.tableHeight {
448 firstVisibleRowIndex = len(r.allRows) - r.tableHeight + bordersHeight
449 }
450
451 printedRows := btoi(r.borderTop) + 1 + btoi(hasHeaders && r.borderHeader) + bton(hasHeaders, r.yPaddingForCell(0, 0))
452
453 for i := range r.allRows {
454 // NOTE(@andreynering): Skip non-visible rows if yOffset is set.
455 if i <= firstVisibleRowIndex {
456 continue
457 }
458
459 isHeader := hasHeaders && i == 0
460 isLastRow := i == len(r.allRows)-1
461
462 rowHeight := r.rowHeights[i] + r.yPaddingForCell(i, 0)
463 nextRowPadding := r.yPaddingForCell(i+1, 0)
464
465 sum := (printedRows +
466 rowHeight +
467 btoi(isHeader && r.borderHeader) +
468 btoi(r.borderBottom) +
469 btoi(!isHeader && !isLastRow) +
470 btoi(!isLastRow && r.borderRow) +
471 nextRowPadding)
472
473 if sum > r.tableHeight {
474 return firstVisibleRowIndex, i - btoi(hasHeaders)
475 }
476
477 printedRows += rowHeight + btoi(isHeader && r.borderHeader) + btoi(!isHeader && r.borderRow)
478 }
479
480 return firstVisibleRowIndex, -2
481}