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)