Repository navigation
feat: add Go table output compatible with the legacy CLI, with browser-like column widths #196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
cd94725
feat: add Go table output compatible with the legacy CLI
pjcdawkins fc8a464
fix(table): replace tabs in table cells to keep columns aligned
pjcdawkins 2fe2d25
feat(table): wrap table cells to the terminal width and support ANSI …
pjcdawkins 6fe65f0
fix(table): don't treat extended color components as SGR resets
pjcdawkins a06944f
fix(table): match legacy column lookup and isolate hyperlinks in cells
pjcdawkins 1813bc8
feat(table): browser-like column widths in adaptive tables (PHP and Go)
pjcdawkins dc59804
test: update wrapped table output for the new column widths
pjcdawkins 6b72e10
fix(table): include indentation in minimum widths and ignore ANSI at …
pjcdawkins 07444ae
fix(table): cap a cell's minimum width at its own width
pjcdawkins aae3398
fix(table): split words only on ASCII whitespace, like the legacy CLI
bojanz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,271 @@ | ||
| package table | ||
|
|
||
| import ( | ||
| "os" | ||
| "regexp" | ||
| "slices" | ||
| "strconv" | ||
| "strings" | ||
|
|
||
| "github.com/charmbracelet/x/ansi" | ||
| "golang.org/x/term" | ||
| ) | ||
|
|
||
| // Words up to maxUnbrokenWidth are not broken when wrapping if the table can fit, and words up to | ||
| // minUnbrokenWidth are not broken even if it does not. | ||
| const ( | ||
| maxUnbrokenWidth = 20 | ||
| minUnbrokenWidth = 10 | ||
| ) | ||
|
|
||
| // terminalWidth returns the width of the terminal, from $COLUMNS or the first standard stream that is a terminal. | ||
| func terminalWidth() int { | ||
| if w, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))); err == nil && w > 0 { | ||
| return w | ||
| } | ||
| for _, f := range []*os.File{os.Stdout, os.Stderr, os.Stdin} { | ||
| if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 { | ||
| return w | ||
| } | ||
| } | ||
| return 80 | ||
| } | ||
|
|
||
| // maxColumnWidths finds the maximum width of each column's content, so that the table fits in maxWidth. | ||
| // | ||
| // This is similar to a web browser's automatic table layout, and matches the legacy CLI's | ||
| // AdaptiveTable::getMaxColumnWidths(). Each column has a maximum width (its widest cell) and a minimum width | ||
| // (its longest word, up to maxUnbrokenWidth, or less if needed to fit). If the maximum widths do not fit, each | ||
| // column gets its minimum width plus a share of the remaining space, in proportion to the difference between its | ||
| // maximum and minimum. | ||
| // The rows before bodyStart are header rows, which are not wrapped. | ||
| func maxColumnWidths(rows [][][]string, bodyStart int, noWrap []bool, maxWidth int) []int { | ||
| count := len(rows[0]) | ||
| maxWidths := make([]int, count) | ||
| fixedWidths := make([]int, count) | ||
| wordWidths := make([]int, count) | ||
| for i, row := range rows { | ||
| for j, lines := range row { | ||
| w := cellWidth(lines) | ||
| maxWidths[j] = max(maxWidths[j], w) | ||
| if (j < len(noWrap) && noWrap[j]) || i < bodyStart { | ||
| fixedWidths[j] = max(fixedWidths[j], w) | ||
| } else { | ||
| // A cell at its full width is not wrapped, so its minimum is no more than that. | ||
| wordWidths[j] = max(wordWidths[j], min(longestWordWidth(lines), w)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| available := maxWidth - (count + 1) - count*2 | ||
| maxTotal := sum(maxWidths) | ||
| if maxTotal <= available { | ||
| return maxWidths | ||
| } | ||
|
|
||
| // The minimum column width is the width of the longest word, capped at maxUnbrokenWidth. | ||
| // The cap is reduced, down to minUnbrokenWidth, until the minimum widths fit. | ||
| minWidths := make([]int, count) | ||
| minTotal := 0 | ||
| for limit := maxUnbrokenWidth; ; limit-- { | ||
| for j := range minWidths { | ||
| minWidths[j] = max(fixedWidths[j], min(wordWidths[j], limit)) | ||
| } | ||
| minTotal = sum(minWidths) | ||
| if minTotal <= available || limit <= minUnbrokenWidth { | ||
| break | ||
| } | ||
| } | ||
| if minTotal >= available { | ||
| return minWidths | ||
| } | ||
|
|
||
| // Share the extra space in proportion to each column's flexibility (its maximum minus its minimum width). | ||
| // The remainder is given to the columns with the largest fractional parts, in column order for ties. | ||
| extra, flexTotal := available-minTotal, maxTotal-minTotal | ||
| widths := make([]int, count) | ||
| remainders := make([]int, count) | ||
| for j := range widths { | ||
| share := (maxWidths[j] - minWidths[j]) * extra | ||
| widths[j] = minWidths[j] + share/flexTotal | ||
| remainders[j] = share % flexTotal | ||
| } | ||
| order := make([]int, count) | ||
| for j := range order { | ||
| order[j] = j | ||
| } | ||
| slices.SortStableFunc(order, func(a, b int) int { return remainders[b] - remainders[a] }) | ||
| for _, j := range order[:available-sum(widths)] { | ||
| widths[j]++ | ||
| } | ||
| return widths | ||
| } | ||
|
|
||
| func sum(s []int) int { | ||
| total := 0 | ||
| for _, v := range s { | ||
| total += v | ||
| } | ||
| return total | ||
| } | ||
|
|
||
| // longestWordWidth returns the display width of the longest whitespace-separated word in a cell, | ||
| // plus its indentation, which is repeated on each wrapped line. | ||
| // Like the legacy CLI, only ASCII whitespace separates words, so non-breaking spaces are kept within a word. | ||
| func longestWordWidth(lines []string) int { | ||
| w := 0 | ||
| for _, line := range lines { | ||
| for word := range strings.FieldsFuncSeq(ansi.Strip(line), isASCIISpace) { | ||
| w = max(w, ansi.StringWidth(word)) | ||
| } | ||
| } | ||
| plain := ansi.Strip(strings.Join(lines, "\n")) | ||
| return w + len(plain) - len(strings.TrimLeft(plain, " ")) | ||
| } | ||
|
|
||
| // isASCIISpace matches the characters of PCRE's \s without Unicode support. | ||
| func isASCIISpace(r rune) bool { | ||
| switch r { | ||
| case ' ', '\t', '\n', '\v', '\f', '\r': | ||
| return true | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // cellWidth returns the display width of the longest line in a cell. | ||
| func cellWidth(lines []string) int { | ||
| w := 0 | ||
| for _, line := range lines { | ||
| w = max(w, ansi.StringWidth(line)) | ||
| } | ||
| return w | ||
| } | ||
|
|
||
| // wrapCell word-wraps a cell's lines to fit the width, keeping any left indentation. | ||
| func wrapCell(lines []string, width int) []string { | ||
| contents := strings.Join(lines, "\n") | ||
| trimmed := strings.TrimLeft(contents, " ") | ||
| indent := contents[:len(contents)-len(trimmed)] | ||
| wrapped := strings.Split(wordwrap(trimmed, max(width-len(indent), 1)), "\n") | ||
| if indent != "" { | ||
| for i, line := range wrapped { | ||
| wrapped[i] = indent + line | ||
| } | ||
| } | ||
| return wrapped | ||
| } | ||
|
|
||
| // wordwrap wraps text to the width, like PHP's wordwrap($text, $width, "\n", true). | ||
| // It is a port of PHP's implementation, measuring display width instead of bytes and keeping ANSI sequences. | ||
| func wordwrap(text string, width int) string { | ||
| var ( | ||
| units []string | ||
| widths []int | ||
| state byte | ||
| ) | ||
| for text != "" { | ||
| seq, w, n, newState := ansi.DecodeSequence(text, state, nil) | ||
| units, widths, state, text = append(units, seq), append(widths, w), newState, text[n:] | ||
| } | ||
| pos := make([]int, len(units)+1) | ||
| for i, w := range widths { | ||
| pos[i+1] = pos[i] + w | ||
| } | ||
|
|
||
| var b strings.Builder | ||
| write := func(from, to int, lineBreak bool) { | ||
| b.WriteString(strings.Join(units[from:to], "")) | ||
| if lineBreak { | ||
| b.WriteString("\n") | ||
| } | ||
| } | ||
| lastStart, lastSpace := 0, 0 | ||
| for cur, unit := range units { | ||
| // Zero-width ANSI sequences never cause a break. | ||
| if widths[cur] == 0 && unit != "\n" { | ||
| continue | ||
| } | ||
| // The line is full, as in PHP, or the current unit would overflow it (if it is wide). | ||
| lineWidth := pos[cur] - pos[lastStart] | ||
| over := lineWidth >= width || lineWidth+widths[cur] > width | ||
| switch { | ||
| case unit == "\n" && cur+1 < len(units): | ||
| // Keep existing line breaks. | ||
| write(lastStart, cur+1, false) | ||
| lastStart, lastSpace = cur+1, cur+1 | ||
| case unit == " ": | ||
| // Break at a space at the line boundary. | ||
| if lineWidth >= width { | ||
| write(lastStart, cur, true) | ||
| lastStart = cur + 1 | ||
| } | ||
| lastSpace = cur | ||
| case over && lastStart >= lastSpace && cur > lastStart: | ||
| // Cut a word that is too long. | ||
| write(lastStart, cur, true) | ||
| lastStart, lastSpace = cur, cur | ||
| case over && lastStart < lastSpace: | ||
| // Break at the last space. | ||
| write(lastStart, lastSpace, true) | ||
| lastStart, lastSpace = lastSpace+1, lastSpace+1 | ||
| } | ||
| } | ||
| write(lastStart, len(units), false) | ||
| return b.String() | ||
| } | ||
|
|
||
| var ( | ||
| sgrRegex = regexp.MustCompile(`\x1b\[([0-9;:]*)m`) | ||
| hyperlinkRegex = regexp.MustCompile(`\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(?:\x07|\x1b\\)`) | ||
| ) | ||
|
|
||
| const hyperlinkClose = "\x1b]8;;\x1b\\" | ||
|
|
||
| // isolateStyles closes any ANSI style (SGR) or hyperlink (OSC 8) still open at the end of each line, | ||
| // and re-opens it on the next line. This stops them from leaking into the table's borders and other cells. | ||
| func isolateStyles(lines []string) []string { | ||
|
upsun-dispatch[bot] marked this conversation as resolved.
|
||
| style, link := "", "" | ||
| out := make([]string, len(lines)) | ||
| for i, line := range lines { | ||
| out[i] = style + link + line | ||
| for _, m := range sgrRegex.FindAllStringSubmatch(line, -1) { | ||
| if sgrResets(m[1]) { | ||
| style = "" | ||
| } | ||
| if m[1] != "" && m[1] != "0" { | ||
| style += m[0] | ||
| } | ||
| } | ||
| for _, m := range hyperlinkRegex.FindAllStringSubmatch(line, -1) { | ||
| link = "" | ||
| if m[1] != "" { | ||
| link = m[0] | ||
| } | ||
| } | ||
| if link != "" { | ||
| out[i] += hyperlinkClose | ||
| } | ||
| if style != "" { | ||
| out[i] += "\x1b[0m" | ||
| } | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // sgrResets returns whether SGR parameters include a reset, skipping the arguments of extended colors. | ||
| func sgrResets(params string) bool { | ||
| parts := strings.Split(params, ";") | ||
| for i := 0; i < len(parts); i++ { | ||
| switch parts[i] { | ||
| case "", "0": | ||
| return true | ||
| case "38", "48", "58": | ||
| if i+1 < len(parts) && parts[i+1] == "5" { | ||
| i += 2 | ||
| } else if i+1 < len(parts) && parts[i+1] == "2" { | ||
| i += 4 | ||
| } | ||
| } | ||
| } | ||
| return false | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.