mirror of
https://github.com/usestrix/strix.git
synced 2026-08-28 05:25:00 +00:00
fix: stop TUI truncation helpers from splitting multi-byte UTF-8 runes
truncStr, firstN, and lastN (internal/render/helpers.go), ptrunc
(proxy.go), and truncateShellLine (terminal.go) all sliced strings on
raw byte offsets. When a cut landed mid-rune, the result was invalid
UTF-8 and the terminal rendered mojibake; the same byte-vs-column
mismatch also cut CJK/emoji lines short, since those runes spend 2-4
bytes but only 1-2 display columns of the budget.
Switch all five to github.com/charmbracelet/x/ansi, already a direct
dependency and already the established pattern for exactly this in
internal/app (view.go). Truncate/TruncateLeft are grapheme-aware and
operate on display columns, not bytes, so a cut can no longer split a
rune and CJK/emoji now correctly consume 2 columns instead of 1.
- truncStr/firstN ("keep the start"): ansi.Truncate(s, n, "")
- lastN ("keep the end"): reverse via ansi.TruncateLeft(s, w-n, "")
where w is the string's total display width
- ptrunc/truncateShellLine (truncate-with-ellipsis): ansi.Truncate
already reserves the tail's width internally, so this is a direct
swap of the same max/length budget, no arithmetic needed
Added helpers_test.go with the issue's own repro (asserting valid
UTF-8 out of all five paths) plus per-function behavior tests.
Fixes #1152.
This commit is contained in:
parent
f4ef8867f6
commit
dfa9433cfb
4 changed files with 87 additions and 18 deletions
|
|
@ -5,6 +5,8 @@ import (
|
|||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -37,25 +39,23 @@ func NumericValue(v any) (float64, bool) {
|
|||
return 0, false
|
||||
}
|
||||
|
||||
// truncStr and firstN keep the first n display columns of s, without splitting
|
||||
// a multi-byte rune. Kept as two names since callers use both spellings for the
|
||||
// same "keep the start" truncation.
|
||||
func truncStr(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
return ansi.Truncate(s, n, "")
|
||||
}
|
||||
|
||||
// lastN keeps the last n display columns of s, without splitting a multi-byte rune.
|
||||
func lastN(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[len(s)-n:]
|
||||
if w := ansi.StringWidth(s); w > n {
|
||||
return ansi.TruncateLeft(s, w-n, "")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstN(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
return ansi.Truncate(s, n, "")
|
||||
}
|
||||
|
||||
func joinTrunc(items []any, max, limit int) string {
|
||||
|
|
|
|||
73
strix/interface/tui/internal/render/helpers_test.go
Normal file
73
strix/interface/tui/internal/render/helpers_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package render
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Regression test for https://github.com/usestrix/strix/issues/1152
|
||||
func TestTruncSplitsRunes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
out string
|
||||
}{
|
||||
{"firstN", firstN("中文字ABC", 4)},
|
||||
{"lastN", lastN("ABC中文字", 4)},
|
||||
{"truncStr", truncStr("中文字ABC", 4)},
|
||||
{"ptrunc", ptrunc("中文字ABCDEF", 5)},
|
||||
{"truncateShellLine", truncateShellLine(repeatStr("中", maxLineLength))},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if !utf8.ValidString(c.out) {
|
||||
t.Errorf("%s produced invalid UTF-8: %q", c.name, c.out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNTruncStrKeepStart(t *testing.T) {
|
||||
if got := firstN("中文字ABC", 4); got != "中文" {
|
||||
t.Errorf("firstN(%q, 4) = %q, want %q", "中文字ABC", got, "中文")
|
||||
}
|
||||
if got := truncStr("中文字ABC", 4); got != "中文" {
|
||||
t.Errorf("truncStr(%q, 4) = %q, want %q", "中文字ABC", got, "中文")
|
||||
}
|
||||
if got := firstN("hello", 3); got != "hel" {
|
||||
t.Errorf("firstN(%q, 3) = %q, want %q", "hello", got, "hel")
|
||||
}
|
||||
if got := firstN("hi", 10); got != "hi" {
|
||||
t.Errorf("firstN(%q, 10) = %q, want unchanged", "hi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastNKeepsEnd(t *testing.T) {
|
||||
// Widths: A=1 B=1 C=1 中=2 文=2 字=2. Keeping the last 4 *columns* (not
|
||||
// characters) lands on 文+字 (2+2=4), matching the issue's "count columns,
|
||||
// not bytes" requirement.
|
||||
if got := lastN("ABC中文字", 4); got != "文字" {
|
||||
t.Errorf("lastN(%q, 4) = %q, want %q", "ABC中文字", got, "文字")
|
||||
}
|
||||
if got := lastN("hello", 3); got != "llo" {
|
||||
t.Errorf("lastN(%q, 3) = %q, want %q", "hello", got, "llo")
|
||||
}
|
||||
if got := lastN("hi", 10); got != "hi" {
|
||||
t.Errorf("lastN(%q, 10) = %q, want unchanged", "hi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPtruncAppendsEllipsisWithinBudget(t *testing.T) {
|
||||
got := ptrunc("abcdefgh", 5)
|
||||
if got != "ab..." {
|
||||
t.Errorf("ptrunc(%q, 5) = %q, want %q", "abcdefgh", got, "ab...")
|
||||
}
|
||||
if got := ptrunc("hi", 5); got != "hi" {
|
||||
t.Errorf("ptrunc(%q, 5) = %q, want unchanged", "hi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func repeatStr(s string, n int) string {
|
||||
out := ""
|
||||
for i := 0; i < n; i++ {
|
||||
out += s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -28,10 +29,7 @@ func proxyStatusStyle(code int) lipgloss.Style {
|
|||
}
|
||||
|
||||
func ptrunc(s string, max int) string {
|
||||
if len(s) > max {
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
return s
|
||||
return ansi.Truncate(s, max, "...")
|
||||
}
|
||||
|
||||
func psanitize(s string, max int) string {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -83,10 +84,7 @@ func cleanShellOutput(output string) string {
|
|||
}
|
||||
|
||||
func truncateShellLine(line string) string {
|
||||
if len(line) > maxLineLength {
|
||||
return line[:maxLineLength-3] + "..."
|
||||
}
|
||||
return line
|
||||
return ansi.Truncate(line, maxLineLength, "...")
|
||||
}
|
||||
|
||||
// formatShellOutput ports _format_output (head/tail truncation with a middle marker).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue