diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-25 18:00:59 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-25 18:00:59 +0300 |
| commit | b642f71049ba6030e45c9ddead6d1cc2b7e289e2 (patch) | |
| tree | b577f7f3eadcfb34028293c79b4688ad0cec73ae | |
| parent | cb36d8f78ce688db6f74cac57e7c10cd334e5f6e (diff) | |
Fix UTF-8 word wrapping for sq0
| -rw-r--r-- | internal/ui/taskdetail.go | 7 | ||||
| -rw-r--r-- | internal/ui/taskdetail_test.go | 86 |
2 files changed, 92 insertions, 1 deletions
diff --git a/internal/ui/taskdetail.go b/internal/ui/taskdetail.go index bcaed8f..d281341 100644 --- a/internal/ui/taskdetail.go +++ b/internal/ui/taskdetail.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "strings" + "unicode/utf8" "charm.land/lipgloss/v2" @@ -26,7 +27,7 @@ func wordWrap(text string, width int) []string { for i := 1; i < len(words); i++ { word := words[i] testLine := currentLine + " " + word - if len(testLine) > width { + if runeLen(testLine) > width { lines = append(lines, currentLine) currentLine = word } else { @@ -40,6 +41,10 @@ func wordWrap(text string, width int) []string { return lines } +func runeLen(s string) int { + return utf8.RuneCountInString(s) +} + // Define field indices for navigation const ( fieldID = iota diff --git a/internal/ui/taskdetail_test.go b/internal/ui/taskdetail_test.go new file mode 100644 index 0000000..04905db --- /dev/null +++ b/internal/ui/taskdetail_test.go @@ -0,0 +1,86 @@ +package ui + +import ( + "reflect" + "testing" +) + +func TestWordWrapPreservesASCIIWrapping(t *testing.T) { + tests := []struct { + name string + text string + width int + want []string + }{ + { + name: "wraps at word boundary", + text: "alpha beta gamma delta", + width: 12, + want: []string{"alpha beta", "gamma delta"}, + }, + { + name: "normalizes ascii whitespace", + text: "alpha beta\ngamma", + width: 10, + want: []string{"alpha beta", "gamma"}, + }, + { + name: "keeps over-width word intact", + text: "alphabetagamma delta", + width: 8, + want: []string{"alphabetagamma", "delta"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wordWrap(tt.text, tt.width) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("wordWrap(%q, %d) = %#v, want %#v", tt.text, tt.width, got, tt.want) + } + }) + } +} + +func TestWordWrapCountsUTF8Runes(t *testing.T) { + tests := []struct { + name string + text string + width int + want []string + }{ + { + name: "accented text fits by rune count", + text: "café latte", + width: 10, + want: []string{"café latte"}, + }, + { + name: "cjk text fits by rune count", + text: "漢字 test", + width: 7, + want: []string{"漢字 test"}, + }, + { + name: "emoji text fits by rune count", + text: "fix 😀 bug", + width: 9, + want: []string{"fix 😀 bug"}, + }, + { + name: "wraps multibyte text when rune count exceeds width", + text: "café latte crème", + width: 10, + want: []string{"café latte", "crème"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wordWrap(tt.text, tt.width) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("wordWrap(%q, %d) = %#v, want %#v", tt.text, tt.width, got, tt.want) + } + }) + } +} |
