fix(config): derive external theme fallback paths
- commit
- 67f784236bbc51ae16e499340cdf016f019e2d35
- author
- brice · · 2026-06-10 05:51 UTC
- committer
- brice · · 2026-06-10 06:37 UTC
- parent
- 66d7b57
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +101 -21
M docs/config/index.md +7 -3
| @@ -144,7 +144,7 @@ auto_open_on_new_pane = false | |||
| 144 | 144 | | `appearance.external_theme.enabled` | bool | `false` | Enable an external palette source | | |
| 145 | 145 | | `appearance.external_theme.provider` | string | `"noctalia"` | External provider. Only `noctalia` is supported | | |
| 146 | 146 | | `appearance.external_theme.format` | string | `"colors-json"` | External file format: `colors-json` or `dumber-json` | | |
| 147 | - | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | Path to the external theme JSON file | | |
| 147 | + | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` for `colors-json`; built-in dumber-json template path for `dumber-json` | Path to the external theme JSON file | | |
| 148 | 148 | ||
| 149 | 149 | ### Color Palettes | |
| 150 | 150 | ||
| @@ -174,7 +174,7 @@ border = "#363636" | |||
| 174 | 174 | ||
| 175 | 175 | ### External Theme: Noctalia | |
| 176 | 176 | ||
| 177 | - | Dumber can follow Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration. | |
| 177 | + | Dumber can integrate with Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration. | |
| 178 | 178 | ||
| 179 | 179 | ```toml | |
| 180 | 180 | [appearance.external_theme] | |
| @@ -203,7 +203,11 @@ Dumber maps Noctalia roles like this: | |||
| 203 | 203 | | `mPrimary` | `accent` | | |
| 204 | 204 | | `mOutline` | `border` | | |
| 205 | 205 | ||
| 206 | - | Every mapped value must be a CSS-safe 6-digit hex color such as `#AABBCC`. `mShadow` and `mHover` are optional nuance roles; when they are present they are validated and used to preserve Dumber's background/surface/input contrast. Because Noctalia's native file contains the active palette only, Dumber applies that mapped palette to both resolved light and dark palettes so the app follows the file immediately. | |
| 206 | + | Every mapped value must be a CSS-safe 6-digit hex color such as `#aabbcc`. | |
| 207 | + | ||
| 208 | + | `mShadow` and `mHover` are optional nuance roles. When present, they are validated and used to preserve Dumber's background/surface/input contrast. | |
| 209 | + | ||
| 210 | + | Because Noctalia's native file contains the active palette only, Dumber applies that mapped palette to both resolved light and dark palettes so the app follows the file immediately. | |
| 207 | 211 | ||
| 208 | 212 | #### Advanced: `dumber-json` user template | |
| 209 | 213 | ||
| @@ -144,7 +144,7 @@ auto_open_on_new_pane = false | |||||
| 144 | | `appearance.external_theme.enabled` | bool | `false` | Enable an external palette source | | 144 | | `appearance.external_theme.enabled` | bool | `false` | Enable an external palette source | | ||
| 145 | | `appearance.external_theme.provider` | string | `"noctalia"` | External provider. Only `noctalia` is supported | | 145 | | `appearance.external_theme.provider` | string | `"noctalia"` | External provider. Only `noctalia` is supported | | ||
| 146 | | `appearance.external_theme.format` | string | `"colors-json"` | External file format: `colors-json` or `dumber-json` | | 146 | | `appearance.external_theme.format` | string | `"colors-json"` | External file format: `colors-json` or `dumber-json` | | ||
| 147 | - | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | Path to the external theme JSON file | | 147 | + | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` for `colors-json`; built-in dumber-json template path for `dumber-json` | Path to the external theme JSON file | |
| 148 | 148 | ||||
| 149 | ### Color Palettes | 149 | ### Color Palettes | ||
| 150 | 150 | ||||
| @@ -174,7 +174,7 @@ border = "#363636" | |||||
| 174 | 174 | ||||
| 175 | ### External Theme: Noctalia | 175 | ### External Theme: Noctalia | ||
| 176 | 176 | ||||
| 177 | - | Dumber can follow Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration. | 177 | + | Dumber can integrate with Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration. |
| 178 | 178 | ||||
| 179 | ```toml | 179 | ```toml | ||
| 180 | [appearance.external_theme] | 180 | [appearance.external_theme] | ||
| @@ -203,7 +203,11 @@ Dumber maps Noctalia roles like this: | |||||
| 203 | | `mPrimary` | `accent` | | 203 | | `mPrimary` | `accent` | | ||
| 204 | | `mOutline` | `border` | | 204 | | `mOutline` | `border` | | ||
| 205 | 205 | ||||
| 206 | - | Every mapped value must be a CSS-safe 6-digit hex color such as `#AABBCC`. `mShadow` and `mHover` are optional nuance roles; when they are present they are validated and used to preserve Dumber's background/surface/input contrast. Because Noctalia's native file contains the active palette only, Dumber applies that mapped palette to both resolved light and dark palettes so the app follows the file immediately. | 206 | + | Every mapped value must be a CSS-safe 6-digit hex color such as `#aabbcc`. |
| 207 | + | ||||
| 208 | + | `mShadow` and `mHover` are optional nuance roles. When present, they are validated and used to preserve Dumber's background/surface/input contrast. | |||
| 209 | + | ||||
| 210 | + | Because Noctalia's native file contains the active palette only, Dumber applies that mapped palette to both resolved light and dark palettes so the app follows the file immediately. | |||
| 207 | 211 | ||||
| 208 | #### Advanced: `dumber-json` user template | 212 | #### Advanced: `dumber-json` user template | ||
| 209 | 213 | ||||
M docs/reference/configuration.md +1 -1
| @@ -36,7 +36,7 @@ | |||
| 36 | 36 | | `appearance.external_theme.enabled` | bool | `false` | | | |
| 37 | 37 | | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` | | |
| 38 | 38 | | `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` | | |
| 39 | - | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia colors.json or explicit dumber-json | | |
| 39 | + | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia `colors.json` or a `dumber-json` theme file; when empty and `format = "dumber-json"`, dumber uses the built-in dumber-json template path | | |
| 40 | 40 | | `appearance.light_palette.background` | string | `#f8f8f8` | | | |
| 41 | 41 | | `appearance.light_palette.surface` | string | `#f2f2f2` | | | |
| 42 | 42 | | `appearance.light_palette.surface_variant` | string | `#ececec` | | | |
| @@ -36,7 +36,7 @@ | |||||
| 36 | | `appearance.external_theme.enabled` | bool | `false` | | | 36 | | `appearance.external_theme.enabled` | bool | `false` | | | ||
| 37 | | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` | | 37 | | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` | | ||
| 38 | | `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` | | 38 | | `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` | | ||
| 39 | - | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia colors.json or explicit dumber-json | | 39 | + | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia `colors.json` or a `dumber-json` theme file; when empty and `format = "dumber-json"`, dumber uses the built-in dumber-json template path | |
| 40 | | `appearance.light_palette.background` | string | `#f8f8f8` | | | 40 | | `appearance.light_palette.background` | string | `#f8f8f8` | | | ||
| 41 | | `appearance.light_palette.surface` | string | `#f2f2f2` | | | 41 | | `appearance.light_palette.surface` | string | `#f2f2f2` | | | ||
| 42 | | `appearance.light_palette.surface_variant` | string | `#ececec` | | | 42 | | `appearance.light_palette.surface_variant` | string | `#ececec` | | | ||
M internal/cli/styles/theme_test.go +13 -0
| @@ -48,3 +48,16 @@ func TestNewThemeFromResolvedFallsBackToDarkPalette(t *testing.T) { | |||
| 48 | 48 | require.Equal(t, lipgloss.Color("#111111"), theme.Background) | |
| 49 | 49 | require.Equal(t, lipgloss.Color("#fedcba"), theme.Accent) | |
| 50 | 50 | } | |
| 51 | + | ||
| 52 | + | func TestNewThemeFromResolvedFallsBackToDefaultDarkPalette(t *testing.T) { | |
| 53 | + | resolved := entity.ResolvedTheme{} | |
| 54 | + | palette := styles.DefaultDarkPalette() | |
| 55 | + | ||
| 56 | + | theme := styles.NewThemeFromResolved(resolved) | |
| 57 | + | ||
| 58 | + | require.Equal(t, lipgloss.Color(palette.Background), theme.Background) | |
| 59 | + | require.Equal(t, lipgloss.Color(palette.Surface), theme.Surface) | |
| 60 | + | require.Equal(t, lipgloss.Color(palette.Text), theme.Text) | |
| 61 | + | require.Equal(t, lipgloss.Color(palette.Accent), theme.Accent) | |
| 62 | + | require.Equal(t, lipgloss.Color(palette.Accent), theme.Success) | |
| 63 | + | } | |
| @@ -48,3 +48,16 @@ func TestNewThemeFromResolvedFallsBackToDarkPalette(t *testing.T) { | |||||
| 48 | require.Equal(t, lipgloss.Color("#111111"), theme.Background) | 48 | require.Equal(t, lipgloss.Color("#111111"), theme.Background) | ||
| 49 | require.Equal(t, lipgloss.Color("#fedcba"), theme.Accent) | 49 | require.Equal(t, lipgloss.Color("#fedcba"), theme.Accent) | ||
| 50 | } | 50 | } | ||
| 51 | + | ||||
| 52 | + | func TestNewThemeFromResolvedFallsBackToDefaultDarkPalette(t *testing.T) { | |||
| 53 | + | resolved := entity.ResolvedTheme{} | |||
| 54 | + | palette := styles.DefaultDarkPalette() | |||
| 55 | + | ||||
| 56 | + | theme := styles.NewThemeFromResolved(resolved) | |||
| 57 | + | ||||
| 58 | + | require.Equal(t, lipgloss.Color(palette.Background), theme.Background) | |||
| 59 | + | require.Equal(t, lipgloss.Color(palette.Surface), theme.Surface) | |||
| 60 | + | require.Equal(t, lipgloss.Color(palette.Text), theme.Text) | |||
| 61 | + | require.Equal(t, lipgloss.Color(palette.Accent), theme.Accent) | |||
| 62 | + | require.Equal(t, lipgloss.Color(palette.Accent), theme.Success) | |||
| 63 | + | } | |||
M internal/infrastructure/config/external_theme_test.go +26 -4
| @@ -27,10 +27,7 @@ func TestManagerSetDefaults_ExternalThemeViperDefaults(t *testing.T) { | |||
| 27 | 27 | require.False(t, mgr.viper.GetBool("appearance.external_theme.enabled")) | |
| 28 | 28 | require.Equal(t, "noctalia", mgr.viper.GetString("appearance.external_theme.provider")) | |
| 29 | 29 | require.Equal(t, "colors-json", mgr.viper.GetString("appearance.external_theme.format")) | |
| 30 | - | require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements( | |
| 31 | - | mgr.viper.GetString("appearance.external_theme.path"), | |
| 32 | - | 2, | |
| 33 | - | )) | |
| 30 | + | require.Empty(t, mgr.viper.GetString("appearance.external_theme.path")) | |
| 34 | 31 | } | |
| 35 | 32 | ||
| 36 | 33 | func TestManagerLoad_ExternalThemeEnabledEmptyPathUsesDefaultColorsJSON(t *testing.T) { | |
| @@ -84,6 +81,31 @@ path = '' | |||
| 84 | 81 | require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path) | |
| 85 | 82 | } | |
| 86 | 83 | ||
| 84 | + | func TestManagerLoad_ExternalThemeDumberJSONOmittedPathUsesTemplateDefault(t *testing.T) { | |
| 85 | + | configHome := t.TempDir() | |
| 86 | + | t.Setenv("ENV", "") | |
| 87 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |
| 88 | + | ||
| 89 | + | configDir := filepath.Join(configHome, appName) | |
| 90 | + | require.NoError(t, os.MkdirAll(configDir, dirPerm)) | |
| 91 | + | require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(` | |
| 92 | + | [appearance.external_theme] | |
| 93 | + | enabled = true | |
| 94 | + | provider = 'noctalia' | |
| 95 | + | format = 'dumber-json' | |
| 96 | + | `), filePerm)) | |
| 97 | + | ||
| 98 | + | mgr, err := NewManager() | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | require.NoError(t, mgr.Load()) | |
| 101 | + | ||
| 102 | + | externalTheme := mgr.Get().Appearance.ExternalTheme | |
| 103 | + | require.True(t, externalTheme.Enabled) | |
| 104 | + | require.Equal(t, "noctalia", externalTheme.Provider) | |
| 105 | + | require.Equal(t, "dumber-json", externalTheme.Format) | |
| 106 | + | require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path) | |
| 107 | + | } | |
| 108 | + | ||
| 87 | 109 | func TestValidateConfig_ExternalTheme(t *testing.T) { | |
| 88 | 110 | tests := []struct { | |
| 89 | 111 | name string | |
| @@ -27,10 +27,7 @@ func TestManagerSetDefaults_ExternalThemeViperDefaults(t *testing.T) { | |||||
| 27 | require.False(t, mgr.viper.GetBool("appearance.external_theme.enabled")) | 27 | require.False(t, mgr.viper.GetBool("appearance.external_theme.enabled")) | ||
| 28 | require.Equal(t, "noctalia", mgr.viper.GetString("appearance.external_theme.provider")) | 28 | require.Equal(t, "noctalia", mgr.viper.GetString("appearance.external_theme.provider")) | ||
| 29 | require.Equal(t, "colors-json", mgr.viper.GetString("appearance.external_theme.format")) | 29 | require.Equal(t, "colors-json", mgr.viper.GetString("appearance.external_theme.format")) | ||
| 30 | - | require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements( | 30 | + | require.Empty(t, mgr.viper.GetString("appearance.external_theme.path")) |
| 31 | - | mgr.viper.GetString("appearance.external_theme.path"), | |||
| 32 | - | 2, | |||
| 33 | - | )) | |||
| 34 | } | 31 | } | ||
| 35 | 32 | ||||
| 36 | func TestManagerLoad_ExternalThemeEnabledEmptyPathUsesDefaultColorsJSON(t *testing.T) { | 33 | func TestManagerLoad_ExternalThemeEnabledEmptyPathUsesDefaultColorsJSON(t *testing.T) { | ||
| @@ -84,6 +81,31 @@ path = '' | |||||
| 84 | require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path) | 81 | require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path) | ||
| 85 | } | 82 | } | ||
| 86 | 83 | ||||
| 84 | + | func TestManagerLoad_ExternalThemeDumberJSONOmittedPathUsesTemplateDefault(t *testing.T) { | |||
| 85 | + | configHome := t.TempDir() | |||
| 86 | + | t.Setenv("ENV", "") | |||
| 87 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |||
| 88 | + | ||||
| 89 | + | configDir := filepath.Join(configHome, appName) | |||
| 90 | + | require.NoError(t, os.MkdirAll(configDir, dirPerm)) | |||
| 91 | + | require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(` | |||
| 92 | + | [appearance.external_theme] | |||
| 93 | + | enabled = true | |||
| 94 | + | provider = 'noctalia' | |||
| 95 | + | format = 'dumber-json' | |||
| 96 | + | `), filePerm)) | |||
| 97 | + | ||||
| 98 | + | mgr, err := NewManager() | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | require.NoError(t, mgr.Load()) | |||
| 101 | + | ||||
| 102 | + | externalTheme := mgr.Get().Appearance.ExternalTheme | |||
| 103 | + | require.True(t, externalTheme.Enabled) | |||
| 104 | + | require.Equal(t, "noctalia", externalTheme.Provider) | |||
| 105 | + | require.Equal(t, "dumber-json", externalTheme.Format) | |||
| 106 | + | require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 87 | func TestValidateConfig_ExternalTheme(t *testing.T) { | 109 | func TestValidateConfig_ExternalTheme(t *testing.T) { | ||
| 88 | tests := []struct { | 110 | tests := []struct { | ||
| 89 | name string | 111 | name string | ||
M internal/infrastructure/config/loader.go +0 -1
| @@ -659,7 +659,6 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) { | |||
| 659 | 659 | m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled) | |
| 660 | 660 | m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider) | |
| 661 | 661 | m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format) | |
| 662 | - | m.viper.SetDefault("appearance.external_theme.path", defaults.Appearance.ExternalTheme.Path) | |
| 663 | 662 | } | |
| 664 | 663 | ||
| 665 | 664 | func (m *Manager) setZoomAndScaleDefaults(defaults *Config) { | |
| @@ -659,7 +659,6 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) { | |||||
| 659 | m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled) | 659 | m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled) | ||
| 660 | m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider) | 660 | m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider) | ||
| 661 | m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format) | 661 | m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format) | ||
| 662 | - | m.viper.SetDefault("appearance.external_theme.path", defaults.Appearance.ExternalTheme.Path) | |||
| 663 | } | 662 | } | ||
| 664 | 663 | ||||
| 665 | func (m *Manager) setZoomAndScaleDefaults(defaults *Config) { | 664 | func (m *Manager) setZoomAndScaleDefaults(defaults *Config) { | ||
M internal/infrastructure/config/webui_gateway.go +1 -4
| @@ -64,10 +64,7 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIC | |||
| 64 | 64 | Accent: cfg.Appearance.DarkPalette.Accent, | |
| 65 | 65 | Border: cfg.Appearance.DarkPalette.Border, | |
| 66 | 66 | } | |
| 67 | - | current.Appearance.ExternalTheme.Enabled = cfg.Appearance.ExternalTheme.Enabled | |
| 68 | - | current.Appearance.ExternalTheme.Provider = cfg.Appearance.ExternalTheme.Provider | |
| 69 | - | current.Appearance.ExternalTheme.Format = cfg.Appearance.ExternalTheme.Format | |
| 70 | - | current.Appearance.ExternalTheme.Path = cfg.Appearance.ExternalTheme.Path | |
| 67 | + | current.Appearance.ExternalTheme = cfg.Appearance.ExternalTheme | |
| 71 | 68 | current.DefaultUIScale = cfg.DefaultUIScale | |
| 72 | 69 | current.DefaultSearchEngine = cfg.DefaultSearchEngine | |
| 73 | 70 | if len(cfg.SearchShortcuts) == 0 { | |
| @@ -64,10 +64,7 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIC | |||||
| 64 | Accent: cfg.Appearance.DarkPalette.Accent, | 64 | Accent: cfg.Appearance.DarkPalette.Accent, | ||
| 65 | Border: cfg.Appearance.DarkPalette.Border, | 65 | Border: cfg.Appearance.DarkPalette.Border, | ||
| 66 | } | 66 | } | ||
| 67 | - | current.Appearance.ExternalTheme.Enabled = cfg.Appearance.ExternalTheme.Enabled | 67 | + | current.Appearance.ExternalTheme = cfg.Appearance.ExternalTheme |
| 68 | - | current.Appearance.ExternalTheme.Provider = cfg.Appearance.ExternalTheme.Provider | |||
| 69 | - | current.Appearance.ExternalTheme.Format = cfg.Appearance.ExternalTheme.Format | |||
| 70 | - | current.Appearance.ExternalTheme.Path = cfg.Appearance.ExternalTheme.Path | |||
| 71 | current.DefaultUIScale = cfg.DefaultUIScale | 68 | current.DefaultUIScale = cfg.DefaultUIScale | ||
| 72 | current.DefaultSearchEngine = cfg.DefaultSearchEngine | 69 | current.DefaultSearchEngine = cfg.DefaultSearchEngine | ||
| 73 | if len(cfg.SearchShortcuts) == 0 { | 70 | if len(cfg.SearchShortcuts) == 0 { | ||
M internal/infrastructure/externaltheme/noctalia/source.go +5 -1
| @@ -63,13 +63,17 @@ func (s *FileSource) Configure(cfg entity.ExternalThemeConfig) { | |||
| 63 | 63 | provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format) | |
| 64 | 64 | enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format) | |
| 65 | 65 | path := strings.TrimSpace(cfg.Path) | |
| 66 | + | identityPath := path | |
| 67 | + | if expandedPath, err := ExpandPath(path); err == nil { | |
| 68 | + | identityPath = expandedPath | |
| 69 | + | } | |
| 66 | 70 | ||
| 67 | 71 | s.mu.Lock() | |
| 68 | 72 | defer s.mu.Unlock() | |
| 69 | 73 | s.enabled = enabled | |
| 70 | 74 | s.path = path | |
| 71 | 75 | s.format = format | |
| 72 | - | s.identity = externalThemeIdentity(enabled, provider, format, path) | |
| 76 | + | s.identity = externalThemeIdentity(enabled, provider, format, identityPath) | |
| 73 | 77 | } | |
| 74 | 78 | ||
| 75 | 79 | // ExternalThemeIdentity returns the current normalized external theme identity. | |
| @@ -63,13 +63,17 @@ func (s *FileSource) Configure(cfg entity.ExternalThemeConfig) { | |||||
| 63 | provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format) | 63 | provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format) | ||
| 64 | enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format) | 64 | enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format) | ||
| 65 | path := strings.TrimSpace(cfg.Path) | 65 | path := strings.TrimSpace(cfg.Path) | ||
| 66 | + | identityPath := path | |||
| 67 | + | if expandedPath, err := ExpandPath(path); err == nil { | |||
| 68 | + | identityPath = expandedPath | |||
| 69 | + | } | |||
| 66 | 70 | ||||
| 67 | s.mu.Lock() | 71 | s.mu.Lock() | ||
| 68 | defer s.mu.Unlock() | 72 | defer s.mu.Unlock() | ||
| 69 | s.enabled = enabled | 73 | s.enabled = enabled | ||
| 70 | s.path = path | 74 | s.path = path | ||
| 71 | s.format = format | 75 | s.format = format | ||
| 72 | - | s.identity = externalThemeIdentity(enabled, provider, format, path) | 76 | + | s.identity = externalThemeIdentity(enabled, provider, format, identityPath) |
| 73 | } | 77 | } | ||
| 74 | 78 | ||||
| 75 | // ExternalThemeIdentity returns the current normalized external theme identity. | 79 | // ExternalThemeIdentity returns the current normalized external theme identity. | ||
M internal/infrastructure/externaltheme/noctalia/source_test.go +38 -4
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | "runtime" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -38,7 +39,7 @@ func TestParseColorsJSONValidActivePalette(t *testing.T) { | |||
| 38 | 39 | } | |
| 39 | 40 | ||
| 40 | 41 | func TestParseColorsJSONDocsExample(t *testing.T) { | |
| 41 | - | path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-colors.json") | |
| 42 | + | path := docsExamplePath(t, "noctalia-colors.json") | |
| 42 | 43 | data, err := os.ReadFile(path) | |
| 43 | 44 | if err != nil { | |
| 44 | 45 | t.Fatalf("ReadFile(%q) error = %v", path, err) | |
| @@ -48,7 +49,7 @@ func TestParseColorsJSONDocsExample(t *testing.T) { | |||
| 48 | 49 | if err != nil { | |
| 49 | 50 | t.Fatalf("ParseColorsJSON(docs example) error = %v", err) | |
| 50 | 51 | } | |
| 51 | - | if theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" { | |
| 52 | + | if theme == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" { | |
| 52 | 53 | t.Fatalf("parsed docs example = %+v", theme) | |
| 53 | 54 | } | |
| 54 | 55 | } | |
| @@ -142,7 +143,7 @@ func TestParseDumberJSONValidFullPalette(t *testing.T) { | |||
| 142 | 143 | } | |
| 143 | 144 | ||
| 144 | 145 | func TestParseDumberJSONDocsExample(t *testing.T) { | |
| 145 | - | path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-dumber-theme.json") | |
| 146 | + | path := docsExamplePath(t, "noctalia-dumber-theme.json") | |
| 146 | 147 | data, err := os.ReadFile(path) | |
| 147 | 148 | if err != nil { | |
| 148 | 149 | t.Fatalf("ReadFile(%q) error = %v", path, err) | |
| @@ -152,7 +153,7 @@ func TestParseDumberJSONDocsExample(t *testing.T) { | |||
| 152 | 153 | if err != nil { | |
| 153 | 154 | t.Fatalf("ParseDumberJSON(docs example) error = %v", err) | |
| 154 | 155 | } | |
| 155 | - | if theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" { | |
| 156 | + | if theme == nil || theme.LightPalette == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" { | |
| 156 | 157 | t.Fatalf("parsed docs example = %+v", theme) | |
| 157 | 158 | } | |
| 158 | 159 | } | |
| @@ -234,6 +235,30 @@ func TestExpandPath(t *testing.T) { | |||
| 234 | 235 | } | |
| 235 | 236 | } | |
| 236 | 237 | ||
| 238 | + | func TestFileSourceIdentityUsesExpandedPath(t *testing.T) { | |
| 239 | + | home := t.TempDir() | |
| 240 | + | t.Setenv("HOME", home) | |
| 241 | + | ||
| 242 | + | path := filepath.Join(home, ".config", "noctalia", "colors.json") | |
| 243 | + | source := NewFileSourceFromConfig(entity.ExternalThemeConfig{ | |
| 244 | + | Enabled: true, | |
| 245 | + | Provider: "noctalia", | |
| 246 | + | Format: "colors-json", | |
| 247 | + | Path: path, | |
| 248 | + | }) | |
| 249 | + | absoluteIdentity := source.ExternalThemeIdentity() | |
| 250 | + | ||
| 251 | + | source.Configure(entity.ExternalThemeConfig{ | |
| 252 | + | Enabled: true, | |
| 253 | + | Provider: "noctalia", | |
| 254 | + | Format: "colors-json", | |
| 255 | + | Path: "~/.config/noctalia/colors.json", | |
| 256 | + | }) | |
| 257 | + | if source.ExternalThemeIdentity() != absoluteIdentity { | |
| 258 | + | t.Fatalf("ExternalThemeIdentity() = %q, want %q", source.ExternalThemeIdentity(), absoluteIdentity) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 237 | 262 | func TestFileSourceDisabledBehavior(t *testing.T) { | |
| 238 | 263 | source := NewFileSource(false, "/does/not/exist") | |
| 239 | 264 | if source.IsEnabled() { | |
| @@ -335,6 +360,15 @@ func TestFileSourceConfigureUpdatesSettings(t *testing.T) { | |||
| 335 | 360 | } | |
| 336 | 361 | } | |
| 337 | 362 | ||
| 363 | + | func docsExamplePath(t *testing.T, name string) string { | |
| 364 | + | t.Helper() | |
| 365 | + | _, file, _, ok := runtime.Caller(0) | |
| 366 | + | if !ok { | |
| 367 | + | t.Fatal("runtime.Caller(0) failed") | |
| 368 | + | } | |
| 369 | + | return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "docs", "examples", name) | |
| 370 | + | } | |
| 371 | + | ||
| 338 | 372 | func noctaliaColorsJSON() []byte { | |
| 339 | 373 | return []byte(`{ | |
| 340 | 374 | "mError": "#ff8080", | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "os" | 5 | "os" | ||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | + | "runtime" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -38,7 +39,7 @@ func TestParseColorsJSONValidActivePalette(t *testing.T) { | |||||
| 38 | } | 39 | } | ||
| 39 | 40 | ||||
| 40 | func TestParseColorsJSONDocsExample(t *testing.T) { | 41 | func TestParseColorsJSONDocsExample(t *testing.T) { | ||
| 41 | - | path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-colors.json") | 42 | + | path := docsExamplePath(t, "noctalia-colors.json") |
| 42 | data, err := os.ReadFile(path) | 43 | data, err := os.ReadFile(path) | ||
| 43 | if err != nil { | 44 | if err != nil { | ||
| 44 | t.Fatalf("ReadFile(%q) error = %v", path, err) | 45 | t.Fatalf("ReadFile(%q) error = %v", path, err) | ||
| @@ -48,7 +49,7 @@ func TestParseColorsJSONDocsExample(t *testing.T) { | |||||
| 48 | if err != nil { | 49 | if err != nil { | ||
| 49 | t.Fatalf("ParseColorsJSON(docs example) error = %v", err) | 50 | t.Fatalf("ParseColorsJSON(docs example) error = %v", err) | ||
| 50 | } | 51 | } | ||
| 51 | - | if theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" { | 52 | + | if theme == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" { |
| 52 | t.Fatalf("parsed docs example = %+v", theme) | 53 | t.Fatalf("parsed docs example = %+v", theme) | ||
| 53 | } | 54 | } | ||
| 54 | } | 55 | } | ||
| @@ -142,7 +143,7 @@ func TestParseDumberJSONValidFullPalette(t *testing.T) { | |||||
| 142 | } | 143 | } | ||
| 143 | 144 | ||||
| 144 | func TestParseDumberJSONDocsExample(t *testing.T) { | 145 | func TestParseDumberJSONDocsExample(t *testing.T) { | ||
| 145 | - | path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-dumber-theme.json") | 146 | + | path := docsExamplePath(t, "noctalia-dumber-theme.json") |
| 146 | data, err := os.ReadFile(path) | 147 | data, err := os.ReadFile(path) | ||
| 147 | if err != nil { | 148 | if err != nil { | ||
| 148 | t.Fatalf("ReadFile(%q) error = %v", path, err) | 149 | t.Fatalf("ReadFile(%q) error = %v", path, err) | ||
| @@ -152,7 +153,7 @@ func TestParseDumberJSONDocsExample(t *testing.T) { | |||||
| 152 | if err != nil { | 153 | if err != nil { | ||
| 153 | t.Fatalf("ParseDumberJSON(docs example) error = %v", err) | 154 | t.Fatalf("ParseDumberJSON(docs example) error = %v", err) | ||
| 154 | } | 155 | } | ||
| 155 | - | if theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" { | 156 | + | if theme == nil || theme.LightPalette == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" { |
| 156 | t.Fatalf("parsed docs example = %+v", theme) | 157 | t.Fatalf("parsed docs example = %+v", theme) | ||
| 157 | } | 158 | } | ||
| 158 | } | 159 | } | ||
| @@ -234,6 +235,30 @@ func TestExpandPath(t *testing.T) { | |||||
| 234 | } | 235 | } | ||
| 235 | } | 236 | } | ||
| 236 | 237 | ||||
| 238 | + | func TestFileSourceIdentityUsesExpandedPath(t *testing.T) { | |||
| 239 | + | home := t.TempDir() | |||
| 240 | + | t.Setenv("HOME", home) | |||
| 241 | + | ||||
| 242 | + | path := filepath.Join(home, ".config", "noctalia", "colors.json") | |||
| 243 | + | source := NewFileSourceFromConfig(entity.ExternalThemeConfig{ | |||
| 244 | + | Enabled: true, | |||
| 245 | + | Provider: "noctalia", | |||
| 246 | + | Format: "colors-json", | |||
| 247 | + | Path: path, | |||
| 248 | + | }) | |||
| 249 | + | absoluteIdentity := source.ExternalThemeIdentity() | |||
| 250 | + | ||||
| 251 | + | source.Configure(entity.ExternalThemeConfig{ | |||
| 252 | + | Enabled: true, | |||
| 253 | + | Provider: "noctalia", | |||
| 254 | + | Format: "colors-json", | |||
| 255 | + | Path: "~/.config/noctalia/colors.json", | |||
| 256 | + | }) | |||
| 257 | + | if source.ExternalThemeIdentity() != absoluteIdentity { | |||
| 258 | + | t.Fatalf("ExternalThemeIdentity() = %q, want %q", source.ExternalThemeIdentity(), absoluteIdentity) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 237 | func TestFileSourceDisabledBehavior(t *testing.T) { | 262 | func TestFileSourceDisabledBehavior(t *testing.T) { | ||
| 238 | source := NewFileSource(false, "/does/not/exist") | 263 | source := NewFileSource(false, "/does/not/exist") | ||
| 239 | if source.IsEnabled() { | 264 | if source.IsEnabled() { | ||
| @@ -335,6 +360,15 @@ func TestFileSourceConfigureUpdatesSettings(t *testing.T) { | |||||
| 335 | } | 360 | } | ||
| 336 | } | 361 | } | ||
| 337 | 362 | ||||
| 363 | + | func docsExamplePath(t *testing.T, name string) string { | |||
| 364 | + | t.Helper() | |||
| 365 | + | _, file, _, ok := runtime.Caller(0) | |||
| 366 | + | if !ok { | |||
| 367 | + | t.Fatal("runtime.Caller(0) failed") | |||
| 368 | + | } | |||
| 369 | + | return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "docs", "examples", name) | |||
| 370 | + | } | |||
| 371 | + | ||||
| 338 | func noctaliaColorsJSON() []byte { | 372 | func noctaliaColorsJSON() []byte { | ||
| 339 | return []byte(`{ | 373 | return []byte(`{ | ||
| 340 | "mError": "#ff8080", | 374 | "mError": "#ff8080", | ||
M internal/infrastructure/externaltheme/noctalia/watcher.go +9 -2
| @@ -43,7 +43,9 @@ func NewFileWatcher() *FileWatcher { | |||
| 43 | 43 | func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error { | |
| 44 | 44 | path, enabled, err := watchPathFromConfig(cfg) | |
| 45 | 45 | if err != nil { | |
| 46 | - | _ = w.Stop() | |
| 46 | + | if stopErr := w.Stop(); stopErr != nil { | |
| 47 | + | return errors.Join(err, fmt.Errorf("stop noctalia theme watcher: %w", stopErr)) | |
| 48 | + | } | |
| 47 | 49 | return err | |
| 48 | 50 | } | |
| 49 | 51 | if !enabled || path == "" { | |
| @@ -75,7 +77,12 @@ func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, | |||
| 75 | 77 | return fmt.Errorf("create noctalia theme watcher: %w", err) | |
| 76 | 78 | } | |
| 77 | 79 | if err := watcher.Add(parent); err != nil { | |
| 78 | - | _ = watcher.Close() | |
| 80 | + | if closeErr := watcher.Close(); closeErr != nil { | |
| 81 | + | return errors.Join( | |
| 82 | + | fmt.Errorf("watch noctalia theme parent directory: %w", err), | |
| 83 | + | fmt.Errorf("close noctalia theme watcher after add failure: %w", closeErr), | |
| 84 | + | ) | |
| 85 | + | } | |
| 79 | 86 | return fmt.Errorf("watch noctalia theme parent directory: %w", err) | |
| 80 | 87 | } | |
| 81 | 88 | ||
| @@ -43,7 +43,9 @@ func NewFileWatcher() *FileWatcher { | |||||
| 43 | func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error { | 43 | func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error { | ||
| 44 | path, enabled, err := watchPathFromConfig(cfg) | 44 | path, enabled, err := watchPathFromConfig(cfg) | ||
| 45 | if err != nil { | 45 | if err != nil { | ||
| 46 | - | _ = w.Stop() | 46 | + | if stopErr := w.Stop(); stopErr != nil { |
| 47 | + | return errors.Join(err, fmt.Errorf("stop noctalia theme watcher: %w", stopErr)) | |||
| 48 | + | } | |||
| 47 | return err | 49 | return err | ||
| 48 | } | 50 | } | ||
| 49 | if !enabled || path == "" { | 51 | if !enabled || path == "" { | ||
| @@ -75,7 +77,12 @@ func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, | |||||
| 75 | return fmt.Errorf("create noctalia theme watcher: %w", err) | 77 | return fmt.Errorf("create noctalia theme watcher: %w", err) | ||
| 76 | } | 78 | } | ||
| 77 | if err := watcher.Add(parent); err != nil { | 79 | if err := watcher.Add(parent); err != nil { | ||
| 78 | - | _ = watcher.Close() | 80 | + | if closeErr := watcher.Close(); closeErr != nil { |
| 81 | + | return errors.Join( | |||
| 82 | + | fmt.Errorf("watch noctalia theme parent directory: %w", err), | |||
| 83 | + | fmt.Errorf("close noctalia theme watcher after add failure: %w", closeErr), | |||
| 84 | + | ) | |||
| 85 | + | } | |||
| 79 | return fmt.Errorf("watch noctalia theme parent directory: %w", err) | 86 | return fmt.Errorf("watch noctalia theme parent directory: %w", err) | ||
| 80 | } | 87 | } | ||
| 81 | 88 | ||||
M internal/infrastructure/externaltheme/noctalia/watcher_test.go +1 -1
| @@ -238,7 +238,7 @@ func waitForStringChange(t *testing.T, ch <-chan string) string { | |||
| 238 | 238 | case <-time.After(500 * time.Millisecond): | |
| 239 | 239 | t.Fatal("timed out waiting for callback") | |
| 240 | 240 | } | |
| 241 | - | return "" | |
| 241 | + | panic("unreachable") | |
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | 244 | func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) { | |
| @@ -238,7 +238,7 @@ func waitForStringChange(t *testing.T, ch <-chan string) string { | |||||
| 238 | case <-time.After(500 * time.Millisecond): | 238 | case <-time.After(500 * time.Millisecond): | ||
| 239 | t.Fatal("timed out waiting for callback") | 239 | t.Fatal("timed out waiting for callback") | ||
| 240 | } | 240 | } | ||
| 241 | - | return "" | 241 | + | panic("unreachable") |
| 242 | } | 242 | } | ||
| 243 | 243 | ||||
| 244 | func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) { | 244 | func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) { | ||