Initial QSfera import
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
package twcache
|
||||
|
||||
// Cache defines a generic interface for a key-value storage with type constraints on keys and values.
|
||||
// The keys must be of a type that supports comparison.
|
||||
// Add inserts a new key-value pair, potentially evicting an item if necessary.
|
||||
// Get retrieves a value associated with the given key, returning a boolean to indicate if the key was found.
|
||||
// Purge clears all items from the cache.
|
||||
type Cache[K comparable, V any] interface {
|
||||
Add(key K, value V) (evicted bool)
|
||||
Get(key K) (value V, ok bool)
|
||||
Purge()
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
package twcache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// EvictCallback is a function called when an entry is evicted.
|
||||
// This includes evictions during Purge or Resize operations.
|
||||
type EvictCallback[K comparable, V any] func(key K, value V)
|
||||
|
||||
// LRU is a thread-safe, generic LRU cache with a fixed size.
|
||||
// It has zero dependencies, high performance, and full features.
|
||||
type LRU[K comparable, V any] struct {
|
||||
size int
|
||||
items map[K]*entry[K, V]
|
||||
head *entry[K, V] // Most Recently Used
|
||||
tail *entry[K, V] // Least Recently Used
|
||||
onEvict EvictCallback[K, V]
|
||||
|
||||
mu sync.Mutex
|
||||
hits atomic.Int64
|
||||
misses atomic.Int64
|
||||
}
|
||||
|
||||
// entry represents a single item in the LRU linked list.
|
||||
// It holds the key, value, and pointers to prev/next entries.
|
||||
type entry[K comparable, V any] struct {
|
||||
key K
|
||||
value V
|
||||
prev *entry[K, V]
|
||||
next *entry[K, V]
|
||||
}
|
||||
|
||||
// NewLRU creates a new LRU cache with the given size.
|
||||
// Returns nil if size <= 0, acting as a disabled cache.
|
||||
// Caps size at 100,000 for reasonableness.
|
||||
func NewLRU[K comparable, V any](size int) *LRU[K, V] {
|
||||
return NewLRUEvict[K, V](size, nil)
|
||||
}
|
||||
|
||||
// NewLRUEvict creates a new LRU cache with an eviction callback.
|
||||
// The callback is optional and called on evictions.
|
||||
// Returns nil if size <= 0.
|
||||
func NewLRUEvict[K comparable, V any](size int, onEvict EvictCallback[K, V]) *LRU[K, V] {
|
||||
if size <= 0 {
|
||||
return nil // nil = disabled cache (fast path in hot code)
|
||||
}
|
||||
if size > 100_000 {
|
||||
size = 100_000 // reasonable upper bound
|
||||
}
|
||||
return &LRU[K, V]{
|
||||
size: size,
|
||||
items: make(map[K]*entry[K, V], size),
|
||||
onEvict: onEvict,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrCompute retrieves a value or computes it if missing.
|
||||
// Ensures no double computation under concurrency.
|
||||
// Ideal for expensive computations like twwidth.
|
||||
func (c *LRU[K, V]) GetOrCompute(key K, compute func() V) V {
|
||||
if c == nil || c.size <= 0 {
|
||||
return compute()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if e, ok := c.items[key]; ok {
|
||||
c.moveToFront(e)
|
||||
c.hits.Add(1)
|
||||
c.mu.Unlock()
|
||||
return e.value
|
||||
}
|
||||
|
||||
c.misses.Add(1)
|
||||
value := compute() // expensive work only on real miss
|
||||
|
||||
// Double-check: someone might have added it while computing
|
||||
if e, ok := c.items[key]; ok {
|
||||
e.value = value
|
||||
c.moveToFront(e)
|
||||
c.mu.Unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
// Evict if needed
|
||||
if len(c.items) >= c.size {
|
||||
c.removeOldest()
|
||||
}
|
||||
|
||||
e := &entry[K, V]{key: key, value: value}
|
||||
c.addToFront(e)
|
||||
c.items[key] = e
|
||||
c.mu.Unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
// Get retrieves a value by key if it exists.
|
||||
// Returns the value and true if found, else zero and false.
|
||||
// Updates the entry to most recently used.
|
||||
func (c *LRU[K, V]) Get(key K) (V, bool) {
|
||||
if c == nil || c.size <= 0 {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
c.misses.Add(1)
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
c.hits.Add(1)
|
||||
c.moveToFront(e)
|
||||
return e.value, true
|
||||
}
|
||||
|
||||
// Add inserts or updates a key-value pair.
|
||||
// Evicts the oldest if cache is full.
|
||||
// Returns true if an eviction occurred.
|
||||
func (c *LRU[K, V]) Add(key K, value V) (evicted bool) {
|
||||
if c == nil || c.size <= 0 {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if e, ok := c.items[key]; ok {
|
||||
e.value = value
|
||||
c.moveToFront(e)
|
||||
return false
|
||||
}
|
||||
|
||||
if len(c.items) >= c.size {
|
||||
c.removeOldest()
|
||||
evicted = true
|
||||
}
|
||||
|
||||
e := &entry[K, V]{key: key, value: value}
|
||||
c.addToFront(e)
|
||||
c.items[key] = e
|
||||
return evicted
|
||||
}
|
||||
|
||||
// Remove deletes a key from the cache.
|
||||
// Returns true if the key was found and removed.
|
||||
func (c *LRU[K, V]) Remove(key K) bool {
|
||||
if c == nil || c.size <= 0 {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
c.removeNode(e)
|
||||
delete(c.items, key)
|
||||
return true
|
||||
}
|
||||
|
||||
// Purge clears all entries from the cache.
|
||||
// Calls onEvict for each entry if set.
|
||||
// Resets hit/miss counters.
|
||||
func (c *LRU[K, V]) Purge() {
|
||||
if c == nil || c.size <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.onEvict != nil {
|
||||
for key, e := range c.items {
|
||||
c.onEvict(key, e.value)
|
||||
}
|
||||
}
|
||||
c.items = make(map[K]*entry[K, V], c.size)
|
||||
c.head = nil
|
||||
c.tail = nil
|
||||
c.hits.Store(0)
|
||||
c.misses.Store(0)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Len returns the current number of items in the cache.
|
||||
func (c *LRU[K, V]) Len() int {
|
||||
if c == nil || c.size <= 0 {
|
||||
return 0
|
||||
}
|
||||
c.mu.Lock()
|
||||
n := len(c.items)
|
||||
c.mu.Unlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// Cap returns the maximum capacity of the cache.
|
||||
func (c *LRU[K, V]) Cap() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.size
|
||||
}
|
||||
|
||||
// HitRate returns the cache hit ratio (0.0 to 1.0).
|
||||
// Based on hits / (hits + misses).
|
||||
func (c *LRU[K, V]) HitRate() float64 {
|
||||
h := c.hits.Load()
|
||||
m := c.misses.Load()
|
||||
total := h + m
|
||||
if total == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return float64(h) / float64(total)
|
||||
}
|
||||
|
||||
// RemoveOldest removes and returns the least recently used item.
|
||||
// Returns key, value, and true if an item was removed.
|
||||
// Calls onEvict if set.
|
||||
func (c *LRU[K, V]) RemoveOldest() (key K, value V, ok bool) {
|
||||
if c == nil || c.size <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.tail == nil {
|
||||
return
|
||||
}
|
||||
|
||||
key = c.tail.key
|
||||
value = c.tail.value
|
||||
|
||||
c.removeOldest()
|
||||
return key, value, true
|
||||
}
|
||||
|
||||
// moveToFront moves an entry to the front (MRU position).
|
||||
func (c *LRU[K, V]) moveToFront(e *entry[K, V]) {
|
||||
if c.head == e {
|
||||
return
|
||||
}
|
||||
c.removeNode(e)
|
||||
c.addToFront(e)
|
||||
}
|
||||
|
||||
// addToFront adds an entry to the front of the list.
|
||||
func (c *LRU[K, V]) addToFront(e *entry[K, V]) {
|
||||
e.prev = nil
|
||||
e.next = c.head
|
||||
if c.head != nil {
|
||||
c.head.prev = e
|
||||
}
|
||||
c.head = e
|
||||
if c.tail == nil {
|
||||
c.tail = e
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// removeNode removes an entry from the linked list.
|
||||
func (c *LRU[K, V]) removeNode(e *entry[K, V]) {
|
||||
if e.prev != nil {
|
||||
e.prev.next = e.next
|
||||
} else {
|
||||
c.head = e.next
|
||||
}
|
||||
if e.next != nil {
|
||||
e.next.prev = e.prev
|
||||
} else {
|
||||
c.tail = e.prev
|
||||
}
|
||||
e.prev = nil
|
||||
e.next = nil
|
||||
}
|
||||
|
||||
// removeOldest removes the tail entry (LRU).
|
||||
// Calls onEvict if set and deletes from map.
|
||||
func (c *LRU[K, V]) removeOldest() {
|
||||
if c.tail == nil {
|
||||
return
|
||||
}
|
||||
e := c.tail
|
||||
if c.onEvict != nil {
|
||||
c.onEvict(e.key, e.value)
|
||||
}
|
||||
c.removeNode(e)
|
||||
delete(c.items, e.key)
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
// Copyright 2014 Oleku Konko All rights reserved.
|
||||
// Use of this source code is governed by a MIT
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This module is a Table Writer API for the Go Programming Language.
|
||||
// The protocols were written in pure Go and works on windows and unix systems
|
||||
|
||||
package twwarp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/clipperhouse/uax29/v2/graphemes"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
)
|
||||
|
||||
const (
|
||||
nl = "\n"
|
||||
sp = " "
|
||||
)
|
||||
|
||||
const defaultPenalty = 1e5
|
||||
|
||||
func SplitWords(s string) []string {
|
||||
words := make([]string, 0, len(s)/5)
|
||||
var wordBegin int
|
||||
wordPending := false
|
||||
for i, c := range s {
|
||||
if unicode.IsSpace(c) {
|
||||
if wordPending {
|
||||
words = append(words, s[wordBegin:i])
|
||||
wordPending = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !wordPending {
|
||||
wordBegin = i
|
||||
wordPending = true
|
||||
}
|
||||
}
|
||||
if wordPending {
|
||||
words = append(words, s[wordBegin:])
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// WrapString wraps s into a paragraph of lines of length lim, with minimal
|
||||
// raggedness.
|
||||
func WrapString(s string, lim int) ([]string, int) {
|
||||
if s == sp {
|
||||
return []string{sp}, lim
|
||||
}
|
||||
words := SplitWords(s)
|
||||
if len(words) == 0 {
|
||||
return []string{""}, lim
|
||||
}
|
||||
var lines []string
|
||||
max := 0
|
||||
for _, v := range words {
|
||||
max = twwidth.Width(v)
|
||||
if max > lim {
|
||||
lim = max
|
||||
}
|
||||
}
|
||||
for _, line := range WrapWords(words, 1, lim, defaultPenalty) {
|
||||
lines = append(lines, strings.Join(line, sp))
|
||||
}
|
||||
return lines, lim
|
||||
}
|
||||
|
||||
// WrapStringWithSpaces wraps a string into lines of a specified display width while preserving
|
||||
// leading and trailing spaces. It splits the input string into words, condenses internal multiple
|
||||
// spaces to a single space, and wraps the content to fit within the given width limit, measured
|
||||
// using Unicode-aware display width. The function is used in the logging library to format log
|
||||
// messages for consistent output. It returns the wrapped lines as a slice of strings and the
|
||||
// adjusted width limit, which may increase if a single word exceeds the input limit. Thread-safe
|
||||
// as it does not modify shared state.
|
||||
func WrapStringWithSpaces(s string, lim int) ([]string, int) {
|
||||
if len(s) == 0 {
|
||||
return []string{""}, lim
|
||||
}
|
||||
if strings.TrimSpace(s) == "" { // All spaces
|
||||
if twwidth.Width(s) <= lim {
|
||||
return []string{s}, twwidth.Width(s)
|
||||
}
|
||||
// For very long all-space strings, "wrap" by truncating to the limit.
|
||||
if lim > 0 {
|
||||
substring, _ := stringToDisplayWidth(s, lim)
|
||||
return []string{substring}, lim
|
||||
}
|
||||
return []string{""}, lim
|
||||
}
|
||||
|
||||
var leadingSpaces, trailingSpaces, coreContent string
|
||||
firstNonSpace := strings.IndexFunc(s, func(r rune) bool { return !unicode.IsSpace(r) })
|
||||
leadingSpaces = s[:firstNonSpace]
|
||||
lastNonSpace := strings.LastIndexFunc(s, func(r rune) bool { return !unicode.IsSpace(r) })
|
||||
trailingSpaces = s[lastNonSpace+1:]
|
||||
coreContent = s[firstNonSpace : lastNonSpace+1]
|
||||
|
||||
if coreContent == "" {
|
||||
return []string{leadingSpaces + trailingSpaces}, lim
|
||||
}
|
||||
|
||||
words := SplitWords(coreContent)
|
||||
if len(words) == 0 {
|
||||
return []string{leadingSpaces + trailingSpaces}, lim
|
||||
}
|
||||
|
||||
var lines []string
|
||||
currentLim := lim
|
||||
|
||||
maxCoreWordWidth := 0
|
||||
for _, v := range words {
|
||||
w := twwidth.Width(v)
|
||||
if w > maxCoreWordWidth {
|
||||
maxCoreWordWidth = w
|
||||
}
|
||||
}
|
||||
|
||||
if maxCoreWordWidth > currentLim {
|
||||
currentLim = maxCoreWordWidth
|
||||
}
|
||||
|
||||
wrappedWordLines := WrapWords(words, 1, currentLim, defaultPenalty)
|
||||
|
||||
for i, lineWords := range wrappedWordLines {
|
||||
joinedLine := strings.Join(lineWords, sp)
|
||||
finalLine := leadingSpaces + joinedLine
|
||||
if i == len(wrappedWordLines)-1 { // Last line
|
||||
finalLine += trailingSpaces
|
||||
}
|
||||
lines = append(lines, finalLine)
|
||||
}
|
||||
return lines, currentLim
|
||||
}
|
||||
|
||||
// stringToDisplayWidth returns a substring of s that has a display width
|
||||
// as close as possible to, but not exceeding, targetWidth.
|
||||
// It returns the substring and its actual display width.
|
||||
func stringToDisplayWidth(s string, targetWidth int) (substring string, actualWidth int) {
|
||||
if targetWidth <= 0 {
|
||||
return "", 0
|
||||
}
|
||||
|
||||
var currentWidth int
|
||||
var endIndex int // Tracks the byte index in the original string
|
||||
|
||||
g := graphemes.FromString(s)
|
||||
for g.Next() {
|
||||
grapheme := g.Value()
|
||||
graphemeWidth := twwidth.Width(grapheme)
|
||||
|
||||
if currentWidth+graphemeWidth > targetWidth {
|
||||
break
|
||||
}
|
||||
|
||||
currentWidth += graphemeWidth
|
||||
endIndex = g.End()
|
||||
}
|
||||
return s[:endIndex], currentWidth
|
||||
}
|
||||
|
||||
// WrapWords is the low-level line-breaking algorithm, useful if you need more
|
||||
// control over the details of the text wrapping process. For most uses,
|
||||
// WrapString will be sufficient and more convenient.
|
||||
//
|
||||
// WrapWords splits a list of words into lines with minimal "raggedness",
|
||||
// treating each rune as one unit, accounting for spc units between adjacent
|
||||
// words on each line, and attempting to limit lines to lim units. Raggedness
|
||||
// is the total error over all lines, where error is the square of the
|
||||
// difference of the length of the line and lim. Too-long lines (which only
|
||||
// happen when a single word is longer than lim units) have pen penalty units
|
||||
// added to the error.
|
||||
func WrapWords(words []string, spc, lim, pen int) [][]string {
|
||||
n := len(words)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
lengths := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
lengths[i] = twwidth.Width(words[i])
|
||||
}
|
||||
nbrk := make([]int, n)
|
||||
cost := make([]int, n)
|
||||
for i := range cost {
|
||||
cost[i] = math.MaxInt32
|
||||
}
|
||||
remainderLen := lengths[n-1] // Uses updated lengths
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if i < n-1 {
|
||||
remainderLen += spc + lengths[i]
|
||||
}
|
||||
if remainderLen <= lim {
|
||||
cost[i] = 0
|
||||
nbrk[i] = n
|
||||
continue
|
||||
}
|
||||
phraseLen := lengths[i]
|
||||
for j := i + 1; j < n; j++ {
|
||||
if j > i+1 {
|
||||
phraseLen += spc + lengths[j-1]
|
||||
}
|
||||
d := lim - phraseLen
|
||||
c := d*d + cost[j]
|
||||
if phraseLen > lim {
|
||||
c += pen // too-long lines get a worse penalty
|
||||
}
|
||||
if c < cost[i] {
|
||||
cost[i] = c
|
||||
nbrk[i] = j
|
||||
}
|
||||
}
|
||||
}
|
||||
var lines [][]string
|
||||
i := 0
|
||||
for i < n {
|
||||
lines = append(lines, words[i:nbrk[i]])
|
||||
i = nbrk[i]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// getLines decomposes a multiline string into a slice of strings.
|
||||
func getLines(s string) []string {
|
||||
return strings.Split(s, nl)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package twwidth
|
||||
|
||||
import "github.com/olekukonko/tablewriter/pkg/twcache"
|
||||
|
||||
// widthCache stores memoized results of Width calculations to improve performance.
|
||||
var widthCache *twcache.LRU[cacheKey, int]
|
||||
|
||||
type cacheKey struct {
|
||||
eastAsian bool
|
||||
str string
|
||||
}
|
||||
|
||||
// SetCacheCapacity changes the cache size dynamically
|
||||
// If capacity <= 0, disables caching entirely
|
||||
func SetCacheCapacity(capacity int) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if capacity <= 0 {
|
||||
widthCache = nil // nil = fully disabled
|
||||
return
|
||||
}
|
||||
|
||||
newCache := twcache.NewLRU[cacheKey, int](capacity)
|
||||
widthCache = newCache
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
Package twwidth provides intelligent East Asian width detection.
|
||||
|
||||
In 2025/2026, most modern terminal emulators (VSCode, Windows Terminal, iTerm2,
|
||||
Alacritty) and modern monospace fonts (Hack, Fira Code, Cascadia Code) treat
|
||||
box-drawing characters as Single Width, regardless of the underlying OS Locale.
|
||||
|
||||
Detection Logic (in order of priority):
|
||||
- RUNEWIDTH_EASTASIAN environment variable (explicit user override)
|
||||
- Force Legacy Mode (programmatic override for backward compatibility)
|
||||
- Modern environment detection (VSCode, Windows Terminal, etc. -> Narrow)
|
||||
- Locale-based detection (CJK locales in traditional terminals -> Wide)
|
||||
|
||||
This prioritization ensures that:
|
||||
- Users can always override behavior using RUNEWIDTH_EASTASIAN
|
||||
- Modern development environments work correctly by default
|
||||
- Traditional CJK terminals maintain compatibility via locale checks
|
||||
|
||||
Examples:
|
||||
|
||||
// Force narrow borders (for Hack font in zh_CN)
|
||||
RUNEWIDTH_EASTASIAN=0 go run .
|
||||
|
||||
// Force wide borders (for legacy CJK terminals)
|
||||
RUNEWIDTH_EASTASIAN=1 go run .
|
||||
*/
|
||||
package twwidth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Environment Variable Constants
|
||||
const (
|
||||
EnvLCAll = "LC_ALL"
|
||||
EnvLCCtype = "LC_CTYPE"
|
||||
EnvLang = "LANG"
|
||||
EnvRuneWidthEastAsian = "RUNEWIDTH_EASTASIAN"
|
||||
EnvTerm = "TERM"
|
||||
EnvTermProgram = "TERM_PROGRAM"
|
||||
EnvTermProgramWsl = "TERM_PROGRAM_WSL"
|
||||
EnvWTProfile = "WT_PROFILE_ID" // Windows Terminal
|
||||
EnvConEmuANSI = "ConEmuANSI" // ConEmu
|
||||
EnvAlacritty = "ALACRITTY_LOG" // Alacritty
|
||||
EnvVTEVersion = "VTE_VERSION" // GNOME/VTE
|
||||
)
|
||||
|
||||
const (
|
||||
overwriteOn = "override_on"
|
||||
overwriteOff = "override_off"
|
||||
|
||||
envModern = "modern_env"
|
||||
envCjk = "locale_cjk"
|
||||
envAscii = "default_ascii"
|
||||
)
|
||||
|
||||
// CJK Language Codes (Prefixes)
|
||||
// Covers ISO 639-1 (2-letter) and common full names used in some systems.
|
||||
var cjkPrefixes = []string{
|
||||
"zh", "ja", "ko", // Standard: Chinese, Japanese, Korean
|
||||
"chi", "zho", // ISO 639-2/B and T for Chinese
|
||||
"jpn", "kor", // ISO 639-2 for Japanese, Korean
|
||||
"chinese", "japanese", "korean", // Full names (rare but possible in some legacy systems)
|
||||
}
|
||||
|
||||
// CJK Region Codes
|
||||
// Checks for specific regions that imply CJK font usage (e.g., en_HK).
|
||||
var cjkRegions = map[string]bool{
|
||||
"cn": true, // China
|
||||
"tw": true, // Taiwan
|
||||
"hk": true, // Hong Kong
|
||||
"mo": true, // Macau
|
||||
"jp": true, // Japan
|
||||
"kr": true, // South Korea
|
||||
"kp": true, // North Korea
|
||||
"sg": true, // Singapore (Often uses CJK fonts)
|
||||
}
|
||||
|
||||
// Modern environments that should use narrow borders (1-width box chars)
|
||||
var modernEnvironments = map[string]bool{
|
||||
// Terminal programs
|
||||
"vscode": true, "visual studio code": true,
|
||||
"iterm.app": true, "iterm2": true,
|
||||
"windows terminal": true, "windowsterminal": true,
|
||||
"alacritty": true, "kitty": true,
|
||||
"hyper": true, "tabby": true, "terminus": true, "fluentterminal": true,
|
||||
"warp": true, "ghostty": true, "rio": true,
|
||||
"jetbrains-jediterm": true,
|
||||
|
||||
// Terminal types (TERM signatures)
|
||||
"xterm-kitty": true, "xterm-ghostty": true, "wezterm": true,
|
||||
}
|
||||
|
||||
var (
|
||||
eastAsianOnce sync.Once
|
||||
eastAsianVal bool
|
||||
|
||||
// Legacy override control
|
||||
// Renamed to cfgMu to avoid conflict with width.go's mu
|
||||
cfgMu sync.RWMutex
|
||||
forceLegacyEastAsian = false
|
||||
)
|
||||
|
||||
type Enviroment struct {
|
||||
GOOS string `json:"goos"`
|
||||
LC_ALL string `json:"lc_all"`
|
||||
LC_CTYPE string `json:"lc_ctype"`
|
||||
LANG string `json:"lang"`
|
||||
RUNEWIDTH_EASTASIAN string `json:"runewidth_eastasian"`
|
||||
TERM string `json:"term"`
|
||||
TERM_PROGRAM string `json:"term_program"`
|
||||
}
|
||||
|
||||
// State captures the calculated internal state.
|
||||
type State struct {
|
||||
NormalizedLocale string `json:"normalized_locale"`
|
||||
IsCJKLocale bool `json:"is_cjk_locale"`
|
||||
IsModernEnv bool `json:"is_modern_env"`
|
||||
LegacyOverrideMode bool `json:"legacy_override_mode"`
|
||||
}
|
||||
|
||||
// Detection aggregates all debug information regarding East Asian width detection.
|
||||
type Detection struct {
|
||||
AutoUseEastAsian bool `json:"auto_use_east_asian"`
|
||||
DetectionMode string `json:"detection_mode"`
|
||||
Raw Enviroment `json:"raw"`
|
||||
Derived State `json:"derived"`
|
||||
}
|
||||
|
||||
// EastAsianForceLegacy forces the detection logic to ignore modern environment checks.
|
||||
// It relies solely on Locale detection. This is useful for applications that need
|
||||
// strict backward compatibility.
|
||||
//
|
||||
// Note: This does NOT override RUNEWIDTH_EASTASIAN. User environment variables take precedence.
|
||||
// This should be called before the first table render.
|
||||
func EastAsianForceLegacy(force bool) {
|
||||
cfgMu.Lock()
|
||||
defer cfgMu.Unlock()
|
||||
forceLegacyEastAsian = force
|
||||
}
|
||||
|
||||
// EastAsianDetect checks the environment variables to determine if
|
||||
// East Asian width calculations should be enabled.
|
||||
func EastAsianDetect() bool {
|
||||
eastAsianOnce.Do(func() {
|
||||
eastAsianVal = detectEastAsian()
|
||||
})
|
||||
return eastAsianVal
|
||||
}
|
||||
|
||||
// EastAsianConservative is a stricter version that only defaults to Narrow
|
||||
// if the terminal is definitely known to be modern (e.g. VSCode, iTerm2).
|
||||
// It avoids heuristics like checking "xterm" in the TERM variable.
|
||||
func EastAsianConservative() bool {
|
||||
// Check overrides first
|
||||
if val, found := checkOverrides(); found {
|
||||
return val
|
||||
}
|
||||
|
||||
// Stricter modern environment detection
|
||||
if isConservativeModernEnvironment() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fall back to locale
|
||||
return checkLocale()
|
||||
}
|
||||
|
||||
// EastAsianMode returns the decision path used for the current environment.
|
||||
// Useful for debugging why a specific width was chosen.
|
||||
func EastAsianMode() string {
|
||||
// Check override
|
||||
if val, found := checkOverrides(); found {
|
||||
if val {
|
||||
return overwriteOn
|
||||
}
|
||||
return overwriteOff
|
||||
}
|
||||
|
||||
cfgMu.RLock()
|
||||
legacy := forceLegacyEastAsian
|
||||
cfgMu.RUnlock()
|
||||
|
||||
if legacy {
|
||||
if checkLocale() {
|
||||
return envCjk
|
||||
}
|
||||
return envAscii
|
||||
}
|
||||
|
||||
if isModernEnvironment() {
|
||||
return envModern
|
||||
}
|
||||
|
||||
if checkLocale() {
|
||||
return envCjk
|
||||
}
|
||||
|
||||
return envAscii
|
||||
}
|
||||
|
||||
// Debugging returns detailed information about the detection decision.
|
||||
// Useful for users to include in Github issues.
|
||||
func Debugging() Detection {
|
||||
locale := getNormalizedLocale()
|
||||
|
||||
cfgMu.RLock()
|
||||
legacy := forceLegacyEastAsian
|
||||
cfgMu.RUnlock()
|
||||
|
||||
return Detection{
|
||||
AutoUseEastAsian: EastAsianDetect(),
|
||||
DetectionMode: EastAsianMode(),
|
||||
Raw: Enviroment{
|
||||
GOOS: runtime.GOOS,
|
||||
LC_ALL: os.Getenv(EnvLCAll),
|
||||
LC_CTYPE: os.Getenv(EnvLCCtype),
|
||||
LANG: os.Getenv(EnvLang),
|
||||
RUNEWIDTH_EASTASIAN: os.Getenv(EnvRuneWidthEastAsian),
|
||||
TERM: os.Getenv(EnvTerm),
|
||||
TERM_PROGRAM: os.Getenv(EnvTermProgram),
|
||||
},
|
||||
Derived: State{
|
||||
NormalizedLocale: locale,
|
||||
IsCJKLocale: isCJKLocale(locale),
|
||||
IsModernEnv: isModernEnvironment(),
|
||||
LegacyOverrideMode: legacy,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// detectEastAsian evaluates the environment and locale settings to determine if East Asian width rules should apply.
|
||||
func detectEastAsian() bool {
|
||||
// User Override check (Highest Priority)
|
||||
if val, found := checkOverrides(); found {
|
||||
return val
|
||||
}
|
||||
|
||||
// Force Legacy Mode check
|
||||
cfgMu.RLock()
|
||||
isLegacy := forceLegacyEastAsian
|
||||
cfgMu.RUnlock()
|
||||
|
||||
if isLegacy {
|
||||
// Legacy mode ignores modern environment checks,
|
||||
// relying solely on locale.
|
||||
return checkLocale()
|
||||
}
|
||||
|
||||
// Modern Environment Detection
|
||||
// If modern, we assume Single Width (return false)
|
||||
if isModernEnvironment() {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Locale Fallback
|
||||
return checkLocale()
|
||||
}
|
||||
|
||||
// checkOverrides looks for RUNEWIDTH_EASTASIAN
|
||||
func checkOverrides() (bool, bool) {
|
||||
if rw := os.Getenv(EnvRuneWidthEastAsian); rw != "" {
|
||||
rw = strings.ToLower(rw)
|
||||
if rw == "0" || rw == "off" || rw == "false" || rw == "no" {
|
||||
return false, true
|
||||
}
|
||||
if rw == "1" || rw == "on" || rw == "true" || rw == "yes" {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// checkLocale performs the string analysis on LANG/LC_ALL
|
||||
func checkLocale() bool {
|
||||
locale := getNormalizedLocale()
|
||||
if locale == "" {
|
||||
return false
|
||||
}
|
||||
return isCJKLocale(locale)
|
||||
}
|
||||
|
||||
// isModernEnvironment performs comprehensive checks for modern terminal capabilities.
|
||||
func isModernEnvironment() bool {
|
||||
// Check TERM_PROGRAM (Most reliable)
|
||||
if termProg := os.Getenv(EnvTermProgram); termProg != "" {
|
||||
termProgLower := strings.ToLower(termProg)
|
||||
if modernEnvironments[termProgLower] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check WSL specific variable
|
||||
if os.Getenv(EnvTermProgramWsl) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows Specifics
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows Terminal
|
||||
if os.Getenv(EnvWTProfile) != "" {
|
||||
return true
|
||||
}
|
||||
// ConEmu/Cmder
|
||||
if os.Getenv(EnvConEmuANSI) == "ON" {
|
||||
return true
|
||||
}
|
||||
// Modern Windows console (Windows 10+) check via TERM
|
||||
if term := os.Getenv(EnvTerm); term != "" {
|
||||
termLower := strings.ToLower(term)
|
||||
if strings.Contains(termLower, "xterm") ||
|
||||
strings.Contains(termLower, "vt") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VTE-based terminals (GNOME Terminal, Tilix, etc.)
|
||||
if os.Getenv(EnvVTEVersion) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for Alacritty specifically
|
||||
if os.Getenv(EnvAlacritty) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check TERM for modern terminal signatures
|
||||
if term := os.Getenv(EnvTerm); term != "" {
|
||||
termLower := strings.ToLower(term)
|
||||
// Specific modern terminals often put their name in TERM
|
||||
if modernEnvironments[termLower] {
|
||||
return true
|
||||
}
|
||||
// Heuristics for standard modern-capable descriptors
|
||||
if strings.Contains(termLower, "xterm") && !strings.Contains(termLower, "xterm-mono") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(termLower, "screen") ||
|
||||
strings.Contains(termLower, "tmux") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isConservativeModernEnvironment performs strict checks only for known modern terminals.
|
||||
func isConservativeModernEnvironment() bool {
|
||||
termProg := strings.ToLower(os.Getenv(EnvTermProgram))
|
||||
|
||||
// Allow-list of definitely modern terminals
|
||||
switch termProg {
|
||||
case "vscode", "visual studio code":
|
||||
return true
|
||||
case "iterm.app", "iterm2":
|
||||
return true
|
||||
case "windows terminal", "windowsterminal":
|
||||
return true
|
||||
case "alacritty", "wezterm", "kitty", "ghostty":
|
||||
return true
|
||||
case "warp", "tabby", "hyper":
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows Terminal via specific Env
|
||||
if os.Getenv(EnvWTProfile) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isCJKLocale determines if a given locale string corresponds to a CJK (Chinese, Japanese, Korean) language or region.
|
||||
func isCJKLocale(locale string) bool {
|
||||
// Check Language Prefix
|
||||
for _, prefix := range cjkPrefixes {
|
||||
if strings.HasPrefix(locale, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check Regions
|
||||
parts := strings.Split(locale, "_")
|
||||
if len(parts) > 1 {
|
||||
for _, part := range parts[1:] {
|
||||
if cjkRegions[part] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getNormalizedLocale returns the normalized locale by inspecting environment variables LC_ALL, LC_CTYPE, and LANG.
|
||||
func getNormalizedLocale() string {
|
||||
var locale string
|
||||
if loc := os.Getenv(EnvLCAll); loc != "" {
|
||||
locale = loc
|
||||
} else if loc := os.Getenv(EnvLCCtype); loc != "" {
|
||||
locale = loc
|
||||
} else if loc := os.Getenv(EnvLang); loc != "" {
|
||||
locale = loc
|
||||
}
|
||||
|
||||
// Fast fail for empty or standard C/POSIX locales
|
||||
if locale == "" || locale == "C" || locale == "POSIX" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Strip encoding and modifiers
|
||||
if idx := strings.IndexByte(locale, '.'); idx != -1 {
|
||||
locale = locale[:idx]
|
||||
}
|
||||
if idx := strings.IndexByte(locale, '@'); idx != -1 {
|
||||
locale = locale[:idx]
|
||||
}
|
||||
|
||||
return strings.ToLower(locale)
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
package twwidth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Tab rune
|
||||
|
||||
const (
|
||||
TabWidthDefault = 8
|
||||
TabString Tab = '\t'
|
||||
)
|
||||
|
||||
// IsTab returns true if t equals the default tab.
|
||||
func (t Tab) IsTab() bool {
|
||||
return t == TabString
|
||||
}
|
||||
|
||||
func (t Tab) Byte() byte {
|
||||
return byte(t)
|
||||
}
|
||||
|
||||
func (t Tab) Rune() rune {
|
||||
return rune(t)
|
||||
}
|
||||
|
||||
func (t Tab) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// IsTab returns true if r is a tab rune.
|
||||
func IsTab(r rune) bool {
|
||||
return r == TabString.Rune()
|
||||
}
|
||||
|
||||
type Tabinal struct {
|
||||
once sync.Once
|
||||
width int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (t *Tabinal) String() string {
|
||||
return TabString.String()
|
||||
}
|
||||
|
||||
// Size returns the current tab width, default if unset.
|
||||
func (t *Tabinal) Size() int {
|
||||
t.once.Do(t.init)
|
||||
|
||||
t.mu.RLock()
|
||||
w := t.width
|
||||
t.mu.RUnlock()
|
||||
|
||||
if w <= 0 {
|
||||
return TabWidthDefault
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// SetWidth sets the tab width if valid (1–32).
|
||||
func (t *Tabinal) SetWidth(w int) {
|
||||
if w <= 0 || w > 32 {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.width = w
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *Tabinal) init() {
|
||||
w := t.detect()
|
||||
t.mu.Lock()
|
||||
t.width = w
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// detect determines tab width using env, editorconfig, project, or term.
|
||||
func (t *Tabinal) detect() int {
|
||||
if w := envInt("TABWIDTH"); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
if w := envInt("TS"); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
if w := envInt("VIM_TABSTOP"); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
if w := editorConfigTabWidth(); w > 0 {
|
||||
return w
|
||||
}
|
||||
if w := projectHeuristic(); w > 0 {
|
||||
return w
|
||||
}
|
||||
if w := termHeuristic(); w > 0 {
|
||||
return w
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func editorConfigTabWidth() int {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
for dir != "" && dir != "/" && dir != "." {
|
||||
path := filepath.Join(dir, ".editorconfig")
|
||||
if w := parseEditorConfig(path); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseEditorConfig reads tab_width or indent_size from a file.
|
||||
func parseEditorConfig(path string) int {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
inMatch := false
|
||||
globalWidth := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
pattern := line[1 : len(line)-1]
|
||||
inMatch = pattern == "*"
|
||||
|
||||
knownExts := []string{".go", ".py", ".js", ".ts", ".java", ".rs"}
|
||||
for _, ext := range knownExts {
|
||||
if strings.Contains(pattern, ext) {
|
||||
inMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !inMatch && globalWidth == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
|
||||
switch key {
|
||||
case "tab_width":
|
||||
if w, err := strconv.Atoi(val); err == nil && w > 0 {
|
||||
if inMatch {
|
||||
return clamp(w)
|
||||
}
|
||||
if globalWidth == 0 {
|
||||
globalWidth = w
|
||||
}
|
||||
}
|
||||
case "indent_size":
|
||||
if val == "tab" {
|
||||
continue
|
||||
}
|
||||
if w, err := strconv.Atoi(val); err == nil && w > 0 {
|
||||
if inMatch {
|
||||
return clamp(w)
|
||||
}
|
||||
if globalWidth == 0 {
|
||||
globalWidth = w
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return globalWidth
|
||||
}
|
||||
|
||||
// projectHeuristic returns 4 for known project types.
|
||||
func projectHeuristic() int {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
indicators := []string{
|
||||
"go.mod", "go.sum",
|
||||
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
||||
"setup.py", "pyproject.toml", "requirements.txt", "Pipfile",
|
||||
"pom.xml", "build.gradle", "build.gradle.kts",
|
||||
"Cargo.toml",
|
||||
"composer.json",
|
||||
}
|
||||
|
||||
for _, indicator := range indicators {
|
||||
if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil {
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
patterns := []string{"*.go", "*.py", "*.js", "*.ts", "*.java", "*.rs"}
|
||||
for _, pattern := range patterns {
|
||||
if matches, _ := filepath.Glob(filepath.Join(dir, pattern)); len(matches) > 0 {
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// termHeuristic returns a default width based on the TERM variable.
|
||||
func termHeuristic() int {
|
||||
termEnv := strings.ToLower(os.Getenv("TERM"))
|
||||
if termEnv == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
if strings.Contains(termEnv, "vt52") {
|
||||
return 2
|
||||
}
|
||||
|
||||
if strings.Contains(termEnv, "xterm") ||
|
||||
strings.Contains(termEnv, "screen") ||
|
||||
strings.Contains(termEnv, "tmux") ||
|
||||
strings.Contains(termEnv, "linux") ||
|
||||
strings.Contains(termEnv, "ansi") ||
|
||||
strings.Contains(termEnv, "rxvt") {
|
||||
return TabWidthDefault
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func clamp(w int) int {
|
||||
if w <= 0 {
|
||||
return 0
|
||||
}
|
||||
if w > 32 {
|
||||
return 32
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
var (
|
||||
globalTab *Tabinal
|
||||
globalTabOnce sync.Once
|
||||
)
|
||||
|
||||
// TabInstance returns the singleton Tabinal.
|
||||
func TabInstance() *Tabinal {
|
||||
globalTabOnce.Do(func() {
|
||||
globalTab = &Tabinal{}
|
||||
})
|
||||
return globalTab
|
||||
}
|
||||
|
||||
// TabWidth returns the detected global tab width.
|
||||
func TabWidth() int {
|
||||
return TabInstance().Size()
|
||||
}
|
||||
|
||||
// SetTabWidth sets the global tab width.
|
||||
func SetTabWidth(w int) {
|
||||
TabInstance().SetWidth(w)
|
||||
}
|
||||
|
||||
func envInt(k string) int {
|
||||
v := os.Getenv(k)
|
||||
w, _ := strconv.Atoi(v)
|
||||
return w
|
||||
}
|
||||
+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