From 8d13557c65d7a1eaa17728225d4bfd4f786956db Mon Sep 17 00:00:00 2001 From: Noboru Saito Date: Mon, 15 Dec 2025 12:13:11 +0900 Subject: [PATCH 1/2] Switch string width calculation from go-runewidth to uniseg - Replaced go-runewidth and normalization logic with uniseg for accurate string width calculation. - Updated StringWidth and truncateWithEscapes to use uniseg. - Improved handling of grapheme clusters and combining characters. --- go.mod | 9 ++----- go.sum | 5 ---- width.go | 79 ++++++++++++++------------------------------------------ 3 files changed, 22 insertions(+), 71 deletions(-) diff --git a/go.mod b/go.mod index 5fec214..cedd4b7 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,5 @@ module github.com/noborus/termhyo -go 1.23.0 +go 1.24.0 -require ( - github.com/mattn/go-runewidth v0.0.16 - golang.org/x/text v0.28.0 -) - -require github.com/rivo/uniseg v0.4.7 // indirect +require github.com/rivo/uniseg v0.4.7 diff --git a/go.sum b/go.sum index 9d4b69e..9008848 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,2 @@ -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= diff --git a/width.go b/width.go index 44e929a..180e2d7 100644 --- a/width.go +++ b/width.go @@ -3,17 +3,15 @@ package termhyo import ( "regexp" "strings" - "unicode" - "github.com/mattn/go-runewidth" - "golang.org/x/text/unicode/norm" + "github.com/rivo/uniseg" ) // ANSI escape sequence patterns. var ( - // ANSI color codes and other escape sequences + // ANSI color codes and other escape sequences. ansiEscapeRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) - // Other control sequences (like \r, \n, \t etc.) + // Other control sequences (like \r, \n, \t etc.). controlCharsRegex = regexp.MustCompile(`[\x00-\x1f\x7f]`) ) @@ -44,10 +42,7 @@ func StringWidth(s string) int { func stringWidth(s string) int { // First remove escape sequences and control characters cleaned := stripEscapeSequences(s) - - // Normalize the string to handle combining characters properly - normalized := norm.NFC.String(cleaned) - return runewidth.StringWidth(normalized) + return uniseg.StringWidth(cleaned) } // truncateString truncates a string to fit within the specified display width. @@ -89,73 +84,39 @@ func truncateWithEscapes(s string, maxWidth int) string { var result strings.Builder var currentWidth int + gr := uniseg.NewGraphemes(s) - // Process the string character by character, handling escape sequences - i := 0 - runes := []rune(s) - - for i < len(runes) { - r := runes[i] - - // Check for ANSI escape sequence - if r == '\x1b' && i+1 < len(runes) && runes[i+1] == '[' { - // Find the end of the escape sequence - escapeStart := i - i += 2 // Skip \x1b[ - - for i < len(runes) && !isAlpha(runes[i]) { - i++ - } - if i < len(runes) { - i++ // Include the final letter - } - - // Add the entire escape sequence to result (doesn't count toward width) - result.WriteString(string(runes[escapeStart:i])) + for gr.Next() { + cluster := gr.Str() + // Detect ANSI escape sequence + if strings.HasPrefix(cluster, "\x1b[") { + result.WriteString(cluster) continue } - - // Handle control characters - if r < 0x20 || r == 0x7f { - if r == ' ' || r == '\t' { - // Count spaces and tabs toward width + // Detect control characters + runes := []rune(cluster) + if len(runes) == 1 && (runes[0] < 0x20 || runes[0] == 0x7f) { + if runes[0] == ' ' || runes[0] == '\t' { if currentWidth >= maxWidth { break } - result.WriteRune(r) + result.WriteString(cluster) currentWidth++ } - // Skip other control characters - i++ continue } - - // Handle combining characters - if unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Mc, r) { - result.WriteRune(r) - i++ - continue - } - - // Regular character - check if it fits - charWidth := runewidth.RuneWidth(r) - if currentWidth+charWidth > maxWidth { + // Normal character + clusterWidth := uniseg.StringWidth(cluster) + if currentWidth+clusterWidth > maxWidth { break } - - result.WriteRune(r) - currentWidth += charWidth - i++ + result.WriteString(cluster) + currentWidth += clusterWidth } return result.String() } -// isAlpha returns true if the rune is an ASCII alphabetic character. -func isAlpha(r rune) bool { - return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') -} - // padString pads a string to the specified display width with spaces. // Correctly handles ANSI escape sequences when calculating padding. func padString(s string, width int, align Alignment) string { From ab5d9f8b56466e6b66ab64a25e22bde1dc032783 Mon Sep 17 00:00:00 2001 From: Noboru Saito Date: Mon, 15 Dec 2025 12:15:45 +0900 Subject: [PATCH 2/2] Fix: period at end of comments in header_styles.go and renderer.go --- header_styles.go | 14 +++++++------- renderer.go | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/header_styles.go b/header_styles.go index 609046a..5a0a850 100644 --- a/header_styles.go +++ b/header_styles.go @@ -7,7 +7,7 @@ import ( // ANSI escape sequences for text formatting. const ( - // Text formatting + // Text formatting. AnsiReset = "\x1b[0m" AnsiBold = "\x1b[1m" AnsiDim = "\x1b[2m" @@ -17,7 +17,7 @@ const ( AnsiReverse = "\x1b[7m" AnsiStrike = "\x1b[9m" - // Foreground colors (text colors) + // Foreground colors (text colors). AnsiBlack = "\x1b[30m" AnsiRed = "\x1b[31m" AnsiGreen = "\x1b[32m" @@ -27,7 +27,7 @@ const ( AnsiCyan = "\x1b[36m" AnsiWhite = "\x1b[37m" - // Bright foreground colors + // Bright foreground colors. AnsiBrightBlack = "\x1b[90m" AnsiBrightRed = "\x1b[91m" AnsiBrightGreen = "\x1b[92m" @@ -37,7 +37,7 @@ const ( AnsiBrightCyan = "\x1b[96m" AnsiBrightWhite = "\x1b[97m" - // Background colors + // Background colors. AnsiBgBlack = "\x1b[40m" AnsiBgRed = "\x1b[41m" AnsiBgGreen = "\x1b[42m" @@ -47,7 +47,7 @@ const ( AnsiBgCyan = "\x1b[46m" AnsiBgWhite = "\x1b[47m" - // Bright background colors + // Bright background colors. AnsiBgBrightBlack = "\x1b[100m" AnsiBgBrightRed = "\x1b[101m" AnsiBgBrightGreen = "\x1b[102m" @@ -115,7 +115,7 @@ func (hs HeaderStyle) ApplyStyle(text string) string { } var prefix string - var suffix string = AnsiReset + var suffix = AnsiReset // Add custom prefix if specified if hs.CustomPrefix != "" { @@ -218,7 +218,7 @@ func (hs HeaderStyle) getSuffix() string { return "" } - var suffix string = AnsiReset + var suffix = AnsiReset // Add custom suffix if specified if hs.CustomSuffix != "" { diff --git a/renderer.go b/renderer.go index a9914cc..97b572a 100644 --- a/renderer.go +++ b/renderer.go @@ -11,9 +11,9 @@ type Renderer interface { type RenderMode int const ( - // BufferedMode: collect all rows before rendering (for auto-width) + // BufferedMode: collect all rows before rendering (for auto-width). BufferedMode RenderMode = iota - // StreamingMode: render immediately (for fixed-width) + // StreamingMode: render immediately (for fixed-width). StreamingMode )