Skip to content

Add support for themes #380

Merged
SuperCoolPencil merged 33 commits intoSurgeDM:mainfrom
bladeacer:main
Apr 20, 2026
Merged

Add support for themes #380
SuperCoolPencil merged 33 commits intoSurgeDM:mainfrom
bladeacer:main

Conversation

@bladeacer
Copy link
Copy Markdown
Contributor

@bladeacer bladeacer commented Apr 17, 2026

Resolves #328 .

Note the merge conflict and details discussed in #328 .

Greptile Summary

This PR adds a TOML-based theming system with four bundled themes (Catppuccin, Gruvbox, Nord, Surge), a multi-location path resolver, light/dark variant selection per theme, a RegisterThemeChangeHook mechanism so styles rebuild on palette swap, and a GoReleaser packaging script for themes.zip. The previously reported critical issues — data race on currentPalette, SetDarkMode not updating the palette, ThemeConfig struct tag mismatch, and the merge-conflict artifact — have all been resolved in this revision.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 style/documentation issues with no impact on runtime correctness.

All previously flagged P0/P1 issues (data race, palette not updated on mode change, TOML struct mismatch, merge artifact) are resolved. The three remaining comments are minor: a wrong inline step-number comment, an undocumented Palette.Dark field, and a missing light-variant test. None affect current behavior.

internal/tui/colors/colors.go and colors_test.go for the minor documentation and test-coverage gaps noted above.

Important Files Changed

Filename Overview
internal/tui/colors/colors.go Core theme engine: ThemeConfig/Palette structs, resolveThemePath, LoadTheme, palette() RLock accessor, SetDarkMode delegation to LoadTheme. Previously-flagged data race and struct-tag issues resolved. Minor: inline step-number comments mis-numbered; Palette.Dark field undocumented.
internal/tui/colors/colors_test.go Tests for dark-mode toggle, concurrent access, single-scheme theme load. Missing a test for light-variant palette selection from a dual-scheme theme file.
themes/catppuccin.toml Catppuccin Frappe (dark) + Latte (light) palette; structure matches ThemeConfig/Palette TOML tags correctly.
themes/surge.toml Dracula-inspired dark palette + standard light; uses single-quoted strings (valid TOML) while other theme files use double quotes — cosmetically inconsistent but harmless.
scripts/package-themes.sh Packages themes/*.toml into themes.zip for GoReleaser; uses set -euo pipefail, proper mktemp/trap cleanup, mirrors the existing fonts packaging script.
internal/config/settings.go Added ThemePath string field and Theme/ThemeAdaptive/ThemeLight/ThemeDark constants to GeneralSettings; defaults correctly to ThemeAdaptive and empty path.
.goreleaser.yaml Added package-themes.sh hook and themes.zip extra_file to the release; mirrors existing fonts.zip pattern correctly.
internal/tui/styles.go Now registers a theme-change hook via RegisterThemeChangeHook to rebuild styles on palette swap; clean integration.

Comments Outside Diff (8)

  1. internal/tui/view_settings.go, line 856-866 (link)

    P1 resetSettingToDefault doesn't handle theme_path

    When the user presses the reset key on the theme_path setting, resetSettingToDefault falls through without resetting it (no case "theme_path" in the General switch). The path remains unchanged and ApplyTheme is not called (it's only called when settingKey == "theme"), so the custom theme stays active even after a reset.

    Add the missing case:

    case "theme_path":
        m.Settings.General.ThemePath = defaults.General.ThemePath
        m.ApplyTheme(m.Settings.General.Theme, defaults.General.ThemePath)
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/tui/view_settings.go
    Line: 856-866
    
    Comment:
    **`resetSettingToDefault` doesn't handle `theme_path`**
    
    When the user presses the reset key on the `theme_path` setting, `resetSettingToDefault` falls through without resetting it (no `case "theme_path"` in the General switch). The path remains unchanged and `ApplyTheme` is not called (it's only called when `settingKey == "theme"`), so the custom theme stays active even after a reset.
    
    Add the missing case:
    
    ```go
    case "theme_path":
        m.Settings.General.ThemePath = defaults.General.ThemePath
        m.ApplyTheme(m.Settings.General.Theme, defaults.General.ThemePath)
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. internal/tui/view_settings.go, line 861-865 (link)

    P1 theme_path reset is a no-op

    resetSettingToDefault handles every new General setting except theme_path. Pressing the reset key on "Theme File" silently does nothing — the path is never cleared and ApplyTheme is never re-invoked. There is also no if settingKey == "theme_path" branch in update_settings.go's reset handler (line 186) to re-apply the theme after clearing the path.

    And in update_settings.go after line 188, add:

    if settingKey == "theme_path" {
        m.ApplyTheme(m.Settings.General.Theme, m.Settings.General.ThemePath)
    }
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/tui/view_settings.go
    Line: 861-865
    
    Comment:
    **`theme_path` reset is a no-op**
    
    `resetSettingToDefault` handles every new General setting except `theme_path`. Pressing the reset key on "Theme File" silently does nothing — the path is never cleared and `ApplyTheme` is never re-invoked. There is also no `if settingKey == "theme_path"` branch in `update_settings.go`'s reset handler (line 186) to re-apply the theme after clearing the path.
    
    
    
    And in `update_settings.go` after line 188, add:
    ```go
    if settingKey == "theme_path" {
        m.ApplyTheme(m.Settings.General.Theme, m.Settings.General.ThemePath)
    }
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. internal/tui/update_settings.go, line 178-190 (link)

    P1 Resetting theme_path leaves the UI on the old custom theme

    When the user presses the reset key on the theme_path setting, resetSettingToDefault clears ThemePath to "", but ApplyTheme is only called when settingKey == "theme". The palette is therefore never reloaded, so the custom theme remains visually active even though the setting now says it's cleared.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/tui/update_settings.go
    Line: 178-190
    
    Comment:
    **Resetting `theme_path` leaves the UI on the old custom theme**
    
    When the user presses the reset key on the `theme_path` setting, `resetSettingToDefault` clears `ThemePath` to `""`, but `ApplyTheme` is only called when `settingKey == "theme"`. The palette is therefore never reloaded, so the custom theme remains visually active even though the setting now says it's cleared.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  4. internal/tui/update_settings.go, line 178-189 (link)

    P1 Resetting theme_path doesn't re-apply the theme

    After resetSettingToDefault clears ThemePath to "", ApplyTheme is only called when settingKey == "theme", not when settingKey == "theme_path". The in-memory LoadTheme state still points at the old custom palette, so the UI continues rendering with the custom theme until a restart — the reset has no visible effect.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/tui/update_settings.go
    Line: 178-189
    
    Comment:
    **Resetting `theme_path` doesn't re-apply the theme**
    
    After `resetSettingToDefault` clears `ThemePath` to `""`, `ApplyTheme` is only called when `settingKey == "theme"`, not when `settingKey == "theme_path"`. The in-memory `LoadTheme` state still points at the old custom palette, so the UI continues rendering with the custom theme until a restart — the reset has no visible effect.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  5. internal/tui/update_settings.go, line 191-203 (link)

    P1 Resetting theme_path doesn't re-apply the theme

    resetSettingToDefault clears m.Settings.General.ThemePath to "", but ApplyTheme is only called when settingKey == "theme". Resetting the path leaves the custom palette active in memory — the user sees no change until they restart the app.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/tui/update_settings.go
    Line: 191-203
    
    Comment:
    **Resetting `theme_path` doesn't re-apply the theme**
    
    `resetSettingToDefault` clears `m.Settings.General.ThemePath` to `""`, but `ApplyTheme` is only called when `settingKey == "theme"`. Resetting the path leaves the custom palette active in memory — the user sees no change until they restart the app.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  6. internal/tui/update_filepicker.go, line 63-65 (link)

    P1 GotoHome loses file-only mode for theme picker

    handleFilePickerGotoHome() calls newFilepicker(defaultDir) which creates a picker with DirAllowed=true, FileAllowed=false (the defaults in newFilepicker). For FilePickerOriginTheme this leaves the picker in directory-only mode, so after pressing H no .toml file can ever be selected.

    updateBatchFilePicker handles the same edge case explicitly (lines 96–98 in the same file):

    cmd := m.handleFilePickerGotoHome()
    m.filepicker.FileAllowed = true
    m.filepicker.DirAllowed = false
    return m, cmd

    The theme branch here should do the same:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/tui/update_filepicker.go
    Line: 63-65
    
    Comment:
    **GotoHome loses file-only mode for theme picker**
    
    `handleFilePickerGotoHome()` calls `newFilepicker(defaultDir)` which creates a picker with `DirAllowed=true, FileAllowed=false` (the defaults in `newFilepicker`). For `FilePickerOriginTheme` this leaves the picker in directory-only mode, so after pressing H no `.toml` file can ever be selected.
    
    `updateBatchFilePicker` handles the same edge case explicitly (lines 96–98 in the same file):
    ```go
    cmd := m.handleFilePickerGotoHome()
    m.filepicker.FileAllowed = true
    m.filepicker.DirAllowed = false
    return m, cmd
    ```
    
    The theme branch here should do the same:
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  7. internal/tui/update_settings.go, line 198-210 (link)

    P1 Resetting theme_path leaves UI with stale palette

    When the user presses Reset on the theme_path setting, the field is cleared to "" by resetSettingToDefault, but ApplyTheme is never called. The currentPalette pointer in the colors package continues pointing at the custom theme's data, so the UI remains visually themed even though the setting now reads empty. By contrast, resetting theme (line 207) does call ApplyTheme. The same call needs to be added for theme_path:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/tui/update_settings.go
    Line: 198-210
    
    Comment:
    **Resetting `theme_path` leaves UI with stale palette**
    
    When the user presses Reset on the `theme_path` setting, the field is cleared to `""` by `resetSettingToDefault`, but `ApplyTheme` is never called. The `currentPalette` pointer in the `colors` package continues pointing at the custom theme's data, so the UI remains visually themed even though the setting now reads empty. By contrast, resetting `theme` (line 207) does call `ApplyTheme`. The same call needs to be added for `theme_path`:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  8. internal/config/paths.go, line 121-125 (link)

    P1 Themes directory never created by EnsureDirs

    GetThemesDir() is defined and used as the default browse location when opening the file picker for theme_path (in update_settings.go), but it is absent from the dirs slice in EnsureDirs. On a fresh install, config.GetThemesDir() always returns a non-empty path, so the browseDir == "" fallback in update_settings.go never fires, and the filepicker is pointed at a directory that doesn't yet exist — resulting in a broken/empty file picker for users trying to browse for a theme.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/config/paths.go
    Line: 121-125
    
    Comment:
    **Themes directory never created by `EnsureDirs`**
    
    `GetThemesDir()` is defined and used as the default browse location when opening the file picker for `theme_path` (in `update_settings.go`), but it is absent from the `dirs` slice in `EnsureDirs`. On a fresh install, `config.GetThemesDir()` always returns a non-empty path, so the `browseDir == ""` fallback in `update_settings.go` never fires, and the filepicker is pointed at a directory that doesn't yet exist — resulting in a broken/empty file picker for users trying to browse for a theme.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: internal/tui/colors/colors.go
Line: 158-169

Comment:
**Incorrect step numbers in inline comments**

The inline comments label the local `./themes/` lookup as steps 5 and 6, but the function-level docstring (lines 129–132) correctly numbers them 3 and 4. The XDG paths are 5 and 6. The mismatch makes the priority order harder to follow.

```suggestion
	// 3. ./themes/path
	// 4. ./themes/path + ".toml"
	localThemesPath := filepath.Join("themes", path)
	if _, err := os.Stat(localThemesPath); err == nil {
		return localThemesPath
	}
	if !strings.HasSuffix(localThemesPath, ".toml") {
		pathWithExt := localThemesPath + ".toml"
		if _, err := os.Stat(pathWithExt); err == nil {
			return pathWithExt
		}
	}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: internal/tui/colors/colors_test.go
Line: 83-130

Comment:
**Missing test for light-variant palette selection**

`TestLoadTheme_SingleScheme` only covers a flat single-scheme theme. The `!darkPreferred && cfg.Colors.Light != nil` branch in `LoadTheme` — the path exercised by every bundled theme (Nord, Catppuccin, etc.) when the user is in light mode — has no test. Adding a complementary `TestLoadTheme_LightVariant` that writes a TOML with a `[colors.light.*]` section and calls `LoadTheme(path, false)` would close this gap and protect against regressions in the selector logic.

**Rule Used:** What: All code changes must include tests for edge... ([source](https://app.greptile.com/review/custom-context?memory=2b22782d-3452-4d55-b059-e631b2540ce8))

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: internal/tui/colors/colors.go
Line: 44-46

Comment:
**`Palette.Dark` is never populated by any bundled theme**

All four bundled themes place the dark variant directly under `[colors]` and only define an explicit `[colors.light.*]` section, so `cfg.Colors.Dark` is always `nil` at runtime and the `darkPreferred && cfg.Colors.Dark != nil` branch in `LoadTheme` is never taken. The field works for hand-crafted themes with `[colors.dark.*]`, but since this is undocumented and asymmetric with how the bundled themes behave, it may confuse future contributors. A brief doc-comment on the field (or an example in `docs/THEMES.md`) would clarify the intent.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (15): Last reviewed commit: "feat: add theme packaging script and inc..." | Re-trigger Greptile

Context used:

  • Rule used - What: All code changes must include tests for edge... (source)

Comment thread internal/tui/colors/colors.go
Comment thread internal/tui/colors/colors.go Outdated
Comment thread internal/tui/colors/colors.go
Comment thread internal/tui/components/box.go
Comment thread internal/config/settings.go Outdated
Comment thread internal/tui/colors/colors.go Outdated
@bladeacer
Copy link
Copy Markdown
Contributor Author

@greptileai

This is a comment left during a code review.
Path: internal/tui/colors/colors.go
Line: 195-220

Comment:
Data race: currentPalette read without lock

All color accessor functions (e.g. Pink(), Gray(), Background()) dereference currentPalette without holding modeMu. LoadTheme writes currentPalette under modeMu.Lock(), so any concurrent UI render racing with a theme reload would be a data race flagged by Go's race detector. Each function should take an RLock before reading the pointer, or currentPalette should be read atomically.

The old themeColor.RGBA() was safe because it called IsDarkMode() which itself holds modeMu.RLock(), but the new code bypasses that protection entirely.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 17, 2026

The cleanest fix is a single helper that takes the read lock, then have all accessor functions use it:

func palette() *Palette {
    modeMu.RLock()
    p := currentPalette
    modeMu.RUnlock()
    return p
}

Then update every accessor to call palette() instead of reading currentPalette directly:

func Background() color.Color { return lipgloss.Color(palette().Primary.Background) }
func Foreground() color.Color { return lipgloss.Color(palette().Primary.Foreground) }
func White() color.Color      { return lipgloss.Color(palette().Normal.White) }
func Gray() color.Color       { return lipgloss.Color(palette().Normal.Black) }
func Pink() color.Color       { return lipgloss.Color(palette().Bright.Red) }
// ... and so on for all accessors
func ProgressStart() color.Color { return lipgloss.Color(palette().Bright.Red) }
func ProgressEnd() color.Color   { return lipgloss.Color(palette().Bright.Magenta) }

This works because reading the pointer under RLock is safe — multiple goroutines can hold the read lock concurrently, and LoadTheme only writes under a full Lock(). The snapshot of the pointer is stable for the duration of each accessor call.

Alternatively, you could use atomic.Pointer[Palette] (Go 1.19+) to avoid the lock entirely in the hot path, but the RLock approach above is simpler given modeMu already exists.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules and .greptile/config.

@bladeacer
Copy link
Copy Markdown
Contributor Author

@greptileai

This is a comment left during a code review.
Path: internal/tui/colors/colors.go
Line: 252-268

Comment:
SetDarkMode no longer updates currentPalette, breaking tests

SetDarkMode changes isDarkMode but does not update currentPalette. Color functions now read directly from currentPalette (initialized to &defaultDark in init()), so calling SetDarkMode(false) leaves currentPalette pointing at the dark palette — Pink() will still return #ff79c6 instead of the expected #d10074.

TestSetDarkMode_UpdatesExportedPalette exercises exactly this path and will fail. SetDarkMode needs to swap currentPalette to the appropriate default palette when no custom theme is active:

func SetDarkMode(isDark bool) {
    modeMu.Lock()
    changed := isDarkMode != isDark
    isDarkMode = isDark
    if isDark {
        currentPalette = &defaultDark
    } else {
        currentPalette = &defaultLight
    }
    modeMu.Unlock()
    // ...
}

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/tui/colors/colors.go
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 17, 2026

The fix is to update currentPalette inside the existing modeMu.Lock() block in SetDarkMode. Here's the exact change:

func SetDarkMode(isDark bool) {
	modeMu.Lock()
	changed := isDarkMode != isDark
	isDarkMode = isDark
	if isDark {
		currentPalette = &defaultDark
	} else {
		currentPalette = &defaultLight
	}
	modeMu.Unlock()

	if !changed {
		return
	}

	hookMu.RLock()
	registeredHooks := append([]func(){}, hooks...)
	hookMu.RUnlock()
	for _, fn := range registeredHooks {
		fn()
	}
}

One caveat: this resets to the default palette even when a custom theme is active (e.g., user toggles dark/light mode while a .toml theme is loaded). If that matters, you'd need to track whether a custom path is set and re-call LoadTheme with it instead of falling back to defaultDark/defaultLight. But for fixing the test and the immediate bug, this is sufficient.

Comment thread internal/tui/colors/colors.go Outdated
@bladeacer bladeacer force-pushed the main branch 2 times, most recently from 187e8b9 to 9419311 Compare April 17, 2026 12:21
@bladeacer
Copy link
Copy Markdown
Contributor Author

Update: CI seems to be happy now.

@bladeacer bladeacer mentioned this pull request Apr 17, 2026
@bladeacer
Copy link
Copy Markdown
Contributor Author

@SuperCoolPencil I think I have added what I want to the PR.

Some things which potentially can be improved:

  • Use of FilePicker (I have no idea how to hook it up to the theme path selection), should default to $XDG_CONFIG_DIR/surge/themes$
  • Expand Palette definition to include name, so that default colour schemes + custom ones can be configured/switched to easily.
  • DarkGrey/Grey are using the same hex value, because there is not much of a notion of the two being separate in Base16 to Alacritty colour scheme mapping.

On an unrelated note imo there could be docs/info on installation and usage of gotestsum/golangci-lint on a local machine for development purposes. Been wanting to contribute code to this cool project for a while.

@SuperCoolPencil
Copy link
Copy Markdown
Member

Hey @bladeacer

Thanks for the PR!

I'm out of town this weekend but i"ll try to review this asap.

Noticed some merge conflicts, would really appreciate if you could resolve them

Awesomeee

@bladeacer
Copy link
Copy Markdown
Contributor Author

bladeacer commented Apr 18, 2026

@SuperCoolPencil , I have fixed the merge conflicts.

@bladeacer bladeacer reopened this Apr 18, 2026
Comment thread internal/tui/colors/colors.go.orig Outdated
Comment thread internal/tui/colors/colors.go
Comment thread surge-default.toml Outdated
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 20, 2026

Want your agent to iterate on Greptile's feedback? Try greploops.

@bladeacer
Copy link
Copy Markdown
Contributor Author

Testing latest changes the theme path file picker does not have a default path, and pressing enter does not select the toml file.

@SuperCoolPencil
Copy link
Copy Markdown
Member

Hey @bladeacer

I'm so sorry for not seeing your comments, I'm just trying to integrate some changes. Yeah, pressing enter doesnt currently select it. Thanks for reporting that. I'll fix it up.

Really good work on the PR, all the changes are well thought out. If I had a few suggestions it would be

  • When you want to create a PR, create a new branch in your fork and open a PR from that. Do not use main to create PR's
  • I've refactored the structs to use a more robust circular approach:
    The Dark and Light pointers are now moved inside the Palette struct itself.
    ThemeConfig now simply decodes into a single Colors *Palette field.
    This naturally maps the [colors] table to the root palette, while [colors.dark] and [colors.light] are captured as nested fields.
  • I've added TestLoadTheme_SingleScheme to colors_test.go which verifies that single-scheme TOML files (like the ones currently provided) correctly override the default colors without needing explicit dark/light blocks.

@bladeacer
Copy link
Copy Markdown
Contributor Author

When you want to create a PR, create a new branch in your fork and open a PR from that. Do not use main to create PR's

Thanks for the head's up :D

I've refactored the structs to use a more robust circular approach:
The Dark and Light pointers are now moved inside the Palette struct itself.
ThemeConfig now simply decodes into a single Colors *Palette field.
This naturally maps the [colors] table to the root palette, while [colors.dark] and [colors.light] are captured as nested fields.

The approach I took to that was assuming the user could either supply a single [colors] with an is_dark boolean for single dark/light colour scheme, while also supporting supplying both [colors.light] and [colors.dark] in the same TOML file (alternate spec).

The explicit light dark block are for the aforementioned purpose, being optional by providing a sane fallback when it is not provided.

@bladeacer
Copy link
Copy Markdown
Contributor Author

@SuperCoolPencil lgtm.

@SuperCoolPencil
Copy link
Copy Markdown
Member

Awesome!

Thank you so much for contributing!

OSS needs more contributors like you :)

@SuperCoolPencil SuperCoolPencil merged commit 7cce3b0 into SurgeDM:main Apr 20, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for themes

2 participants