From 612678df40c45ae0b31a07bac921df9b4e731b0c Mon Sep 17 00:00:00 2001 From: Naveen Mahalingam Date: Mon, 9 Dec 2024 08:28:07 -0800 Subject: [PATCH] table: RowPainterWithAttributes; addresses #345 (#346) --- .github/workflows/ci.yml | 1 + Makefile | 9 ++- table/pager_test.go | 3 +- table/render_init.go | 53 +++++++++++------ table/render_test.go | 100 ++++++++++++++++++++------------- table/row.go | 43 ++++++++++++++ table/table.go | 73 +++++++++++++----------- table/table_test.go | 2 +- table/writer.go | 2 +- text/escape_seq_parser_test.go | 3 +- 10 files changed, 194 insertions(+), 95 deletions(-) create mode 100644 table/row.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c44d5f..c6f63dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: run: | go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 go install github.com/mattn/goveralls@v0.0.12 + go install github.com/rinchsan/gosimports/cmd/gosimports@v0.3.8 # Run all the unit-tests - name: Test diff --git a/Makefile b/Makefile index 6efb0ff..3bd19f3 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ all: test bench tools: go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.5.1 + go install github.com/rinchsan/gosimports/cmd/gosimports@v0.3.8 bench: go test -bench=. -benchmem @@ -23,16 +24,18 @@ demo-table: go run cmd/demo-table/demo.go fmt: - go fmt $(shell go list ./...) + go fmt ./... + gosimports -w . profile: sh profile.sh test: fmt vet cyclo - go test -cover -coverprofile=.coverprofile $(shell go list ./...) + go test -cover -coverprofile=.coverprofile ./... test-race: go run -race ./cmd/demo-progress/demo.go vet: - go vet $(shell go list ./...) + go vet ./... + diff --git a/table/pager_test.go b/table/pager_test.go index ec431b6..fe4b6a3 100644 --- a/table/pager_test.go +++ b/table/pager_test.go @@ -1,9 +1,10 @@ package table import ( - "github.com/stretchr/testify/assert" "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestPager(t *testing.T) { diff --git a/table/render_init.go b/table/render_init.go index 2627e01..7ad2b61 100644 --- a/table/render_init.go +++ b/table/render_init.go @@ -223,9 +223,6 @@ func (t *Table) initForRenderRows() { t.autoIndexVIndexMaxLength = len(fmt.Sprint(len(t.rowsRaw))) // stringify all the rows to make it easy to render - if t.rowPainter != nil { - t.rowsColors = make([]text.Colors, len(t.rowsRaw)) - } t.rows = t.initForRenderRowsStringify(t.rowsRaw, renderHint{}) t.rowsFooter = t.initForRenderRowsStringify(t.rowsFooterRaw, renderHint{isFooterRow: true}) t.rowsHeader = t.initForRenderRowsStringify(t.rowsHeaderRaw, renderHint{isHeaderRow: true}) @@ -233,6 +230,9 @@ func (t *Table) initForRenderRows() { // sort the rows as requested t.initForRenderSortRows() + // find the row colors (if any) + t.initForRenderRowPainterColors() + // suppress columns without any content t.initForRenderSuppressColumns() @@ -243,14 +243,42 @@ func (t *Table) initForRenderRows() { func (t *Table) initForRenderRowsStringify(rows []Row, hint renderHint) []rowStr { rowsStr := make([]rowStr, len(rows)) for idx, row := range rows { - if t.rowPainter != nil && hint.isRegularRow() { - t.rowsColors[idx] = t.rowPainter(row) - } + hint.rowNumber = idx + 1 rowsStr[idx] = t.analyzeAndStringify(row, hint) } return rowsStr } +func (t *Table) initForRenderRowPainterColors() { + if !t.hasRowPainter() { + return + } + + // generate the colors + t.rowsColors = make([]text.Colors, len(t.rowsRaw)) + for idx, row := range t.rowsRaw { + idxColors := idx + if len(t.sortedRowIndices) > 0 { + // override with the sorted row index + for j := 0; j < len(t.sortedRowIndices); j++ { + if t.sortedRowIndices[j] == idx { + idxColors = j + break + } + } + } + + if t.rowPainter != nil { + t.rowsColors[idxColors] = t.rowPainter(row) + } else if t.rowPainterWithAttributes != nil { + t.rowsColors[idxColors] = t.rowPainterWithAttributes(row, RowAttributes{ + Number: idx + 1, + NumberSorted: idxColors + 1, + }) + } + } +} + func (t *Table) initForRenderRowSeparator() { t.rowSeparator = make(rowStr, t.numColumns) for colIdx, maxColumnLength := range t.maxColumnLengths { @@ -265,21 +293,12 @@ func (t *Table) initForRenderSortRows() { } // sort the rows - sortedRowIndices := t.getSortedRowIndices() + t.sortedRowIndices = t.getSortedRowIndices() sortedRows := make([]rowStr, len(t.rows)) for idx := range t.rows { - sortedRows[idx] = t.rows[sortedRowIndices[idx]] + sortedRows[idx] = t.rows[t.sortedRowIndices[idx]] } t.rows = sortedRows - - // sort the rowsColors - if len(t.rowsColors) > 0 { - sortedRowsColors := make([]text.Colors, len(t.rows)) - for idx := range t.rows { - sortedRowsColors[idx] = t.rowsColors[sortedRowIndices[idx]] - } - t.rowsColors = sortedRowsColors - } } func (t *Table) initForRenderSuppressColumns() { diff --git a/table/render_test.go b/table/render_test.go index b0ad3ed..b2a50e7 100644 --- a/table/render_test.go +++ b/table/render_test.go @@ -878,13 +878,51 @@ func TestTable_Render_Reset(t *testing.T) { } func TestTable_Render_RowPainter(t *testing.T) { - tw := NewWriter() - tw.AppendHeader(testHeader) - tw.AppendRows(testRows) - tw.AppendRow(testRowMultiLine) - tw.AppendFooter(testFooter) - tw.SetIndexColumn(1) - tw.SetRowPainter(func(row Row) text.Colors { + runTestWithRowPainter := func(t *testing.T, rowPainter interface{}) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendRow(testRowMultiLine) + tw.AppendFooter(testFooter) + tw.SetIndexColumn(1) + tw.SetRowPainter(rowPainter) + tw.SetStyle(StyleLight) + tw.SortBy([]SortBy{{Name: "Salary", Mode: AscNumeric}}) + + expectedOutLines := []string{ + "┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐", + "│ # │ FIRST NAME │ LAST NAME │ SALARY │ │", + "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤", + "│ 0 │\x1b[41;30m Winter \x1b[0m│\x1b[41;30m Is \x1b[0m│\x1b[41;30m 0 \x1b[0m│\x1b[41;30m Coming. \x1b[0m│", + "│ │\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m The North Remembers! \x1b[0m│", + "│ │\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m This is known. \x1b[0m│", + "│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │", + "│ 1 │ Arya │ Stark │ 3000 │ │", + "│ 300 │\x1b[43;30m Tyrion \x1b[0m│\x1b[43;30m Lannister \x1b[0m│\x1b[43;30m 5000 \x1b[0m│\x1b[43;30m \x1b[0m│", + "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤", + "│ │ │ TOTAL │ 10000 │ │", + "└─────┴────────────┴───────────┴────────┴─────────────────────────────┘", + } + expectedOut := strings.Join(expectedOutLines, "\n") + assert.Equal(t, expectedOut, tw.Render()) + + tw.SetStyle(StyleColoredBright) + tw.Style().Color.RowAlternate = tw.Style().Color.Row + expectedOutLines = []string{ + "\x1b[106;30m # \x1b[0m\x1b[106;30m FIRST NAME \x1b[0m\x1b[106;30m LAST NAME \x1b[0m\x1b[106;30m SALARY \x1b[0m\x1b[106;30m \x1b[0m", + "\x1b[106;30m 0 \x1b[0m\x1b[41;30m Winter \x1b[0m\x1b[41;30m Is \x1b[0m\x1b[41;30m 0 \x1b[0m\x1b[41;30m Coming. \x1b[0m", + "\x1b[106;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m The North Remembers! \x1b[0m", + "\x1b[106;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m This is known. \x1b[0m", + "\x1b[106;30m 20 \x1b[0m\x1b[107;30m Jon \x1b[0m\x1b[107;30m Snow \x1b[0m\x1b[107;30m 2000 \x1b[0m\x1b[107;30m You know nothing, Jon Snow! \x1b[0m", + "\x1b[106;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m", + "\x1b[106;30m 300 \x1b[0m\x1b[43;30m Tyrion \x1b[0m\x1b[43;30m Lannister \x1b[0m\x1b[43;30m 5000 \x1b[0m\x1b[43;30m \x1b[0m", + "\x1b[46;30m \x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m TOTAL \x1b[0m\x1b[46;30m 10000 \x1b[0m\x1b[46;30m \x1b[0m", + } + expectedOut = strings.Join(expectedOutLines, "\n") + assert.Equal(t, expectedOut, tw.Render()) + } + + rowPainter := func(row Row) text.Colors { if salary, ok := row[3].(int); ok { if salary > 3000 { return text.Colors{text.BgYellow, text.FgBlack} @@ -893,41 +931,25 @@ func TestTable_Render_RowPainter(t *testing.T) { } } return nil + } + t.Run("RowPainter 1", func(t *testing.T) { + runTestWithRowPainter(t, rowPainter) + }) + t.Run("RowPainter 2", func(t *testing.T) { + runTestWithRowPainter(t, RowPainter(rowPainter)) }) - tw.SetStyle(StyleLight) - tw.SortBy([]SortBy{{Name: "Salary", Mode: AscNumeric}}) - expectedOutLines := []string{ - "┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐", - "│ # │ FIRST NAME │ LAST NAME │ SALARY │ │", - "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤", - "│ 0 │\x1b[41;30m Winter \x1b[0m│\x1b[41;30m Is \x1b[0m│\x1b[41;30m 0 \x1b[0m│\x1b[41;30m Coming. \x1b[0m│", - "│ │\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m The North Remembers! \x1b[0m│", - "│ │\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m \x1b[0m│\x1b[41;30m This is known. \x1b[0m│", - "│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │", - "│ 1 │ Arya │ Stark │ 3000 │ │", - "│ 300 │\x1b[43;30m Tyrion \x1b[0m│\x1b[43;30m Lannister \x1b[0m│\x1b[43;30m 5000 \x1b[0m│\x1b[43;30m \x1b[0m│", - "├─────┼────────────┼───────────┼────────┼─────────────────────────────┤", - "│ │ │ TOTAL │ 10000 │ │", - "└─────┴────────────┴───────────┴────────┴─────────────────────────────┘", + rowPainterWithAttributes := func(row Row, attr RowAttributes) text.Colors { + assert.NotZero(t, attr.Number) + assert.NotZero(t, attr.NumberSorted) + return rowPainter(row) } - expectedOut := strings.Join(expectedOutLines, "\n") - assert.Equal(t, expectedOut, tw.Render()) - - tw.SetStyle(StyleColoredBright) - tw.Style().Color.RowAlternate = tw.Style().Color.Row - expectedOutLines = []string{ - "\x1b[106;30m # \x1b[0m\x1b[106;30m FIRST NAME \x1b[0m\x1b[106;30m LAST NAME \x1b[0m\x1b[106;30m SALARY \x1b[0m\x1b[106;30m \x1b[0m", - "\x1b[106;30m 0 \x1b[0m\x1b[41;30m Winter \x1b[0m\x1b[41;30m Is \x1b[0m\x1b[41;30m 0 \x1b[0m\x1b[41;30m Coming. \x1b[0m", - "\x1b[106;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m The North Remembers! \x1b[0m", - "\x1b[106;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m \x1b[0m\x1b[41;30m This is known. \x1b[0m", - "\x1b[106;30m 20 \x1b[0m\x1b[107;30m Jon \x1b[0m\x1b[107;30m Snow \x1b[0m\x1b[107;30m 2000 \x1b[0m\x1b[107;30m You know nothing, Jon Snow! \x1b[0m", - "\x1b[106;30m 1 \x1b[0m\x1b[107;30m Arya \x1b[0m\x1b[107;30m Stark \x1b[0m\x1b[107;30m 3000 \x1b[0m\x1b[107;30m \x1b[0m", - "\x1b[106;30m 300 \x1b[0m\x1b[43;30m Tyrion \x1b[0m\x1b[43;30m Lannister \x1b[0m\x1b[43;30m 5000 \x1b[0m\x1b[43;30m \x1b[0m", - "\x1b[46;30m \x1b[0m\x1b[46;30m \x1b[0m\x1b[46;30m TOTAL \x1b[0m\x1b[46;30m 10000 \x1b[0m\x1b[46;30m \x1b[0m", - } - expectedOut = strings.Join(expectedOutLines, "\n") - assert.Equal(t, expectedOut, tw.Render()) + t.Run("RowPainterWithAttributes 1", func(t *testing.T) { + runTestWithRowPainter(t, rowPainterWithAttributes) + }) + t.Run("RowPainterWithAttributes 2", func(t *testing.T) { + runTestWithRowPainter(t, RowPainterWithAttributes(rowPainterWithAttributes)) + }) } func TestTable_Render_Sorted(t *testing.T) { diff --git a/table/row.go b/table/row.go new file mode 100644 index 0000000..3c165fc --- /dev/null +++ b/table/row.go @@ -0,0 +1,43 @@ +package table + +import ( + "fmt" + + "github.com/jedib0t/go-pretty/v6/text" +) + +// Row defines a single row in the Table. +type Row []interface{} + +func (r Row) findColumnNumber(colName string) int { + for colIdx, col := range r { + if fmt.Sprint(col) == colName { + return colIdx + 1 + } + } + return 0 +} + +// RowAttributes contains properties about the Row during the render. +type RowAttributes struct { + Number int // Row Number (1-indexed) as appended + NumberSorted int // Row number (1-indexed) after sorting +} + +// RowPainter is a custom function that takes a Row as input and returns the +// text.Colors{} to use on the entire row +type RowPainter func(row Row) text.Colors + +// RowPainterWithAttributes is the same as RowPainter but passes in additional +// attributes from render time +type RowPainterWithAttributes func(row Row, attr RowAttributes) text.Colors + +// rowStr defines a single row in the Table comprised of just string objects. +type rowStr []string + +// areEqual returns true if the contents of the 2 given columns are the same +func (row rowStr) areEqual(colIdx1 int, colIdx2 int) bool { + return colIdx1 >= 0 && colIdx1 < len(row) && + colIdx2 >= 0 && colIdx2 < len(row) && + row[colIdx1] == row[colIdx2] +} diff --git a/table/table.go b/table/table.go index 6cf0433..479d4b3 100644 --- a/table/table.go +++ b/table/table.go @@ -10,30 +10,6 @@ import ( "github.com/jedib0t/go-pretty/v6/text" ) -// Row defines a single row in the Table. -type Row []interface{} - -func (r Row) findColumnNumber(colName string) int { - for colIdx, col := range r { - if fmt.Sprint(col) == colName { - return colIdx + 1 - } - } - return 0 -} - -// RowPainter is a custom function that takes a Row as input and returns the -// text.Colors{} to use on the entire row -type RowPainter func(row Row) text.Colors - -// rowStr defines a single row in the Table comprised of just string objects. -type rowStr []string - -// areEqual returns true if the contents of the 2 given columns are the same -func (row rowStr) areEqual(colIdx1 int, colIdx2 int) bool { - return colIdx1 >= 0 && colIdx2 < len(row) && row[colIdx1] == row[colIdx2] -} - // Table helps print a 2-dimensional array in a human-readable pretty-table. type Table struct { // allowedRowLength is the max allowed length for a row (or line of output) @@ -74,7 +50,7 @@ type Table struct { // rows stores the rows that make up the body (in string form) rows []rowStr // rowsColors stores the text.Colors over-rides for each row as defined by - // rowPainter + // rowPainter or rowPainterWithAttributes rowsColors []text.Colors // rowsConfigs stores RowConfig for each row rowsConfigMap map[int]RowConfig @@ -95,6 +71,8 @@ type Table struct { // rowPainter is a custom function that given a Row, returns the colors to // use on the entire row rowPainter RowPainter + // rowPainterWithAttributes is same as rowPainter, but with attributes + rowPainterWithAttributes RowPainterWithAttributes // rowSeparator is a dummy row that contains the separator columns (dashes // that make up the separator between header/body/footer rowSeparator rowStr @@ -103,6 +81,8 @@ type Table struct { separators map[int]bool // sortBy stores a map of Column sortBy []SortBy + // sortedRowIndices is the output of sorting + sortedRowIndices []int // style contains all the strings used to draw the table, and more style *Style // suppressEmptyColumns hides columns which have no content on all regular @@ -309,12 +289,37 @@ func (t *Table) SetPageSize(numLines int) { t.pager.size = numLines } -// SetRowPainter sets the RowPainter function which determines the colors to use -// on a row. Before rendering, this function is invoked on all rows and the -// color of each row is determined. This color takes precedence over other ways -// to set color (ColumnConfig.Color*, SetColor*()). -func (t *Table) SetRowPainter(painter RowPainter) { - t.rowPainter = painter +// SetRowPainter sets up the function which determines the colors to use on a +// row. Before rendering, this function is invoked on all rows and the color +// of each row is determined. This color takes precedence over other ways to +// set color (ColumnConfig.Color*, SetColor*()). +func (t *Table) SetRowPainter(painter interface{}) { + // TODO: fix interface on major version bump to accept only + // one type of RowPainter: RowPainterWithAttributes renamed to RowPainter + + // reset both so only one is set at any given time + t.rowPainter = nil + t.rowPainterWithAttributes = nil + + // if called as SetRowPainter(RowPainter(func...)) + switch painter.(type) { + case RowPainter: + t.rowPainter = painter.(RowPainter) + return + case RowPainterWithAttributes: + t.rowPainterWithAttributes = painter.(RowPainterWithAttributes) + return + } + + // if called as SetRowPainter(func...) + switch fmt.Sprintf("%T", painter) { + case "func(table.Row) text.Colors": + t.rowPainter = painter.(func(row Row) text.Colors) + return + case "func(table.Row, table.RowAttributes) text.Colors": + t.rowPainterWithAttributes = painter.(func(row Row, attr RowAttributes) text.Colors) + return + } } // SetStyle overrides the DefaultStyle with the provided one. @@ -461,7 +466,7 @@ func (t *Table) getColumnColors(colIdx int, hint renderHint) text.Colors { return colors } } - if t.rowPainter != nil && hint.isRegularNonSeparatorRow() && !t.isIndexColumn(colIdx, hint) { + if t.hasRowPainter() && hint.isRegularNonSeparatorRow() && !t.isIndexColumn(colIdx, hint) { if colors := t.rowsColors[hint.rowNumber-1]; colors != nil { return colors } @@ -717,6 +722,10 @@ func (t *Table) hasHiddenColumns() bool { return false } +func (t *Table) hasRowPainter() bool { + return t.rowPainter != nil || t.rowPainterWithAttributes != nil +} + func (t *Table) hideColumns() map[int]int { colIdxMap := make(map[int]int) numColumns := 0 diff --git a/table/table_test.go b/table/table_test.go index 7ac01b0..1222aa8 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -2,13 +2,13 @@ package table import ( "fmt" - "github.com/stretchr/testify/require" "strings" "testing" "unicode/utf8" "github.com/jedib0t/go-pretty/v6/text" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var ( diff --git a/table/writer.go b/table/writer.go index 406aaab..51eee8c 100644 --- a/table/writer.go +++ b/table/writer.go @@ -27,7 +27,7 @@ type Writer interface { SetColumnConfigs(configs []ColumnConfig) SetIndexColumn(colNum int) SetOutputMirror(mirror io.Writer) - SetRowPainter(painter RowPainter) + SetRowPainter(painter interface{}) SetStyle(style Style) SetTitle(format string, a ...interface{}) SortBy(sortBy []SortBy) diff --git a/text/escape_seq_parser_test.go b/text/escape_seq_parser_test.go index f5926fb..5d7ae8a 100644 --- a/text/escape_seq_parser_test.go +++ b/text/escape_seq_parser_test.go @@ -1,8 +1,9 @@ package text import ( - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" ) func Test_escSeqParser(t *testing.T) {