Skip to content

Commit 853b6a5

Browse files
authored
Merge pull request #61 from cli/tableprinter
Extract table printer from CLI
2 parents 2a2d6b3 + 58c4805 commit 853b6a5

File tree

8 files changed

+656
-0
lines changed

8 files changed

+656
-0
lines changed

example_gh_test.go

+24
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"time"
99

1010
"github.com/cli/go-gh/pkg/api"
11+
"github.com/cli/go-gh/pkg/tableprinter"
12+
"github.com/cli/go-gh/pkg/term"
1113
graphql "github.com/cli/shurcooL-graphql"
1214
)
1315

@@ -162,3 +164,25 @@ func ExampleCurrentRepository() {
162164
}
163165
fmt.Printf("%s/%s/%s\n", repo.Host(), repo.Owner(), repo.Name())
164166
}
167+
168+
// Print tabular data to a terminal or in machine-readable format for scripts.
169+
func ExampleTablePrinter() {
170+
terminal := term.FromEnv()
171+
termWidth, _, _ := terminal.Size()
172+
t := tableprinter.New(terminal.Out(), terminal.IsTerminalOutput(), termWidth)
173+
174+
red := func(s string) string {
175+
return "\x1b[31m" + s + "\x1b[m"
176+
}
177+
178+
// add a field that will render with color and will not be auto-truncated
179+
t.AddField("1", tableprinter.WithColor(red), tableprinter.WithTruncate(nil))
180+
t.AddField("hello")
181+
t.EndRow()
182+
t.AddField("2")
183+
t.AddField("world")
184+
t.EndRow()
185+
if err := t.Render(); err != nil {
186+
log.Fatal(err)
187+
}
188+
}

go.mod

+3
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ require (
1010
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
1111
github.com/henvic/httpretty v0.0.6
1212
github.com/kr/pretty v0.1.0 // indirect
13+
github.com/mattn/go-runewidth v0.0.13
1314
github.com/stretchr/testify v1.7.0
1415
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e
16+
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e
17+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1
1518
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
1619
gopkg.in/h2non/gock.v1 v1.1.2
1720
gopkg.in/yaml.v3 v3.0.1

go.sum

+5
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
1919
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
2020
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
2121
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
22+
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
23+
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
2224
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
2325
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
2426
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2527
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
28+
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
29+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
2630
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
2731
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
2832
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -35,6 +39,7 @@ golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7w
3539
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
3640
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e h1:XMgFehsDnnLGtjvjOfqWSUzt0alpTR1RSEuznObga2c=
3741
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
42+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
3843
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
3944
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
4045
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

pkg/tableprinter/table.go

+242
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Package tableprinter facilitates rendering column-formatted data to a terminal and TSV-formatted data to
2+
// a script or a file. It is suitable for presenting tabular data in a human-readable format that is
3+
// guaranteed to fit within the given viewport, while at the same time offering the same data in a
4+
// machine-readable format for scripts.
5+
package tableprinter
6+
7+
import (
8+
"fmt"
9+
"io"
10+
"strings"
11+
12+
"github.com/mattn/go-runewidth"
13+
)
14+
15+
type fieldOption func(*tableField)
16+
17+
type TablePrinter interface {
18+
AddField(string, ...fieldOption)
19+
EndRow()
20+
Render() error
21+
}
22+
23+
// WithTruncate overrides the truncation function for the field. The function should transform a string
24+
// argument into a string that fits within the given display width. The default behavior is to truncate the
25+
// value by adding "..." in the end. Pass nil to disable truncation for this value.
26+
func WithTruncate(fn func(int, string) string) fieldOption {
27+
return func(f *tableField) {
28+
f.truncateFunc = fn
29+
}
30+
}
31+
32+
// WithColor sets the color function for the field. The function should transform a string value by wrapping
33+
// it in ANSI escape codes. The color function will not be used if the table was initialized in non-terminal mode.
34+
func WithColor(fn func(string) string) fieldOption {
35+
return func(f *tableField) {
36+
f.colorFunc = fn
37+
}
38+
}
39+
40+
// New initializes a table printer with terminal mode and terminal width. When terminal mode is enabled, the
41+
// output will be human-readable, column-formatted to fit available width, and rendered with color support.
42+
// In non-terminal mode, the output is tab-separated and all truncation of values is disabled.
43+
func New(w io.Writer, isTTY bool, maxWidth int) TablePrinter {
44+
if isTTY {
45+
return &ttyTablePrinter{
46+
out: w,
47+
maxWidth: maxWidth,
48+
}
49+
}
50+
return &tsvTablePrinter{
51+
out: w,
52+
}
53+
}
54+
55+
type tableField struct {
56+
text string
57+
truncateFunc func(int, string) string
58+
colorFunc func(string) string
59+
}
60+
61+
type ttyTablePrinter struct {
62+
out io.Writer
63+
maxWidth int
64+
rows [][]tableField
65+
}
66+
67+
func (t *ttyTablePrinter) AddField(s string, opts ...fieldOption) {
68+
if t.rows == nil {
69+
t.rows = make([][]tableField, 1)
70+
}
71+
rowI := len(t.rows) - 1
72+
field := tableField{
73+
text: s,
74+
truncateFunc: truncateText,
75+
}
76+
for _, opt := range opts {
77+
opt(&field)
78+
}
79+
t.rows[rowI] = append(t.rows[rowI], field)
80+
}
81+
82+
func (t *ttyTablePrinter) EndRow() {
83+
t.rows = append(t.rows, []tableField{})
84+
}
85+
86+
func (t *ttyTablePrinter) Render() error {
87+
if len(t.rows) == 0 {
88+
return nil
89+
}
90+
91+
delim := " "
92+
numCols := len(t.rows[0])
93+
colWidths := t.calculateColumnWidths(len(delim))
94+
95+
for _, row := range t.rows {
96+
for col, field := range row {
97+
if col > 0 {
98+
_, err := fmt.Fprint(t.out, delim)
99+
if err != nil {
100+
return err
101+
}
102+
}
103+
truncVal := field.text
104+
if field.truncateFunc != nil {
105+
truncVal = field.truncateFunc(colWidths[col], field.text)
106+
}
107+
if col < numCols-1 {
108+
// pad value with spaces on the right
109+
if padWidth := colWidths[col] - displayWidth(field.text); padWidth > 0 {
110+
truncVal += strings.Repeat(" ", padWidth)
111+
}
112+
}
113+
if field.colorFunc != nil {
114+
truncVal = field.colorFunc(truncVal)
115+
}
116+
_, err := fmt.Fprint(t.out, truncVal)
117+
if err != nil {
118+
return err
119+
}
120+
}
121+
if len(row) > 0 {
122+
_, err := fmt.Fprint(t.out, "\n")
123+
if err != nil {
124+
return err
125+
}
126+
}
127+
}
128+
return nil
129+
}
130+
131+
func (t *ttyTablePrinter) calculateColumnWidths(delimSize int) []int {
132+
numCols := len(t.rows[0])
133+
maxColWidths := make([]int, numCols)
134+
colWidths := make([]int, numCols)
135+
136+
for _, row := range t.rows {
137+
for col, field := range row {
138+
w := displayWidth(field.text)
139+
if w > maxColWidths[col] {
140+
maxColWidths[col] = w
141+
}
142+
// if this field has disabled truncating, ensure that the column is wide enough
143+
if field.truncateFunc == nil && w > colWidths[col] {
144+
colWidths[col] = w
145+
}
146+
}
147+
}
148+
149+
availWidth := func() int {
150+
setWidths := 0
151+
for col := 0; col < numCols; col++ {
152+
setWidths += colWidths[col]
153+
}
154+
return t.maxWidth - delimSize*(numCols-1) - setWidths
155+
}
156+
numFixedCols := func() int {
157+
fixedCols := 0
158+
for col := 0; col < numCols; col++ {
159+
if colWidths[col] > 0 {
160+
fixedCols++
161+
}
162+
}
163+
return fixedCols
164+
}
165+
166+
// set the widths of short columns
167+
if w := availWidth(); w > 0 {
168+
if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 {
169+
perColumn := w / numFlexColumns
170+
for col := 0; col < numCols; col++ {
171+
if max := maxColWidths[col]; max < perColumn {
172+
colWidths[col] = max
173+
}
174+
}
175+
}
176+
}
177+
178+
// truncate long columns to the remaining available width
179+
if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 {
180+
perColumn := availWidth() / numFlexColumns
181+
for col := 0; col < numCols; col++ {
182+
if colWidths[col] == 0 {
183+
if max := maxColWidths[col]; max < perColumn {
184+
colWidths[col] = max
185+
} else if perColumn > 0 {
186+
colWidths[col] = perColumn
187+
}
188+
}
189+
}
190+
}
191+
192+
// add the remainder to truncated columns
193+
if w := availWidth(); w > 0 {
194+
for col := 0; col < numCols; col++ {
195+
d := maxColWidths[col] - colWidths[col]
196+
toAdd := w
197+
if d < toAdd {
198+
toAdd = d
199+
}
200+
colWidths[col] += toAdd
201+
w -= toAdd
202+
if w <= 0 {
203+
break
204+
}
205+
}
206+
}
207+
208+
return colWidths
209+
}
210+
211+
type tsvTablePrinter struct {
212+
out io.Writer
213+
currentCol int
214+
}
215+
216+
func (t *tsvTablePrinter) AddField(text string, opts ...fieldOption) {
217+
if t.currentCol > 0 {
218+
fmt.Fprint(t.out, "\t")
219+
}
220+
fmt.Fprint(t.out, text)
221+
t.currentCol++
222+
}
223+
224+
func (t *tsvTablePrinter) EndRow() {
225+
fmt.Fprint(t.out, "\n")
226+
t.currentCol = 0
227+
}
228+
229+
func (t *tsvTablePrinter) Render() error {
230+
return nil
231+
}
232+
233+
func truncateText(maxWidth int, s string) string {
234+
if maxWidth < 5 {
235+
return runewidth.Truncate(s, maxWidth, "")
236+
}
237+
return runewidth.Truncate(s, maxWidth, "...")
238+
}
239+
240+
func displayWidth(s string) int {
241+
return runewidth.StringWidth(s)
242+
}

0 commit comments

Comments
 (0)