-
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 all commits
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,18 @@ package terminal | |
import ( | ||
"encoding/csv" | ||
"fmt" | ||
"io" | ||
"os" | ||
"strconv" | ||
"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 +32,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 +76,160 @@ 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 | ||
} | ||
|
||
testTerminalWidth, envSet := os.LookupEnv("TEST_TERMINAL_WIDTH") | ||
if envSet { | ||
envWidth, err := strconv.Atoi(testTerminalWidth) | ||
if err == nil { | ||
terminalWidth = envWidth | ||
} | ||
} | ||
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 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. 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. Added more unit tests for various scenarios. |
||
// there must be at row in order to configure column | ||
if len(t.rows) == 0 { | ||
return []table.ColumnConfig{} | ||
} | ||
|
||
for _, line := range t.rows { | ||
t.printRow(line) | ||
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 = max(0, terminalWidth-totalPaddingSpace) | ||
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. Callout: It is unlikely for the |
||
// the estimated max column width by dividing the remaining space evenly across the columns | ||
maxColWidth = remainingSpace / colCount | ||
) | ||
columnConfig := make([]table.ColumnConfig, colCount) | ||
|
||
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 && (i < len(t.headers) && isWideColumn(t.headers[i])) { | ||
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 |
||
} | ||
} | ||
|
||
t.rows = [][]string{} | ||
} | ||
// 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 | ||
} | ||
|
||
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 | ||
// 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 | ||
} | ||
|
||
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 j, cell := range t.rows[i] { | ||
if j == 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 |
---|---|---|
|
@@ -2,6 +2,7 @@ package terminal_test | |
|
||
import ( | ||
"bytes" | ||
"os" | ||
"strings" | ||
"testing" | ||
|
||
|
@@ -38,7 +39,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 +80,49 @@ 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 TestMoreColThanTerminalWidth(t *testing.T) { | ||
os.Setenv("TEST_TERMINAL_WIDTH", "1") | ||
buf := bytes.Buffer{} | ||
testTable := NewTable(&buf, []string{"col1"}) | ||
testTable.Add("row1", "row2") | ||
testTable.Print() | ||
assert.Contains(t, buf.String(), "row1") | ||
assert.Equal(t, "col1\nrow1 row2\n", buf.String()) | ||
os.Unsetenv("TEST_TERMINAL_WIDTH") | ||
} | ||
|
||
func TestWideHeaderNames(t *testing.T) { | ||
buf := bytes.Buffer{} | ||
testTable := NewTable(&buf, []string{"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt u", "NAME"}) | ||
testTable.Add("col1", "col2") | ||
testTable.Print() | ||
assert.Contains(t, buf.String(), "Lorem ipsum dolor sit amet, consectetu") | ||
assert.Equal(t, "Lorem ipsum dolor sit amet, consectetu NAME\nr adipiscing elit, sed do eiusmod temp\nor incididunt u\ncol1 col2\n", buf.String()) | ||
} | ||
|
||
func TestWidestColumn(t *testing.T) { | ||
buf := bytes.Buffer{} | ||
id := "ABCDEFG-9b8babbd-f2ed-4371-b817-a839e4130332" | ||
testTable := NewTable(&buf, []string{"ID", "Name"}) | ||
testTable.Add(id, "row2") | ||
testTable.Print() | ||
assert.Contains(t, buf.String(), id) | ||
assert.Equal(t, buf.String(), "ID Name\nABCDEFG-9b8babbd-f2ed-4371-b817-a839e4130332 row2\n") | ||
} | ||
|
||
func TestMultiWideColumns(t *testing.T) { | ||
buf := bytes.Buffer{} | ||
id := "ABCDEFG-9b8babbd-f2ed-4371-b817-a839e4130332" | ||
desc := "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut" | ||
testTable := NewTable(&buf, []string{"ID", "Description", "Name"}) | ||
testTable.Add(id, desc, "col3") | ||
testTable.Print() | ||
assert.Contains(t, buf.String(), "ABCDEFG-9b8babbd-f2ed-4371-b817-a839") | ||
assert.Contains(t, buf.String(), "e4130332") | ||
assert.Equal(t, buf.String(), "ID Description Name\nABCDEFG-9b8babbd-f2ed-4371-b817-a839 Lorem ipsum dolor sit amet, consect col3\ne4130332 etur adipiscing elit, sed do eiusmo\n d tempor incididunt ut\n") | ||
} | ||
|
||
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.
Callout: Added test environment variable to test variable terminal width in unit tests