resizing.go

  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}