diff --git a/examples/table/footer/main.go b/examples/table/footer/main.go new file mode 100644 index 00000000..5a4d46cf --- /dev/null +++ b/examples/table/footer/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +func main() { + tbl := table.New(). + Headers("Name", "Age", "City"). + Row("Alice", "25", "New York"). + Row("Bob", "30", "Los Angeles"). + Row("Charlie", "35", "Chicago"). + Footers("Total", "3", "3 cities"). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4A90E2")) + } + if row == table.FooterRow { + return lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#34495E")) + } + return lipgloss.NewStyle() + }). + Border(lipgloss.RoundedBorder()). + BorderHeader(true). + BorderFooter(true). + BorderColumn(true). + BorderRow(false) + + fmt.Println(tbl) +} diff --git a/table/resizing.go b/table/resizing.go index 30b0a010..334314d8 100644 --- a/table/resizing.go +++ b/table/resizing.go @@ -46,8 +46,9 @@ import ( // The biggest difference is 15 - 2, so we can shrink the 2nd column by 13. func (t *Table) resize() { hasHeaders := len(t.headers) > 0 + hasFooters := len(t.footers) > 0 rows := dataToMatrix(t.data) - r := newResizer(t.width, t.height, t.headers, rows) + r := newResizer(t.width, t.height, t.headers, rows, t.footers) r.wrap = t.wrap r.borderColumn = t.borderColumn r.yPaddings = make([][]int, len(r.allRows)) @@ -58,6 +59,9 @@ func (t *Table) resize() { } else { allRows = rows } + if hasFooters { + allRows = append(allRows, t.footers) + } styleFunc := t.styleFunc if t.styleFunc == nil { @@ -72,12 +76,15 @@ func (t *Table) resize() { for j := range row { column := &r.columns[j] - // Making sure we're passing the right index to `styleFunc`. The header row should be `-1` and - // the others should start from `0`. + // Making sure we're passing the right index to `styleFunc`. The header row should be `-1`, + // footer row should be `-2`, and the others should start from `0`. rowIndex := i if hasHeaders { rowIndex-- } + if hasFooters && i >= len(rows)+btoi(hasHeaders) { + rowIndex = FooterRow + } style := styleFunc(rowIndex, j) topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin() @@ -129,7 +136,7 @@ type resizer struct { } // newResizer creates a new resizer. -func newResizer(tableWidth, tableHeight int, headers []string, rows [][]string) *resizer { +func newResizer(tableWidth, tableHeight int, headers []string, rows [][]string, footers []string) *resizer { r := &resizer{ tableWidth: tableWidth, tableHeight: tableHeight, @@ -141,6 +148,9 @@ func newResizer(tableWidth, tableHeight int, headers []string, rows [][]string) } else { r.allRows = rows } + if len(footers) > 0 { + r.allRows = append(r.allRows, footers) + } for _, row := range r.allRows { for i, cell := range row { diff --git a/table/table.go b/table/table.go index ad17fcfb..2b4cbb0c 100644 --- a/table/table.go +++ b/table/table.go @@ -12,6 +12,10 @@ import ( // this value when looking to customize header styles in StyleFunc. const HeaderRow int = -1 +// FooterRow denotes the footer's row index used when rendering footers. Use +// this value when looking to customize footer styles in StyleFunc. +const FooterRow int = -2 + // StyleFunc is the style function that determines the style of a Cell. // // It takes the row and column of the cell as an input and determines the @@ -51,11 +55,13 @@ type Table struct { borderLeft bool borderRight bool borderHeader bool + borderFooter bool borderColumn bool borderRow bool borderStyle lipgloss.Style headers []string + footers []string data Data width int @@ -82,6 +88,7 @@ func New() *Table { borderBottom: true, borderColumn: true, borderHeader: true, + borderFooter: true, borderLeft: true, borderRight: true, borderTop: true, @@ -142,6 +149,12 @@ func (t *Table) Headers(headers ...string) *Table { return t } +// Footers sets the table footers. +func (t *Table) Footers(footers ...string) *Table { + t.footers = footers + return t +} + // Border sets the table border. func (t *Table) Border(border lipgloss.Border) *Table { t.border = border @@ -178,6 +191,12 @@ func (t *Table) BorderHeader(v bool) *Table { return t } +// BorderFooter sets the footer separator border. +func (t *Table) BorderFooter(v bool) *Table { + t.borderFooter = v + return t +} + // BorderColumn sets the column border separator. func (t *Table) BorderColumn(v bool) *Table { t.borderColumn = v @@ -229,9 +248,10 @@ func (t *Table) Wrap(w bool) *Table { // String returns the table as a string. func (t *Table) String() string { hasHeaders := len(t.headers) > 0 + hasFooters := len(t.footers) > 0 hasRows := t.data != nil && t.data.Rows() > 0 - if !hasHeaders && !hasRows { + if !hasHeaders && !hasFooters && !hasRows { return "" } @@ -243,6 +263,14 @@ func (t *Table) String() string { } } + // Add empty cells to the footers, until it's the same length as the longest + // row (only if there are footers in the first place). + if hasFooters { + for i := len(t.footers); i < t.data.Columns(); i++ { + t.footers = append(t.footers, "") + } + } + // Do all the sizing calculations for width and height. t.resize() @@ -285,6 +313,11 @@ func (t *Table) String() string { } } + if hasFooters { + sb.WriteString(t.constructFooters()) + sb.WriteString("\n") + } + sb.WriteString(bottom) return lipgloss.NewStyle(). @@ -296,9 +329,10 @@ func (t *Table) String() string { // computeHeight computes the height of the table in it's current configuration. func (t *Table) computeHeight() int { hasHeaders := len(t.headers) > 0 - return sum(t.heights) - 1 + btoi(hasHeaders) + + hasFooters := len(t.footers) > 0 + return sum(t.heights) - 1 + btoi(hasHeaders) + btoi(hasFooters) + btoi(t.borderTop) + btoi(t.borderBottom) + - btoi(t.borderHeader) + t.data.Rows()*btoi(t.borderRow) + btoi(t.borderHeader) + btoi(hasFooters && t.borderFooter) + t.data.Rows()*btoi(t.borderRow) } // Render returns the table as a string. @@ -394,6 +428,55 @@ func (t *Table) constructHeaders() string { return s.String() } +// constructFooters constructs the footers for the table given it's current +// footer configuration and data. +func (t *Table) constructFooters() string { + // Footer row is at the end of the heights array + footerRowIndex := len(t.heights) - 1 + height := t.heights[footerRowIndex] + + var s strings.Builder + if t.borderFooter { + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.MiddleLeft)) + } + for i := 0; i < len(t.footers); i++ { + s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Bottom, t.widths[i]))) + if i < len(t.footers)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.Middle)) + } + } + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.MiddleRight)) + } + s.WriteString("\n") + } + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.Left)) + } + for i, footer := range t.footers { + cellStyle := t.style(FooterRow, i) + + if !t.wrap { + footer = t.truncateCell(footer, FooterRow, i) + } + + s.WriteString(cellStyle. + Height(height - cellStyle.GetVerticalMargins()). + MaxHeight(height). + Width(t.widths[i] - cellStyle.GetHorizontalMargins()). + MaxWidth(t.widths[i]). + Render(t.truncateCell(footer, FooterRow, i))) + if i < len(t.footers)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.Left)) + } + } + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.Right)) + } + return s.String() +} + func (t *Table) constructRows(availableLines int) string { var sb strings.Builder @@ -494,7 +577,15 @@ func (t *Table) constructRow(index int, isOverflow bool) string { func (t *Table) truncateCell(cell string, rowIndex, colIndex int) string { hasHeaders := len(t.headers) > 0 - height := t.heights[rowIndex+btoi(hasHeaders)] + + var height int + if rowIndex == FooterRow { + // Footer row is at the end of the heights array + height = t.heights[len(t.heights)-1] + } else { + height = t.heights[rowIndex+btoi(hasHeaders)] + } + cellWidth := t.widths[colIndex] cellStyle := t.style(rowIndex, colIndex) diff --git a/table/table_test.go b/table/table_test.go index 3957c5d1..456ab6f4 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -1407,3 +1407,18 @@ func TestWrapStyleFuncContent(t *testing.T) { Wrap(true) golden.RequireEqual(t, []byte(table.String())) } + +func TestTableFooters(t *testing.T) { + tbl := New(). + Headers("Name", "Age", "City"). + Row("Alice", "25", "New York"). + Row("Bob", "30", "Los Angeles"). + Row("Charlie", "35", "Chicago"). + Footers("Total", "3", "3 cities"). + StyleFunc(func(row, col int) lipgloss.Style { + return lipgloss.NewStyle() + }). + Border(lipgloss.RoundedBorder()) + + golden.RequireEqual(t, []byte(tbl.String())) +} diff --git a/table/testdata/TestTableFooters.golden b/table/testdata/TestTableFooters.golden new file mode 100644 index 00000000..9bc64e51 --- /dev/null +++ b/table/testdata/TestTableFooters.golden @@ -0,0 +1,9 @@ +╭───────┬───┬───────────╮ +│Name │Age│City │ +├───────┼───┼───────────┤ +│Alice │25 │New York │ +│Bob │30 │Los Angeles│ +│Charlie│35 │Chicago │ +├───────┼───┼───────────┤ +│Total │3 │3 cities │ +╰───────┴───┴───────────╯ \ No newline at end of file