425 lines
11 KiB
Go
425 lines
11 KiB
Go
/*
|
|
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)
|
|
}
|