github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/table/table.go (about) 1 package table 2 3 import ( 4 "strings" 5 "unicode/utf8" 6 7 "github.com/ActiveState/cli/internal/colorize" 8 "github.com/ActiveState/cli/internal/logging" 9 "github.com/ActiveState/cli/internal/mathutils" 10 "github.com/ActiveState/cli/internal/sliceutils" 11 "github.com/ActiveState/cli/internal/termutils" 12 ) 13 14 const dash = "\u2500" 15 const linebreak = "\n" 16 const padding = 2 17 18 type FormatFunc func(string, ...interface{}) string 19 20 type row struct { 21 columns []string 22 } 23 24 type Table struct { 25 headers []string 26 rows []row 27 28 HideHeaders bool 29 HideDash bool 30 Vertical bool 31 } 32 33 func New(headers []string) *Table { 34 return &Table{headers: headers} 35 } 36 37 func (t *Table) AddRow(vs ...[]string) *Table { 38 for _, v := range vs { 39 t.rows = append(t.rows, row{v}) 40 } 41 return t 42 } 43 44 func (t *Table) Render() string { 45 if len(t.rows) == 0 { 46 return "" 47 } 48 49 termWidth := termutils.GetWidth() 50 colWidths, total := t.calculateWidth(termWidth) 51 52 var out string 53 if !t.HideHeaders { 54 out += "[NOTICE]" + renderRow(t.headers, colWidths) + "[/RESET]" + linebreak 55 if !t.HideDash { 56 out += "[DISABLED]" + strings.Repeat(dash, total) + "[/RESET]" + linebreak 57 } 58 } 59 for _, row := range t.rows { 60 out += renderRow(row.columns, colWidths) + linebreak 61 } 62 63 return strings.TrimRight(out, linebreak) 64 } 65 66 func (t *Table) calculateWidth(maxTableWidth int) ([]int, int) { 67 // Calculate total width of each column, not worrying about max width just yet 68 minTableWidth := padding * 2 69 colWidths := make([]int, len(t.headers)) 70 colWidthsCombined := 0 71 for n, header := range t.headers { 72 // Start with the header size 73 colWidths[n] = utf8.RuneCountInString(header) 74 75 // Check column sizes for each row 76 for _, row := range t.rows { 77 columnValue, ok := sliceutils.GetString(row.columns, n) 78 if !ok { 79 continue // column doesn't exit because the previous column spans 80 } 81 // Strip any colour tags so they are not included in the width calculation 82 columnValue = colorize.StripColorCodes(columnValue) 83 columnSize := utf8.RuneCountInString(columnValue) 84 85 // Detect spanned column info 86 rowHasSpannedColumn := len(row.columns) < len(t.headers) 87 spannedColumnIndex := len(row.columns) - 1 88 89 if rowHasSpannedColumn && n == spannedColumnIndex { 90 // Record total row size as minTableWidth 91 colWidthBefore := mathutils.Total(sliceutils.IntRangeUncapped(colWidths, 0, n)...) 92 minTableWidth = mathutils.MaxInt(minTableWidth, colWidthBefore+columnSize+(padding*2)) 93 } else { 94 // This is a regular non-spanned column 95 colWidths[n] = mathutils.MaxInt(colWidths[n], columnSize) 96 } 97 } 98 99 // Add padding and update the total width so far 100 colWidths[n] += padding * 2 101 colWidthsCombined += colWidths[n] 102 } 103 104 // Capture the width of the vertical header before we equalize the column widths. 105 // We must respect this width when rescaling the columns. 106 var verticalHeaderWidth int 107 if len(colWidths) > 0 && t.Vertical { 108 verticalHeaderWidth = colWidths[0] 109 } 110 111 if colWidthsCombined >= maxTableWidth { 112 // Equalize widths by 20% of average width. 113 // This is to prevent columns that are much larger than others 114 // from taking up most of the table width. 115 equalizeWidths(colWidths, 20) 116 } 117 118 // Constrain table to max and min dimensions 119 tableWidth := mathutils.MaxInt(colWidthsCombined, minTableWidth) 120 tableWidth = mathutils.MinInt(tableWidth, maxTableWidth) 121 122 // Now scale back the row sizes according to the max width 123 rescaleColumns(colWidths, tableWidth, t.Vertical, verticalHeaderWidth) 124 logging.Debug("Table column widths: %v, total: %d", colWidths, tableWidth) 125 126 return colWidths, tableWidth 127 } 128 129 // equalizeWidths equalizes the width of given columns by a given percentage of the average columns width 130 func equalizeWidths(colWidths []int, percentage int) { 131 total := float64(mathutils.Total(colWidths...)) 132 multiplier := float64(percentage) / 100 133 averageWidth := total / float64(len(colWidths)) 134 135 for n := range colWidths { 136 colWidth := float64(colWidths[n]) 137 colWidths[n] += int((averageWidth - colWidth) * multiplier) 138 } 139 140 // Account for floats that got rounded 141 if len(colWidths) > 0 { 142 colWidths[len(colWidths)-1] += int(total) - mathutils.Total(colWidths...) 143 } 144 } 145 146 func rescaleColumns(colWidths []int, targetTotal int, vertical bool, verticalHeaderWidth int) { 147 total := float64(mathutils.Total(colWidths...)) 148 multiplier := float64(targetTotal) / total 149 150 originalWidths := make([]int, len(colWidths)) 151 for n := range colWidths { 152 originalWidths[n] = colWidths[n] 153 colWidths[n] = int(float64(colWidths[n]) * multiplier) 154 } 155 156 // Account for floats that got rounded 157 if len(colWidths) > 0 { 158 colWidths[len(colWidths)-1] += targetTotal - mathutils.Total(colWidths...) 159 } 160 161 // If vertical, respect the header width 162 // verticalHeaderWidth is the width of the header column before we equalized the column widths. 163 // We compare the current width of the header column with the original width and adjust the other columns accordingly. 164 if vertical && len(colWidths) > 0 && colWidths[0] < verticalHeaderWidth { 165 diff := verticalHeaderWidth - colWidths[0] 166 colWidths[0] += diff 167 for i := 1; i < len(colWidths); i++ { 168 colWidths[i] -= diff / (len(colWidths) - 1) 169 } 170 } 171 } 172 173 func renderRow(providedColumns []string, colWidths []int) string { 174 // Do not modify the original column widths 175 widths := make([]int, len(providedColumns)) 176 copy(widths, colWidths) 177 178 // Combine column widths if we have a spanned column 179 if len(widths) < len(colWidths) { 180 widths[len(widths)-1] = mathutils.Total(colWidths[len(widths)-1:]...) 181 } 182 183 croppedColumns := []colorize.CroppedLines{} 184 for n, column := range providedColumns { 185 croppedColumns = append(croppedColumns, colorize.GetCroppedText(column, widths[n]-(padding*2), false)) 186 } 187 188 var rendered = true 189 var lines []string 190 // Iterate over rows until we reach a row where no column has data 191 for lineNo := 0; rendered; lineNo++ { 192 rendered = false 193 var line string 194 for columnNo, column := range croppedColumns { 195 if lineNo > len(column)-1 { 196 line += strings.Repeat(" ", widths[columnNo]) // empty column 197 continue 198 } 199 columnLine := column[lineNo] 200 201 // Add padding and fill up missing whitespace 202 prefix := strings.Repeat(" ", padding) 203 suffix := strings.Repeat(" ", padding+(widths[columnNo]-columnLine.Length-(padding*2))) 204 205 line += prefix + columnLine.Line + suffix 206 rendered = true 207 } 208 if rendered { 209 lines = append(lines, line) 210 } 211 } 212 213 return strings.TrimRight(strings.Join(lines, linebreak), linebreak) 214 }