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

384
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",
}
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,
}
}