From 0d56e62c01a96d469d66d4a9eb602979de10faa8 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 12 Sep 2024 16:24:50 -0400 Subject: [PATCH] feat: add color profiles and detect the terminal profile on program run This defines `tea.Profile` and its consts. A color profile can downgrade `color.Color` based on terminal colors support. When the program starts, it will detect the color profile from the output and the program environment variables before sending the profile to the program model. This also adds a `startup` example that demonstrates messages a program receive during startup. --- env.go | 178 ++++++++++++++++ examples/startup/main.go | 39 ++++ profile.go | 424 +++++++++++++++++++++++++++++++++++++++ tea.go | 23 +++ termcap.go | 21 ++ 5 files changed, 685 insertions(+) create mode 100644 env.go create mode 100644 examples/startup/main.go create mode 100644 profile.go diff --git a/env.go b/env.go new file mode 100644 index 0000000000..dd512cab78 --- /dev/null +++ b/env.go @@ -0,0 +1,178 @@ +package tea + +import ( + "io" + "strconv" + "strings" + + "github.com/charmbracelet/x/term" + "github.com/xo/terminfo" +) + +// detectColorProfile returns the color profile based on the terminal output, +// and environment variables. This respects NO_COLOR, CLICOLOR, and +// CLICOLOR_FORCE environment variables. +// +// The rules as follows: +// - TERM=dumb is always treated as NoTTY unless CLICOLOR_FORCE=1 is set. +// - If COLORTERM=truecolor, and the profile is not NoTTY, it gest upgraded to TrueColor. +// - Using any 256 color terminal (e.g. TERM=xterm-256color) will set the profile to ANSI256. +// - Using any color terminal (e.g. TERM=xterm-color) will set the profile to ANSI. +// - Using CLICOLOR=1 without TERM defined should be treated as ANSI if the +// output is a terminal. +// - NO_COLOR takes precedence over CLICOLOR/CLICOLOR_FORCE, and will disable +// 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 detectColorProfile(output io.Writer, environ []string) (p Profile) { + out, ok := output.(term.File) + isatty := ok && term.IsTerminal(out.Fd()) + return colorProfile(isatty, environ) +} + +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" + + // Check if the output is a terminal. + // Treat dumb terminals as NoTTY + if !isatty || isDumb { + p = NoTTY + } + + if envNoColor(env) { + if p < Ascii { + p = Ascii + } + return + } + + if cliColorForced(env) { + if p > ANSI { + p = ANSI + } + if envProfile < p { + p = envProfile + } + + return + } + + if cliColor(env) { + if isatty && !isDumb && p > ANSI { + p = ANSI + } + } + + return p +} + +// 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"]) + return noColor +} + +func cliColor(env map[string]string) bool { + cliColor, _ := strconv.ParseBool(env["CLICOLOR"]) + return cliColor +} + +func cliColorForced(env map[string]string) bool { + cliColorForce, _ := strconv.ParseBool(env["CLICOLOR_FORCE"]) + return cliColorForce +} + +func colorTerm(env map[string]string) bool { + colorTerm := strings.ToLower(env["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) { + p = Ascii // Default to Ascii + if isCloudShell, _ := strconv.ParseBool(env["GOOGLE_CLOUD_SHELL"]); isCloudShell { + p = TrueColor + return + } + + term := strings.ToLower(env["TERM"]) + switch term { + case "", "dumb": + p = NoTTY + } + + if colorTerm(env) { + p = TrueColor + return + } + + switch term { + case "alacritty", "contour", "wezterm", "xterm-ghostty", "xterm-kitty": + p = TrueColor + return + case "linux": + if p > ANSI { + p = ANSI + } + } + + if strings.Contains(term, "256color") && p > ANSI256 { + p = ANSI256 + } + if strings.Contains(term, "color") && p > ANSI { + p = ANSI + } + if strings.Contains(term, "ansi") && p > ANSI { + p = ANSI + } + + if ti, err := terminfo.Load(term); err == nil { + extbools := ti.ExtBoolCapsShort() + if _, ok := extbools["RGB"]; ok { + p = TrueColor + return + } + + if _, ok := extbools["Tc"]; ok { + p = TrueColor + return + } + + 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 + } + } + } + + return +} + +// environMap converts an environment slice to a map. +func environMap(environ []string) map[string]string { + m := make(map[string]string, len(environ)) + for _, e := range environ { + parts := strings.SplitN(e, "=", 2) + var value string + if len(parts) == 2 { + value = parts[1] + } + m[parts[0]] = value + } + return m +} diff --git a/examples/startup/main.go b/examples/startup/main.go new file mode 100644 index 0000000000..2c8b13e9f1 --- /dev/null +++ b/examples/startup/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + + tea "github.com/charmbracelet/bubbletea" +) + +type model struct{} + +var _ tea.Model = model{} + +// Init implements tea.Model. +func (m model) Init() (tea.Model, tea.Cmd) { + return m, tea.TerminalVersion +} + +// Update implements tea.Model. +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m, tea.Quit + case tea.BackgroundColorMsg, tea.ForegroundColorMsg, tea.CursorColorMsg, tea.TerminalVersionMsg, tea.ColorProfileMsg: + return m, tea.Printf("Received a terminal startup message: %T: %s", msg, msg) + } + return m, nil +} + +// View implements tea.Model. +func (m model) View() string { + return "Press any key to exit." +} + +func main() { + p := tea.NewProgram(model{}) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/profile.go b/profile.go new file mode 100644 index 0000000000..1d2b1734a3 --- /dev/null +++ b/profile.go @@ -0,0 +1,424 @@ +package tea + +import ( + "image/color" + "math" + + "github.com/charmbracelet/x/ansi" + "github.com/lucasb-eyer/go-colorful" +) + +// ColorProfileMsg is a message that carries the program's color profile. +// This message is sent when the program starts. +type ColorProfileMsg struct { + Profile +} + +// colorProfileMsg is an internal message that sends the program's color profile. +type colorProfileMsg struct{} + +// ColorProfile is a command that returns the program's color profile. +func ColorProfile() Msg { + return colorProfileMsg{} +} + +// setColorProfileMsg is an internal message that sets the program's color +// profile. +type setColorProfileMsg Profile + +// SetColorProfile is a command that sets the program's color profile. +func SetColorProfile(p Profile) Cmd { + return func() Msg { + return setColorProfileMsg(p) + } +} + +// Profile is a color profile: NoTTY, Ascii, ANSI, ANSI256, or TrueColor. +type Profile byte + +const ( + // TrueColor, 24-bit color profile + TrueColor Profile = iota + // ANSI256, 8-bit color profile + ANSI256 + // ANSI, 4-bit color profile + ANSI + // Ascii, uncolored profile + Ascii // nolint: revive + // NoTTY, not a terminal profile + NoTTY +) + +// String returns the string representation of a Profile. +func (p Profile) String() string { + switch p { + case TrueColor: + return "TrueColor" + case ANSI256: + return "ANSI256" + case ANSI: + return "ANSI" + case Ascii: + return "Ascii" + case NoTTY: + return "NoTTY" + } + return "Unknown" +} + +// Convert transforms a given Color to a Color supported within the Profile. +func (p Profile) Convert(c color.Color) color.Color { + if p >= Ascii { + return nil + } + + switch c := c.(type) { + case ansi.BasicColor: + return c + + case ansi.ExtendedColor: + if p == ANSI { + return ansi256ToANSIColor(c) + } + return c + + case ansi.TrueColor, color.Color: + h, ok := colorful.MakeColor(c) + if !ok { + return nil + } + if p != TrueColor { + ac := hexToANSI256Color(h) + if p == ANSI { + return ansi256ToANSIColor(ac) + } + return ac + } + return c + } + + return c +} + +func hexToANSI256Color(c colorful.Color) ansi.ExtendedColor { + v2ci := func(v float64) int { + if v < 48 { + return 0 + } + if v < 115 { + return 1 + } + return int((v - 35) / 40) + } + + // Calculate the nearest 0-based color index at 16..231 + r := v2ci(c.R * 255.0) // 0..5 each + g := v2ci(c.G * 255.0) + b := v2ci(c.B * 255.0) + ci := 36*r + 6*g + b /* 0..215 */ + + // Calculate the represented colors back from the index + i2cv := [6]int{0, 0x5f, 0x87, 0xaf, 0xd7, 0xff} + cr := i2cv[r] // r/g/b, 0..255 each + cg := i2cv[g] + cb := i2cv[b] + + // Calculate the nearest 0-based gray index at 232..255 + var grayIdx int + average := (r + g + b) / 3 + if average > 238 { + grayIdx = 23 + } else { + grayIdx = (average - 3) / 10 // 0..23 + } + gv := 8 + 10*grayIdx // same value for r/g/b, 0..255 + + // Return the one which is nearer to the original input rgb value + c2 := colorful.Color{R: float64(cr) / 255.0, G: float64(cg) / 255.0, B: float64(cb) / 255.0} + g2 := colorful.Color{R: float64(gv) / 255.0, G: float64(gv) / 255.0, B: float64(gv) / 255.0} + colorDist := c.DistanceHSLuv(c2) + grayDist := c.DistanceHSLuv(g2) + + if colorDist <= grayDist { + return ansi.ExtendedColor(16 + ci) //nolint:gosec + } + return ansi.ExtendedColor(232 + grayIdx) //nolint:gosec +} + +func ansi256ToANSIColor(c ansi.ExtendedColor) ansi.BasicColor { + var r int + md := math.MaxFloat64 + + h, _ := colorful.Hex(ansiHex[c]) + for i := 0; i <= 15; i++ { + hb, _ := colorful.Hex(ansiHex[i]) + d := h.DistanceHSLuv(hb) + + if d < md { + md = d + r = i + } + } + + return ansi.BasicColor(r) //nolint:gosec +} + +// RGB values of ANSI colors (0-255). +var ansiHex = []string{ + "#000000", + "#800000", + "#008000", + "#808000", + "#000080", + "#800080", + "#008080", + "#c0c0c0", + "#808080", + "#ff0000", + "#00ff00", + "#ffff00", + "#0000ff", + "#ff00ff", + "#00ffff", + "#ffffff", + "#000000", + "#00005f", + "#000087", + "#0000af", + "#0000d7", + "#0000ff", + "#005f00", + "#005f5f", + "#005f87", + "#005faf", + "#005fd7", + "#005fff", + "#008700", + "#00875f", + "#008787", + "#0087af", + "#0087d7", + "#0087ff", + "#00af00", + "#00af5f", + "#00af87", + "#00afaf", + "#00afd7", + "#00afff", + "#00d700", + "#00d75f", + "#00d787", + "#00d7af", + "#00d7d7", + "#00d7ff", + "#00ff00", + "#00ff5f", + "#00ff87", + "#00ffaf", + "#00ffd7", + "#00ffff", + "#5f0000", + "#5f005f", + "#5f0087", + "#5f00af", + "#5f00d7", + "#5f00ff", + "#5f5f00", + "#5f5f5f", + "#5f5f87", + "#5f5faf", + "#5f5fd7", + "#5f5fff", + "#5f8700", + "#5f875f", + "#5f8787", + "#5f87af", + "#5f87d7", + "#5f87ff", + "#5faf00", + "#5faf5f", + "#5faf87", + "#5fafaf", + "#5fafd7", + "#5fafff", + "#5fd700", + "#5fd75f", + "#5fd787", + "#5fd7af", + "#5fd7d7", + "#5fd7ff", + "#5fff00", + "#5fff5f", + "#5fff87", + "#5fffaf", + "#5fffd7", + "#5fffff", + "#870000", + "#87005f", + "#870087", + "#8700af", + "#8700d7", + "#8700ff", + "#875f00", + "#875f5f", + "#875f87", + "#875faf", + "#875fd7", + "#875fff", + "#878700", + "#87875f", + "#878787", + "#8787af", + "#8787d7", + "#8787ff", + "#87af00", + "#87af5f", + "#87af87", + "#87afaf", + "#87afd7", + "#87afff", + "#87d700", + "#87d75f", + "#87d787", + "#87d7af", + "#87d7d7", + "#87d7ff", + "#87ff00", + "#87ff5f", + "#87ff87", + "#87ffaf", + "#87ffd7", + "#87ffff", + "#af0000", + "#af005f", + "#af0087", + "#af00af", + "#af00d7", + "#af00ff", + "#af5f00", + "#af5f5f", + "#af5f87", + "#af5faf", + "#af5fd7", + "#af5fff", + "#af8700", + "#af875f", + "#af8787", + "#af87af", + "#af87d7", + "#af87ff", + "#afaf00", + "#afaf5f", + "#afaf87", + "#afafaf", + "#afafd7", + "#afafff", + "#afd700", + "#afd75f", + "#afd787", + "#afd7af", + "#afd7d7", + "#afd7ff", + "#afff00", + "#afff5f", + "#afff87", + "#afffaf", + "#afffd7", + "#afffff", + "#d70000", + "#d7005f", + "#d70087", + "#d700af", + "#d700d7", + "#d700ff", + "#d75f00", + "#d75f5f", + "#d75f87", + "#d75faf", + "#d75fd7", + "#d75fff", + "#d78700", + "#d7875f", + "#d78787", + "#d787af", + "#d787d7", + "#d787ff", + "#d7af00", + "#d7af5f", + "#d7af87", + "#d7afaf", + "#d7afd7", + "#d7afff", + "#d7d700", + "#d7d75f", + "#d7d787", + "#d7d7af", + "#d7d7d7", + "#d7d7ff", + "#d7ff00", + "#d7ff5f", + "#d7ff87", + "#d7ffaf", + "#d7ffd7", + "#d7ffff", + "#ff0000", + "#ff005f", + "#ff0087", + "#ff00af", + "#ff00d7", + "#ff00ff", + "#ff5f00", + "#ff5f5f", + "#ff5f87", + "#ff5faf", + "#ff5fd7", + "#ff5fff", + "#ff8700", + "#ff875f", + "#ff8787", + "#ff87af", + "#ff87d7", + "#ff87ff", + "#ffaf00", + "#ffaf5f", + "#ffaf87", + "#ffafaf", + "#ffafd7", + "#ffafff", + "#ffd700", + "#ffd75f", + "#ffd787", + "#ffd7af", + "#ffd7d7", + "#ffd7ff", + "#ffff00", + "#ffff5f", + "#ffff87", + "#ffffaf", + "#ffffd7", + "#ffffff", + "#080808", + "#121212", + "#1c1c1c", + "#262626", + "#303030", + "#3a3a3a", + "#444444", + "#4e4e4e", + "#585858", + "#626262", + "#6c6c6c", + "#767676", + "#808080", + "#8a8a8a", + "#949494", + "#9e9e9e", + "#a8a8a8", + "#b2b2b2", + "#bcbcbc", + "#c6c6c6", + "#d0d0d0", + "#dadada", + "#e4e4e4", + "#eeeeee", +} diff --git a/tea.go b/tea.go index 9b9d297e2e..5acc234a63 100644 --- a/tea.go +++ b/tea.go @@ -219,6 +219,9 @@ type Program struct { // is paused. This saves the terminal colors state so they can be restored // when the program is resumed. setBg, setFg, setCc color.Color + + // profile stores the color profile of the terminal. + profile Profile } // Quit is a special command that tells the Bubble Tea program to exit. @@ -421,6 +424,19 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { p.cc = msg } + case CapabilityMsg: + switch string(msg) { + case "RGB", "Tc": + p.profile = TrueColor + go p.Send(ColorProfileMsg{p.profile}) + } + + case colorProfileMsg: + go p.Send(ColorProfileMsg{p.profile}) + + case setColorProfileMsg: + p.profile = Profile(msg) + case modeReportMsg: switch msg.Mode { case graphemeClustering: @@ -655,6 +671,13 @@ func (p *Program) Run() (Model, error) { return p.initialModel, err } + // Get the color profile. + p.profile = detectColorProfile(p.output.Writer(), p.environ) + // Send the color profile msg to the program. + // TODO: Consider querying XTGETTCAP "RGB" and "Tc" to determine the color + // profile when not "TrueColor". + go p.Send(ColorProfileMsg{p.profile}) + // If no renderer is set use the standard one. var output io.Writer output = p.output diff --git a/termcap.go b/termcap.go index 416f865b1f..2ae35fc513 100644 --- a/termcap.go +++ b/termcap.go @@ -12,6 +12,27 @@ type requestCapabilityMsg string // RequestCapability is a command that requests the terminal to send its // Termcap/Terminfo response for the given capability. +// +// Bubble Tea recognizes the following capabilities and will use them to set +// the program's color profile: +// - "RGB" Xterm direct color +// - "Tc" True color support +// +// Note: that some terminal's like Apple's Terminal.app do not support this and +// will send the wrong response to the terminal breaking the program's output. +// +// When the Bubble Tea advertises a non-TrueColor profile, you can use this +// command to query the terminal for its color capabilities. Example: +// +// switch msg := msg.(type) { +// case tea.ColorProfileMsg: +// if msg.Profile != tea.TrueColor { +// return m, tea.Batch( +// tea.RequestCapability("RGB"), +// tea.RequestCapability("Tc"), +// ) +// } +// } func RequestCapability(s string) Cmd { return func() Msg { return requestCapabilityMsg(s)