diff options
Diffstat (limited to 'internal/termprint')
| -rw-r--r-- | internal/termprint/columns.go | 207 |
1 files changed, 207 insertions, 0 deletions
diff --git a/internal/termprint/columns.go b/internal/termprint/columns.go new file mode 100644 index 0000000..b4d30bc --- /dev/null +++ b/internal/termprint/columns.go @@ -0,0 +1,207 @@ +package termprint + +import ( + "io" + "os" + "strings" + "sync" + + "github.com/mattn/go-runewidth" + "golang.org/x/term" +) + +// ColumnPrinter streams provider output in side-by-side columns. +type ColumnPrinter struct { + mu sync.Mutex + stdout io.Writer + columns int + colWidth int + partial []string + providers []string + models []string +} + +type columnWriter struct { + printer *ColumnPrinter + index int +} + +// NewColumnPrinter builds a multi-column printer for the provider/model pairs. +func NewColumnPrinter(stdout io.Writer, providers []string, models []string) *ColumnPrinter { + cols := len(providers) + if len(models) > cols { + cols = len(models) + } + if cols == 0 { + return nil + } + + width := detectTerminalWidth(stdout) + if width <= 0 { + width = 100 + } + sepWidth := (cols - 1) * 3 + colWidth := (width - sepWidth) / cols + if colWidth < 20 { + colWidth = 20 + } + + providerCols := make([]string, cols) + copy(providerCols, providers) + modelCols := make([]string, cols) + copy(modelCols, models) + + return &ColumnPrinter{ + stdout: stdout, + columns: cols, + colWidth: colWidth, + partial: make([]string, cols), + providers: providerCols, + models: modelCols, + } +} + +func detectTerminalWidth(w io.Writer) int { + type fder interface{ Fd() uintptr } + if f, ok := w.(*os.File); ok { + if width, _, err := term.GetSize(int(f.Fd())); err == nil { + return width + } + } + if f, ok := w.(fder); ok { + if width, _, err := term.GetSize(int(f.Fd())); err == nil { + return width + } + } + return 0 +} + +// Writer returns an io.Writer that routes chunks to a single column index. +func (cp *ColumnPrinter) Writer(idx int) io.Writer { + return columnWriter{printer: cp, index: idx} +} + +// PrintHeader writes provider/model headers and a divider row. +func (cp *ColumnPrinter) PrintHeader() { + cp.mu.Lock() + defer cp.mu.Unlock() + combo := make([]string, cp.columns) + for i := 0; i < cp.columns; i++ { + provider := strings.TrimSpace(cp.providers[i]) + model := strings.TrimSpace(cp.models[i]) + switch { + case provider != "" && model != "": + combo[i] = provider + ":" + model + case provider != "": + combo[i] = provider + case model != "": + combo[i] = model + default: + combo[i] = "" + } + } + cp.writeLine(combo) + divider := make([]string, cp.columns) + line := strings.Repeat("─", cp.colWidth) + for i := range divider { + divider[i] = line + } + cp.writeLine(divider) +} + +// Flush emits any buffered partial line for a column. +func (cp *ColumnPrinter) Flush(idx int) { + cp.mu.Lock() + defer cp.mu.Unlock() + if idx < 0 || idx >= len(cp.partial) { + return + } + if cp.partial[idx] == "" { + return + } + cp.emitJobLine(idx, cp.partial[idx]) + cp.partial[idx] = "" +} + +func (w columnWriter) Write(p []byte) (int, error) { + return w.printer.write(w.index, string(p)) +} + +func (cp *ColumnPrinter) write(idx int, data string) (int, error) { + cp.mu.Lock() + defer cp.mu.Unlock() + if idx < 0 || idx >= len(cp.partial) { + return len(data), nil + } + data = strings.ReplaceAll(data, "\r", "") + cp.partial[idx] += data + for strings.Contains(cp.partial[idx], "\n") { + line, rest, _ := strings.Cut(cp.partial[idx], "\n") + cp.partial[idx] = rest + cp.emitJobLine(idx, line) + } + return len(data), nil +} + +func (cp *ColumnPrinter) emitJobLine(idx int, line string) { + segments := cp.wrap(line) + for _, seg := range segments { + cells := make([]string, cp.columns) + if idx >= 0 && idx < len(cells) { + cells[idx] = seg + } + cp.writeLine(cells) + } +} + +func (cp *ColumnPrinter) wrap(text string) []string { + text = strings.ReplaceAll(text, "\t", " ") + if runewidth.StringWidth(text) <= cp.colWidth { + return []string{text} + } + var lines []string + var current strings.Builder + width := 0 + for _, r := range text { + rw := runewidth.RuneWidth(r) + if width+rw > cp.colWidth && current.Len() > 0 { + lines = append(lines, current.String()) + current.Reset() + width = 0 + } + current.WriteRune(r) + width += rw + } + if current.Len() > 0 { + lines = append(lines, current.String()) + } + if len(lines) == 0 { + lines = append(lines, "") + } + return lines +} + +func (cp *ColumnPrinter) writeLine(cells []string) { + if len(cells) < cp.columns { + extra := make([]string, cp.columns-len(cells)) + cells = append(cells, extra...) + } + var builder strings.Builder + for i := 0; i < cp.columns; i++ { + cell := cells[i] + width := runewidth.StringWidth(cell) + if width > cp.colWidth { + cell = runewidth.Truncate(cell, cp.colWidth, "…") + width = runewidth.StringWidth(cell) + } + builder.WriteString(cell) + if pad := cp.colWidth - width; pad > 0 { + builder.WriteString(strings.Repeat(" ", pad)) + } + if i != cp.columns-1 { + builder.WriteString(" │ ") + } + } + builder.WriteByte('\n') + _, _ = cp.stdout.Write([]byte(builder.String())) +} |
