-
Notifications
You must be signed in to change notification settings - Fork 51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: used go-pretty to improve table formatting on screen width #414
base: dev
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,10 +3,17 @@ package terminal | |
import ( | ||
"encoding/csv" | ||
"fmt" | ||
"io" | ||
"os" | ||
"strings" | ||
|
||
"golang.org/x/term" | ||
|
||
. "github.com/IBM-Cloud/ibm-cloud-cli-sdk/i18n" | ||
|
||
"io" | ||
|
||
"github.com/jedib0t/go-pretty/v6/table" | ||
"github.com/jedib0t/go-pretty/v6/text" | ||
"github.com/mattn/go-runewidth" | ||
) | ||
|
||
|
@@ -24,11 +31,10 @@ type Table interface { | |
} | ||
|
||
type PrintableTable struct { | ||
writer io.Writer | ||
headers []string | ||
headerPrinted bool | ||
maxSizes []int | ||
rows [][]string //each row is single line | ||
writer io.Writer | ||
headers []string | ||
maxSizes []int | ||
rows [][]string //each row is single line | ||
} | ||
|
||
func NewTable(w io.Writer, headers []string) Table { | ||
|
@@ -69,58 +75,152 @@ func (t *PrintableTable) Add(row ...string) { | |
} | ||
} | ||
|
||
func isWideColumn(col string) bool { | ||
// list of common columns that are usually wide | ||
largeColumnTypes := []string{T("ID"), T("Description")} | ||
|
||
for _, largeColn := range largeColumnTypes { | ||
if strings.Contains(largeColn, col) { | ||
return true | ||
} | ||
} | ||
|
||
return false | ||
|
||
} | ||
|
||
func terminalWidth() int { | ||
var err error | ||
terminalWidth, _, err := term.GetSize(int(os.Stdin.Fd())) | ||
|
||
if err != nil { | ||
// Assume normal 80 char width line | ||
terminalWidth = 80 | ||
} | ||
return terminalWidth | ||
} | ||
|
||
func (t *PrintableTable) Print() { | ||
for _, row := range append(t.rows, t.headers) { | ||
t.calculateMaxSize(row) | ||
} | ||
|
||
if t.headerPrinted == false { | ||
t.printHeader() | ||
t.headerPrinted = true | ||
tbl := table.NewWriter() | ||
tbl.SetOutputMirror(t.writer) | ||
tbl.SuppressTrailingSpaces() | ||
// remove padding from the left to keep the table aligned to the left | ||
tbl.Style().Box.PaddingLeft = "" | ||
tbl.Style().Box.PaddingRight = strings.Repeat(" ", minSpace) | ||
// remove all border and column and row separators | ||
tbl.Style().Options.DrawBorder = false | ||
tbl.Style().Options.SeparateColumns = false | ||
tbl.Style().Options.SeparateFooter = false | ||
tbl.Style().Options.SeparateHeader = false | ||
tbl.Style().Options.SeparateRows = false | ||
tbl.Style().Format.Header = text.FormatDefault | ||
|
||
headerRow, rows := t.createPrettyRowsAndHeaders() | ||
columnConfig := t.createColumnConfigs() | ||
|
||
tbl.SetColumnConfigs(columnConfig) | ||
tbl.AppendHeader(headerRow) | ||
tbl.AppendRows(rows) | ||
tbl.Render() | ||
} | ||
|
||
func (t *PrintableTable) createColumnConfigs() []table.ColumnConfig { | ||
// there must be at row in order to configure column | ||
if len(t.rows) == 0 { | ||
return []table.ColumnConfig{} | ||
} | ||
|
||
colCount := len(t.rows[0]) | ||
var ( | ||
widestColIndicies []int | ||
terminalWidth = terminalWidth() | ||
// total amount padding space that a row will take up | ||
totalPaddingSpace = (colCount - 1) * minSpace | ||
remainingSpace = terminalWidth - totalPaddingSpace | ||
// the estimated max column width by dividing the remaining space evenly across the columns | ||
maxColWidth = (terminalWidth - totalPaddingSpace) / colCount | ||
Aerex marked this conversation as resolved.
Show resolved
Hide resolved
|
||
) | ||
columnConfig := make([]table.ColumnConfig, len(t.maxSizes)) | ||
|
||
for i := range columnConfig { | ||
columnConfig[i] = table.ColumnConfig{ | ||
AlignHeader: text.AlignLeft, | ||
Align: text.AlignLeft, | ||
WidthMax: maxColWidth, | ||
Number: i + 1, | ||
} | ||
|
||
// assuming the table has headers: store columns with wide content where the max width may need to be adjusted | ||
// using the remaining space | ||
if t.maxSizes[i] > maxColWidth && (len(t.headers) > 0 && isWideColumn(t.headers[i])) { | ||
Aerex marked this conversation as resolved.
Show resolved
Hide resolved
|
||
widestColIndicies = append(widestColIndicies, i) | ||
} else if t.maxSizes[i] < maxColWidth { | ||
// use the max column width instead of the estimated max column width | ||
// if it is shorter | ||
columnConfig[i].WidthMax = t.maxSizes[i] | ||
remainingSpace -= t.maxSizes[i] | ||
} else { | ||
remainingSpace -= maxColWidth | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wondering if there can be a scenario that remainingSpace becomes 0 or less. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good question. I don't think there is a valid scenario since that would mean the user would need to have a lot of columns. For instance, for terminal width of 80, the user would need to create more than 27 columns (27 * 3 > 80) for there to be negative |
||
} | ||
} | ||
|
||
for _, line := range t.rows { | ||
t.printRow(line) | ||
// if only one wide column use the remaining space as the max column width | ||
if len(widestColIndicies) == 1 { | ||
idx := widestColIndicies[0] | ||
columnConfig[idx].WidthMax = remainingSpace | ||
} | ||
|
||
t.rows = [][]string{} | ||
} | ||
// if more than one wide column, spread the remaining space between the columns | ||
if len(widestColIndicies) > 1 { | ||
remainingSpace /= len(widestColIndicies) | ||
for _, columnCfgIdx := range widestColIndicies { | ||
columnConfig[columnCfgIdx].WidthMax = remainingSpace | ||
} | ||
|
||
func (t *PrintableTable) calculateMaxSize(row []string) { | ||
for index, value := range row { | ||
cellLength := runewidth.StringWidth(Decolorize(value)) | ||
if t.maxSizes[index] < cellLength { | ||
t.maxSizes[index] = cellLength | ||
origRemainingSpace := remainingSpace | ||
moreRemainingSpace := origRemainingSpace % len(widestColIndicies) | ||
if moreRemainingSpace != 0 { | ||
columnConfig[0].WidthMax += moreRemainingSpace | ||
} | ||
} | ||
|
||
return columnConfig | ||
} | ||
|
||
func (t *PrintableTable) printHeader() { | ||
output := "" | ||
for col, value := range t.headers { | ||
output = output + t.cellValue(col, HeaderColor(value)) | ||
func (t *PrintableTable) createPrettyRowsAndHeaders() (headerRow table.Row, rows []table.Row) { | ||
for _, header := range t.headers { | ||
headerRow = append(headerRow, header) | ||
} | ||
fmt.Fprintln(t.writer, output) | ||
} | ||
|
||
func (t *PrintableTable) printRow(row []string) { | ||
output := "" | ||
for columnIndex, value := range row { | ||
if columnIndex == 0 { | ||
value = TableContentHeaderColor(value) | ||
for i := range t.rows { | ||
var row, emptyRow table.Row | ||
for i, cell := range t.rows[i] { | ||
Aerex marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if i == 0 { | ||
cell = TableContentHeaderColor(cell) | ||
} | ||
row = append(row, cell) | ||
emptyRow = append(emptyRow, "") | ||
} | ||
|
||
output = output + t.cellValue(columnIndex, value) | ||
if i == 0 && len(t.headers) == 0 { | ||
rows = append(rows, emptyRow) | ||
} | ||
rows = append(rows, row) | ||
} | ||
fmt.Fprintln(t.writer, output) | ||
|
||
return | ||
} | ||
|
||
func (t *PrintableTable) cellValue(col int, value string) string { | ||
padding := "" | ||
if col < len(t.maxSizes)-1 { | ||
padding = strings.Repeat(" ", t.maxSizes[col]-runewidth.StringWidth(Decolorize(value))+minSpace) | ||
func (t *PrintableTable) calculateMaxSize(row []string) { | ||
for index, value := range row { | ||
cellLength := runewidth.StringWidth(Decolorize(value)) | ||
if t.maxSizes[index] < cellLength { | ||
t.maxSizes[index] = cellLength | ||
} | ||
} | ||
return fmt.Sprintf("%s%s", value, padding) | ||
} | ||
|
||
// Prints out a nicely/human formatted Json string instead of a table structure | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,7 +38,7 @@ func TestEmptyHeaderTable(t *testing.T) { | |
testTable.Add("row1", "row2") | ||
testTable.Print() | ||
assert.Contains(t, buf.String(), "row1") | ||
assert.Equal(t, " \nrow1 row2\n", buf.String()) | ||
assert.Equal(t, "\nrow1 row2\n", buf.String()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
|
||
func TestEmptyHeaderTableJson(t *testing.T) { | ||
|
@@ -79,7 +79,7 @@ func TestNotEnoughRowEntires(t *testing.T) { | |
testTable.Add("", "row2") | ||
testTable.Print() | ||
assert.Contains(t, buf.String(), "row1") | ||
assert.Equal(t, "col1 col2\nrow1 \n row2\n", buf.String()) | ||
assert.Equal(t, "col1 col2\nrow1\n row2\n", buf.String()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
|
||
func TestNotEnoughRowEntiresJson(t *testing.T) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be good to add some more testing to explicitly try out some odd column headings to ensure there's no breaking conditions. I don't have any in mind, but would like to see if we can test some boundary conditions here.
For example, these paths are not unit tested:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added more unit tests for various scenarios.