From b642f71049ba6030e45c9ddead6d1cc2b7e289e2 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 25 Jun 2026 18:00:59 +0300 Subject: Fix UTF-8 word wrapping for sq0 --- internal/ui/taskdetail.go | 7 +++- internal/ui/taskdetail_test.go | 86 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 internal/ui/taskdetail_test.go 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) + } + }) + } +} -- cgit v1.2.3