diff --git a/pkg/term/color.go b/pkg/term/color.go new file mode 100644 index 0000000..e71c19c --- /dev/null +++ b/pkg/term/color.go @@ -0,0 +1,193 @@ +package term + +import ( + "fmt" + + "github.com/mgutz/ansi" +) + +const ( + highlightStyle = "black:yellow" +) + +var ( + black = ansi.ColorFunc("black") + red = ansi.ColorFunc("red") + green = ansi.ColorFunc("green") + yellow = ansi.ColorFunc("yellow") + blue = ansi.ColorFunc("blue") + magenta = ansi.ColorFunc("magenta") + cyan = ansi.ColorFunc("cyan") + white = ansi.ColorFunc("white") + bold = ansi.ColorFunc("default+b") + + gray = ansi.ColorFunc("black+h") + gray256 = func(t string) string { + return fmt.Sprintf("\x1b[38;5;242m%s\x1b[0m", t) + } + + highlight = ansi.ColorFunc(highlightStyle) + highlightStart = ansi.ColorCode(highlightStyle) + + darkThemeMuted = ansi.ColorFunc("white+d") + lightThemeMuted = ansi.ColorFunc("black+h") +) + +// ColorScheme for the current [Term]. +type ColorScheme struct { + Accessible bool + ColorEnabled bool + Is256Enabled bool + Theme string +} + +func (c ColorScheme) Black(s string) string { + if !c.ColorEnabled { + return s + } + + return black(s) +} + +func (c ColorScheme) Red(s string) string { + if !c.ColorEnabled { + return s + } + + return red(s) +} + +func (c ColorScheme) Green(s string) string { + if !c.ColorEnabled { + return s + } + + return green(s) +} + +func (c ColorScheme) Yellow(s string) string { + if !c.ColorEnabled { + return s + } + + return yellow(s) +} + +func (c ColorScheme) Blue(s string) string { + if !c.ColorEnabled { + return s + } + + return blue(s) +} + +func (c ColorScheme) Magenta(s string) string { + if !c.ColorEnabled { + return s + } + + return magenta(s) +} + +func (c ColorScheme) Cyan(s string) string { + if !c.ColorEnabled { + return s + } + + return cyan(s) +} + +func (c ColorScheme) White(s string) string { + if !c.ColorEnabled { + return s + } + + return white(s) +} + +func (c ColorScheme) Bold(s string) string { + if !c.ColorEnabled { + return s + } + + return bold(s) +} + +func (c ColorScheme) gray(s string) string { + if !c.ColorEnabled { + return s + } + if c.Is256Enabled { + return gray256(s) + } + return gray(s) +} + +func (c ColorScheme) Muted(s string) string { + if !c.Accessible { + return c.gray(s) + } + + if !c.ColorEnabled { + return s + } + + switch c.Theme { + case LightTheme: + return lightThemeMuted(s) + case DarkTheme: + return darkThemeMuted(s) + default: + return s + } +} + +func (c ColorScheme) Highlight(s string) string { + if !c.ColorEnabled { + return s + } + + return highlight(s) +} + +// HighlightStart starts highlighting text. +// Use [ColorScheme.Reset] to end highlighting. +func (c ColorScheme) HighlightStart() string { + if !c.ColorEnabled { + return "" + } + + return highlightStart +} + +func (c ColorScheme) SuccessIcon() string { + return c.SuccessIconWithColor(c.Green) +} + +func (c ColorScheme) SuccessIconWithColor(f func(string) string) string { + return f("✓") +} + +func (c ColorScheme) WarningIcon() string { + return c.WarningIconWithColor(c.Yellow) +} + +func (c ColorScheme) WarningIconWithColor(f func(string) string) string { + return f("!") +} + +func (c ColorScheme) FailureIcon() string { + return c.FailureIconWithColor(c.Red) +} + +func (c ColorScheme) FailureIconWithColor(f func(string) string) string { + return f("X") +} + +func (c ColorScheme) Reset() string { + if !c.ColorEnabled { + return "" + } + + return ansi.Reset +} diff --git a/pkg/term/color_test.go b/pkg/term/color_test.go new file mode 100644 index 0000000..e6be46e --- /dev/null +++ b/pkg/term/color_test.go @@ -0,0 +1,249 @@ +package term + +import ( + "strings" + "testing" + + "github.com/mgutz/ansi" +) + +func TestColorSchemeMuted(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + want string + }{ + {name: "disabled returns plain text", cs: ColorScheme{ColorEnabled: false}, want: "plain"}, + {name: "default theme no color", cs: ColorScheme{ColorEnabled: true, Theme: NoTheme}, want: "plain"}, + {name: "light theme colorizes", cs: ColorScheme{Accessible: true, ColorEnabled: true, Theme: LightTheme}, want: "plain"}, + {name: "dark theme colorizes", cs: ColorScheme{Accessible: true, ColorEnabled: true, Theme: DarkTheme}, want: "plain"}, + {name: "inaccessible uses gray path with 256", cs: ColorScheme{Accessible: false, ColorEnabled: true, Is256Enabled: true}, want: "plain"}, + {name: "inaccessible uses gray path without 256", cs: ColorScheme{Accessible: false, ColorEnabled: true, Is256Enabled: false}, want: "plain"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cs.Muted("plain") + if !tt.cs.ColorEnabled { + if got != tt.want { + t.Fatalf("expected plain text when colors are disabled, got %q", got) + } + return + } + + switch { + case tt.cs.Accessible && tt.cs.Theme == LightTheme: + if got == tt.want || !strings.Contains(got, "\x1b[") { + t.Fatalf("expected light-theme muted output with ANSI codes, got %q", got) + } + case tt.cs.Accessible && tt.cs.Theme == DarkTheme: + if got == tt.want || !strings.Contains(got, "\x1b[") { + t.Fatalf("expected dark-theme muted output with ANSI codes, got %q", got) + } + case !tt.cs.Accessible && tt.cs.Is256Enabled: + if got == tt.want || !strings.Contains(got, "\x1b[") { + t.Fatalf("expected 256-color gray output, got %q", got) + } + case !tt.cs.Accessible: + if got == tt.want || !strings.Contains(got, "\x1b[") { + t.Fatalf("expected gray output, got %q", got) + } + default: + if got != tt.want { + t.Fatalf("expected plain text for default theme without color changes, got %q", got) + } + } + }) + } +} + +func TestColorSchemeHighlight(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + want string + }{ + {name: "disabled returns plain text", cs: ColorScheme{ColorEnabled: false}, want: "plain"}, + {name: "enabled returns highlighted text", cs: ColorScheme{ColorEnabled: true}, want: "plain"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cs.Highlight("plain") + if !tt.cs.ColorEnabled { + if got != tt.want { + t.Fatalf("expected plain text with colors disabled, got %q", got) + } + return + } + + if got == tt.want || !strings.Contains(got, "\x1b[") { + t.Fatalf("expected highlighted text to contain ANSI escapes, got %q", got) + } + }) + } +} + +func TestColorSchemeHighlightStart(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + want string + }{ + {name: "disabled returns empty", cs: ColorScheme{ColorEnabled: false}, want: ""}, + {name: "enabled returns highlight start sequence", cs: ColorScheme{ColorEnabled: true}, want: highlightStart}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cs.HighlightStart(); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestColorSchemeSuccessIcon(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + }{ + {name: "disabled", cs: ColorScheme{ColorEnabled: false}}, + {name: "enabled", cs: ColorScheme{ColorEnabled: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + want := "✓" + if tt.cs.ColorEnabled { + want = tt.cs.Green("✓") + } + if got := tt.cs.SuccessIcon(); got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) + } +} + +func TestColorSchemeSuccessIconWithColor(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + fn func(string) string + want string + }{ + {name: "custom formatter", cs: ColorScheme{ColorEnabled: true}, fn: func(s string) string { return "[green]" + s }, want: "[green]✓"}, + {name: "empty formatter", cs: ColorScheme{ColorEnabled: false}, fn: func(s string) string { return "" }, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cs.SuccessIconWithColor(tt.fn); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestColorSchemeWarningIcon(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + }{ + {name: "disabled", cs: ColorScheme{ColorEnabled: false}}, + {name: "enabled", cs: ColorScheme{ColorEnabled: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + want := "!" + if tt.cs.ColorEnabled { + want = tt.cs.Yellow("!") + } + if got := tt.cs.WarningIcon(); got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) + } +} + +func TestColorSchemeWarningIconWithColor(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + fn func(string) string + want string + }{ + {name: "custom formatter", cs: ColorScheme{ColorEnabled: true}, fn: func(s string) string { return "[yellow]" + s }, want: "[yellow]!"}, + {name: "empty formatter", cs: ColorScheme{ColorEnabled: false}, fn: func(s string) string { return "" }, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cs.WarningIconWithColor(tt.fn); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestColorSchemeFailureIcon(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + }{ + {name: "disabled", cs: ColorScheme{ColorEnabled: false}}, + {name: "enabled", cs: ColorScheme{ColorEnabled: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + want := "X" + if tt.cs.ColorEnabled { + want = tt.cs.Red("X") + } + if got := tt.cs.FailureIcon(); got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) + } +} + +func TestColorSchemeFailureIconWithColor(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + fn func(string) string + want string + }{ + {name: "custom formatter", cs: ColorScheme{ColorEnabled: true}, fn: func(s string) string { return "[red]" + s }, want: "[red]X"}, + {name: "empty formatter", cs: ColorScheme{ColorEnabled: false}, fn: func(s string) string { return "" }, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cs.FailureIconWithColor(tt.fn); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestColorSchemeReset(t *testing.T) { + tests := []struct { + name string + cs ColorScheme + want string + }{ + {name: "disabled returns empty", cs: ColorScheme{ColorEnabled: false}, want: ""}, + {name: "enabled returns ANSI reset", cs: ColorScheme{ColorEnabled: true}, want: ansi.Reset}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cs.Reset(); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} diff --git a/pkg/term/env.go b/pkg/term/env.go index c650cc2..cffc421 100644 --- a/pkg/term/env.go +++ b/pkg/term/env.go @@ -8,10 +8,17 @@ import ( "strconv" "strings" + "github.com/cli/go-gh/v2/pkg/x/color" "github.com/muesli/termenv" "golang.org/x/term" ) +const ( + NoTheme = "none" + LightTheme = "light" + DarkTheme = "dark" +) + // Term represents information about the terminal that a process is connected to. type Term struct { in *os.File @@ -139,12 +146,22 @@ func (t Term) Size() (int, int, error) { // Theme returns the theme of the terminal by analyzing the background color of the terminal. func (t Term) Theme() string { if !t.IsColorEnabled() { - return "none" + return NoTheme } if termenv.HasDarkBackground() { - return "dark" + return DarkTheme + } + return LightTheme +} + +// ColorScheme returns the [ColorScheme] for the current Term. +func (t Term) ColorScheme() ColorScheme { + return ColorScheme{ + Accessible: color.IsAccessibleColorsEnabled(), + ColorEnabled: t.colorEnabled, + Is256Enabled: t.is256enabled, + Theme: t.Theme(), } - return "light" } // IsTerminal reports whether a file descriptor is connected to a terminal.