Compare commits

...

2 Commits

Author SHA1 Message Date
e89c524438 Add shell completion 2026-07-28 20:45:05 +02:00
ef32aa473e Add custom theming 2026-07-28 20:31:27 +02:00
17 changed files with 1057 additions and 162 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
diple

View File

@@ -59,6 +59,29 @@ diple --repo owner/repository \
--endpoint https://github.example.com/api/graphql
```
## Shell completion
`diple` generates completion scripts without contacting GitHub or loading the
configuration. Choose the command for your shell:
```sh
# Bash: current session
source <(diple completion bash)
# Zsh: current session
source <(diple completion zsh)
# Fish: install for the current user
diple completion fish > ~/.config/fish/completions/diple.fish
```
For persistent Bash completion, write the generated output to a directory
loaded by your distribution's `bash-completion` package. For persistent Zsh
completion, write it to a file named `_diple` in a directory on `$fpath`, then
run `compinit`. `diple completion --help` lists the supported shells, while
`diple --help` shows grouped command-line options, defaults, configuration
precedence, and authentication behavior.
## Configuration
The optional TOML configuration is loaded from
@@ -74,7 +97,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 +215,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

238
cli.go Normal file
View File

@@ -0,0 +1,238 @@
package main
import (
"fmt"
"io"
"strings"
)
var completionShells = []string{"bash", "zsh", "fish"}
func handleCompletionCommand(args []string, output io.Writer) (bool, error) {
if len(args) == 0 || args[0] != "completion" {
return false, nil
}
if len(args) == 2 && (args[1] == "-h" || args[1] == "--help") {
_, err := fmt.Fprintln(output, completionHelp)
return true, err
}
if len(args) != 2 {
return true, fmt.Errorf("usage: diple completion <bash|zsh|fish>")
}
var script string
switch args[1] {
case "bash":
script = bashCompletion
case "zsh":
script = zshCompletion
case "fish":
script = fishCompletion
default:
return true, fmt.Errorf(
"unsupported shell %q; choose %s",
args[1], strings.Join(completionShells, ", "),
)
}
_, err := io.WriteString(output, script)
return true, err
}
const completionHelp = `Generate a shell completion script.
Usage:
diple completion <shell>
Available shells:
bash
zsh
fish
Run 'diple completion <shell>' and source or install the generated script.
See the README for shell-specific installation paths.`
func writeCLIHelp(output io.Writer, defaults Config, configPath string) {
fmt.Fprintf(output, `diple — review and manage GitHub pull requests from the terminal
Usage:
diple [options]
diple completion <bash|zsh|fish>
diple help
Pull-request selection:
--repo OWNER/REPOSITORY Limit results to one repository (or use GH_REPO).
--all[=BOOL] Include every open PR in --repo. Default: %t.
--limit NUMBER Maximum PRs to load, from 1 to 1000. Default: %d.
GitHub and refresh:
--poll DURATION Base refresh interval, at least 2s. Default: %s.
--endpoint URL GitHub GraphQL endpoint.
Default: %s.
Appearance and navigation:
--theme NAME dark, light, catppuccin, catppuccin-latte,
gruvbox, gruvbox-light, one-dark-pro, github,
github-light, high-contrast, no-color, or custom.
Default: %s.
--dashboard-mode MODE hotkey or intermediate. Default: %s.
--thread-list-width N List width in percent, from 20 to 60. Default: %d.
--fold-resolved[=BOOL] Start resolved threads folded. Default: %t.
--compact-reviews[=BOOL] Aggregate submitted-review history. Default: %t.
--path-scroll[=BOOL] Scroll truncated file paths. Default: %t.
--path-scroll-interval D Path scroll step interval. Default: %s.
--editor-mode MODE vim or standard. Default: %s.
Local state:
--config FILE TOML configuration file.
Default: %s.
--cache[=BOOL] Enable instant cached startup and offline fallback.
Default: %t.
--cache-max-age DURATION Maximum offline cache age; 0 disables expiry.
Default: %s.
--cache-dir DIRECTORY Override the operating-system cache directory.
Other:
-h, --help Show this help and exit.
Boolean options accept explicit values, for example --cache=false.
Command-line options override TOML settings. GH_REPO is used only when
--repo is absent. DIPLE_CONFIG selects a configuration file; GH_THREADS_CONFIG
is retained as a migration fallback.
Authentication uses GH_TOKEN or GITHUB_TOKEN when set, otherwise the active
credential from 'gh auth login'. Run 'diple completion --help' for completion
installation guidance.
`,
defaults.ShowAll,
defaults.Limit,
defaults.RefreshInterval.Duration,
defaults.Endpoint,
defaults.Theme,
defaults.Display.DashboardMode,
defaults.Display.ThreadListWidthPercent,
defaults.Display.FoldResolved,
defaults.Display.CompactReviews,
defaults.Paths.Scroll,
defaults.Paths.ScrollInterval.Duration,
defaults.Editing.Mode,
configPath,
defaults.Cache.Enabled,
defaults.Cache.MaxAge.Duration,
)
}
const bashCompletion = `# bash completion for diple
_diple_completion() {
local current previous
current="${COMP_WORDS[COMP_CWORD]}"
previous="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 1 && ${current} != -* ]]; then
COMPREPLY=($(compgen -W "completion help" -- "${current}"))
return
fi
if [[ ${COMP_WORDS[1]} == completion ]]; then
COMPREPLY=($(compgen -W "bash zsh fish" -- "${current}"))
return
fi
if [[ ${COMP_WORDS[1]} == help ]]; then
COMPREPLY=()
return
fi
case "${previous}" in
--theme)
COMPREPLY=($(compgen -W "dark light catppuccin catppuccin-mocha catppuccin-latte gruvbox gruvbox-dark gruvbox-light one-dark-pro github github-dark github-light high-contrast no-color custom" -- "${current}"))
return
;;
--dashboard-mode)
COMPREPLY=($(compgen -W "hotkey intermediate" -- "${current}"))
return
;;
--editor-mode)
COMPREPLY=($(compgen -W "vim standard" -- "${current}"))
return
;;
--config|--cache-dir)
COMPREPLY=($(compgen -f -- "${current}"))
return
;;
esac
local options="--repo --all --limit --poll --endpoint --theme --dashboard-mode --thread-list-width --fold-resolved --compact-reviews --path-scroll --path-scroll-interval --editor-mode --config --cache --cache-max-age --cache-dir --help -h"
COMPREPLY=($(compgen -W "${options}" -- "${current}"))
}
complete -F _diple_completion diple
`
const zshCompletion = `#compdef diple
_diple() {
local -a themes
themes=(dark light catppuccin catppuccin-mocha catppuccin-latte gruvbox gruvbox-dark gruvbox-light one-dark-pro github github-dark github-light high-contrast no-color custom)
if (( CURRENT == 2 )) && [[ ${PREFIX} != -* ]]; then
_values 'command' \
'completion[generate shell completion]' \
'help[show command-line help]'
return
fi
if [[ ${words[2]} == completion ]]; then
_values 'shell' bash zsh fish
return
fi
if [[ ${words[2]} == help ]]; then
return
fi
_arguments -s \
'--repo[limit results to one repository]:owner/repository:' \
'--all=[include every open PR in the repository]:boolean:(true false)' \
'--limit[maximum pull requests to load]:number:' \
'--poll[base GitHub refresh interval]:duration:' \
'--endpoint[GitHub GraphQL endpoint]:url:' \
'--theme[UI and syntax color theme]:theme:($themes)' \
'--dashboard-mode[dashboard navigation mode]:mode:(hotkey intermediate)' \
'--thread-list-width[thread-list width percentage]:percent:' \
'--fold-resolved=[start resolved threads folded]:boolean:(true false)' \
'--compact-reviews=[aggregate submitted reviews]:boolean:(true false)' \
'--path-scroll=[scroll truncated paths]:boolean:(true false)' \
'--path-scroll-interval[path scrolling interval]:duration:' \
'--editor-mode[description editor mode]:mode:(vim standard)' \
'--config[TOML configuration file]:file:_files' \
'--cache=[enable local read cache]:boolean:(true false)' \
'--cache-max-age[maximum offline cache age]:duration:' \
'--cache-dir[local read-cache directory]:directory:_directories' \
'(-h --help)'{-h,--help}'[show help]'
}
if (( ! ${+functions[compdef]} )); then
autoload -Uz compinit
compinit -D
fi
compdef _diple diple
`
const fishCompletion = `# fish completion for diple
complete -c diple -f
complete -c diple -n '__fish_use_subcommand' -a completion -d 'Generate shell completion'
complete -c diple -n '__fish_use_subcommand' -a help -d 'Show help'
complete -c diple -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
complete -c diple -l repo -d 'Limit results to owner/repository'
complete -c diple -l all -d 'Include every open PR in --repo'
complete -c diple -l limit -x -d 'Maximum pull requests to load'
complete -c diple -l poll -x -d 'Base GitHub refresh interval'
complete -c diple -l endpoint -x -d 'GitHub GraphQL endpoint'
complete -c diple -l theme -x -a 'dark light catppuccin catppuccin-mocha catppuccin-latte gruvbox gruvbox-dark gruvbox-light one-dark-pro github github-dark github-light high-contrast no-color custom' -d 'UI and syntax color theme'
complete -c diple -l dashboard-mode -x -a 'hotkey intermediate' -d 'Dashboard navigation mode'
complete -c diple -l thread-list-width -x -d 'Thread-list width percentage'
complete -c diple -l fold-resolved -d 'Start resolved threads folded'
complete -c diple -l compact-reviews -d 'Aggregate submitted reviews'
complete -c diple -l path-scroll -d 'Scroll truncated paths'
complete -c diple -l path-scroll-interval -x -d 'Path scrolling interval'
complete -c diple -l editor-mode -x -a 'vim standard' -d 'Description editor mode'
complete -c diple -l config -r -F -d 'TOML configuration file'
complete -c diple -l cache -d 'Enable local read cache'
complete -c diple -l cache-max-age -x -d 'Maximum offline cache age'
complete -c diple -l cache-dir -r -a '(__fish_complete_directories)' -d 'Local read-cache directory'
complete -c diple -s h -l help -d 'Show help'
`

77
cli_test.go Normal file
View File

@@ -0,0 +1,77 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestCompletionCommandGeneratesSupportedShells(t *testing.T) {
for _, shell := range completionShells {
t.Run(shell, func(t *testing.T) {
var output bytes.Buffer
handled, err := handleCompletionCommand([]string{"completion", shell}, &output)
if err != nil {
t.Fatal(err)
}
if !handled || output.Len() == 0 ||
!strings.Contains(output.String(), "diple") {
t.Fatalf("completion output = %q, handled=%t", output.String(), handled)
}
})
}
}
func TestZshCompletionRegistersWithoutCallingCompletionFunction(t *testing.T) {
var output bytes.Buffer
handled, err := handleCompletionCommand([]string{"completion", "zsh"}, &output)
if err != nil {
t.Fatal(err)
}
script := output.String()
if !handled || !strings.Contains(script, "compdef _diple diple") {
t.Fatalf("Zsh completion does not register _diple:\n%s", script)
}
if strings.Contains(script, `_diple "$@"`) {
t.Fatalf("Zsh completion invokes _diple while being sourced:\n%s", script)
}
}
func TestCompletionCommandRejectsUnknownShell(t *testing.T) {
var output bytes.Buffer
handled, err := handleCompletionCommand(
[]string{"completion", "powershell"}, &output,
)
if !handled || err == nil || !strings.Contains(err.Error(), "unsupported shell") {
t.Fatalf("handled=%t error=%v", handled, err)
}
}
func TestCompletionCommandDoesNotClaimNormalInvocation(t *testing.T) {
handled, err := handleCompletionCommand([]string{"--repo", "owner/repo"}, &bytes.Buffer{})
if handled || err != nil {
t.Fatalf("handled=%t error=%v", handled, err)
}
}
func TestCLIHelpIsGroupedAndActionable(t *testing.T) {
var output bytes.Buffer
writeCLIHelp(&output, defaultConfig(), "/tmp/diple/config.toml")
help := output.String()
for _, expected := range []string{
"Usage:",
"Pull-request selection:",
"GitHub and refresh:",
"Appearance and navigation:",
"Local state:",
"diple completion <bash|zsh|fish>",
"--repo OWNER/REPOSITORY",
"--cache=false",
"gh auth login",
"/tmp/diple/config.toml",
} {
if !strings.Contains(help, expected) {
t.Fatalf("help does not contain %q:\n%s", expected, help)
}
}
}

View File

@@ -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")

View File

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

View File

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

38
highlight_lexer_test.go Normal file
View File

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

50
main.go
View File

@@ -11,31 +11,47 @@ import (
)
func main() {
if handled, err := handleCompletionCommand(os.Args[1:], os.Stdout); handled {
if err != nil {
exitf("%v", err)
}
return
}
defaults := defaultConfig()
defaultConfigPath, err := configPath()
if err != nil {
exitf("configuration: %v", err)
}
flag.Usage = func() {
writeCLIHelp(flag.CommandLine.Output(), defaults, defaultConfigPath)
}
if len(os.Args) == 2 && os.Args[1] == "help" {
flag.Usage()
return
}
var (
configFile = flag.String("config", defaultConfigPath, "TOML configuration file")
repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)")
poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval")
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")
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")
compactReviews = flag.Bool("compact-reviews", defaults.Display.CompactReviews, "aggregate submitted review history")
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths")
pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval")
cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable local read cache fallback")
cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "maximum offline cache age (0 disables expiry)")
cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read cache directory")
editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode: standard or vim")
repo = flag.String("repo", "", "limit pull requests to an owner/repository")
poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "base interval between GitHub refreshes")
showAll = flag.Bool("all", defaults.ShowAll, "show every open PR in --repo instead of only assigned PRs")
limit = flag.Int("limit", defaults.Limit, "maximum number of open pull requests to load")
endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL API endpoint")
theme = flag.String("theme", defaults.Theme, "built-in or custom color theme")
foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved review threads folded")
listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread-list width as a percentage of the terminal")
dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "open the dashboard by hotkey or as an intermediate screen")
compactReviews = flag.Bool("compact-reviews", defaults.Display.CompactReviews, "aggregate repeated submitted-review entries")
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll file paths that do not fit")
pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "interval between file-path scroll steps")
cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable the local read cache and offline fallback")
cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "oldest cache entry accepted for offline fallback")
cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read-cache directory")
editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode")
)
flag.Parse()
if flag.NArg() != 0 {
exitf("unexpected argument %q; run 'diple --help' for usage", flag.Arg(0))
}
visited := map[string]bool{}
flag.Visit(func(item *flag.Flag) { visited[item.Name] = true })
@@ -109,7 +125,7 @@ func main() {
}
owner, name = parts[0], parts[1]
}
if err := applyTheme(config.Theme); err != nil {
if err := applyTheme(config.Theme, config.CustomTheme); err != nil {
exitf("configuration: %v", err)
}
token, err := resolveToken(config.Endpoint)

View File

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

View File

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

View File

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

View File

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

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

View File

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

1
tui.go
View File

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

View File

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