Repository: charmbracelet/bubbletea
Pull request #1085 (2024-08-14 19:57:54)

feat: query and set terminal background, foreground, and cursor colors
This adds the ability to read and set terminal background, foreground, and cursor color.

Supersedes: https://github.com/charmbracelet/bubbletea/pull/567
Fixes: https://github.com/charmbracelet/bubbletea/issues/207


Patch:
diff --git a/color.go b/color.go
index 0b49185..a471a79 100644
--- a/color.go
+++ b/color.go
@@ -3,10 +3,65 @@ package tea
 import (
 	"fmt"
 	"image/color"
+	"math"
 	"strconv"
 	"strings"
 )
 
+// backgroundColorMsg is a message that requests the terminal background color.
+type backgroundColorMsg struct{}
+
+// BackgroundColor is a command that requests the terminal background color.
+func BackgroundColor() Msg {
+	return backgroundColorMsg{}
+}
+
+// foregroundColorMsg is a message that requests the terminal foreground color.
+type foregroundColorMsg struct{}
+
+// ForegroundColor is a command that requests the terminal foreground color.
+func ForegroundColor() Msg {
+	return foregroundColorMsg{}
+}
+
+// cursorColorMsg is a message that requests the terminal cursor color.
+type cursorColorMsg struct{}
+
+// CursorColor is a command that requests the terminal cursor color.
+func CursorColor() Msg {
+	return cursorColorMsg{}
+}
+
+// setBackgroundColorMsg is a message that sets the terminal background color.
+type setBackgroundColorMsg struct{ color.Color }
+
+// SetBackgroundColor is a command that sets the terminal background color.
+func SetBackgroundColor(c color.Color) Cmd {
+	return func() Msg {
+		return setBackgroundColorMsg{c}
+	}
+}
+
+// setForegroundColorMsg is a message that sets the terminal foreground color.
+type setForegroundColorMsg struct{ color.Color }
+
+// SetForegroundColor is a command that sets the terminal foreground color.
+func SetForegroundColor(c color.Color) Cmd {
+	return func() Msg {
+		return setForegroundColorMsg{c}
+	}
+}
+
+// setCursorColorMsg is a message that sets the terminal cursor color.
+type setCursorColorMsg struct{ color.Color }
+
+// SetCursorColor is a command that sets the terminal cursor color.
+func SetCursorColor(c color.Color) Cmd {
+	return func() Msg {
+		return setCursorColorMsg{c}
+	}
+}
+
 // ForegroundColorMsg represents a foreground color message.
 // This message is emitted when the program requests the terminal foreground
 // color.
@@ -17,6 +72,11 @@ func (e ForegroundColorMsg) String() string {
 	return colorToHex(e)
 }
 
+// IsDark returns whether the color is dark.
+func (e ForegroundColorMsg) IsDark() bool {
+	return isDarkColor(e)
+}
+
 // BackgroundColorMsg represents a background color message.
 // This message is emitted when the program requests the terminal background
 // color.
@@ -27,6 +87,11 @@ func (e BackgroundColorMsg) String() string {
 	return colorToHex(e)
 }
 
+// IsDark returns whether the color is dark.
+func (e BackgroundColorMsg) IsDark() bool {
+	return isDarkColor(e)
+}
+
 // CursorColorMsg represents a cursor color change message.
 // This message is emitted when the program requests the terminal cursor color.
 type CursorColorMsg struct{ color.Color }
@@ -36,6 +101,11 @@ func (e CursorColorMsg) String() string {
 	return colorToHex(e)
 }
 
+// IsDark returns whether the color is dark.
+func (e CursorColorMsg) IsDark() bool {
+	return isDarkColor(e)
+}
+
 type shiftable interface {
 	~uint | ~uint16 | ~uint32 | ~uint64
 }
@@ -80,3 +150,69 @@ func xParseColor(s string) color.Color {
 	}
 	return color.Black
 }
+
+func getMaxMin(a, b, c float64) (max, min float64) {
+	// TODO: use go1.21 min/max functions
+	if a > b {
+		max = a
+		min = b
+	} else {
+		max = b
+		min = a
+	}
+	if c > max {
+		max = c
+	} else if c < min {
+		min = c
+	}
+	return max, min
+}
+
+func round(x float64) float64 {
+	return math.Round(x*1000) / 1000
+}
+
+// rgbToHSL converts an RGB triple to an HSL triple.
+func rgbToHSL(r, g, b uint8) (h, s, l float64) {
+	// convert uint32 pre-multiplied value to uint8
+	// The r,g,b values are divided by 255 to change the range from 0..255 to 0..1:
+	Rnot := float64(r) / 255
+	Gnot := float64(g) / 255
+	Bnot := float64(b) / 255
+	Cmax, Cmin := getMaxMin(Rnot, Gnot, Bnot)
+	Δ := Cmax - Cmin
+	// Lightness calculation:
+	l = (Cmax + Cmin) / 2
+	// Hue and Saturation Calculation:
+	if Δ == 0 {
+		h = 0
+		s = 0
+	} else {
+		switch Cmax {
+		case Rnot:
+			h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
+		case Gnot:
+			h = 60 * (((Bnot - Rnot) / Δ) + 2)
+		case Bnot:
+			h = 60 * (((Rnot - Gnot) / Δ) + 4)
+		}
+		if h < 0 {
+			h += 360
+		}
+
+		s = Δ / (1 - math.Abs((2*l)-1))
+	}
+
+	return h, round(s), round(l)
+}
+
+// isDarkColor returns whether the given color is dark.
+func isDarkColor(c color.Color) bool {
+	if c == nil {
+		return true
+	}
+
+	r, g, b, _ := c.RGBA()
+	_, _, l := rgbToHSL(uint8(r>>8), uint8(g>>8), uint8(b>>8))
+	return l < 0.5
+}
diff --git a/examples/set-terminal-color/main.go b/examples/set-terminal-color/main.go
new file mode 100644
index 0000000..a674477
--- /dev/null
+++ b/examples/set-terminal-color/main.go
@@ -0,0 +1,209 @@
+package main
+
+import (
+	"image/color"
+	"log"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/textinput"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/lucasb-eyer/go-colorful"
+)
+
+type colorType int
+
+const (
+	foreground colorType = iota + 1
+	background
+	cursor
+)
+
+func (c colorType) String() string {
+	switch c {
+	case foreground:
+		return "Foreground"
+	case background:
+		return "Background"
+	case cursor:
+		return "Cursor"
+	default:
+		return "Unknown"
+	}
+}
+
+type state int
+
+const (
+	chooseState state = iota
+	inputState
+)
+
+type model struct {
+	ti          textinput.Model
+	choice      colorType
+	state       state
+	choiceIndex int
+	err         error
+	bg          color.Color
+	fg          color.Color
+	cursor      color.Color
+}
+
+func (m model) Init() tea.Cmd {
+	return tea.Batch(
+		textinput.Blink,
+		tea.BackgroundColor,
+		tea.ForegroundColor,
+		tea.CursorColor,
+	)
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c", "q":
+			var cmds []tea.Cmd
+			if m.fg != nil {
+				cmds = append(cmds, tea.SetForegroundColor(m.fg))
+			}
+			if m.bg != nil {
+				cmds = append(cmds, tea.SetBackgroundColor(m.bg))
+			}
+			if m.cursor != nil {
+				cmds = append(cmds, tea.SetCursorColor(m.cursor))
+			}
+			cmds = append(cmds, tea.Quit)
+			return m, tea.Batch(cmds...)
+		}
+
+		switch m.state {
+		case chooseState:
+			switch msg.String() {
+			case "j", "down":
+				m.choiceIndex++
+				if m.choiceIndex > 2 {
+					m.choiceIndex = 0
+				}
+			case "k", "up":
+				m.choiceIndex--
+				if m.choiceIndex < 0 {
+					m.choiceIndex = 2
+				}
+			case "enter":
+				m.state = inputState
+				switch m.choiceIndex {
+				case 0:
+					m.choice = foreground
+				case 1:
+					m.choice = background
+				case 2:
+					m.choice = cursor
+				}
+			}
+
+		case inputState:
+			switch msg.String() {
+			case "esc":
+				m.choice = 0
+				m.choiceIndex = 0
+				m.state = chooseState
+				m.err = nil
+			case "enter":
+				val := m.ti.Value()
+				col, err := colorful.Hex(val)
+				if err != nil {
+					m.err = err
+				} else {
+					m.err = nil
+					choice := m.choice
+					m.choice = 0
+					m.choiceIndex = 0
+					m.state = chooseState
+
+					switch choice {
+					case foreground:
+						return m, tea.SetForegroundColor(col)
+					case background:
+						return m, tea.SetBackgroundColor(col)
+					case cursor:
+						return m, tea.SetCursorColor(col)
+					}
+				}
+
+			default:
+				var cmd tea.Cmd
+				m.ti, cmd = m.ti.Update(msg)
+				return m, cmd
+			}
+		}
+
+	case tea.BackgroundColorMsg:
+		m.bg = msg.Color
+
+	case tea.ForegroundColorMsg:
+		m.fg = msg.Color
+
+	case tea.CursorColorMsg:
+		m.cursor = msg.Color
+
+	}
+
+	return m, nil
+}
+
+func (m model) View() string {
+	var s strings.Builder
+
+	switch m.state {
+	case chooseState:
+		s.WriteString("Choose a color to set:\n\n")
+		for i, c := range []colorType{foreground, background, cursor} {
+			if i == m.choiceIndex {
+				s.WriteString(" > ")
+			} else {
+				s.WriteString("   ")
+			}
+			s.WriteString(c.String())
+			s.WriteString("\n")
+		}
+	case inputState:
+		s.WriteString("Enter a color in hex format:\n\n")
+		s.WriteString(m.ti.View())
+		s.WriteString("\n")
+	}
+
+	if m.err != nil {
+		s.WriteString("\nError: ")
+		s.WriteString(m.err.Error())
+	}
+
+	s.WriteString("\nPress q to quit")
+
+	switch m.state {
+	case chooseState:
+		s.WriteString(", j/k to move, and enter to select")
+	case inputState:
+		s.WriteString(", and enter to submit, esc to go back")
+	}
+
+	s.WriteString("\n")
+
+	return s.String()
+}
+
+func main() {
+	ti := textinput.New()
+	ti.Placeholder = "#ff00ff"
+	ti.Focus()
+	ti.CharLimit = 156
+	ti.Width = 20
+	p := tea.NewProgram(model{
+		ti: ti,
+	})
+
+	_, err := p.Run()
+	if err != nil {
+		log.Fatalf("Error running program: %v", err)
+	}
+}
diff --git a/tea.go b/tea.go
index 2c616ce..5f293fb 100644
--- a/tea.go
+++ b/tea.go
@@ -400,6 +400,30 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				p.renderer.execute(ansi.DisableBracketedPaste)
 				p.bpActive = false
 
+			case setBackgroundColorMsg:
+				if msg.Color != nil {
+					p.renderer.execute(ansi.SetBackgroundColor(msg.Color))
+				}
+
+			case setForegroundColorMsg:
+				if msg.Color != nil {
+					p.renderer.execute(ansi.SetForegroundColor(msg.Color))
+				}
+
+			case setCursorColorMsg:
+				if msg.Color != nil {
+					p.renderer.execute(ansi.SetCursorColor(msg.Color))
+				}
+
+			case backgroundColorMsg:
+				p.renderer.execute(ansi.RequestBackgroundColor)
+
+			case foregroundColorMsg:
+				p.renderer.execute(ansi.RequestForegroundColor)
+
+			case cursorColorMsg:
+				p.renderer.execute(ansi.RequestCursorColor)
+
 			case KittyKeyboardMsg:
 				// Store the kitty flags whenever they are queried.
 				p.kittyFlags = int(msg)


Test patch:
diff --git a/screen_test.go b/screen_test.go
index a62a100..f686a89 100644
--- a/screen_test.go
+++ b/screen_test.go
@@ -2,6 +2,7 @@ package tea
 
 import (
 	"bytes"
+	"image/color"
 	"testing"
 )
 
@@ -56,6 +57,16 @@ func TestClearMsg(t *testing.T) {
 			cmds:     []Cmd{DisableBracketedPaste, EnableBracketedPaste},
 			expected: "\x1b[?25l\x1b[?2004h\x1b[?2004l\x1b[?2004h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
+		{
+			name:     "bg_fg_cur_color",
+			cmds:     []Cmd{ForegroundColor, BackgroundColor, CursorColor},
+			expected: "\x1b[?25l\x1b[?2004h\x1b]10;?\a\x1b]11;?\a\x1b]12;?\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+		},
+		{
+			name:     "bg_set_color",
+			cmds:     []Cmd{SetBackgroundColor(color.RGBA{255, 255, 255, 255})},
+			expected: "\x1b[?25l\x1b[?2004h\x1b]11;#ffffff\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+		},
 		{
 			name:     "kitty_start",
 			cmds:     []Cmd{DisableKittyKeyboard, EnableKittyKeyboard(3)},