cli/utils/table_printer.go
Mislav Marohnić ed15bebb84 Ensure that table printer fills the full width of the terminal
Sometimes, due to rounding errors, after calculating the width of each
column in a table, the sum of all columns would be shorter that the
total available width in the terminal. This reimplements the elastic
column resizing algorithm to ensure that all available space has been
filled.

As a bonus fix, columns that contain URLs are never truncated.
2021-03-11 19:04:57 +01:00

223 lines
4.8 KiB
Go

package utils
import (
"fmt"
"io"
"sort"
"strings"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/text"
)
type TablePrinter interface {
IsTTY() bool
AddField(string, func(int, string) string, func(string) string)
EndRow()
Render() error
}
func NewTablePrinter(io *iostreams.IOStreams) TablePrinter {
if io.IsStdoutTTY() {
return &ttyTablePrinter{
out: io.Out,
maxWidth: io.TerminalWidth(),
}
}
return &tsvTablePrinter{
out: io.Out,
}
}
type tableField struct {
Text string
TruncateFunc func(int, string) string
ColorFunc func(string) string
}
func (f *tableField) DisplayWidth() int {
return text.DisplayWidth(f.Text)
}
type ttyTablePrinter struct {
out io.Writer
maxWidth int
rows [][]tableField
}
func (t ttyTablePrinter) IsTTY() bool {
return true
}
func (t *ttyTablePrinter) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) {
if truncateFunc == nil {
truncateFunc = text.Truncate
}
if t.rows == nil {
t.rows = make([][]tableField, 1)
}
rowI := len(t.rows) - 1
field := tableField{
Text: s,
TruncateFunc: truncateFunc,
ColorFunc: colorFunc,
}
t.rows[rowI] = append(t.rows[rowI], field)
}
func (t *ttyTablePrinter) EndRow() {
t.rows = append(t.rows, []tableField{})
}
func (t *ttyTablePrinter) Render() error {
if len(t.rows) == 0 {
return nil
}
delim := " "
numCols := len(t.rows[0])
colWidths := t.calculateColumnWidths(len(delim))
for _, row := range t.rows {
for col, field := range row {
if col > 0 {
_, err := fmt.Fprint(t.out, delim)
if err != nil {
return err
}
}
truncVal := field.TruncateFunc(colWidths[col], field.Text)
if col < numCols-1 {
// pad value with spaces on the right
if padWidth := colWidths[col] - field.DisplayWidth(); padWidth > 0 {
truncVal += strings.Repeat(" ", padWidth)
}
}
if field.ColorFunc != nil {
truncVal = field.ColorFunc(truncVal)
}
_, err := fmt.Fprint(t.out, truncVal)
if err != nil {
return err
}
}
if len(row) > 0 {
_, err := fmt.Fprint(t.out, "\n")
if err != nil {
return err
}
}
}
return nil
}
func (t *ttyTablePrinter) calculateColumnWidths(delimSize int) []int {
numCols := len(t.rows[0])
allColWidths := make([][]int, numCols)
for _, row := range t.rows {
for col, field := range row {
allColWidths[col] = append(allColWidths[col], field.DisplayWidth())
}
}
// calculate max & median content width per column
maxColWidths := make([]int, numCols)
// medianColWidth := make([]int, numCols)
for col := 0; col < numCols; col++ {
widths := allColWidths[col]
sort.Ints(widths)
maxColWidths[col] = widths[len(widths)-1]
// medianColWidth[col] = widths[(len(widths)+1)/2]
}
colWidths := make([]int, numCols)
// never truncate the first column
colWidths[0] = maxColWidths[0]
// never truncate the last column if it contains URLs
if strings.HasPrefix(t.rows[0][numCols-1].Text, "https://") {
colWidths[numCols-1] = maxColWidths[numCols-1]
}
availWidth := func() int {
setWidths := 0
for col := 0; col < numCols; col++ {
setWidths += colWidths[col]
}
return t.maxWidth - delimSize*(numCols-1) - setWidths
}
numFixedCols := func() int {
fixedCols := 0
for col := 0; col < numCols; col++ {
if colWidths[col] > 0 {
fixedCols++
}
}
return fixedCols
}
// set the widths of short columns
if w := availWidth(); w > 0 {
if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 {
perColumn := w / numFlexColumns
for col := 0; col < numCols; col++ {
if max := maxColWidths[col]; max < perColumn {
colWidths[col] = max
}
}
}
}
firstFlexCol := -1
// truncate long columns to the remaining available width
if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 {
perColumn := availWidth() / numFlexColumns
for col := 0; col < numCols; col++ {
if colWidths[col] == 0 {
if firstFlexCol == -1 {
firstFlexCol = col
}
if max := maxColWidths[col]; max < perColumn {
colWidths[col] = max
} else {
colWidths[col] = perColumn
}
}
}
}
// add remainder to the first flex column
if w := availWidth(); w > 0 && firstFlexCol > -1 {
colWidths[firstFlexCol] += w
if max := maxColWidths[firstFlexCol]; max < colWidths[firstFlexCol] {
colWidths[firstFlexCol] = max
}
}
return colWidths
}
type tsvTablePrinter struct {
out io.Writer
currentCol int
}
func (t tsvTablePrinter) IsTTY() bool {
return false
}
func (t *tsvTablePrinter) AddField(text string, _ func(int, string) string, _ func(string) string) {
if t.currentCol > 0 {
fmt.Fprint(t.out, "\t")
}
fmt.Fprint(t.out, text)
t.currentCol++
}
func (t *tsvTablePrinter) EndRow() {
fmt.Fprint(t.out, "\n")
t.currentCol = 0
}
func (t *tsvTablePrinter) Render() error {
return nil
}