From 44d4973291755341228eae204041dfccafade339 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 14 Aug 2024 15:55:26 -0400 Subject: [PATCH 1/4] feat: query and set terminal background, foreground, and cursor colors This adds the ability to read and set terminal background, foreground, and cursor color. --- color.go | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ tea.go | 24 ++++++++++ 2 files changed, 159 insertions(+) diff --git a/color.go b/color.go index 0b49185205..9253a5583b 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,68 @@ func xParseColor(s string) color.Color { } return color.Black } + +func getMaxMin(a, b, c float64) (max, min float64) { + 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/tea.go b/tea.go index d9b95a6d2f..eab5f6050f 100644 --- a/tea.go +++ b/tea.go @@ -392,6 +392,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 execMsg: // NB: this blocks. p.exec(msg.cmd, msg.fn) From cdc250332b587f456eaf7f20511cdc03719513e7 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 14 Aug 2024 15:58:43 -0400 Subject: [PATCH 2/4] feat(example): add example to set-terminal-color's --- examples/set-terminal-color/main.go | 209 ++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 examples/set-terminal-color/main.go diff --git a/examples/set-terminal-color/main.go b/examples/set-terminal-color/main.go new file mode 100644 index 0000000000..a674477885 --- /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) + } +} From e6ad78c915cf2fa848586d107daeed0d685c7b59 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 15 Aug 2024 14:55:32 -0400 Subject: [PATCH 3/4] chore: add bg/fg/cur terminal color command test cases --- screen_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/screen_test.go b/screen_test.go index 728cd9779f..478944abe3 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", + }, } for _, test := range tests { From 8f6ac0118f7cc42bf2cb2ba57460fdbccda1c8b0 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 15 Aug 2024 14:58:08 -0400 Subject: [PATCH 4/4] chore: color: add min/max todo note --- color.go | 1 + 1 file changed, 1 insertion(+) diff --git a/color.go b/color.go index 9253a5583b..a471a79f2d 100644 --- a/color.go +++ b/color.go @@ -152,6 +152,7 @@ func xParseColor(s string) color.Color { } func getMaxMin(a, b, c float64) (max, min float64) { + // TODO: use go1.21 min/max functions if a > b { max = a min = b