Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: add xterm and rio color profiles #22

Merged
merged 5 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
feat: support terminfo databases and query tmux for color profile (#24)
This adds two new functions to detect the color profile based on the
terminal's terminfo database and tmux configuration. The `Terminfo`
function returns the color profile based on the terminal's terminfo
database. This relies on the Tc and RGB capabilities to determine if the
terminal supports TrueColor. The `Tmux` function returns the color
profile based on `tmux info` output. Tmux supports overriding the
terminal's terminfo database, so this function will return the color
profile based on `tmux info` output which is taken from the tmux
configurations.

Now, `Detect` will return the minimum of the color profile based on the
environment, terminfo, and tmux. This ensures that the color profile is
the most accurate based on the terminal's capabilities.

Environment variables take precedence over terminfo and tmux.
  • Loading branch information
aymanbagabas authored Dec 12, 2024
commit 51716908bcd03e5f1d925d68353e0887910b1727
219 changes: 138 additions & 81 deletions env.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package colorprofile

import (
"bytes"
"io"
"os/exec"
"runtime"
"strconv"
"strings"
Expand All @@ -25,10 +27,22 @@
// colors but not text decoration, i.e. bold, italic, faint, etc.
//
// See https://no-color.org/ and https://bixense.com/clicolors/ for more information.
func Detect(output io.Writer, environ []string) (p Profile) {
func Detect(output io.Writer, env []string) (p Profile) {
out, ok := output.(term.File)
isatty := ok && term.IsTerminal(out.Fd())
return colorProfile(isatty, environ)
environ := newEnviron(env)
term := environ.get("TERM")
envp := colorProfile(isatty, environ)
if envp == TrueColor || envNoColor(environ) {
// We already know we have TrueColor, or NO_COLOR is set.
return envp
}

tip := Terminfo(term)
tmuxp := tmux(environ)

// Color profile is the minimum of env, terminfo, and tmux.
return min(envp, min(tip, tmuxp))
}

// Env returns the color profile based on the terminal environment variables.
Expand All @@ -45,19 +59,14 @@
// colors but not text decoration, i.e. bold, italic, faint, etc.
//
// See https://no-color.org/ and https://bixense.com/clicolors/ for more information.
func Env(environ []string) (p Profile) {
return colorProfile(true, environ)
func Env(env []string) (p Profile) {
return colorProfile(true, newEnviron(env))
}

func colorProfile(isatty bool, environ []string) (p Profile) {
env := environMap(environ)
envProfile := envColorProfile(env)

// Start with the environment profile.
p = envProfile

term := strings.ToLower(env["TERM"])
isDumb := term == "dumb"
func colorProfile(isatty bool, env environ) (p Profile) {
envp := envColorProfile(env)
p = envp
isDumb := env.get("TERM") == "dumb"

Check failure on line 69 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (ubuntu-latest)

string `dumb` has 3 occurrences, make it a constant (goconst)

Check failure on line 69 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (ubuntu-latest)

string `dumb` has 3 occurrences, make it a constant (goconst)

Check failure on line 69 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (macos-latest)

string `dumb` has 3 occurrences, make it a constant (goconst)

Check failure on line 69 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (windows-latest)

string `dumb` has 3 occurrences, make it a constant (goconst)

Check failure on line 69 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (macos-latest)

string `dumb` has 3 occurrences, make it a constant (goconst)

Check failure on line 69 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (windows-latest)

string `dumb` has 3 occurrences, make it a constant (goconst)

// Check if the output is a terminal.
// Treat dumb terminals as NoTTY
Expand All @@ -76,8 +85,8 @@
if p > ANSI {
p = ANSI
}
if envProfile < p {
p = envProfile
if envp < p {
p = envp
}

return
Expand All @@ -94,35 +103,45 @@

// envNoColor returns true if the environment variables explicitly disable color output
// by setting NO_COLOR (https://no-color.org/).
func envNoColor(env map[string]string) bool {
noColor, _ := strconv.ParseBool(env["NO_COLOR"])
func envNoColor(env environ) bool {
noColor, _ := strconv.ParseBool(env.get("NO_COLOR"))
return noColor
}

func cliColor(env map[string]string) bool {
cliColor, _ := strconv.ParseBool(env["CLICOLOR"])
func cliColor(env environ) bool {
cliColor, _ := strconv.ParseBool(env.get("CLICOLOR"))
return cliColor
}

func cliColorForced(env map[string]string) bool {
cliColorForce, _ := strconv.ParseBool(env["CLICOLOR_FORCE"])
func cliColorForced(env environ) bool {
cliColorForce, _ := strconv.ParseBool(env.get("CLICOLOR_FORCE"))
return cliColorForce
}

func colorTerm(env map[string]string) bool {
colorTerm := strings.ToLower(env["COLORTERM"])
func colorTerm(env environ) bool {
colorTerm := strings.ToLower(env.get("COLORTERM"))
return colorTerm == "truecolor" || colorTerm == "24bit" ||
colorTerm == "yes" || colorTerm == "true"
}

// envColorProfile returns infers the color profile from the environment.
func envColorProfile(env map[string]string) (p Profile) {
term, ok := env["TERM"]
term = strings.ToLower(term)
func envColorProfile(env environ) (p Profile) {
term, ok := env.lookup("TERM")
if !ok || len(term) == 0 || term == "dumb" {
p = NoTTY
if runtime.GOOS == "windows" {
// Use Windows API to detect color profile. Windows Terminal and
// cmd.exe don't define $TERM.
if wcp, ok := windowsColorProfile(env); ok {
p = wcp
}
}
} else {
p = ANSI
}

parts := strings.Split(term, "-")
switch parts[0] {
case "", "dumb":
p = NoTTY
case "alacritty",
"contour",
"foot",
Expand All @@ -133,88 +152,105 @@
"wezterm":
return TrueColor
case "xterm":
for _, t := range []string{
// These terminals can be defined as xterm-TERMNAME
"ghostty",
"kitty",
} {
if strings.Contains(term, "-"+t) {
if len(parts) > 1 {
switch parts[1] {
case "ghostty", "kitty":
// These terminals can be defined as xterm-TERMNAME
return TrueColor
}
}
fallthrough
case "linux":
p = ANSI
case "screen":
p = ANSI256
default:
p = Ascii // Default to Ascii
}

if !ok && runtime.GOOS == "windows" {
// Use Windows API to detect color profile
wcp, _ := windowsColorProfile(env)
if wcp < p {
p = wcp
case "tmux", "screen":
if p > ANSI256 {
p = ANSI256
}
}

if isCloudShell, _ := strconv.ParseBool(env["GOOGLE_CLOUD_SHELL"]); isCloudShell {
p = TrueColor
return
if isCloudShell, _ := strconv.ParseBool(env.get("GOOGLE_CLOUD_SHELL")); isCloudShell {
return TrueColor
}

// GNU Screen doesn't support TrueColor
// Tmux doesn't support $COLORTERM
if colorTerm(env) && !strings.HasPrefix(term, "screen") && !strings.HasPrefix(term, "tmux") {
p = TrueColor
return
return TrueColor
}

if strings.Contains(term, "256color") && p > ANSI256 {
if strings.HasSuffix(term, "256color") && p > ANSI256 {
p = ANSI256
}
if strings.Contains(term, "color") && p > ANSI {
p = ANSI
}
if strings.Contains(term, "ansi") && p > ANSI {
p = ANSI

return
}

// Terminfo returns the color profile based on the terminal's terminfo
// database. This relies on the Tc and RGB capabilities to determine if the
// terminal supports TrueColor.
// If term is empty or "dumb", it returns NoTTY.
func Terminfo(term string) (p Profile) {
if len(term) == 0 || term == "dumb" {
return NoTTY
}

p = ANSI
ti, err := terminfo.Load(term)
if err == nil {
extbools := ti.ExtBoolCapsShort()
if _, ok := extbools["RGB"]; ok {
p = TrueColor
return
}
if err != nil {
return
}

if _, ok := extbools["Tc"]; ok {
p = TrueColor
return
}
extbools := ti.ExtBoolCapsShort()
if _, ok := extbools["Tc"]; ok {
return TrueColor
}

nums := ti.NumCapsShort()
if colors, ok := nums["colors"]; ok {
if colors >= 0x1000000 {
p = TrueColor
return
} else if colors >= 0x100 && p > ANSI256 {
p = ANSI256
} else if colors >= 0x10 && p > ANSI {
p = ANSI
}
if _, ok := extbools["RGB"]; ok {
return TrueColor
}

return
}

// Tmux returns the color profile based on `tmux info` output. Tmux supports
// overriding the terminal's color capabilities, so this function will return
// the color profile based on the tmux configuration.
func Tmux(env []string) Profile {
return tmux(newEnviron(env))
}

// tmux returns the color profile based on the tmux environment variables.
func tmux(env environ) (p Profile) {
if tmux, ok := env.lookup("TMUX"); !ok || len(tmux) == 0 {
// Not in tmux
return NoTTY
}

// Check if tmux has either Tc or RGB capabilities. Otherwise, return
// ANSI256.
p = ANSI256
cmd := exec.Command("tmux", "info")
out, err := cmd.Output()
if err != nil {
return
}

for _, line := range bytes.Split(out, []byte("\n")) {
if (bytes.Contains(line, []byte("Tc")) || bytes.Contains(line, []byte("RGB"))) &&
bytes.Contains(line, []byte("true")) {
return TrueColor
}
}

return
}

// environMap converts an environment slice to a map.
func environMap(environ []string) map[string]string {
// environ is a map of environment variables.
type environ map[string]string

// newEnviron returns a new environment map from a slice of environment
// variables.
func newEnviron(environ []string) environ {
m := make(map[string]string, len(environ))
for _, e := range environ {
parts := strings.SplitN(e, "=", 2)

Check failure on line 253 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (ubuntu-latest)

Magic number: 2, in <argument> detected (mnd)

Check failure on line 253 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (ubuntu-latest)

Magic number: 2, in <argument> detected (mnd)

Check failure on line 253 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (macos-latest)

Magic number: 2, in <argument> detected (mnd)

Check failure on line 253 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (windows-latest)

Magic number: 2, in <argument> detected (mnd)

Check failure on line 253 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (macos-latest)

Magic number: 2, in <argument> detected (mnd)

Check failure on line 253 in env.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (windows-latest)

Magic number: 2, in <argument> detected (mnd)
var value string
if len(parts) == 2 {
value = parts[1]
Expand All @@ -223,3 +259,24 @@
}
return m
}

// lookup returns the value of an environment variable and a boolean indicating
// if it exists.
func (e environ) lookup(key string) (string, bool) {
v, ok := e[key]
return v, ok
}

// get returns the value of an environment variable and empty string if it
// doesn't exist.
func (e environ) get(key string) string {
v, _ := e.lookup(key)
return v
}

func min[T ~byte | ~int](a, b T) T {
if a < b {
return a
}
return b
}
12 changes: 9 additions & 3 deletions env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,14 @@ var cases = []struct {
"TERM=dumb",
"CLICOLOR_FORCE=1",
},
expected: ANSI,
expected: func() Profile {
if runtime.GOOS == "windows" {
// Windows Terminal supports TrueColor
return TrueColor
} else {
return ANSI
}
}(),
},
{
name: "dumb term, CLICOLOR=1",
Expand Down Expand Up @@ -180,8 +187,7 @@ var cases = []struct {
"TERM=tmux",
"COLORTERM=truecolor",
},
// TODO: Should this be ANSI256?
expected: Ascii,
expected: ANSI256,
},
{
name: "tmux 256color",
Expand Down
Loading