Repository navigation
Expand file tree
/
Copy pathpretty_table_layout.go
More file actions
287 lines (261 loc) · 8.97 KB
/
Copy pathpretty_table_layout.go
File metadata and controls
287 lines (261 loc) · 8.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package output
import (
"bytes"
"fmt"
"regexp"
"strings"
"text/template"
"github.com/mattn/go-runewidth"
)
// ansiRegex matches ANSI escape codes used for terminal coloring.
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// osc8Regex matches OSC-8 hyperlink escape sequences.
// Format: \x1b]8;params;URI\x1b\ (ST terminator) or \x1b]8;params;URI\a (BEL terminator)
var osc8Regex = regexp.MustCompile(`\x1b\]8;[^;]*;[^\x1b\a]*(?:\x1b\\|\a)`)
// displayWidth returns the visible column width of s, ignoring ANSI escape
// codes, OSC-8 hyperlink sequences, and accounting for wide Unicode characters.
func displayWidth(s string) int {
return runewidth.StringWidth(stripTerminalEscapes(s))
}
// stripTerminalEscapes removes OSC-8 hyperlink and ANSI color escape sequences.
func stripTerminalEscapes(s string) string {
stripped := osc8Regex.ReplaceAllString(s, "")
return ansiRegex.ReplaceAllString(stripped, "")
}
// hasTerminalEscapes reports whether s contains ANSI or OSC-8 escape sequences.
func hasTerminalEscapes(s string) bool {
return ansiRegex.MatchString(s) || osc8Regex.MatchString(s)
}
func sumWidths(widths []int) int {
total := 0
for _, w := range widths {
total += w
}
return total
}
// prettyExecTemplate renders a template against a data row and returns the string result.
func prettyExecTemplate(tmpl *template.Template, data any) (string, error) {
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// rowWidth returns the total rendered width of a row given per-column widths,
// including the padding between columns.
func rowWidth(widths []int) int {
if len(widths) == 0 {
return 0
}
return sumWidths(widths) + (len(widths)-1)*columnPadding
}
// columnFloor returns the minimum width a column may be shrunk to. Non-shrinkable
// columns return their natural width. The floor never drops below the heading
// width, so headings are never truncated.
func columnFloor(c resolvedColumn, natural int) int {
headingWidth := displayWidth(c.col.Heading)
switch {
case c.col.Truncatable:
// A truncatable column can shrink to a few characters plus the ellipsis.
// If it is also wrappable, it wraps across maxWrapLines lines and then
// truncates within them, so it can still reach this small floor — which
// lets it yield space to columns that cannot truncate.
target := minVisibleChars + displayWidth(ellipsis)
return clampWidth(target, headingWidth, natural)
case c.col.Wrappable:
// A wrap-only column spreads its content across maxWrapLines lines, so it
// needs roughly natural/maxWrapLines width to show everything.
target := (natural + maxWrapLines - 1) / maxWrapLines
return clampWidth(target, headingWidth, natural)
default:
return natural
}
}
// clampWidth returns target bounded to [floor, ceil], assuming floor ≤ ceil.
func clampWidth(target, floor, ceil int) int {
return min(max(target, floor), ceil)
}
// fitColumns assigns a rendered width to each column so the row fits termWidth.
// Overflow is reclaimed from shrinkable columns (Truncatable or Wrappable),
// shrinking the least-important columns first (highest Priority, then right-most).
// Non-shrinkable columns keep their natural width. The returned widths are never
// smaller than each column's floor; if the row still overflows, it is left to
// overflow rather than truncating headings.
func fitColumns(cols []resolvedColumn, natural []int, termWidth int) []int {
widths := make([]int, len(natural))
copy(widths, natural)
if rowWidth(widths) <= termWidth || len(cols) == 0 {
return widths
}
order := shrinkOrder(cols)
for _, ci := range order {
if rowWidth(widths) <= termWidth {
break
}
floor := columnFloor(cols[ci], natural[ci])
if widths[ci] <= floor {
continue
}
over := rowWidth(widths) - termWidth
widths[ci] = max(widths[ci]-over, floor)
}
return widths
}
// shrinkOrder returns the indices of shrinkable columns in the order they should
// be shrunk: least important first (highest Priority), ties broken right-to-left.
func shrinkOrder(cols []resolvedColumn) []int {
order := make([]int, 0, len(cols))
for i, c := range cols {
if c.col.Truncatable || c.col.Wrappable {
order = append(order, i)
}
}
// Stable sort: higher priority value first; equal priority right-most first.
for i := 1; i < len(order); i++ {
for j := i; j > 0; j-- {
a, b := order[j-1], order[j]
swap := cols[a].priority() < cols[b].priority() ||
(cols[a].priority() == cols[b].priority() && a < b)
if !swap {
break
}
order[j-1], order[j] = order[j], order[j-1]
}
}
return order
}
// layoutCell returns the display lines for a value rendered within width and
// reports whether content was lost (truncated). Wrappable values wrap onto at
// most maxWrapLines lines before truncating; others render on a single line,
// truncating with an ellipsis when needed.
//
// Values that contain terminal escape sequences (ANSI color or OSC-8
// hyperlinks) are returned unchanged: truncating mid-sequence would corrupt the
// output, and escape-bearing columns are only configured as non-shrinkable
// (e.g. LOCATION links), so they are never assigned a width below their content
// and this branch is not reached in practice. Mark such a column Truncatable
// only after teaching this function to truncate while preserving escapes.
func layoutCell(value string, width int, wrappable bool) ([]string, bool) {
if value == "" {
return []string{""}, false
}
if displayWidth(value) <= width || hasTerminalEscapes(value) || width <= 0 {
return []string{value}, false
}
if wrappable {
return wrapValue(value, width, maxWrapLines)
}
return []string{truncateWithEllipsis(value, width)}, true
}
// truncateWithEllipsis shortens s to fit within maxWidth display columns,
// appending an ellipsis. At least minVisibleChars characters are kept.
func truncateWithEllipsis(s string, maxWidth int) string {
if displayWidth(s) <= maxWidth {
return s
}
keep := max(maxWidth-displayWidth(ellipsis), minVisibleChars)
return takeWidth(s, keep) + ellipsis
}
// wrapValue word-wraps s onto at most maxLines lines of the given width. If
// content remains after maxLines lines, the last line is truncated with an
// ellipsis. It reports whether any content was lost.
func wrapValue(s string, width, maxLines int) ([]string, bool) {
if maxLines <= 0 {
return []string{""}, true
}
if width <= 0 {
return []string{s}, true
}
words := strings.Fields(s)
if len(words) == 0 {
return []string{""}, false
}
all := wrapWords(words, width)
if len(all) <= maxLines {
return all, false
}
// Keep the first maxLines lines and mark the last one as truncated so the
// dropped content is visibly indicated.
kept := all[:maxLines:maxLines]
last := kept[len(kept)-1]
kept[len(kept)-1] = truncateWithEllipsis(last+" "+ellipsis, width)
return kept, true
}
// wrapWords greedily wraps words into lines no wider than width, hard-splitting
// any single word that exceeds width. It applies no line limit; callers cap the
// result as needed.
func wrapWords(words []string, width int) []string {
var lines []string
var current string
for _, word := range words {
// Hard-split a word that is wider than a full line into width-sized chunks.
for displayWidth(word) > width {
if current != "" {
lines = append(lines, current)
current = ""
}
head := takeWidth(word, width)
if head == "" {
// width is too narrow for even the first rune (e.g. width 1 with a
// width-2 rune). Take one rune anyway so the loop makes progress;
// the cell overflows by at most one rune, which only happens at
// degenerate widths.
head = string([]rune(word)[0])
}
lines = append(lines, head)
word = word[len(head):]
}
if word == "" {
continue
}
switch {
case current == "":
current = word
case displayWidth(current)+1+displayWidth(word) <= width:
current += " " + word
default:
lines = append(lines, current)
current = word
}
}
if current != "" {
lines = append(lines, current)
}
return lines
}
// takeWidth returns the longest prefix of s whose display width is ≤ width.
func takeWidth(s string, width int) string {
if width <= 0 {
return ""
}
var b strings.Builder
used := 0
for _, r := range s {
rw := runewidth.RuneWidth(r)
if used+rw > width {
break
}
b.WriteRune(r)
used += rw
}
return b.String()
}
// buildColumnHint returns the responsive hint message shown after a table when
// columns are hidden or values are truncated, or "" when no hint is needed.
// When columns are hidden it is prefixed with "Showing N of M columns.".
func buildColumnHint(visible, total int, truncated bool) string {
hidden := total - visible
if hidden <= 0 && !truncated {
return ""
}
resize := fmt.Sprintf(
"Resize the terminal or run with %s for full details.",
WithHighLightFormat("-o json"),
)
if hidden > 0 {
return fmt.Sprintf("Showing %d of %d columns. %s", visible, total, resize)
}
return resize
}