diff --git a/internal/output/format.go b/internal/output/format.go index e8c2427..ffde995 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -25,11 +25,12 @@ type Formattable interface { // Printer handles output rendering. type Printer struct { - Writer io.Writer - ErrWriter io.Writer - Format Format - Template string - NoColor bool + Writer io.Writer + ErrWriter io.Writer + Format Format + Template string + NoColor bool + TableWidth int } // NewPrinter creates a new Printer with the given format settings. diff --git a/internal/output/format_test.go b/internal/output/format_test.go index 9780d8d..5fd1a13 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -68,6 +68,31 @@ func TestPrinterPrintTable(t *testing.T) { } } +func TestPrinterPrintTableWrapsLongCells(t *testing.T) { + var buf bytes.Buffer + p := &Printer{Writer: &buf, Format: FormatTable, TableWidth: 38} + + err := p.Print(&stubFormattable{ + headers: []string{"ID", "CONTENT", "TTL"}, + rows: [][]string{ + {"1", "alpha beta gamma delta epsilon zeta", "3600"}, + {"2", strings.Repeat("A", 20), "3600"}, + }, + }) + if !assert.NoError(t, err) { + return + } + + out := strings.TrimSuffix(buf.String(), "\n") + for _, line := range strings.Split(out, "\n") { + assert.LessOrEqual(t, len([]rune(line)), 38, "line exceeds configured width: %q", line) + } + + assert.Contains(t, out, "alpha beta") + assert.Contains(t, out, "gamma delta") + assert.Contains(t, out, "AAAAAAAAAAAA") +} + func TestPrinterPrintTableEmptyHeaders(t *testing.T) { var buf bytes.Buffer p := &Printer{Writer: &buf, Format: FormatTable} diff --git a/internal/output/table.go b/internal/output/table.go index 3ddcdc3..6119f21 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -2,8 +2,16 @@ package output import ( "fmt" + "io" + "os" + "strconv" "strings" - "text/tabwriter" + "unicode/utf8" +) + +const ( + tablePadding = 2 + maxCompactColumnWidth = 12 ) func (p *Printer) printTable(data Formattable) error { @@ -14,15 +22,251 @@ func (p *Printer) printTable(data Formattable) error { return nil } - w := tabwriter.NewWriter(p.Writer, 0, 0, 2, ' ', 0) + widths := naturalColumnWidths(headers, rows) + if tableWidth, ok := p.effectiveTableWidth(); ok { + widths = fitColumnWidths(widths, headers, tableWidth) + } + + return writeTable(p.Writer, headers, rows, widths) +} + +func (p *Printer) effectiveTableWidth() (int, bool) { + if p.TableWidth > 0 { + return p.TableWidth, true + } + + if width, ok := detectTerminalWidth(p.Writer); ok { + return width, true + } + + if raw, ok := os.LookupEnv("COLUMNS"); ok { + if width, err := strconv.Atoi(raw); err == nil && width > 0 { + return width, true + } + } + + return 0, false +} + +func naturalColumnWidths(headers []string, rows [][]string) []int { + widths := make([]int, len(headers)) + for i, header := range headers { + widths[i] = cellWidth(header) + } + + for _, row := range rows { + for i := 0; i < len(headers) && i < len(row); i++ { + if width := cellWidth(row[i]); width > widths[i] { + widths[i] = width + } + } + } + + return widths +} + +func fitColumnWidths(widths []int, headers []string, tableWidth int) []int { + if tableWidth <= 0 || totalTableWidth(widths) <= tableWidth { + return widths + } + + fitted := append([]int(nil), widths...) + minWidths := minimumColumnWidths(widths, headers) + + for totalTableWidth(fitted) > tableWidth { + column := widestShrinkableColumn(fitted, minWidths) + if column == -1 { + break + } + fitted[column]-- + } + + return fitted +} - // Print header - fmt.Fprintln(w, strings.Join(headers, "\t")) +func minimumColumnWidths(widths []int, headers []string) []int { + minWidths := make([]int, len(widths)) + for i, width := range widths { + minWidths[i] = maxInt(cellWidth(headers[i]), minInt(width, maxCompactColumnWidth)) + } + return minWidths +} + +func widestShrinkableColumn(widths []int, minWidths []int) int { + column := -1 + for i := range widths { + if widths[i] <= minWidths[i] { + continue + } + if column == -1 || widths[i] > widths[column] { + column = i + } + } + return column +} + +func totalTableWidth(widths []int) int { + if len(widths) == 0 { + return 0 + } + + total := tablePadding * (len(widths) - 1) + for _, width := range widths { + total += width + } + return total +} + +func writeTable(w io.Writer, headers []string, rows [][]string, widths []int) error { + if err := writeVisualRow(w, headers, widths); err != nil { + return err + } - // Print rows for _, row := range rows { - fmt.Fprintln(w, strings.Join(row, "\t")) + cells := append([]string(nil), row...) + if len(cells) < len(headers) { + cells = append(cells, make([]string, len(headers)-len(cells))...) + } + if err := writeVisualRow(w, cells[:len(headers)], widths); err != nil { + return err + } } - return w.Flush() + return nil +} + +func writeVisualRow(w io.Writer, row []string, widths []int) error { + wrapped := make([][]string, len(widths)) + lines := 1 + + for i, width := range widths { + cell := "" + if i < len(row) { + cell = row[i] + } + wrapped[i] = wrapCell(cell, width) + if len(wrapped[i]) > lines { + lines = len(wrapped[i]) + } + } + + for line := 0; line < lines; line++ { + var b strings.Builder + for column, width := range widths { + if column > 0 { + b.WriteString(strings.Repeat(" ", tablePadding)) + } + + part := "" + if line < len(wrapped[column]) { + part = wrapped[column][line] + } + b.WriteString(part) + if pad := width - utf8.RuneCountInString(part); pad > 0 { + b.WriteString(strings.Repeat(" ", pad)) + } + } + + if _, err := fmt.Fprintln(w, strings.TrimRight(b.String(), " ")); err != nil { + return err + } + } + + return nil +} + +func wrapCell(cell string, width int) []string { + if width <= 0 { + return []string{cell} + } + + lines := strings.Split(cell, "\n") + wrapped := make([]string, 0, len(lines)) + for _, line := range lines { + wrapped = append(wrapped, wrapLine(line, width)...) + } + + if len(wrapped) == 0 { + return []string{""} + } + + return wrapped +} + +func wrapLine(line string, width int) []string { + if line == "" { + return []string{""} + } + + words := strings.Fields(line) + if len(words) == 0 { + return []string{""} + } + + wrapped := make([]string, 0, 1) + current := "" + + for _, word := range words { + for utf8.RuneCountInString(word) > width { + if current != "" { + wrapped = append(wrapped, current) + current = "" + } + + part, rest := splitAtWidth(word, width) + wrapped = append(wrapped, part) + word = rest + } + + if current == "" { + current = word + continue + } + + if utf8.RuneCountInString(current)+1+utf8.RuneCountInString(word) <= width { + current += " " + word + continue + } + + wrapped = append(wrapped, current) + current = word + } + + if current != "" { + wrapped = append(wrapped, current) + } + + return wrapped +} + +func splitAtWidth(s string, width int) (string, string) { + runes := []rune(s) + if len(runes) <= width { + return s, "" + } + return string(runes[:width]), string(runes[width:]) +} + +func cellWidth(cell string) int { + width := 0 + for _, line := range strings.Split(cell, "\n") { + if lineWidth := utf8.RuneCountInString(line); lineWidth > width { + width = lineWidth + } + } + return width +} + +func minInt(a int, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a int, b int) int { + if a > b { + return a + } + return b } diff --git a/internal/output/table_width_other.go b/internal/output/table_width_other.go new file mode 100644 index 0000000..1af71e6 --- /dev/null +++ b/internal/output/table_width_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package output + +import "io" + +func detectTerminalWidth(io.Writer) (int, bool) { + return 0, false +} diff --git a/internal/output/table_width_unix.go b/internal/output/table_width_unix.go new file mode 100644 index 0000000..6ec36cb --- /dev/null +++ b/internal/output/table_width_unix.go @@ -0,0 +1,24 @@ +//go:build unix + +package output + +import ( + "io" + "os" + + "golang.org/x/sys/unix" +) + +func detectTerminalWidth(w io.Writer) (int, bool) { + file, ok := w.(*os.File) + if !ok { + return 0, false + } + + ws, err := unix.IoctlGetWinsize(int(file.Fd()), unix.TIOCGWINSZ) + if err != nil || ws == nil || ws.Col == 0 { + return 0, false + } + + return int(ws.Col), true +}