table.go

  1// Package table provides a styled table renderer for terminals.
  2package table
  3
  4import (
  5	"strings"
  6
  7	"github.com/charmbracelet/lipgloss/v2"
  8	"github.com/charmbracelet/x/ansi"
  9)
 10
 11// HeaderRow denotes the header's row index used when rendering headers. Use
 12// this value when looking to customize header styles in StyleFunc.
 13const HeaderRow int = -1
 14
 15// StyleFunc is the style function that determines the style of a Cell.
 16//
 17// It takes the row and column of the cell as an input and determines the
 18// lipgloss Style to use for that cell position.
 19//
 20// Example:
 21//
 22//	t := table.New().
 23//	    Headers("Name", "Age").
 24//	    Row("Kini", 4).
 25//	    Row("Eli", 1).
 26//	    Row("Iris", 102).
 27//	    StyleFunc(func(row, col int) lipgloss.Style {
 28//	        switch {
 29//	           case row == 0:
 30//	               return HeaderStyle
 31//	           case row%2 == 0:
 32//	               return EvenRowStyle
 33//	           default:
 34//	               return OddRowStyle
 35//	           }
 36//	    })
 37type StyleFunc func(row, col int) lipgloss.Style
 38
 39// DefaultStyles is a TableStyleFunc that returns a new Style with no attributes.
 40func DefaultStyles(_, _ int) lipgloss.Style {
 41	return lipgloss.NewStyle()
 42}
 43
 44// Table is a type for rendering tables.
 45type Table struct {
 46	baseStyle lipgloss.Style
 47	styleFunc StyleFunc
 48	border    lipgloss.Border
 49
 50	borderTop    bool
 51	borderBottom bool
 52	borderLeft   bool
 53	borderRight  bool
 54	borderHeader bool
 55	borderColumn bool
 56	borderRow    bool
 57
 58	borderStyle lipgloss.Style
 59	headers     []string
 60	data        Data
 61
 62	width           int
 63	height          int
 64	useManualHeight bool
 65	yOffset         int
 66	wrap            bool
 67
 68	widths  []int
 69	heights []int
 70
 71	firstVisibleRowIndex int
 72	lastVisibleRowIndex  int
 73}
 74
 75// New returns a new Table that can be modified through different
 76// attributes.
 77//
 78// By default, a table has normal border, no styling, and no rows.
 79func New() *Table {
 80	return &Table{
 81		styleFunc:    DefaultStyles,
 82		border:       lipgloss.NormalBorder(),
 83		borderBottom: true,
 84		borderColumn: true,
 85		borderHeader: true,
 86		borderLeft:   true,
 87		borderRight:  true,
 88		borderTop:    true,
 89		wrap:         true,
 90		data:         NewStringData(),
 91	}
 92}
 93
 94// ClearRows clears the table rows.
 95func (t *Table) ClearRows() *Table {
 96	t.data = NewStringData()
 97	return t
 98}
 99
100// BaseStyle sets the base style for the whole table. If you need to set a
101// background color for the whole table, use this.
102func (t *Table) BaseStyle(baseStyle lipgloss.Style) *Table {
103	t.baseStyle = baseStyle
104	t.borderStyle = t.borderStyle.Inherit(baseStyle)
105	return t
106}
107
108// StyleFunc sets the style for a cell based on it's position (row, column).
109func (t *Table) StyleFunc(style StyleFunc) *Table {
110	t.styleFunc = style
111	return t
112}
113
114// style returns the style for a cell based on it's position (row, column).
115func (t *Table) style(row, col int) lipgloss.Style {
116	if t.styleFunc == nil {
117		return t.baseStyle
118	}
119	return t.styleFunc(row, col).Inherit(t.baseStyle)
120}
121
122// Data sets the table data.
123func (t *Table) Data(data Data) *Table {
124	t.data = data
125	return t
126}
127
128// GetData returns the table data.
129func (t *Table) GetData() Data {
130	return t.data
131}
132
133// Rows appends rows to the table data.
134func (t *Table) Rows(rows ...[]string) *Table {
135	for _, row := range rows {
136		switch t.data.(type) {
137		case *StringData:
138			t.data.(*StringData).Append(row)
139		}
140	}
141	return t
142}
143
144// Row appends a row to the table data.
145func (t *Table) Row(row ...string) *Table {
146	switch t.data.(type) {
147	case *StringData:
148		t.data.(*StringData).Append(row)
149	}
150	return t
151}
152
153// Headers sets the table headers.
154func (t *Table) Headers(headers ...string) *Table {
155	t.headers = headers
156	return t
157}
158
159// GetHeaders returns the table headers.
160func (t *Table) GetHeaders() []string {
161	return t.headers
162}
163
164// Border sets the table border.
165func (t *Table) Border(border lipgloss.Border) *Table {
166	t.border = border
167	return t
168}
169
170// BorderTop sets the top border.
171func (t *Table) BorderTop(v bool) *Table {
172	t.borderTop = v
173	return t
174}
175
176// BorderBottom sets the bottom border.
177func (t *Table) BorderBottom(v bool) *Table {
178	t.borderBottom = v
179	return t
180}
181
182// BorderLeft sets the left border.
183func (t *Table) BorderLeft(v bool) *Table {
184	t.borderLeft = v
185	return t
186}
187
188// BorderRight sets the right border.
189func (t *Table) BorderRight(v bool) *Table {
190	t.borderRight = v
191	return t
192}
193
194// BorderHeader sets the header separator border.
195func (t *Table) BorderHeader(v bool) *Table {
196	t.borderHeader = v
197	return t
198}
199
200// BorderColumn sets the column border separator.
201func (t *Table) BorderColumn(v bool) *Table {
202	t.borderColumn = v
203	return t
204}
205
206// BorderRow sets the row border separator.
207func (t *Table) BorderRow(v bool) *Table {
208	t.borderRow = v
209	return t
210}
211
212// BorderStyle sets the style for the table border.
213func (t *Table) BorderStyle(style lipgloss.Style) *Table {
214	t.borderStyle = style.Inherit(t.baseStyle)
215	return t
216}
217
218// GetBorderTop gets the top border.
219func (t *Table) GetBorderTop() bool {
220	return t.borderTop
221}
222
223// GetBorderBottom gets the bottom border.
224func (t *Table) GetBorderBottom() bool {
225	return t.borderBottom
226}
227
228// GetBorderLeft gets the left border.
229func (t *Table) GetBorderLeft() bool {
230	return t.borderLeft
231}
232
233// GetBorderRight gets the right border.
234func (t *Table) GetBorderRight() bool {
235	return t.borderRight
236}
237
238// GetBorderHeader gets the header separator border.
239func (t *Table) GetBorderHeader() bool {
240	return t.borderHeader
241}
242
243// GetBorderColumn gets the column border separator.
244func (t *Table) GetBorderColumn() bool {
245	return t.borderColumn
246}
247
248// GetBorderRow gets the row border separator.
249func (t *Table) GetBorderRow() bool {
250	return t.borderRow
251}
252
253// Width sets the table width, this auto-sizes the columns to fit the width by
254// either expanding or contracting the widths of each column as a best effort
255// approach.
256func (t *Table) Width(w int) *Table {
257	t.width = w
258	return t
259}
260
261// Height sets the table height.
262func (t *Table) Height(h int) *Table {
263	t.height = h
264	t.useManualHeight = true
265	return t
266}
267
268// GetHeight returns the height of the table.
269func (t *Table) GetHeight() int {
270	return t.height
271}
272
273// YOffset sets the table rendering offset.
274func (t *Table) YOffset(o int) *Table {
275	t.yOffset = o
276	return t
277}
278
279// GetYOffset returns the table rendering offset.
280func (t *Table) GetYOffset() int {
281	return t.yOffset
282}
283
284// FirstVisibleRowIndex returns the index of the first visible row in the table.
285func (t *Table) FirstVisibleRowIndex() int {
286	return t.firstVisibleRowIndex
287}
288
289// LastVisibleRowIndex returns the index of the last visible row in the table.
290func (t *Table) LastVisibleRowIndex() int {
291	return t.lastVisibleRowIndex
292}
293
294// VisibleRows returns the number of visible rows in the table.
295func (t *Table) VisibleRows() int {
296	if t.lastVisibleRowIndex == -2 {
297		return t.data.Rows() - t.firstVisibleRowIndex
298	}
299	return t.lastVisibleRowIndex - t.firstVisibleRowIndex + 1
300}
301
302// Wrap dictates whether or not the table content should wrap.
303//
304// This only applies to data cells. Headers are never wrapped.
305func (t *Table) Wrap(w bool) *Table {
306	t.wrap = w
307	return t
308}
309
310// String returns the table as a string.
311func (t *Table) String() string {
312	hasHeaders := len(t.headers) > 0
313	hasRows := t.data != nil && t.data.Rows() > 0
314
315	if !hasHeaders && !hasRows {
316		return ""
317	}
318
319	// Add empty cells to the headers, until it's the same length as the longest
320	// row (only if there are at headers in the first place).
321	if hasHeaders {
322		for i := len(t.headers); i < t.data.Columns(); i++ {
323			t.headers = append(t.headers, "")
324		}
325	}
326
327	// Do all the sizing calculations for width and height.
328	t.resize()
329
330	var sb strings.Builder
331
332	if t.borderTop {
333		sb.WriteString(t.constructTopBorder())
334		sb.WriteString("\n")
335	}
336
337	if hasHeaders {
338		sb.WriteString(t.constructHeaders())
339	}
340
341	var bottom string
342	if t.borderBottom {
343		bottom = t.constructBottomBorder()
344	}
345
346	// If there are no data rows render nothing.
347	if t.data.Rows() > 0 {
348		for r := t.firstVisibleRowIndex; r < t.data.Rows(); r++ {
349			if t.lastVisibleRowIndex != -2 && r > t.lastVisibleRowIndex {
350				break
351			}
352			sb.WriteString(t.constructRow(r))
353		}
354	}
355
356	sb.WriteString(bottom)
357
358	return lipgloss.NewStyle().
359		MaxHeight(min(t.height, t.computeHeight())).
360		MaxWidth(t.width).
361		Render(strings.TrimSuffix(sb.String(), "\n"))
362}
363
364// computeHeight computes the height of the table in it's current configuration.
365func (t *Table) computeHeight() int {
366	hasHeaders := len(t.headers) > 0
367	return sum(t.heights) - 1 + btoi(hasHeaders) +
368		btoi(t.borderTop) + btoi(t.borderBottom) +
369		btoi(t.borderHeader) + t.data.Rows()*btoi(t.borderRow)
370}
371
372// Render returns the table as a string.
373func (t *Table) Render() string {
374	return t.String()
375}
376
377// constructTopBorder constructs the top border for the table given it's current
378// border configuration and data.
379func (t *Table) constructTopBorder() string {
380	var s strings.Builder
381	if t.borderLeft {
382		s.WriteString(t.borderStyle.Render(t.border.TopLeft))
383	}
384	for i := range t.widths {
385		s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Top, t.widths[i])))
386		if i < len(t.widths)-1 && t.borderColumn {
387			s.WriteString(t.borderStyle.Render(t.border.MiddleTop))
388		}
389	}
390	if t.borderRight {
391		s.WriteString(t.borderStyle.Render(t.border.TopRight))
392	}
393	return s.String()
394}
395
396// constructBottomBorder constructs the bottom border for the table given it's current
397// border configuration and data.
398func (t *Table) constructBottomBorder() string {
399	var s strings.Builder
400	if t.borderLeft {
401		s.WriteString(t.borderStyle.Render(t.border.BottomLeft))
402	}
403	for i := range t.widths {
404		s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Bottom, t.widths[i])))
405		if i < len(t.widths)-1 && t.borderColumn {
406			s.WriteString(t.borderStyle.Render(t.border.MiddleBottom))
407		}
408	}
409	if t.borderRight {
410		s.WriteString(t.borderStyle.Render(t.border.BottomRight))
411	}
412	return s.String()
413}
414
415// constructHeaders constructs the headers for the table given it's current
416// header configuration and data.
417func (t *Table) constructHeaders() string {
418	var s strings.Builder
419	cells := make([]string, 0, len(t.headers)*2+1)
420	height := t.heights[0]
421
422	left := strings.Repeat(t.borderStyle.Render(t.border.Left)+"\n", height)
423	if t.borderLeft {
424		cells = append(cells, left)
425	}
426
427	for j, header := range t.headers {
428		cellStyle := t.style(HeaderRow, j)
429
430		// NOTE(@andreynering): We always truncate headers.
431		header = t.truncateCell(header, HeaderRow, j)
432
433		cells = append(cells,
434			cellStyle.
435				Height(height-cellStyle.GetVerticalMargins()).
436				Width(t.widths[j]-cellStyle.GetHorizontalMargins()).
437				Render(header),
438		)
439
440		if j < len(t.headers)-1 && t.borderColumn {
441			cells = append(cells, left)
442		}
443	}
444
445	if t.borderRight {
446		right := strings.Repeat(t.borderStyle.Render(t.border.Right)+"\n", height)
447		cells = append(cells, right)
448	}
449
450	for i, cell := range cells {
451		cells[i] = strings.TrimRight(cell, "\n")
452	}
453
454	s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, cells...) + "\n")
455
456	if t.borderHeader {
457		if t.borderLeft {
458			s.WriteString(t.borderStyle.Render(t.border.MiddleLeft))
459		}
460		for i := range t.headers {
461			s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Top, t.widths[i])))
462			if i < len(t.headers)-1 && t.borderColumn {
463				s.WriteString(t.borderStyle.Render(t.border.Middle))
464			}
465		}
466		if t.borderRight {
467			s.WriteString(t.borderStyle.Render(t.border.MiddleRight))
468		}
469		s.WriteString("\n")
470	}
471
472	return s.String()
473}
474
475// constructRow constructs the row for the table given an index and row data
476// based on the current configuration. If isOverflow is true, the row is
477// rendered as an overflow row (using ellipsis).
478func (t *Table) constructRow(index int) string {
479	var s strings.Builder
480	cells := make([]string, 0, t.data.Columns()*2+1)
481
482	hasHeaders := len(t.headers) > 0
483	height := t.heights[index+btoi(hasHeaders)]
484	isLastRow := index == t.data.Rows()-1
485	isOverflow := !isLastRow && t.lastVisibleRowIndex == index
486	if isOverflow {
487		height = max(height, 1)
488	}
489
490	left := strings.Repeat(t.borderStyle.Render(t.border.Left)+"\n", height)
491	if t.borderLeft {
492		cells = append(cells, left)
493	}
494
495	for c := range t.data.Columns() {
496		cell := "…"
497		if !isOverflow {
498			cell = t.data.At(index, c)
499		}
500
501		cellStyle := t.style(index, c)
502		if !t.wrap {
503			cell = t.truncateCell(cell, index, c)
504		}
505		cells = append(cells, cellStyle.
506			// Account for the margins in the cell sizing.
507			Height(height-cellStyle.GetVerticalMargins()).
508			MaxHeight(height).
509			Width(t.widths[c]-cellStyle.GetHorizontalMargins()).
510			MaxWidth(t.widths[c]).
511			Render(cell))
512
513		if c < t.data.Columns()-1 && t.borderColumn {
514			cells = append(cells, left)
515		}
516	}
517
518	if t.borderRight {
519		right := strings.Repeat(t.borderStyle.Render(t.border.Right)+"\n", height)
520		cells = append(cells, right)
521	}
522
523	for i, cell := range cells {
524		cells[i] = strings.TrimRight(cell, "\n")
525	}
526
527	s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, cells...) + "\n")
528
529	if t.borderRow && !isOverflow && index < t.data.Rows()-1 {
530		if t.borderLeft {
531			s.WriteString(t.borderStyle.Render(t.border.MiddleLeft))
532		}
533		for i := range t.widths {
534			s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Bottom, t.widths[i])))
535			if i < len(t.widths)-1 && t.borderColumn {
536				s.WriteString(t.borderStyle.Render(t.border.Middle))
537			}
538		}
539		if t.borderRight {
540			s.WriteString(t.borderStyle.Render(t.border.MiddleRight))
541		}
542		s.WriteString("\n")
543	}
544
545	return s.String()
546}
547
548func (t *Table) truncateCell(cell string, rowIndex, colIndex int) string {
549	hasHeaders := len(t.headers) > 0
550	height := t.heights[rowIndex+btoi(hasHeaders)]
551	cellWidth := t.widths[colIndex]
552	cellStyle := t.style(rowIndex, colIndex)
553
554	// NOTE(@andreynering): We always truncate headers to 1 line.
555	if rowIndex == HeaderRow {
556		height = 1
557	}
558
559	length := (cellWidth * height) - cellStyle.GetHorizontalPadding() - cellStyle.GetHorizontalMargins()
560	return ansi.Truncate(cell, length, "…")
561}