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 --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 ## Configuration
The optional TOML configuration is loaded from 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. 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 +215,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

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 { 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)
}
}

50
main.go
View File

@@ -11,31 +11,47 @@ import (
) )
func main() { func main() {
if handled, err := handleCompletionCommand(os.Args[1:], os.Stdout); handled {
if err != nil {
exitf("%v", err)
}
return
}
defaults := defaultConfig() defaults := defaultConfig()
defaultConfigPath, err := configPath() defaultConfigPath, err := configPath()
if err != nil { if err != nil {
exitf("configuration: %v", err) 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 ( var (
configFile = flag.String("config", defaultConfigPath, "TOML configuration file") configFile = flag.String("config", defaultConfigPath, "TOML configuration file")
repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)") repo = flag.String("repo", "", "limit pull requests to an owner/repository")
poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval") poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "base interval between GitHub refreshes")
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 every open PR in --repo instead of only assigned PRs")
limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-1000)") limit = flag.Int("limit", defaults.Limit, "maximum number of open pull requests to load")
endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint") endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL API endpoint")
theme = flag.String("theme", defaults.Theme, "color theme: dark, light, high-contrast, or no-color") theme = flag.String("theme", defaults.Theme, "built-in or custom color theme")
foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded") 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 terminal percentage (20-60)") 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, "dashboard navigation: intermediate or hotkey") 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 submitted review history") compactReviews = flag.Bool("compact-reviews", defaults.Display.CompactReviews, "aggregate repeated submitted-review entries")
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths") 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, "path scrolling interval") pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "interval between file-path scroll steps")
cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable local read cache fallback") 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, "maximum offline cache age (0 disables expiry)") 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") 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") editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode")
) )
flag.Parse() flag.Parse()
if flag.NArg() != 0 {
exitf("unexpected argument %q; run 'diple --help' for usage", flag.Arg(0))
}
visited := map[string]bool{} visited := map[string]bool{}
flag.Visit(func(item *flag.Flag) { visited[item.Name] = true }) flag.Visit(func(item *flag.Flag) { visited[item.Name] = true })
@@ -109,7 +125,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)
} }
} }