Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"github.com/charmbracelet/x/ansi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -1845,7 +1846,7 @@ func TestWrapText(t *testing.T) {
name: "unicode characters",
text: "hello 世界 emoji 🎉 test",
maxWidth: 15,
expected: "hello 世界 emoji\n🎉 test",
expected: "hello 世界\nemoji 🎉 test",
},
{
name: "zero width defaults to 80",
Expand Down Expand Up @@ -2176,6 +2177,15 @@ func TestFormatCellDoesNotTruncateURLs(t *testing.T) {
assert.Equal(t, httpURL, result)
})

t.Run("emoji string truncated by display width", func(t *testing.T) {
// 38 runes but 42 display cells (4 emoji x 2 cells each = +4 extra)
// Old rune-count code wouldn't truncate (38 < 40); new code does (42 > 40)
input := "Hello world with some emoji here: 🎉🎊🎈🎆"
result := formatCell(input)
assert.LessOrEqual(t, ansi.StringWidth(result), 40)
assert.True(t, strings.HasSuffix(result, "..."))
})

t.Run("URL-like string with spaces is truncated", func(t *testing.T) {
// After newline collapsing, a value like "https://example.com\n(extra...)"
// becomes "https://example.com (extra...)" — not a real URL.
Expand Down Expand Up @@ -2227,6 +2237,46 @@ func TestStyledRenderTablePreservesURLs(t *testing.T) {
assert.Contains(t, buf.String(), url)
}

func TestStyledRenderTableEmojiAlignment(t *testing.T) {
data := []any{
map[string]any{
"title": "Plain ASCII title here",
"id": 1,
},
map[string]any{
"title": "Title with emoji 🎉🎊🎈🎆 and more text padding it out",
"id": 2,
},
map[string]any{
"title": "CJK混合English标题needs correct width",
"id": 3,
},
}

var buf bytes.Buffer
w := New(Options{Format: FormatStyled, Writer: &buf})
err := w.OK(data)
require.NoError(t, err)

output := buf.String()
lines := strings.Split(output, "\n")

// All data lines in the table should have the same display width,
// proving that selectColumns (lipgloss.Width) and formatCell
// (ansi.StringWidth + ansi.Truncate) agree on cell widths.
var widths []int
for _, line := range lines {
w := ansi.StringWidth(line)
if w > 0 {
widths = append(widths, w)
}
}
require.NotEmpty(t, widths)
for _, w := range widths[1:] {
assert.Equal(t, widths[0], w, "all table lines should have equal display width")
}
}

func TestMarkdownRenderTableSkipsURLColumns(t *testing.T) {
data := []any{
map[string]any{
Expand Down
18 changes: 8 additions & 10 deletions internal/output/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"sort"
"strings"
"time"
"unicode/utf8"

"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
Expand Down Expand Up @@ -216,7 +215,7 @@ func (r *Renderer) RenderError(w io.Writer, resp *ErrorResponse) error {
}

// wrapText wraps text to fit within maxWidth, preserving words and newlines.
// Uses rune counting for proper Unicode support.
// Uses display-cell width for proper Unicode support.
func wrapText(text string, maxWidth int) string {
if maxWidth <= 0 {
maxWidth = 80
Expand All @@ -242,7 +241,7 @@ func wrapText(text string, maxWidth int) string {
currentWidth := 0

for _, word := range words {
wordWidth := runeWidth(word)
wordWidth := cellWidth(word)

// Handle words longer than maxWidth by adding them on their own line
if wordWidth > maxWidth {
Expand Down Expand Up @@ -280,9 +279,9 @@ func wrapText(text string, maxWidth int) string {
return strings.Join(result, "\n")
}

// runeWidth returns the display width of a string, counting runes.
func runeWidth(s string) int {
return utf8.RuneCountInString(s)
// cellWidth returns the display width of a string in terminal cells.
func cellWidth(s string) int {
return ansi.StringWidth(s)
}

func (r *Renderer) renderData(b *strings.Builder, data any) {
Expand Down Expand Up @@ -719,11 +718,10 @@ func formatCell(val any) string {
if strings.ContainsAny(v, "\n\r") {
v = strings.Join(strings.Fields(v), " ")
}
// Truncate long strings (rune-safe for multi-byte UTF-8).
// Truncate long strings by display width.
// HTTP(S) URLs are never truncated — a truncated URL is useless.
if utf8.RuneCountInString(v) > 40 && !isURL(v) {
runes := []rune(v)
return string(runes[:37]) + "..."
if ansi.StringWidth(v) > 40 && !isURL(v) {
return ansi.Truncate(v, 40, "...")
}
return v
case bool:
Expand Down
Loading