Initial QSfera import
This commit is contained in:
+419
@@ -0,0 +1,419 @@
|
||||
package twwidth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/clipperhouse/displaywidth"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/olekukonko/tablewriter/pkg/twcache"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheCapacity = 8192
|
||||
|
||||
cachePrefix = "0:"
|
||||
cacheEastAsianPrefix = "1:"
|
||||
)
|
||||
|
||||
// Options allows for configuring width calculation on a per-call basis.
|
||||
type Options struct {
|
||||
EastAsianWidth bool
|
||||
|
||||
// Explicitly force box drawing chars to be narrow
|
||||
// regardless of EastAsianWidth setting.
|
||||
ForceNarrowBorders bool
|
||||
}
|
||||
|
||||
// globalOptions holds the global displaywidth configuration, including East Asian width settings.
|
||||
var globalOptions Options
|
||||
|
||||
// mu protects access to globalOptions for thread safety.
|
||||
var mu sync.Mutex
|
||||
|
||||
// ansi is a compiled regular expression for stripping ANSI escape codes from strings.
|
||||
var ansi = Filter()
|
||||
|
||||
func init() {
|
||||
isEastAsian := EastAsianDetect()
|
||||
|
||||
cond := runewidth.NewCondition()
|
||||
cond.EastAsianWidth = isEastAsian
|
||||
|
||||
globalOptions = Options{
|
||||
EastAsianWidth: isEastAsian,
|
||||
|
||||
// Auto-enable ForceNarrowBorders for edge cases.
|
||||
// If EastAsianWidth is ON (e.g. forced via Env Var), but we detect
|
||||
// a modern environment, we might technically want to narrow borders
|
||||
// while keeping text wide.
|
||||
ForceNarrowBorders: isEastAsian && isModernEnvironment(),
|
||||
}
|
||||
|
||||
widthCache = twcache.NewLRU[cacheKey, int](cacheCapacity)
|
||||
}
|
||||
|
||||
// Display calculates the visual width of a string using a specific runewidth.Condition.
|
||||
// Deprecated: use WidthWithOptions with the new twwidth.Options struct instead.
|
||||
// This function is kept for backward compatibility.
|
||||
func Display(cond *runewidth.Condition, str string) int {
|
||||
opts := Options{EastAsianWidth: cond.EastAsianWidth}
|
||||
return WidthWithOptions(str, opts)
|
||||
}
|
||||
|
||||
// Filter compiles and returns a regular expression for matching ANSI escape sequences,
|
||||
// including CSI (Control Sequence Introducer) and OSC (Operating System Command) sequences.
|
||||
// The returned regex can be used to strip ANSI codes from strings.
|
||||
func Filter() *regexp.Regexp {
|
||||
regESC := "\x1b" // ASCII escape character
|
||||
regBEL := "\x07" // ASCII bell character
|
||||
|
||||
// ANSI string terminator: either ESC+\ or BEL
|
||||
regST := "(" + regexp.QuoteMeta(regESC+"\\") + "|" + regexp.QuoteMeta(regBEL) + ")"
|
||||
// Control Sequence Introducer (CSI): ESC[ followed by parameters and a final byte
|
||||
regCSI := regexp.QuoteMeta(regESC+"[") + "[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]"
|
||||
// Operating System Command (OSC): ESC] followed by arbitrary content until a terminator
|
||||
regOSC := regexp.QuoteMeta(regESC+"]") + ".*?" + regST
|
||||
|
||||
// Combine CSI and OSC patterns into a single regex
|
||||
return regexp.MustCompile("(" + regCSI + "|" + regOSC + ")")
|
||||
}
|
||||
|
||||
// GetCacheStats returns current cache statistics
|
||||
func GetCacheStats() (size, capacity int, hitRate float64) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if widthCache == nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
return widthCache.Len(), widthCache.Cap(), widthCache.HitRate()
|
||||
}
|
||||
|
||||
// IsEastAsian returns the current East Asian width setting.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if twdw.IsEastAsian() {
|
||||
// // Handle East Asian width characters
|
||||
// }
|
||||
func IsEastAsian() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return globalOptions.EastAsianWidth
|
||||
}
|
||||
|
||||
// SetCondition sets the global East Asian width setting based on a runewidth.Condition.
|
||||
// Deprecated: use SetOptions with the new twwidth.Options struct instead.
|
||||
// This function is kept for backward compatibility.
|
||||
func SetCondition(cond *runewidth.Condition) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
newEastAsianWidth := cond.EastAsianWidth
|
||||
if globalOptions.EastAsianWidth != newEastAsianWidth {
|
||||
globalOptions.EastAsianWidth = newEastAsianWidth
|
||||
widthCache.Purge()
|
||||
}
|
||||
}
|
||||
|
||||
// SetEastAsian enables or disables East Asian width handling globally.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// twdw.SetEastAsian(true) // Enable East Asian width handling
|
||||
func SetEastAsian(enable bool) {
|
||||
SetOptions(Options{EastAsianWidth: enable})
|
||||
}
|
||||
|
||||
// SetForceNarrow to preserve the new flag, or create a new setter
|
||||
func SetForceNarrow(enable bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
globalOptions.ForceNarrowBorders = enable
|
||||
widthCache.Purge() // Clear cache because widths might change
|
||||
}
|
||||
|
||||
// SetOptions sets the global options for width calculation.
|
||||
// This function is thread-safe.
|
||||
func SetOptions(opts Options) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if globalOptions.EastAsianWidth != opts.EastAsianWidth || globalOptions.ForceNarrowBorders != opts.ForceNarrowBorders {
|
||||
globalOptions = opts
|
||||
widthCache.Purge()
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate shortens a string to fit within a specified visual width, optionally
|
||||
// appending a suffix (e.g., "..."). It preserves ANSI escape sequences and adds
|
||||
// a reset sequence (\x1b[0m) if needed to prevent formatting bleed. The function
|
||||
// respects the global East Asian width setting and is thread-safe.
|
||||
//
|
||||
// If maxWidth is negative, an empty string is returned. If maxWidth is zero and
|
||||
// a suffix is provided, the suffix is returned. If the string's visual width is
|
||||
// less than or equal to maxWidth, the string (and suffix, if provided and fits)
|
||||
// is returned unchanged.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// s := twdw.Truncate("Hello\x1b[31mWorld", 5, "...") // Returns "Hello..."
|
||||
// s = twdw.Truncate("Hello", 10) // Returns "Hello"
|
||||
func Truncate(s string, maxWidth int, suffix ...string) string {
|
||||
if maxWidth < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
suffixStr := strings.Join(suffix, "")
|
||||
sDisplayWidth := Width(s) // Uses global cached Width
|
||||
suffixDisplayWidth := Width(suffixStr) // Uses global cached Width
|
||||
|
||||
// Case 1: Original string is visually empty.
|
||||
if sDisplayWidth == 0 {
|
||||
// If suffix is provided and fits within maxWidth (or if maxWidth is generous)
|
||||
if len(suffixStr) > 0 && suffixDisplayWidth <= maxWidth {
|
||||
return suffixStr
|
||||
}
|
||||
// If s has ANSI codes (len(s)>0) but maxWidth is 0, can't display them.
|
||||
if maxWidth == 0 && len(s) > 0 {
|
||||
return ""
|
||||
}
|
||||
return s // Returns "" or original ANSI codes
|
||||
}
|
||||
|
||||
// Case 2: maxWidth is 0, but string has content. Cannot display anything.
|
||||
if maxWidth == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Case 3: String fits completely or fits with suffix.
|
||||
// Here, maxWidth is the total budget for the line.
|
||||
if sDisplayWidth <= maxWidth {
|
||||
// If the string contains ANSI, we must ensure it ends with a reset
|
||||
// to prevent bleeding, even if we don't truncate.
|
||||
safeS := s
|
||||
if strings.Contains(s, "\x1b") && !strings.HasSuffix(s, "\x1b[0m") {
|
||||
safeS += "\x1b[0m"
|
||||
}
|
||||
|
||||
if len(suffixStr) == 0 { // No suffix.
|
||||
return safeS
|
||||
}
|
||||
// Suffix is provided. Check if s + suffix fits.
|
||||
if sDisplayWidth+suffixDisplayWidth <= maxWidth {
|
||||
return safeS + suffixStr
|
||||
}
|
||||
// s fits, but s + suffix is too long. Return s (with reset if needed).
|
||||
return safeS
|
||||
}
|
||||
|
||||
// Case 4: String needs truncation (sDisplayWidth > maxWidth).
|
||||
// maxWidth is the total budget for the final string (content + suffix).
|
||||
mu.Lock()
|
||||
currentOpts := globalOptions
|
||||
mu.Unlock()
|
||||
|
||||
// Special case for EastAsianDetect true: if only suffix fits, return suffix.
|
||||
// This was derived from previous test behavior.
|
||||
if len(suffixStr) > 0 && currentOpts.EastAsianWidth {
|
||||
provisionalContentWidth := maxWidth - suffixDisplayWidth
|
||||
if provisionalContentWidth == 0 { // Exactly enough space for suffix only
|
||||
return suffixStr
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the budget for the content part, reserving space for the suffix.
|
||||
targetContentForIteration := maxWidth
|
||||
if len(suffixStr) > 0 {
|
||||
targetContentForIteration -= suffixDisplayWidth
|
||||
}
|
||||
|
||||
// If content budget is negative, means not even suffix fits (or no suffix and no space).
|
||||
// However, if only suffix fits, it should be handled.
|
||||
if targetContentForIteration < 0 {
|
||||
// Can we still fit just the suffix?
|
||||
if len(suffixStr) > 0 && suffixDisplayWidth <= maxWidth {
|
||||
if strings.Contains(s, "\x1b[") {
|
||||
return "\x1b[0m" + suffixStr
|
||||
}
|
||||
return suffixStr
|
||||
}
|
||||
return "" // Cannot fit anything.
|
||||
}
|
||||
|
||||
var contentBuf bytes.Buffer
|
||||
var currentContentDisplayWidth int
|
||||
var ansiSeqBuf bytes.Buffer
|
||||
inAnsiSequence := false
|
||||
ansiWrittenToContent := false
|
||||
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
inAnsiSequence = true
|
||||
ansiSeqBuf.Reset()
|
||||
ansiSeqBuf.WriteRune(r)
|
||||
} else if inAnsiSequence {
|
||||
ansiSeqBuf.WriteRune(r)
|
||||
seqBytes := ansiSeqBuf.Bytes()
|
||||
seqLen := len(seqBytes)
|
||||
terminated := false
|
||||
if seqLen >= 2 {
|
||||
introducer := seqBytes[1]
|
||||
switch introducer {
|
||||
case '[':
|
||||
if seqLen >= 3 && r >= 0x40 && r <= 0x7E {
|
||||
terminated = true
|
||||
}
|
||||
case ']':
|
||||
if r == '\x07' {
|
||||
terminated = true
|
||||
} else if seqLen > 1 && seqBytes[seqLen-2] == '\x1b' && r == '\\' { // Check for ST: \x1b\
|
||||
terminated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if terminated {
|
||||
inAnsiSequence = false
|
||||
contentBuf.Write(ansiSeqBuf.Bytes())
|
||||
ansiWrittenToContent = true
|
||||
ansiSeqBuf.Reset()
|
||||
}
|
||||
} else { // Normal character
|
||||
runeDisplayWidth := calculateRunewidth(r, currentOpts)
|
||||
if targetContentForIteration == 0 { // No budget for content at all
|
||||
break
|
||||
}
|
||||
if currentContentDisplayWidth+runeDisplayWidth > targetContentForIteration {
|
||||
break
|
||||
}
|
||||
contentBuf.WriteRune(r)
|
||||
currentContentDisplayWidth += runeDisplayWidth
|
||||
}
|
||||
}
|
||||
|
||||
result := contentBuf.String()
|
||||
|
||||
// Determine if we need to append a reset sequence to prevent color bleeding.
|
||||
// This is needed if we wrote any ANSI codes or if the input had active codes
|
||||
// that we might have cut off or left open.
|
||||
needsReset := false
|
||||
if (ansiWrittenToContent || (inAnsiSequence && strings.Contains(s, "\x1b["))) && (currentContentDisplayWidth > 0 || ansiWrittenToContent) {
|
||||
if !strings.HasSuffix(result, "\x1b[0m") {
|
||||
needsReset = true
|
||||
}
|
||||
} else if currentContentDisplayWidth > 0 && strings.Contains(result, "\x1b[") && !strings.HasSuffix(result, "\x1b[0m") && strings.Contains(s, "\x1b[") {
|
||||
needsReset = true
|
||||
}
|
||||
|
||||
if needsReset {
|
||||
result += "\x1b[0m"
|
||||
}
|
||||
|
||||
// Suffix is added if provided.
|
||||
if len(suffixStr) > 0 {
|
||||
result += suffixStr
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Width calculates the visual width of a string using the global cache for performance.
|
||||
// It excludes ANSI escape sequences and accounts for the global East Asian width setting.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// width := twdw.Width("Hello\x1b[31mWorld") // Returns 10
|
||||
func Width(str string) int {
|
||||
// Fast path ASCII (Optimization)
|
||||
if len(str) == 1 && str[0] < 0x80 {
|
||||
// Treat tab as special case even in fast path
|
||||
if IsTab(rune(str[0])) {
|
||||
return TabWidth()
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
currentOpts := globalOptions
|
||||
mu.Unlock()
|
||||
|
||||
key := cacheKey{
|
||||
eastAsian: currentOpts.EastAsianWidth,
|
||||
str: str,
|
||||
}
|
||||
|
||||
// Check Cache (Optimization)
|
||||
if w, found := widthCache.Get(key); found {
|
||||
return w
|
||||
}
|
||||
|
||||
//stripped := ansi.ReplaceAllLiteralString(str, "")
|
||||
calculatedWidth := 0
|
||||
|
||||
for _, r := range strip(str) {
|
||||
calculatedWidth += calculateRunewidth(r, currentOpts)
|
||||
}
|
||||
|
||||
// Store in Cache
|
||||
widthCache.Add(key, calculatedWidth)
|
||||
return calculatedWidth
|
||||
}
|
||||
|
||||
// WidthNoCache calculates the visual width of a string without using the global cache.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// width := twdw.WidthNoCache("Hello\x1b[31mWorld") // Returns 10
|
||||
func WidthNoCache(str string) int {
|
||||
// This function's behavior is equivalent to a one-shot calculation
|
||||
// using the current global options. The WidthWithOptions function
|
||||
// does not interact with the cache, thus fulfilling the requirement.
|
||||
mu.Lock()
|
||||
opts := globalOptions
|
||||
mu.Unlock()
|
||||
return WidthWithOptions(str, opts)
|
||||
}
|
||||
|
||||
// WidthWithOptions calculates the visual width of a string with specific options,
|
||||
// bypassing the global settings and cache. This is useful for one-shot calculations
|
||||
// where global state is not desired.
|
||||
func WidthWithOptions(str string, opts Options) int {
|
||||
// stripped := ansi.ReplaceAllLiteralString(str, "")
|
||||
calculatedWidth := 0
|
||||
for _, r := range strip(str) {
|
||||
calculatedWidth += calculateRunewidth(r, opts)
|
||||
}
|
||||
return calculatedWidth
|
||||
}
|
||||
|
||||
// calculateRunewidth calculates the width of a single rune based on the provided options.
|
||||
// It applies narrow overrides for box drawing characters if configured and handles Tabs.
|
||||
func calculateRunewidth(r rune, opts Options) int {
|
||||
if opts.ForceNarrowBorders && isBoxDrawingChar(r) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Explicitly handle Tabinal to ensure tables have enough space
|
||||
// when TrimTab is Off.
|
||||
if IsTab(r) {
|
||||
return TabWidth()
|
||||
}
|
||||
|
||||
dwOpts := displaywidth.Options{EastAsianWidth: opts.EastAsianWidth}
|
||||
return dwOpts.Rune(r)
|
||||
}
|
||||
|
||||
// isBoxDrawingChar checks if a rune is within the Unicode Box Drawing range.
|
||||
func isBoxDrawingChar(r rune) bool {
|
||||
return r >= 0x2500 && r <= 0x257F
|
||||
}
|
||||
|
||||
func strip(s string) string {
|
||||
if strings.IndexByte(s, '\x1b') == -1 {
|
||||
return s
|
||||
}
|
||||
return ansi.ReplaceAllLiteralString(s, "")
|
||||
}
|
||||
Reference in New Issue
Block a user