From ef32aa473e17062b00c7e1c3c7e9b9e7f976d481 Mon Sep 17 00:00:00 2001 From: Pablu Date: Tue, 28 Jul 2026 20:09:40 +0200 Subject: [PATCH] Add custom theming --- README.md | 36 +++- config.go | 56 +++-- config_test.go | 33 +++ highlight.go | 10 +- highlight_lexer_test.go | 38 ++++ main.go | 4 +- markdown.go | 72 ++++++- markdown_editor_highlight.go | 50 ++--- markdown_test.go | 25 ++- suggestions_test.go | 8 +- theme.go | 384 ++++++++++++++++++++++++++++------- theme_test.go | 115 +++++++++++ tui.go | 1 + tui_test.go | 2 +- 14 files changed, 687 insertions(+), 147 deletions(-) create mode 100644 highlight_lexer_test.go diff --git a/README.md b/README.md index cc09204..217e9a3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.go b/config.go index 642b2a1..45eb6da 100644 --- a/config.go +++ b/config.go @@ -25,18 +25,44 @@ func (d *configDuration) UnmarshalText(text []byte) error { } type Config struct { - Theme string `toml:"theme"` - RefreshInterval configDuration `toml:"refresh_interval"` - Repository string `toml:"repository"` - ShowAll bool `toml:"show_all"` - Limit int `toml:"limit"` - Endpoint string `toml:"endpoint"` - Display DisplayConfig `toml:"display"` - Paths PathConfig `toml:"paths"` - Threads ThreadConfig `toml:"threads"` - Cache CacheConfig `toml:"cache"` - Editing EditingConfig `toml:"editing"` - KeyBindings KeyBindings `toml:"keybindings"` + Theme string `toml:"theme"` + RefreshInterval configDuration `toml:"refresh_interval"` + Repository string `toml:"repository"` + ShowAll bool `toml:"show_all"` + Limit int `toml:"limit"` + Endpoint string `toml:"endpoint"` + Display DisplayConfig `toml:"display"` + Paths PathConfig `toml:"paths"` + 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 { @@ -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") diff --git a/config_test.go b/config_test.go index 14d70bd..4b946b8 100644 --- a/config_test.go +++ b/config_test.go @@ -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 diff --git a/highlight.go b/highlight.go index c2bd61d..4c8891b 100644 --- a/highlight.go +++ b/highlight.go @@ -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 } diff --git a/highlight_lexer_test.go b/highlight_lexer_test.go new file mode 100644 index 0000000..576b75b --- /dev/null +++ b/highlight_lexer_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 09623ca..179939a 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/markdown.go b/markdown.go index cc9d3af..a574a21 100644 --- a/markdown.go +++ b/markdown.go @@ -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, ">") { diff --git a/markdown_editor_highlight.go b/markdown_editor_highlight.go index 7b1c355..1352c04 100644 --- a/markdown_editor_highlight.go +++ b/markdown_editor_highlight.go @@ -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 } diff --git a/markdown_test.go b/markdown_test.go index 8fd0f4e..e3d4cf7 100644 --- a/markdown_test.go +++ b/markdown_test.go @@ -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( diff --git a/suggestions_test.go b/suggestions_test.go index 6ffc140..1ab201a 100644 --- a/suggestions_test.go +++ b/suggestions_test.go @@ -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") } } diff --git a/theme.go b/theme.go index eabe7cc..816b8f0 100644 --- a/theme.go +++ b/theme.go @@ -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", - } - 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", - } - 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() +var ( + currentThemeName = "dark" + themeIsLight bool + editorMarkdownTheme themePalette +) + +func applyTheme(name string, custom ...CustomThemeConfig) error { + var configured CustomThemeConfig + if len(custom) > 0 { + configured = custom[0] + } + palette, err := resolveThemePalette(name, configured) + if err != nil { + return err + } + 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, + } +} diff --git a/theme_test.go b/theme_test.go index aac219a..f9e4706 100644 --- a/theme_test.go +++ b/theme_test.go @@ -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, + ) + } +} diff --git a/tui.go b/tui.go index 3552765..5e6ef3d 100644 --- a/tui.go +++ b/tui.go @@ -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{ diff --git a/tui_test.go b/tui_test.go index ce9cfbb..5f1a128 100644 --- a/tui_test.go +++ b/tui_test.go @@ -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) } }