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. counterparts do not yet exist.
```toml ```toml
theme = "dark" # dark, light, high-contrast, or no-color theme = "dark" # built-in name, "custom", or an accessibility mode
refresh_interval = "10s" refresh_interval = "10s"
repository = "" # optional owner/repository default repository = "" # optional owner/repository default
show_all = false # requires repository show_all = false # requires repository
@@ -192,6 +192,40 @@ repeat_find = [";"]
repeat_find_reverse = [","] 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 Every command binding accepts one or more Bubble Tea key names. Omitted
settings retain their defaults, while an explicitly configured action replaces settings retain their defaults, while an explicitly configured action replaces
its default keys. Printable keys remain text in Insert mode, reply drafts, and its default keys. Printable keys remain text in Insert mode, reply drafts, and

View File

@@ -25,18 +25,44 @@ func (d *configDuration) UnmarshalText(text []byte) error {
} }
type Config struct { type Config struct {
Theme string `toml:"theme"` Theme string `toml:"theme"`
RefreshInterval configDuration `toml:"refresh_interval"` RefreshInterval configDuration `toml:"refresh_interval"`
Repository string `toml:"repository"` Repository string `toml:"repository"`
ShowAll bool `toml:"show_all"` ShowAll bool `toml:"show_all"`
Limit int `toml:"limit"` Limit int `toml:"limit"`
Endpoint string `toml:"endpoint"` Endpoint string `toml:"endpoint"`
Display DisplayConfig `toml:"display"` Display DisplayConfig `toml:"display"`
Paths PathConfig `toml:"paths"` Paths PathConfig `toml:"paths"`
Threads ThreadConfig `toml:"threads"` Threads ThreadConfig `toml:"threads"`
Cache CacheConfig `toml:"cache"` Cache CacheConfig `toml:"cache"`
Editing EditingConfig `toml:"editing"` Editing EditingConfig `toml:"editing"`
KeyBindings KeyBindings `toml:"keybindings"` 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 { type DisplayConfig struct {
@@ -160,10 +186,8 @@ func loadConfig(path string, required bool) (Config, error) {
} }
func validateConfig(config Config) error { func validateConfig(config Config) error {
switch config.Theme { if _, err := resolveThemePalette(config.Theme, config.CustomTheme); err != nil {
case "dark", "light", "no-color", "high-contrast": return err
default:
return fmt.Errorf("theme must be dark or light")
} }
if config.RefreshInterval.Duration < 2*time.Second { if config.RefreshInterval.Duration < 2*time.Second {
return fmt.Errorf("refresh_interval must be at least 2s") 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) { func TestValidateConfigRejectsEmptyKeyBinding(t *testing.T) {
config := defaultConfig() config := defaultConfig()
config.KeyBindings.Navigation.Down = nil config.KeyBindings.Navigation.Down = nil

View File

@@ -3,11 +3,11 @@ package main
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/quick" "github.com/alecthomas/chroma/v2/quick"
) )
@@ -252,9 +252,9 @@ func highlightedSource(lexer, source string) string {
} }
func lexerForPath(path string) string { func lexerForPath(path string) string {
ext := strings.TrimPrefix(filepath.Ext(path), ".") lexer := lexers.Match(path)
if ext == "" { if lexer == nil {
return "plaintext" 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") 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)") limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-1000)")
endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint") 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") 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)") 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") 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] 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) exitf("configuration: %v", err)
} }
token, err := resolveToken(config.Endpoint) token, err := resolveToken(config.Endpoint)

View File

@@ -5,6 +5,7 @@ import (
"sync" "sync"
"github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour"
glamouransi "github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/glamour/styles" "github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -79,12 +80,7 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
if cached, ok := commentMarkdownRenderers.Load(width); ok { if cached, ok := commentMarkdownRenderers.Load(width); ok {
return cached.(*glamour.TermRenderer), nil return cached.(*glamour.TermRenderer), nil
} }
style := styles.DarkStyleConfig style := markdownStyleForTheme()
if markdownStyleName == "light" {
style = styles.LightStyleConfig
} else if markdownStyleName == "notty" {
style = styles.NoTTYStyleConfig
}
zero := uint(0) zero := uint(0)
style.Document.Margin = &zero style.Document.Margin = &zero
style.Code.Prefix = "" style.Code.Prefix = ""
@@ -103,6 +99,70 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
return actual.(*glamour.TermRenderer), nil 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) { func stripQuoteMarker(line string) (string, bool) {
trimmed := strings.TrimLeft(line, " \t") trimmed := strings.TrimLeft(line, " \t")
if !strings.HasPrefix(trimmed, ">") { if !strings.HasPrefix(trimmed, ">") {

View File

@@ -124,43 +124,30 @@ func editorMarkdownStyleStart(style editorMarkdownStyle) string {
return "" return ""
} }
} }
if currentThemeName == "light" { authors := editorMarkdownTheme.AuthorPalette
switch style { author := func(index int) string {
case editorMarkdownHeading: if len(authors) == 0 {
return "\x1b[1;38;2;154;103;0m" return editorMarkdownTheme.Text
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"
} }
return authors[index%len(authors)]
} }
switch style { switch style {
case editorMarkdownHeading: case editorMarkdownHeading:
return "\x1b[1;38;2;240;183;47m" return "\x1b[1m" + foregroundSequence(editorMarkdownTheme.Title)
case editorMarkdownStrong: case editorMarkdownStrong:
return "\x1b[1;38;2;198;120;221m" return "\x1b[1m" + foregroundSequence(author(1))
case editorMarkdownEmphasis: case editorMarkdownEmphasis:
return "\x1b[3;38;2;215;218;232m" return "\x1b[3m" + foregroundSequence(editorMarkdownTheme.Text)
case editorMarkdownCode: case editorMarkdownCode:
return "\x1b[38;2;152;195;121m" return foregroundSequence(editorMarkdownTheme.Success)
case editorMarkdownLink: case editorMarkdownLink:
return "\x1b[4;38;2;97;175;239m" return "\x1b[4m" + foregroundSequence(author(0))
case editorMarkdownDestination: case editorMarkdownDestination:
return "\x1b[38;2;86;182;194m" return foregroundSequence(author(2))
case editorMarkdownQuote: case editorMarkdownQuote:
return "\x1b[38;2;229;192;123m" return foregroundSequence(editorMarkdownTheme.Warning)
case editorMarkdownComment: case editorMarkdownComment:
return "\x1b[2;38;2;119;119;119m" return "\x1b[2m" + foregroundSequence(editorMarkdownTheme.Dim)
default: default:
return "" return ""
} }
@@ -168,15 +155,8 @@ func editorMarkdownStyleStart(style editorMarkdownStyle) string {
func editorMarkdownStyleEnd(active bool) string { func editorMarkdownStyleEnd(active bool) string {
foreground := "\x1b[39m" foreground := "\x1b[39m"
if active { if active && colorEnabled {
switch currentThemeName { foreground = foregroundSequence(editorMarkdownTheme.EditorForeground)
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"
}
} }
return "\x1b[22;23;24m" + foreground return "\x1b[22;23;24m" + foreground
} }

View File

@@ -22,6 +22,10 @@ func TestCommentMarkdownDistinguishesQuoteAndReply(t *testing.T) {
} }
func TestCommentMarkdownStylesInlineCode(t *testing.T) { func TestCommentMarkdownStylesInlineCode(t *testing.T) {
defer applyTheme("dark")
if err := applyTheme("dark"); err != nil {
t.Fatal(err)
}
rendered := strings.Join(renderCommentMarkdown( rendered := strings.Join(renderCommentMarkdown(
"Use `list_comparison` for this value.", 60, "Use `list_comparison` for this value.", 60,
), "\n") ), "\n")
@@ -32,11 +36,30 @@ func TestCommentMarkdownStylesInlineCode(t *testing.T) {
if strings.Contains(plain, "Use list_comparison for") { if strings.Contains(plain, "Use list_comparison for") {
t.Fatalf("inline code added surrounding spaces: %q", plain) 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) 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) { func TestWrappedQuoteKeepsRailOnEveryLine(t *testing.T) {
const width = 28 const width = 28
lines := renderCommentMarkdown( lines := renderCommentMarkdown(

View File

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

384
theme.go
View File

@@ -2,97 +2,329 @@ package main
import ( import (
"fmt" "fmt"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2/styles"
"github.com/charmbracelet/lipgloss" "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 { var (
colorEnabled = true currentThemeName = "dark"
switch name { themeIsLight bool
case "dark": editorMarkdownTheme themePalette
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")) func applyTheme(name string, custom ...CustomThemeConfig) error {
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587")) var configured CustomThemeConfig
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) if len(custom) > 0 {
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) configured = custom[0]
editorLineStyle = lipgloss.NewStyle(). }
Foreground(lipgloss.Color("#D7DAE8")). palette, err := resolveThemePalette(name, configured)
Background(lipgloss.Color("#2C3045")) if err != nil {
paneInactiveColor = lipgloss.Color("#50566F") return err
paneActiveColor = lipgloss.Color("#F0B72F") }
authorPalette = []lipgloss.Color{ colorEnabled = !palette.NoColor
"#61AFEF", "#C678DD", "#56B6C2", "#E5C07B", themeIsLight = palette.Mode == "light"
"#E06C75", "#98C379", "#D19A66", "#7FC8FF", editorMarkdownTheme = palette
}
codeHighlightTheme = "github-dark" if palette.NoColor {
markdownStyleName = "dark" titleStyle, dimStyle = lipgloss.NewStyle(), lipgloss.NewStyle()
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()
activeStyle = lipgloss.NewStyle().Reverse(true) activeStyle = lipgloss.NewStyle().Reverse(true)
okStyle, warnStyle, badStyle = lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle() okStyle, warnStyle, badStyle = lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()
editorLineStyle = lipgloss.NewStyle().Reverse(true) editorLineStyle = lipgloss.NewStyle().Reverse(true)
paneInactiveColor, paneActiveColor = "", "" paneInactiveColor, paneActiveColor = "", ""
authorPalette = []lipgloss.Color{""} authorPalette = []lipgloss.Color{""}
codeHighlightTheme, markdownStyleName = "github", "notty"
quoteRailStyle = lipgloss.NewStyle() quoteRailStyle = lipgloss.NewStyle()
selectedLineBackground, suggestionRemoveBackground, suggestionAddBackground = "", "", "" selectedLineBackground, suggestionRemoveBackground, suggestionAddBackground = "", "", ""
changedRemoveBackground, changedAddBackground = "", "" changedRemoveBackground, changedAddBackground = "", ""
default: codeHighlightTheme, markdownStyleName = "github", "notty"
return fmt.Errorf("unknown theme %q", name) } 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 currentThemeName = name
commentMarkdownRenderers.Clear() commentMarkdownRenderers.Clear()
return nil 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 ( import (
"strings" "strings"
"testing" "testing"
"github.com/charmbracelet/lipgloss"
) )
func TestApplyThemeChangesAllRenderingThemes(t *testing.T) { func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
@@ -11,6 +13,12 @@ func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
t.Fatal(err) 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 { if err := applyTheme("light"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -32,3 +40,110 @@ func TestNoColorThemeDisablesSyntaxColors(t *testing.T) {
t.Fatalf("no-color source contains terminal colors: %q", rendered) 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, refresh)
components = append(components, HealthComponent{ components = append(components, HealthComponent{
Name: "configuration", Level: healthOK, Summary: "configuration loaded and validated", Name: "configuration", Level: healthOK, Summary: "configuration loaded and validated",
Detail: "theme " + currentThemeName,
}) })
if m.readState != nil { if m.readState != nil {
component := HealthComponent{ component := HealthComponent{

View File

@@ -1704,7 +1704,7 @@ func TestTabHidesListAndHRestoresIt(t *testing.T) {
func TestSelectedBackgroundFillsLine(t *testing.T) { func TestSelectedBackgroundFillsLine(t *testing.T) {
rendered := selectedBackground("code", 12) 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) t.Fatalf("selected background was not full width: %q", rendered)
} }
} }