Skip to content

Commit 821d698

Browse files
authored
feat: used go-pretty to improve table formatting on screen width (#414)
1 parent 023a0fb commit 821d698

File tree

4 files changed

+206
-48
lines changed

4 files changed

+206
-48
lines changed

bluemix/terminal/table.go

Lines changed: 146 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@ package terminal
33
import (
44
"encoding/csv"
55
"fmt"
6-
"io"
6+
"os"
7+
"strconv"
78
"strings"
89

10+
"golang.org/x/term"
11+
912
. "github.com/IBM-Cloud/ibm-cloud-cli-sdk/i18n"
13+
14+
"io"
15+
16+
"github.com/jedib0t/go-pretty/v6/table"
17+
"github.com/jedib0t/go-pretty/v6/text"
1018
"github.com/mattn/go-runewidth"
1119
)
1220

@@ -24,11 +32,10 @@ type Table interface {
2432
}
2533

2634
type PrintableTable struct {
27-
writer io.Writer
28-
headers []string
29-
headerPrinted bool
30-
maxSizes []int
31-
rows [][]string //each row is single line
35+
writer io.Writer
36+
headers []string
37+
maxSizes []int
38+
rows [][]string //each row is single line
3239
}
3340

3441
func NewTable(w io.Writer, headers []string) Table {
@@ -69,58 +76,160 @@ func (t *PrintableTable) Add(row ...string) {
6976
}
7077
}
7178

79+
func isWideColumn(col string) bool {
80+
// list of common columns that are usually wide
81+
largeColumnTypes := []string{T("ID"), T("Description")}
82+
83+
for _, largeColn := range largeColumnTypes {
84+
if strings.Contains(largeColn, col) {
85+
return true
86+
}
87+
}
88+
89+
return false
90+
91+
}
92+
93+
func terminalWidth() int {
94+
var err error
95+
terminalWidth, _, err := term.GetSize(int(os.Stdin.Fd()))
96+
97+
if err != nil {
98+
// Assume normal 80 char width line
99+
terminalWidth = 80
100+
}
101+
102+
testTerminalWidth, envSet := os.LookupEnv("TEST_TERMINAL_WIDTH")
103+
if envSet {
104+
envWidth, err := strconv.Atoi(testTerminalWidth)
105+
if err == nil {
106+
terminalWidth = envWidth
107+
}
108+
}
109+
return terminalWidth
110+
}
111+
72112
func (t *PrintableTable) Print() {
73113
for _, row := range append(t.rows, t.headers) {
74114
t.calculateMaxSize(row)
75115
}
76116

77-
if t.headerPrinted == false {
78-
t.printHeader()
79-
t.headerPrinted = true
117+
tbl := table.NewWriter()
118+
tbl.SetOutputMirror(t.writer)
119+
tbl.SuppressTrailingSpaces()
120+
// remove padding from the left to keep the table aligned to the left
121+
tbl.Style().Box.PaddingLeft = ""
122+
tbl.Style().Box.PaddingRight = strings.Repeat(" ", minSpace)
123+
// remove all border and column and row separators
124+
tbl.Style().Options.DrawBorder = false
125+
tbl.Style().Options.SeparateColumns = false
126+
tbl.Style().Options.SeparateFooter = false
127+
tbl.Style().Options.SeparateHeader = false
128+
tbl.Style().Options.SeparateRows = false
129+
tbl.Style().Format.Header = text.FormatDefault
130+
131+
headerRow, rows := t.createPrettyRowsAndHeaders()
132+
columnConfig := t.createColumnConfigs()
133+
134+
tbl.SetColumnConfigs(columnConfig)
135+
tbl.AppendHeader(headerRow)
136+
tbl.AppendRows(rows)
137+
tbl.Render()
138+
}
139+
140+
func (t *PrintableTable) createColumnConfigs() []table.ColumnConfig {
141+
// there must be at row in order to configure column
142+
if len(t.rows) == 0 {
143+
return []table.ColumnConfig{}
80144
}
81145

82-
for _, line := range t.rows {
83-
t.printRow(line)
146+
colCount := len(t.rows[0])
147+
var (
148+
widestColIndicies []int
149+
terminalWidth = terminalWidth()
150+
// total amount padding space that a row will take up
151+
totalPaddingSpace = (colCount - 1) * minSpace
152+
remainingSpace = max(0, terminalWidth-totalPaddingSpace)
153+
// the estimated max column width by dividing the remaining space evenly across the columns
154+
maxColWidth = remainingSpace / colCount
155+
)
156+
columnConfig := make([]table.ColumnConfig, colCount)
157+
158+
for colIndex := range columnConfig {
159+
columnConfig[colIndex] = table.ColumnConfig{
160+
AlignHeader: text.AlignLeft,
161+
Align: text.AlignLeft,
162+
WidthMax: maxColWidth,
163+
Number: colIndex + 1,
164+
}
165+
166+
// assuming the table has headers: store columns with wide content where the max width may need to be adjusted
167+
// using the remaining space
168+
if t.maxSizes[colIndex] > maxColWidth && (colIndex < len(t.headers) && isWideColumn(t.headers[colIndex])) {
169+
widestColIndicies = append(widestColIndicies, colIndex)
170+
} else if t.maxSizes[colIndex] < maxColWidth {
171+
// use the max column width instead of the estimated max column width
172+
// if it is shorter
173+
columnConfig[colIndex].WidthMax = t.maxSizes[colIndex]
174+
remainingSpace -= t.maxSizes[colIndex]
175+
} else {
176+
remainingSpace -= maxColWidth
177+
}
84178
}
85179

86-
t.rows = [][]string{}
87-
}
180+
// if only one wide column use the remaining space as the max column width
181+
if len(widestColIndicies) == 1 {
182+
widestColIndx := widestColIndicies[0]
183+
columnConfig[widestColIndx].WidthMax = remainingSpace
184+
}
88185

89-
func (t *PrintableTable) calculateMaxSize(row []string) {
90-
for index, value := range row {
91-
cellLength := runewidth.StringWidth(Decolorize(value))
92-
if t.maxSizes[index] < cellLength {
93-
t.maxSizes[index] = cellLength
186+
// if more than one wide column, spread the remaining space between the columns
187+
if len(widestColIndicies) > 1 {
188+
remainingSpace /= len(widestColIndicies)
189+
for _, columnCfgIdx := range widestColIndicies {
190+
columnConfig[columnCfgIdx].WidthMax = remainingSpace
191+
}
192+
193+
origRemainingSpace := remainingSpace
194+
moreRemainingSpace := origRemainingSpace % len(widestColIndicies)
195+
if moreRemainingSpace != 0 {
196+
columnConfig[0].WidthMax += moreRemainingSpace
94197
}
95198
}
199+
200+
return columnConfig
96201
}
97202

98-
func (t *PrintableTable) printHeader() {
99-
output := ""
100-
for col, value := range t.headers {
101-
output = output + t.cellValue(col, HeaderColor(value))
203+
func (t *PrintableTable) createPrettyRowsAndHeaders() (headerRow table.Row, rows []table.Row) {
204+
for _, header := range t.headers {
205+
headerRow = append(headerRow, header)
102206
}
103-
fmt.Fprintln(t.writer, output)
104-
}
105207

106-
func (t *PrintableTable) printRow(row []string) {
107-
output := ""
108-
for columnIndex, value := range row {
109-
if columnIndex == 0 {
110-
value = TableContentHeaderColor(value)
208+
for i := range t.rows {
209+
var row, emptyRow table.Row
210+
for j, cell := range t.rows[i] {
211+
if j == 0 {
212+
cell = TableContentHeaderColor(cell)
213+
}
214+
row = append(row, cell)
215+
emptyRow = append(emptyRow, "")
111216
}
112-
113-
output = output + t.cellValue(columnIndex, value)
217+
if i == 0 && len(t.headers) == 0 {
218+
rows = append(rows, emptyRow)
219+
}
220+
rows = append(rows, row)
114221
}
115-
fmt.Fprintln(t.writer, output)
222+
223+
return
116224
}
117225

118-
func (t *PrintableTable) cellValue(col int, value string) string {
119-
padding := ""
120-
if col < len(t.maxSizes)-1 {
121-
padding = strings.Repeat(" ", t.maxSizes[col]-runewidth.StringWidth(Decolorize(value))+minSpace)
226+
func (t *PrintableTable) calculateMaxSize(row []string) {
227+
for index, value := range row {
228+
cellLength := runewidth.StringWidth(Decolorize(value))
229+
if t.maxSizes[index] < cellLength {
230+
t.maxSizes[index] = cellLength
231+
}
122232
}
123-
return fmt.Sprintf("%s%s", value, padding)
124233
}
125234

126235
// Prints out a nicely/human formatted Json string instead of a table structure

bluemix/terminal/table_test.go

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package terminal_test
22

33
import (
44
"bytes"
5+
"os"
56
"strings"
67
"testing"
78

@@ -38,7 +39,7 @@ func TestEmptyHeaderTable(t *testing.T) {
3839
testTable.Add("row1", "row2")
3940
testTable.Print()
4041
assert.Contains(t, buf.String(), "row1")
41-
assert.Equal(t, " \nrow1 row2\n", buf.String())
42+
assert.Equal(t, "\nrow1 row2\n", buf.String())
4243
}
4344

4445
func TestEmptyHeaderTableJson(t *testing.T) {
@@ -79,7 +80,49 @@ func TestNotEnoughRowEntires(t *testing.T) {
7980
testTable.Add("", "row2")
8081
testTable.Print()
8182
assert.Contains(t, buf.String(), "row1")
82-
assert.Equal(t, "col1 col2\nrow1 \n row2\n", buf.String())
83+
assert.Equal(t, "col1 col2\nrow1\n row2\n", buf.String())
84+
}
85+
86+
func TestMoreColThanTerminalWidth(t *testing.T) {
87+
os.Setenv("TEST_TERMINAL_WIDTH", "1")
88+
buf := bytes.Buffer{}
89+
testTable := NewTable(&buf, []string{"col1"})
90+
testTable.Add("row1", "row2")
91+
testTable.Print()
92+
assert.Contains(t, buf.String(), "row1")
93+
assert.Equal(t, "col1\nrow1 row2\n", buf.String())
94+
os.Unsetenv("TEST_TERMINAL_WIDTH")
95+
}
96+
97+
func TestWideHeaderNames(t *testing.T) {
98+
buf := bytes.Buffer{}
99+
testTable := NewTable(&buf, []string{"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt u", "NAME"})
100+
testTable.Add("col1", "col2")
101+
testTable.Print()
102+
assert.Contains(t, buf.String(), "Lorem ipsum dolor sit amet, consectetu")
103+
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())
104+
}
105+
106+
func TestWidestColumn(t *testing.T) {
107+
buf := bytes.Buffer{}
108+
id := "ABCDEFG-9b8babbd-f2ed-4371-b817-a839e4130332"
109+
testTable := NewTable(&buf, []string{"ID", "Name"})
110+
testTable.Add(id, "row2")
111+
testTable.Print()
112+
assert.Contains(t, buf.String(), id)
113+
assert.Equal(t, buf.String(), "ID Name\nABCDEFG-9b8babbd-f2ed-4371-b817-a839e4130332 row2\n")
114+
}
115+
116+
func TestMultiWideColumns(t *testing.T) {
117+
buf := bytes.Buffer{}
118+
id := "ABCDEFG-9b8babbd-f2ed-4371-b817-a839e4130332"
119+
desc := "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut"
120+
testTable := NewTable(&buf, []string{"ID", "Description", "Name"})
121+
testTable.Add(id, desc, "col3")
122+
testTable.Print()
123+
assert.Contains(t, buf.String(), "ABCDEFG-9b8babbd-f2ed-4371-b817-a839")
124+
assert.Contains(t, buf.String(), "e4130332")
125+
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")
83126
}
84127

85128
func TestNotEnoughRowEntiresJson(t *testing.T) {

go.mod

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ require (
66
github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886
77
github.com/fatih/structs v1.0.1-0.20171020064819-f5faa72e7309
88
github.com/gofrs/flock v0.8.1
9+
github.com/jedib0t/go-pretty/v6 v6.6.1
910
github.com/mattn/go-colorable v0.0.0-20160210001857-9fdad7c47650
10-
github.com/mattn/go-runewidth v0.0.0-20151118072159-d96d1bd051f2
11+
github.com/mattn/go-runewidth v0.0.15
1112
github.com/nicksnyder/go-i18n/v2 v2.2.0
1213
github.com/onsi/gomega v1.33.0
1314
github.com/spf13/cobra v1.6.1
1415
github.com/spf13/pflag v1.0.5
15-
github.com/stretchr/testify v1.2.2
16+
github.com/stretchr/testify v1.8.4
1617
golang.org/x/crypto v0.31.0
18+
golang.org/x/term v0.27.0
1719
golang.org/x/text v0.21.0
1820
gopkg.in/cheggaaa/pb.v1 v1.0.15
1921
gopkg.in/yaml.v2 v2.4.0
@@ -26,8 +28,8 @@ require (
2628
github.com/inconshreveable/mousetrap v1.0.1 // indirect
2729
github.com/mattn/go-isatty v0.0.5-0.20180830101745-3fb116b82035 // indirect
2830
github.com/pmezard/go-difflib v1.0.0 // indirect
31+
github.com/rivo/uniseg v0.2.0 // indirect
2932
golang.org/x/net v0.33.0 // indirect
3033
golang.org/x/sys v0.28.0 // indirect
31-
golang.org/x/term v0.27.0 // indirect
3234
gopkg.in/yaml.v3 v3.0.1 // indirect
3335
)

go.sum

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,18 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
1616
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
1717
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
1818
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
19-
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
20-
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
19+
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
20+
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
2121
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
2222
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
23+
github.com/jedib0t/go-pretty/v6 v6.6.1 h1:iJ65Xjb680rHcikRj6DSIbzCex2huitmc7bDtxYVWyc=
24+
github.com/jedib0t/go-pretty/v6 v6.6.1/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E=
2325
github.com/mattn/go-colorable v0.0.0-20160210001857-9fdad7c47650 h1:pwtfAm8Do0gwFJ2J+iUrEVR9qI03BpDSuDQCIqbd6iY=
2426
github.com/mattn/go-colorable v0.0.0-20160210001857-9fdad7c47650/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
2527
github.com/mattn/go-isatty v0.0.5-0.20180830101745-3fb116b82035 h1:USWjF42jDCSEeikX/G1g40ZWnsPXN5WkZ4jMHZWyBK4=
2628
github.com/mattn/go-isatty v0.0.5-0.20180830101745-3fb116b82035/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
27-
github.com/mattn/go-runewidth v0.0.0-20151118072159-d96d1bd051f2 h1:K4BQSf+ZGZ8QlDL8RsUD1DES25Lgetj1JJGJz1G7Bno=
28-
github.com/mattn/go-runewidth v0.0.0-20151118072159-d96d1bd051f2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
29+
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
30+
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
2931
github.com/nicksnyder/go-i18n/v2 v2.2.0 h1:MNXbyPvd141JJqlU6gJKrczThxJy+kdCNivxZpBQFkw=
3032
github.com/nicksnyder/go-i18n/v2 v2.2.0/go.mod h1:4OtLfzqyAxsscyCb//3gfqSvBc81gImX91LrZzczN1o=
3133
github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8=
@@ -34,13 +36,15 @@ github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE=
3436
github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY=
3537
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
3638
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
39+
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
40+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
3741
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
3842
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
3943
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
4044
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
4145
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
42-
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
43-
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
46+
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
47+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
4448
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
4549
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
4650
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=

0 commit comments

Comments
 (0)