package ui import ( "bytes" "fmt" "image/color" "strconv" "strings" "sync" "time" "github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2/terminfo" "github.com/rivo/tview" ) // terminalInfo is what the terminal said about itself at startup. type terminalInfo struct { graphics bool // the Kitty graphics protocol is supported bg color.RGBA // its background color, if hasBG hasBG bool fg color.RGBA // its text color, if hasFG hasFG bool } var ( terminalOnce sync.Once terminalResult terminalInfo ) // terminalAnswers asks the terminal once (see probeTerminal) and remembers the // answers. The first call must happen before the screen is set up, which owns // the terminal afterwards; New makes it. It is a variable so tests can replace it. var terminalAnswers = func() terminalInfo { terminalOnce.Do(func() { terminalResult = probeTerminal(300 * time.Millisecond) }) return terminalResult } // parseTerminalAnswers reads the answers to probeTerminal's questions. func parseTerminalAnswers(got []byte) (info terminalInfo) { info.graphics = bytes.Contains(got, []byte("_Gi=31;OK")) info.fg, info.hasFG = parseColorAnswer(got, "10") info.bg, info.hasBG = parseColorAnswer(got, "11") return info } // parseColorAnswer finds the answer to an OSC 10 (text) or 11 (background) // color query: ESC ] 11 ; rgb:RRRR/GGGG/BBBB, ended by ST or BEL. Each // component has 1 to 4 hex digits. func parseColorAnswer(got []byte, osc string) (color.RGBA, bool) { prefix := []byte("\x1b]" + osc + ";rgb:") i := bytes.Index(got, prefix) if i < 0 { return color.RGBA{}, false } rest := got[i+len(prefix):] if end := bytes.IndexAny(rest, "\x1b\a"); end >= 0 { rest = rest[:end] } parts := strings.Split(string(rest), "/") if len(parts) != 3 { return color.RGBA{}, false } var c [3]uint8 for j, p := range parts { v, err := strconv.ParseUint(p, 16, 16) if err != nil || len(p) < 1 || len(p) > 4 { return color.RGBA{}, false } c[j] = uint8(v * 255 / (1<<(4*len(p)) - 1)) } return color.RGBA{c[0], c[1], c[2], 255}, true } // activeTabTag colors the name of the open view in the tab bar. var activeTabTag = "[black:white]" // useTerminalBackground makes the terminal's own background color the // background of every pane, instead of color 0 of the palette ("black"), which // color themes often set to a grey that differs from their background (and // on which grey text almost disappears; in light themes color 0 is a dark // grey). On a light background the text, borders and fields switch to the // terminal's text color and shades between the two, since tview's defaults // (white text, blue fields) are made for dark ones. The accent colors (yellow, // green, red, grey) stay palette colors, which a theme picks to suit its // background. Needs true color, else the exact colors cannot be drawn. Must // run before any widget is created, they copy the styles. It returns a line // for the log that says what was decided and why. func useTerminalBackground(info terminalInfo, env func(string) string) string { if !info.hasBG { return "Background: palette color 0 (the terminal did not report its background color)" } hex := hexColor(info.bg) if !hasTrueColor(env) { return "Background: palette color 0 (the terminal's " + hex + " needs 24-bit color, which " + env("TERM") + " does not have; COLORTERM=truecolor enables it)" } st := &tview.Styles st.PrimitiveBackgroundColor = rgb(info.bg) if luminance(info.bg) <= 0.5 { return "Background: the terminal's own color " + hex } fg := color.RGBA{0, 0, 0, 255} if info.hasFG && luminance(info.fg) < luminance(info.bg) { fg = info.fg } st.PrimaryTextColor, st.BorderColor, st.TitleColor, st.GraphicsColor = rgb(fg), rgb(fg), rgb(fg), rgb(fg) st.ContrastBackgroundColor = rgb(mix(info.bg, fg, 0.12)) // input fields, buttons st.ContrastSecondaryTextColor = rgb(mix(info.bg, fg, 0.55)) // placeholders, disabled buttons st.InverseTextColor = rgb(info.bg) // a pressed button: background on text color activeTabTag = "[" + hexColor(info.bg) + ":" + hexColor(fg) + "]" return "Background: the terminal's own color " + hex + ", light, with its text color " + hexColor(fg) } func rgb(c color.RGBA) tcell.Color { return tcell.NewRGBColor(int32(c.R), int32(c.G), int32(c.B)) } func hexColor(c color.RGBA) string { return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B) } // mix returns the color a fraction t of the way from a to b. func mix(a, b color.RGBA, t float64) color.RGBA { m := func(x, y uint8) uint8 { return uint8(float64(x) + (float64(y)-float64(x))*t + 0.5) } return color.RGBA{m(a.R, b.R), m(a.G, b.G), m(a.B, b.B), 255} } // hasTrueColor reports whether tcell will draw 24-bit colors, by the same // rules: TCELL_TRUECOLOR, COLORTERM, or a terminal description with direct // color (e.g. xterm-ghostty and xterm-kitty, also without COLORTERM). func hasTrueColor(env func(string) string) bool { switch env("TCELL_TRUECOLOR") { case "": case "disable": return false default: return true } switch strings.ToLower(env("COLORTERM")) { case "truecolor", "24bit", "24-bit": return true } if strings.HasSuffix(env("TERM"), "-direct") { return true } ti, err := terminfo.LookupTerminfo(env("TERM")) return err == nil && (ti.TrueColor || ti.SetFgBgRGB != "") } // luminance is the relative brightness of c, from 0 (black) to 1 (white). func luminance(c color.RGBA) float64 { return (0.2126*float64(c.R) + 0.7152*float64(c.G) + 0.0722*float64(c.B)) / 255 }