Skip to content

Commit 2cdd521

Browse files
committed
fix: align table columns correctly for CJK characters
Replace text/tabwriter with manual padding using go-runewidth. CJK characters occupy 2 terminal columns but tabwriter treated them as 1, causing misaligned columns. Truncate now also uses display width instead of byte length.
1 parent f771d5d commit 2cdd521

3 files changed

Lines changed: 53 additions & 15 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ go 1.25.1
44

55
require (
66
github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260514094839-5405a3ab38b1
7+
github.com/mattn/go-runewidth v0.0.23
78
github.com/spf13/cobra v1.10.2
89
golang.org/x/term v0.42.0
910
gopkg.in/yaml.v3 v3.0.1
1011
)
1112

1213
require (
14+
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
1315
github.com/inconshreveable/mousetrap v1.1.0 // indirect
1416
github.com/spf13/pflag v1.0.9 // indirect
1517
github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
2+
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
13
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
24
github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260514094839-5405a3ab38b1 h1:Q9FJkGSAQCXCjjnjS18QYMNpHT8O27oRj9kd13tUeiI=
35
github.com/flashcatcloud/flashduty-sdk v0.8.1-0.20260514094839-5405a3ab38b1/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY=
46
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
57
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
8+
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
9+
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
610
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
711
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
812
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=

internal/output/table.go

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import (
55
"io"
66
"reflect"
77
"strings"
8-
"text/tabwriter"
98
"time"
9+
10+
"github.com/mattn/go-runewidth"
1011
)
1112

13+
const columnGap = 2
14+
1215
// TablePrinter prints data as aligned tables.
1316
type TablePrinter struct {
1417
w io.Writer
@@ -18,19 +21,14 @@ type TablePrinter struct {
1821
func (p *TablePrinter) Print(data any, columns []Column) error {
1922
items := toSlice(data)
2023

21-
tw := tabwriter.NewWriter(p.w, 0, 4, 2, ' ', 0)
22-
23-
// Header
24+
// Build all cell values and compute column widths using display width.
2425
headers := make([]string, len(columns))
2526
for i, col := range columns {
2627
headers[i] = col.Header
2728
}
28-
if _, err := fmt.Fprintln(tw, strings.Join(headers, "\t")); err != nil {
29-
return err
30-
}
3129

32-
// Rows
33-
for _, item := range items {
30+
rows := make([][]string, len(items))
31+
for r, item := range items {
3432
vals := make([]string, len(columns))
3533
for i, col := range columns {
3634
v := col.Field(item)
@@ -39,23 +37,57 @@ func (p *TablePrinter) Print(data any, columns []Column) error {
3937
}
4038
vals[i] = v
4139
}
42-
if _, err := fmt.Fprintln(tw, strings.Join(vals, "\t")); err != nil {
40+
rows[r] = vals
41+
}
42+
43+
// Compute max display width per column.
44+
colWidths := make([]int, len(columns))
45+
for i, h := range headers {
46+
colWidths[i] = runewidth.StringWidth(h)
47+
}
48+
for _, row := range rows {
49+
for i, v := range row {
50+
if w := runewidth.StringWidth(v); w > colWidths[i] {
51+
colWidths[i] = w
52+
}
53+
}
54+
}
55+
56+
// Print header.
57+
if err := p.printRow(headers, colWidths); err != nil {
58+
return err
59+
}
60+
// Print data rows.
61+
for _, row := range rows {
62+
if err := p.printRow(row, colWidths); err != nil {
4363
return err
4464
}
4565
}
66+
return nil
67+
}
4668

47-
return tw.Flush()
69+
func (p *TablePrinter) printRow(cells []string, colWidths []int) error {
70+
var sb strings.Builder
71+
for i, cell := range cells {
72+
sb.WriteString(cell)
73+
if i < len(cells)-1 {
74+
pad := colWidths[i] - runewidth.StringWidth(cell) + columnGap
75+
sb.WriteString(strings.Repeat(" ", pad))
76+
}
77+
}
78+
_, err := fmt.Fprintln(p.w, sb.String())
79+
return err
4880
}
4981

50-
// Truncate shortens s to maxLen, appending "..." if truncated.
82+
// Truncate shortens s to maxLen display columns, appending "..." if truncated.
5183
func Truncate(s string, maxLen int) string {
52-
if maxLen <= 0 || len(s) <= maxLen {
84+
if maxLen <= 0 || runewidth.StringWidth(s) <= maxLen {
5385
return s
5486
}
5587
if maxLen <= 3 {
56-
return s[:maxLen]
88+
return runewidth.Truncate(s, maxLen, "")
5789
}
58-
return s[:maxLen-3] + "..."
90+
return runewidth.Truncate(s, maxLen, "...")
5991
}
6092

6193
// FormatTime formats a unix timestamp as local time.

0 commit comments

Comments
 (0)