Compare commits
2 Commits
a0098a3946
...
e89c524438
| Author | SHA1 | Date | |
|---|---|---|---|
| e89c524438 | |||
| ef32aa473e |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
diple
|
||||||
59
README.md
59
README.md
@@ -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
238
cli.go
Normal 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
77
cli_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
config.go
56
config.go
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
highlight.go
10
highlight.go
@@ -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
38
highlight_lexer_test.go
Normal 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
50
main.go
@@ -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)
|
||||||
|
|||||||
72
markdown.go
72
markdown.go
@@ -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, ">") {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
384
theme.go
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
115
theme_test.go
115
theme_test.go
@@ -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
1
tui.go
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user