From b52a00d2dca1ef3c0e3b27355a744323862cfa74 Mon Sep 17 00:00:00 2001 From: Philipp Trentmann Date: Tue, 12 May 2026 18:20:47 +0200 Subject: [PATCH 1/2] feat: add selectable themes and harden installer downloads --- README.md | 48 +- cmd/root.go | 11 +- internal/config/config.go | 35 ++ internal/config/config_test.go | 43 ++ internal/config/load.go | 13 + internal/config/validate.go | 10 + internal/ui/app.go | 11 +- internal/ui/settings.go | 82 ++++ .../theme/examples/daylight-neon.theme.yaml | 23 + .../theme/examples/midnight-neon.theme.yaml | 23 + internal/ui/theme/palette.go | 8 +- internal/ui/theme/resolve.go | 453 ++++++++++++++++++ internal/ui/theme/resolve_test.go | 167 +++++++ internal/ui/theme/seed.go | 166 +++++++ internal/ui/theme/styles.go | 94 ++-- scripts/install.sh | 32 +- 16 files changed, 1165 insertions(+), 54 deletions(-) create mode 100644 internal/config/config_test.go create mode 100644 internal/ui/theme/examples/daylight-neon.theme.yaml create mode 100644 internal/ui/theme/examples/midnight-neon.theme.yaml create mode 100644 internal/ui/theme/resolve.go create mode 100644 internal/ui/theme/resolve_test.go create mode 100644 internal/ui/theme/seed.go diff --git a/README.md b/README.md index 85aad79..b36fe4f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A neon-themed terminal dashboard that polls GitHub via the local `gh` CLI and sh ## Features -- Live-refreshing TUI (Bubble Tea) with a neon truecolor palette and gradients +- Live-refreshing TUI (Bubble Tea) with built-in dark/light neon themes and gradients - Polls every N seconds (default 20s, configurable in-app) - Tracks **only PRs you authored** across a configurable list of repositories - Shows per-PR: @@ -16,6 +16,7 @@ A neon-themed terminal dashboard that polls GitHub via the local `gh` CLI and sh - `[DRAFT]` chip for draft PRs (still listed, not filtered out) - `[STALE]` chip for PRs created more than 4 weeks ago - In-app menu for **managing repositories** (add/remove) and **settings** (poll interval) +- Theme picker with best-effort light/dark auto-detection plus user `*.theme.yaml` files - Config persisted as YAML; atomic saves so a crash can't corrupt the file - Open a highlighted PR in your browser with one keystroke @@ -104,6 +105,11 @@ repos: - owner/repo-b poll_interval_seconds: 20 group_by_repo: false +theme: + selected: auto # auto, neon-dark, neon-light, or a user theme id + directory: ~/.config/github-butler/themes + colors: + accent: "#FF00FF" ``` ### Available settings @@ -113,11 +119,45 @@ group_by_repo: false | `repos` | list | `[]` | List of `owner/repo` slugs to track | | `poll_interval_seconds` | int | `20` | How often the app polls GitHub (min `2`, max `3600`) | | `group_by_repo` | bool | `false` | When `true`, PRs are grouped under per-repo section headers instead of one flat updated list | +| `theme.selected` | str | `auto` | Theme id to use (`auto`, `neon-dark`, `neon-light`, or a user theme filename without suffix) | +| `theme.directory` | str | XDG dir | Directory scanned for `*.theme.yaml` user themes | +| `theme.colors` | map | `{}` | Optional config-level color overrides applied after the selected theme | -All three are editable from inside the app (`m` → **Settings** / **Repositories**), so you rarely need to hand-edit the YAML — but doing so works too. +Repositories and settings are editable from inside the app (`m` → **Settings** / **Repositories**), so you rarely need to hand-edit the YAML — but doing so works too. On first launch with no config, the app opens to an empty dashboard; press `m` → Repositories → `a` to add one. +### Themes + +`auto` uses Lipgloss/termenv to detect whether your terminal background is dark or light, then chooses the built-in `neon-dark` or `neon-light` theme. Detection depends on terminal support, so you can force either built-in theme from **Settings** → **Theme**. + +User themes live in `~/.config/github-butler/themes` by default. The app seeds two bundled examples there on startup: + +- `midnight-neon.theme.yaml` +- `daylight-neon.theme.yaml` + +Self-updates seed newer examples after restart. If you changed one of the bundled examples, the app preserves your file and writes the updated example next to it with a `.new` suffix. + +Theme files are YAML partial overrides layered over a built-in base: + +```yaml +name: High Contrast Blue +base: neon-light +colors: + accent: "#0047AB" + success: "#006B3C" + warning: "#805A00" + danger: "#B00020" + selected_background: "#0047AB" +gradients: + title: + - "#0047AB" + - "#0077CC" + - "#4B0082" +``` + +Supported color keys include `accent`, `info`, `success`, `warning`, `danger`, `selected_foreground`, `selected_background`, `chip_foreground`, and the raw palette slots (`neon_pink`, `neon_cyan`, `neon_magenta`, `neon_lime`, `neon_purple`, `neon_orange`, `neon_yellow`, `neon_blue`, `hot_pink`, `black`, `white`, `dim`, `dark_bg`). Supported gradients are `title`, `countdown`, and `header`. + ## Key bindings ### Dashboard @@ -131,7 +171,7 @@ On first launch with no config, the app opens to an empty dashboard; press `m` | `m` | Open main menu | | `q`, Ctrl+C | Quit | -### Menu / Repositories / Settings +### Menu / Repositories / Settings / Theme Picker | Key | Action | | --------- | ------------------------------------------------------------------------- | @@ -179,7 +219,7 @@ internal/ menu.go # main menu repos.go # repo manager + add/remove flows settings.go # settings list + interval editor - theme/ # neon palette, styles, gradient helper + theme/ # built-in themes, user theme loading, seeded examples components/ # small reusable widgets (banner, countdown, toast, confirm) scripts/ install.sh # curl | bash release installer diff --git a/cmd/root.go b/cmd/root.go index ccfad2c..3173930 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,6 +15,7 @@ import ( "github.com/bluegardenproject/github-butler/internal/config" "github.com/bluegardenproject/github-butler/internal/github" "github.com/bluegardenproject/github-butler/internal/ui" + "github.com/bluegardenproject/github-butler/internal/ui/theme" tea "github.com/charmbracelet/bubbletea" ) @@ -89,9 +90,17 @@ func Run(ctx context.Context, args []string) error { } } cfg.Path = path + cfg.Theme.Directory = cfg.ThemeDirectory() + + if err := theme.SeedExamples(cfg.Theme.Directory); err != nil { + return fmt.Errorf("preparing themes: %w", err) + } + if err := theme.Activate(theme.OptionsFromConfig(cfg.Theme)); err != nil { + return fmt.Errorf("loading theme: %w", err) + } client := github.NewClient() - model := ui.NewModel(cfg, client) + model := ui.NewModel(cfg, client, theme.Choices(cfg.Theme.Directory)) p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithContext(ctx)) _, err = p.Run() diff --git a/internal/config/config.go b/internal/config/config.go index 4e67623..4824efa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,12 +20,20 @@ type Config struct { Repos []string `yaml:"repos"` PollIntervalSeconds int `yaml:"poll_interval_seconds"` GroupByRepo bool `yaml:"group_by_repo"` + Theme Theme `yaml:"theme"` // Path is the file this config was loaded from (or should be saved to). // Not persisted to YAML. Path string `yaml:"-"` } +// Theme is the user-facing theme configuration persisted as YAML. +type Theme struct { + Selected string `yaml:"selected"` + Directory string `yaml:"directory"` + Colors map[string]string `yaml:"colors,omitempty"` +} + // PollInterval returns the poll interval as a time.Duration, falling back // to the default if the stored value is zero or outside the allowed range. func (c Config) PollInterval() time.Duration { @@ -38,12 +46,27 @@ func (c Config) PollInterval() time.Duration { // Default returns a Config with sensible defaults but no repos. func Default() Config { + themeDir, _ := DefaultThemeDirectory() return Config{ Repos: []string{}, PollIntervalSeconds: int(DefaultPollInterval / time.Second), + Theme: Theme{ + Selected: "auto", + Directory: themeDir, + }, } } +// ThemeDirectory returns the directory where user theme files live. An empty +// config value falls back to the default XDG-ish theme directory. +func (c Config) ThemeDirectory() string { + if c.Theme.Directory != "" { + return c.Theme.Directory + } + dir, _ := DefaultThemeDirectory() + return dir +} + // DefaultPath returns the standard XDG-ish config file path: // $XDG_CONFIG_HOME/github-butler/config.yaml, falling back to // $HOME/.config/github-butler/config.yaml. @@ -57,3 +80,15 @@ func DefaultPath() (string, error) { } return filepath.Join(home, ".config", "github-butler", "config.yaml"), nil } + +// DefaultThemeDirectory returns the standard directory for user theme files. +func DefaultThemeDirectory() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "github-butler", "themes"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "github-butler", "themes"), nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5c5a940 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,43 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadDefaultsThemeConfig(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("repos: []\npoll_interval_seconds: 20\ngroup_by_repo: false\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Theme.Selected != "auto" { + t.Fatalf("Theme.Selected = %q, want auto", cfg.Theme.Selected) + } + wantDir := filepath.Join(xdg, "github-butler", "themes") + if cfg.Theme.Directory != wantDir { + t.Fatalf("Theme.Directory = %q, want %q", cfg.Theme.Directory, wantDir) + } +} + +func TestValidateThemeColors(t *testing.T) { + cfg := Default() + cfg.Theme.Colors = map[string]string{"accent": "not-a-color"} + + err := Validate(cfg) + if err == nil { + t.Fatal("Validate succeeded, want error") + } + if !strings.Contains(err.Error(), "theme.colors.accent") { + t.Fatalf("Validate error = %q, want theme color path", err) + } +} diff --git a/internal/config/load.go b/internal/config/load.go index 72a5314..3a6d5fe 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -41,6 +41,19 @@ func Load(path string) (Config, error) { if cfg.Repos == nil { cfg.Repos = []string{} } + if cfg.Theme.Selected == "" { + cfg.Theme.Selected = "auto" + } + if cfg.Theme.Directory == "" { + dir, err := DefaultThemeDirectory() + if err != nil { + return Config{}, err + } + cfg.Theme.Directory = dir + } + if cfg.Theme.Colors == nil { + cfg.Theme.Colors = map[string]string{} + } if err := Validate(cfg); err != nil { return Config{}, err diff --git a/internal/config/validate.go b/internal/config/validate.go index 3ef58fa..9ef8918 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -11,6 +11,8 @@ import ( // must be 1..100 characters. var repoSlugPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9._-]{1,100}$`) +var hexColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + // Validate checks the config for obvious mistakes. Repos are checked for // the "owner/repo" slug format; poll interval is bounds-checked. func Validate(cfg Config) error { @@ -32,6 +34,14 @@ func Validate(cfg Config) error { } seen[key] = struct{}{} } + if strings.TrimSpace(cfg.Theme.Selected) == "" { + return fmt.Errorf("theme.selected must not be empty") + } + for key, value := range cfg.Theme.Colors { + if !hexColorPattern.MatchString(strings.TrimSpace(value)) { + return fmt.Errorf("theme.colors.%s must be a #RRGGBB hex color", key) + } + } return nil } diff --git a/internal/ui/app.go b/internal/ui/app.go index 682cfa5..ff9dfa6 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -6,6 +6,7 @@ import ( "github.com/bluegardenproject/github-butler/internal/config" "github.com/bluegardenproject/github-butler/internal/github" "github.com/bluegardenproject/github-butler/internal/ui/components" + "github.com/bluegardenproject/github-butler/internal/ui/theme" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -22,6 +23,7 @@ const ( screenConfirmRemove screenSettings screenEditInterval + screenThemes ) // Model is the single Bubble Tea model backing every screen. Per-screen @@ -48,6 +50,8 @@ type Model struct { menuCursor int reposCursor int settingsCursor int + themeCursor int + themeChoices []theme.Choice // inputs addInput textinput.Model @@ -66,7 +70,7 @@ type Model struct { } // NewModel constructs the root model. -func NewModel(cfg config.Config, client *github.Client) Model { +func NewModel(cfg config.Config, client *github.Client, choices []theme.Choice) Model { addIn := textinput.New() addIn.Placeholder = "owner/repo or https://github.com/owner/repo" addIn.CharLimit = 200 @@ -80,6 +84,7 @@ func NewModel(cfg config.Config, client *github.Client) Model { return Model{ cfg: cfg, client: client, + themeChoices: choices, screen: screenDashboard, addInput: addIn, intervalInput: intervalIn, @@ -177,6 +182,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSettings(msg) case screenEditInterval: return m.updateEditInterval(msg) + case screenThemes: + return m.updateThemes(msg) } return m, nil } @@ -195,7 +202,7 @@ func (m Model) View() string { body = m.viewMenu() case screenRepos, screenAddRepo, screenConfirmRemove: body = m.viewRepos() - case screenSettings, screenEditInterval: + case screenSettings, screenEditInterval, screenThemes: body = m.viewSettings() } diff --git a/internal/ui/settings.go b/internal/ui/settings.go index cf769fb..d75c91c 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -34,6 +34,11 @@ var settingItems = []settingItem{ value: func(cfg config.Config) string { return onOff(cfg.GroupByRepo) }, activate: toggleGroupByRepo, }, + { + label: "Theme", + value: func(cfg config.Config) string { return cfg.Theme.Selected }, + activate: openThemePicker, + }, } func onOff(b bool) string { @@ -63,6 +68,18 @@ func toggleGroupByRepo(m Model) (Model, tea.Cmd) { return m, saveConfigCmd(m.cfg) } +func openThemePicker(m Model) (Model, tea.Cmd) { + m.screen = screenThemes + m.themeCursor = 0 + for i, choice := range m.themeChoices { + if choice.ID == m.cfg.Theme.Selected { + m.themeCursor = i + break + } + } + return m, nil +} + func (m Model) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) { km, ok := msg.(tea.KeyMsg) if !ok { @@ -127,6 +144,44 @@ func (m Model) updateEditInterval(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +func (m Model) updateThemes(msg tea.Msg) (tea.Model, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch { + case key.Matches(km, keys.Quit): + return m, tea.Quit + case key.Matches(km, keys.Back): + m.screen = screenSettings + case key.Matches(km, keys.Menu): + m.screen = screenDashboard + case key.Matches(km, keys.Up): + if m.themeCursor > 0 { + m.themeCursor-- + } + case key.Matches(km, keys.Down): + if m.themeCursor < len(m.themeChoices)-1 { + m.themeCursor++ + } + case key.Matches(km, keys.Select): + if m.themeCursor < 0 || m.themeCursor >= len(m.themeChoices) { + return m, nil + } + choice := m.themeChoices[m.themeCursor] + previous := m.cfg.Theme.Selected + m.cfg.Theme.Selected = choice.ID + if err := theme.Activate(theme.OptionsFromConfig(m.cfg.Theme)); err != nil { + m.cfg.Theme.Selected = previous + _ = theme.Activate(theme.OptionsFromConfig(m.cfg.Theme)) + return m.showToast("theme failed: "+err.Error(), components.ToastError) + } + m.screen = screenSettings + return m, saveConfigCmd(m.cfg) + } + return m, nil +} + func (m Model) viewSettings() string { banner := components.Banner(" SETTINGS ") @@ -146,6 +201,8 @@ func (m Model) viewSettings() string { var extra string if m.screen == screenEditInterval { extra = m.renderEditInterval() + } else if m.screen == screenThemes { + extra = m.renderThemePicker() } hints := footerHints(keys.Up, keys.Down, keys.Select, keys.Back, keys.Menu, keys.Quit) @@ -158,6 +215,31 @@ func (m Model) viewSettings() string { return lipgloss.JoinVertical(lipgloss.Left, parts...) } +func (m Model) renderThemePicker() string { + label := theme.PanelTitle.Render("Select theme") + var rows []string + for i, choice := range m.themeChoices { + line := fmt.Sprintf(" %s (%s)", choice.Name, choice.Kind) + if i == m.themeCursor { + line = theme.SelectedRow.Render(" ▸ " + choice.Name + " ") + } + if choice.ID == m.cfg.Theme.Selected { + line += theme.Accent.Render(" current") + } + rows = append(rows, line) + } + if len(rows) == 0 { + rows = append(rows, theme.Dimmed.Render(" no themes found")) + } + rows = append(rows, theme.Dimmed.Render("[enter] select [esc] cancel")) + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.NeonPink). + Padding(0, 1). + Render(lipgloss.JoinVertical(lipgloss.Left, append([]string{label}, rows...)...)) +} + func (m Model) renderEditInterval() string { label := theme.PanelTitle.Render("Set poll interval") input := m.intervalInput.View() diff --git a/internal/ui/theme/examples/daylight-neon.theme.yaml b/internal/ui/theme/examples/daylight-neon.theme.yaml new file mode 100644 index 0000000..1256122 --- /dev/null +++ b/internal/ui/theme/examples/daylight-neon.theme.yaml @@ -0,0 +1,23 @@ +name: Daylight Neon +base: neon-light +colors: + accent: "#7B1FA2" + info: "#005F73" + success: "#006B3C" + warning: "#7A5C00" + danger: "#B00020" + selected_foreground: "#FFFFFF" + selected_background: "#005F73" + chip_foreground: "#FFFFFF" +gradients: + title: + - "#A0007A" + - "#7B1FA2" + - "#005F73" + countdown: + - "#A0007A" + - "#5A2D82" + - "#005F73" + header: + - "#005F73" + - "#A0007A" diff --git a/internal/ui/theme/examples/midnight-neon.theme.yaml b/internal/ui/theme/examples/midnight-neon.theme.yaml new file mode 100644 index 0000000..cf2189c --- /dev/null +++ b/internal/ui/theme/examples/midnight-neon.theme.yaml @@ -0,0 +1,23 @@ +name: Midnight Neon +base: neon-dark +colors: + accent: "#FF00FF" + info: "#00F0FF" + success: "#39FF14" + warning: "#F5FF00" + danger: "#FF2A6D" + selected_foreground: "#F5FF00" + selected_background: "#BF00FF" +gradients: + title: + - "#FF10F0" + - "#FF00FF" + - "#BF00FF" + - "#00F0FF" + countdown: + - "#FF10F0" + - "#BF00FF" + - "#00F0FF" + header: + - "#00F0FF" + - "#FF10F0" diff --git a/internal/ui/theme/palette.go b/internal/ui/theme/palette.go index 0ae4075..5215b77 100644 --- a/internal/ui/theme/palette.go +++ b/internal/ui/theme/palette.go @@ -5,8 +5,6 @@ package theme import "github.com/charmbracelet/lipgloss" -// Neon palette (24-bit truecolor). Lipgloss falls back to the closest -// 256-color match on terminals that can't render truecolor. var ( NeonPink = lipgloss.Color("#FF10F0") NeonCyan = lipgloss.Color("#00F0FF") @@ -32,3 +30,9 @@ var CountdownStops = []lipgloss.Color{NeonPink, NeonPurple, NeonCyan} // HeaderStops colors the table header row. var HeaderStops = []lipgloss.Color{NeonCyan, NeonPink} + +var ( + selectedForeground = NeonYellow + selectedBackground = NeonPurple + chipForeground = Black +) diff --git a/internal/ui/theme/resolve.go b/internal/ui/theme/resolve.go new file mode 100644 index 0000000..6377b32 --- /dev/null +++ b/internal/ui/theme/resolve.go @@ -0,0 +1,453 @@ +package theme + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/bluegardenproject/github-butler/internal/config" + "github.com/charmbracelet/lipgloss" + "gopkg.in/yaml.v3" +) + +const ( + BuiltinAuto = "auto" + BuiltinDark = "neon-dark" + BuiltinLight = "neon-light" +) + +type Options struct { + Selected string + Directory string + Colors map[string]string + DetectDark func() bool +} + +type Choice struct { + ID string + Name string + Kind string +} + +type Resolved struct { + ID string + Name string + Colors map[string]string + Gradients map[string][]string +} + +type spec struct { + Name string `yaml:"name"` + Base string `yaml:"base"` + Colors map[string]string `yaml:"colors"` + Gradients map[string][]string `yaml:"gradients"` +} + +var hexColor = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) +var themeIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$`) + +var builtinThemes = map[string]spec{ + BuiltinDark: { + Name: "Neon Dark", + Colors: map[string]string{ + "neon_pink": "#FF10F0", + "neon_cyan": "#00F0FF", + "neon_magenta": "#FF00FF", + "neon_lime": "#39FF14", + "neon_purple": "#BF00FF", + "neon_orange": "#FF6A00", + "neon_yellow": "#F5FF00", + "neon_blue": "#1B03FF", + "hot_pink": "#FF2A6D", + "black": "#000000", + "white": "#FFFFFF", + "dim": "#6C6C80", + "dark_bg": "#120018", + "selected_fg": "#F5FF00", + "selected_bg": "#BF00FF", + "chip_fg": "#000000", + }, + Gradients: map[string][]string{ + "title": {"#FF10F0", "#FF00FF", "#BF00FF", "#00F0FF"}, + "countdown": {"#FF10F0", "#BF00FF", "#00F0FF"}, + "header": {"#00F0FF", "#FF10F0"}, + }, + }, + BuiltinLight: { + Name: "Neon Light", + Colors: map[string]string{ + "neon_pink": "#A0007A", + "neon_cyan": "#005F73", + "neon_magenta": "#7B1FA2", + "neon_lime": "#006B3C", + "neon_purple": "#5A2D82", + "neon_orange": "#9A4D00", + "neon_yellow": "#7A5C00", + "neon_blue": "#0033A0", + "hot_pink": "#B00020", + "black": "#111111", + "white": "#FFFFFF", + "dim": "#5F6470", + "dark_bg": "#FFFFFF", + "selected_fg": "#FFFFFF", + "selected_bg": "#005F73", + "chip_fg": "#FFFFFF", + }, + Gradients: map[string][]string{ + "title": {"#A0007A", "#7B1FA2", "#005F73"}, + "countdown": {"#A0007A", "#5A2D82", "#005F73"}, + "header": {"#005F73", "#A0007A"}, + }, + }, +} + +var colorAliases = map[string]string{ + "pink": "neon_pink", + "cyan": "neon_cyan", + "magenta": "neon_magenta", + "lime": "neon_lime", + "purple": "neon_purple", + "orange": "neon_orange", + "yellow": "neon_yellow", + "blue": "neon_blue", + "danger": "hot_pink", + "success": "neon_lime", + "warning": "neon_yellow", + "info": "neon_cyan", + "accent": "neon_magenta", + "background": "dark_bg", + "selected_foreground": "selected_fg", + "selected_background": "selected_bg", + "chip_foreground": "chip_fg", +} + +func OptionsFromConfig(cfg config.Theme) Options { + return Options{ + Selected: cfg.Selected, + Directory: cfg.Directory, + Colors: cfg.Colors, + DetectDark: lipgloss.HasDarkBackground, + } +} + +func Activate(opts Options) error { + resolved, err := Resolve(opts) + if err != nil { + return err + } + apply(resolved) + return nil +} + +func Resolve(opts Options) (Resolved, error) { + selected := strings.TrimSpace(opts.Selected) + if selected == "" { + selected = BuiltinAuto + } + if opts.DetectDark == nil { + opts.DetectDark = lipgloss.HasDarkBackground + } + + baseID := selected + if selected == BuiltinAuto { + baseID = builtinForBackground(opts.DetectDark()) + } + + base, ok := builtinThemes[baseID] + if !ok { + user, err := loadUserTheme(opts.Directory, selected) + if err != nil { + return Resolved{}, err + } + if user.Base == "" { + user.Base = BuiltinDark + } + baseID = user.Base + if baseID == BuiltinAuto { + baseID = builtinForBackground(opts.DetectDark()) + } + base, ok = builtinThemes[baseID] + if !ok { + return Resolved{}, fmt.Errorf("theme %q has unknown base %q", selected, user.Base) + } + base = cloneSpec(base) + if err := mergeSpec(&base, user); err != nil { + return Resolved{}, fmt.Errorf("theme %q: %w", selected, err) + } + if base.Name == "" { + base.Name = selected + } + } else if selected == BuiltinAuto { + base = cloneSpec(base) + base.Name = "Auto (" + base.Name + ")" + } else { + base = cloneSpec(base) + } + + resolved := Resolved{ + ID: selected, + Name: base.Name, + Colors: cloneColors(base.Colors), + Gradients: cloneGradients(base.Gradients), + } + if err := mergeColors(resolved.Colors, opts.Colors); err != nil { + return Resolved{}, err + } + if err := validateResolved(resolved); err != nil { + return Resolved{}, err + } + return resolved, nil +} + +func Choices(directory string) []Choice { + choices := []Choice{ + {ID: BuiltinAuto, Name: "Auto", Kind: "built-in"}, + {ID: BuiltinDark, Name: builtinThemes[BuiltinDark].Name, Kind: "built-in"}, + {ID: BuiltinLight, Name: builtinThemes[BuiltinLight].Name, Kind: "built-in"}, + } + + dir, err := expandHome(directory) + if err != nil { + return choices + } + entries, err := os.ReadDir(dir) + if err != nil { + return choices + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + if entry.IsDir() || !isThemeFile(entry.Name()) { + continue + } + id := themeID(entry.Name()) + if !validThemeID(id) { + continue + } + name := id + if parsed, err := readThemeFile(filepath.Join(dir, entry.Name())); err == nil && parsed.Name != "" { + name = parsed.Name + } + choices = append(choices, Choice{ID: id, Name: name, Kind: "custom"}) + } + return choices +} + +func builtinForBackground(dark bool) string { + if dark { + return BuiltinDark + } + return BuiltinLight +} + +func mergeSpec(dst *spec, override spec) error { + if override.Name != "" { + dst.Name = override.Name + } + if err := mergeColors(dst.Colors, override.Colors); err != nil { + return err + } + for key, stops := range override.Gradients { + normalized := normalizeKey(key) + if !isKnownGradient(normalized) { + return fmt.Errorf("unknown gradient %q", key) + } + if len(stops) == 0 { + return fmt.Errorf("gradient %q must contain at least one color", key) + } + for _, stop := range stops { + if !isHexColor(stop) { + return fmt.Errorf("gradient %q contains invalid color %q", key, stop) + } + } + dst.Gradients[normalized] = normalizeHexSlice(stops) + } + return nil +} + +func mergeColors(dst map[string]string, override map[string]string) error { + for key, value := range override { + normalized, ok := canonicalColorKey(key) + if !ok { + return fmt.Errorf("unknown color %q", key) + } + if !isHexColor(value) { + return fmt.Errorf("color %q must be a #RRGGBB hex color", key) + } + dst[normalized] = strings.ToUpper(strings.TrimSpace(value)) + } + return nil +} + +func loadUserTheme(directory, selected string) (spec, error) { + if strings.ContainsAny(selected, `/\`) || !validThemeID(themeID(selected)) { + return spec{}, fmt.Errorf("theme %q is not a valid theme id", selected) + } + dir, err := expandHome(directory) + if err != nil { + return spec{}, err + } + candidates := []string{selected} + if !isThemeFile(selected) { + candidates = append(candidates, selected+".theme.yaml", selected+".theme.yml") + } + for _, candidate := range candidates { + path := filepath.Join(dir, candidate) + parsed, err := readThemeFile(path) + if err == nil { + return parsed, nil + } + if !os.IsNotExist(err) { + return spec{}, err + } + } + return spec{}, fmt.Errorf("theme %q was not found in %s", selected, dir) +} + +func readThemeFile(path string) (spec, error) { + data, err := os.ReadFile(path) + if err != nil { + return spec{}, err + } + var parsed spec + if err := yaml.Unmarshal(data, &parsed); err != nil { + return spec{}, fmt.Errorf("parsing %s: %w", path, err) + } + return parsed, nil +} + +func apply(resolved Resolved) { + NeonPink = lipgloss.Color(resolved.Colors["neon_pink"]) + NeonCyan = lipgloss.Color(resolved.Colors["neon_cyan"]) + NeonMagenta = lipgloss.Color(resolved.Colors["neon_magenta"]) + NeonLime = lipgloss.Color(resolved.Colors["neon_lime"]) + NeonPurple = lipgloss.Color(resolved.Colors["neon_purple"]) + NeonOrange = lipgloss.Color(resolved.Colors["neon_orange"]) + NeonYellow = lipgloss.Color(resolved.Colors["neon_yellow"]) + NeonBlue = lipgloss.Color(resolved.Colors["neon_blue"]) + HotPink = lipgloss.Color(resolved.Colors["hot_pink"]) + Black = lipgloss.Color(resolved.Colors["black"]) + White = lipgloss.Color(resolved.Colors["white"]) + Dim = lipgloss.Color(resolved.Colors["dim"]) + DarkBg = lipgloss.Color(resolved.Colors["dark_bg"]) + selectedForeground = lipgloss.Color(resolved.Colors["selected_fg"]) + selectedBackground = lipgloss.Color(resolved.Colors["selected_bg"]) + chipForeground = lipgloss.Color(resolved.Colors["chip_fg"]) + + TitleStops = toLipglossColors(resolved.Gradients["title"]) + CountdownStops = toLipglossColors(resolved.Gradients["countdown"]) + HeaderStops = toLipglossColors(resolved.Gradients["header"]) + rebuildStyles() +} + +func validateResolved(resolved Resolved) error { + for key := range builtinThemes[BuiltinDark].Colors { + value, ok := resolved.Colors[key] + if !ok { + return fmt.Errorf("theme is missing color %q", key) + } + if !isHexColor(value) { + return fmt.Errorf("color %q must be a #RRGGBB hex color", key) + } + } + for key := range builtinThemes[BuiltinDark].Gradients { + stops := resolved.Gradients[key] + if len(stops) == 0 { + return fmt.Errorf("theme is missing gradient %q", key) + } + for _, stop := range stops { + if !isHexColor(stop) { + return fmt.Errorf("gradient %q contains invalid color %q", key, stop) + } + } + } + return nil +} + +func canonicalColorKey(key string) (string, bool) { + normalized := normalizeKey(key) + if alias, ok := colorAliases[normalized]; ok { + normalized = alias + } + _, ok := builtinThemes[BuiltinDark].Colors[normalized] + return normalized, ok +} + +func isKnownGradient(key string) bool { + _, ok := builtinThemes[BuiltinDark].Gradients[key] + return ok +} + +func isHexColor(value string) bool { + return hexColor.MatchString(strings.TrimSpace(value)) +} + +func normalizeKey(key string) string { + key = strings.ToLower(strings.TrimSpace(key)) + key = strings.ReplaceAll(key, "-", "_") + return key +} + +func normalizeHexSlice(values []string) []string { + out := make([]string, len(values)) + for i, value := range values { + out[i] = strings.ToUpper(strings.TrimSpace(value)) + } + return out +} + +func cloneColors(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func cloneGradients(in map[string][]string) map[string][]string { + out := make(map[string][]string, len(in)) + for key, values := range in { + out[key] = append([]string(nil), values...) + } + return out +} + +func cloneSpec(in spec) spec { + in.Colors = cloneColors(in.Colors) + in.Gradients = cloneGradients(in.Gradients) + return in +} + +func toLipglossColors(values []string) []lipgloss.Color { + out := make([]lipgloss.Color, len(values)) + for i, value := range values { + out[i] = lipgloss.Color(value) + } + return out +} + +func isThemeFile(name string) bool { + return strings.HasSuffix(name, ".theme.yaml") || strings.HasSuffix(name, ".theme.yml") +} + +func themeID(name string) string { + name = strings.TrimSuffix(name, ".theme.yaml") + return strings.TrimSuffix(name, ".theme.yml") +} + +func validThemeID(id string) bool { + return themeIDPattern.MatchString(id) +} + +func expandHome(path string) (string, error) { + if path == "" || !strings.HasPrefix(path, "~") { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, strings.TrimPrefix(path, "~")), nil +} diff --git a/internal/ui/theme/resolve_test.go b/internal/ui/theme/resolve_test.go new file mode 100644 index 0000000..c465c5e --- /dev/null +++ b/internal/ui/theme/resolve_test.go @@ -0,0 +1,167 @@ +package theme + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveBuiltinsAndAuto(t *testing.T) { + dark, err := Resolve(Options{Selected: BuiltinDark}) + if err != nil { + t.Fatal(err) + } + if got := dark.Colors["neon_pink"]; got != "#FF10F0" { + t.Fatalf("dark neon_pink = %q, want current palette", got) + } + + light, err := Resolve(Options{Selected: BuiltinLight}) + if err != nil { + t.Fatal(err) + } + if got := light.Colors["selected_fg"]; got != "#FFFFFF" { + t.Fatalf("light selected_fg = %q, want #FFFFFF", got) + } + + autoDark, err := Resolve(Options{Selected: BuiltinAuto, DetectDark: func() bool { return true }}) + if err != nil { + t.Fatal(err) + } + if got := autoDark.Colors["neon_pink"]; got != dark.Colors["neon_pink"] { + t.Fatalf("auto dark neon_pink = %q, want %q", got, dark.Colors["neon_pink"]) + } + + autoLight, err := Resolve(Options{Selected: BuiltinAuto, DetectDark: func() bool { return false }}) + if err != nil { + t.Fatal(err) + } + if got := autoLight.Colors["neon_pink"]; got != light.Colors["neon_pink"] { + t.Fatalf("auto light neon_pink = %q, want %q", got, light.Colors["neon_pink"]) + } +} + +func TestResolveConfigColorAliases(t *testing.T) { + resolved, err := Resolve(Options{ + Selected: BuiltinDark, + Colors: map[string]string{ + "accent": "#123456", + "selected-background": "#654321", + }, + }) + if err != nil { + t.Fatal(err) + } + if got := resolved.Colors["neon_magenta"]; got != "#123456" { + t.Fatalf("accent override = %q, want #123456", got) + } + if got := resolved.Colors["selected_bg"]; got != "#654321" { + t.Fatalf("selected background override = %q, want #654321", got) + } +} + +func TestResolveUserThemeFile(t *testing.T) { + dir := t.TempDir() + writeTheme(t, dir, "ocean.theme.yaml", ` +name: Ocean +base: neon-light +colors: + accent: "#123456" +gradients: + title: + - "#123456" + - "#654321" +`) + + resolved, err := Resolve(Options{Selected: "ocean", Directory: dir}) + if err != nil { + t.Fatal(err) + } + if resolved.Name != "Ocean" { + t.Fatalf("Name = %q, want Ocean", resolved.Name) + } + if got := resolved.Colors["neon_magenta"]; got != "#123456" { + t.Fatalf("accent override = %q, want #123456", got) + } + if got := resolved.Gradients["title"][0]; got != "#123456" { + t.Fatalf("title gradient first stop = %q, want #123456", got) + } + + dark, err := Resolve(Options{Selected: BuiltinDark}) + if err != nil { + t.Fatal(err) + } + if got := dark.Colors["neon_magenta"]; got != "#FF00FF" { + t.Fatalf("custom theme mutated built-in dark accent: %q", got) + } +} + +func TestResolveUserThemeRejectsInvalidColor(t *testing.T) { + dir := t.TempDir() + writeTheme(t, dir, "bad.theme.yaml", ` +name: Bad +base: neon-dark +colors: + accent: "blue" +`) + + if _, err := Resolve(Options{Selected: "bad", Directory: dir}); err == nil { + t.Fatal("Resolve succeeded, want invalid color error") + } +} + +func TestChoicesIncludesCustomThemesAfterBuiltins(t *testing.T) { + dir := t.TempDir() + writeTheme(t, dir, "ocean.theme.yaml", "name: Ocean\nbase: neon-light\n") + + choices := Choices(dir) + if len(choices) < 4 { + t.Fatalf("Choices length = %d, want at least 4", len(choices)) + } + if choices[0].ID != BuiltinAuto || choices[1].ID != BuiltinDark || choices[2].ID != BuiltinLight { + t.Fatalf("built-in choices = %#v", choices[:3]) + } + if got := choices[3]; got.ID != "ocean" || got.Name != "Ocean" || got.Kind != "custom" { + t.Fatalf("custom choice = %#v, want ocean/Ocean/custom", got) + } +} + +func TestSeedExamplesPreservesModifiedFiles(t *testing.T) { + dir := t.TempDir() + if err := SeedExamples(dir); err != nil { + t.Fatal(err) + } + + target := filepath.Join(dir, "midnight-neon.theme.yaml") + if _, err := os.Stat(target); err != nil { + t.Fatalf("seeded theme missing: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, manifestName)); err != nil { + t.Fatalf("manifest missing: %v", err) + } + + custom := []byte("name: User Modified\nbase: neon-dark\n") + if err := os.WriteFile(target, custom, 0o644); err != nil { + t.Fatal(err) + } + if err := SeedExamples(dir); err != nil { + t.Fatal(err) + } + + current, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(current) != string(custom) { + t.Fatalf("modified theme was overwritten:\n%s", current) + } + if _, err := os.Stat(target + ".new"); err != nil { + t.Fatalf("updated example was not written as .new: %v", err) + } +} + +func writeTheme(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/ui/theme/seed.go b/internal/ui/theme/seed.go new file mode 100644 index 0000000..ebee5b1 --- /dev/null +++ b/internal/ui/theme/seed.go @@ -0,0 +1,166 @@ +package theme + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +//go:embed examples/*.theme.yaml +var exampleThemes embed.FS + +const manifestName = ".github-butler-theme-manifest.yaml" + +type seedManifest struct { + Files map[string]string `yaml:"files"` +} + +func SeedExamples(directory string) error { + dir, err := expandHome(directory) + if err != nil { + return err + } + if dir == "" { + return fmt.Errorf("theme directory is empty") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating theme directory %s: %w", dir, err) + } + + manifest, err := loadManifest(dir) + if err != nil { + return err + } + changed := false + + entries, err := exampleThemes.ReadDir("examples") + if err != nil { + return fmt.Errorf("reading embedded themes: %w", err) + } + for _, entry := range entries { + if entry.IsDir() || !isThemeFile(entry.Name()) { + continue + } + data, err := exampleThemes.ReadFile(filepath.ToSlash(filepath.Join("examples", entry.Name()))) + if err != nil { + return fmt.Errorf("reading embedded theme %s: %w", entry.Name(), err) + } + wrote, err := seedThemeFile(dir, manifest, entry.Name(), data) + if err != nil { + return err + } + changed = changed || wrote + } + + if changed { + return saveManifest(dir, manifest) + } + return nil +} + +func seedThemeFile(dir string, manifest seedManifest, name string, data []byte) (bool, error) { + target := filepath.Join(dir, name) + newSum := checksum(data) + oldSum := manifest.Files[name] + + current, err := os.ReadFile(target) + switch { + case os.IsNotExist(err): + if err := os.WriteFile(target, data, 0o644); err != nil { + return false, fmt.Errorf("writing theme %s: %w", target, err) + } + manifest.Files[name] = newSum + return true, nil + case err != nil: + return false, fmt.Errorf("reading theme %s: %w", target, err) + } + + currentSum := checksum(current) + if currentSum == newSum { + if oldSum != newSum { + manifest.Files[name] = newSum + return true, nil + } + return false, nil + } + if oldSum != "" && currentSum == oldSum { + if err := os.WriteFile(target, data, 0o644); err != nil { + return false, fmt.Errorf("updating theme %s: %w", target, err) + } + manifest.Files[name] = newSum + return true, nil + } + + nextPath, err := newThemePath(target, data) + if err != nil { + return false, err + } + if nextPath == "" { + return false, nil + } + if err := os.WriteFile(nextPath, data, 0o644); err != nil { + return false, fmt.Errorf("writing updated theme example %s: %w", nextPath, err) + } + return false, nil +} + +func loadManifest(dir string) (seedManifest, error) { + manifest := seedManifest{Files: map[string]string{}} + data, err := os.ReadFile(filepath.Join(dir, manifestName)) + if os.IsNotExist(err) { + return manifest, nil + } + if err != nil { + return seedManifest{}, fmt.Errorf("reading theme manifest: %w", err) + } + if err := yaml.Unmarshal(data, &manifest); err != nil { + return seedManifest{}, fmt.Errorf("parsing theme manifest: %w", err) + } + if manifest.Files == nil { + manifest.Files = map[string]string{} + } + return manifest, nil +} + +func saveManifest(dir string, manifest seedManifest) error { + data, err := yaml.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshaling theme manifest: %w", err) + } + path := filepath.Join(dir, manifestName) + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("writing theme manifest: %w", err) + } + return nil +} + +func newThemePath(target string, data []byte) (string, error) { + for i := 0; i < 100; i++ { + suffix := ".new" + if i > 0 { + suffix = fmt.Sprintf(".new.%d", i) + } + candidate := target + suffix + existing, err := os.ReadFile(candidate) + if os.IsNotExist(err) { + return candidate, nil + } + if err != nil { + return "", fmt.Errorf("reading updated theme example %s: %w", candidate, err) + } + if checksum(existing) == checksum(data) { + return "", nil + } + } + return "", fmt.Errorf("could not find available .new path for %s", target) +} + +func checksum(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/ui/theme/styles.go b/internal/ui/theme/styles.go index 8c5ab53..8f65e8d 100644 --- a/internal/ui/theme/styles.go +++ b/internal/ui/theme/styles.go @@ -2,13 +2,39 @@ package theme import "github.com/charmbracelet/lipgloss" -// Pre-built styles. Views should compose these rather than re-creating -// them inline. var ( + OuterBorder lipgloss.Style + Panel lipgloss.Style + PanelTitle lipgloss.Style + Dimmed lipgloss.Style + Bold lipgloss.Style + OK lipgloss.Style + Fail lipgloss.Style + Pending lipgloss.Style + Info lipgloss.Style + Accent lipgloss.Style + SelectedRow lipgloss.Style + + DraftChip lipgloss.Style + StaleChip lipgloss.Style + CodeOwnerChip lipgloss.Style + + SuccessToast lipgloss.Style + ErrorToast lipgloss.Style + + KeyHint lipgloss.Style + KeyLabel lipgloss.Style +) + +func init() { + rebuildStyles() +} + +func rebuildStyles() { OuterBorder = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(NeonMagenta). - Padding(0, 1) + Border(lipgloss.RoundedBorder()). + BorderForeground(NeonMagenta). + Padding(0, 1) Panel = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). @@ -16,9 +42,9 @@ var ( Padding(0, 1) PanelTitle = lipgloss.NewStyle(). - Foreground(NeonPink). - Bold(true). - MarginBottom(1) + Foreground(NeonPink). + Bold(true). + MarginBottom(1) Dimmed = lipgloss.NewStyle(). Foreground(Dim) @@ -45,46 +71,46 @@ var ( Bold(true) SelectedRow = lipgloss.NewStyle(). - Foreground(NeonYellow). - Background(NeonPurple). - Bold(true) + Foreground(selectedForeground). + Background(selectedBackground). + Bold(true) // Chips DraftChip = lipgloss.NewStyle(). - Foreground(Black). - Background(NeonCyan). - Padding(0, 1). - Bold(true) + Foreground(chipForeground). + Background(NeonCyan). + Padding(0, 1). + Bold(true) StaleChip = lipgloss.NewStyle(). - Foreground(Black). - Background(NeonOrange). - Padding(0, 1). - Bold(true) + Foreground(chipForeground). + Background(NeonOrange). + Padding(0, 1). + Bold(true) CodeOwnerChip = lipgloss.NewStyle(). - Foreground(Black). - Background(NeonYellow). - Padding(0, 1) + Foreground(chipForeground). + Background(NeonYellow). + Padding(0, 1) SuccessToast = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(NeonLime). - Foreground(NeonLime). - Padding(0, 1). - Bold(true) + Border(lipgloss.RoundedBorder()). + BorderForeground(NeonLime). + Foreground(NeonLime). + Padding(0, 1). + Bold(true) ErrorToast = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(HotPink). - Foreground(HotPink). - Padding(0, 1). - Bold(true) + Border(lipgloss.RoundedBorder()). + BorderForeground(HotPink). + Foreground(HotPink). + Padding(0, 1). + Bold(true) KeyHint = lipgloss.NewStyle(). Foreground(NeonMagenta). Bold(true) KeyLabel = lipgloss.NewStyle(). - Foreground(NeonCyan) -) + Foreground(NeonCyan) +} diff --git a/scripts/install.sh b/scripts/install.sh index d59fa4b..60c859c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -39,25 +39,35 @@ case $OS in esac echo -e "Detected: ${GREEN}$OS-$ARCH${NC}" +ASSET_NAME="$BINARY_NAME-$OS-$ARCH" echo -e "${BLUE}Creating installation directory...${NC}" mkdir -p "$INSTALL_DIR" -echo -e "${BLUE}Fetching latest release...${NC}" -RELEASE_URL="https://api.github.com/repos/$REPO/releases/latest" -DOWNLOAD_URL=$(curl -fsSL "$RELEASE_URL" | grep -o "https://.*github-butler-$OS-$ARCH[^\"]*" | head -n 1 || true) - -if [ -z "$DOWNLOAD_URL" ]; then - echo -e "${RED}Error: Could not find binary for $OS-$ARCH${NC}" - echo -e "${YELLOW}Available releases: https://github.com/$REPO/releases${NC}" - exit 1 -fi - +echo -e "${BLUE}Preparing latest release download...${NC}" +DOWNLOAD_URL="https://github.com/$REPO/releases/latest/download/$ASSET_NAME" echo -e "Download URL: ${GREEN}$DOWNLOAD_URL${NC}" echo -e "${BLUE}Downloading github-butler...${NC}" TEMP_FILE=$(mktemp) -curl -L -o "$TEMP_FILE" "$DOWNLOAD_URL" +MAX_ATTEMPTS=10 +ATTEMPT=1 +while [ "$ATTEMPT" -le "$MAX_ATTEMPTS" ]; do + if curl -fL -o "$TEMP_FILE" "$DOWNLOAD_URL"; then + break + fi + + if [ "$ATTEMPT" -eq "$MAX_ATTEMPTS" ]; then + echo -e "${RED}Error: Could not download $ASSET_NAME${NC}" + echo -e "${YELLOW}Available releases: https://github.com/$REPO/releases${NC}" + rm -f "$TEMP_FILE" + exit 1 + fi + + echo -e "${YELLOW}Release asset not ready yet; retrying in 3s ($ATTEMPT/$MAX_ATTEMPTS)...${NC}" + ATTEMPT=$((ATTEMPT + 1)) + sleep 3 +done echo -e "${BLUE}Installing binary...${NC}" mv "$TEMP_FILE" "$INSTALL_DIR/$BINARY_NAME" From 530763cde0a06337e82ee2f6a217d39d8335334d Mon Sep 17 00:00:00 2001 From: Philipp Trentmann Date: Tue, 12 May 2026 18:31:03 +0200 Subject: [PATCH 2/2] feat(theme): add high contrast examples --- README.md | 4 ++- .../examples/high-contrast-dark.theme.yaml | 27 +++++++++++++++++++ .../examples/high-contrast-light.theme.yaml | 27 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 internal/ui/theme/examples/high-contrast-dark.theme.yaml create mode 100644 internal/ui/theme/examples/high-contrast-light.theme.yaml diff --git a/README.md b/README.md index b36fe4f..35fa59e 100644 --- a/README.md +++ b/README.md @@ -131,10 +131,12 @@ On first launch with no config, the app opens to an empty dashboard; press `m` `auto` uses Lipgloss/termenv to detect whether your terminal background is dark or light, then chooses the built-in `neon-dark` or `neon-light` theme. Detection depends on terminal support, so you can force either built-in theme from **Settings** → **Theme**. -User themes live in `~/.config/github-butler/themes` by default. The app seeds two bundled examples there on startup: +User themes live in `~/.config/github-butler/themes` by default. The app seeds bundled examples there on startup: - `midnight-neon.theme.yaml` - `daylight-neon.theme.yaml` +- `high-contrast-dark.theme.yaml` +- `high-contrast-light.theme.yaml` Self-updates seed newer examples after restart. If you changed one of the bundled examples, the app preserves your file and writes the updated example next to it with a `.new` suffix. diff --git a/internal/ui/theme/examples/high-contrast-dark.theme.yaml b/internal/ui/theme/examples/high-contrast-dark.theme.yaml new file mode 100644 index 0000000..54da415 --- /dev/null +++ b/internal/ui/theme/examples/high-contrast-dark.theme.yaml @@ -0,0 +1,27 @@ +name: High Contrast Dark +base: neon-dark +colors: + accent: "#D0D7DE" + info: "#79C0FF" + success: "#56D364" + warning: "#F2CC60" + danger: "#FF7B72" + dim: "#8B949E" + selected_foreground: "#0D1117" + selected_background: "#F0F6FC" + chip_foreground: "#0D1117" + black: "#0D1117" + white: "#F0F6FC" + dark_bg: "#0D1117" +gradients: + title: + - "#F0F6FC" + - "#79C0FF" + - "#56D364" + countdown: + - "#FF7B72" + - "#F2CC60" + - "#56D364" + header: + - "#F0F6FC" + - "#79C0FF" diff --git a/internal/ui/theme/examples/high-contrast-light.theme.yaml b/internal/ui/theme/examples/high-contrast-light.theme.yaml new file mode 100644 index 0000000..314d11b --- /dev/null +++ b/internal/ui/theme/examples/high-contrast-light.theme.yaml @@ -0,0 +1,27 @@ +name: High Contrast Light +base: neon-light +colors: + accent: "#24292F" + info: "#0550AE" + success: "#116329" + warning: "#744500" + danger: "#A40E26" + dim: "#57606A" + selected_foreground: "#FFFFFF" + selected_background: "#24292F" + chip_foreground: "#FFFFFF" + black: "#24292F" + white: "#FFFFFF" + dark_bg: "#FFFFFF" +gradients: + title: + - "#24292F" + - "#0550AE" + - "#116329" + countdown: + - "#A40E26" + - "#744500" + - "#116329" + header: + - "#24292F" + - "#0550AE"