diff --git a/colors.go b/colors.go index 7619e28..c99ede8 100644 --- a/colors.go +++ b/colors.go @@ -1,61 +1,24 @@ package main import ( - "regexp" + "github.com/bcicen/ctop/theme" ui "github.com/gizak/termui" ) -/* -Valid colors: - ui.ColorDefault - ui.ColorBlack - ui.ColorRed - ui.ColorGreen - ui.ColorYellow - ui.ColorBlue - ui.ColorMagenta - ui.ColorCyan - ui.ColorWhite -*/ +var ColorMap map[string]ui.Attribute -var ColorMap = map[string]ui.Attribute{ - "fg": ui.ColorWhite, - "bg": ui.ColorDefault, - "block.bg": ui.ColorDefault, - "border.bg": ui.ColorDefault, - "border.fg": ui.ColorWhite, - "label.bg": ui.ColorDefault, - "label.fg": ui.ColorGreen, - "menu.text.fg": ui.ColorWhite, - "menu.text.bg": ui.ColorDefault, - "menu.border.fg": ui.ColorCyan, - "menu.label.fg": ui.ColorGreen, - "header.fg": ui.ColorBlack, - "header.bg": ui.ColorWhite, - "gauge.bar.bg": ui.ColorGreen, - "gauge.percent.fg": ui.ColorWhite, - "linechart.axes.fg": ui.ColorDefault, - "linechart.line.fg": ui.ColorGreen, - "mbarchart.bar.bg": ui.ColorGreen, - "mbarchart.num.fg": ui.ColorWhite, - "mbarchart.text.fg": ui.ColorWhite, - "par.text.fg": ui.ColorWhite, - "par.text.bg": ui.ColorDefault, - "par.text.hi": ui.ColorBlack, - "sparkline.line.fg": ui.ColorGreen, - "sparkline.title.fg": ui.ColorWhite, - "status.ok": ui.ColorGreen, - "status.warn": ui.ColorYellow, - "status.danger": ui.ColorRed, +// SetColorMap installs the color scheme for the currently selected theme +func SetColorMap() { + ColorMap = theme.BuildColorMap() } +// InvertColorMap switches to the inverse (light/dark) flavor func InvertColorMap() { - re := regexp.MustCompile(".*.fg") - for k := range ColorMap { - if re.FindAllString(k, 1) != nil { - ColorMap[k] = ui.ColorBlack - } + if theme.Current() == theme.Latte { + theme.Set(theme.Mocha) + } else { + theme.Set(theme.Latte) } - ColorMap["par.text.hi"] = ui.ColorWhite -} + SetColorMap() +} \ No newline at end of file diff --git a/cwidgets/compact/gauge.go b/cwidgets/compact/gauge.go index 39fccdc..b4dc14e 100644 --- a/cwidgets/compact/gauge.go +++ b/cwidgets/compact/gauge.go @@ -5,6 +5,7 @@ import ( "github.com/bcicen/ctop/cwidgets" "github.com/bcicen/ctop/models" + "github.com/bcicen/ctop/theme" ui "github.com/gizak/termui" ) @@ -75,7 +76,7 @@ func (w *GaugeCol) Buffer() ui.Buffer { // percentage value and low-contrast color for structure if w.Percent < 5 { w.Percent = 5 - w.BarColor = ui.ColorBlack + w.BarColor = theme.Attr("crust") } return w.Gauge.Buffer() diff --git a/cwidgets/compact/status.go b/cwidgets/compact/status.go index a22f405..d5fffc3 100644 --- a/cwidgets/compact/status.go +++ b/cwidgets/compact/status.go @@ -2,6 +2,7 @@ package compact import ( "github.com/bcicen/ctop/models" + "github.com/bcicen/ctop/theme" ui "github.com/gizak/termui" ) @@ -45,7 +46,7 @@ func (s *Status) Header() string { return "" } func (s *Status) FixedWidth() int { return 3 } func (s *Status) setState(val string) { - color := ui.ColorDefault + color := theme.Attr("text") var mark string switch val { @@ -66,11 +67,11 @@ func (s *Status) setState(val string) { log.Warningf("unknown status string: \"%v\"", val) } - s.status = ui.TextCells(mark, color, ui.ColorDefault) + s.status = ui.TextCells(mark, color, theme.Attr("bg")) } func (s *Status) setHealth(val string) { - color := ui.ColorDefault + color := theme.Attr("text") var mark string switch val { @@ -90,5 +91,5 @@ func (s *Status) setHealth(val string) { log.Warningf("unknown health state string: \"%v\"", val) } - s.health = ui.TextCells(mark, color, ui.ColorDefault) + s.health = ui.TextCells(mark, color, theme.Attr("bg")) } diff --git a/cwidgets/single/io.go b/cwidgets/single/io.go index 41fd1ba..f5b966b 100644 --- a/cwidgets/single/io.go +++ b/cwidgets/single/io.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/bcicen/ctop/cwidgets" + "github.com/bcicen/ctop/theme" ui "github.com/gizak/termui" ) @@ -26,13 +27,13 @@ func NewIO() *IO { read.Title = "READ" read.Height = 1 read.Data = io.readHist.Data - read.LineColor = ui.ColorGreen + read.LineColor = theme.Attr("io.read") write := ui.NewSparkline() write.Title = "WRITE" write.Height = 1 write.Data = io.writeHist.Data - write.LineColor = ui.ColorYellow + write.LineColor = theme.Attr("io.write") io.Lines = []ui.Sparkline{read, write} return io diff --git a/cwidgets/single/mem.go b/cwidgets/single/mem.go index f64e802..f888605 100644 --- a/cwidgets/single/mem.go +++ b/cwidgets/single/mem.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/bcicen/ctop/cwidgets" + "github.com/bcicen/ctop/theme" ui "github.com/gizak/termui" ) @@ -67,8 +68,8 @@ func newMemChart() *ui.MBarChart { mbar.BarGap = 1 mbar.BarWidth = 6 - mbar.BarColor[1] = ui.ColorBlack - mbar.NumColor[1] = ui.ColorBlack + mbar.BarColor[1] = theme.Attr("crust") + mbar.NumColor[1] = theme.Attr("crust") mbar.NumFmt = cwidgets.ByteFormatShort //mbar.ShowScale = true diff --git a/cwidgets/single/net.go b/cwidgets/single/net.go index 3e05594..0b1bc57 100644 --- a/cwidgets/single/net.go +++ b/cwidgets/single/net.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/bcicen/ctop/cwidgets" + "github.com/bcicen/ctop/theme" ui "github.com/gizak/termui" ) @@ -26,13 +27,13 @@ func NewNet() *Net { rx.Title = "RX" rx.Height = 1 rx.Data = net.rxHist.Data - rx.LineColor = ui.ColorGreen + rx.LineColor = theme.Attr("net.rx") tx := ui.NewSparkline() tx.Title = "TX" tx.Height = 1 tx.Data = net.txHist.Data - tx.LineColor = ui.ColorYellow + tx.LineColor = theme.Attr("net.tx") net.Lines = []ui.Sparkline{rx, tx} return net diff --git a/go.mod b/go.mod index 0d036bf..afbb8a1 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ require ( github.com/c9s/goprocinfo v0.0.0-20170609001544-b34328d6e0cd github.com/fsouza/go-dockerclient v1.7.0 github.com/gizak/termui v2.3.1-0.20180817033724-8d4faad06196+incompatible - github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b github.com/jgautheron/codename-generator v0.0.0-20150829203204-16d037c7cc3c github.com/mattn/go-runewidth v0.0.2 github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d @@ -14,7 +14,6 @@ require ( github.com/opencontainers/runc v1.1.0 github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23 github.com/pkg/errors v0.9.1 - github.com/stretchr/testify v1.4.0 ) require ( @@ -29,7 +28,6 @@ require ( github.com/containerd/continuity v0.0.0-20200928162600-f2cc35102c2a // indirect github.com/coreos/go-systemd/v22 v22.3.2 // indirect github.com/cyphar/filepath-securejoin v0.2.3 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/docker v20.10.0-beta1.0.20201113105859-b6bfff2a628f+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect @@ -47,7 +45,6 @@ require ( github.com/opencontainers/image-spec v1.0.1 // indirect github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417 // indirect github.com/opencontainers/selinux v1.10.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921 // indirect github.com/sirupsen/logrus v1.8.1 // indirect github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect @@ -58,7 +55,6 @@ require ( golang.org/x/sync v0.0.0-20190423024810-112230192c58 // indirect golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c // indirect google.golang.org/protobuf v1.27.1 // indirect - gopkg.in/yaml.v2 v2.2.8 // indirect ) go 1.18 diff --git a/go.sum b/go.sum index ca3a07c..68fabc3 100644 --- a/go.sum +++ b/go.sum @@ -242,7 +242,6 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= diff --git a/main.go b/main.go index 659074e..2bb94da 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "github.com/bcicen/ctop/container" "github.com/bcicen/ctop/cwidgets/compact" "github.com/bcicen/ctop/logging" + "github.com/bcicen/ctop/theme" "github.com/bcicen/ctop/widgets" ui "github.com/gizak/termui" tm "github.com/nsf/termbox-go" @@ -45,6 +46,8 @@ func main() { reverseSortFlag = flag.Bool("r", false, "reverse container sort order") invertFlag = flag.Bool("i", false, "invert default colors") connectorFlag = flag.String("connector", "docker", "container connector to use") + themeFlag = flag.String("theme", "", "catppuccin color theme (mocha, frappe, macchiato, latte)") + solidFlag = flag.Bool("solid", false, "paint opaque catppuccin background instead of transparent") ) flag.Parse() @@ -85,15 +88,39 @@ func main() { config.Toggle("sortReversed") } + // resolve catppuccin theme in order: -theme flag, CTOP_THEME env, default + themeName := "mocha" + switch { + case *themeFlag != "": + themeName = *themeFlag + case os.Getenv("CTOP_THEME") != "": + themeName = os.Getenv("CTOP_THEME") + } + flavor, ok := theme.ParseFlavor(themeName) + if !ok { + fmt.Printf("invalid theme: %q (available: %s)\n", themeName, strings.Join(theme.Flavors(), ", ")) + os.Exit(1) + } + theme.Set(flavor) + solid := *solidFlag + switch os.Getenv("CTOP_SOLID") { + case "1", "true", "TRUE", "yes", "YES": + solid = true + } + theme.SetOpaque(solid) + // init ui if *invertFlag { InvertColorMap() + } else { + SetColorMap() } ui.ColorMap = ColorMap // override default colormap if err := ui.Init(); err != nil { panic(err) } tm.SetInputMode(tm.InputAlt) + tm.SetOutputMode(tm.Output256) // enable 256-color catppuccin palette defer Shutdown() // init grid, cursor, header @@ -150,6 +177,8 @@ options: func printHelp() { fmt.Println(helpMsg) flag.PrintDefaults() + fmt.Printf("\ncatppuccin themes: mocha (default), frappe, macchiato, latte\n") + fmt.Printf("select a theme with -theme or the CTOP_THEME environment variable\n") fmt.Printf("\navailable connectors: ") fmt.Println(strings.Join(connector.Enabled(), ", ")) } diff --git a/theme/theme.go b/theme/theme.go new file mode 100644 index 0000000..9eaa48e --- /dev/null +++ b/theme/theme.go @@ -0,0 +1,217 @@ +package theme + +import ui "github.com/gizak/termui" + +type Flavor string +const ( + Mocha Flavor = "mocha" + Frappe Flavor = "frappe" + Macchiato Flavor = "macchiato" + Latte Flavor = "latte" +) + +func Flavors() []string { return []string{"mocha", "frappe", "macchiato", "latte"} } + +func ParseFlavor(s string) (Flavor, bool) { + if s == "mocha" { return Mocha, true } + if s == "frappe" { return Frappe, true } + if s == "macchiato" { return Macchiato, true } + if s == "latte" { return Latte, true } + return "", false +} + +var paletteMocha = map[string]int{ + "rosewater": 224, + "flamingo": 224, + "pink": 218, + "mauve": 183, + "red": 211, + "maroon": 181, + "peach": 216, + "yellow": 223, + "green": 151, + "teal": 116, + "sky": 116, + "sapphire": 117, + "blue": 111, + "lavender": 147, + "text": 189, + "subtext1": 146, + "subtext0": 146, + "overlay2": 103, + "overlay1": 103, + "overlay0": 243, + "surface2": 241, + "surface1": 239, + "surface0": 237, + "base": 235, + "mantle": 234, + "crust": 233, +} + +var paletteFrappe = map[string]int{ + "rosewater": 224, + "flamingo": 217, + "pink": 218, + "mauve": 182, + "red": 174, + "maroon": 175, + "peach": 216, + "yellow": 186, + "green": 150, + "teal": 115, + "sky": 116, + "sapphire": 110, + "blue": 111, + "lavender": 147, + "text": 189, + "subtext1": 146, + "subtext0": 146, + "overlay2": 109, + "overlay1": 103, + "overlay0": 243, + "surface2": 60, + "surface1": 59, + "surface0": 239, + "base": 237, + "mantle": 236, + "crust": 235, +} + +var paletteMacchiato = map[string]int{ + "rosewater": 224, + "flamingo": 224, + "pink": 218, + "mauve": 183, + "red": 210, + "maroon": 211, + "peach": 216, + "yellow": 223, + "green": 150, + "teal": 116, + "sky": 116, + "sapphire": 116, + "blue": 111, + "lavender": 147, + "text": 189, + "subtext1": 146, + "subtext0": 146, + "overlay2": 103, + "overlay1": 103, + "overlay0": 243, + "surface2": 60, + "surface1": 240, + "surface0": 238, + "base": 236, + "mantle": 235, + "crust": 234, +} + +var paletteLatte = map[string]int{ + "rosewater": 174, + "flamingo": 174, + "pink": 176, + "mauve": 99, + "red": 161, + "maroon": 167, + "peach": 202, + "yellow": 172, + "green": 70, + "teal": 30, + "sky": 38, + "sapphire": 37, + "blue": 27, + "lavender": 69, + "text": 240, + "subtext1": 60, + "subtext0": 243, + "overlay2": 102, + "overlay1": 246, + "overlay0": 248, + "surface2": 249, + "surface1": 7, + "surface0": 252, + "base": 255, + "mantle": 255, + "crust": 254, +} + +var palettes = map[Flavor]map[string]int{ + Mocha: paletteMocha, + Frappe: paletteFrappe, + Macchiato: paletteMacchiato, + Latte: paletteLatte, +} + +var slots = map[string]string{ + "fg": "text", + "bg": "", + "block.bg": "", + "border.bg": "", + "border.fg": "overlay1", + "label.fg": "mauve", + "menu.text.fg": "text", + "menu.text.bg": "", + "menu.border.fg": "mauve", + "menu.label.fg": "mauve", + "header.fg": "crust", + "header.bg": "mauve", + "gauge.bar.bg": "teal", + "gauge.percent.fg": "text", + "linechart.axes.fg": "overlay1", + "linechart.line.fg": "blue", + "mbarchart.bar.bg": "teal", + "mbarchart.num.fg": "text", + "mbarchart.text.fg": "text", + "par.text.fg": "text", + "par.text.bg": "", + "par.text.hi": "crust", + "sparkline.line.fg": "blue", + "sparkline.title.fg": "subtext1", + "status.ok": "green", + "status.warn": "yellow", + "status.danger": "red", + "io.read": "teal", + "io.write": "peach", + "net.rx": "teal", + "net.tx": "peach", +} + +var current = Mocha +var opaque = false + +func Set(f Flavor) { current = f } +func Current() Flavor { return current } + +// SetOpaque toggles whether background slots paint the flavor's base color +// (opaque) or leave the terminal background untouched (transparent). +func SetOpaque(v bool) { opaque = v } + +func Color(name string) ui.Attribute { + if p, ok := palettes[current]; ok { + if idx, ok := p[name]; ok { + return ui.Attribute(idx + 1) + } + } + return ui.ColorDefault +} + +func Attr(slot string) ui.Attribute { + if name, ok := slots[slot]; ok { + if name == "" && opaque { + return Color("base") + } + return Color(name) + } + return ui.ColorDefault +} + +func BuildColorMap() map[string]ui.Attribute { + m := make(map[string]ui.Attribute, len(slots)+4) + for slot := range slots { + m[slot] = Attr(slot) + } + m["par.fg"] = Color("text") + m["par.label.bg"] = Color("surface0") + return m +} diff --git a/theme/theme_test.go b/theme/theme_test.go new file mode 100644 index 0000000..990f66d --- /dev/null +++ b/theme/theme_test.go @@ -0,0 +1,177 @@ +package theme + +import ( + "testing" + + ui "github.com/gizak/termui" +) + +func TestPaletteIndices(t *testing.T) {} +var want = map[Flavor]map[string]int{ + Mocha: { + "rosewater": 224, + "flamingo": 224, + "pink": 218, + "mauve": 183, + "red": 211, + "maroon": 181, + "peach": 216, + "yellow": 223, + "green": 151, + "teal": 116, + "sky": 116, + "sapphire": 117, + "blue": 111, + "lavender": 147, + "text": 189, + "subtext1": 146, + "subtext0": 146, + "overlay2": 103, + "overlay1": 103, + "overlay0": 243, + "surface2": 241, + "surface1": 239, + "surface0": 237, + "base": 235, + "mantle": 234, + "crust": 233, + }, + Frappe: { + "rosewater": 224, + "flamingo": 217, + "pink": 218, + "mauve": 182, + "red": 174, + "maroon": 175, + "peach": 216, + "yellow": 186, + "green": 150, + "teal": 115, + "sky": 116, + "sapphire": 110, + "blue": 111, + "lavender": 147, + "text": 189, + "subtext1": 146, + "subtext0": 146, + "overlay2": 109, + "overlay1": 103, + "overlay0": 243, + "surface2": 60, + "surface1": 59, + "surface0": 239, + "base": 237, + "mantle": 236, + "crust": 235, + }, + Macchiato: { + "rosewater": 224, + "flamingo": 224, + "pink": 218, + "mauve": 183, + "red": 210, + "maroon": 211, + "peach": 216, + "yellow": 223, + "green": 150, + "teal": 116, + "sky": 116, + "sapphire": 116, + "blue": 111, + "lavender": 147, + "text": 189, + "subtext1": 146, + "subtext0": 146, + "overlay2": 103, + "overlay1": 103, + "overlay0": 243, + "surface2": 60, + "surface1": 240, + "surface0": 238, + "base": 236, + "mantle": 235, + "crust": 234, + }, + Latte: { + "rosewater": 174, + "flamingo": 174, + "pink": 176, + "mauve": 99, + "red": 161, + "maroon": 167, + "peach": 202, + "yellow": 172, + "green": 70, + "teal": 30, + "sky": 38, + "sapphire": 37, + "blue": 27, + "lavender": 69, + "text": 240, + "subtext1": 60, + "subtext0": 243, + "overlay2": 102, + "overlay1": 246, + "overlay0": 248, + "surface2": 249, + "surface1": 7, + "surface0": 252, + "base": 255, + "mantle": 255, + "crust": 254, + }, +} + +func TestPaletteColors(t *testing.T) { + for flavor, m := range want { + Set(flavor) + for name, idx := range m { + got := int(Color(name)) + if got != idx+1 { + t.Errorf("%s/%s: Color=%d want %d", flavor, name, got, idx+1) + } + } + } +} + +func TestSlotsResolve(t *testing.T) { + for _, f := range []Flavor{Mocha, Frappe, Macchiato, Latte} { + Set(f) + m := BuildColorMap() + for slot := range slots { + if _, ok := m[slot]; !ok { t.Fatalf("%s: missing slot %s", f, slot) } + } + } +} + +func TestParseFlavor(t *testing.T) { + for _, s := range []string{"mocha","frappe","macchiato","latte"} { + if _, ok := ParseFlavor(s); !ok { t.Errorf("ParseFlavor(%s) failed", s) } + } + if _, ok := ParseFlavor("bogus"); ok { t.Error("bogus flavor accepted") } +} + +func TestTransparentSlots(t *testing.T) { + Set(Mocha) + SetOpaque(false) + for _, slot := range []string{"bg", "block.bg", "border.bg", "par.text.bg", "menu.text.bg"} { + if got := Attr(slot); got != ui.ColorDefault { + t.Errorf("transparent %s: got %d want ColorDefault(0)", slot, got) + } + } +} + +func TestOpaqueSlots(t *testing.T) { + Set(Mocha) + SetOpaque(true) + base := Color("base") + if base == 0 { t.Fatal("Color(base) should not be ColorDefault") } + for _, slot := range []string{"bg", "block.bg", "border.bg", "par.text.bg", "menu.text.bg"} { + if got := Attr(slot); got != base { + t.Errorf("opaque %s: got %d want base(%d)", slot, got, base) + } + } + SetOpaque(false) // restore +} + +var _ = ui.ColorDefault // keep import diff --git a/widgets/menu/main.go b/widgets/menu/main.go index 476bab8..6fb7afd 100644 --- a/widgets/menu/main.go +++ b/widgets/menu/main.go @@ -3,6 +3,7 @@ package menu import ( "sort" + "github.com/bcicen/ctop/theme" ui "github.com/gizak/termui" ) @@ -105,7 +106,7 @@ func (m *Menu) Buffer() ui.Buffer { for _, ch := range item.Text() { // invert bg/fg colors on currently selected row if m.Selectable && n == m.cursorPos { - cell = ui.Cell{Ch: ch, Fg: ui.ColorBlack, Bg: m.TextFgColor} + cell = ui.Cell{Ch: ch, Fg: theme.Attr("crust"), Bg: m.TextFgColor} } else { cell = ui.Cell{Ch: ch, Fg: m.TextFgColor, Bg: m.TextBgColor} }