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}