summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-25 18:00:59 +0300
committerPaul Buetow <paul@buetow.org>2026-06-25 18:00:59 +0300
commitb642f71049ba6030e45c9ddead6d1cc2b7e289e2 (patch)
treeb577f7f3eadcfb34028293c79b4688ad0cec73ae
parentcb36d8f78ce688db6f74cac57e7c10cd334e5f6e (diff)
Fix UTF-8 word wrapping for sq0
-rw-r--r--internal/ui/taskdetail.go7
-rw-r--r--internal/ui/taskdetail_test.go86
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)
+ }
+ })
+ }
+}