mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-27 17:56:37 -04:00
Detect whether the terminal is dark or light
Lazygit doesn't know whether the terminal it runs in has a dark or a light background. The colors of authors, for example, have to suit one or the other, and are too pale to read on a light background. Ask the terminal. Many terminals answer CSI ? 996 n with whether they are dark or light, and send the same report again whenever that changes while mode 2031 is on. Terminals differ in what they base this on, though. kitty goes by its background color, but Ghostty goes by the dark or light mode of the operating system, even with a theme that doesn't follow it. So also ask for the background color with OSC 11. If the terminal answers that, the background decides, and a report only makes us ask for the background again. Some terminals answer OSC 11 but send no reports; ask these for the background again whenever the terminal gains focus. Turn mode 2031 off whenever lazygit hands the terminal to another program, so that the program doesn't receive the reports as typed text. For the same reason, first wait for the answers to any queries that are still outstanding, but for no longer than half a second. Locally, the answers take a few milliseconds at most, but over ssh they take a network round trip, and focusing the terminal and then pressing a key that starts an editor fits into that. Leave out the terminals that tcell doesn't send its own queries to, except for Terminal.app and WezTerm; these are only asked for the background color. This doesn't make startup any slower. tcell sends its own queries in Screen.Init and waits for the terminal to answer them. Ours go out just before, so their answers arrive before tcell stops waiting, and the color scheme is known before the first layout. tcell has no support for any of this, and drops the answers when it parses its input. Instead of adding support to tcell, wrap the tty that tcell reads from and watch the input as it goes by. For now, lazygit only writes the color scheme to the debug log. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1b39e38ae6
commit
65efd3dde5
@@ -0,0 +1,497 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
// ColorScheme says whether the terminal shows light text on a dark background,
|
||||
// or dark text on a light one.
|
||||
type ColorScheme int
|
||||
|
||||
const (
|
||||
ColorSchemeUnknown ColorScheme = iota
|
||||
ColorSchemeDark
|
||||
ColorSchemeLight
|
||||
)
|
||||
|
||||
func (self ColorScheme) String() string {
|
||||
switch self {
|
||||
case ColorSchemeDark:
|
||||
return "dark"
|
||||
case ColorSchemeLight:
|
||||
return "light"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DetectedColorScheme is what the terminal has told us about its colors.
|
||||
type DetectedColorScheme struct {
|
||||
ColorScheme ColorScheme
|
||||
// The background color that ColorScheme was derived from, as #rrggbb. It is
|
||||
// empty if the terminal didn't tell us its background color; ColorScheme is
|
||||
// then whatever the terminal said about itself, if anything.
|
||||
Background string
|
||||
}
|
||||
|
||||
func (self DetectedColorScheme) String() string {
|
||||
if self.Background != "" {
|
||||
return fmt.Sprintf("%s (background %s)", self.ColorScheme, self.Background)
|
||||
}
|
||||
if self.ColorScheme != ColorSchemeUnknown {
|
||||
return fmt.Sprintf("%s (reported by the terminal)", self.ColorScheme)
|
||||
}
|
||||
return self.ColorScheme.String()
|
||||
}
|
||||
|
||||
const (
|
||||
// Makes the terminal send a color scheme report whenever its colors change
|
||||
enableColorSchemeReports = "\x1b[?2031h"
|
||||
disableColorSchemeReports = "\x1b[?2031l"
|
||||
// Asks for a color scheme report: CSI ? 997 ; 1 n for dark, 2 for light
|
||||
requestColorScheme = "\x1b[?996n"
|
||||
// Asks for the background color: OSC 11 ; rgb:RRRR/GGGG/BBBB, ended by BEL
|
||||
// or ST. Ending the request with BEL makes more terminals reply.
|
||||
requestBackgroundColor = "\x1b]11;?\a"
|
||||
)
|
||||
|
||||
// colorSchemeTty is a tcell.Tty that finds out the terminal's color scheme.
|
||||
//
|
||||
// Some terminals say whether they are dark or light, and can report it again
|
||||
// whenever that changes. What they base this on varies, though: some go by the
|
||||
// terminal's own colors, others by the dark or light mode of the operating
|
||||
// system, whether or not the terminal follows it. So we also ask for the
|
||||
// background color, and when the terminal tells us that, it decides; a color
|
||||
// scheme report is then only a sign that the background may have changed.
|
||||
//
|
||||
// The queries go out when tcell starts the tty, which is before tcell sends its
|
||||
// own queries during Screen.Init and waits for their answer. Terminals answer in
|
||||
// order, so the answers to ours have arrived by the time Init returns, and we
|
||||
// know the color scheme before drawing anything without waiting for it.
|
||||
//
|
||||
// tcell doesn't understand the answers and drops them, so all we need to do is
|
||||
// watch for them in the input as it goes by.
|
||||
type colorSchemeTty struct {
|
||||
tcell.Tty
|
||||
|
||||
// What we send when the tty starts
|
||||
queries string
|
||||
|
||||
// Guards all of the fields below, and serializes our writes to the terminal
|
||||
// with tcell's
|
||||
mutex sync.Mutex
|
||||
|
||||
started bool
|
||||
|
||||
background colorful.Color
|
||||
// Whether the terminal has told us its background color
|
||||
haveBackground bool
|
||||
// Whether we have asked for the background color and are waiting for the
|
||||
// answer
|
||||
backgroundRequested bool
|
||||
// The color scheme that the terminal last reported for itself
|
||||
reported ColorScheme
|
||||
// Whether we have asked for a color scheme report and are waiting for it
|
||||
colorSchemeRequested bool
|
||||
// Closed once no answer that waitForReplies waits for is outstanding
|
||||
repliesArrived chan struct{}
|
||||
|
||||
notified DetectedColorScheme
|
||||
onChange func(DetectedColorScheme)
|
||||
|
||||
// Only used by Read, which tcell never calls concurrently with itself
|
||||
scanner terminalReplyScanner
|
||||
}
|
||||
|
||||
var _ tcell.Tty = &colorSchemeTty{}
|
||||
|
||||
func newColorSchemeTty(tty tcell.Tty) *colorSchemeTty {
|
||||
return &colorSchemeTty{
|
||||
Tty: tty,
|
||||
queries: colorSchemeQueries(
|
||||
os.Getenv("TERM"),
|
||||
os.Getenv("TERM_PROGRAM"),
|
||||
os.Getenv("TCELL_NEGOTIATE"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// colorSchemeQueries returns what to ask the terminal. It leaves out the
|
||||
// terminals that tcell doesn't send its own queries to (see
|
||||
// applyKnownTerminalProfile and the legacy terminals in tScreen.Init), except
|
||||
// for those that are known to answer a request for the background color.
|
||||
func colorSchemeQueries(term string, termProgram string, tcellNegotiate string) string {
|
||||
if tcellNegotiate == "disable" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if term == "st" || strings.HasPrefix(term, "st-") ||
|
||||
strings.HasPrefix(term, "vt") || strings.Contains(term, "ansi") ||
|
||||
term == "linux" || term == "sun" || term == "sun-color" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if termProgram == "Apple_Terminal" || termProgram == "WezTerm" {
|
||||
return requestBackgroundColor
|
||||
}
|
||||
|
||||
return enableColorSchemeReports + requestColorScheme + requestBackgroundColor
|
||||
}
|
||||
|
||||
// subscribe sets a function to call whenever the detected color scheme changes,
|
||||
// and returns the one detected so far. The function is called on the goroutine
|
||||
// that reads from the tty, so it must not block.
|
||||
func (self *colorSchemeTty) subscribe(onChange func(DetectedColorScheme)) DetectedColorScheme {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.onChange = onChange
|
||||
self.notified = self.detected()
|
||||
return self.notified
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Start() error {
|
||||
if err := self.Tty.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.started = true
|
||||
self.writeLocked(self.queries)
|
||||
if strings.Contains(self.queries, requestBackgroundColor) {
|
||||
self.backgroundRequested = true
|
||||
}
|
||||
if strings.Contains(self.queries, requestColorScheme) {
|
||||
self.colorSchemeRequested = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForReplies waits until the terminal has answered what we asked it, but
|
||||
// no longer than the timeout. Call it before handing the terminal to another
|
||||
// program, or the answers would reach that program as if they were typed. It
|
||||
// only waits for the answers that the terminal has given before, so a terminal
|
||||
// that doesn't answer at all costs no time.
|
||||
func (self *colorSchemeTty) waitForReplies(timeout time.Duration) {
|
||||
self.mutex.Lock()
|
||||
if !self.awaitingRepliesLocked() {
|
||||
self.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
if self.repliesArrived == nil {
|
||||
self.repliesArrived = make(chan struct{})
|
||||
}
|
||||
repliesArrived := self.repliesArrived
|
||||
self.mutex.Unlock()
|
||||
|
||||
select {
|
||||
case <-repliesArrived:
|
||||
case <-time.After(timeout):
|
||||
}
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) awaitingRepliesLocked() bool {
|
||||
return (self.backgroundRequested && self.haveBackground) ||
|
||||
(self.colorSchemeRequested && self.reported != ColorSchemeUnknown)
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Stop() error {
|
||||
self.mutex.Lock()
|
||||
// Otherwise, the program we hand the terminal to would receive the reports
|
||||
// as if they were typed
|
||||
if strings.Contains(self.queries, enableColorSchemeReports) {
|
||||
self.writeLocked(disableColorSchemeReports)
|
||||
}
|
||||
self.started = false
|
||||
self.mutex.Unlock()
|
||||
|
||||
return self.Tty.Stop()
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Write(p []byte) (int, error) {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
return self.Tty.Write(p)
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Read(p []byte) (int, error) {
|
||||
n, err := self.Tty.Read(p)
|
||||
|
||||
for _, reply := range self.scanner.scan(p[:n]) {
|
||||
self.handleReply(reply)
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// onFocusGained asks for the background color again, for the terminals that
|
||||
// tell us their background color but not when it changes. When the operating
|
||||
// system switches between dark and light mode, the user is usually busy
|
||||
// elsewhere, so coming back to the terminal is a good time to check.
|
||||
func (self *colorSchemeTty) onFocusGained() {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.requestBackgroundColorLocked()
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) handleReply(reply terminalReply) {
|
||||
self.mutex.Lock()
|
||||
|
||||
if reply.background != nil {
|
||||
self.background = *reply.background
|
||||
self.haveBackground = true
|
||||
self.backgroundRequested = false
|
||||
} else {
|
||||
self.reported = reply.colorScheme
|
||||
self.colorSchemeRequested = false
|
||||
self.requestBackgroundColorLocked()
|
||||
}
|
||||
|
||||
if self.repliesArrived != nil && !self.awaitingRepliesLocked() {
|
||||
close(self.repliesArrived)
|
||||
self.repliesArrived = nil
|
||||
}
|
||||
|
||||
detected := self.detected()
|
||||
onChange := self.onChange
|
||||
changed := detected != self.notified
|
||||
if changed {
|
||||
self.notified = detected
|
||||
}
|
||||
|
||||
self.mutex.Unlock()
|
||||
|
||||
if changed && onChange != nil {
|
||||
onChange(detected)
|
||||
}
|
||||
}
|
||||
|
||||
// requestBackgroundColorLocked asks for the background color, if the terminal
|
||||
// has told us its background color before and isn't about to do it anyway.
|
||||
func (self *colorSchemeTty) requestBackgroundColorLocked() {
|
||||
if !self.started || !self.haveBackground || self.backgroundRequested {
|
||||
return
|
||||
}
|
||||
|
||||
self.writeLocked(requestBackgroundColor)
|
||||
self.backgroundRequested = true
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) detected() DetectedColorScheme {
|
||||
if !self.haveBackground {
|
||||
return DetectedColorScheme{ColorScheme: self.reported}
|
||||
}
|
||||
|
||||
return DetectedColorScheme{
|
||||
ColorScheme: colorSchemeOfBackground(self.background),
|
||||
Background: self.background.Hex(),
|
||||
}
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) writeLocked(s string) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// If the terminal is gone, tcell finds out when it next writes or reads
|
||||
_, _ = self.Tty.Write([]byte(s))
|
||||
}
|
||||
|
||||
func colorSchemeOfBackground(background colorful.Color) ColorScheme {
|
||||
lightness, _, _ := background.Lab()
|
||||
if lightness < 0.5 {
|
||||
return ColorSchemeDark
|
||||
}
|
||||
return ColorSchemeLight
|
||||
}
|
||||
|
||||
// terminalReply is one of the answers that colorSchemeTty asks the terminal
|
||||
// for. Exactly one of its fields is set.
|
||||
type terminalReply struct {
|
||||
colorScheme ColorScheme
|
||||
background *colorful.Color
|
||||
}
|
||||
|
||||
type terminalReplyScannerState int
|
||||
|
||||
const (
|
||||
scanningText terminalReplyScannerState = iota
|
||||
scanningEscape
|
||||
scanningCsi
|
||||
scanningOsc
|
||||
scanningOscEscape
|
||||
)
|
||||
|
||||
// No reply we look for is longer than this; we don't look at longer sequences
|
||||
const maxTerminalReplyLength = 64
|
||||
|
||||
// terminalReplyScanner finds the terminal's answers to our color scheme queries
|
||||
// in its input. The input arrives in chunks, which may split an answer anywhere,
|
||||
// so the scanner keeps its state from one chunk to the next.
|
||||
type terminalReplyScanner struct {
|
||||
state terminalReplyScannerState
|
||||
sequence []byte
|
||||
oversized bool
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) scan(input []byte) []terminalReply {
|
||||
var replies []terminalReply
|
||||
|
||||
for _, b := range input {
|
||||
switch self.state {
|
||||
case scanningText:
|
||||
if b == '\x1b' {
|
||||
self.state = scanningEscape
|
||||
}
|
||||
|
||||
case scanningEscape:
|
||||
self.scanEscape(b)
|
||||
|
||||
case scanningCsi:
|
||||
switch {
|
||||
case b == '\x1b':
|
||||
self.state = scanningEscape
|
||||
case b >= 0x20 && b <= 0x3f: // parameter and intermediate bytes
|
||||
self.appendToSequence(b)
|
||||
case b >= 0x40 && b <= 0x7e: // final byte
|
||||
self.state = scanningText
|
||||
if b == 'n' && !self.oversized {
|
||||
if colorScheme, ok := parseColorSchemeReport(string(self.sequence)); ok {
|
||||
replies = append(replies, terminalReply{colorScheme: colorScheme})
|
||||
}
|
||||
}
|
||||
default:
|
||||
self.state = scanningText
|
||||
}
|
||||
|
||||
case scanningOsc:
|
||||
switch b {
|
||||
case '\a':
|
||||
self.state = scanningText
|
||||
replies = self.appendOscReply(replies)
|
||||
case '\x1b':
|
||||
self.state = scanningOscEscape
|
||||
default:
|
||||
self.appendToSequence(b)
|
||||
}
|
||||
|
||||
case scanningOscEscape:
|
||||
if b == '\\' {
|
||||
self.state = scanningText
|
||||
replies = self.appendOscReply(replies)
|
||||
} else {
|
||||
// Not a string terminator, so a new escape sequence has begun and
|
||||
// the one before it was cut short
|
||||
self.scanEscape(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return replies
|
||||
}
|
||||
|
||||
// scanEscape handles the byte after an ESC.
|
||||
func (self *terminalReplyScanner) scanEscape(b byte) {
|
||||
switch b {
|
||||
case '[':
|
||||
self.startSequence(scanningCsi)
|
||||
case ']':
|
||||
self.startSequence(scanningOsc)
|
||||
case '\x1b':
|
||||
self.state = scanningEscape
|
||||
default:
|
||||
self.state = scanningText
|
||||
}
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) startSequence(state terminalReplyScannerState) {
|
||||
self.state = state
|
||||
self.sequence = self.sequence[:0]
|
||||
self.oversized = false
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) appendToSequence(b byte) {
|
||||
if len(self.sequence) >= maxTerminalReplyLength {
|
||||
self.oversized = true
|
||||
return
|
||||
}
|
||||
self.sequence = append(self.sequence, b)
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) appendOscReply(replies []terminalReply) []terminalReply {
|
||||
if self.oversized {
|
||||
return replies
|
||||
}
|
||||
|
||||
if background, ok := parseBackgroundColorReport(string(self.sequence)); ok {
|
||||
replies = append(replies, terminalReply{background: &background})
|
||||
}
|
||||
|
||||
return replies
|
||||
}
|
||||
|
||||
// parseColorSchemeReport parses the parameters of CSI ? 997 ; <n> n
|
||||
func parseColorSchemeReport(params string) (ColorScheme, bool) {
|
||||
switch params {
|
||||
case "?997;1":
|
||||
return ColorSchemeDark, true
|
||||
case "?997;2":
|
||||
return ColorSchemeLight, true
|
||||
default:
|
||||
return ColorSchemeUnknown, false
|
||||
}
|
||||
}
|
||||
|
||||
// parseBackgroundColorReport parses the content of OSC 11 ; rgb:R/G/B, where
|
||||
// each of R, G and B has from one to four hex digits. Some terminals send
|
||||
// rgba:R/G/B/A instead.
|
||||
func parseBackgroundColorReport(content string) (colorful.Color, bool) {
|
||||
spec, ok := strings.CutPrefix(content, "11;")
|
||||
if !ok {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
|
||||
var components []string
|
||||
if rgb, ok := strings.CutPrefix(spec, "rgb:"); ok {
|
||||
components = strings.Split(rgb, "/")
|
||||
if len(components) != 3 {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
} else if rgba, ok := strings.CutPrefix(spec, "rgba:"); ok {
|
||||
components = strings.Split(rgba, "/")
|
||||
if len(components) != 4 {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
} else {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
|
||||
values := [3]float64{}
|
||||
for i := range values {
|
||||
component := components[i]
|
||||
if len(component) < 1 || len(component) > 4 {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
value, err := strconv.ParseUint(component, 16, 16)
|
||||
if err != nil {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
maxValue := uint64(1)<<(4*len(component)) - 1
|
||||
values[i] = float64(value) / float64(maxValue)
|
||||
}
|
||||
|
||||
return colorful.Color{R: values[0], G: values[1], B: values[2]}, true
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func describeReplies(replies []terminalReply) []string {
|
||||
result := []string{}
|
||||
for _, reply := range replies {
|
||||
if reply.background != nil {
|
||||
result = append(result, reply.background.Hex())
|
||||
} else {
|
||||
result = append(result, reply.colorScheme.String())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestTerminalReplyScanner(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "dark report",
|
||||
input: "\x1b[?997;1n",
|
||||
expected: []string{"dark"},
|
||||
},
|
||||
{
|
||||
name: "light report",
|
||||
input: "\x1b[?997;2n",
|
||||
expected: []string{"light"},
|
||||
},
|
||||
{
|
||||
name: "background ended by ST",
|
||||
input: "\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\",
|
||||
expected: []string{"#1e1e1e"},
|
||||
},
|
||||
{
|
||||
name: "background ended by BEL",
|
||||
input: "\x1b]11;rgb:ffff/ffff/ffff\a",
|
||||
expected: []string{"#ffffff"},
|
||||
},
|
||||
{
|
||||
name: "background with two hex digits per component",
|
||||
input: "\x1b]11;rgb:fd/f6/e3\a",
|
||||
expected: []string{"#fdf6e3"},
|
||||
},
|
||||
{
|
||||
name: "background with one hex digit per component",
|
||||
input: "\x1b]11;rgb:f/0/f\a",
|
||||
expected: []string{"#ff00ff"},
|
||||
},
|
||||
{
|
||||
name: "background with alpha",
|
||||
input: "\x1b]11;rgba:0000/2b2b/3636/ffff\a",
|
||||
expected: []string{"#002b36"},
|
||||
},
|
||||
{
|
||||
name: "replies among other input",
|
||||
input: "j\x1b[?997;1n\x1b[A\x1b[<0;10;5M\x1b]11;rgb:0000/0000/0000\x1b\\\x1b[?62;22c",
|
||||
expected: []string{"dark", "#000000"},
|
||||
},
|
||||
{
|
||||
name: "other reports",
|
||||
input: "\x1b[?997;3n\x1b[0n\x1b[?996n\x1b]10;rgb:ffff/ffff/ffff\a\x1b]4;1;rgb:ffff/0000/0000\a",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "malformed backgrounds",
|
||||
input: "\x1b]11;rgb:zz/00/00\a\x1b]11;rgb:10/20\a\x1b]11;rgb:12345/0/0\a\x1b]11;rgb://\a\x1b]11;#ffffff\a",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "reply after a sequence that was cut short",
|
||||
input: "\x1b]11;rgb:\x1b[?997;2n\x1b[?99\x1b]11;rgb:00/00/00\a",
|
||||
expected: []string{"light", "#000000"},
|
||||
},
|
||||
{
|
||||
name: "reply after an oversized sequence",
|
||||
input: "\x1b]11;" + strings.Repeat("x", 100) + "\a\x1b[?" + strings.Repeat("9", 100) + "n\x1b[?997;1n",
|
||||
expected: []string{"dark"},
|
||||
},
|
||||
{
|
||||
name: "oversized sequence that starts like a reply",
|
||||
input: "\x1b]11;rgb:00/00/00" + strings.Repeat("x", 100) + "\a",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
scanner := terminalReplyScanner{}
|
||||
assert.Equal(t, s.expected, describeReplies(scanner.scan([]byte(s.input))))
|
||||
})
|
||||
|
||||
t.Run(s.name+", split in two", func(t *testing.T) {
|
||||
for i := range len(s.input) {
|
||||
scanner := terminalReplyScanner{}
|
||||
replies := scanner.scan([]byte(s.input[:i]))
|
||||
replies = append(replies, scanner.scan([]byte(s.input[i:]))...)
|
||||
assert.Equal(t, s.expected, describeReplies(replies), "split at %d", i)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(s.name+", byte by byte", func(t *testing.T) {
|
||||
scanner := terminalReplyScanner{}
|
||||
replies := []terminalReply{}
|
||||
for i := range len(s.input) {
|
||||
replies = append(replies, scanner.scan([]byte{s.input[i]})...)
|
||||
}
|
||||
assert.Equal(t, s.expected, describeReplies(replies))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorSchemeQueries(t *testing.T) {
|
||||
allQueries := enableColorSchemeReports + requestColorScheme + requestBackgroundColor
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
term string
|
||||
termProgram string
|
||||
tcellNegotiate string
|
||||
expected string
|
||||
}{
|
||||
{name: "xterm", term: "xterm-256color", expected: allQueries},
|
||||
{name: "tmux", term: "tmux-256color", termProgram: "tmux", expected: allQueries},
|
||||
{name: "Terminal.app", term: "xterm-256color", termProgram: "Apple_Terminal", expected: requestBackgroundColor},
|
||||
{name: "WezTerm", term: "xterm-256color", termProgram: "WezTerm", expected: requestBackgroundColor},
|
||||
{name: "st", term: "st-256color", expected: ""},
|
||||
{name: "Linux console", term: "linux", expected: ""},
|
||||
{name: "VT100", term: "vt100", expected: ""},
|
||||
{name: "negotiation disabled", term: "xterm-256color", tcellNegotiate: "disable", expected: ""},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, colorSchemeQueries(s.term, s.termProgram, s.tcellNegotiate))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTty struct {
|
||||
input string
|
||||
written strings.Builder
|
||||
}
|
||||
|
||||
var _ tcell.Tty = &fakeTty{}
|
||||
|
||||
func (self *fakeTty) Start() error { return nil }
|
||||
func (self *fakeTty) Stop() error { return nil }
|
||||
func (self *fakeTty) Drain() error { return nil }
|
||||
func (self *fakeTty) NotifyResize(chan<- bool) {}
|
||||
func (self *fakeTty) WindowSize() (tcell.WindowSize, error) { return tcell.WindowSize{}, nil }
|
||||
func (self *fakeTty) Close() error { return nil }
|
||||
func (self *fakeTty) Write(p []byte) (int, error) { return self.written.Write(p) }
|
||||
func (self *fakeTty) Read(p []byte) (int, error) {
|
||||
n := copy(p, self.input)
|
||||
self.input = self.input[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (self *fakeTty) takeWritten() string {
|
||||
written := self.written.String()
|
||||
self.written.Reset()
|
||||
return written
|
||||
}
|
||||
|
||||
type colorSchemeTtyTest struct {
|
||||
fake *fakeTty
|
||||
tty *colorSchemeTty
|
||||
notifications []string
|
||||
}
|
||||
|
||||
func newColorSchemeTtyTest(termProgram string) *colorSchemeTtyTest {
|
||||
test := &colorSchemeTtyTest{fake: &fakeTty{}}
|
||||
test.tty = &colorSchemeTty{
|
||||
Tty: test.fake,
|
||||
queries: colorSchemeQueries("xterm-256color", termProgram, ""),
|
||||
}
|
||||
test.tty.subscribe(func(colorScheme DetectedColorScheme) {
|
||||
test.notifications = append(test.notifications, colorScheme.String())
|
||||
})
|
||||
return test
|
||||
}
|
||||
|
||||
// feed has the terminal send input, and has tcell read it
|
||||
func (self *colorSchemeTtyTest) feed(input string) {
|
||||
self.fake.input = input
|
||||
_, _ = self.tty.Read(make([]byte, 128))
|
||||
}
|
||||
|
||||
func (self *colorSchemeTtyTest) takeNotifications() []string {
|
||||
notifications := self.notifications
|
||||
self.notifications = nil
|
||||
return notifications
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyPrefersTheBackground(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assert.Equal(t, enableColorSchemeReports+requestColorScheme+requestBackgroundColor, test.fake.takeWritten())
|
||||
|
||||
// The terminal says it's light, but its background is dark
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, []string{"light (reported by the terminal)"}, test.takeNotifications())
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\\x1b[?62;22c")
|
||||
assert.Equal(t, []string{"dark (background #1e1e1e)"}, test.takeNotifications())
|
||||
assert.Equal(t, "", test.fake.takeWritten())
|
||||
|
||||
// A later report only makes us ask for the background again
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Nil(t, test.takeNotifications())
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:ffff/ffff/ffff\x1b\\")
|
||||
assert.Equal(t, []string{"light (background #ffffff)"}, test.takeNotifications())
|
||||
|
||||
// The same background again is no change
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
test.feed("\x1b]11;rgb:ffff/ffff/ffff\x1b\\")
|
||||
assert.Nil(t, test.takeNotifications())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyWithoutBackground(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.fake.takeWritten()
|
||||
|
||||
test.feed("\x1b[?997;1n\x1b[?62;22c")
|
||||
assert.Equal(t, []string{"dark (reported by the terminal)"}, test.takeNotifications())
|
||||
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, []string{"light (reported by the terminal)"}, test.takeNotifications())
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "a terminal that didn't answer before isn't asked again")
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyAsksAgainOnFocus(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("Apple_Terminal")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:0000/0000/0000\a")
|
||||
assert.Equal(t, []string{"dark (background #000000)"}, test.takeNotifications())
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:ffff/ffff/ffff\a")
|
||||
assert.Equal(t, []string{"light (background #ffffff)"}, test.takeNotifications())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyStopAndStart(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.fake.takeWritten()
|
||||
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
|
||||
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.Equal(t, disableColorSchemeReports, test.fake.takeWritten())
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "nothing is written to a stopped tty")
|
||||
|
||||
// Anything may have changed while we were stopped, so ask again
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assert.Equal(t, enableColorSchemeReports+requestColorScheme+requestBackgroundColor, test.fake.takeWritten())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyStopWithoutReports(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("WezTerm")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.fake.takeWritten()
|
||||
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "reports were never turned on")
|
||||
}
|
||||
|
||||
func TestColorSchemeOfBackground(t *testing.T) {
|
||||
for background, expected := range map[string]ColorScheme{
|
||||
"rgb:0000/0000/0000": ColorSchemeDark,
|
||||
"rgb:1e/1e/1e": ColorSchemeDark,
|
||||
"rgb:00/2b/36": ColorSchemeDark,
|
||||
"rgb:70/70/70": ColorSchemeDark,
|
||||
"rgb:80/80/80": ColorSchemeLight,
|
||||
"rgb:fd/f6/e3": ColorSchemeLight,
|
||||
"rgb:ff/ff/ff": ColorSchemeLight,
|
||||
} {
|
||||
color, ok := parseBackgroundColorReport("11;" + background)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, expected, colorSchemeOfBackground(color), background)
|
||||
}
|
||||
}
|
||||
|
||||
// startWaiting starts waiting for replies, with a timeout too long to matter
|
||||
func (self *colorSchemeTtyTest) startWaiting() <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
self.tty.waitForReplies(time.Minute)
|
||||
close(done)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func isClosed(ch <-chan struct{}) bool {
|
||||
select {
|
||||
case <-ch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func assertDoneWaiting(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
assert.Eventually(t, func() bool { return isClosed(done) }, time.Second, time.Millisecond)
|
||||
}
|
||||
|
||||
func assertStillWaiting(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
assert.Never(t, func() bool { return isClosed(done) }, 50*time.Millisecond, time.Millisecond)
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyWaitsForReplies(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
|
||||
assertDoneWaiting(t, test.startWaiting())
|
||||
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.NoError(t, test.tty.Start())
|
||||
|
||||
done := test.startWaiting()
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b[?997;1n")
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b]11;rgb:0000/0000/0000\a")
|
||||
assertDoneWaiting(t, done)
|
||||
|
||||
test.tty.onFocusGained()
|
||||
done = test.startWaiting()
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b]11;rgb:0000/0000/0000\a")
|
||||
assertDoneWaiting(t, done)
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyGivesUpWaiting(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
|
||||
test.tty.onFocusGained()
|
||||
|
||||
start := time.Now()
|
||||
test.tty.waitForReplies(20 * time.Millisecond)
|
||||
assert.GreaterOrEqual(t, time.Since(start), 20*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyDoesntWaitForRepliesThatNeverCame(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
// The terminal answers nothing
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assertDoneWaiting(t, test.startWaiting())
|
||||
|
||||
// The terminal reports its color scheme, but not its background
|
||||
test.feed("\x1b[?997;1n\x1b[?62;22c")
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.NoError(t, test.tty.Start())
|
||||
|
||||
done := test.startWaiting()
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b[?997;1n")
|
||||
assertDoneWaiting(t, done)
|
||||
}
|
||||
@@ -227,6 +227,12 @@ type Gui struct {
|
||||
// readable from anywhere, so it's atomic.
|
||||
focused atomic.Bool
|
||||
|
||||
// colorSchemeTty is nil when running headless. colorScheme and
|
||||
// colorSchemeHandler are only touched on the UI thread.
|
||||
colorSchemeTty *colorSchemeTty
|
||||
colorScheme DetectedColorScheme
|
||||
colorSchemeHandler func(DetectedColorScheme) error
|
||||
|
||||
// blockInputCount, when greater than zero, withholds keyboard input from
|
||||
// the handlers: key events are buffered into bufferedKeyEvents and replayed
|
||||
// once the count drops back to zero, while mouse clicks and hover are
|
||||
@@ -317,6 +323,18 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
|
||||
// never happened.
|
||||
g.focused.Store(true)
|
||||
|
||||
if g.colorSchemeTty != nil {
|
||||
g.colorScheme = g.colorSchemeTty.subscribe(func(colorScheme DetectedColorScheme) {
|
||||
g.UpdateBackground(func(g *Gui) error {
|
||||
g.colorScheme = colorScheme
|
||||
if g.colorSchemeHandler != nil {
|
||||
return g.colorSchemeHandler(colorScheme)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
@@ -374,6 +392,7 @@ func (g *Gui) WaitUntilIdle() {
|
||||
// initialization and when gocui is not needed anymore.
|
||||
func (g *Gui) Close() {
|
||||
close(g.stop)
|
||||
g.waitForColorSchemeReplies()
|
||||
Screen.Fini()
|
||||
}
|
||||
|
||||
@@ -708,6 +727,29 @@ func (g *Gui) SetFocusHandler(handler func(bool) error) {
|
||||
g.focusHandler = handler
|
||||
}
|
||||
|
||||
// DetectedColorScheme returns what the terminal has told us about its colors.
|
||||
// It is known before the first layout, for the terminals that tell us at all.
|
||||
func (g *Gui) DetectedColorScheme() DetectedColorScheme {
|
||||
return g.colorScheme
|
||||
}
|
||||
|
||||
// SetColorSchemeChangeHandler sets a function to call on the UI thread whenever
|
||||
// the terminal's colors change after startup.
|
||||
func (g *Gui) SetColorSchemeChangeHandler(handler func(DetectedColorScheme) error) {
|
||||
g.colorSchemeHandler = handler
|
||||
}
|
||||
|
||||
// Long enough for the round trip of a slow ssh connection
|
||||
const colorSchemeReplyTimeout = 500 * time.Millisecond
|
||||
|
||||
// waitForColorSchemeReplies is for before we give up the terminal. tcell is
|
||||
// still reading the input at that point, so the answers are consumed as usual.
|
||||
func (g *Gui) waitForColorSchemeReplies() {
|
||||
if g.colorSchemeTty != nil {
|
||||
g.colorSchemeTty.waitForReplies(colorSchemeReplyTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gui) SetOpenHyperlinkFunc(openHyperlinkFunc func(string, string) error) {
|
||||
g.openHyperlink = openHyperlinkFunc
|
||||
}
|
||||
@@ -2043,6 +2085,10 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
}
|
||||
g.focused.Store(ev.Focused)
|
||||
|
||||
if ev.Focused && g.colorSchemeTty != nil {
|
||||
g.colorSchemeTty.onFocusGained()
|
||||
}
|
||||
|
||||
if g.focusHandler != nil {
|
||||
return g.focusHandler(ev.Focused)
|
||||
}
|
||||
@@ -2059,6 +2105,8 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
// after re-engaging.
|
||||
|
||||
func (g *Gui) Suspend() error {
|
||||
g.waitForColorSchemeReplies()
|
||||
|
||||
g.suspendedMutex.Lock()
|
||||
defer g.suspendedMutex.Unlock()
|
||||
|
||||
|
||||
@@ -56,7 +56,13 @@ var runeReplacements = map[rune]string{
|
||||
func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
|
||||
tcell.SetEncodingFallback(tcell.EncodingFallbackASCII)
|
||||
|
||||
s, e := tcell.NewScreen()
|
||||
tty, e := tcell.NewDevTty()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
colorSchemeTty := newColorSchemeTty(tty)
|
||||
|
||||
s, e := tcell.NewTerminfoScreenFromTty(colorSchemeTty)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -68,6 +74,7 @@ func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
|
||||
registerRuneFallbacks(s, runeReplacements)
|
||||
|
||||
g.screen = s
|
||||
g.colorSchemeTty = colorSchemeTty
|
||||
Screen = s
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -942,6 +942,12 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
|
||||
|
||||
g.ErrorHandler = gui.PopupHandler.ErrorHandler
|
||||
|
||||
gui.c.Log.Infof("Terminal color scheme: %s", g.DetectedColorScheme())
|
||||
g.SetColorSchemeChangeHandler(func(colorScheme gocui.DetectedColorScheme) error {
|
||||
gui.c.Log.Infof("Terminal color scheme changed: %s", colorScheme)
|
||||
return nil
|
||||
})
|
||||
|
||||
gui.g.ShouldHandleMouseEvent = func(view *gocui.View, key gocui.KeyName) bool {
|
||||
if gui.helpers.Confirmation.IsPopupPanelFocused() && gui.currentViewName() != view.Name() &&
|
||||
!gocui.IsMouseScrollKey(key) {
|
||||
|
||||
Reference in New Issue
Block a user