Add custom theming

This commit is contained in:
2026-07-28 20:09:40 +02:00
parent a0098a3946
commit ef32aa473e
14 changed files with 687 additions and 147 deletions

View File

@@ -74,7 +74,7 @@ cache directories remain fallback locations when their new `diple`
counterparts do not yet exist.
```toml
theme = "dark" # dark, light, high-contrast, or no-color
theme = "dark" # built-in name, "custom", or an accessibility mode
refresh_interval = "10s"
repository = "" # optional owner/repository default
show_all = false # requires repository
@@ -192,6 +192,40 @@ repeat_find = [";"]
repeat_find_reverse = [","]
```
Themes are compiled into `diple`; they do not require a separate download.
Available names are `dark`, `light`, `catppuccin` (`catppuccin-mocha`),
`catppuccin-latte`, `gruvbox` (`gruvbox-dark`), `gruvbox-light`,
`one-dark-pro`, `github` (`github-dark`), `github-light`, `high-contrast`,
and `no-color`.
Set `theme = "custom"` to inherit a built-in palette and replace only the
roles you care about:
```toml
theme = "custom"
[custom_theme]
base = "catppuccin-mocha" # defaults to "dark"
mode = "dark" # "dark" or "light"; controls Markdown rendering
title = "#F5C2E7"
active_foreground = "#1E1E2E"
active_background = "#89B4FA"
selection_background = "#313244"
author_palette = ["#89B4FA", "#CBA6F7", "#94E2D5", "#F9E2AF"]
syntax_theme = "catppuccin-mocha"
```
Every color override uses `#RRGGBB`. The complete set of roles is `title`,
`dim`, `text`, `active_foreground`, `active_background`, `success`, `warning`,
`error`, `editor_foreground`, `editor_background`, `pane_inactive`,
`pane_active`, `quote`, `selection_background`,
`suggestion_remove_background`, `suggestion_add_background`,
`changed_remove_background`, and `changed_add_background`.
`author_palette` accepts one or more colors. `syntax_theme` accepts an installed
Chroma style name; invalid colors, bases, and syntax styles are reported as
configuration errors at startup. The `[custom_theme]` table is ignored unless
`theme = "custom"`.
Every command binding accepts one or more Bubble Tea key names. Omitted
settings retain their defaults, while an explicitly configured action replaces
its default keys. Printable keys remain text in Insert mode, reply drafts, and

View File

@@ -36,9 +36,35 @@ type Config struct {
Threads ThreadConfig `toml:"threads"`
Cache CacheConfig `toml:"cache"`
Editing EditingConfig `toml:"editing"`
CustomTheme CustomThemeConfig `toml:"custom_theme"`
KeyBindings KeyBindings `toml:"keybindings"`
}
type CustomThemeConfig struct {
Base string `toml:"base"`
Mode string `toml:"mode"`
Title string `toml:"title"`
Dim string `toml:"dim"`
Text string `toml:"text"`
ActiveForeground string `toml:"active_foreground"`
ActiveBackground string `toml:"active_background"`
Success string `toml:"success"`
Warning string `toml:"warning"`
Error string `toml:"error"`
EditorForeground string `toml:"editor_foreground"`
EditorBackground string `toml:"editor_background"`
PaneInactive string `toml:"pane_inactive"`
PaneActive string `toml:"pane_active"`
Quote string `toml:"quote"`
SelectionBackground string `toml:"selection_background"`
SuggestionRemoveBackground string `toml:"suggestion_remove_background"`
SuggestionAddBackground string `toml:"suggestion_add_background"`
ChangedRemoveBackground string `toml:"changed_remove_background"`
ChangedAddBackground string `toml:"changed_add_background"`
AuthorPalette []string `toml:"author_palette"`
SyntaxTheme string `toml:"syntax_theme"`
}
type DisplayConfig struct {
FoldResolved bool `toml:"fold_resolved"`
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
@@ -160,10 +186,8 @@ func loadConfig(path string, required bool) (Config, error) {
}
func validateConfig(config Config) error {
switch config.Theme {
case "dark", "light", "no-color", "high-contrast":
default:
return fmt.Errorf("theme must be dark or light")
if _, err := resolveThemePalette(config.Theme, config.CustomTheme); err != nil {
return err
}
if config.RefreshInterval.Duration < 2*time.Second {
return fmt.Errorf("refresh_interval must be at least 2s")

View File

@@ -87,6 +87,39 @@ up = ["ctrl+k"]
}
}
func TestLoadConfigParsesCustomTheme(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
content := `
theme = "custom"
[custom_theme]
base = "catppuccin-mocha"
mode = "dark"
title = "#112233"
selection_background = "#223344"
author_palette = ["#334455", "#445566"]
syntax_theme = "gruvbox"
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
got, err := loadConfig(path, true)
if err != nil {
t.Fatal(err)
}
if err := validateConfig(got); err != nil {
t.Fatal(err)
}
if got.Theme != "custom" ||
got.CustomTheme.Base != "catppuccin-mocha" ||
got.CustomTheme.Title != "#112233" ||
got.CustomTheme.SelectionBackground != "#223344" ||
len(got.CustomTheme.AuthorPalette) != 2 ||
got.CustomTheme.SyntaxTheme != "gruvbox" {
t.Fatalf("custom theme config = %#v", got.CustomTheme)
}
}
func TestValidateConfigRejectsEmptyKeyBinding(t *testing.T) {
config := defaultConfig()
config.KeyBindings.Navigation.Down = nil

View File

@@ -3,11 +3,11 @@ package main
import (
"bytes"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/quick"
)
@@ -252,9 +252,9 @@ func highlightedSource(lexer, source string) string {
}
func lexerForPath(path string) string {
ext := strings.TrimPrefix(filepath.Ext(path), ".")
if ext == "" {
return "plaintext"
lexer := lexers.Match(path)
if lexer == nil {
return ""
}
return ext
return lexer.Config().Name
}

38
highlight_lexer_test.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"testing"
"github.com/alecthomas/chroma/v2/lexers"
)
func TestLexerForPathUsesCompleteFilename(t *testing.T) {
tests := []struct {
path string
want string
}{
{"Dockerfile", "Docker"},
{"Makefile", "Makefile"},
{"src/component.tsx", "TypeScript"},
{"scripts/check.py", "Python"},
{".github/workflows/test.yml", "YAML"},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
got := lexerForPath(test.path)
lexer := lexers.Get(got)
if lexer == nil {
t.Fatalf("lexerForPath(%q) = %q, which is not registered", test.path, got)
}
if lexer.Config().Name != test.want {
t.Fatalf("lexerForPath(%q) selected %q, want %q", test.path, lexer.Config().Name, test.want)
}
})
}
}
func TestLexerForUnknownPathAllowsContentAnalysis(t *testing.T) {
if got := lexerForPath("LICENSE.unknown-extension"); got != "" {
t.Fatalf("unknown path selected %q instead of allowing content analysis", got)
}
}

View File

@@ -23,7 +23,7 @@ func main() {
showAll = flag.Bool("all", defaults.ShowAll, "show all open PRs in --repo, not only PRs assigned to you")
limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-1000)")
endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint")
theme = flag.String("theme", defaults.Theme, "color theme: dark, light, high-contrast, or no-color")
theme = flag.String("theme", defaults.Theme, "color theme name (including custom, catppuccin, gruvbox, one-dark-pro, and github)")
foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded")
listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)")
dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "dashboard navigation: intermediate or hotkey")
@@ -109,7 +109,7 @@ func main() {
}
owner, name = parts[0], parts[1]
}
if err := applyTheme(config.Theme); err != nil {
if err := applyTheme(config.Theme, config.CustomTheme); err != nil {
exitf("configuration: %v", err)
}
token, err := resolveToken(config.Endpoint)

View File

@@ -5,6 +5,7 @@ import (
"sync"
"github.com/charmbracelet/glamour"
glamouransi "github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
)
@@ -79,12 +80,7 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
if cached, ok := commentMarkdownRenderers.Load(width); ok {
return cached.(*glamour.TermRenderer), nil
}
style := styles.DarkStyleConfig
if markdownStyleName == "light" {
style = styles.LightStyleConfig
} else if markdownStyleName == "notty" {
style = styles.NoTTYStyleConfig
}
style := markdownStyleForTheme()
zero := uint(0)
style.Document.Margin = &zero
style.Code.Prefix = ""
@@ -103,6 +99,70 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
return actual.(*glamour.TermRenderer), nil
}
func markdownStyleForTheme() glamouransi.StyleConfig {
if markdownStyleName == "notty" {
return styles.NoTTYStyleConfig
}
style := styles.DarkStyleConfig
if themeIsLight {
style = styles.LightStyleConfig
}
palette := editorMarkdownTheme
color := func(value string) *string { return &value }
truth := func(value bool) *bool { return &value }
style.Document.Color = color(palette.Text)
// Leave Text unset so inline text inherits the surrounding heading, link,
// quote, or paragraph color instead of flattening every Markdown token to
// the document foreground.
style.Text.Color = nil
style.Paragraph.Color = color(palette.Text)
style.Heading.Color = color(palette.Title)
style.H1.Color = color(palette.ActiveForeground)
style.H1.BackgroundColor = color(palette.ActiveBackground)
style.H2.Color = color(palette.Title)
style.H3.Color = color(palette.Title)
style.H4.Color = color(palette.Title)
style.H5.Color = color(palette.Title)
style.H6.Color = color(palette.Title)
style.HorizontalRule.Color = color(palette.Dim)
style.Item.Color = color(palette.Title)
style.Enumeration.Color = color(palette.Title)
style.Task.Color = color(palette.Text)
style.BlockQuote.Color = color(palette.Quote)
style.Strong.Color = color(palette.Text)
style.Strong.Bold = truth(true)
style.Emph.Color = color(palette.Text)
style.Link.Color = color(themeAuthorColor(palette, 0))
style.LinkText.Color = color(themeAuthorColor(palette, 1))
style.Image.Color = color(themeAuthorColor(palette, 0))
style.ImageText.Color = color(palette.Dim)
style.Code.Color = color(palette.Success)
style.Code.BackgroundColor = color(palette.EditorBackground)
style.CodeBlock.Color = color(palette.EditorForeground)
style.CodeBlock.BackgroundColor = color(palette.EditorBackground)
style.CodeBlock.Theme = palette.SyntaxTheme
style.CodeBlock.Chroma = nil
style.Table.Color = color(palette.Text)
style.Table.CenterSeparator = stringPointer("─")
style.Table.ColumnSeparator = stringPointer("│")
style.Table.RowSeparator = stringPointer("─")
style.DefinitionTerm.Color = color(palette.Title)
style.DefinitionDescription.Color = color(palette.Text)
return style
}
func themeAuthorColor(palette themePalette, index int) string {
if len(palette.AuthorPalette) == 0 {
return palette.Text
}
return palette.AuthorPalette[index%len(palette.AuthorPalette)]
}
func stringPointer(value string) *string {
return &value
}
func stripQuoteMarker(line string) (string, bool) {
trimmed := strings.TrimLeft(line, " \t")
if !strings.HasPrefix(trimmed, ">") {

View File

@@ -124,43 +124,30 @@ func editorMarkdownStyleStart(style editorMarkdownStyle) string {
return ""
}
}
if currentThemeName == "light" {
switch style {
case editorMarkdownHeading:
return "\x1b[1;38;2;154;103;0m"
case editorMarkdownStrong:
return "\x1b[1;38;2;130;80;223m"
case editorMarkdownEmphasis:
return "\x1b[3;38;2;87;96;106m"
case editorMarkdownCode:
return "\x1b[38;2;17;99;41m"
case editorMarkdownLink:
return "\x1b[4;38;2;9;105;218m"
case editorMarkdownDestination:
return "\x1b[38;2;10;112;111m"
case editorMarkdownQuote:
return "\x1b[38;2;154;103;0m"
case editorMarkdownComment:
return "\x1b[2;38;2;101;109;118m"
authors := editorMarkdownTheme.AuthorPalette
author := func(index int) string {
if len(authors) == 0 {
return editorMarkdownTheme.Text
}
return authors[index%len(authors)]
}
switch style {
case editorMarkdownHeading:
return "\x1b[1;38;2;240;183;47m"
return "\x1b[1m" + foregroundSequence(editorMarkdownTheme.Title)
case editorMarkdownStrong:
return "\x1b[1;38;2;198;120;221m"
return "\x1b[1m" + foregroundSequence(author(1))
case editorMarkdownEmphasis:
return "\x1b[3;38;2;215;218;232m"
return "\x1b[3m" + foregroundSequence(editorMarkdownTheme.Text)
case editorMarkdownCode:
return "\x1b[38;2;152;195;121m"
return foregroundSequence(editorMarkdownTheme.Success)
case editorMarkdownLink:
return "\x1b[4;38;2;97;175;239m"
return "\x1b[4m" + foregroundSequence(author(0))
case editorMarkdownDestination:
return "\x1b[38;2;86;182;194m"
return foregroundSequence(author(2))
case editorMarkdownQuote:
return "\x1b[38;2;229;192;123m"
return foregroundSequence(editorMarkdownTheme.Warning)
case editorMarkdownComment:
return "\x1b[2;38;2;119;119;119m"
return "\x1b[2m" + foregroundSequence(editorMarkdownTheme.Dim)
default:
return ""
}
@@ -168,15 +155,8 @@ func editorMarkdownStyleStart(style editorMarkdownStyle) string {
func editorMarkdownStyleEnd(active bool) string {
foreground := "\x1b[39m"
if active {
switch currentThemeName {
case "dark":
foreground = "\x1b[38;2;215;218;232m"
case "light":
foreground = "\x1b[38;2;36;41;47m"
case "high-contrast":
foreground = "\x1b[38;2;255;255;255m"
}
if active && colorEnabled {
foreground = foregroundSequence(editorMarkdownTheme.EditorForeground)
}
return "\x1b[22;23;24m" + foreground
}

View File

@@ -22,6 +22,10 @@ func TestCommentMarkdownDistinguishesQuoteAndReply(t *testing.T) {
}
func TestCommentMarkdownStylesInlineCode(t *testing.T) {
defer applyTheme("dark")
if err := applyTheme("dark"); err != nil {
t.Fatal(err)
}
rendered := strings.Join(renderCommentMarkdown(
"Use `list_comparison` for this value.", 60,
), "\n")
@@ -32,11 +36,30 @@ func TestCommentMarkdownStylesInlineCode(t *testing.T) {
if strings.Contains(plain, "Use list_comparison for") {
t.Fatalf("inline code added surrounding spaces: %q", plain)
}
if !strings.Contains(rendered, "48;5;236m") {
if !strings.Contains(rendered, "48;2;44;48;69m") {
t.Fatalf("inline code has no distinct background: %q", rendered)
}
}
func TestCommentMarkdownUsesActiveThemePalette(t *testing.T) {
defer applyTheme("dark")
if err := applyTheme("catppuccin-mocha"); err != nil {
t.Fatal(err)
}
rendered := strings.Join(renderCommentMarkdown(
"## Heading\n\nUse `value` and [link](https://example.com).\n\n```python\nif ready:\n return 1\n```",
80,
), "\n")
for _, sequence := range []string{
"38;2;203;166;247", // Catppuccin mauve heading/link.
"38;2;166;227;161", // Catppuccin green inline code/string.
} {
if !strings.Contains(rendered, sequence) {
t.Fatalf("Markdown did not use active palette color %q:\n%q", sequence, rendered)
}
}
}
func TestWrappedQuoteKeepsRailOnEveryLine(t *testing.T) {
const width = 28
lines := renderCommentMarkdown(

View File

@@ -88,8 +88,8 @@ func TestDetailRendersSuggestionAsRemovalAndAddition(t *testing.T) {
func TestSuggestionBackgroundIsDirectionalWithoutTextUnderline(t *testing.T) {
removed := suggestionHighlight(" - ", "old", 12, '-')
added := suggestionHighlight(" + ", "new", 12, '+')
if !strings.Contains(removed, "\x1b[48;5;52m") ||
!strings.Contains(added, "\x1b[48;5;22m") {
if !strings.Contains(removed, suggestionRemoveBackground) ||
!strings.Contains(added, suggestionAddBackground) {
t.Fatalf("suggestion decorations missing: removed=%q added=%q", removed, added)
}
if strings.Contains(removed, "\x1b[4m") || strings.Contains(added, "\x1b[4m") ||
@@ -100,8 +100,8 @@ func TestSuggestionBackgroundIsDirectionalWithoutTextUnderline(t *testing.T) {
if ansi.StringWidth(removed) != 12 || ansi.StringWidth(added) != 12 {
t.Fatal("suggestion backgrounds do not fill the row")
}
if !strings.Contains(removed, "\x1b[0m\x1b[48;5;52m ") ||
!strings.Contains(added, "\x1b[0m\x1b[48;5;22m ") {
if !strings.Contains(removed, "\x1b[0m"+suggestionRemoveBackground+" ") ||
!strings.Contains(added, "\x1b[0m"+suggestionAddBackground+" ") {
t.Fatal("padded row remainder is still underlined")
}
}

380
theme.go
View File

@@ -2,97 +2,329 @@ package main
import (
"fmt"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2/styles"
"github.com/charmbracelet/lipgloss"
)
var currentThemeName = "dark"
type themePalette struct {
Mode string
Title, Dim, Text string
ActiveForeground string
ActiveBackground string
Success, Warning, Error string
EditorForeground string
EditorBackground string
PaneInactive, PaneActive string
Quote string
SelectionBackground string
SuggestionRemoveBackground string
SuggestionAddBackground string
ChangedRemoveBackground string
ChangedAddBackground string
AuthorPalette []string
SyntaxTheme string
NoColor bool
HighContrast bool
}
func applyTheme(name string) error {
colorEnabled = true
switch name {
case "dark":
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#D7DAE8")).
Background(lipgloss.Color("#2C3045"))
paneInactiveColor = lipgloss.Color("#50566F")
paneActiveColor = lipgloss.Color("#F0B72F")
authorPalette = []lipgloss.Color{
"#61AFEF", "#C678DD", "#56B6C2", "#E5C07B",
"#E06C75", "#98C379", "#D19A66", "#7FC8FF",
var (
currentThemeName = "dark"
themeIsLight bool
editorMarkdownTheme themePalette
)
func applyTheme(name string, custom ...CustomThemeConfig) error {
var configured CustomThemeConfig
if len(custom) > 0 {
configured = custom[0]
}
codeHighlightTheme = "github-dark"
markdownStyleName = "dark"
quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
selectedLineBackground = "\x1b[48;5;24m"
suggestionRemoveBackground = "\x1b[48;5;52m"
suggestionAddBackground = "\x1b[48;5;22m"
changedRemoveBackground = "\x1b[48;2;55;0;0m"
changedAddBackground = "\x1b[48;2;0;55;0m"
case "light":
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#9A6700"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#656D76"))
activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#0969DA"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1A7F37"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9A6700"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CF222E"))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#24292F")).
Background(lipgloss.Color("#DDE8FF"))
paneInactiveColor = lipgloss.Color("#8C959F")
paneActiveColor = lipgloss.Color("#0969DA")
authorPalette = []lipgloss.Color{
"#0550AE", "#8250DF", "#0A706F", "#9A6700",
"#CF222E", "#116329", "#953800", "#0969DA",
palette, err := resolveThemePalette(name, configured)
if err != nil {
return err
}
codeHighlightTheme = "github"
markdownStyleName = "light"
quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#656D76"))
selectedLineBackground = "\x1b[48;5;153m"
suggestionRemoveBackground = "\x1b[48;5;224m"
suggestionAddBackground = "\x1b[48;5;194m"
changedRemoveBackground = "\x1b[48;2;255;170;170m"
changedAddBackground = "\x1b[48;2;170;230;170m"
case "high-contrast":
titleStyle = lipgloss.NewStyle().Bold(true).Underline(true).Foreground(lipgloss.Color("#FFFF00"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
activeStyle = lipgloss.NewStyle().Bold(true).Reverse(true)
okStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF00"))
warnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFF00"))
badStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5555"))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Reverse(true)
paneInactiveColor, paneActiveColor = lipgloss.Color("#FFFFFF"), lipgloss.Color("#FFFF00")
authorPalette = []lipgloss.Color{"#00FFFF", "#FF55FF", "#FFFF00", "#00FF00", "#FFFFFF"}
codeHighlightTheme, markdownStyleName = "github-dark", "dark"
quoteRailStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF"))
selectedLineBackground = "\x1b[7m"
suggestionRemoveBackground, suggestionAddBackground = "\x1b[48;5;88m", "\x1b[48;5;28m"
changedRemoveBackground, changedAddBackground = "\x1b[48;5;88m", "\x1b[48;5;28m"
case "no-color":
colorEnabled = false
titleStyle = lipgloss.NewStyle()
dimStyle = lipgloss.NewStyle()
colorEnabled = !palette.NoColor
themeIsLight = palette.Mode == "light"
editorMarkdownTheme = palette
if palette.NoColor {
titleStyle, dimStyle = lipgloss.NewStyle(), lipgloss.NewStyle()
activeStyle = lipgloss.NewStyle().Reverse(true)
okStyle, warnStyle, badStyle = lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()
editorLineStyle = lipgloss.NewStyle().Reverse(true)
paneInactiveColor, paneActiveColor = "", ""
authorPalette = []lipgloss.Color{""}
codeHighlightTheme, markdownStyleName = "github", "notty"
quoteRailStyle = lipgloss.NewStyle()
selectedLineBackground, suggestionRemoveBackground, suggestionAddBackground = "", "", ""
changedRemoveBackground, changedAddBackground = "", ""
default:
return fmt.Errorf("unknown theme %q", name)
codeHighlightTheme, markdownStyleName = "github", "notty"
} else {
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(palette.Title))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(palette.Dim))
activeStyle = lipgloss.NewStyle().Bold(true).
Foreground(lipgloss.Color(palette.ActiveForeground)).
Background(lipgloss.Color(palette.ActiveBackground))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(palette.Success))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(palette.Warning))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(palette.Error))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(palette.EditorForeground)).
Background(lipgloss.Color(palette.EditorBackground))
paneInactiveColor = lipgloss.Color(palette.PaneInactive)
paneActiveColor = lipgloss.Color(palette.PaneActive)
quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(palette.Quote))
authorPalette = make([]lipgloss.Color, len(palette.AuthorPalette))
for index, color := range palette.AuthorPalette {
authorPalette[index] = lipgloss.Color(color)
}
selectedLineBackground = backgroundSequence(palette.SelectionBackground)
suggestionRemoveBackground = backgroundSequence(palette.SuggestionRemoveBackground)
suggestionAddBackground = backgroundSequence(palette.SuggestionAddBackground)
changedRemoveBackground = backgroundSequence(palette.ChangedRemoveBackground)
changedAddBackground = backgroundSequence(palette.ChangedAddBackground)
codeHighlightTheme = palette.SyntaxTheme
markdownStyleName = "dark"
if themeIsLight {
markdownStyleName = "light"
}
if palette.HighContrast {
titleStyle = titleStyle.Underline(true)
activeStyle = lipgloss.NewStyle().Bold(true).Reverse(true)
okStyle, warnStyle, badStyle = okStyle.Bold(true), warnStyle.Bold(true), badStyle.Bold(true)
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(palette.EditorForeground)).Reverse(true)
quoteRailStyle = quoteRailStyle.Bold(true)
selectedLineBackground = "\x1b[7m"
}
}
currentThemeName = name
commentMarkdownRenderers.Clear()
return nil
}
func resolveThemePalette(name string, custom CustomThemeConfig) (themePalette, error) {
if name == "custom" {
base := custom.Base
if base == "" {
base = "dark"
}
if base == "custom" {
return themePalette{}, fmt.Errorf("custom_theme.base cannot be custom")
}
palette, err := resolveThemePalette(base, CustomThemeConfig{})
if err != nil {
return themePalette{}, fmt.Errorf("custom_theme.base: %w", err)
}
applyCustomTheme(&palette, custom)
if err := validateThemePalette(palette); err != nil {
return themePalette{}, fmt.Errorf("custom_theme: %w", err)
}
return palette, nil
}
palette, ok := builtinThemePalettes()[name]
if !ok {
return themePalette{}, fmt.Errorf(
"unknown theme %q; use dark, light, catppuccin, catppuccin-latte, "+
"gruvbox, gruvbox-light, one-dark-pro, github, github-light, "+
"high-contrast, no-color, or custom", name,
)
}
return palette, nil
}
func applyCustomTheme(palette *themePalette, custom CustomThemeConfig) {
set := func(target *string, value string) {
if value != "" {
*target = value
}
}
set(&palette.Mode, custom.Mode)
set(&palette.Title, custom.Title)
set(&palette.Dim, custom.Dim)
set(&palette.Text, custom.Text)
set(&palette.ActiveForeground, custom.ActiveForeground)
set(&palette.ActiveBackground, custom.ActiveBackground)
set(&palette.Success, custom.Success)
set(&palette.Warning, custom.Warning)
set(&palette.Error, custom.Error)
set(&palette.EditorForeground, custom.EditorForeground)
set(&palette.EditorBackground, custom.EditorBackground)
set(&palette.PaneInactive, custom.PaneInactive)
set(&palette.PaneActive, custom.PaneActive)
set(&palette.Quote, custom.Quote)
set(&palette.SelectionBackground, custom.SelectionBackground)
set(&palette.SuggestionRemoveBackground, custom.SuggestionRemoveBackground)
set(&palette.SuggestionAddBackground, custom.SuggestionAddBackground)
set(&palette.ChangedRemoveBackground, custom.ChangedRemoveBackground)
set(&palette.ChangedAddBackground, custom.ChangedAddBackground)
set(&palette.SyntaxTheme, custom.SyntaxTheme)
if len(custom.AuthorPalette) > 0 {
palette.AuthorPalette = append([]string(nil), custom.AuthorPalette...)
}
palette.NoColor, palette.HighContrast = false, false
}
func validateThemePalette(palette themePalette) error {
if palette.Mode != "dark" && palette.Mode != "light" {
return fmt.Errorf("mode must be dark or light")
}
colors := map[string]string{
"title": palette.Title, "dim": palette.Dim, "text": palette.Text,
"active_foreground": palette.ActiveForeground,
"active_background": palette.ActiveBackground,
"success": palette.Success, "warning": palette.Warning, "error": palette.Error,
"editor_foreground": palette.EditorForeground,
"editor_background": palette.EditorBackground,
"pane_inactive": palette.PaneInactive, "pane_active": palette.PaneActive,
"quote": palette.Quote, "selection_background": palette.SelectionBackground,
"suggestion_remove_background": palette.SuggestionRemoveBackground,
"suggestion_add_background": palette.SuggestionAddBackground,
"changed_remove_background": palette.ChangedRemoveBackground,
"changed_add_background": palette.ChangedAddBackground,
}
for name, value := range colors {
if !validHexColor(value) {
return fmt.Errorf("%s must be a #RRGGBB color", name)
}
}
if len(palette.AuthorPalette) == 0 {
return fmt.Errorf("author_palette must contain at least one color")
}
for _, color := range palette.AuthorPalette {
if !validHexColor(color) {
return fmt.Errorf("author_palette contains invalid color %q", color)
}
}
if _, ok := styles.Registry[palette.SyntaxTheme]; !ok {
return fmt.Errorf("unknown Chroma syntax_theme %q", palette.SyntaxTheme)
}
return nil
}
func validHexColor(value string) bool {
if len(value) != 7 || value[0] != '#' {
return false
}
_, err := strconv.ParseUint(value[1:], 16, 24)
return err == nil
}
func backgroundSequence(color string) string {
value, err := strconv.ParseUint(strings.TrimPrefix(color, "#"), 16, 24)
if err != nil {
return ""
}
return fmt.Sprintf(
"\x1b[48;2;%d;%d;%dm", (value>>16)&0xff, (value>>8)&0xff, value&0xff,
)
}
func foregroundSequence(color string) string {
value, err := strconv.ParseUint(strings.TrimPrefix(color, "#"), 16, 24)
if err != nil {
return ""
}
return fmt.Sprintf(
"\x1b[38;2;%d;%d;%dm", (value>>16)&0xff, (value>>8)&0xff, value&0xff,
)
}
func builtinThemePalettes() map[string]themePalette {
dark := palette(
"dark", "#F0B72F", "#777777", "#D7DAE8", "#FFFFFF", "#3B4261",
"#67C587", "#E5C07B", "#E06C75", "#D7DAE8", "#2C3045",
"#50566F", "#F0B72F", "#777777", "#18344F", "#370000", "#003700",
"#370000", "#003700", "onedark",
"#61AFEF", "#C678DD", "#56B6C2", "#E5C07B", "#E06C75", "#98C379", "#D19A66", "#7FC8FF",
)
light := palette(
"light", "#9A6700", "#656D76", "#24292F", "#FFFFFF", "#0969DA",
"#1A7F37", "#9A6700", "#CF222E", "#24292F", "#DDE8FF",
"#8C959F", "#0969DA", "#656D76", "#ADD6FF", "#FFD7D5", "#CCFFD8",
"#FFAAAA", "#AAE6AA", "github",
"#0550AE", "#8250DF", "#0A706F", "#9A6700", "#CF222E", "#116329", "#953800", "#0969DA",
)
catMocha := palette(
"dark", "#CBA6F7", "#6C7086", "#CDD6F4", "#1E1E2E", "#CBA6F7",
"#A6E3A1", "#F9E2AF", "#F38BA8", "#CDD6F4", "#313244",
"#45475A", "#CBA6F7", "#6C7086", "#313244", "#452B36", "#23402E",
"#512B3A", "#254936", "catppuccin-mocha",
"#89B4FA", "#CBA6F7", "#94E2D5", "#F9E2AF", "#F38BA8", "#A6E3A1", "#FAB387",
)
catLatte := palette(
"light", "#8839EF", "#8C8FA1", "#4C4F69", "#EFF1F5", "#8839EF",
"#40A02B", "#DF8E1D", "#D20F39", "#4C4F69", "#DCE0E8",
"#9CA0B0", "#8839EF", "#8C8FA1", "#CCD0DA", "#F2CDCD", "#C9E7CA",
"#EFB8C0", "#B8DDB5", "catppuccin-latte",
"#1E66F5", "#8839EF", "#179299", "#DF8E1D", "#D20F39", "#40A02B", "#FE640B",
)
gruvbox := palette(
"dark", "#FABD2F", "#928374", "#EBDBB2", "#282828", "#458588",
"#B8BB26", "#FABD2F", "#FB4934", "#EBDBB2", "#3C3836",
"#665C54", "#D3869B", "#928374", "#3C3836", "#4C2828", "#324028",
"#5A2929", "#354929", "gruvbox",
"#83A598", "#D3869B", "#8EC07C", "#FABD2F", "#FB4934", "#B8BB26", "#FE8019",
)
gruvboxLight := palette(
"light", "#D79921", "#928374", "#3C3836", "#FBF1C7", "#458588",
"#98971A", "#D79921", "#CC241D", "#3C3836", "#EBDBB2",
"#A89984", "#B16286", "#928374", "#D5C4A1", "#F2C8C5", "#D8E0B0",
"#E9B9B3", "#C9D59A", "gruvbox-light",
"#458588", "#B16286", "#689D6A", "#D79921", "#CC241D", "#98971A", "#D65D0E",
)
oneDark := palette(
"dark", "#E5C07B", "#5C6370", "#ABB2BF", "#FFFFFF", "#3E4451",
"#98C379", "#E5C07B", "#E06C75", "#ABB2BF", "#2C313C",
"#4B5263", "#61AFEF", "#5C6370", "#2C313C", "#4B2B31", "#2D4032",
"#562D35", "#314A35", "onedark",
"#61AFEF", "#C678DD", "#56B6C2", "#E5C07B", "#E06C75", "#98C379", "#D19A66",
)
githubDark := palette(
"dark", "#D29922", "#8B949E", "#E6EDF3", "#FFFFFF", "#1F6FEB",
"#3FB950", "#D29922", "#F85149", "#E6EDF3", "#161B22",
"#30363D", "#58A6FF", "#8B949E", "#1F2937", "#4A2028", "#183D2A",
"#5A222C", "#1C4A30", "github-dark",
"#58A6FF", "#BC8CFF", "#39C5CF", "#D29922", "#F85149", "#3FB950", "#DB6D28",
)
highContrast := palette(
"dark", "#FFFF00", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#000000",
"#00FF00", "#FFFF00", "#FF5555", "#FFFFFF", "#000000",
"#FFFFFF", "#FFFF00", "#FFFFFF", "#000080", "#5F0000", "#005F00",
"#5F0000", "#005F00", "github-dark",
"#00FFFF", "#FF55FF", "#FFFF00", "#00FF00", "#FFFFFF",
)
highContrast.HighContrast = true
noColor := dark
noColor.NoColor = true
return map[string]themePalette{
"dark": dark, "light": light,
"catppuccin": catMocha, "catppuccin-mocha": catMocha,
"catppuccin-latte": catLatte,
"gruvbox": gruvbox, "gruvbox-dark": gruvbox, "gruvbox-light": gruvboxLight,
"one-dark-pro": oneDark, "onedark": oneDark,
"github": githubDark, "github-dark": githubDark, "github-light": light,
"high-contrast": highContrast, "no-color": noColor,
}
}
func palette(
mode, title, dim, text, activeFG, activeBG, success, warning, failure,
editorFG, editorBG, paneInactive, paneActive, quote, selection,
suggestionRemove, suggestionAdd, changedRemove, changedAdd, syntax string,
authors ...string,
) themePalette {
return themePalette{
Mode: mode, Title: title, Dim: dim, Text: text,
ActiveForeground: activeFG, ActiveBackground: activeBG,
Success: success, Warning: warning, Error: failure,
EditorForeground: editorFG, EditorBackground: editorBG,
PaneInactive: paneInactive, PaneActive: paneActive, Quote: quote,
SelectionBackground: selection,
SuggestionRemoveBackground: suggestionRemove,
SuggestionAddBackground: suggestionAdd,
ChangedRemoveBackground: changedRemove, ChangedAddBackground: changedAdd,
AuthorPalette: append([]string(nil), authors...), SyntaxTheme: syntax,
}
}

View File

@@ -3,6 +3,8 @@ package main
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
@@ -11,6 +13,12 @@ func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
t.Fatal(err)
}
}()
if err := applyTheme("dark"); err != nil {
t.Fatal(err)
}
if codeHighlightTheme != "onedark" {
t.Fatalf("dark syntax theme = %q, want onedark", codeHighlightTheme)
}
if err := applyTheme("light"); err != nil {
t.Fatal(err)
}
@@ -32,3 +40,110 @@ func TestNoColorThemeDisablesSyntaxColors(t *testing.T) {
t.Fatalf("no-color source contains terminal colors: %q", rendered)
}
}
func TestBuiltinThemesApply(t *testing.T) {
defer applyTheme("dark")
names := []string{
"dark", "light",
"catppuccin", "catppuccin-mocha", "catppuccin-latte",
"gruvbox", "gruvbox-dark", "gruvbox-light",
"one-dark-pro", "github", "github-dark", "github-light",
"high-contrast", "no-color",
}
for _, name := range names {
t.Run(name, func(t *testing.T) {
if err := applyTheme(name); err != nil {
t.Fatal(err)
}
if len(authorPalette) == 0 {
t.Fatal("theme has no author colors")
}
})
}
}
func TestLightBuiltinThemesSelectLightRendering(t *testing.T) {
defer applyTheme("dark")
for _, name := range []string{"light", "catppuccin-latte", "gruvbox-light", "github-light"} {
if err := applyTheme(name); err != nil {
t.Fatal(err)
}
if !themeIsLight || markdownStyleName != "light" {
t.Fatalf("%s was not treated as a light theme", name)
}
}
}
func TestCustomThemeOverlaysBuiltinBase(t *testing.T) {
defer applyTheme("dark")
custom := CustomThemeConfig{
Base: "catppuccin-mocha",
Title: "#010203",
AuthorPalette: []string{"#112233", "#445566"},
SyntaxTheme: "gruvbox",
}
if err := applyTheme("custom", custom); err != nil {
t.Fatal(err)
}
if currentThemeName != "custom" ||
titleStyle.GetForeground() != lipgloss.Color("#010203") ||
codeHighlightTheme != "gruvbox" {
t.Fatalf(
"custom theme was not applied: name=%q title=%q syntax=%q",
currentThemeName, titleStyle.GetForeground(), codeHighlightTheme,
)
}
if len(authorPalette) != 2 ||
authorPalette[0] != lipgloss.Color("#112233") ||
authorPalette[1] != lipgloss.Color("#445566") {
t.Fatalf("custom author palette = %#v", authorPalette)
}
if selectedLineBackground == "" {
t.Fatal("custom theme did not inherit unspecified base colors")
}
}
func TestCustomThemeValidation(t *testing.T) {
tests := []struct {
name string
custom CustomThemeConfig
want string
}{
{
name: "invalid color",
custom: CustomThemeConfig{Title: "red"},
want: "title must be a #RRGGBB color",
},
{
name: "invalid syntax theme",
custom: CustomThemeConfig{SyntaxTheme: "not-a-chroma-theme"},
want: "unknown Chroma syntax_theme",
},
{
name: "recursive base",
custom: CustomThemeConfig{Base: "custom"},
want: "custom_theme.base cannot be custom",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := resolveThemePalette("custom", test.custom)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want it to contain %q", err, test.want)
}
})
}
}
func TestCatppuccinUsesCanonicalMauveAccent(t *testing.T) {
palette, err := resolveThemePalette("catppuccin-mocha", CustomThemeConfig{})
if err != nil {
t.Fatal(err)
}
if palette.Title != "#CBA6F7" || palette.ActiveBackground != "#CBA6F7" {
t.Fatalf(
"Catppuccin accent is title=%q active=%q, want mauve",
palette.Title, palette.ActiveBackground,
)
}
}

1
tui.go
View File

@@ -2314,6 +2314,7 @@ func (m App) healthLines() []string {
components = append(components, refresh)
components = append(components, HealthComponent{
Name: "configuration", Level: healthOK, Summary: "configuration loaded and validated",
Detail: "theme " + currentThemeName,
})
if m.readState != nil {
component := HealthComponent{

View File

@@ -1704,7 +1704,7 @@ func TestTabHidesListAndHRestoresIt(t *testing.T) {
func TestSelectedBackgroundFillsLine(t *testing.T) {
rendered := selectedBackground("code", 12)
if !strings.Contains(rendered, "\x1b[48;5;24m") || ansi.StringWidth(rendered) != 12 {
if !strings.Contains(rendered, selectedLineBackground) || ansi.StringWidth(rendered) != 12 {
t.Fatalf("selected background was not full width: %q", rendered)
}
}