Skip to content

Commit 9a0a989

Browse files
wpjuniorclaude
andcommitted
Add UTF-8 box-drawing borders and border color customization
Introduce UseUTF8Borders option to render tables with Unicode box-drawing characters (┌┬┐│├┼┤└┴┘─) instead of ASCII (+|-), with position-aware separators for correct top/middle/bottom junctions. Add BorderColorFunc to allow styling border characters with custom color functions (e.g. fatih/color). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ccb1fc9 commit 9a0a989

2 files changed

Lines changed: 174 additions & 15 deletions

File tree

render.go

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,34 @@ var TableConfig = struct {
2424
MaxTTYWidth int
2525

2626
TabWriterTruncate bool
27+
28+
UseUTF8Borders bool
29+
BorderColorFunc func(string) string
2730
}{
2831
BreakOnAny: false,
2932
ForceWrap: false,
3033
UseTabWriter: false,
3134
MaxTTYWidth: 0,
3235

3336
TabWriterTruncate: false,
37+
38+
UseUTF8Borders: false,
39+
BorderColorFunc: nil,
40+
}
41+
42+
type separatorPosition int
43+
44+
const (
45+
sepTop separatorPosition = iota
46+
sepMiddle
47+
sepBottom
48+
)
49+
50+
func borderColor(s string) string {
51+
if TableConfig.BorderColorFunc != nil {
52+
return TableConfig.BorderColorFunc(s)
53+
}
54+
return s
3455
}
3556

3657
// ignoredPatterns matches ANSI escape sequences produced by fatih/color and similar libraries.
@@ -75,7 +96,11 @@ func (t *Table) SortByColumn(columns ...int) {
7596
}
7697

7798
func (t *Table) addRows(rows rowSlice, sizes []int, buf *strings.Builder) {
78-
for _, row := range rows {
99+
vbar := borderColor("|")
100+
if TableConfig.UseUTF8Borders {
101+
vbar = borderColor("│")
102+
}
103+
for rowIdx, row := range rows {
79104
extraRows := rowSlice{}
80105
for column, field := range row {
81106
parts := strings.Split(field, "\n")
@@ -90,16 +115,22 @@ func (t *Table) addRows(rows rowSlice, sizes []int, buf *strings.Builder) {
90115
}
91116
newRow[column] = parts[i+1]
92117
}
93-
buf.WriteString("| ")
118+
buf.WriteString(vbar)
119+
buf.WriteString(" ")
94120
buf.WriteString(field)
95121
buf.Write(bytes.Repeat([]byte(" "), sizes[column]+1-runeLen(field)))
96122
}
97-
buf.WriteString("|\n")
123+
buf.WriteString(vbar)
124+
buf.WriteString("\n")
98125
t.addRows(extraRows, sizes, buf)
99126
ptr1 := reflect.ValueOf(rows).Pointer()
100127
ptr2 := reflect.ValueOf(t.rows).Pointer()
101128
if ptr1 == ptr2 && t.LineSeparator {
102-
t.separator(buf, sizes)
129+
if rowIdx == len(rows)-1 {
130+
t.separator(buf, sizes, sepBottom)
131+
} else {
132+
t.separator(buf, sizes, sepMiddle)
133+
}
103134
}
104135
}
105136
}
@@ -354,19 +385,25 @@ func (t *Table) String() string {
354385
}
355386
sizes := t.resizeLargestColumn(ttyWidth)
356387
buf := &strings.Builder{}
357-
t.separator(buf, sizes)
388+
t.separator(buf, sizes, sepTop)
358389
if t.Headers != nil {
390+
vbar := borderColor("|")
391+
if TableConfig.UseUTF8Borders {
392+
vbar = borderColor("│")
393+
}
359394
for column, header := range t.Headers {
360-
buf.WriteString("| ")
395+
buf.WriteString(vbar)
396+
buf.WriteString(" ")
361397
buf.WriteString(header)
362398
buf.Write(bytes.Repeat([]byte(" "), sizes[column]+1-len(header)))
363399
}
364-
buf.WriteString("|\n")
365-
t.separator(buf, sizes)
400+
buf.WriteString(vbar)
401+
buf.WriteString("\n")
402+
t.separator(buf, sizes, sepMiddle)
366403
}
367404
t.addRows(t.rows, sizes, buf)
368405
if !t.LineSeparator {
369-
t.separator(buf, sizes)
406+
t.separator(buf, sizes, sepBottom)
370407
}
371408
return buf.String()
372409
}
@@ -430,12 +467,30 @@ func (t *Table) columnsSize() []int {
430467
return sizes
431468
}
432469

433-
func (t *Table) separator(buf *strings.Builder, sizes []int) {
434-
for _, sz := range sizes {
435-
buf.WriteString("+")
436-
buf.Write(bytes.Repeat([]byte("-"), sz+2))
470+
func (t *Table) separator(buf *strings.Builder, sizes []int, pos separatorPosition) {
471+
left, mid, right, horiz := "+", "+", "+", "-"
472+
if TableConfig.UseUTF8Borders {
473+
switch pos {
474+
case sepTop:
475+
left, mid, right, horiz = "┌", "┬", "┐", "─"
476+
case sepMiddle:
477+
left, mid, right, horiz = "├", "┼", "┤", "─"
478+
case sepBottom:
479+
left, mid, right, horiz = "└", "┴", "┘", "─"
480+
}
481+
}
482+
colorLeft := borderColor(left)
483+
colorMid := borderColor(mid)
484+
colorRight := borderColor(right)
485+
buf.WriteString(colorLeft)
486+
for i, sz := range sizes {
487+
if i > 0 {
488+
buf.WriteString(colorMid)
489+
}
490+
buf.WriteString(borderColor(strings.Repeat(horiz, sz+2)))
437491
}
438-
buf.WriteString("+\n")
492+
buf.WriteString(colorRight)
493+
buf.WriteString("\n")
439494
}
440495

441496
type rowSlice []Row

render_test.go

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func TestSeparator(t *testing.T) {
7575
table := NewTable()
7676
expected := "+-------+---+\n"
7777
buf := &strings.Builder{}
78-
table.separator(buf, []int{5, 1})
78+
table.separator(buf, []int{5, 1}, sepTop)
7979
assert.Equal(t, expected, buf.String())
8080
}
8181

@@ -1184,6 +1184,110 @@ func TestTableWriterExpandRowsLongContent(t *testing.T) {
11841184
assert.Len(t, lines, 3) // 2 rows + trailing newline
11851185
}
11861186

1187+
func TestUTF8BordersBasic(t *testing.T) {
1188+
TableConfig.UseUTF8Borders = true
1189+
defer func() { TableConfig.UseUTF8Borders = false }()
1190+
table := NewTable()
1191+
table.AddRow(Row{"One", "1"})
1192+
table.AddRow(Row{"Two", "2"})
1193+
table.AddRow(Row{"Three", "3"})
1194+
expected := "┌───────┬───┐\n│ One │ 1 │\n│ Two │ 2 │\n│ Three │ 3 │\n└───────┴───┘\n"
1195+
assert.Equal(t, expected, table.String())
1196+
}
1197+
1198+
func TestUTF8BordersWithHeaders(t *testing.T) {
1199+
TableConfig.UseUTF8Borders = true
1200+
defer func() { TableConfig.UseUTF8Borders = false }()
1201+
table := NewTable()
1202+
table.Headers = Row{"Word", "Number"}
1203+
table.AddRow(Row{"One", "1"})
1204+
table.AddRow(Row{"Two", "2"})
1205+
table.AddRow(Row{"Three", "3"})
1206+
expected := "┌───────┬────────┐\n│ Word │ Number │\n├───────┼────────┤\n│ One │ 1 │\n│ Two │ 2 │\n│ Three │ 3 │\n└───────┴────────┘\n"
1207+
assert.Equal(t, expected, table.String())
1208+
}
1209+
1210+
func TestUTF8BordersWithLineSeparator(t *testing.T) {
1211+
TableConfig.UseUTF8Borders = true
1212+
defer func() { TableConfig.UseUTF8Borders = false }()
1213+
table := NewTable()
1214+
table.LineSeparator = true
1215+
table.AddRow(Row{"One", "1"})
1216+
table.AddRow(Row{"Two", "2"})
1217+
table.AddRow(Row{"Three", "3"})
1218+
expected := "┌───────┬───┐\n│ One │ 1 │\n├───────┼───┤\n│ Two │ 2 │\n├───────┼───┤\n│ Three │ 3 │\n└───────┴───┘\n"
1219+
assert.Equal(t, expected, table.String())
1220+
}
1221+
1222+
func TestUTF8BordersWithHeadersAndLineSeparator(t *testing.T) {
1223+
TableConfig.UseUTF8Borders = true
1224+
defer func() { TableConfig.UseUTF8Borders = false }()
1225+
table := NewTable()
1226+
table.Headers = Row{"Word", "Number"}
1227+
table.LineSeparator = true
1228+
table.AddRow(Row{"One", "1"})
1229+
table.AddRow(Row{"Two", "2"})
1230+
expected := "┌──────┬────────┐\n│ Word │ Number │\n├──────┼────────┤\n│ One │ 1 │\n├──────┼────────┤\n│ Two │ 2 │\n└──────┴────────┘\n"
1231+
assert.Equal(t, expected, table.String())
1232+
}
1233+
1234+
func TestBorderColorFuncASCII(t *testing.T) {
1235+
TableConfig.BorderColorFunc = func(s string) string {
1236+
return "[" + s + "]"
1237+
}
1238+
defer func() { TableConfig.BorderColorFunc = nil }()
1239+
table := NewTable()
1240+
table.AddRow(Row{"One", "1"})
1241+
result := table.String()
1242+
assert.Contains(t, result, "[+]")
1243+
assert.Contains(t, result, "[|]")
1244+
assert.Contains(t, result, "[-----]")
1245+
}
1246+
1247+
func TestBorderColorFuncUTF8(t *testing.T) {
1248+
TableConfig.UseUTF8Borders = true
1249+
TableConfig.BorderColorFunc = func(s string) string {
1250+
return "[" + s + "]"
1251+
}
1252+
defer func() {
1253+
TableConfig.UseUTF8Borders = false
1254+
TableConfig.BorderColorFunc = nil
1255+
}()
1256+
table := NewTable()
1257+
table.AddRow(Row{"One", "1"})
1258+
result := table.String()
1259+
assert.Contains(t, result, "[┌]")
1260+
assert.Contains(t, result, "[┐]")
1261+
assert.Contains(t, result, "[└]")
1262+
assert.Contains(t, result, "[┘]")
1263+
assert.Contains(t, result, "[│]")
1264+
assert.Contains(t, result, "[─────]")
1265+
}
1266+
1267+
func TestUTF8BordersSeparatorPositions(t *testing.T) {
1268+
TableConfig.UseUTF8Borders = true
1269+
defer func() { TableConfig.UseUTF8Borders = false }()
1270+
table := NewTable()
1271+
buf := &strings.Builder{}
1272+
table.separator(buf, []int{3, 2}, sepTop)
1273+
assert.Equal(t, "┌─────┬────┐\n", buf.String())
1274+
buf.Reset()
1275+
table.separator(buf, []int{3, 2}, sepMiddle)
1276+
assert.Equal(t, "├─────┼────┤\n", buf.String())
1277+
buf.Reset()
1278+
table.separator(buf, []int{3, 2}, sepBottom)
1279+
assert.Equal(t, "└─────┴────┘\n", buf.String())
1280+
}
1281+
1282+
func TestUTF8BordersNoRows(t *testing.T) {
1283+
TableConfig.UseUTF8Borders = true
1284+
defer func() { TableConfig.UseUTF8Borders = false }()
1285+
table := NewTable()
1286+
table.Headers = Row{"Word", "Number"}
1287+
expected := "┌──────┬────────┐\n│ Word │ Number │\n├──────┼────────┤\n└──────┴────────┘\n"
1288+
assert.Equal(t, expected, table.String())
1289+
}
1290+
11871291
func BenchmarkString(b *testing.B) {
11881292
b.StopTimer()
11891293
table := NewTable()

0 commit comments

Comments
 (0)