feat(theme): follow Noctalia external palettes
- commit
- 66d7b57d4bb4cad13105836050d8f70027c38fab
- author
- brice · · 2026-06-02 13:15 UTC
- committer
- brice · · 2026-06-09 20:01 UTC
- parent
- 9807be4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
47 files · +6022 -823
M cmd/dumber/main.go +33 -20
| @@ -249,6 +249,7 @@ func runGUI(cfg *config.Config) int { | |||
| 249 | 249 | ||
| 250 | 250 | ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog) | |
| 251 | 251 | defer sessionCleanup() | |
| 252 | + | bootstrap.LogResolvedTheme(ctx, initResult.ResolvedTheme) | |
| 252 | 253 | timer.Mark("session") | |
| 253 | 254 | ||
| 254 | 255 | log := logging.FromContext(ctx) | |
| @@ -326,6 +327,9 @@ func runStandaloneOmnibox() int { | |||
| 326 | 327 | cfg, | |
| 327 | 328 | initResult.RuntimeProfile, | |
| 328 | 329 | initResult.ThemeManager, | |
| 330 | + | initResult.ResolveThemeUC, | |
| 331 | + | initResult.ExternalThemeSource, | |
| 332 | + | initResult.ExternalThemeWatcher, | |
| 329 | 333 | initResult.ColorResolver, | |
| 330 | 334 | initResult.AdwaitaDetector, | |
| 331 | 335 | nil, | |
| @@ -471,7 +475,8 @@ func buildAndConfigureApp( | |||
| 471 | 475 | ) (*ui.App, error) { | |
| 472 | 476 | uiDeps, err := buildUIDependencies( | |
| 473 | 477 | ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager, | |
| 474 | - | initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 478 | + | initResult.ResolveThemeUC, initResult.ExternalThemeSource, | |
| 479 | + | initResult.ExternalThemeWatcher, initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 475 | 480 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | |
| 476 | 481 | ) | |
| 477 | 482 | if err != nil { | |
| @@ -506,11 +511,12 @@ func initStackAndRepos( | |||
| 506 | 511 | if needsEagerDB { | |
| 507 | 512 | // Parallel phase 2: Database + engine initialize concurrently | |
| 508 | 513 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | |
| 509 | - | Ctx: ctx, | |
| 510 | - | Config: cfg, | |
| 511 | - | RuntimeProfile: initResult.RuntimeProfile, | |
| 512 | - | ThemeManager: initResult.ThemeManager, | |
| 513 | - | ColorResolver: initResult.ColorResolver, | |
| 514 | + | Ctx: ctx, | |
| 515 | + | Config: cfg, | |
| 516 | + | RuntimeProfile: initResult.RuntimeProfile, | |
| 517 | + | ThemeManager: initResult.ThemeManager, | |
| 518 | + | ExternalThemeSource: initResult.ExternalThemeSource, | |
| 519 | + | ColorResolver: initResult.ColorResolver, | |
| 514 | 520 | }) | |
| 515 | 521 | if err != nil { | |
| 516 | 522 | return nil, nil, nil, err | |
| @@ -520,12 +526,13 @@ func initStackAndRepos( | |||
| 520 | 526 | ||
| 521 | 527 | log := logging.FromContext(ctx) | |
| 522 | 528 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | |
| 523 | - | Ctx: ctx, | |
| 524 | - | Config: cfg, | |
| 525 | - | RuntimeProfile: initResult.RuntimeProfile, | |
| 526 | - | ThemeManager: initResult.ThemeManager, | |
| 527 | - | ColorResolver: initResult.ColorResolver, | |
| 528 | - | Logger: *log, | |
| 529 | + | Ctx: ctx, | |
| 530 | + | Config: cfg, | |
| 531 | + | RuntimeProfile: initResult.RuntimeProfile, | |
| 532 | + | ThemeManager: initResult.ThemeManager, | |
| 533 | + | ExternalThemeSource: initResult.ExternalThemeSource, | |
| 534 | + | ColorResolver: initResult.ColorResolver, | |
| 535 | + | Logger: *log, | |
| 529 | 536 | }) | |
| 530 | 537 | if err != nil { | |
| 531 | 538 | return nil, nil, nil, err | |
| @@ -834,6 +841,9 @@ func buildUIDependencies( | |||
| 834 | 841 | cfg *config.Config, | |
| 835 | 842 | runtimeProfile runtimeprofile.Profile, | |
| 836 | 843 | themeManager *theme.Manager, | |
| 844 | + | resolveThemeUC *usecase.ResolveThemeUseCase, | |
| 845 | + | externalThemeSource port.ConfigurableExternalThemeSource, | |
| 846 | + | externalThemeWatcher port.ExternalThemeWatcher, | |
| 837 | 847 | colorResolver port.ColorSchemeResolver, | |
| 838 | 848 | adwaitaDetector port.ToolkitAvailabilityNotifier, | |
| 839 | 849 | engine port.Engine, | |
| @@ -857,14 +867,17 @@ func buildUIDependencies( | |||
| 857 | 867 | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | |
| 858 | 868 | ||
| 859 | 869 | uiDeps := &ui.Dependencies{ | |
| 860 | - | Ctx: ctx, | |
| 861 | - | Config: cfg, | |
| 862 | - | InitialURL: initialURL, | |
| 863 | - | RestoreSessionID: restoreSessionID, | |
| 864 | - | StartupCrashReports: startupCrashReports, | |
| 865 | - | Theme: themeManager, | |
| 866 | - | ColorResolver: colorResolver, | |
| 867 | - | AdwaitaDetector: adwaitaDetector, | |
| 870 | + | Ctx: ctx, | |
| 871 | + | Config: cfg, | |
| 872 | + | InitialURL: initialURL, | |
| 873 | + | RestoreSessionID: restoreSessionID, | |
| 874 | + | StartupCrashReports: startupCrashReports, | |
| 875 | + | Theme: themeManager, | |
| 876 | + | ResolveThemeUC: resolveThemeUC, | |
| 877 | + | ExternalThemeSource: externalThemeSource, | |
| 878 | + | ExternalThemeWatcher: externalThemeWatcher, | |
| 879 | + | ColorResolver: colorResolver, | |
| 880 | + | AdwaitaDetector: adwaitaDetector, | |
| 868 | 881 | XDG: xdg.New( | |
| 869 | 882 | runtimeProfile.Mode == runtimeprofile.ModeDev, | |
| 870 | 883 | bootstrap.ResolveXDGRuntimeDir(runtimeProfile), | |
M docs/config/index.md +68 -0
| @@ -141,6 +141,10 @@ auto_open_on_new_pane = false | |||
| 141 | 141 | | `appearance.monospace_font` | string | `"Fira Code"` | Monospace font | | |
| 142 | 142 | | `appearance.default_font_size` | int | `16` | Font size in points | | |
| 143 | 143 | | `appearance.color_scheme` | string | `"default"` | `prefer-dark`, `prefer-light`, `default` | | |
| 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 | | |
| 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 | | |
| 144 | 148 | ||
| 145 | 149 | ### Color Palettes | |
| 146 | 150 | ||
| @@ -168,6 +172,70 @@ accent = "#a8a8a8" | |||
| 168 | 172 | border = "#363636" | |
| 169 | 173 | ``` | |
| 170 | 174 | ||
| 175 | + | ### External Theme: Noctalia | |
| 176 | + | ||
| 177 | + | Dumber can follow Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration. | |
| 178 | + | ||
| 179 | + | ```toml | |
| 180 | + | [appearance.external_theme] | |
| 181 | + | enabled = true | |
| 182 | + | provider = "noctalia" | |
| 183 | + | format = "colors-json" # default | |
| 184 | + | # Optional. Defaults to $XDG_CONFIG_HOME/noctalia/colors.json. | |
| 185 | + | path = "~/.config/noctalia/colors.json" | |
| 186 | + | ``` | |
| 187 | + | ||
| 188 | + | The external file overrides palette colors only. Fonts, UI scale, pane-mode colors, and other appearance settings still come from Dumber config. | |
| 189 | + | ||
| 190 | + | #### `colors-json` contract | |
| 191 | + | ||
| 192 | + | `colors-json` reads Noctalia's active `colors.json` palette. Example file: [`docs/examples/noctalia-colors.json`](../examples/noctalia-colors.json). | |
| 193 | + | ||
| 194 | + | Dumber maps Noctalia roles like this: | |
| 195 | + | ||
| 196 | + | | Noctalia key | Dumber palette field | | |
| 197 | + | |--------------|----------------------| | |
| 198 | + | | `mShadow` | `background` when present, otherwise `mSurface` | | |
| 199 | + | | `mSurface` | `surface` | | |
| 200 | + | | `mHover` | `surface_variant` when present, otherwise `mSurfaceVariant` | | |
| 201 | + | | `mOnSurface` | `text` | | |
| 202 | + | | `mOnSurfaceVariant` | `muted` | | |
| 203 | + | | `mPrimary` | `accent` | | |
| 204 | + | | `mOutline` | `border` | | |
| 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. | |
| 207 | + | ||
| 208 | + | #### Advanced: `dumber-json` user template | |
| 209 | + | ||
| 210 | + | `dumber-json` remains supported for an explicit Dumber-specific Noctalia user template: | |
| 211 | + | ||
| 212 | + | ```toml | |
| 213 | + | [appearance.external_theme] | |
| 214 | + | enabled = true | |
| 215 | + | provider = "noctalia" | |
| 216 | + | format = "dumber-json" | |
| 217 | + | # Optional for dumber-json. Defaults to $XDG_CONFIG_HOME/dumber/noctalia-theme.json. | |
| 218 | + | path = "~/.config/dumber/noctalia-theme.json" | |
| 219 | + | ``` | |
| 220 | + | ||
| 221 | + | The file must be JSON with root `light` and `dark` objects. Optional `source`, `name`, and `mode` metadata fields are accepted; `name` or `source` is used only as source metadata. Dumber chooses the active palette from `appearance.color_scheme` and the system color-scheme preference. | |
| 222 | + | ||
| 223 | + | Supported palette fields are: | |
| 224 | + | ||
| 225 | + | - `background` | |
| 226 | + | - `surface` | |
| 227 | + | - `surface_variant` | |
| 228 | + | - `text` | |
| 229 | + | - `muted` | |
| 230 | + | - `accent` | |
| 231 | + | - `border` | |
| 232 | + | ||
| 233 | + | Each non-empty palette value must be a CSS-safe 6-digit hex color such as `#AABBCC`. Empty or omitted palette fields inherit from the configured Dumber palette for that mode. | |
| 234 | + | ||
| 235 | + | Example rendered file: [`docs/examples/noctalia-dumber-theme.json`](../examples/noctalia-dumber-theme.json). Example template: [`docs/examples/noctalia-dumber-theme.template.json`](../examples/noctalia-dumber-theme.template.json). | |
| 236 | + | ||
| 237 | + | When Noctalia rewrites the watched file, Dumber reapplies the same resolved theme path used by config reloads. A malformed or temporarily missing file keeps the last valid external palette when one exists; disabling `appearance.external_theme.enabled` clears that last-good external palette and returns to Dumber's configured palettes. | |
| 238 | + | ||
| 171 | 239 | ## Debug Options | |
| 172 | 240 | ||
| 173 | 241 | | Key | Type | Default | Description | | |
A docs/examples/noctalia-colors.json +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | { | |
| 2 | + | "mError": "#ff8080", | |
| 3 | + | "mHover": "#282828", | |
| 4 | + | "mOnError": "#000000", | |
| 5 | + | "mOnHover": "#ffffff", | |
| 6 | + | "mOnPrimary": "#000000", | |
| 7 | + | "mOnSecondary": "#000000", | |
| 8 | + | "mOnSurface": "#ffffff", | |
| 9 | + | "mOnSurfaceVariant": "#a0a0a0", | |
| 10 | + | "mOnTertiary": "#000000", | |
| 11 | + | "mOutline": "#505050", | |
| 12 | + | "mPrimary": "#ffc799", | |
| 13 | + | "mSecondary": "#99ffe4", | |
| 14 | + | "mShadow": "#000000", | |
| 15 | + | "mSurface": "#0c0c0c", | |
| 16 | + | "mSurfaceVariant": "#1c1c1c", | |
| 17 | + | "mTertiary": "#fbadff" | |
| 18 | + | } | |
A docs/examples/noctalia-dumber-theme.json +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "source": "noctalia-user-template", | |
| 3 | + | "mode": "dark", | |
| 4 | + | "light": { | |
| 5 | + | "background": "#fbf8ff", | |
| 6 | + | "surface": "#f4eff7", | |
| 7 | + | "surface_variant": "#e7e0ec", | |
| 8 | + | "text": "#1d1b20", | |
| 9 | + | "muted": "#49454f", | |
| 10 | + | "accent": "#6750a4", | |
| 11 | + | "border": "#79747e" | |
| 12 | + | }, | |
| 13 | + | "dark": { | |
| 14 | + | "background": "#141218", | |
| 15 | + | "surface": "#211f26", | |
| 16 | + | "surface_variant": "#49454f", | |
| 17 | + | "text": "#e6e0e9", | |
| 18 | + | "muted": "#cac4d0", | |
| 19 | + | "accent": "#d0bcff", | |
| 20 | + | "border": "#938f99" | |
| 21 | + | } | |
| 22 | + | } | |
A docs/examples/noctalia-dumber-theme.template.json +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "source": "noctalia-user-template", | |
| 3 | + | "mode": "{{mode}}", | |
| 4 | + | "light": { | |
| 5 | + | "background": "{{colors.background.light.hex}}", | |
| 6 | + | "surface": "{{colors.surface.light.hex}}", | |
| 7 | + | "surface_variant": "{{colors.surface_variant.light.hex}}", | |
| 8 | + | "text": "{{colors.on_surface.light.hex}}", | |
| 9 | + | "muted": "{{colors.on_surface_variant.light.hex}}", | |
| 10 | + | "accent": "{{colors.primary.light.hex}}", | |
| 11 | + | "border": "{{colors.outline.light.hex}}" | |
| 12 | + | }, | |
| 13 | + | "dark": { | |
| 14 | + | "background": "{{colors.background.dark.hex}}", | |
| 15 | + | "surface": "{{colors.surface.dark.hex}}", | |
| 16 | + | "surface_variant": "{{colors.surface_variant.dark.hex}}", | |
| 17 | + | "text": "{{colors.on_surface.dark.hex}}", | |
| 18 | + | "muted": "{{colors.on_surface_variant.dark.hex}}", | |
| 19 | + | "accent": "{{colors.primary.dark.hex}}", | |
| 20 | + | "border": "{{colors.outline.dark.hex}}" | |
| 21 | + | } | |
| 22 | + | } | |
M docs/reference/configuration.md +4 -0
| @@ -33,6 +33,10 @@ | |||
| 33 | 33 | | `appearance.monospace_font` | string | `Fira Code` | | | |
| 34 | 34 | | `appearance.default_font_size` | int | `16` | | | |
| 35 | 35 | | `appearance.color_scheme` | string | `default` | `prefer-dark`, `prefer-light`, `default` | | |
| 36 | + | | `appearance.external_theme.enabled` | bool | `false` | | | |
| 37 | + | | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` | | |
| 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 | | |
| 36 | 40 | | `appearance.light_palette.background` | string | `#f8f8f8` | | | |
| 37 | 41 | | `appearance.light_palette.surface` | string | `#f2f2f2` | | | |
| 38 | 42 | | `appearance.light_palette.surface_variant` | string | `#ececec` | | | |
M internal/application/dto/webui_config.go +25 -20
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package dto | |
| 2 | 2 | ||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |
| 4 | + | ||
| 3 | 5 | // WebUIConfig represents the subset of configuration editable in dumb://config. | |
| 4 | 6 | type WebUIConfig struct { | |
| 5 | 7 | Appearance WebUIAppearanceConfig `json:"appearance"` | |
| @@ -43,7 +45,11 @@ type SystemviewPerformancePayload struct { | |||
| 43 | 45 | ||
| 44 | 46 | // SystemviewConfigPayload is the full JSON shape expected by dumb://config. | |
| 45 | 47 | type SystemviewConfigPayload struct { | |
| 46 | - | Appearance WebUIAppearanceConfig `json:"appearance"` | |
| 48 | + | // Appearance is the editable config snapshot. | |
| 49 | + | Appearance WebUIAppearanceConfig `json:"appearance"` | |
| 50 | + | // ResolvedAppearance is optional, display-only appearance resolved from config | |
| 51 | + | // plus external theme sources. Forms must save Appearance, never this field. | |
| 52 | + | ResolvedAppearance *WebUIAppearanceConfig `json:"resolved_appearance,omitempty"` | |
| 47 | 53 | Performance SystemviewPerformancePayload `json:"performance"` | |
| 48 | 54 | DefaultUIScale float64 `json:"default_ui_scale"` | |
| 49 | 55 | DefaultSearchEngine string `json:"default_search_engine"` | |
| @@ -66,28 +72,27 @@ type WebUICustomPerformanceConfig struct { | |||
| 66 | 72 | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |
| 67 | 73 | } | |
| 68 | 74 | ||
| 69 | - | type WebUIAppearanceConfig struct { | |
| 70 | - | SansFont string `json:"sans_font"` | |
| 71 | - | SerifFont string `json:"serif_font"` | |
| 72 | - | MonospaceFont string `json:"monospace_font"` | |
| 73 | - | GtkFont string `json:"gtk_font"` | |
| 74 | - | DefaultFontSize int `json:"default_font_size"` | |
| 75 | - | ColorScheme string `json:"color_scheme"` | |
| 76 | - | LightPalette ColorPalette `json:"light_palette"` | |
| 77 | - | DarkPalette ColorPalette `json:"dark_palette"` | |
| 78 | - | } | |
| 75 | + | type WebUIAppearanceConfig = entity.AppearanceConfig | |
| 79 | 76 | ||
| 80 | - | type ColorPalette struct { | |
| 81 | - | Background string `json:"background"` | |
| 82 | - | Surface string `json:"surface"` | |
| 83 | - | SurfaceVariant string `json:"surface_variant"` | |
| 84 | - | Text string `json:"text"` | |
| 85 | - | Muted string `json:"muted"` | |
| 86 | - | Accent string `json:"accent"` | |
| 87 | - | Border string `json:"border"` | |
| 88 | - | } | |
| 77 | + | type WebUIExternalThemeConfig = entity.ExternalThemeConfig | |
| 78 | + | ||
| 79 | + | type ColorPalette = entity.ColorPalette | |
| 89 | 80 | ||
| 90 | 81 | type SearchShortcut struct { | |
| 91 | 82 | URL string `json:"url"` | |
| 92 | 83 | Description string `json:"description"` | |
| 93 | 84 | } | |
| 85 | + | ||
| 86 | + | // WebUIAppearanceWithResolvedTheme builds a display-only appearance payload from | |
| 87 | + | // resolved theme values while preserving fields that are not theme-resolution | |
| 88 | + | // outputs, such as ColorScheme and ExternalTheme. | |
| 89 | + | func WebUIAppearanceWithResolvedTheme(base WebUIAppearanceConfig, resolved entity.ResolvedTheme) WebUIAppearanceConfig { | |
| 90 | + | base.SansFont = resolved.Fonts.SansFont | |
| 91 | + | base.SerifFont = resolved.Fonts.SerifFont | |
| 92 | + | base.MonospaceFont = resolved.Fonts.MonospaceFont | |
| 93 | + | base.GtkFont = resolved.Fonts.GtkFont | |
| 94 | + | base.DefaultFontSize = resolved.Fonts.DefaultSize | |
| 95 | + | base.LightPalette = resolved.LightPalette | |
| 96 | + | base.DarkPalette = resolved.DarkPalette | |
| 97 | + | return base | |
| 98 | + | } | |
A internal/application/dto/webui_config_test.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestWebUIAppearanceWithResolvedThemePreservesColorSchemeAndUsesResolvedValues(t *testing.T) { | |
| 12 | + | base := WebUIAppearanceConfig{ | |
| 13 | + | ColorScheme: "prefer-dark", | |
| 14 | + | ExternalTheme: WebUIExternalThemeConfig{ | |
| 15 | + | Enabled: true, | |
| 16 | + | Provider: "noctalia", | |
| 17 | + | Format: "dumber-json", | |
| 18 | + | Path: "/tmp/theme.json", | |
| 19 | + | }, | |
| 20 | + | } | |
| 21 | + | resolved := entity.ResolvedTheme{ | |
| 22 | + | Fonts: entity.ThemeFonts{ | |
| 23 | + | SansFont: "Inter", | |
| 24 | + | SerifFont: "Georgia", | |
| 25 | + | MonospaceFont: "JetBrains Mono", | |
| 26 | + | GtkFont: "Adwaita Sans", | |
| 27 | + | DefaultSize: 18, | |
| 28 | + | }, | |
| 29 | + | LightPalette: entity.ColorPalette{Background: "#ffffff", Accent: "#0055ff"}, | |
| 30 | + | DarkPalette: entity.ColorPalette{Background: "#111111", Accent: "#66aaff"}, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | got := WebUIAppearanceWithResolvedTheme(base, resolved) | |
| 34 | + | ||
| 35 | + | require.Equal(t, "prefer-dark", got.ColorScheme) | |
| 36 | + | require.Equal(t, base.ExternalTheme, got.ExternalTheme) | |
| 37 | + | require.Equal(t, "Inter", got.SansFont) | |
| 38 | + | require.Equal(t, "Georgia", got.SerifFont) | |
| 39 | + | require.Equal(t, "JetBrains Mono", got.MonospaceFont) | |
| 40 | + | require.Equal(t, "Adwaita Sans", got.GtkFont) | |
| 41 | + | require.Equal(t, 18, got.DefaultFontSize) | |
| 42 | + | require.Equal(t, "#ffffff", got.LightPalette.Background) | |
| 43 | + | require.Equal(t, "#0055ff", got.LightPalette.Accent) | |
| 44 | + | require.Equal(t, "#111111", got.DarkPalette.Background) | |
| 45 | + | require.Equal(t, "#66aaff", got.DarkPalette.Accent) | |
| 46 | + | } | |
A internal/application/port/external_theme.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ExternalThemeSource provides access to an external theme provider (e.g., Noctalia). | |
| 10 | + | // It must not import GTK, WebKit, CEF, lipgloss, fsnotify, Viper, or infrastructure config. | |
| 11 | + | type ExternalThemeSource interface { | |
| 12 | + | // Get returns the current external theme, or nil if no external theme is available. | |
| 13 | + | // An error indicates a transient I/O problem (e.g., connection lost). | |
| 14 | + | // A nil theme with nil error means the source is enabled but has no theme data yet. | |
| 15 | + | Get(ctx context.Context) (*entity.ExternalTheme, error) | |
| 16 | + | ||
| 17 | + | // IsEnabled returns true if the external theme source is enabled. | |
| 18 | + | // When disabled, the usecase should fall back to config/default palettes. | |
| 19 | + | IsEnabled() bool | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // ExternalThemeIdentityProvider exposes a stable source identity for last-good cache invalidation. | |
| 23 | + | // Implementations should change this value when the configured provider/format/path changes. | |
| 24 | + | type ExternalThemeIdentityProvider interface { | |
| 25 | + | ExternalThemeIdentity() string | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // ConfigurableExternalThemeSource is an external source whose settings can be | |
| 29 | + | // updated from a new config snapshot without replacing the ResolveThemeUseCase. | |
| 30 | + | type ConfigurableExternalThemeSource interface { | |
| 31 | + | ExternalThemeSource | |
| 32 | + | ExternalThemeIdentityProvider | |
| 33 | + | Configure(entity.ExternalThemeConfig) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // ExternalThemeWatcher watches an external theme source and notifies callers | |
| 37 | + | // when they should refresh the resolved theme through the shared apply path. | |
| 38 | + | type ExternalThemeWatcher interface { | |
| 39 | + | Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error | |
| 40 | + | Stop() error | |
| 41 | + | } | |
A internal/application/port/external_theme_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // compileCheckExternalThemeSource ensures the ExternalThemeSource interface | |
| 11 | + | // is defined and can be implemented by test mocks. | |
| 12 | + | func TestCompileCheckExternalThemeSource(_ *testing.T) { | |
| 13 | + | var _ ExternalThemeSource = (*mockExternalThemeSource)(nil) | |
| 14 | + | var _ ConfigurableExternalThemeSource = (*mockExternalThemeSource)(nil) | |
| 15 | + | var _ ExternalThemeWatcher = (*mockExternalThemeWatcher)(nil) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // mockExternalThemeSource is a test-only implementation of ExternalThemeSource. | |
| 19 | + | type mockExternalThemeSource struct { | |
| 20 | + | theme *entity.ExternalTheme | |
| 21 | + | err error | |
| 22 | + | enabled bool | |
| 23 | + | identity string | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (m *mockExternalThemeSource) Get(_ context.Context) (*entity.ExternalTheme, error) { | |
| 27 | + | return m.theme, m.err | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (m *mockExternalThemeSource) IsEnabled() bool { | |
| 31 | + | return m.enabled | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (m *mockExternalThemeSource) Configure(cfg entity.ExternalThemeConfig) { | |
| 35 | + | m.enabled = cfg.Enabled | |
| 36 | + | m.identity = cfg.Provider + "|" + cfg.Format + "|" + cfg.Path | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (m *mockExternalThemeSource) ExternalThemeIdentity() string { | |
| 40 | + | return m.identity | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type mockExternalThemeWatcher struct{} | |
| 44 | + | ||
| 45 | + | func (*mockExternalThemeWatcher) Start(context.Context, entity.ExternalThemeConfig, func()) error { | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (*mockExternalThemeWatcher) Stop() error { | |
| 50 | + | return nil | |
| 51 | + | } | |
M internal/application/usecase/read_systemview_config.go +88 -4
| @@ -6,19 +6,53 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // ReadSystemviewConfigUseCase exposes read-side systemview config payloads. | |
| 12 | 13 | type ReadSystemviewConfigUseCase struct { | |
| 13 | - | reader port.SystemviewConfigReader | |
| 14 | + | reader port.SystemviewConfigReader | |
| 15 | + | resolvedAppearance *systemviewResolvedAppearanceResolver | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | type systemviewResolvedAppearanceResolver struct { | |
| 19 | + | externalTheme port.ExternalThemeSource | |
| 20 | + | colorResolver port.ColorSchemeResolver | |
| 21 | + | prefersDark func() bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // ReadSystemviewConfigOption customizes ReadSystemviewConfigUseCase behavior. | |
| 25 | + | type ReadSystemviewConfigOption func(*ReadSystemviewConfigUseCase) | |
| 26 | + | ||
| 27 | + | // WithSystemviewResolvedAppearance overlays the current config payload with a display-only | |
| 28 | + | // resolved appearance snapshot. It uses an isolated theme resolver per read so opening | |
| 29 | + | // dumb://config cannot mutate the live browser theme resolver's last-good cache. | |
| 30 | + | func WithSystemviewResolvedAppearance( | |
| 31 | + | externalTheme port.ExternalThemeSource, | |
| 32 | + | colorResolver port.ColorSchemeResolver, | |
| 33 | + | prefersDark func() bool, | |
| 34 | + | ) ReadSystemviewConfigOption { | |
| 35 | + | return func(uc *ReadSystemviewConfigUseCase) { | |
| 36 | + | uc.resolvedAppearance = &systemviewResolvedAppearanceResolver{ | |
| 37 | + | externalTheme: externalTheme, | |
| 38 | + | colorResolver: colorResolver, | |
| 39 | + | prefersDark: prefersDark, | |
| 40 | + | } | |
| 41 | + | } | |
| 14 | 42 | } | |
| 15 | 43 | ||
| 16 | 44 | // NewReadSystemviewConfigUseCase creates a new read systemview config use case. | |
| 17 | - | func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase { | |
| 45 | + | func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader, opts ...ReadSystemviewConfigOption) *ReadSystemviewConfigUseCase { | |
| 18 | 46 | if reader == nil { | |
| 19 | 47 | panic("NewReadSystemviewConfigUseCase: reader is nil") | |
| 20 | 48 | } | |
| 21 | - | return &ReadSystemviewConfigUseCase{reader: reader} | |
| 49 | + | uc := &ReadSystemviewConfigUseCase{reader: reader} | |
| 50 | + | for _, opt := range opts { | |
| 51 | + | if opt != nil { | |
| 52 | + | opt(uc) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | return uc | |
| 22 | 56 | } | |
| 23 | 57 | ||
| 24 | 58 | // ErrNilSystemviewConfigReader is returned when the config reader dependency is nil. | |
| @@ -29,7 +63,11 @@ func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (dto.Systemv | |||
| 29 | 63 | if uc == nil || uc.reader == nil { | |
| 30 | 64 | return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader | |
| 31 | 65 | } | |
| 32 | - | return uc.reader.Current(ctx) | |
| 66 | + | payload, err := uc.reader.Current(ctx) | |
| 67 | + | if err != nil { | |
| 68 | + | return dto.SystemviewConfigPayload{}, err | |
| 69 | + | } | |
| 70 | + | return uc.withResolvedAppearance(ctx, payload), nil | |
| 33 | 71 | } | |
| 34 | 72 | ||
| 35 | 73 | // Default returns the default systemview config payload. | |
| @@ -39,3 +77,49 @@ func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (dto.Systemv | |||
| 39 | 77 | } | |
| 40 | 78 | return uc.reader.Default(ctx) | |
| 41 | 79 | } | |
| 80 | + | ||
| 81 | + | func (uc *ReadSystemviewConfigUseCase) withResolvedAppearance( | |
| 82 | + | ctx context.Context, | |
| 83 | + | payload dto.SystemviewConfigPayload, | |
| 84 | + | ) dto.SystemviewConfigPayload { | |
| 85 | + | if uc == nil || uc.resolvedAppearance == nil { | |
| 86 | + | return payload | |
| 87 | + | } | |
| 88 | + | resolved, ok := uc.resolvedAppearance.resolve(ctx, payload) | |
| 89 | + | if !ok { | |
| 90 | + | return payload | |
| 91 | + | } | |
| 92 | + | appearance := dto.WebUIAppearanceWithResolvedTheme(payload.Appearance, resolved) | |
| 93 | + | payload.ResolvedAppearance = &appearance | |
| 94 | + | return payload | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (r *systemviewResolvedAppearanceResolver) resolve( | |
| 98 | + | ctx context.Context, | |
| 99 | + | payload dto.SystemviewConfigPayload, | |
| 100 | + | ) (entity.ResolvedTheme, bool) { | |
| 101 | + | if r == nil { | |
| 102 | + | return entity.ResolvedTheme{}, false | |
| 103 | + | } | |
| 104 | + | resolver := NewResolveThemeUseCase(r.externalTheme) | |
| 105 | + | out, err := resolver.Execute(ctx, ResolveThemeInputFromConfig( | |
| 106 | + | &payload.Appearance, | |
| 107 | + | payload.DefaultUIScale, | |
| 108 | + | nil, | |
| 109 | + | r.preference(payload.Appearance.ColorScheme), | |
| 110 | + | )) | |
| 111 | + | if err != nil { | |
| 112 | + | return entity.ResolvedTheme{}, false | |
| 113 | + | } | |
| 114 | + | return out.Theme, true | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (r *systemviewResolvedAppearanceResolver) preference(colorScheme string) port.ColorSchemePreference { | |
| 118 | + | fallback := port.ColorSchemePreference{PrefersDark: true, Source: "systemviews"} | |
| 119 | + | if r.colorResolver != nil { | |
| 120 | + | fallback = r.colorResolver.Resolve() | |
| 121 | + | } else if r.prefersDark != nil { | |
| 122 | + | fallback.PrefersDark = r.prefersDark() | |
| 123 | + | } | |
| 124 | + | return ColorSchemePreferenceFromConfig(colorScheme, fallback) | |
| 125 | + | } | |
A internal/application/usecase/read_systemview_config_theme_test.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type systemviewConfigReaderStub struct { | |
| 14 | + | current dto.SystemviewConfigPayload | |
| 15 | + | def dto.SystemviewConfigPayload | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (s systemviewConfigReaderStub) Current(context.Context) (dto.SystemviewConfigPayload, error) { | |
| 19 | + | return s.current, nil | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (s systemviewConfigReaderStub) Default(context.Context) (dto.SystemviewConfigPayload, error) { | |
| 23 | + | return s.def, nil | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type systemviewExternalThemeSource struct { | |
| 27 | + | theme *entity.ExternalTheme | |
| 28 | + | enabled bool | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (s systemviewExternalThemeSource) Get(context.Context) (*entity.ExternalTheme, error) { | |
| 32 | + | return s.theme, nil | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (s systemviewExternalThemeSource) IsEnabled() bool { | |
| 36 | + | return s.enabled | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestReadSystemviewConfigUseCase_CurrentResolvedAppearanceUsesPayloadSnapshot(t *testing.T) { | |
| 40 | + | payload := systemviewThemePayload("prefer-light") | |
| 41 | + | uc := NewReadSystemviewConfigUseCase( | |
| 42 | + | systemviewConfigReaderStub{current: payload}, | |
| 43 | + | WithSystemviewResolvedAppearance(nil, nil, func() bool { return true }), | |
| 44 | + | ) | |
| 45 | + | ||
| 46 | + | got, err := uc.Current(context.Background()) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | ||
| 49 | + | require.NotNil(t, got.ResolvedAppearance) | |
| 50 | + | require.NotEmpty(t, got.ResolvedAppearance.ColorScheme, "resolved appearance should preserve color scheme metadata") | |
| 51 | + | require.Equal(t, "prefer-light", got.ResolvedAppearance.ColorScheme) | |
| 52 | + | require.Equal(t, "#ffffff", got.ResolvedAppearance.LightPalette.Background) | |
| 53 | + | require.Equal(t, "Inter", got.ResolvedAppearance.SansFont) | |
| 54 | + | require.Equal(t, 18, got.ResolvedAppearance.DefaultFontSize) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestReadSystemviewConfigUseCase_CurrentResolvedAppearanceKeepsEditableAppearanceUnchanged(t *testing.T) { | |
| 58 | + | payload := systemviewThemePayload("prefer-dark") | |
| 59 | + | external := &entity.ExternalTheme{ | |
| 60 | + | Name: "Noctalia", | |
| 61 | + | Provider: "noctalia", | |
| 62 | + | LightPalette: &entity.ColorPalette{ | |
| 63 | + | Background: "#eeeeee", | |
| 64 | + | Accent: "#112233", | |
| 65 | + | }, | |
| 66 | + | DarkPalette: &entity.ColorPalette{ | |
| 67 | + | Background: "#000000", | |
| 68 | + | Accent: "#445566", | |
| 69 | + | }, | |
| 70 | + | } | |
| 71 | + | uc := NewReadSystemviewConfigUseCase( | |
| 72 | + | systemviewConfigReaderStub{current: payload}, | |
| 73 | + | WithSystemviewResolvedAppearance(systemviewExternalThemeSource{theme: external, enabled: true}, nil, nil), | |
| 74 | + | ) | |
| 75 | + | ||
| 76 | + | got, err := uc.Current(context.Background()) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | ||
| 79 | + | require.Equal(t, "#111111", got.Appearance.DarkPalette.Background) | |
| 80 | + | require.Equal(t, "#66aaff", got.Appearance.DarkPalette.Accent) | |
| 81 | + | require.NotNil(t, got.ResolvedAppearance) | |
| 82 | + | require.Equal(t, "#000000", got.ResolvedAppearance.DarkPalette.Background) | |
| 83 | + | require.Equal(t, "#445566", got.ResolvedAppearance.DarkPalette.Accent) | |
| 84 | + | require.Equal(t, got.Appearance.ExternalTheme, got.ResolvedAppearance.ExternalTheme) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestReadSystemviewConfigUseCase_DefaultDoesNotOverlayResolvedAppearance(t *testing.T) { | |
| 88 | + | payload := systemviewThemePayload("prefer-dark") | |
| 89 | + | uc := NewReadSystemviewConfigUseCase( | |
| 90 | + | systemviewConfigReaderStub{def: payload}, | |
| 91 | + | WithSystemviewResolvedAppearance(systemviewExternalThemeSource{theme: validExternalTheme(), enabled: true}, nil, nil), | |
| 92 | + | ) | |
| 93 | + | ||
| 94 | + | got, err := uc.Default(context.Background()) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | require.Nil(t, got.ResolvedAppearance) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func systemviewThemePayload(colorScheme string) dto.SystemviewConfigPayload { | |
| 100 | + | return dto.SystemviewConfigPayload{ | |
| 101 | + | DefaultUIScale: 1.25, | |
| 102 | + | Appearance: dto.WebUIAppearanceConfig{ | |
| 103 | + | ColorScheme: colorScheme, | |
| 104 | + | SansFont: "Inter", | |
| 105 | + | SerifFont: "Georgia", | |
| 106 | + | MonospaceFont: "JetBrains Mono", | |
| 107 | + | GtkFont: "Adwaita Sans", | |
| 108 | + | DefaultFontSize: 18, | |
| 109 | + | LightPalette: dto.ColorPalette{ | |
| 110 | + | Background: "#ffffff", | |
| 111 | + | Surface: "#f8f8f8", | |
| 112 | + | SurfaceVariant: "#eeeeee", | |
| 113 | + | Text: "#111111", | |
| 114 | + | Muted: "#666666", | |
| 115 | + | Accent: "#0055ff", | |
| 116 | + | Border: "#dddddd", | |
| 117 | + | }, | |
| 118 | + | DarkPalette: dto.ColorPalette{ | |
| 119 | + | Background: "#111111", | |
| 120 | + | Surface: "#1a1a1a", | |
| 121 | + | SurfaceVariant: "#2a2a2a", | |
| 122 | + | Text: "#f5f5f5", | |
| 123 | + | Muted: "#a0a0a0", | |
| 124 | + | Accent: "#66aaff", | |
| 125 | + | Border: "#333333", | |
| 126 | + | }, | |
| 127 | + | }, | |
| 128 | + | } | |
| 129 | + | } | |
A internal/application/usecase/resolve_theme.go +436 -0
| @@ -0,0 +1,436 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // ResolveThemeInput contains the inputs needed to resolve the active theme. | |
| 13 | + | // It accepts config-level overrides and delegates to external sources via port. | |
| 14 | + | type ResolveThemeInput struct { | |
| 15 | + | // ColorScheme is the user-configured color scheme preference. | |
| 16 | + | // Values: "default", "prefer-dark", "prefer-light", "dark", "light". | |
| 17 | + | // Phase 2 expects callers to resolve this into PrefersDark before invoking the usecase. | |
| 18 | + | ColorScheme string | |
| 19 | + | ||
| 20 | + | // PrefersDark is the detected system color scheme preference. | |
| 21 | + | PrefersDark bool | |
| 22 | + | ||
| 23 | + | // ColorSchemeSource identifies which detector provided the color scheme preference. | |
| 24 | + | // Examples: "config", "adwaita", "gsettings", "env", "fallback". | |
| 25 | + | ColorSchemeSource string | |
| 26 | + | ||
| 27 | + | // LightPalette from user config (may be empty/nil). | |
| 28 | + | LightPalette *entity.ColorPalette | |
| 29 | + | ||
| 30 | + | // DarkPalette from user config (may be empty/nil). | |
| 31 | + | DarkPalette *entity.ColorPalette | |
| 32 | + | ||
| 33 | + | // Fonts from user config (may be nil for defaults). | |
| 34 | + | Fonts *entity.ThemeFonts | |
| 35 | + | ||
| 36 | + | // UIScale from GTK/display settings. | |
| 37 | + | UIScale float64 | |
| 38 | + | ||
| 39 | + | // ModeColors from user config (may be nil for defaults). | |
| 40 | + | ModeColors *entity.ThemeModeColors | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // ResolveThemeOutput is the result of theme resolution. | |
| 44 | + | type ResolveThemeOutput struct { | |
| 45 | + | // Theme is the resolved theme. | |
| 46 | + | Theme entity.ResolvedTheme | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // ResolveThemeUseCase resolves the active theme by merging config defaults, | |
| 50 | + | // user configuration, and an optional external theme source. | |
| 51 | + | // | |
| 52 | + | // Resolution precedence (locked contract): | |
| 53 | + | // - External disabled -> config/default palettes | |
| 54 | + | // - Valid external -> external palettes override palette colors only | |
| 55 | + | // - Malformed/missing external at startup -> config/default palettes + warning | |
| 56 | + | // - Malformed after valid external -> keep last-good external + warning/source metadata | |
| 57 | + | // - Disabling external after valid -> clear last-good and use config/defaults | |
| 58 | + | // | |
| 59 | + | // CSS generation is NOT part of this usecase. | |
| 60 | + | // This usecase must NOT import GTK, WebKit, CEF, lipgloss, fsnotify, Viper, or infrastructure config. | |
| 61 | + | type ResolveThemeUseCase struct { | |
| 62 | + | external port.ExternalThemeSource | |
| 63 | + | mu sync.Mutex | |
| 64 | + | ||
| 65 | + | // lastGoodExternal holds the most recent fully-resolved valid external palette. | |
| 66 | + | // Used for the "malformed after valid" -> keep last-good contract. | |
| 67 | + | lastGoodExternal *entity.ExternalTheme | |
| 68 | + | ||
| 69 | + | // lastExternalIdentity tracks the configured external source identity that produced lastGoodExternal. | |
| 70 | + | // If the enabled provider/format/path changes, last-good is invalidated before resolving. | |
| 71 | + | lastExternalIdentity string | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // NewResolveThemeUseCase creates a new ResolveThemeUseCase. | |
| 75 | + | // external may be nil if no external theme source is available. | |
| 76 | + | func NewResolveThemeUseCase(external port.ExternalThemeSource) *ResolveThemeUseCase { | |
| 77 | + | return &ResolveThemeUseCase{external: external} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // ResolveThemeInputFromConfig maps domain config shapes plus a detected color-scheme | |
| 81 | + | // preference into the application usecase input. It performs no precedence/fallback | |
| 82 | + | // resolution; Execute/Refresh owns that business logic. | |
| 83 | + | func ResolveThemeInputFromConfig( | |
| 84 | + | appearance *entity.AppearanceConfig, | |
| 85 | + | uiScale float64, | |
| 86 | + | styling *entity.WorkspaceStylingConfig, | |
| 87 | + | preference port.ColorSchemePreference, | |
| 88 | + | ) ResolveThemeInput { | |
| 89 | + | input := ResolveThemeInput{ | |
| 90 | + | PrefersDark: preference.PrefersDark, | |
| 91 | + | ColorSchemeSource: preference.Source, | |
| 92 | + | UIScale: uiScale, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | if appearance != nil { | |
| 96 | + | lightPalette := appearance.LightPalette | |
| 97 | + | darkPalette := appearance.DarkPalette | |
| 98 | + | fonts := entity.ThemeFonts{ | |
| 99 | + | SansFont: appearance.SansFont, | |
| 100 | + | SerifFont: appearance.SerifFont, | |
| 101 | + | MonospaceFont: appearance.MonospaceFont, | |
| 102 | + | GtkFont: appearance.GtkFont, | |
| 103 | + | DefaultSize: appearance.DefaultFontSize, | |
| 104 | + | } | |
| 105 | + | input.ColorScheme = appearance.ColorScheme | |
| 106 | + | input.LightPalette = &lightPalette | |
| 107 | + | input.DarkPalette = &darkPalette | |
| 108 | + | input.Fonts = &fonts | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | if styling != nil { | |
| 112 | + | modeColors := entity.ThemeModeColors{ | |
| 113 | + | PaneMode: styling.PaneModeColor, | |
| 114 | + | TabMode: styling.TabModeColor, | |
| 115 | + | SessionMode: styling.SessionModeColor, | |
| 116 | + | ResizeMode: styling.ResizeModeColor, | |
| 117 | + | } | |
| 118 | + | input.ModeColors = &modeColors | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | return input | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // ColorSchemePreferenceFromConfig converts explicit config color-scheme values into | |
| 125 | + | // a resolver preference, falling back when config asks for the default/system value. | |
| 126 | + | func ColorSchemePreferenceFromConfig(colorScheme string, fallback port.ColorSchemePreference) port.ColorSchemePreference { | |
| 127 | + | switch strings.ToLower(strings.TrimSpace(colorScheme)) { | |
| 128 | + | case "prefer-dark", "dark": | |
| 129 | + | return port.ColorSchemePreference{PrefersDark: true, Source: "config"} | |
| 130 | + | case "prefer-light", "light": | |
| 131 | + | return port.ColorSchemePreference{PrefersDark: false, Source: "config"} | |
| 132 | + | default: | |
| 133 | + | return fallback | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Refresh resolves the active theme for config reloads and future external-theme reloads. | |
| 138 | + | func (uc *ResolveThemeUseCase) Refresh(ctx context.Context, input ResolveThemeInput) (ResolveThemeOutput, error) { | |
| 139 | + | return uc.Execute(ctx, input) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Execute resolves the active theme. | |
| 143 | + | func (uc *ResolveThemeUseCase) Execute(ctx context.Context, input ResolveThemeInput) (ResolveThemeOutput, error) { | |
| 144 | + | uc.mu.Lock() | |
| 145 | + | defer uc.mu.Unlock() | |
| 146 | + | ||
| 147 | + | base := uc.resolveBase(input) | |
| 148 | + | lightPalette := base.lightPalette | |
| 149 | + | darkPalette := base.darkPalette | |
| 150 | + | warnings := base.warnings | |
| 151 | + | themeSource := base.themeSource | |
| 152 | + | ||
| 153 | + | if uc.external != nil && uc.external.IsEnabled() { | |
| 154 | + | uc.syncExternalIdentity(externalThemeIdentity(uc.external)) | |
| 155 | + | ext, err := uc.external.Get(ctx) | |
| 156 | + | if err != nil { | |
| 157 | + | lightPalette, darkPalette, themeSource, warnings = uc.handleExternalError(err, lightPalette, darkPalette, themeSource, warnings) | |
| 158 | + | } else if ext != nil { | |
| 159 | + | lightPalette, darkPalette, themeSource, warnings = uc.applyExternalTheme(ext, lightPalette, darkPalette, themeSource, warnings) | |
| 160 | + | } else { | |
| 161 | + | lightPalette, darkPalette, themeSource, warnings = uc.handleMissingExternalTheme(lightPalette, darkPalette, themeSource, warnings) | |
| 162 | + | } | |
| 163 | + | } else { | |
| 164 | + | uc.clearLastGoodExternal() | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | return ResolveThemeOutput{ | |
| 168 | + | Theme: buildResolvedTheme(input, lightPalette, darkPalette, base.fonts, base.modeColors, base.uiScale, themeSource, warnings), | |
| 169 | + | }, nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | type resolvedThemeBase struct { | |
| 173 | + | lightPalette entity.ColorPalette | |
| 174 | + | darkPalette entity.ColorPalette | |
| 175 | + | fonts entity.ThemeFonts | |
| 176 | + | modeColors entity.ThemeModeColors | |
| 177 | + | uiScale float64 | |
| 178 | + | themeSource entity.ThemeSourceMetadata | |
| 179 | + | warnings []entity.ThemeWarning | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (*ResolveThemeUseCase) resolveBase(input ResolveThemeInput) resolvedThemeBase { | |
| 183 | + | lightDefault := entity.DefaultLightPalette() | |
| 184 | + | darkDefault := entity.DefaultDarkPalette() | |
| 185 | + | lightPalette := entity.MergePalette(input.LightPalette, &lightDefault) | |
| 186 | + | darkPalette := entity.MergePalette(input.DarkPalette, &darkDefault) | |
| 187 | + | ||
| 188 | + | fonts := entity.MergeFonts(input.Fonts, entity.DefaultThemeFonts()) | |
| 189 | + | modeColors := entity.MergeModeColors(input.ModeColors, entity.DefaultThemeModeColors()) | |
| 190 | + | uiScale := input.UIScale | |
| 191 | + | if uiScale <= 0 { | |
| 192 | + | uiScale = 1.0 | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | themeSourceKind := entity.ThemeSourceConfig | |
| 196 | + | if input.LightPalette == nil && input.DarkPalette == nil { | |
| 197 | + | themeSourceKind = entity.ThemeSourceDefault | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | return resolvedThemeBase{ | |
| 201 | + | lightPalette: lightPalette, | |
| 202 | + | darkPalette: darkPalette, | |
| 203 | + | fonts: fonts, | |
| 204 | + | modeColors: modeColors, | |
| 205 | + | uiScale: uiScale, | |
| 206 | + | themeSource: entity.ThemeSourceMetadata{ | |
| 207 | + | Kind: themeSourceKind, | |
| 208 | + | }, | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (uc *ResolveThemeUseCase) clearLastGoodExternal() { | |
| 213 | + | uc.lastGoodExternal = nil | |
| 214 | + | uc.lastExternalIdentity = "" | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (uc *ResolveThemeUseCase) syncExternalIdentity(identity string) { | |
| 218 | + | if identity == "" { | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | if uc.lastExternalIdentity != "" && uc.lastExternalIdentity != identity { | |
| 222 | + | uc.lastGoodExternal = nil | |
| 223 | + | } | |
| 224 | + | uc.lastExternalIdentity = identity | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func externalThemeIdentity(source port.ExternalThemeSource) string { | |
| 228 | + | identityProvider, ok := source.(port.ExternalThemeIdentityProvider) | |
| 229 | + | if !ok { | |
| 230 | + | return "" | |
| 231 | + | } | |
| 232 | + | return identityProvider.ExternalThemeIdentity() | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (uc *ResolveThemeUseCase) handleMissingExternalTheme( | |
| 236 | + | lightPalette, darkPalette entity.ColorPalette, | |
| 237 | + | themeSource entity.ThemeSourceMetadata, | |
| 238 | + | warnings []entity.ThemeWarning, | |
| 239 | + | ) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) { | |
| 240 | + | if uc.lastGoodExternal != nil { | |
| 241 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 242 | + | Field: "external", | |
| 243 | + | Message: "external theme is missing, using last-good", | |
| 244 | + | }) | |
| 245 | + | return uc.lastGoodPalettes(themeSource, warnings) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 249 | + | Field: "external", | |
| 250 | + | Message: "external theme is missing, using config/defaults", | |
| 251 | + | }) | |
| 252 | + | return lightPalette, darkPalette, themeSource, warnings | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func (uc *ResolveThemeUseCase) handleExternalError( | |
| 256 | + | err error, | |
| 257 | + | lightPalette, darkPalette entity.ColorPalette, | |
| 258 | + | themeSource entity.ThemeSourceMetadata, | |
| 259 | + | warnings []entity.ThemeWarning, | |
| 260 | + | ) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) { | |
| 261 | + | if uc.lastGoodExternal != nil { | |
| 262 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 263 | + | Field: "external", | |
| 264 | + | Message: "failed to fetch external theme: " + err.Error() + ", using last-good", | |
| 265 | + | }) | |
| 266 | + | return uc.lastGoodPalettes(themeSource, warnings) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 270 | + | Field: "external", | |
| 271 | + | Message: "failed to fetch external theme: " + err.Error(), | |
| 272 | + | }) | |
| 273 | + | return lightPalette, darkPalette, themeSource, warnings | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (uc *ResolveThemeUseCase) applyExternalTheme( | |
| 277 | + | ext *entity.ExternalTheme, | |
| 278 | + | lightPalette, darkPalette entity.ColorPalette, | |
| 279 | + | themeSource entity.ThemeSourceMetadata, | |
| 280 | + | warnings []entity.ThemeWarning, | |
| 281 | + | ) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) { | |
| 282 | + | validationWarnings := externalThemeWarnings(ext) | |
| 283 | + | if len(validationWarnings) > 0 { | |
| 284 | + | if uc.lastGoodExternal != nil { | |
| 285 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 286 | + | Field: "external", | |
| 287 | + | Message: "malformed external theme update, keeping last-good", | |
| 288 | + | }) | |
| 289 | + | warnings = append(warnings, validationWarnings...) | |
| 290 | + | return uc.lastGoodPalettes(themeSource, warnings) | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 294 | + | Field: "external", | |
| 295 | + | Message: "malformed external theme, using config/defaults", | |
| 296 | + | }) | |
| 297 | + | warnings = append(warnings, validationWarnings...) | |
| 298 | + | return lightPalette, darkPalette, themeSource, warnings | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // External palettes are partial overrides. Empty fields fall back through a | |
| 302 | + | // repaired config/default palette so one invalid config value cannot discard | |
| 303 | + | // otherwise valid external overrides or poison the last-good cache. | |
| 304 | + | lightPalette, warnings = paletteWithValidFallbackFields(lightPalette, entity.DefaultLightPalette(), "light_palette", warnings) | |
| 305 | + | darkPalette, warnings = paletteWithValidFallbackFields(darkPalette, entity.DefaultDarkPalette(), "dark_palette", warnings) | |
| 306 | + | resolvedLight := entity.MergePalette(ext.LightPalette, &lightPalette) | |
| 307 | + | resolvedDark := entity.MergePalette(ext.DarkPalette, &darkPalette) | |
| 308 | + | ||
| 309 | + | provider := ext.Provider | |
| 310 | + | lightCopy := resolvedLight | |
| 311 | + | darkCopy := resolvedDark | |
| 312 | + | uc.lastGoodExternal = &entity.ExternalTheme{ | |
| 313 | + | Name: ext.Name, | |
| 314 | + | Provider: provider, | |
| 315 | + | LightPalette: &lightCopy, | |
| 316 | + | DarkPalette: &darkCopy, | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | return resolvedLight, resolvedDark, entity.ThemeSourceMetadata{ | |
| 320 | + | Kind: entity.ThemeSourceExternal, | |
| 321 | + | Provider: provider, | |
| 322 | + | LastGood: false, | |
| 323 | + | }, warnings | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func externalThemeWarnings(ext *entity.ExternalTheme) []entity.ThemeWarning { | |
| 327 | + | if ext == nil { | |
| 328 | + | return []entity.ThemeWarning{{Field: "external", Message: "theme is nil"}} | |
| 329 | + | } | |
| 330 | + | var warnings []entity.ThemeWarning | |
| 331 | + | warnings = append(warnings, entity.ValidatePaletteOverrideHex(ext.LightPalette, "external.light_palette")...) | |
| 332 | + | warnings = append(warnings, entity.ValidatePaletteOverrideHex(ext.DarkPalette, "external.dark_palette")...) | |
| 333 | + | return warnings | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func paletteWithValidFallbackFields( | |
| 337 | + | palette entity.ColorPalette, | |
| 338 | + | fallback entity.ColorPalette, | |
| 339 | + | fieldPrefix string, | |
| 340 | + | warnings []entity.ThemeWarning, | |
| 341 | + | ) (entity.ColorPalette, []entity.ThemeWarning) { | |
| 342 | + | if entity.IsPaletteValid(&palette) { | |
| 343 | + | return palette, warnings | |
| 344 | + | } | |
| 345 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 346 | + | Field: fieldPrefix, | |
| 347 | + | Message: "resolved config palette contained invalid colors, using built-in defaults for invalid fields", | |
| 348 | + | }) | |
| 349 | + | if !entity.IsValidHex(palette.Background) { | |
| 350 | + | palette.Background = fallback.Background | |
| 351 | + | } | |
| 352 | + | if !entity.IsValidHex(palette.Surface) { | |
| 353 | + | palette.Surface = fallback.Surface | |
| 354 | + | } | |
| 355 | + | if !entity.IsValidHex(palette.SurfaceVariant) { | |
| 356 | + | palette.SurfaceVariant = fallback.SurfaceVariant | |
| 357 | + | } | |
| 358 | + | if !entity.IsValidHex(palette.Text) { | |
| 359 | + | palette.Text = fallback.Text | |
| 360 | + | } | |
| 361 | + | if !entity.IsValidHex(palette.Muted) { | |
| 362 | + | palette.Muted = fallback.Muted | |
| 363 | + | } | |
| 364 | + | if !entity.IsValidHex(palette.Accent) { | |
| 365 | + | palette.Accent = fallback.Accent | |
| 366 | + | } | |
| 367 | + | if !entity.IsValidHex(palette.Border) { | |
| 368 | + | palette.Border = fallback.Border | |
| 369 | + | } | |
| 370 | + | return palette, warnings | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func (uc *ResolveThemeUseCase) lastGoodPalettes( | |
| 374 | + | fallback entity.ThemeSourceMetadata, | |
| 375 | + | warnings []entity.ThemeWarning, | |
| 376 | + | ) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) { | |
| 377 | + | if uc.lastGoodExternal == nil || uc.lastGoodExternal.LightPalette == nil || uc.lastGoodExternal.DarkPalette == nil { | |
| 378 | + | return entity.DefaultLightPalette(), entity.DefaultDarkPalette(), fallback, warnings | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | return *uc.lastGoodExternal.LightPalette, *uc.lastGoodExternal.DarkPalette, entity.ThemeSourceMetadata{ | |
| 382 | + | Kind: entity.ThemeSourceExternal, | |
| 383 | + | Provider: uc.lastGoodExternal.Provider, | |
| 384 | + | LastGood: true, | |
| 385 | + | }, warnings | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func buildResolvedTheme( | |
| 389 | + | input ResolveThemeInput, | |
| 390 | + | lightPalette, darkPalette entity.ColorPalette, | |
| 391 | + | fonts entity.ThemeFonts, | |
| 392 | + | modeColors entity.ThemeModeColors, | |
| 393 | + | uiScale float64, | |
| 394 | + | themeSource entity.ThemeSourceMetadata, | |
| 395 | + | warnings []entity.ThemeWarning, | |
| 396 | + | ) entity.ResolvedTheme { | |
| 397 | + | if !entity.IsPaletteValid(&lightPalette) { | |
| 398 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 399 | + | Field: "light_palette", | |
| 400 | + | Message: "resolved light palette was invalid, using built-in default", | |
| 401 | + | }) | |
| 402 | + | lightPalette = entity.DefaultLightPalette() | |
| 403 | + | } | |
| 404 | + | if !entity.IsPaletteValid(&darkPalette) { | |
| 405 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 406 | + | Field: "dark_palette", | |
| 407 | + | Message: "resolved dark palette was invalid, using built-in default", | |
| 408 | + | }) | |
| 409 | + | darkPalette = entity.DefaultDarkPalette() | |
| 410 | + | } | |
| 411 | + | if !entity.IsModeColorsValid(&modeColors) { | |
| 412 | + | warnings = append(warnings, entity.ThemeWarning{ | |
| 413 | + | Field: "mode_colors", | |
| 414 | + | Message: "resolved mode colors were invalid, using built-in defaults", | |
| 415 | + | }) | |
| 416 | + | modeColors = entity.DefaultThemeModeColors() | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | activePalette := lightPalette | |
| 420 | + | if input.PrefersDark { | |
| 421 | + | activePalette = darkPalette | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | return entity.ResolvedTheme{ | |
| 425 | + | LightPalette: lightPalette, | |
| 426 | + | DarkPalette: darkPalette, | |
| 427 | + | ActivePalette: activePalette, | |
| 428 | + | PrefersDark: input.PrefersDark, | |
| 429 | + | ColorSchemeSource: input.ColorSchemeSource, | |
| 430 | + | ThemeSource: themeSource, | |
| 431 | + | Fonts: fonts, | |
| 432 | + | UIScale: uiScale, | |
| 433 | + | ModeColors: modeColors, | |
| 434 | + | Warnings: warnings, | |
| 435 | + | } | |
| 436 | + | } | |
A internal/application/usecase/resolve_theme_test.go +1365 -0
| @@ -0,0 +1,1365 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // assertNoWarnings fails the test if there are any warnings. | |
| 15 | + | func assertNoWarnings(t *testing.T, warnings []entity.ThemeWarning) { | |
| 16 | + | t.Helper() | |
| 17 | + | if len(warnings) > 0 { | |
| 18 | + | var msgs []string | |
| 19 | + | for _, w := range warnings { | |
| 20 | + | msgs = append(msgs, fmt.Sprintf("%s: %s", w.Field, w.Message)) | |
| 21 | + | } | |
| 22 | + | t.Errorf("unexpected warnings: %s", strings.Join(msgs, "; ")) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // assertValidPaletteHex checks every color field in a palette is valid CSS-safe hex. | |
| 27 | + | func assertValidPaletteHex(t *testing.T, label string, p entity.ResolvedThemePalette) { | |
| 28 | + | t.Helper() | |
| 29 | + | fields := map[string]string{ | |
| 30 | + | "Background": p.Background, | |
| 31 | + | "Surface": p.Surface, | |
| 32 | + | "SurfaceVariant": p.SurfaceVariant, | |
| 33 | + | "Text": p.Text, | |
| 34 | + | "Muted": p.Muted, | |
| 35 | + | "Accent": p.Accent, | |
| 36 | + | "Border": p.Border, | |
| 37 | + | } | |
| 38 | + | for name, value := range fields { | |
| 39 | + | if !entity.IsValidHex(value) { | |
| 40 | + | t.Errorf("%s.%s = %q is not a valid CSS-safe hex color", label, name, value) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // newMockExternalSource creates a test double for the external theme source port. | |
| 46 | + | type newMockExternalSource struct { | |
| 47 | + | theme *entity.ExternalTheme | |
| 48 | + | err error | |
| 49 | + | enabled bool | |
| 50 | + | identity string | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (m *newMockExternalSource) Get(_ context.Context) (*entity.ExternalTheme, error) { | |
| 54 | + | return m.theme, m.err | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (m *newMockExternalSource) IsEnabled() bool { | |
| 58 | + | return m.enabled | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (m *newMockExternalSource) ExternalThemeIdentity() string { | |
| 62 | + | return m.identity | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // configLightPalette returns a recognizable light palette for config. | |
| 66 | + | func configLightPalette() *entity.ColorPalette { | |
| 67 | + | return &entity.ColorPalette{ | |
| 68 | + | Background: "#fafafa", | |
| 69 | + | Surface: "#ffffff", | |
| 70 | + | SurfaceVariant: "#f0f0f0", | |
| 71 | + | Text: "#1a1a1a", | |
| 72 | + | Muted: "#666666", | |
| 73 | + | Accent: "#22c55e", | |
| 74 | + | Border: "#dddddd", | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // configDarkPalette returns a recognizable dark palette for config. | |
| 79 | + | func configDarkPalette() *entity.ColorPalette { | |
| 80 | + | return &entity.ColorPalette{ | |
| 81 | + | Background: "#0a0a0b", | |
| 82 | + | Surface: "#1a1a1b", | |
| 83 | + | SurfaceVariant: "#2d2d2d", | |
| 84 | + | Text: "#ffffff", | |
| 85 | + | Muted: "#909090", | |
| 86 | + | Accent: "#4ade80", | |
| 87 | + | Border: "#333333", | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // externalLightPalette returns a different light palette for external testing. | |
| 92 | + | func externalLightPalette() *entity.ColorPalette { | |
| 93 | + | return &entity.ColorPalette{ | |
| 94 | + | Background: "#fff8e7", | |
| 95 | + | Surface: "#fffef5", | |
| 96 | + | SurfaceVariant: "#f5ecd7", | |
| 97 | + | Text: "#2d2a1e", | |
| 98 | + | Muted: "#8c876e", | |
| 99 | + | Accent: "#d4a017", | |
| 100 | + | Border: "#e0d8b0", | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // externalDarkPalette returns a different dark palette for external testing. | |
| 105 | + | func externalDarkPalette() *entity.ColorPalette { | |
| 106 | + | return &entity.ColorPalette{ | |
| 107 | + | Background: "#1a1a2e", | |
| 108 | + | Surface: "#16213e", | |
| 109 | + | SurfaceVariant: "#0f3460", | |
| 110 | + | Text: "#e0e0e0", | |
| 111 | + | Muted: "#a0a0a0", | |
| 112 | + | Accent: "#e94560", | |
| 113 | + | Border: "#533483", | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // validExternalTheme returns a fully valid ExternalTheme for testing. | |
| 118 | + | func validExternalTheme() *entity.ExternalTheme { | |
| 119 | + | return &entity.ExternalTheme{ | |
| 120 | + | Name: "Noctalia Test", | |
| 121 | + | Provider: "noctalia", | |
| 122 | + | LightPalette: externalLightPalette(), | |
| 123 | + | DarkPalette: externalDarkPalette(), | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // ============================================================ | |
| 128 | + | // Precedence: External disabled → config/default palettes | |
| 129 | + | // ============================================================ | |
| 130 | + | ||
| 131 | + | // TestResolveTheme_ExternalDisabled_UsesConfigPalettes verifies that when the external | |
| 132 | + | // source is disabled, config palettes (or defaults) are used. | |
| 133 | + | func TestResolveTheme_ExternalDisabled_UsesConfigPalettes(t *testing.T) { | |
| 134 | + | uc := NewResolveThemeUseCase(nil) | |
| 135 | + | ||
| 136 | + | light := configLightPalette() | |
| 137 | + | dark := configDarkPalette() | |
| 138 | + | ||
| 139 | + | input := ResolveThemeInput{ | |
| 140 | + | ColorScheme: "default", | |
| 141 | + | PrefersDark: true, | |
| 142 | + | ColorSchemeSource: "adwaita", | |
| 143 | + | LightPalette: light, | |
| 144 | + | DarkPalette: dark, | |
| 145 | + | UIScale: 1.0, | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | output, err := uc.Execute(context.Background(), input) | |
| 149 | + | if err != nil { | |
| 150 | + | t.Fatalf("unexpected error: %v", err) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette) | |
| 154 | + | assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette) | |
| 155 | + | assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette) | |
| 156 | + | ||
| 157 | + | // With PrefersDark=true and no external, light palette should be config | |
| 158 | + | if output.Theme.LightPalette.Background != light.Background { | |
| 159 | + | t.Errorf("expected LightPalette.Background=%q, got %q", light.Background, output.Theme.LightPalette.Background) | |
| 160 | + | } | |
| 161 | + | // Active palette should be dark (PrefersDark=true) | |
| 162 | + | if output.Theme.ActivePalette.Background != dark.Background { | |
| 163 | + | t.Errorf("expected ActivePalette.Background=%q (dark), got %q", dark.Background, output.Theme.ActivePalette.Background) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // TestResolveTheme_NoConfig_UsesDefaults verifies that when neither config nor external | |
| 168 | + | // provide palettes, sensible defaults are returned. | |
| 169 | + | func TestResolveTheme_NoConfig_UsesDefaults(t *testing.T) { | |
| 170 | + | uc := NewResolveThemeUseCase(nil) | |
| 171 | + | ||
| 172 | + | input := ResolveThemeInput{ | |
| 173 | + | ColorScheme: "default", | |
| 174 | + | PrefersDark: true, | |
| 175 | + | ColorSchemeSource: "fallback", | |
| 176 | + | UIScale: 1.0, | |
| 177 | + | // LightPalette and DarkPalette are nil (no config) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | output, err := uc.Execute(context.Background(), input) | |
| 181 | + | if err != nil { | |
| 182 | + | t.Fatalf("unexpected error: %v", err) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette) | |
| 186 | + | assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette) | |
| 187 | + | assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette) | |
| 188 | + | ||
| 189 | + | // Defaults must be non-trivial (Background, Text non-empty) | |
| 190 | + | if output.Theme.DarkPalette.Background == "" { | |
| 191 | + | t.Error("expected non-empty DarkPalette.Background from defaults") | |
| 192 | + | } | |
| 193 | + | if output.Theme.DarkPalette.Text == "" { | |
| 194 | + | t.Error("expected non-empty DarkPalette.Text from defaults") | |
| 195 | + | } | |
| 196 | + | if output.Theme.LightPalette.Background == "" { | |
| 197 | + | t.Error("expected non-empty LightPalette.Background from defaults") | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // ============================================================ | |
| 202 | + | // Precedence: Valid external → external palettes override palette colors only | |
| 203 | + | // ============================================================ | |
| 204 | + | ||
| 205 | + | // TestResolveTheme_ValidExternal_OverridesPaletteColors verifies that when a valid | |
| 206 | + | // external theme is available, its palette colors override config palette colors, | |
| 207 | + | // but other fields (fonts, mode colors, scale) come from config or defaults. | |
| 208 | + | func TestResolveTheme_ValidExternal_OverridesPaletteColors(t *testing.T) { | |
| 209 | + | ext := validExternalTheme() | |
| 210 | + | mock := &newMockExternalSource{ | |
| 211 | + | theme: ext, | |
| 212 | + | enabled: true, | |
| 213 | + | } | |
| 214 | + | uc := NewResolveThemeUseCase(mock) | |
| 215 | + | ||
| 216 | + | input := ResolveThemeInput{ | |
| 217 | + | ColorScheme: "default", | |
| 218 | + | PrefersDark: true, | |
| 219 | + | ColorSchemeSource: "adwaita", | |
| 220 | + | LightPalette: configLightPalette(), | |
| 221 | + | DarkPalette: configDarkPalette(), | |
| 222 | + | Fonts: &entity.ThemeFonts{ | |
| 223 | + | SansFont: "config-sans", | |
| 224 | + | }, | |
| 225 | + | UIScale: 1.5, | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | output, err := uc.Execute(context.Background(), input) | |
| 229 | + | if err != nil { | |
| 230 | + | t.Fatalf("unexpected error: %v", err) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette) | |
| 234 | + | assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette) | |
| 235 | + | assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette) | |
| 236 | + | ||
| 237 | + | // External palette should override config palette | |
| 238 | + | if output.Theme.DarkPalette.Background != ext.DarkPalette.Background { | |
| 239 | + | t.Errorf("expected DarkPalette.Background from external (%q), got %q", | |
| 240 | + | ext.DarkPalette.Background, output.Theme.DarkPalette.Background) | |
| 241 | + | } | |
| 242 | + | if output.Theme.DarkPalette.Accent != ext.DarkPalette.Accent { | |
| 243 | + | t.Errorf("expected DarkPalette.Accent from external (%q), got %q", | |
| 244 | + | ext.DarkPalette.Accent, output.Theme.DarkPalette.Accent) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // ThemeSource should indicate external | |
| 248 | + | if output.Theme.ThemeSource.Kind != entity.ThemeSourceExternal { | |
| 249 | + | t.Errorf("expected ThemeSource.Kind=external, got %q", output.Theme.ThemeSource.Kind) | |
| 250 | + | } | |
| 251 | + | if output.Theme.ThemeSource.Provider != "noctalia" { | |
| 252 | + | t.Errorf("expected ThemeSource.Provider=noctalia, got %q", output.Theme.ThemeSource.Provider) | |
| 253 | + | } | |
| 254 | + | if output.Theme.ThemeSource.LastGood { | |
| 255 | + | t.Error("expected ThemeSource.LastGood=false for fresh valid external theme") | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // ColorSchemeSource should still reflect the detector | |
| 259 | + | if output.Theme.ColorSchemeSource != "adwaita" { | |
| 260 | + | t.Errorf("expected ColorSchemeSource='adwaita', got %q", output.Theme.ColorSchemeSource) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // TestResolveTheme_ValidExternal_DoesNotOverrideNonPaletteFields verifies that | |
| 265 | + | // external theme only overrides palette colors, not fonts, scale, or mode colors. | |
| 266 | + | func TestResolveTheme_ValidExternal_DoesNotOverrideNonPaletteFields(t *testing.T) { | |
| 267 | + | ext := validExternalTheme() | |
| 268 | + | mock := &newMockExternalSource{ | |
| 269 | + | theme: ext, | |
| 270 | + | enabled: true, | |
| 271 | + | } | |
| 272 | + | uc := NewResolveThemeUseCase(mock) | |
| 273 | + | ||
| 274 | + | input := ResolveThemeInput{ | |
| 275 | + | ColorScheme: "default", | |
| 276 | + | PrefersDark: false, | |
| 277 | + | ColorSchemeSource: "env", | |
| 278 | + | Fonts: &entity.ThemeFonts{ | |
| 279 | + | SansFont: "MyConfigSans", | |
| 280 | + | SerifFont: "MyConfigSerif", | |
| 281 | + | MonospaceFont: "MyConfigMono", | |
| 282 | + | GtkFont: "MyConfigGtk", | |
| 283 | + | DefaultSize: 14, | |
| 284 | + | }, | |
| 285 | + | UIScale: 2.0, | |
| 286 | + | ModeColors: &entity.ThemeModeColors{ | |
| 287 | + | PaneMode: "#111111", | |
| 288 | + | TabMode: "#222222", | |
| 289 | + | SessionMode: "#333333", | |
| 290 | + | ResizeMode: "#444444", | |
| 291 | + | }, | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | output, err := uc.Execute(context.Background(), input) | |
| 295 | + | if err != nil { | |
| 296 | + | t.Fatalf("unexpected error: %v", err) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // Fonts should come from config, not external | |
| 300 | + | if output.Theme.Fonts.SansFont != "MyConfigSans" { | |
| 301 | + | t.Errorf("expected SansFont from config (%q), got %q", | |
| 302 | + | "MyConfigSans", output.Theme.Fonts.SansFont) | |
| 303 | + | } | |
| 304 | + | if output.Theme.Fonts.DefaultSize != 14 { | |
| 305 | + | t.Errorf("expected DefaultFontSize from config (14), got %d", | |
| 306 | + | output.Theme.Fonts.DefaultSize) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // UI scale should come from config | |
| 310 | + | if output.Theme.UIScale != 2.0 { | |
| 311 | + | t.Errorf("expected UIScale=2.0 from config, got %f", output.Theme.UIScale) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // Mode colors should come from config | |
| 315 | + | if output.Theme.ModeColors.PaneMode != "#111111" { | |
| 316 | + | t.Errorf("expected PaneMode from config (#111111), got %q", | |
| 317 | + | output.Theme.ModeColors.PaneMode) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // TestResolveTheme_PartialExternal_MergesOverConfig verifies that empty external | |
| 322 | + | // fields are treated as "inherit" rather than malformed. | |
| 323 | + | func TestResolveTheme_PartialExternal_MergesOverConfig(t *testing.T) { | |
| 324 | + | partialLight := &entity.ColorPalette{ | |
| 325 | + | Accent: "#123456", | |
| 326 | + | } | |
| 327 | + | partialDark := &entity.ColorPalette{ | |
| 328 | + | Background: "#010203", | |
| 329 | + | Border: "#abcdef", | |
| 330 | + | } | |
| 331 | + | mock := &newMockExternalSource{ | |
| 332 | + | theme: &entity.ExternalTheme{ | |
| 333 | + | Name: "Partial Noctalia", | |
| 334 | + | Provider: "noctalia", | |
| 335 | + | LightPalette: partialLight, | |
| 336 | + | DarkPalette: partialDark, | |
| 337 | + | }, | |
| 338 | + | enabled: true, | |
| 339 | + | } | |
| 340 | + | uc := NewResolveThemeUseCase(mock) | |
| 341 | + | ||
| 342 | + | input := ResolveThemeInput{ | |
| 343 | + | PrefersDark: true, | |
| 344 | + | ColorSchemeSource: "adwaita", | |
| 345 | + | LightPalette: configLightPalette(), | |
| 346 | + | DarkPalette: configDarkPalette(), | |
| 347 | + | UIScale: 1.0, | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | output, err := uc.Execute(context.Background(), input) | |
| 351 | + | if err != nil { | |
| 352 | + | t.Fatalf("unexpected error: %v", err) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette) | |
| 356 | + | assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette) | |
| 357 | + | assertNoWarnings(t, output.Theme.Warnings) | |
| 358 | + | ||
| 359 | + | if output.Theme.LightPalette.Accent != "#123456" { | |
| 360 | + | t.Errorf("expected partial external light accent override, got %q", output.Theme.LightPalette.Accent) | |
| 361 | + | } | |
| 362 | + | if output.Theme.LightPalette.Background != configLightPalette().Background { | |
| 363 | + | t.Errorf("expected empty external light background to inherit config value %q, got %q", | |
| 364 | + | configLightPalette().Background, output.Theme.LightPalette.Background) | |
| 365 | + | } | |
| 366 | + | if output.Theme.DarkPalette.Background != "#010203" { | |
| 367 | + | t.Errorf("expected partial external dark background override, got %q", output.Theme.DarkPalette.Background) | |
| 368 | + | } | |
| 369 | + | if output.Theme.DarkPalette.Text != configDarkPalette().Text { | |
| 370 | + | t.Errorf("expected empty external dark text to inherit config value %q, got %q", | |
| 371 | + | configDarkPalette().Text, output.Theme.DarkPalette.Text) | |
| 372 | + | } | |
| 373 | + | if output.Theme.ThemeSource.Kind != entity.ThemeSourceExternal || output.Theme.ThemeSource.Provider != "noctalia" { | |
| 374 | + | t.Errorf("expected noctalia external source metadata, got %+v", output.Theme.ThemeSource) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // TestResolveTheme_PartialExternal_MergesOverDefaults verifies partial external | |
| 379 | + | // palettes also merge over built-in defaults when config palettes are absent. | |
| 380 | + | func TestResolveTheme_PartialExternal_MergesOverDefaults(t *testing.T) { | |
| 381 | + | mock := &newMockExternalSource{ | |
| 382 | + | theme: &entity.ExternalTheme{ | |
| 383 | + | Name: "Partial Defaults", | |
| 384 | + | LightPalette: &entity.ColorPalette{ | |
| 385 | + | Accent: "#112233", | |
| 386 | + | }, | |
| 387 | + | DarkPalette: &entity.ColorPalette{ | |
| 388 | + | Accent: "#445566", | |
| 389 | + | }, | |
| 390 | + | }, | |
| 391 | + | enabled: true, | |
| 392 | + | } | |
| 393 | + | uc := NewResolveThemeUseCase(mock) | |
| 394 | + | ||
| 395 | + | output, err := uc.Execute(context.Background(), ResolveThemeInput{PrefersDark: false}) | |
| 396 | + | if err != nil { | |
| 397 | + | t.Fatalf("unexpected error: %v", err) | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette) | |
| 401 | + | assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette) | |
| 402 | + | if output.Theme.LightPalette.Accent != "#112233" { | |
| 403 | + | t.Errorf("expected external light accent, got %q", output.Theme.LightPalette.Accent) | |
| 404 | + | } | |
| 405 | + | if output.Theme.LightPalette.Background != entity.DefaultLightPalette().Background { | |
| 406 | + | t.Errorf("expected light background to inherit default %q, got %q", | |
| 407 | + | entity.DefaultLightPalette().Background, output.Theme.LightPalette.Background) | |
| 408 | + | } | |
| 409 | + | if output.Theme.DarkPalette.Accent != "#445566" { | |
| 410 | + | t.Errorf("expected external dark accent, got %q", output.Theme.DarkPalette.Accent) | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func TestResolveTheme_PartialExternal_RepairsInvalidConfigFallbackFields(t *testing.T) { | |
| 415 | + | partialLight := &entity.ColorPalette{Accent: "#112233"} | |
| 416 | + | partialDark := &entity.ColorPalette{Background: "#445566"} | |
| 417 | + | mock := &newMockExternalSource{ | |
| 418 | + | theme: &entity.ExternalTheme{ | |
| 419 | + | Name: "Partial With Invalid Config", | |
| 420 | + | Provider: "noctalia", | |
| 421 | + | LightPalette: partialLight, | |
| 422 | + | DarkPalette: partialDark, | |
| 423 | + | }, | |
| 424 | + | enabled: true, | |
| 425 | + | } | |
| 426 | + | uc := NewResolveThemeUseCase(mock) | |
| 427 | + | invalidLight := *configLightPalette() | |
| 428 | + | invalidLight.Background = "not-a-hex" | |
| 429 | + | invalidDark := *configDarkPalette() | |
| 430 | + | invalidDark.Text = "also-invalid" | |
| 431 | + | ||
| 432 | + | output, err := uc.Execute(context.Background(), ResolveThemeInput{ | |
| 433 | + | PrefersDark: true, | |
| 434 | + | LightPalette: &invalidLight, | |
| 435 | + | DarkPalette: &invalidDark, | |
| 436 | + | UIScale: 1.0, | |
| 437 | + | }) | |
| 438 | + | if err != nil { | |
| 439 | + | t.Fatalf("unexpected error: %v", err) | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette) | |
| 443 | + | assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette) | |
| 444 | + | if output.Theme.LightPalette.Accent != "#112233" { | |
| 445 | + | t.Fatalf("expected valid external light accent to survive invalid config fallback, got %q", output.Theme.LightPalette.Accent) | |
| 446 | + | } | |
| 447 | + | if output.Theme.LightPalette.Background != entity.DefaultLightPalette().Background { | |
| 448 | + | t.Fatalf("expected invalid config light background to be repaired from defaults, got %q", output.Theme.LightPalette.Background) | |
| 449 | + | } | |
| 450 | + | if output.Theme.DarkPalette.Background != "#445566" { | |
| 451 | + | t.Fatalf("expected valid external dark background to survive invalid config fallback, got %q", output.Theme.DarkPalette.Background) | |
| 452 | + | } | |
| 453 | + | if output.Theme.DarkPalette.Text != entity.DefaultDarkPalette().Text { | |
| 454 | + | t.Fatalf("expected invalid config dark text to be repaired from defaults, got %q", output.Theme.DarkPalette.Text) | |
| 455 | + | } | |
| 456 | + | if output.Theme.ThemeSource.Kind != entity.ThemeSourceExternal || output.Theme.ThemeSource.LastGood { | |
| 457 | + | t.Fatalf("expected fresh external source metadata, got %+v", output.Theme.ThemeSource) | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | // ============================================================ | |
| 462 | + | // PrefersDark selects ActivePalette | |
| 463 | + | // ============================================================ | |
| 464 | + | ||
| 465 | + | // TestResolveTheme_PrefersDark_SelectsDarkPalette verifies that when PrefersDark=true, | |
| 466 | + | // ActivePalette is the dark palette. | |
| 467 | + | func TestResolveTheme_PrefersDark_SelectsDarkPalette(t *testing.T) { | |
| 468 | + | uc := NewResolveThemeUseCase(nil) | |
| 469 | + | ||
| 470 | + | input := ResolveThemeInput{ | |
| 471 | + | PrefersDark: true, | |
| 472 | + | ColorSchemeSource: "config", | |
| 473 | + | LightPalette: configLightPalette(), | |
| 474 | + | DarkPalette: configDarkPalette(), | |
| 475 | + | UIScale: 1.0, | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | output, err := uc.Execute(context.Background(), input) | |
| 479 | + | if err != nil { | |
| 480 | + | t.Fatalf("unexpected error: %v", err) | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | if output.Theme.ActivePalette.Background != configDarkPalette().Background { | |
| 484 | + | t.Errorf("expected ActivePalette to be dark palette, got Background=%q", | |
| 485 | + | output.Theme.ActivePalette.Background) | |
| 486 | + | } | |
| 487 | + | if !output.Theme.PrefersDark { | |
| 488 | + | t.Error("expected PrefersDark=true in output") | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | // TestResolveTheme_PrefersLight_SelectsLightPalette verifies that when PrefersDark=false, | |
| 493 | + | // ActivePalette is the light palette. | |
| 494 | + | func TestResolveTheme_PrefersLight_SelectsLightPalette(t *testing.T) { | |
| 495 | + | uc := NewResolveThemeUseCase(nil) | |
| 496 | + | ||
| 497 | + | input := ResolveThemeInput{ | |
| 498 | + | PrefersDark: false, | |
| 499 | + | ColorSchemeSource: "config", | |
| 500 | + | LightPalette: configLightPalette(), | |
| 501 | + | DarkPalette: configDarkPalette(), | |
| 502 | + | UIScale: 1.0, | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | output, err := uc.Execute(context.Background(), input) | |
| 506 | + | if err != nil { | |
| 507 | + | t.Fatalf("unexpected error: %v", err) | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | if output.Theme.ActivePalette.Background != configLightPalette().Background { | |
| 511 | + | t.Errorf("expected ActivePalette to be light palette, got Background=%q", | |
| 512 | + | output.Theme.ActivePalette.Background) | |
| 513 | + | } | |
| 514 | + | if output.Theme.PrefersDark { | |
| 515 | + | t.Error("expected PrefersDark=false in output") | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // ============================================================ | |
| 520 | + | // Malformed external at startup → config/default palettes + warning | |
| 521 | + | // ============================================================ | |
| 522 | + | ||
| 523 | + | // TestResolveTheme_MalformedExternalAtStartup_FallsBackToConfig tests that when the | |
| 524 | + | // external theme has malformed palette colors on first resolution, the usecase falls | |
| 525 | + | // back to config/defaults and includes a warning. | |
| 526 | + | func TestResolveTheme_MalformedExternalAtStartup_FallsBackToConfig(t *testing.T) { | |
| 527 | + | malformed := &entity.ExternalTheme{ | |
| 528 | + | Name: "Malformed Theme", | |
| 529 | + | LightPalette: &entity.ColorPalette{ | |
| 530 | + | Background: "rgb(0,0,0)", // not hex | |
| 531 | + | Text: "#ffffff", | |
| 532 | + | }, | |
| 533 | + | DarkPalette: &entity.ColorPalette{ | |
| 534 | + | Background: "#hijklm", // invalid hex | |
| 535 | + | Text: "#ffffff", | |
| 536 | + | }, | |
| 537 | + | } | |
| 538 | + | mock := &newMockExternalSource{ | |
| 539 | + | theme: malformed, | |
| 540 | + | enabled: true, | |
| 541 | + | } | |
| 542 | + | uc := NewResolveThemeUseCase(mock) | |
| 543 | + | ||
| 544 | + | input := ResolveThemeInput{ | |
| 545 | + | PrefersDark: true, | |
| 546 | + | ColorSchemeSource: "adwaita", | |
| 547 | + | LightPalette: configLightPalette(), | |
| 548 | + | DarkPalette: configDarkPalette(), | |
| 549 | + | UIScale: 1.0, | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | output, err := uc.Execute(context.Background(), input) | |
| 553 | + | if err != nil { | |
| 554 | + | t.Fatalf("unexpected error: %v", err) | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette) | |
| 558 | + | assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette) | |
| 559 | + | assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette) | |
| 560 | + | ||
| 561 | + | // Should fall back to config palette (not the malformed external) | |
| 562 | + | if output.Theme.DarkPalette.Background != configDarkPalette().Background { | |
| 563 | + | t.Errorf("expected fallback to config dark palette (%q), got %q", | |
| 564 | + | configDarkPalette().Background, output.Theme.DarkPalette.Background) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | // Should have at least one warning about the malformed external | |
| 568 | + | if len(output.Theme.Warnings) == 0 { | |
| 569 | + | t.Error("expected at least one warning for malformed external theme") | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | // TestResolveTheme_MissingExternalAtStartup_FallsBackWithWarning verifies that enabled | |
| 574 | + | // external sources returning nil data are treated as missing, not silently ignored. | |
| 575 | + | func TestResolveTheme_MissingExternalAtStartup_FallsBackWithWarning(t *testing.T) { | |
| 576 | + | mock := &newMockExternalSource{ | |
| 577 | + | theme: nil, | |
| 578 | + | enabled: true, | |
| 579 | + | } | |
| 580 | + | uc := NewResolveThemeUseCase(mock) | |
| 581 | + | ||
| 582 | + | input := ResolveThemeInput{ | |
| 583 | + | PrefersDark: true, | |
| 584 | + | ColorSchemeSource: "adwaita", | |
| 585 | + | LightPalette: configLightPalette(), | |
| 586 | + | DarkPalette: configDarkPalette(), | |
| 587 | + | UIScale: 1.0, | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | output, err := uc.Execute(context.Background(), input) | |
| 591 | + | if err != nil { | |
| 592 | + | t.Fatalf("unexpected error: %v", err) | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | if output.Theme.DarkPalette.Background != configDarkPalette().Background { | |
| 596 | + | t.Errorf("expected missing external startup fallback to config dark palette %q, got %q", | |
| 597 | + | configDarkPalette().Background, output.Theme.DarkPalette.Background) | |
| 598 | + | } | |
| 599 | + | if len(output.Theme.Warnings) == 0 { | |
| 600 | + | t.Fatal("expected warning for missing external theme") | |
| 601 | + | } | |
| 602 | + | if output.Theme.ThemeSource.Kind == entity.ThemeSourceExternal { | |
| 603 | + | t.Errorf("expected non-external source for startup missing external, got %+v", output.Theme.ThemeSource) | |
| 604 | + | } | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func TestResolveTheme_ReadErrorAtStartup_FallsBackWithWarning(t *testing.T) { | |
| 608 | + | mock := &newMockExternalSource{ | |
| 609 | + | err: errors.New("read failed"), | |
| 610 | + | enabled: true, | |
| 611 | + | identity: "noctalia|colors-json|/tmp/theme-a.json", | |
| 612 | + | } | |
| 613 | + | uc := NewResolveThemeUseCase(mock) | |
| 614 | + | ||
| 615 | + | input := ResolveThemeInput{ | |
| 616 | + | PrefersDark: true, | |
| 617 | + | ColorSchemeSource: "adwaita", | |
| 618 | + | LightPalette: configLightPalette(), | |
| 619 | + | DarkPalette: configDarkPalette(), | |
| 620 | + | UIScale: 1.0, | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | output, err := uc.Execute(context.Background(), input) | |
| 624 | + | if err != nil { | |
| 625 | + | t.Fatalf("unexpected error: %v", err) | |
| 626 | + | } | |
| 627 | + | if output.Theme.DarkPalette.Background != configDarkPalette().Background { | |
| 628 | + | t.Errorf("expected read-error startup fallback to config dark palette %q, got %q", | |
| 629 | + | configDarkPalette().Background, output.Theme.DarkPalette.Background) | |
| 630 | + | } | |
| 631 | + | if output.Theme.ThemeSource.Kind == entity.ThemeSourceExternal { | |
| 632 | + | t.Errorf("expected non-external source for startup read error, got %+v", output.Theme.ThemeSource) | |
| 633 | + | } | |
| 634 | + | if len(output.Theme.Warnings) == 0 { | |
| 635 | + | t.Fatal("expected warning for external read error") | |
| 636 | + | } | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | func TestResolveTheme_ReadErrorAfterValid_KeepsLastGoodForSameIdentity(t *testing.T) { | |
| 640 | + | ext := validExternalTheme() | |
| 641 | + | mock := &newMockExternalSource{ | |
| 642 | + | theme: ext, | |
| 643 | + | enabled: true, | |
| 644 | + | identity: "noctalia|colors-json|/tmp/theme-a.json", | |
| 645 | + | } | |
| 646 | + | uc := NewResolveThemeUseCase(mock) | |
| 647 | + | ||
| 648 | + | input := ResolveThemeInput{ | |
| 649 | + | PrefersDark: true, | |
| 650 | + | ColorSchemeSource: "adwaita", | |
| 651 | + | LightPalette: configLightPalette(), | |
| 652 | + | DarkPalette: configDarkPalette(), | |
| 653 | + | UIScale: 1.0, | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | first, err := uc.Execute(context.Background(), input) | |
| 657 | + | if err != nil { | |
| 658 | + | t.Fatalf("first resolution error: %v", err) | |
| 659 | + | } | |
| 660 | + | mock.theme = nil | |
| 661 | + | mock.err = errors.New("temporary read failure") | |
| 662 | + | ||
| 663 | + | second, err := uc.Execute(context.Background(), input) | |
| 664 | + | if err != nil { | |
| 665 | + | t.Fatalf("second resolution error: %v", err) | |
| 666 | + | } | |
| 667 | + | if second.Theme.DarkPalette.Background != first.Theme.DarkPalette.Background { | |
| 668 | + | t.Errorf("expected same-identity read error to keep last-good dark background %q, got %q", | |
| 669 | + | first.Theme.DarkPalette.Background, second.Theme.DarkPalette.Background) | |
| 670 | + | } | |
| 671 | + | if !second.Theme.ThemeSource.LastGood { | |
| 672 | + | t.Fatal("expected LastGood=true for same-identity read error after valid external") | |
| 673 | + | } | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | func TestResolveTheme_ExternalIdentityChangeClearsLastGoodBeforeReadError(t *testing.T) { | |
| 677 | + | ext := validExternalTheme() | |
| 678 | + | mock := &newMockExternalSource{ | |
| 679 | + | theme: ext, | |
| 680 | + | enabled: true, | |
| 681 | + | identity: "noctalia|colors-json|/tmp/theme-a.json", | |
| 682 | + | } | |
| 683 | + | uc := NewResolveThemeUseCase(mock) | |
| 684 | + | ||
| 685 | + | input := ResolveThemeInput{ | |
| 686 | + | PrefersDark: true, | |
| 687 | + | ColorSchemeSource: "adwaita", | |
| 688 | + | LightPalette: configLightPalette(), | |
| 689 | + | DarkPalette: configDarkPalette(), | |
| 690 | + | UIScale: 1.0, | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | first, err := uc.Execute(context.Background(), input) | |
| 694 | + | if err != nil { | |
| 695 | + | t.Fatalf("first resolution error: %v", err) | |
| 696 | + | } | |
| 697 | + | if first.Theme.DarkPalette.Background == configDarkPalette().Background { | |
| 698 | + | t.Fatal("first resolution should use external palette, not config") | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | mock.theme = nil | |
| 702 | + | mock.err = errors.New("new path missing") | |
| 703 | + | mock.identity = "noctalia|colors-json|/tmp/theme-b.json" | |
| 704 | + | ||
| 705 | + | second, err := uc.Execute(context.Background(), input) | |
| 706 | + | if err != nil { | |
| 707 | + | t.Fatalf("second resolution error: %v", err) | |
| 708 | + | } | |
| 709 | + | if second.Theme.DarkPalette.Background != configDarkPalette().Background { | |
| 710 | + | t.Errorf("expected changed-identity read error to fall back to config dark background %q, got %q", | |
| 711 | + | configDarkPalette().Background, second.Theme.DarkPalette.Background) | |
| 712 | + | } | |
| 713 | + | if second.Theme.ThemeSource.LastGood { | |
| 714 | + | t.Fatal("expected LastGood=false after external identity changed") | |
| 715 | + | } | |
| 716 | + | if second.Theme.ThemeSource.Kind == entity.ThemeSourceExternal { | |
| 717 | + | t.Errorf("expected non-external source after changed-identity read error, got %+v", second.Theme.ThemeSource) | |
| 718 | + | } | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | // TestResolveTheme_MissingExternalAfterValid_KeepsLastGood verifies missing nil data | |
| 722 | + | // after a valid external theme keeps last-good with warning metadata. | |
| 723 | + | func TestResolveTheme_MissingExternalAfterValid_KeepsLastGood(t *testing.T) { | |
| 724 | + | ext := validExternalTheme() | |
| 725 | + | mock := &newMockExternalSource{ | |
| 726 | + | theme: ext, | |
| 727 | + | enabled: true, | |
| 728 | + | } | |
| 729 | + | uc := NewResolveThemeUseCase(mock) | |
| 730 | + | ||
| 731 | + | input := ResolveThemeInput{ | |
| 732 | + | PrefersDark: true, | |
| 733 | + | ColorSchemeSource: "adwaita", | |
| 734 | + | LightPalette: configLightPalette(), | |
| 735 | + | DarkPalette: configDarkPalette(), | |
| 736 | + | UIScale: 1.0, | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | first, err := uc.Execute(context.Background(), input) | |
| 740 | + | if err != nil { | |
| 741 | + | t.Fatalf("first resolution error: %v", err) | |
| 742 | + | } | |
| 743 | + | mock.theme = nil | |
| 744 | + | ||
| 745 | + | second, err := uc.Execute(context.Background(), input) | |
| 746 | + | if err != nil { | |
| 747 | + | t.Fatalf("second resolution error: %v", err) | |
| 748 | + | } | |
| 749 | + | ||
| 750 | + | if second.Theme.DarkPalette.Background != first.Theme.DarkPalette.Background { | |
| 751 | + | t.Errorf("expected last-good dark background %q, got %q", | |
| 752 | + | first.Theme.DarkPalette.Background, second.Theme.DarkPalette.Background) | |
| 753 | + | } | |
| 754 | + | if !second.Theme.ThemeSource.LastGood { | |
| 755 | + | t.Error("expected LastGood=true when missing external follows valid external") | |
| 756 | + | } | |
| 757 | + | if second.Theme.ThemeSource.Provider != "noctalia" { | |
| 758 | + | t.Errorf("expected provider noctalia, got %q", second.Theme.ThemeSource.Provider) | |
| 759 | + | } | |
| 760 | + | if len(second.Theme.Warnings) == 0 { | |
| 761 | + | t.Fatal("expected warning for missing external after valid") | |
| 762 | + | } | |
| 763 | + | } | |
| 764 | + | ||
| 765 | + | // TestResolveTheme_MalformedExternalMissingPalette_FallsBack verifies fallback | |
| 766 | + | // when the external theme has nil palettes. | |
| 767 | + | func TestResolveTheme_MalformedExternalMissingPalette_FallsBack(t *testing.T) { | |
| 768 | + | malformed := &entity.ExternalTheme{ | |
| 769 | + | Name: "Missing Palettes", | |
| 770 | + | LightPalette: nil, | |
| 771 | + | DarkPalette: nil, | |
| 772 | + | } | |
| 773 | + | mock := &newMockExternalSource{ | |
| 774 | + | theme: malformed, | |
| 775 | + | enabled: true, | |
| 776 | + | } | |
| 777 | + | uc := NewResolveThemeUseCase(mock) | |
| 778 | + | ||
| 779 | + | input := ResolveThemeInput{ | |
| 780 | + | PrefersDark: true, | |
| 781 | + | ColorSchemeSource: "adwaita", | |
| 782 | + | LightPalette: configLightPalette(), | |
| 783 | + | DarkPalette: configDarkPalette(), | |
| 784 | + | UIScale: 1.0, | |
| 785 | + | } | |
| 786 | + | ||
| 787 | + | output, err := uc.Execute(context.Background(), input) | |
| 788 | + | if err != nil { | |
| 789 | + | t.Fatalf("unexpected error: %v", err) | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette) | |
| 793 | + | assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette) | |
| 794 | + | ||
| 795 | + | // Should have warnings about missing palettes | |
| 796 | + | if len(output.Theme.Warnings) == 0 { | |
| 797 | + | t.Error("expected warnings for external theme with nil palettes") | |
| 798 | + | } | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | // ============================================================ | |
| 802 | + | // Malformed after valid external → keep last-good external + warning | |
| 803 | + | // ============================================================ | |
| 804 | + | ||
| 805 | + | // TestResolveTheme_MalformedAfterValid_KeepsLastGood tests the "last-good" contract: | |
| 806 | + | // when an external theme becomes malformed after previously being valid, the usecase | |
| 807 | + | // keeps the last-good external palette colors and emits a warning. | |
| 808 | + | func TestResolveTheme_MalformedAfterValid_KeepsLastGood(t *testing.T) { | |
| 809 | + | // Step 1: First resolution with valid external | |
| 810 | + | ext := validExternalTheme() | |
| 811 | + | mock := &newMockExternalSource{ | |
| 812 | + | theme: ext, | |
| 813 | + | enabled: true, | |
| 814 | + | } | |
| 815 | + | uc := NewResolveThemeUseCase(mock) | |
| 816 | + | ||
| 817 | + | input := ResolveThemeInput{ | |
| 818 | + | PrefersDark: true, | |
| 819 | + | ColorSchemeSource: "adwaita", | |
| 820 | + | LightPalette: configLightPalette(), | |
| 821 | + | DarkPalette: configDarkPalette(), | |
| 822 | + | UIScale: 1.0, | |
| 823 | + | } | |
| 824 | + | ||
| 825 | + | output1, err := uc.Execute(context.Background(), input) | |
| 826 | + | if err != nil { | |
| 827 | + | t.Fatalf("first resolution error: %v", err) | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | firstDarkBg := output1.Theme.DarkPalette.Background | |
| 831 | + | // Verify first resolution uses external palette | |
| 832 | + | if firstDarkBg != ext.DarkPalette.Background { | |
| 833 | + | t.Fatalf("expected first resolution to use external dark palette (%q), got %q", | |
| 834 | + | ext.DarkPalette.Background, firstDarkBg) | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | // Step 2: External becomes malformed | |
| 838 | + | malformed := &entity.ExternalTheme{ | |
| 839 | + | Name: "Now Malformed", | |
| 840 | + | LightPalette: &entity.ColorPalette{ | |
| 841 | + | Background: "bad-color", | |
| 842 | + | Text: "#ffffff", | |
| 843 | + | }, | |
| 844 | + | DarkPalette: nil, // missing palette | |
| 845 | + | } | |
| 846 | + | mock.theme = malformed | |
| 847 | + | ||
| 848 | + | output2, err := uc.Execute(context.Background(), input) | |
| 849 | + | if err != nil { | |
| 850 | + | t.Fatalf("second resolution error: %v", err) | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | assertValidPaletteHex(t, "DarkPalette", output2.Theme.DarkPalette) | |
| 854 | + | assertValidPaletteHex(t, "ActivePalette", output2.Theme.ActivePalette) | |
| 855 | + | ||
| 856 | + | // Should keep last-good colors (from first resolution) | |
| 857 | + | if output2.Theme.DarkPalette.Background != firstDarkBg { | |
| 858 | + | t.Errorf("expected to keep last-good dark palette background (%q), got %q", | |
| 859 | + | firstDarkBg, output2.Theme.DarkPalette.Background) | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | // Should have warnings about the malformed update | |
| 863 | + | if len(output2.Theme.Warnings) == 0 { | |
| 864 | + | t.Error("expected warnings when external becomes malformed after being valid") | |
| 865 | + | } | |
| 866 | + | ||
| 867 | + | // ThemeSource should still indicate external (last-good) | |
| 868 | + | if output2.Theme.ThemeSource.Kind != entity.ThemeSourceExternal { | |
| 869 | + | t.Errorf("expected ThemeSource.Kind='external' for last-good, got %q", output2.Theme.ThemeSource.Kind) | |
| 870 | + | } | |
| 871 | + | if output2.Theme.ThemeSource.Provider != "noctalia" { | |
| 872 | + | t.Errorf("expected ThemeSource.Provider='noctalia' for last-good, got %q", output2.Theme.ThemeSource.Provider) | |
| 873 | + | } | |
| 874 | + | if !output2.Theme.ThemeSource.LastGood { | |
| 875 | + | t.Error("expected ThemeSource.LastGood=true for last-good external palette") | |
| 876 | + | } | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | // ============================================================ | |
| 880 | + | // Disabling external after valid → clear last-good and use config/defaults | |
| 881 | + | // ============================================================ | |
| 882 | + | ||
| 883 | + | // TestResolveTheme_DisablingExternalAfterValid_ClearsLastGood verifies that when the | |
| 884 | + | // external source is disabled after having provided a valid theme, last-good is cleared | |
| 885 | + | // and config/defaults are used instead. | |
| 886 | + | func TestResolveTheme_DisablingExternalAfterValid_ClearsLastGood(t *testing.T) { | |
| 887 | + | // Step 1: First resolution with valid external | |
| 888 | + | ext := validExternalTheme() | |
| 889 | + | mock := &newMockExternalSource{ | |
| 890 | + | theme: ext, | |
| 891 | + | enabled: true, | |
| 892 | + | } | |
| 893 | + | uc := NewResolveThemeUseCase(mock) | |
| 894 | + | ||
| 895 | + | input := ResolveThemeInput{ | |
| 896 | + | PrefersDark: true, | |
| 897 | + | ColorSchemeSource: "adwaita", | |
| 898 | + | LightPalette: configLightPalette(), | |
| 899 | + | DarkPalette: configDarkPalette(), | |
| 900 | + | UIScale: 1.0, | |
| 901 | + | } | |
| 902 | + | ||
| 903 | + | output1, err := uc.Execute(context.Background(), input) | |
| 904 | + | if err != nil { | |
| 905 | + | t.Fatalf("first resolution error: %v", err) | |
| 906 | + | } | |
| 907 | + | ||
| 908 | + | // Verify first resolution uses external palette | |
| 909 | + | if output1.Theme.DarkPalette.Background == configDarkPalette().Background { | |
| 910 | + | t.Fatal("first resolution should use external palette, not config") | |
| 911 | + | } | |
| 912 | + | ||
| 913 | + | // Step 2: External becomes disabled | |
| 914 | + | mock.theme = nil | |
| 915 | + | mock.enabled = false | |
| 916 | + | ||
| 917 | + | output2, err := uc.Execute(context.Background(), input) | |
| 918 | + | if err != nil { | |
| 919 | + | t.Fatalf("second resolution error: %v", err) | |
| 920 | + | } | |
| 921 | + | ||
| 922 | + | assertValidPaletteHex(t, "DarkPalette", output2.Theme.DarkPalette) | |
| 923 | + | ||
| 924 | + | // Should fall back to config palette (NOT keep last-good) | |
| 925 | + | if output2.Theme.DarkPalette.Background != configDarkPalette().Background { | |
| 926 | + | t.Errorf("expected fallback to config dark palette (%q) after external disabled, got %q", | |
| 927 | + | configDarkPalette().Background, output2.Theme.DarkPalette.Background) | |
| 928 | + | } | |
| 929 | + | ||
| 930 | + | // ThemeSource should indicate config or default | |
| 931 | + | if output2.Theme.ThemeSource.Kind == entity.ThemeSourceExternal { | |
| 932 | + | t.Error("expected ThemeSource.Kind NOT to be 'external' after external is disabled") | |
| 933 | + | } | |
| 934 | + | if output2.Theme.ThemeSource.LastGood { | |
| 935 | + | t.Error("expected ThemeSource.LastGood=false after external is disabled") | |
| 936 | + | } | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | // TestResolveTheme_DisablingExternalTwice_Noop verifies that disabling external | |
| 940 | + | // when it was already disabled is a no-op (no warnings, uses config). | |
| 941 | + | func TestResolveTheme_DisablingExternalTwice_Noop(t *testing.T) { | |
| 942 | + | // External already disabled from start | |
| 943 | + | mock := &newMockExternalSource{ | |
| 944 | + | enabled: false, | |
| 945 | + | } | |
| 946 | + | uc := NewResolveThemeUseCase(mock) | |
| 947 | + | ||
| 948 | + | input := ResolveThemeInput{ | |
| 949 | + | PrefersDark: true, | |
| 950 | + | ColorSchemeSource: "adwaita", | |
| 951 | + | LightPalette: configLightPalette(), | |
| 952 | + | DarkPalette: configDarkPalette(), | |
| 953 | + | UIScale: 1.0, | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | output1, err := uc.Execute(context.Background(), input) | |
| 957 | + | if err != nil { | |
| 958 | + | t.Fatalf("first resolution error: %v", err) | |
| 959 | + | } | |
| 960 | + | ||
| 961 | + | // Should use config | |
| 962 | + | if output1.Theme.DarkPalette.Background != configDarkPalette().Background { | |
| 963 | + | t.Errorf("expected config dark palette, got %q", output1.Theme.DarkPalette.Background) | |
| 964 | + | } | |
| 965 | + | ||
| 966 | + | // Second resolution with same disabled state — should behave identically | |
| 967 | + | output2, err := uc.Execute(context.Background(), input) | |
| 968 | + | if err != nil { | |
| 969 | + | t.Fatalf("second resolution error: %v", err) | |
| 970 | + | } | |
| 971 | + | ||
| 972 | + | if output2.Theme.DarkPalette.Background != configDarkPalette().Background { | |
| 973 | + | t.Errorf("expected config dark palette on second call, got %q", output2.Theme.DarkPalette.Background) | |
| 974 | + | } | |
| 975 | + | ||
| 976 | + | assertNoWarnings(t, output2.Theme.Warnings) | |
| 977 | + | } | |
| 978 | + | ||
| 979 | + | // ============================================================ | |
| 980 | + | // Output completeness: all ResolvedTheme fields | |
| 981 | + | // ============================================================ | |
| 982 | + | ||
| 983 | + | // TestResolveTheme_OutputContainsAllFields verifies that every field of | |
| 984 | + | // ResolvedTheme is populated in the output (no zero-value surprises). | |
| 985 | + | func TestResolveTheme_OutputContainsAllFields(t *testing.T) { | |
| 986 | + | uc := NewResolveThemeUseCase(nil) | |
| 987 | + | ||
| 988 | + | fonts := entity.ThemeFonts{ | |
| 989 | + | SansFont: "config-sans", | |
| 990 | + | SerifFont: "config-serif", | |
| 991 | + | MonospaceFont: "config-mono", | |
| 992 | + | GtkFont: "config-gtk", | |
| 993 | + | DefaultSize: 12, | |
| 994 | + | } | |
| 995 | + | modeColors := entity.ThemeModeColors{ | |
| 996 | + | PaneMode: "#111111", | |
| 997 | + | TabMode: "#222222", | |
| 998 | + | SessionMode: "#333333", | |
| 999 | + | ResizeMode: "#444444", | |
| 1000 | + | } | |
| 1001 | + | ||
| 1002 | + | input := ResolveThemeInput{ | |
| 1003 | + | ColorScheme: "prefer-dark", | |
| 1004 | + | PrefersDark: true, | |
| 1005 | + | ColorSchemeSource: "config", | |
| 1006 | + | LightPalette: configLightPalette(), | |
| 1007 | + | DarkPalette: configDarkPalette(), | |
| 1008 | + | Fonts: &fonts, | |
| 1009 | + | UIScale: 1.25, | |
| 1010 | + | ModeColors: &modeColors, | |
| 1011 | + | } | |
| 1012 | + | ||
| 1013 | + | output, err := uc.Execute(context.Background(), input) | |
| 1014 | + | if err != nil { | |
| 1015 | + | t.Fatalf("unexpected error: %v", err) | |
| 1016 | + | } | |
| 1017 | + | ||
| 1018 | + | rt := output.Theme | |
| 1019 | + | ||
| 1020 | + | // LightPalette must be valid | |
| 1021 | + | assertValidPaletteHex(t, "LightPalette", rt.LightPalette) | |
| 1022 | + | // DarkPalette must be valid | |
| 1023 | + | assertValidPaletteHex(t, "DarkPalette", rt.DarkPalette) | |
| 1024 | + | // ActivePalette must be valid | |
| 1025 | + | assertValidPaletteHex(t, "ActivePalette", rt.ActivePalette) | |
| 1026 | + | // PrefersDark | |
| 1027 | + | if !rt.PrefersDark { | |
| 1028 | + | t.Error("expected PrefersDark=true") | |
| 1029 | + | } | |
| 1030 | + | // ColorSchemeSource | |
| 1031 | + | if rt.ColorSchemeSource != "config" { | |
| 1032 | + | t.Errorf("expected ColorSchemeSource='config', got %q", rt.ColorSchemeSource) | |
| 1033 | + | } | |
| 1034 | + | // ThemeSource | |
| 1035 | + | if rt.ThemeSource.Kind == "" { | |
| 1036 | + | t.Error("expected non-empty ThemeSource.Kind") | |
| 1037 | + | } | |
| 1038 | + | // Fonts | |
| 1039 | + | if rt.Fonts.SansFont == "" { | |
| 1040 | + | t.Error("expected non-empty Fonts.SansFont") | |
| 1041 | + | } | |
| 1042 | + | if rt.Fonts.SerifFont == "" { | |
| 1043 | + | t.Error("expected non-empty Fonts.SerifFont") | |
| 1044 | + | } | |
| 1045 | + | if rt.Fonts.DefaultSize <= 0 { | |
| 1046 | + | t.Errorf("expected positive Fonts.DefaultSize, got %d", rt.Fonts.DefaultSize) | |
| 1047 | + | } | |
| 1048 | + | // UIScale | |
| 1049 | + | if rt.UIScale <= 0 { | |
| 1050 | + | t.Errorf("expected positive UIScale, got %f", rt.UIScale) | |
| 1051 | + | } | |
| 1052 | + | // ModeColors | |
| 1053 | + | if rt.ModeColors.PaneMode == "" { | |
| 1054 | + | t.Error("expected non-empty ModeColors.PaneMode") | |
| 1055 | + | } | |
| 1056 | + | if rt.ModeColors.TabMode == "" { | |
| 1057 | + | t.Error("expected non-empty ModeColors.TabMode") | |
| 1058 | + | } | |
| 1059 | + | if rt.ModeColors.SessionMode == "" { | |
| 1060 | + | t.Error("expected non-empty ModeColors.SessionMode") | |
| 1061 | + | } | |
| 1062 | + | if rt.ModeColors.ResizeMode == "" { | |
| 1063 | + | t.Error("expected non-empty ModeColors.ResizeMode") | |
| 1064 | + | } | |
| 1065 | + | } | |
| 1066 | + | ||
| 1067 | + | // ============================================================ | |
| 1068 | + | // All palette colors must be valid CSS-safe hex | |
| 1069 | + | // ============================================================ | |
| 1070 | + | ||
| 1071 | + | // TestResolveTheme_AllOutputPalettesAreCSSSafeHex is the contract test: every color | |
| 1072 | + | // field in every palette of the output must be a valid #RRGGBB hex color. | |
| 1073 | + | func TestResolveTheme_AllOutputPalettesAreCSSSafeHex(t *testing.T) { | |
| 1074 | + | uc := NewResolveThemeUseCase(nil) | |
| 1075 | + | ||
| 1076 | + | input := ResolveThemeInput{ | |
| 1077 | + | PrefersDark: false, | |
| 1078 | + | ColorSchemeSource: "fallback", | |
| 1079 | + | UIScale: 1.0, | |
| 1080 | + | // No config palettes — must get valid defaults | |
| 1081 | + | } | |
| 1082 | + | ||
| 1083 | + | output, err := uc.Execute(context.Background(), input) | |
| 1084 | + | if err != nil { | |
| 1085 | + | t.Fatalf("unexpected error: %v", err) | |
| 1086 | + | } | |
| 1087 | + | ||
| 1088 | + | assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette) | |
| 1089 | + | assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette) | |
| 1090 | + | assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette) | |
| 1091 | + | } | |
| 1092 | + | ||
| 1093 | + | // ============================================================ | |
| 1094 | + | // Font defaults are used when no config or external fonts provided | |
| 1095 | + | // ============================================================ | |
| 1096 | + | ||
| 1097 | + | // TestResolveTheme_NoConfigFonts_UsesDefaults verifies that when fonts are not | |
| 1098 | + | // provided in config or external, sensible defaults are used. | |
| 1099 | + | func TestResolveTheme_NoConfigFonts_UsesDefaults(t *testing.T) { | |
| 1100 | + | uc := NewResolveThemeUseCase(nil) | |
| 1101 | + | ||
| 1102 | + | input := ResolveThemeInput{ | |
| 1103 | + | PrefersDark: true, | |
| 1104 | + | ColorSchemeSource: "fallback", | |
| 1105 | + | UIScale: 1.0, | |
| 1106 | + | // Fonts is nil | |
| 1107 | + | } | |
| 1108 | + | ||
| 1109 | + | output, err := uc.Execute(context.Background(), input) | |
| 1110 | + | if err != nil { | |
| 1111 | + | t.Fatalf("unexpected error: %v", err) | |
| 1112 | + | } | |
| 1113 | + | ||
| 1114 | + | defaults := entity.DefaultThemeFonts() | |
| 1115 | + | ||
| 1116 | + | if output.Theme.Fonts.SansFont != defaults.SansFont { | |
| 1117 | + | t.Errorf("expected default SansFont (%q), got %q", | |
| 1118 | + | defaults.SansFont, output.Theme.Fonts.SansFont) | |
| 1119 | + | } | |
| 1120 | + | if output.Theme.Fonts.SerifFont != defaults.SerifFont { | |
| 1121 | + | t.Errorf("expected default SerifFont (%q), got %q", | |
| 1122 | + | defaults.SerifFont, output.Theme.Fonts.SerifFont) | |
| 1123 | + | } | |
| 1124 | + | if output.Theme.Fonts.DefaultSize != defaults.DefaultSize { | |
| 1125 | + | t.Errorf("expected default DefaultSize (%d), got %d", | |
| 1126 | + | defaults.DefaultSize, output.Theme.Fonts.DefaultSize) | |
| 1127 | + | } | |
| 1128 | + | } | |
| 1129 | + | ||
| 1130 | + | // ============================================================ | |
| 1131 | + | // Mode color defaults are used when not provided | |
| 1132 | + | // ============================================================ | |
| 1133 | + | ||
| 1134 | + | // TestResolveTheme_NoConfigModeColors_UsesDefaults verifies that when mode colors | |
| 1135 | + | // are not provided, sensible defaults are used. | |
| 1136 | + | func TestResolveTheme_NoConfigModeColors_UsesDefaults(t *testing.T) { | |
| 1137 | + | uc := NewResolveThemeUseCase(nil) | |
| 1138 | + | ||
| 1139 | + | input := ResolveThemeInput{ | |
| 1140 | + | PrefersDark: true, | |
| 1141 | + | ColorSchemeSource: "fallback", | |
| 1142 | + | UIScale: 1.0, | |
| 1143 | + | // ModeColors is nil | |
| 1144 | + | } | |
| 1145 | + | ||
| 1146 | + | output, err := uc.Execute(context.Background(), input) | |
| 1147 | + | if err != nil { | |
| 1148 | + | t.Fatalf("unexpected error: %v", err) | |
| 1149 | + | } | |
| 1150 | + | ||
| 1151 | + | // Mode colors must be valid CSS-safe hex | |
| 1152 | + | fields := map[string]string{ | |
| 1153 | + | "PaneMode": output.Theme.ModeColors.PaneMode, | |
| 1154 | + | "TabMode": output.Theme.ModeColors.TabMode, | |
| 1155 | + | "SessionMode": output.Theme.ModeColors.SessionMode, | |
| 1156 | + | "ResizeMode": output.Theme.ModeColors.ResizeMode, | |
| 1157 | + | } | |
| 1158 | + | for name, value := range fields { | |
| 1159 | + | if value == "" { | |
| 1160 | + | t.Errorf("expected non-empty ModeColors.%s", name) | |
| 1161 | + | } | |
| 1162 | + | if !entity.IsValidHex(value) { | |
| 1163 | + | t.Errorf("ModeColors.%s = %q is not valid CSS-safe hex", name, value) | |
| 1164 | + | } | |
| 1165 | + | } | |
| 1166 | + | } | |
| 1167 | + | ||
| 1168 | + | // TestResolveTheme_InvalidModeColors_FallsBackToDefaults verifies invalid config mode | |
| 1169 | + | // colors cannot pass through ResolvedTheme. | |
| 1170 | + | func TestResolveTheme_InvalidModeColors_FallsBackToDefaults(t *testing.T) { | |
| 1171 | + | uc := NewResolveThemeUseCase(nil) | |
| 1172 | + | invalid := &entity.ThemeModeColors{ | |
| 1173 | + | PaneMode: "not-a-color", | |
| 1174 | + | TabMode: "#222222", | |
| 1175 | + | SessionMode: "#333333", | |
| 1176 | + | ResizeMode: "#444444", | |
| 1177 | + | } | |
| 1178 | + | ||
| 1179 | + | output, err := uc.Execute(context.Background(), ResolveThemeInput{ | |
| 1180 | + | PrefersDark: true, | |
| 1181 | + | ColorSchemeSource: "config", | |
| 1182 | + | ModeColors: invalid, | |
| 1183 | + | }) | |
| 1184 | + | if err != nil { | |
| 1185 | + | t.Fatalf("unexpected error: %v", err) | |
| 1186 | + | } | |
| 1187 | + | ||
| 1188 | + | defaults := entity.DefaultThemeModeColors() | |
| 1189 | + | if output.Theme.ModeColors != defaults { | |
| 1190 | + | t.Errorf("expected invalid mode colors to fall back to defaults %+v, got %+v", defaults, output.Theme.ModeColors) | |
| 1191 | + | } | |
| 1192 | + | if len(output.Theme.Warnings) == 0 { | |
| 1193 | + | t.Fatal("expected warning for invalid mode colors") | |
| 1194 | + | } | |
| 1195 | + | } | |
| 1196 | + | ||
| 1197 | + | // ============================================================ | |
| 1198 | + | // UIScale defaults | |
| 1199 | + | // ============================================================ | |
| 1200 | + | ||
| 1201 | + | // TestResolveTheme_UIScaleDefaultsToOne verifies that when UIScale is zero or negative, | |
| 1202 | + | // it defaults to 1.0. | |
| 1203 | + | func TestResolveTheme_UIScaleDefaultsToOne(t *testing.T) { | |
| 1204 | + | uc := NewResolveThemeUseCase(nil) | |
| 1205 | + | ||
| 1206 | + | tests := []struct { | |
| 1207 | + | name string | |
| 1208 | + | uiScale float64 | |
| 1209 | + | expected float64 | |
| 1210 | + | }{ | |
| 1211 | + | {"zero", 0.0, 1.0}, | |
| 1212 | + | {"negative", -0.5, 1.0}, | |
| 1213 | + | {"positive", 1.25, 1.25}, | |
| 1214 | + | } | |
| 1215 | + | ||
| 1216 | + | for _, tt := range tests { | |
| 1217 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1218 | + | input := ResolveThemeInput{ | |
| 1219 | + | PrefersDark: true, | |
| 1220 | + | ColorSchemeSource: "fallback", | |
| 1221 | + | UIScale: tt.uiScale, | |
| 1222 | + | } | |
| 1223 | + | ||
| 1224 | + | output, err := uc.Execute(context.Background(), input) | |
| 1225 | + | if err != nil { | |
| 1226 | + | t.Fatalf("unexpected error: %v", err) | |
| 1227 | + | } | |
| 1228 | + | ||
| 1229 | + | if output.Theme.UIScale != tt.expected { | |
| 1230 | + | t.Errorf("expected UIScale=%f, got %f", tt.expected, output.Theme.UIScale) | |
| 1231 | + | } | |
| 1232 | + | }) | |
| 1233 | + | } | |
| 1234 | + | } | |
| 1235 | + | ||
| 1236 | + | // ============================================================ | |
| 1237 | + | // Color scheme source is properly captured | |
| 1238 | + | // ============================================================ | |
| 1239 | + | ||
| 1240 | + | // TestResolveTheme_ColorSchemeSourceIsCaptured verifies that the color scheme | |
| 1241 | + | // source string is passed through to the output. | |
| 1242 | + | func TestResolveTheme_ColorSchemeSourceIsCaptured(t *testing.T) { | |
| 1243 | + | uc := NewResolveThemeUseCase(nil) | |
| 1244 | + | ||
| 1245 | + | sources := []string{"config", "adwaita", "gsettings", "env", "fallback"} | |
| 1246 | + | ||
| 1247 | + | for _, source := range sources { | |
| 1248 | + | input := ResolveThemeInput{ | |
| 1249 | + | PrefersDark: true, | |
| 1250 | + | ColorSchemeSource: source, | |
| 1251 | + | UIScale: 1.0, | |
| 1252 | + | } | |
| 1253 | + | ||
| 1254 | + | output, err := uc.Execute(context.Background(), input) | |
| 1255 | + | if err != nil { | |
| 1256 | + | t.Fatalf("unexpected error for source %q: %v", source, err) | |
| 1257 | + | } | |
| 1258 | + | ||
| 1259 | + | if output.Theme.ColorSchemeSource != source { | |
| 1260 | + | t.Errorf("expected ColorSchemeSource=%q, got %q", source, output.Theme.ColorSchemeSource) | |
| 1261 | + | } | |
| 1262 | + | } | |
| 1263 | + | } | |
| 1264 | + | ||
| 1265 | + | // ============================================================ | |
| 1266 | + | // Refresh API | |
| 1267 | + | // ============================================================ | |
| 1268 | + | ||
| 1269 | + | // TestResolveTheme_RefreshDelegatesToExecute verifies the refresh/update API returns | |
| 1270 | + | // the same single result shape as Execute without adding CSS generation or I/O. | |
| 1271 | + | func TestResolveTheme_RefreshDelegatesToExecute(t *testing.T) { | |
| 1272 | + | uc := NewResolveThemeUseCase(nil) | |
| 1273 | + | input := ResolveThemeInput{ | |
| 1274 | + | PrefersDark: true, | |
| 1275 | + | ColorSchemeSource: "adwaita", | |
| 1276 | + | LightPalette: configLightPalette(), | |
| 1277 | + | DarkPalette: configDarkPalette(), | |
| 1278 | + | UIScale: 1.25, | |
| 1279 | + | } | |
| 1280 | + | ||
| 1281 | + | output, err := uc.Refresh(context.Background(), input) | |
| 1282 | + | if err != nil { | |
| 1283 | + | t.Fatalf("unexpected error: %v", err) | |
| 1284 | + | } | |
| 1285 | + | ||
| 1286 | + | if !output.Theme.PrefersDark { | |
| 1287 | + | t.Error("expected refreshed theme to preserve PrefersDark") | |
| 1288 | + | } | |
| 1289 | + | if output.Theme.ActivePalette.Background != configDarkPalette().Background { | |
| 1290 | + | t.Errorf("expected refreshed active palette background %q, got %q", | |
| 1291 | + | configDarkPalette().Background, output.Theme.ActivePalette.Background) | |
| 1292 | + | } | |
| 1293 | + | if output.Theme.UIScale != 1.25 { | |
| 1294 | + | t.Errorf("expected refreshed UI scale 1.25, got %f", output.Theme.UIScale) | |
| 1295 | + | } | |
| 1296 | + | } | |
| 1297 | + | ||
| 1298 | + | // ============================================================ | |
| 1299 | + | // Input mapping from domain config shapes | |
| 1300 | + | // ============================================================ | |
| 1301 | + | ||
| 1302 | + | // TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference verifies | |
| 1303 | + | // app/bootstrap callers can build usecase input without duplicating fallback logic. | |
| 1304 | + | func TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference(t *testing.T) { | |
| 1305 | + | appearance := &entity.AppearanceConfig{ | |
| 1306 | + | SansFont: "Inter", | |
| 1307 | + | SerifFont: "Georgia", | |
| 1308 | + | MonospaceFont: "JetBrains Mono", | |
| 1309 | + | GtkFont: "Adwaita Sans", | |
| 1310 | + | DefaultFontSize: 18, | |
| 1311 | + | ColorScheme: "prefer-dark", | |
| 1312 | + | LightPalette: *configLightPalette(), | |
| 1313 | + | DarkPalette: *configDarkPalette(), | |
| 1314 | + | } | |
| 1315 | + | styling := &entity.WorkspaceStylingConfig{ | |
| 1316 | + | PaneModeColor: "#111111", | |
| 1317 | + | TabModeColor: "#222222", | |
| 1318 | + | SessionModeColor: "#333333", | |
| 1319 | + | ResizeModeColor: "#444444", | |
| 1320 | + | } | |
| 1321 | + | preference := port.ColorSchemePreference{PrefersDark: true, Source: "adwaita"} | |
| 1322 | + | ||
| 1323 | + | input := ResolveThemeInputFromConfig(appearance, 1.25, styling, preference) | |
| 1324 | + | ||
| 1325 | + | if input.ColorScheme != "prefer-dark" { | |
| 1326 | + | t.Errorf("expected color scheme prefer-dark, got %q", input.ColorScheme) | |
| 1327 | + | } | |
| 1328 | + | if !input.PrefersDark || input.ColorSchemeSource != "adwaita" { | |
| 1329 | + | t.Errorf("expected preference from detector, got PrefersDark=%v Source=%q", input.PrefersDark, input.ColorSchemeSource) | |
| 1330 | + | } | |
| 1331 | + | if input.LightPalette == nil || input.LightPalette.Background != configLightPalette().Background { | |
| 1332 | + | t.Fatalf("expected light palette pointer from appearance, got %+v", input.LightPalette) | |
| 1333 | + | } | |
| 1334 | + | if input.DarkPalette == nil || input.DarkPalette.Background != configDarkPalette().Background { | |
| 1335 | + | t.Fatalf("expected dark palette pointer from appearance, got %+v", input.DarkPalette) | |
| 1336 | + | } | |
| 1337 | + | if input.Fonts == nil || input.Fonts.SansFont != "Inter" || input.Fonts.DefaultSize != 18 { | |
| 1338 | + | t.Fatalf("expected fonts from appearance, got %+v", input.Fonts) | |
| 1339 | + | } | |
| 1340 | + | if input.UIScale != 1.25 { | |
| 1341 | + | t.Errorf("expected UI scale 1.25, got %f", input.UIScale) | |
| 1342 | + | } | |
| 1343 | + | if input.ModeColors == nil || input.ModeColors.PaneMode != "#111111" || input.ModeColors.ResizeMode != "#444444" { | |
| 1344 | + | t.Fatalf("expected mode colors from styling, got %+v", input.ModeColors) | |
| 1345 | + | } | |
| 1346 | + | } | |
| 1347 | + | ||
| 1348 | + | // TestResolveThemeInputFromConfig_AllowsNilConfig verifies callers can resolve defaults | |
| 1349 | + | // when appearance/styling config is unavailable. | |
| 1350 | + | func TestResolveThemeInputFromConfig_AllowsNilConfig(t *testing.T) { | |
| 1351 | + | preference := port.ColorSchemePreference{PrefersDark: false, Source: "fallback"} | |
| 1352 | + | ||
| 1353 | + | input := ResolveThemeInputFromConfig(nil, 0, nil, preference) | |
| 1354 | + | ||
| 1355 | + | if input.ColorScheme != "" { | |
| 1356 | + | t.Errorf("expected empty color scheme for nil appearance, got %q", input.ColorScheme) | |
| 1357 | + | } | |
| 1358 | + | if input.PrefersDark || input.ColorSchemeSource != "fallback" { | |
| 1359 | + | t.Errorf("expected fallback light preference, got PrefersDark=%v Source=%q", input.PrefersDark, input.ColorSchemeSource) | |
| 1360 | + | } | |
| 1361 | + | if input.LightPalette != nil || input.DarkPalette != nil || input.Fonts != nil || input.ModeColors != nil { | |
| 1362 | + | t.Fatalf("expected nil optional inputs, got light=%v dark=%v fonts=%v modes=%v", | |
| 1363 | + | input.LightPalette, input.DarkPalette, input.Fonts, input.ModeColors) | |
| 1364 | + | } | |
| 1365 | + | } | |
M internal/bootstrap/engine.go +24 -7
| @@ -23,19 +23,27 @@ import ( | |||
| 23 | 23 | ||
| 24 | 24 | // EngineInput holds the input for BuildEngine. | |
| 25 | 25 | type EngineInput struct { | |
| 26 | - | Ctx context.Context | |
| 27 | - | Config *config.Config | |
| 28 | - | RuntimeProfile runtimeprofile.Profile | |
| 29 | - | ThemeManager *theme.Manager | |
| 30 | - | ColorResolver port.ColorSchemeResolver | |
| 31 | - | Logger zerolog.Logger | |
| 26 | + | Ctx context.Context | |
| 27 | + | Config *config.Config | |
| 28 | + | RuntimeProfile runtimeprofile.Profile | |
| 29 | + | ThemeManager *theme.Manager | |
| 30 | + | ExternalThemeSource port.ExternalThemeSource | |
| 31 | + | ColorResolver port.ColorSchemeResolver | |
| 32 | + | Logger zerolog.Logger | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | |
| 35 | 36 | func BuildEngine(input EngineInput) (port.Engine, error) { | |
| 36 | 37 | cfg := input.Config | |
| 37 | 38 | systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor()) | |
| 38 | - | systemviewUC := usecase.NewReadSystemviewConfigUseCase(systemviewReader) | |
| 39 | + | systemviewUC := usecase.NewReadSystemviewConfigUseCase( | |
| 40 | + | systemviewReader, | |
| 41 | + | usecase.WithSystemviewResolvedAppearance( | |
| 42 | + | input.ExternalThemeSource, | |
| 43 | + | input.ColorResolver, | |
| 44 | + | prefersDarkProvider(input.ThemeManager), | |
| 45 | + | ), | |
| 46 | + | ) | |
| 39 | 47 | buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) { | |
| 40 | 48 | return func() ([]byte, error) { | |
| 41 | 49 | payload, err := read(input.Ctx) | |
| @@ -120,6 +128,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 120 | 128 | } | |
| 121 | 129 | } | |
| 122 | 130 | ||
| 131 | + | func prefersDarkProvider(manager *theme.Manager) func() bool { | |
| 132 | + | return func() bool { | |
| 133 | + | if manager == nil { | |
| 134 | + | return true | |
| 135 | + | } | |
| 136 | + | return manager.PrefersDark() | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 123 | 140 | func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) { | |
| 124 | 141 | if profile.Engine == "" { | |
| 125 | 142 | return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType) | |
M internal/bootstrap/gui.go +77 -28
| @@ -11,10 +11,12 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | 13 | "github.com/bnema/dumber/internal/application/usecase" | |
| 14 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | |
| 15 | 16 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 19 | + | "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia" | |
| 18 | 20 | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 19 | 21 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 20 | 22 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| @@ -30,11 +32,15 @@ type DatabaseResult struct { | |||
| 30 | 32 | ||
| 31 | 33 | // ParallelInitResult holds the results of parallel initialization phase. | |
| 32 | 34 | type ParallelInitResult struct { | |
| 33 | - | RuntimeProfile runtimeprofile.Profile | |
| 34 | - | ThemeManager *theme.Manager | |
| 35 | - | ColorResolver port.ColorSchemeResolver | |
| 36 | - | AdwaitaDetector *colorscheme.AdwaitaDetector | |
| 37 | - | Duration time.Duration | |
| 35 | + | RuntimeProfile runtimeprofile.Profile | |
| 36 | + | ThemeManager *theme.Manager | |
| 37 | + | ResolvedTheme entity.ResolvedTheme | |
| 38 | + | ResolveThemeUC *usecase.ResolveThemeUseCase | |
| 39 | + | ExternalThemeSource port.ConfigurableExternalThemeSource | |
| 40 | + | ExternalThemeWatcher port.ExternalThemeWatcher | |
| 41 | + | ColorResolver port.ColorSchemeResolver | |
| 42 | + | AdwaitaDetector *colorscheme.AdwaitaDetector | |
| 43 | + | Duration time.Duration | |
| 38 | 44 | } | |
| 39 | 45 | ||
| 40 | 46 | // DeferredInitResult holds results from deferred initialization checks. | |
| @@ -90,8 +96,9 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |||
| 90 | 96 | // Returns the first fatal error encountered, or nil with the results. | |
| 91 | 97 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |
| 92 | 98 | var ( | |
| 93 | - | dirsErr error | |
| 94 | - | wg sync.WaitGroup | |
| 99 | + | dirsErr error | |
| 100 | + | themeErr error | |
| 101 | + | wg sync.WaitGroup | |
| 95 | 102 | ) | |
| 96 | 103 | ||
| 97 | 104 | profile, err := ResolveRuntimeProfile(input.Config) | |
| @@ -122,14 +129,28 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 122 | 129 | dirsErr = resolveWebKitDirs(profile) | |
| 123 | 130 | }() | |
| 124 | 131 | ||
| 125 | - | // Theme manager (CPU-bound, no I/O) | |
| 132 | + | // Theme manager. External theme file reads, when enabled, are non-fatal | |
| 133 | + | // and reported as theme warnings by the usecase. | |
| 134 | + | externalThemeSource := noctalia.NewFileSourceFromConfig(input.Config.Appearance.ExternalTheme) | |
| 135 | + | resolveThemeUC := usecase.NewResolveThemeUseCase(externalThemeSource) | |
| 126 | 136 | var themeManager *theme.Manager | |
| 137 | + | var resolvedTheme entity.ResolvedTheme | |
| 127 | 138 | go func() { | |
| 128 | 139 | defer wg.Done() | |
| 129 | - | themeManager = theme.NewManager( | |
| 130 | - | input.Ctx, &input.Config.Appearance, input.Config.DefaultUIScale, | |
| 131 | - | &input.Config.Workspace.Styling, resolver, | |
| 132 | - | ) | |
| 140 | + | preference := resolver.Resolve() | |
| 141 | + | resolved, err := resolveThemeUC.Execute(input.Ctx, usecase.ResolveThemeInputFromConfig( | |
| 142 | + | &input.Config.Appearance, | |
| 143 | + | input.Config.DefaultUIScale, | |
| 144 | + | &input.Config.Workspace.Styling, | |
| 145 | + | preference, | |
| 146 | + | )) | |
| 147 | + | if err != nil { | |
| 148 | + | themeErr = err | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | LogResolvedTheme(input.Ctx, resolved.Theme) | |
| 152 | + | resolvedTheme = resolved.Theme | |
| 153 | + | themeManager = theme.NewManager(input.Ctx, resolved.Theme) | |
| 133 | 154 | }() | |
| 134 | 155 | ||
| 135 | 156 | wg.Wait() | |
| @@ -139,16 +160,42 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 139 | 160 | if dirsErr != nil { | |
| 140 | 161 | return nil, fmt.Errorf("resolve directories: %w", dirsErr) | |
| 141 | 162 | } | |
| 163 | + | if themeErr != nil { | |
| 164 | + | return nil, fmt.Errorf("resolve theme: %w", themeErr) | |
| 165 | + | } | |
| 142 | 166 | ||
| 143 | 167 | return &ParallelInitResult{ | |
| 144 | - | RuntimeProfile: profile, | |
| 145 | - | ThemeManager: themeManager, | |
| 146 | - | ColorResolver: resolver, | |
| 147 | - | AdwaitaDetector: adwaitaDetector, | |
| 148 | - | Duration: duration, | |
| 168 | + | RuntimeProfile: profile, | |
| 169 | + | ThemeManager: themeManager, | |
| 170 | + | ResolvedTheme: resolvedTheme, | |
| 171 | + | ResolveThemeUC: resolveThemeUC, | |
| 172 | + | ExternalThemeSource: externalThemeSource, | |
| 173 | + | ExternalThemeWatcher: noctalia.NewFileWatcher(), | |
| 174 | + | ColorResolver: resolver, | |
| 175 | + | AdwaitaDetector: adwaitaDetector, | |
| 176 | + | Duration: duration, | |
| 149 | 177 | }, nil | |
| 150 | 178 | } | |
| 151 | 179 | ||
| 180 | + | // LogResolvedTheme writes a compact theme-source summary and non-fatal warnings. | |
| 181 | + | func LogResolvedTheme(ctx context.Context, resolved entity.ResolvedTheme) { | |
| 182 | + | log := logging.FromContext(ctx) | |
| 183 | + | for _, warning := range resolved.Warnings { | |
| 184 | + | log.Warn().Str("field", warning.Field).Msg(warning.Message) | |
| 185 | + | } | |
| 186 | + | event := log.Info(). | |
| 187 | + | Str("theme_source", string(resolved.ThemeSource.Kind)). | |
| 188 | + | Str("color_scheme_source", resolved.ColorSchemeSource). | |
| 189 | + | Bool("prefers_dark", resolved.PrefersDark) | |
| 190 | + | if resolved.ThemeSource.Provider != "" { | |
| 191 | + | event = event.Str("provider", resolved.ThemeSource.Provider) | |
| 192 | + | } | |
| 193 | + | if resolved.ThemeSource.LastGood { | |
| 194 | + | event = event.Bool("last_good", true) | |
| 195 | + | } | |
| 196 | + | event.Msg("theme resolved") | |
| 197 | + | } | |
| 198 | + | ||
| 152 | 199 | // RunDeferredInit runs deferred initialization checks off the critical path. | |
| 153 | 200 | // This includes runtime requirements and media checks. | |
| 154 | 201 | func RunDeferredInit(input DeferredInitInput) DeferredInitResult { | |
| @@ -275,11 +322,12 @@ type ParallelDBEngineResult struct { | |||
| 275 | 322 | ||
| 276 | 323 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | |
| 277 | 324 | type ParallelDBEngineInput struct { | |
| 278 | - | Ctx context.Context | |
| 279 | - | Config *config.Config | |
| 280 | - | RuntimeProfile runtimeprofile.Profile | |
| 281 | - | ThemeManager *theme.Manager | |
| 282 | - | ColorResolver port.ColorSchemeResolver | |
| 325 | + | Ctx context.Context | |
| 326 | + | Config *config.Config | |
| 327 | + | RuntimeProfile runtimeprofile.Profile | |
| 328 | + | ThemeManager *theme.Manager | |
| 329 | + | ExternalThemeSource port.ExternalThemeSource | |
| 330 | + | ColorResolver port.ColorSchemeResolver | |
| 283 | 331 | } | |
| 284 | 332 | ||
| 285 | 333 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | |
| @@ -314,12 +362,13 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, | |||
| 314 | 362 | ||
| 315 | 363 | // Engine on main thread (GTK requirement) | |
| 316 | 364 | engine, err := BuildEngine(EngineInput{ | |
| 317 | - | Ctx: input.Ctx, | |
| 318 | - | Config: input.Config, | |
| 319 | - | RuntimeProfile: input.RuntimeProfile, | |
| 320 | - | ThemeManager: input.ThemeManager, | |
| 321 | - | ColorResolver: input.ColorResolver, | |
| 322 | - | Logger: *log, | |
| 365 | + | Ctx: input.Ctx, | |
| 366 | + | Config: input.Config, | |
| 367 | + | RuntimeProfile: input.RuntimeProfile, | |
| 368 | + | ThemeManager: input.ThemeManager, | |
| 369 | + | ExternalThemeSource: input.ExternalThemeSource, | |
| 370 | + | ColorResolver: input.ColorResolver, | |
| 371 | + | Logger: *log, | |
| 323 | 372 | }) | |
| 324 | 373 | if err != nil { | |
| 325 | 374 | dbRes := <-dbCh | |
M internal/cli/app.go +41 -2
| @@ -13,8 +13,10 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/bootstrap" | |
| 14 | 14 | "github.com/bnema/dumber/internal/cli/styles" | |
| 15 | 15 | "github.com/bnema/dumber/internal/domain/build" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | 17 | "github.com/bnema/dumber/internal/domain/repository" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 19 | + | "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia" | |
| 18 | 20 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 19 | 21 | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | |
| 20 | 22 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| @@ -56,8 +58,9 @@ func NewApp() (*App, error) { | |||
| 56 | 58 | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | - | // Create theme from config | |
| 60 | - | theme := styles.NewTheme(cfg) | |
| 61 | + | // Create theme through the shared resolver. CLI keeps the historical dark default | |
| 62 | + | // unless config explicitly requests a light scheme. | |
| 63 | + | theme := styles.NewThemeFromResolved(resolveCLITheme(cfg)) | |
| 61 | 64 | ||
| 62 | 65 | // Start with a quiet logger. If a browser session is active, we'll attach to it. | |
| 63 | 66 | logLevel := cfg.Logging.Level | |
| @@ -157,6 +160,42 @@ func (a *App) Close() error { | |||
| 157 | 160 | return nil | |
| 158 | 161 | } | |
| 159 | 162 | ||
| 163 | + | func resolveCLITheme(cfg *config.Config) entity.ResolvedTheme { | |
| 164 | + | colorScheme := "" | |
| 165 | + | if cfg != nil { | |
| 166 | + | colorScheme = cfg.Appearance.ColorScheme | |
| 167 | + | } | |
| 168 | + | preference := usecase.ColorSchemePreferenceFromConfig(colorScheme, port.ColorSchemePreference{ | |
| 169 | + | PrefersDark: true, | |
| 170 | + | Source: "cli", | |
| 171 | + | }) | |
| 172 | + | ||
| 173 | + | var appearance *entity.AppearanceConfig | |
| 174 | + | var styling *entity.WorkspaceStylingConfig | |
| 175 | + | var uiScale float64 | |
| 176 | + | if cfg != nil { | |
| 177 | + | appearance = &cfg.Appearance | |
| 178 | + | styling = &cfg.Workspace.Styling | |
| 179 | + | uiScale = cfg.DefaultUIScale | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | externalThemeSource := noctalia.NewFileSource(false, "") | |
| 183 | + | if cfg != nil { | |
| 184 | + | externalThemeSource.Configure(cfg.Appearance.ExternalTheme) | |
| 185 | + | } | |
| 186 | + | uc := usecase.NewResolveThemeUseCase(externalThemeSource) | |
| 187 | + | out, err := uc.Execute(context.Background(), usecase.ResolveThemeInputFromConfig( | |
| 188 | + | appearance, | |
| 189 | + | uiScale, | |
| 190 | + | styling, | |
| 191 | + | preference, | |
| 192 | + | )) | |
| 193 | + | if err != nil { | |
| 194 | + | return entity.ResolvedTheme{ActivePalette: entity.DefaultDarkPalette()} | |
| 195 | + | } | |
| 196 | + | return out.Theme | |
| 197 | + | } | |
| 198 | + | ||
| 160 | 199 | // Ctx returns the application context with logger. | |
| 161 | 200 | func (a *App) Ctx() context.Context { | |
| 162 | 201 | return a.ctx | |
A internal/cli/app_theme_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestResolveCLIThemePreservesDarkDefault(t *testing.T) { | |
| 14 | + | cfg := config.DefaultConfig() | |
| 15 | + | cfg.Appearance.ColorScheme = "default" | |
| 16 | + | ||
| 17 | + | resolved := resolveCLITheme(cfg) | |
| 18 | + | ||
| 19 | + | require.True(t, resolved.PrefersDark) | |
| 20 | + | require.Equal(t, cfg.Appearance.DarkPalette.Background, resolved.ActivePalette.Background) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestResolveCLIThemeHonorsExplicitLightPreference(t *testing.T) { | |
| 24 | + | cfg := config.DefaultConfig() | |
| 25 | + | cfg.Appearance.ColorScheme = "prefer-light" | |
| 26 | + | ||
| 27 | + | resolved := resolveCLITheme(cfg) | |
| 28 | + | ||
| 29 | + | require.False(t, resolved.PrefersDark) | |
| 30 | + | require.Equal(t, cfg.Appearance.LightPalette.Background, resolved.ActivePalette.Background) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestResolveCLIThemeUsesEnabledExternalTheme(t *testing.T) { | |
| 34 | + | path := filepath.Join(t.TempDir(), "theme.json") | |
| 35 | + | require.NoError(t, os.WriteFile(path, []byte(`{ | |
| 36 | + | "light":{"background":"#ffffff","accent":"#112233"}, | |
| 37 | + | "dark":{"background":"#000000","accent":"#445566"} | |
| 38 | + | }`), 0o600)) | |
| 39 | + | cfg := config.DefaultConfig() | |
| 40 | + | cfg.Appearance.ExternalTheme.Enabled = true | |
| 41 | + | cfg.Appearance.ExternalTheme.Provider = "noctalia" | |
| 42 | + | cfg.Appearance.ExternalTheme.Format = "dumber-json" | |
| 43 | + | cfg.Appearance.ExternalTheme.Path = path | |
| 44 | + | ||
| 45 | + | resolved := resolveCLITheme(cfg) | |
| 46 | + | ||
| 47 | + | require.True(t, resolved.PrefersDark) | |
| 48 | + | require.Equal(t, "#000000", resolved.ActivePalette.Background) | |
| 49 | + | require.Equal(t, "#445566", resolved.ActivePalette.Accent) | |
| 50 | + | require.Equal(t, "external", string(resolved.ThemeSource.Kind)) | |
| 51 | + | require.Equal(t, "noctalia", resolved.ThemeSource.Provider) | |
| 52 | + | } | |
M internal/cli/styles/theme.go +13 -0
| @@ -4,6 +4,7 @@ package styles | |||
| 4 | 4 | import ( | |
| 5 | 5 | "github.com/charmbracelet/lipgloss" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -81,6 +82,18 @@ func NewTheme(cfg *config.Config) *Theme { | |||
| 81 | 82 | return NewThemeFromPalette(p) | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 85 | + | // NewThemeFromResolved creates a Theme from the active resolved palette. | |
| 86 | + | func NewThemeFromResolved(resolved entity.ResolvedTheme) *Theme { | |
| 87 | + | p := resolved.ActivePalette | |
| 88 | + | if p == (entity.ColorPalette{}) { | |
| 89 | + | p = resolved.DarkPalette | |
| 90 | + | } | |
| 91 | + | if p == (entity.ColorPalette{}) { | |
| 92 | + | p = DefaultDarkPalette() | |
| 93 | + | } | |
| 94 | + | return NewThemeFromPalette(p) | |
| 95 | + | } | |
| 96 | + | ||
| 84 | 97 | // NewThemeFromPalette creates a Theme from a ColorPalette. | |
| 85 | 98 | func NewThemeFromPalette(p config.ColorPalette) *Theme { | |
| 86 | 99 | t := &Theme{ | |
A internal/cli/styles/theme_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package styles_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/charmbracelet/lipgloss" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestNewThemeFromResolvedUsesActivePalette(t *testing.T) { | |
| 14 | + | resolved := entity.ResolvedTheme{ | |
| 15 | + | ActivePalette: entity.ColorPalette{ | |
| 16 | + | Background: "#010101", | |
| 17 | + | Surface: "#020202", | |
| 18 | + | SurfaceVariant: "#030303", | |
| 19 | + | Text: "#fefefe", | |
| 20 | + | Muted: "#999999", | |
| 21 | + | Accent: "#abcdef", | |
| 22 | + | Border: "#444444", | |
| 23 | + | }, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | theme := styles.NewThemeFromResolved(resolved) | |
| 27 | + | ||
| 28 | + | require.Equal(t, lipgloss.Color("#010101"), theme.Background) | |
| 29 | + | require.Equal(t, lipgloss.Color("#abcdef"), theme.Accent) | |
| 30 | + | require.Equal(t, lipgloss.Color("#abcdef"), theme.Success) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestNewThemeFromResolvedFallsBackToDarkPalette(t *testing.T) { | |
| 34 | + | resolved := entity.ResolvedTheme{ | |
| 35 | + | DarkPalette: entity.ColorPalette{ | |
| 36 | + | Background: "#111111", | |
| 37 | + | Surface: "#222222", | |
| 38 | + | SurfaceVariant: "#333333", | |
| 39 | + | Text: "#eeeeee", | |
| 40 | + | Muted: "#888888", | |
| 41 | + | Accent: "#fedcba", | |
| 42 | + | Border: "#444444", | |
| 43 | + | }, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | theme := styles.NewThemeFromResolved(resolved) | |
| 47 | + | ||
| 48 | + | require.Equal(t, lipgloss.Color("#111111"), theme.Background) | |
| 49 | + | require.Equal(t, lipgloss.Color("#fedcba"), theme.Accent) | |
| 50 | + | } | |
M internal/domain/entity/config_types.go +17 -8
| @@ -6,14 +6,23 @@ package entity | |||
| 6 | 6 | ||
| 7 | 7 | // AppearanceConfig holds visual appearance settings. | |
| 8 | 8 | type AppearanceConfig struct { | |
| 9 | - | SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font" json:"sans_font"` | |
| 10 | - | SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font" json:"serif_font"` | |
| 11 | - | MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font" json:"monospace_font"` | |
| 12 | - | GtkFont string `mapstructure:"gtk_font" yaml:"gtk_font" toml:"gtk_font" json:"gtk_font"` | |
| 13 | - | DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size" json:"default_font_size"` | |
| 14 | - | LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette" json:"light_palette"` | |
| 15 | - | DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette" json:"dark_palette"` | |
| 16 | - | ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme" json:"color_scheme"` | |
| 9 | + | SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font" json:"sans_font"` | |
| 10 | + | SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font" json:"serif_font"` | |
| 11 | + | MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font" json:"monospace_font"` | |
| 12 | + | GtkFont string `mapstructure:"gtk_font" yaml:"gtk_font" toml:"gtk_font" json:"gtk_font"` | |
| 13 | + | DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size" json:"default_font_size"` //nolint:lll // struct tags must stay on one line | |
| 14 | + | LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette" json:"light_palette"` | |
| 15 | + | DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette" json:"dark_palette"` | |
| 16 | + | ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme" json:"color_scheme"` | |
| 17 | + | ExternalTheme ExternalThemeConfig `mapstructure:"external_theme" yaml:"external_theme" toml:"external_theme" json:"external_theme"` | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // ExternalThemeConfig controls optional external theme loading. | |
| 21 | + | type ExternalThemeConfig struct { | |
| 22 | + | Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled" json:"enabled"` | |
| 23 | + | Provider string `mapstructure:"provider" yaml:"provider" toml:"provider" json:"provider"` | |
| 24 | + | Format string `mapstructure:"format" yaml:"format" toml:"format" json:"format"` | |
| 25 | + | Path string `mapstructure:"path" yaml:"path" toml:"path" json:"path"` | |
| 17 | 26 | } | |
| 18 | 27 | ||
| 19 | 28 | // ColorPalette defines the color scheme for a theme variant (light/dark). | |
A internal/domain/entity/theme.go +299 -0
| @@ -0,0 +1,299 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import "regexp" | |
| 4 | + | ||
| 5 | + | // ThemeSourceKind describes where the resolved palette colors originated. | |
| 6 | + | type ThemeSourceKind string | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | // ThemeSourceDefault indicates palette colors come from built-in defaults. | |
| 10 | + | ThemeSourceDefault ThemeSourceKind = "default" | |
| 11 | + | // ThemeSourceConfig indicates palette colors come from user configuration. | |
| 12 | + | ThemeSourceConfig ThemeSourceKind = "config" | |
| 13 | + | // ThemeSourceExternal indicates palette colors come from an external theme source. | |
| 14 | + | ThemeSourceExternal ThemeSourceKind = "external" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // ThemeSourceMetadata describes the palette source used for a resolved theme. | |
| 18 | + | type ThemeSourceMetadata struct { | |
| 19 | + | Kind ThemeSourceKind | |
| 20 | + | Provider string | |
| 21 | + | LastGood bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // ThemeWarning represents a non-fatal issue encountered during theme resolution. | |
| 25 | + | type ThemeWarning struct { | |
| 26 | + | Field string | |
| 27 | + | Message string | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // ThemeFonts holds the resolved font families and default size. | |
| 31 | + | type ThemeFonts struct { | |
| 32 | + | SansFont string | |
| 33 | + | SerifFont string | |
| 34 | + | MonospaceFont string | |
| 35 | + | GtkFont string | |
| 36 | + | DefaultSize int | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // ThemeModeColors holds mode indicator colors. | |
| 40 | + | // All values must be CSS-safe hex colors (e.g., "#4A90E2"). | |
| 41 | + | type ThemeModeColors struct { | |
| 42 | + | PaneMode string | |
| 43 | + | TabMode string | |
| 44 | + | SessionMode string | |
| 45 | + | ResizeMode string | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ResolvedThemePalette is a CSS-safe hex color palette. | |
| 49 | + | // Alias for ColorPalette with the contract that all color strings | |
| 50 | + | // are valid CSS hex colors (validated during resolution). | |
| 51 | + | type ResolvedThemePalette = ColorPalette | |
| 52 | + | ||
| 53 | + | // ExternalTheme represents palette data from an external source (e.g., Noctalia). | |
| 54 | + | // External themes override palette colors only; fonts, UI scale, and mode colors stay in Dumber config. | |
| 55 | + | // It must not depend on GTK, WebKit, CEF, lipgloss, fsnotify, Viper, or infrastructure config. | |
| 56 | + | type ExternalTheme struct { | |
| 57 | + | Name string | |
| 58 | + | Provider string | |
| 59 | + | ||
| 60 | + | // LightPalette and DarkPalette may contain partial overrides. | |
| 61 | + | // Nil palette pointers are malformed; empty fields mean "use the resolved config/default field". | |
| 62 | + | LightPalette *ColorPalette | |
| 63 | + | DarkPalette *ColorPalette | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // ResolvedTheme is the final resolved theme output. | |
| 67 | + | // It contains palettes, fonts, colors, scale, and metadata. | |
| 68 | + | // All palette colors are guaranteed to be CSS-safe hex colors. | |
| 69 | + | // CSS generation is NOT part of this core type — it is a separate adapter concern. | |
| 70 | + | type ResolvedTheme struct { | |
| 71 | + | LightPalette ResolvedThemePalette | |
| 72 | + | DarkPalette ResolvedThemePalette | |
| 73 | + | ActivePalette ResolvedThemePalette | |
| 74 | + | PrefersDark bool | |
| 75 | + | ColorSchemeSource string | |
| 76 | + | ThemeSource ThemeSourceMetadata | |
| 77 | + | Fonts ThemeFonts | |
| 78 | + | UIScale float64 | |
| 79 | + | ModeColors ThemeModeColors | |
| 80 | + | Warnings []ThemeWarning | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // hexColorRegex matches valid CSS-safe #RRGGBB hex colors (strict). | |
| 84 | + | var hexColorRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 85 | + | ||
| 86 | + | // IsValidHex returns true if s is a valid CSS-safe hex color (#RRGGBB format only). | |
| 87 | + | func IsValidHex(s string) bool { | |
| 88 | + | return hexColorRegex.MatchString(s) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // DefaultThemeFonts returns Dumber's appearance font defaults. | |
| 92 | + | func DefaultThemeFonts() ThemeFonts { | |
| 93 | + | return ThemeFonts{ | |
| 94 | + | SansFont: "Fira Sans", | |
| 95 | + | SerifFont: "Fira Sans", | |
| 96 | + | MonospaceFont: "Fira Code", | |
| 97 | + | GtkFont: "Adwaita Sans", | |
| 98 | + | DefaultSize: 16, | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // DefaultThemeModeColors returns the default mode indicator colors. | |
| 103 | + | // These match the existing default mode colors from config/infrastructure. | |
| 104 | + | func DefaultThemeModeColors() ThemeModeColors { | |
| 105 | + | return ThemeModeColors{ | |
| 106 | + | PaneMode: "#4A90E2", | |
| 107 | + | TabMode: "#FFA500", | |
| 108 | + | SessionMode: "#9B59B6", | |
| 109 | + | ResizeMode: "#00D4AA", | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // DefaultDarkPalette returns the built-in dark theme palette. | |
| 114 | + | // These values mirror the config default dark palette. | |
| 115 | + | func DefaultDarkPalette() ColorPalette { | |
| 116 | + | return ColorPalette{ | |
| 117 | + | Background: "#0a0a0b", | |
| 118 | + | Surface: "#18181b", | |
| 119 | + | SurfaceVariant: "#27272a", | |
| 120 | + | Text: "#fafafa", | |
| 121 | + | Muted: "#a1a1aa", | |
| 122 | + | Accent: "#4ade80", | |
| 123 | + | Border: "#3f3f46", | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // DefaultLightPalette returns the built-in light theme palette. | |
| 128 | + | // These values mirror the config default light palette. | |
| 129 | + | func DefaultLightPalette() ColorPalette { | |
| 130 | + | return ColorPalette{ | |
| 131 | + | Background: "#fafafa", | |
| 132 | + | Surface: "#f4f4f5", | |
| 133 | + | SurfaceVariant: "#e4e4e7", | |
| 134 | + | Text: "#18181b", | |
| 135 | + | Muted: "#71717a", | |
| 136 | + | Accent: "#22c55e", | |
| 137 | + | Border: "#d4d4d8", | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // ValidatePaletteHex checks every field in the palette is a valid #RRGGBB hex color. | |
| 142 | + | // Returns a slice of warnings for each invalid field. Empty slice means valid. | |
| 143 | + | func ValidatePaletteHex(p *ColorPalette, prefix string) []ThemeWarning { | |
| 144 | + | if p == nil { | |
| 145 | + | return []ThemeWarning{{Field: prefix, Message: "palette is nil"}} | |
| 146 | + | } | |
| 147 | + | var warnings []ThemeWarning | |
| 148 | + | checkRequiredHex(&warnings, prefix, "background", p.Background) | |
| 149 | + | checkRequiredHex(&warnings, prefix, "surface", p.Surface) | |
| 150 | + | checkRequiredHex(&warnings, prefix, "surface_variant", p.SurfaceVariant) | |
| 151 | + | checkRequiredHex(&warnings, prefix, "text", p.Text) | |
| 152 | + | checkRequiredHex(&warnings, prefix, "muted", p.Muted) | |
| 153 | + | checkRequiredHex(&warnings, prefix, "accent", p.Accent) | |
| 154 | + | checkRequiredHex(&warnings, prefix, "border", p.Border) | |
| 155 | + | return warnings | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // ValidatePaletteOverrideHex checks non-empty override fields are valid #RRGGBB hex colors. | |
| 159 | + | // Empty fields are accepted and mean "inherit from the fallback palette". | |
| 160 | + | func ValidatePaletteOverrideHex(p *ColorPalette, prefix string) []ThemeWarning { | |
| 161 | + | if p == nil { | |
| 162 | + | return []ThemeWarning{{Field: prefix, Message: "palette is nil"}} | |
| 163 | + | } | |
| 164 | + | var warnings []ThemeWarning | |
| 165 | + | checkOptionalHex(&warnings, prefix, "background", p.Background) | |
| 166 | + | checkOptionalHex(&warnings, prefix, "surface", p.Surface) | |
| 167 | + | checkOptionalHex(&warnings, prefix, "surface_variant", p.SurfaceVariant) | |
| 168 | + | checkOptionalHex(&warnings, prefix, "text", p.Text) | |
| 169 | + | checkOptionalHex(&warnings, prefix, "muted", p.Muted) | |
| 170 | + | checkOptionalHex(&warnings, prefix, "accent", p.Accent) | |
| 171 | + | checkOptionalHex(&warnings, prefix, "border", p.Border) | |
| 172 | + | return warnings | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func checkRequiredHex(warnings *[]ThemeWarning, prefix, field, value string) { | |
| 176 | + | if !IsValidHex(value) { | |
| 177 | + | *warnings = append(*warnings, ThemeWarning{ | |
| 178 | + | Field: prefix + "." + field, | |
| 179 | + | Message: "not a valid CSS-safe hex color (#RRGGBB)", | |
| 180 | + | }) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func checkOptionalHex(warnings *[]ThemeWarning, prefix, field, value string) { | |
| 185 | + | if value != "" && !IsValidHex(value) { | |
| 186 | + | *warnings = append(*warnings, ThemeWarning{ | |
| 187 | + | Field: prefix + "." + field, | |
| 188 | + | Message: "not a valid CSS-safe hex color (#RRGGBB)", | |
| 189 | + | }) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // IsPaletteValid returns true if every field in the palette is a valid #RRGGBB hex color. | |
| 194 | + | func IsPaletteValid(p *ColorPalette) bool { | |
| 195 | + | return len(ValidatePaletteHex(p, "palette")) == 0 | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // IsPaletteOverrideValid returns true if every non-empty field in the palette is a valid #RRGGBB hex color. | |
| 199 | + | func IsPaletteOverrideValid(p *ColorPalette) bool { | |
| 200 | + | return len(ValidatePaletteOverrideHex(p, "palette")) == 0 | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // MergePalette returns a new ColorPalette where empty fields in src are filled from defaults. | |
| 204 | + | // Both src and defaults may be nil. Returns a copy, never mutates inputs. | |
| 205 | + | func MergePalette(src, defaults *ColorPalette) ColorPalette { | |
| 206 | + | var d ColorPalette | |
| 207 | + | if defaults != nil { | |
| 208 | + | d = *defaults | |
| 209 | + | } | |
| 210 | + | if src == nil { | |
| 211 | + | return d | |
| 212 | + | } | |
| 213 | + | result := *src | |
| 214 | + | if result.Background == "" { | |
| 215 | + | result.Background = d.Background | |
| 216 | + | } | |
| 217 | + | if result.Surface == "" { | |
| 218 | + | result.Surface = d.Surface | |
| 219 | + | } | |
| 220 | + | if result.SurfaceVariant == "" { | |
| 221 | + | result.SurfaceVariant = d.SurfaceVariant | |
| 222 | + | } | |
| 223 | + | if result.Text == "" { | |
| 224 | + | result.Text = d.Text | |
| 225 | + | } | |
| 226 | + | if result.Muted == "" { | |
| 227 | + | result.Muted = d.Muted | |
| 228 | + | } | |
| 229 | + | if result.Accent == "" { | |
| 230 | + | result.Accent = d.Accent | |
| 231 | + | } | |
| 232 | + | if result.Border == "" { | |
| 233 | + | result.Border = d.Border | |
| 234 | + | } | |
| 235 | + | return result | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // MergeFonts returns a ThemeFonts where zero/empty fields in src are filled from defaults. | |
| 239 | + | func MergeFonts(src *ThemeFonts, defaults ThemeFonts) ThemeFonts { | |
| 240 | + | if src == nil { | |
| 241 | + | return defaults | |
| 242 | + | } | |
| 243 | + | result := *src | |
| 244 | + | if result.SansFont == "" { | |
| 245 | + | result.SansFont = defaults.SansFont | |
| 246 | + | } | |
| 247 | + | if result.SerifFont == "" { | |
| 248 | + | result.SerifFont = defaults.SerifFont | |
| 249 | + | } | |
| 250 | + | if result.MonospaceFont == "" { | |
| 251 | + | result.MonospaceFont = defaults.MonospaceFont | |
| 252 | + | } | |
| 253 | + | if result.GtkFont == "" { | |
| 254 | + | result.GtkFont = defaults.GtkFont | |
| 255 | + | } | |
| 256 | + | if result.DefaultSize <= 0 { | |
| 257 | + | result.DefaultSize = defaults.DefaultSize | |
| 258 | + | } | |
| 259 | + | return result | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // ValidateModeColorsHex checks every mode color field is a valid #RRGGBB hex color. | |
| 263 | + | func ValidateModeColorsHex(colors *ThemeModeColors, prefix string) []ThemeWarning { | |
| 264 | + | if colors == nil { | |
| 265 | + | return []ThemeWarning{{Field: prefix, Message: "mode colors are nil"}} | |
| 266 | + | } | |
| 267 | + | var warnings []ThemeWarning | |
| 268 | + | checkRequiredHex(&warnings, prefix, "pane_mode", colors.PaneMode) | |
| 269 | + | checkRequiredHex(&warnings, prefix, "tab_mode", colors.TabMode) | |
| 270 | + | checkRequiredHex(&warnings, prefix, "session_mode", colors.SessionMode) | |
| 271 | + | checkRequiredHex(&warnings, prefix, "resize_mode", colors.ResizeMode) | |
| 272 | + | return warnings | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // IsModeColorsValid returns true when every mode color is a valid #RRGGBB hex color. | |
| 276 | + | func IsModeColorsValid(colors *ThemeModeColors) bool { | |
| 277 | + | return len(ValidateModeColorsHex(colors, "mode_colors")) == 0 | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // MergeModeColors returns a ThemeModeColors where empty fields in src are filled from defaults. | |
| 281 | + | func MergeModeColors(src *ThemeModeColors, defaults ThemeModeColors) ThemeModeColors { | |
| 282 | + | if src == nil { | |
| 283 | + | return defaults | |
| 284 | + | } | |
| 285 | + | result := *src | |
| 286 | + | if result.PaneMode == "" { | |
| 287 | + | result.PaneMode = defaults.PaneMode | |
| 288 | + | } | |
| 289 | + | if result.TabMode == "" { | |
| 290 | + | result.TabMode = defaults.TabMode | |
| 291 | + | } | |
| 292 | + | if result.SessionMode == "" { | |
| 293 | + | result.SessionMode = defaults.SessionMode | |
| 294 | + | } | |
| 295 | + | if result.ResizeMode == "" { | |
| 296 | + | result.ResizeMode = defaults.ResizeMode | |
| 297 | + | } | |
| 298 | + | return result | |
| 299 | + | } | |
A internal/domain/entity/theme_test.go +528 -0
| @@ -0,0 +1,528 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | // TestValidateHexColor_ValidHex tests that standard hex colors are recognized. | |
| 8 | + | func TestValidateHexColor_ValidHex(t *testing.T) { | |
| 9 | + | valid := []string{ | |
| 10 | + | "#000000", | |
| 11 | + | "#ffffff", | |
| 12 | + | "#0a0a0b", | |
| 13 | + | "#4ade80", | |
| 14 | + | "#4A90E2", | |
| 15 | + | "#FFA500", | |
| 16 | + | "#9B59B6", | |
| 17 | + | "#00D4AA", | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | for _, hex := range valid { | |
| 21 | + | if !IsValidHex(hex) { | |
| 22 | + | t.Errorf("expected %q to be a valid hex color", hex) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // TestValidateHexColor_InvalidHex tests that non-hex or malformed strings are rejected. | |
| 28 | + | func TestValidateHexColor_InvalidHex(t *testing.T) { | |
| 29 | + | invalid := []string{ | |
| 30 | + | "", | |
| 31 | + | "rgb(0,0,0)", | |
| 32 | + | "#FFF", // 3-char shorthand | |
| 33 | + | "#FFFFFFFF", // 8-char with alpha | |
| 34 | + | "#GGGGGG", // non-hex chars | |
| 35 | + | "red", | |
| 36 | + | "#12", // too short | |
| 37 | + | "#1234567", // odd length | |
| 38 | + | " #ffffff ", // whitespace | |
| 39 | + | "#ffffff ", // trailing space | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | for _, hex := range invalid { | |
| 43 | + | if IsValidHex(hex) { | |
| 44 | + | t.Errorf("expected %q to be invalid hex", hex) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // TestDefaultThemeFonts_ReturnsSaneDefaults verifies the default font configuration has non-empty, | |
| 50 | + | // sensible values. | |
| 51 | + | func TestDefaultThemeFonts_ReturnsSaneDefaults(t *testing.T) { | |
| 52 | + | fonts := DefaultThemeFonts() | |
| 53 | + | ||
| 54 | + | if fonts.SansFont != "Fira Sans" { | |
| 55 | + | t.Errorf("expected SansFont Fira Sans, got %q", fonts.SansFont) | |
| 56 | + | } | |
| 57 | + | if fonts.SerifFont != "Fira Sans" { | |
| 58 | + | t.Errorf("expected SerifFont Fira Sans, got %q", fonts.SerifFont) | |
| 59 | + | } | |
| 60 | + | if fonts.MonospaceFont != "Fira Code" { | |
| 61 | + | t.Errorf("expected MonospaceFont Fira Code, got %q", fonts.MonospaceFont) | |
| 62 | + | } | |
| 63 | + | if fonts.GtkFont != "Adwaita Sans" { | |
| 64 | + | t.Errorf("expected GtkFont Adwaita Sans, got %q", fonts.GtkFont) | |
| 65 | + | } | |
| 66 | + | if fonts.DefaultSize != 16 { | |
| 67 | + | t.Errorf("expected DefaultSize 16, got %d", fonts.DefaultSize) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // TestResolvedThemePalette_MustBeCSSSafeHex ensures that every color field | |
| 72 | + | // in a ResolvedThemePalette passes hex validation. | |
| 73 | + | // This is the locked contract: all palette colors in ResolvedTheme are CSS-safe hex. | |
| 74 | + | func TestResolvedThemePalette_MustBeCSSSafeHex(t *testing.T) { | |
| 75 | + | // A well-formed palette that SHOULD be produced by resolution. | |
| 76 | + | validPalette := ResolvedThemePalette{ | |
| 77 | + | Background: "#0a0a0b", | |
| 78 | + | Surface: "#1a1a1b", | |
| 79 | + | SurfaceVariant: "#2d2d2d", | |
| 80 | + | Text: "#ffffff", | |
| 81 | + | Muted: "#909090", | |
| 82 | + | Accent: "#4ade80", | |
| 83 | + | Border: "#333333", | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | fields := map[string]string{ | |
| 87 | + | "Background": validPalette.Background, | |
| 88 | + | "Surface": validPalette.Surface, | |
| 89 | + | "SurfaceVariant": validPalette.SurfaceVariant, | |
| 90 | + | "Text": validPalette.Text, | |
| 91 | + | "Muted": validPalette.Muted, | |
| 92 | + | "Accent": validPalette.Accent, | |
| 93 | + | "Border": validPalette.Border, | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | for name, value := range fields { | |
| 97 | + | if !IsValidHex(value) { | |
| 98 | + | t.Errorf("ResolvedThemePalette.%s = %q is not a valid CSS-safe hex color", name, value) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // TestResolvedThemePalette_RejectsInvalidColors verifies that any non-hex color | |
| 104 | + | // in a palette is detectable by a validation helper. | |
| 105 | + | func TestResolvedThemePalette_RejectsInvalidColors(t *testing.T) { | |
| 106 | + | invalidFields := []struct { | |
| 107 | + | name string | |
| 108 | + | value string | |
| 109 | + | }{ | |
| 110 | + | {"Background", "rgb(0,0,0)"}, | |
| 111 | + | {"Background", "#F"}, | |
| 112 | + | {"Surface", "white"}, | |
| 113 | + | {"Text", "#GGGGGG"}, | |
| 114 | + | {"Accent", "hsl(0,0%,0%)"}, | |
| 115 | + | {"Border", ""}, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | for _, tc := range invalidFields { | |
| 119 | + | if IsValidHex(tc.value) { | |
| 120 | + | t.Errorf("field %q value %q should be rejected as invalid hex", tc.name, tc.value) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // TestThemeModeColors_DefaultsAreCSSSafe ensures the default mode colors are valid hex. | |
| 126 | + | func TestThemeModeColors_DefaultsAreCSSSafe(t *testing.T) { | |
| 127 | + | // Per the existing ui/theme palette: defaults are #4A90E2, #FFA500, #9B59B6, #00D4AA | |
| 128 | + | defaults := ThemeModeColors{ | |
| 129 | + | PaneMode: "#4A90E2", | |
| 130 | + | TabMode: "#FFA500", | |
| 131 | + | SessionMode: "#9B59B6", | |
| 132 | + | ResizeMode: "#00D4AA", | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | fields := map[string]string{ | |
| 136 | + | "PaneMode": defaults.PaneMode, | |
| 137 | + | "TabMode": defaults.TabMode, | |
| 138 | + | "SessionMode": defaults.SessionMode, | |
| 139 | + | "ResizeMode": defaults.ResizeMode, | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | for name, value := range fields { | |
| 143 | + | if !IsValidHex(value) { | |
| 144 | + | t.Errorf("ThemeModeColors.%s = %q is not a valid CSS-safe hex color", name, value) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // TestResolvedTheme_StructFieldsExist is a compile-time guard and field-inspection test | |
| 150 | + | // to verify that the ResolvedTheme struct carries all required fields from the locked contract. | |
| 151 | + | func TestResolvedTheme_StructFieldsExist(t *testing.T) { | |
| 152 | + | rt := ResolvedTheme{ | |
| 153 | + | LightPalette: ResolvedThemePalette{ | |
| 154 | + | Background: "#0a0a0b", | |
| 155 | + | Surface: "#1a1a1b", | |
| 156 | + | }, | |
| 157 | + | DarkPalette: ResolvedThemePalette{ | |
| 158 | + | Background: "#fafafa", | |
| 159 | + | Surface: "#ffffff", | |
| 160 | + | }, | |
| 161 | + | ActivePalette: ResolvedThemePalette{ | |
| 162 | + | Background: "#0a0a0b", | |
| 163 | + | }, | |
| 164 | + | PrefersDark: true, | |
| 165 | + | ColorSchemeSource: "adwaita", | |
| 166 | + | ThemeSource: ThemeSourceMetadata{Kind: ThemeSourceConfig}, | |
| 167 | + | Fonts: DefaultThemeFonts(), | |
| 168 | + | UIScale: 1.0, | |
| 169 | + | ModeColors: ThemeModeColors{ | |
| 170 | + | PaneMode: "#4A90E2", | |
| 171 | + | TabMode: "#FFA500", | |
| 172 | + | SessionMode: "#9B59B6", | |
| 173 | + | ResizeMode: "#00D4AA", | |
| 174 | + | }, | |
| 175 | + | Warnings: []ThemeWarning{ | |
| 176 | + | {Field: "external", Message: "malformed external theme, using last-good"}, | |
| 177 | + | }, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // Verify the struct can be constructed and fields are accessible. | |
| 181 | + | if !rt.PrefersDark { | |
| 182 | + | t.Error("expected PrefersDark to be true") | |
| 183 | + | } | |
| 184 | + | if rt.ColorSchemeSource != "adwaita" { | |
| 185 | + | t.Errorf("expected ColorSchemeSource 'adwaita', got %q", rt.ColorSchemeSource) | |
| 186 | + | } | |
| 187 | + | if rt.ThemeSource.Kind != ThemeSourceConfig { | |
| 188 | + | t.Errorf("expected ThemeSource.Kind 'config', got %q", rt.ThemeSource.Kind) | |
| 189 | + | } | |
| 190 | + | if rt.UIScale != 1.0 { | |
| 191 | + | t.Errorf("expected UIScale 1.0, got %f", rt.UIScale) | |
| 192 | + | } | |
| 193 | + | if len(rt.Warnings) != 1 { | |
| 194 | + | t.Errorf("expected 1 warning, got %d", len(rt.Warnings)) | |
| 195 | + | } | |
| 196 | + | if rt.Fonts.DefaultSize != 16 { | |
| 197 | + | t.Errorf("expected DefaultSize 16, got %d", rt.Fonts.DefaultSize) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // TestThemeSourceKind_ConstantsAreDistinct verifies the ThemeSourceKind constants | |
| 202 | + | // have unique values so that source metadata comparisons work correctly. | |
| 203 | + | func TestThemeSourceKind_ConstantsAreDistinct(t *testing.T) { | |
| 204 | + | kinds := map[ThemeSourceKind]bool{ | |
| 205 | + | ThemeSourceDefault: true, | |
| 206 | + | ThemeSourceConfig: true, | |
| 207 | + | ThemeSourceExternal: true, | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | if len(kinds) != 3 { | |
| 211 | + | t.Error("ThemeSourceKind constants must be distinct") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | if ThemeSourceDefault == ThemeSourceExternal { | |
| 215 | + | t.Error("ThemeSourceDefault must not equal ThemeSourceExternal") | |
| 216 | + | } | |
| 217 | + | if ThemeSourceDefault == ThemeSourceConfig { | |
| 218 | + | t.Error("ThemeSourceDefault must not equal ThemeSourceConfig") | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // TestThemeWarning_FieldsExist verifies ThemeWarning carries the expected shape. | |
| 223 | + | func TestThemeWarning_FieldsExist(t *testing.T) { | |
| 224 | + | w := ThemeWarning{ | |
| 225 | + | Field: "light_palette.background", | |
| 226 | + | Message: "not a valid hex color", | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | if w.Field == "" { | |
| 230 | + | t.Error("expected ThemeWarning.Field to be non-empty") | |
| 231 | + | } | |
| 232 | + | if w.Message == "" { | |
| 233 | + | t.Error("expected ThemeWarning.Message to be non-empty") | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // TestExternalTheme_IsDomainPure verifies ExternalTheme can be constructed without any | |
| 238 | + | // infrastructure imports (this is a compile-time contract via package structure). | |
| 239 | + | func TestExternalTheme_IsDomainPure(t *testing.T) { | |
| 240 | + | light := ColorPalette{ | |
| 241 | + | Background: "#0a0a0b", | |
| 242 | + | Surface: "#1a1a1b", | |
| 243 | + | } | |
| 244 | + | dark := ColorPalette{ | |
| 245 | + | Background: "#fafafa", | |
| 246 | + | Surface: "#ffffff", | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | ext := ExternalTheme{ | |
| 250 | + | Name: "Noctalia Dark", | |
| 251 | + | Provider: "noctalia", | |
| 252 | + | LightPalette: &light, | |
| 253 | + | DarkPalette: &dark, | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | if ext.Name != "Noctalia Dark" { | |
| 257 | + | t.Errorf("expected Name 'Noctalia Dark', got %q", ext.Name) | |
| 258 | + | } | |
| 259 | + | if ext.Provider != "noctalia" { | |
| 260 | + | t.Errorf("expected Provider 'noctalia', got %q", ext.Provider) | |
| 261 | + | } | |
| 262 | + | if ext.LightPalette == nil || ext.DarkPalette == nil { | |
| 263 | + | t.Error("expected non-nil palettes") | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // TestDefaultThemeFonts_AllFieldsAreSet ensures no zero-value fields in the defaults. | |
| 268 | + | func TestDefaultThemeFonts_AllFieldsAreSet(t *testing.T) { | |
| 269 | + | fonts := DefaultThemeFonts() | |
| 270 | + | ||
| 271 | + | // Check that all string fields are non-empty | |
| 272 | + | if fonts.SansFont == "" { | |
| 273 | + | t.Error("SansFont should have a default value") | |
| 274 | + | } | |
| 275 | + | if fonts.SerifFont == "" { | |
| 276 | + | t.Error("SerifFont should have a default value") | |
| 277 | + | } | |
| 278 | + | if fonts.MonospaceFont == "" { | |
| 279 | + | t.Error("MonospaceFont should have a default value") | |
| 280 | + | } | |
| 281 | + | if fonts.GtkFont == "" { | |
| 282 | + | t.Error("GtkFont should have a default value") | |
| 283 | + | } | |
| 284 | + | if fonts.DefaultSize <= 0 { | |
| 285 | + | t.Errorf("DefaultSize should be positive, got %d", fonts.DefaultSize) | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // TestDefaultThemeModeColors_ReturnsSaneDefaults verifies the default mode colors. | |
| 290 | + | func TestDefaultThemeModeColors_ReturnsSaneDefaults(t *testing.T) { | |
| 291 | + | mc := DefaultThemeModeColors() | |
| 292 | + | ||
| 293 | + | if mc.PaneMode != "#4A90E2" { | |
| 294 | + | t.Errorf("expected PaneMode #4A90E2, got %q", mc.PaneMode) | |
| 295 | + | } | |
| 296 | + | if mc.TabMode != "#FFA500" { | |
| 297 | + | t.Errorf("expected TabMode #FFA500, got %q", mc.TabMode) | |
| 298 | + | } | |
| 299 | + | if mc.SessionMode != "#9B59B6" { | |
| 300 | + | t.Errorf("expected SessionMode #9B59B6, got %q", mc.SessionMode) | |
| 301 | + | } | |
| 302 | + | if mc.ResizeMode != "#00D4AA" { | |
| 303 | + | t.Errorf("expected ResizeMode #00D4AA, got %q", mc.ResizeMode) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // All must be valid hex | |
| 307 | + | if !IsValidHex(mc.PaneMode) { | |
| 308 | + | t.Error("default PaneMode should be valid hex") | |
| 309 | + | } | |
| 310 | + | if !IsValidHex(mc.TabMode) { | |
| 311 | + | t.Error("default TabMode should be valid hex") | |
| 312 | + | } | |
| 313 | + | if !IsValidHex(mc.SessionMode) { | |
| 314 | + | t.Error("default SessionMode should be valid hex") | |
| 315 | + | } | |
| 316 | + | if !IsValidHex(mc.ResizeMode) { | |
| 317 | + | t.Error("default ResizeMode should be valid hex") | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // TestDefaultDarkPalette_ReturnsValidHex verifies the default dark palette is all valid hex. | |
| 322 | + | func TestDefaultDarkPalette_ReturnsValidHex(t *testing.T) { | |
| 323 | + | p := DefaultDarkPalette() | |
| 324 | + | ||
| 325 | + | fields := map[string]string{ | |
| 326 | + | "Background": p.Background, | |
| 327 | + | "Surface": p.Surface, | |
| 328 | + | "SurfaceVariant": p.SurfaceVariant, | |
| 329 | + | "Text": p.Text, | |
| 330 | + | "Muted": p.Muted, | |
| 331 | + | "Accent": p.Accent, | |
| 332 | + | "Border": p.Border, | |
| 333 | + | } | |
| 334 | + | for name, value := range fields { | |
| 335 | + | if value == "" { | |
| 336 | + | t.Errorf("DefaultDarkPalette.%s should not be empty", name) | |
| 337 | + | } | |
| 338 | + | if !IsValidHex(value) { | |
| 339 | + | t.Errorf("DefaultDarkPalette.%s = %q is not valid hex", name, value) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // TestDefaultLightPalette_ReturnsValidHex verifies the default light palette is all valid hex. | |
| 345 | + | func TestDefaultLightPalette_ReturnsValidHex(t *testing.T) { | |
| 346 | + | p := DefaultLightPalette() | |
| 347 | + | ||
| 348 | + | fields := map[string]string{ | |
| 349 | + | "Background": p.Background, | |
| 350 | + | "Surface": p.Surface, | |
| 351 | + | "SurfaceVariant": p.SurfaceVariant, | |
| 352 | + | "Text": p.Text, | |
| 353 | + | "Muted": p.Muted, | |
| 354 | + | "Accent": p.Accent, | |
| 355 | + | "Border": p.Border, | |
| 356 | + | } | |
| 357 | + | for name, value := range fields { | |
| 358 | + | if value == "" { | |
| 359 | + | t.Errorf("DefaultLightPalette.%s should not be empty", name) | |
| 360 | + | } | |
| 361 | + | if !IsValidHex(value) { | |
| 362 | + | t.Errorf("DefaultLightPalette.%s = %q is not valid hex", name, value) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // TestValidatePaletteHex_Valid verifies ValidatePaletteHex returns no warnings for valid palettes. | |
| 368 | + | func TestValidatePaletteHex_Valid(t *testing.T) { | |
| 369 | + | p := DefaultDarkPalette() | |
| 370 | + | warnings := ValidatePaletteHex(&p, "dark") | |
| 371 | + | if len(warnings) != 0 { | |
| 372 | + | t.Errorf("expected no warnings for valid palette, got %d: %v", len(warnings), warnings) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // TestValidatePaletteHex_Invalid verifies ValidatePaletteHex returns warnings for invalid palettes. | |
| 377 | + | func TestValidatePaletteHex_Invalid(t *testing.T) { | |
| 378 | + | p := ColorPalette{ | |
| 379 | + | Background: "rgb(0,0,0)", // invalid | |
| 380 | + | Surface: "#ffffff", | |
| 381 | + | SurfaceVariant: "not-a-color", | |
| 382 | + | Text: "", // invalid | |
| 383 | + | Muted: "#GGGGGG", // invalid | |
| 384 | + | Accent: "#123", // 3-char shorthand, rejected | |
| 385 | + | Border: "#123456", | |
| 386 | + | } | |
| 387 | + | warnings := ValidatePaletteHex(&p, "test") | |
| 388 | + | if len(warnings) == 0 { | |
| 389 | + | t.Error("expected warnings for invalid palette, got none") | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // TestValidatePaletteHex_Nil verifies ValidatePaletteHex handles nil. | |
| 394 | + | func TestValidatePaletteHex_Nil(t *testing.T) { | |
| 395 | + | warnings := ValidatePaletteHex(nil, "ext") | |
| 396 | + | if len(warnings) == 0 { | |
| 397 | + | t.Error("expected warnings for nil palette") | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | // TestIsPaletteValid verifies IsPaletteValid returns correct truthiness. | |
| 402 | + | func TestIsPaletteValid_EdgeCases(t *testing.T) { | |
| 403 | + | if IsPaletteValid(nil) { | |
| 404 | + | t.Error("nil palette should not be valid") | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | valid := DefaultDarkPalette() | |
| 408 | + | if !IsPaletteValid(&valid) { | |
| 409 | + | t.Error("default dark palette should be valid") | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | partial := ColorPalette{ | |
| 413 | + | Background: "#000000", | |
| 414 | + | Surface: "#111111", | |
| 415 | + | // missing fields are "" | |
| 416 | + | } | |
| 417 | + | if IsPaletteValid(&partial) { | |
| 418 | + | t.Error("partial palette with empty fields should not be valid") | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | // TestMergePalette_FillsEmpties verifies MergePalette fills empty fields from defaults. | |
| 423 | + | func TestMergePalette_FillsEmpties(t *testing.T) { | |
| 424 | + | src := &ColorPalette{ | |
| 425 | + | Background: "#000000", | |
| 426 | + | Surface: "", // should be filled | |
| 427 | + | Text: "#ffffff", | |
| 428 | + | } | |
| 429 | + | def := DefaultDarkPalette() | |
| 430 | + | result := MergePalette(src, &def) | |
| 431 | + | ||
| 432 | + | if result.Background != "#000000" { | |
| 433 | + | t.Errorf("Background should be from src, got %q", result.Background) | |
| 434 | + | } | |
| 435 | + | if result.Surface != def.Surface { | |
| 436 | + | t.Errorf("Surface should be filled from defaults, got %q", result.Surface) | |
| 437 | + | } | |
| 438 | + | if result.Text != "#ffffff" { | |
| 439 | + | t.Errorf("Text should be from src, got %q", result.Text) | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // TestMergePalette_NilSrc verifies MergePalette returns defaults when src is nil. | |
| 444 | + | func TestMergePalette_NilSrc(t *testing.T) { | |
| 445 | + | def := DefaultDarkPalette() | |
| 446 | + | result := MergePalette(nil, &def) | |
| 447 | + | ||
| 448 | + | if result.Background != def.Background { | |
| 449 | + | t.Errorf("expected default Background, got %q", result.Background) | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | // TestMergeFonts_FillsEmpties verifies MergeFonts fills zero fields from defaults. | |
| 454 | + | func TestMergeFonts_FillsEmpties(t *testing.T) { | |
| 455 | + | src := &ThemeFonts{ | |
| 456 | + | SansFont: "Custom Sans", | |
| 457 | + | // SerifFont empty | |
| 458 | + | DefaultSize: 0, // zero | |
| 459 | + | } | |
| 460 | + | def := DefaultThemeFonts() | |
| 461 | + | result := MergeFonts(src, def) | |
| 462 | + | ||
| 463 | + | if result.SansFont != "Custom Sans" { | |
| 464 | + | t.Errorf("SansFont should be from src, got %q", result.SansFont) | |
| 465 | + | } | |
| 466 | + | if result.SerifFont != def.SerifFont { | |
| 467 | + | t.Errorf("SerifFont should be from defaults, got %q", result.SerifFont) | |
| 468 | + | } | |
| 469 | + | if result.DefaultSize != def.DefaultSize { | |
| 470 | + | t.Errorf("DefaultSize should be from defaults, got %d", result.DefaultSize) | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // TestMergeFonts_NilSrc verifies MergeFonts returns defaults when src is nil. | |
| 475 | + | func TestMergeFonts_NilSrc(t *testing.T) { | |
| 476 | + | def := DefaultThemeFonts() | |
| 477 | + | result := MergeFonts(nil, def) | |
| 478 | + | ||
| 479 | + | if result.SansFont != def.SansFont { | |
| 480 | + | t.Errorf("expected default SansFont, got %q", result.SansFont) | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // TestMergeModeColors_FillsEmpties verifies mode color merging. | |
| 485 | + | func TestMergeModeColors_FillsEmpties(t *testing.T) { | |
| 486 | + | src := &ThemeModeColors{ | |
| 487 | + | PaneMode: "#111111", | |
| 488 | + | // TabMode empty | |
| 489 | + | } | |
| 490 | + | def := DefaultThemeModeColors() | |
| 491 | + | result := MergeModeColors(src, def) | |
| 492 | + | ||
| 493 | + | if result.PaneMode != "#111111" { | |
| 494 | + | t.Errorf("PaneMode should be from src, got %q", result.PaneMode) | |
| 495 | + | } | |
| 496 | + | if result.TabMode != def.TabMode { | |
| 497 | + | t.Errorf("TabMode should be from defaults, got %q", result.TabMode) | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | // TestValidateModeColorsHex_ReturnsWarnings verifies invalid mode colors are detected. | |
| 502 | + | func TestValidateModeColorsHex_ReturnsWarnings(t *testing.T) { | |
| 503 | + | colors := &ThemeModeColors{ | |
| 504 | + | PaneMode: "#111111", | |
| 505 | + | TabMode: "not-a-color", | |
| 506 | + | SessionMode: "#333333", | |
| 507 | + | ResizeMode: "", | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | warnings := ValidateModeColorsHex(colors, "mode_colors") | |
| 511 | + | if len(warnings) != 2 { | |
| 512 | + | t.Fatalf("expected 2 invalid mode color warnings, got %d: %+v", len(warnings), warnings) | |
| 513 | + | } | |
| 514 | + | if IsModeColorsValid(colors) { | |
| 515 | + | t.Error("expected invalid mode colors to be rejected") | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // TestValidateModeColorsHex_DefaultsAreValid verifies default mode colors satisfy validation. | |
| 520 | + | func TestValidateModeColorsHex_DefaultsAreValid(t *testing.T) { | |
| 521 | + | defaults := DefaultThemeModeColors() | |
| 522 | + | if warnings := ValidateModeColorsHex(&defaults, "mode_colors"); len(warnings) != 0 { | |
| 523 | + | t.Fatalf("expected default mode colors to be valid, got warnings: %+v", warnings) | |
| 524 | + | } | |
| 525 | + | if !IsModeColorsValid(&defaults) { | |
| 526 | + | t.Error("expected default mode colors to be valid") | |
| 527 | + | } | |
| 528 | + | } | |
M internal/infrastructure/config/defaults.go +49 -1
| @@ -1,5 +1,12 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 3 | 10 | // Default configuration constants | |
| 4 | 11 | const ( | |
| 5 | 12 | // History defaults | |
| @@ -14,7 +21,12 @@ const ( | |||
| 14 | 21 | defaultMaxLogFiles = 100 // session log files | |
| 15 | 22 | ||
| 16 | 23 | // Appearance defaults | |
| 17 | - | defaultFontSize = 16 // points | |
| 24 | + | defaultFontSize = 16 // points | |
| 25 | + | defaultExternalThemeProvider = "noctalia" | |
| 26 | + | defaultExternalThemeFormat = "colors-json" | |
| 27 | + | defaultExternalThemeColorsFilename = "colors.json" | |
| 28 | + | defaultExternalThemeTemplateFormat = "dumber-json" | |
| 29 | + | defaultExternalThemeTemplateFilename = "noctalia-theme.json" | |
| 18 | 30 | ||
| 19 | 31 | // Workspace defaults | |
| 20 | 32 | defaultNewPaneURL = "about:blank" | |
| @@ -104,6 +116,36 @@ func getDefaultLogDir() string { | |||
| 104 | 116 | return logDir | |
| 105 | 117 | } | |
| 106 | 118 | ||
| 119 | + | // getDefaultExternalThemePath returns Noctalia's native colors.json path. | |
| 120 | + | func getDefaultExternalThemePath() string { | |
| 121 | + | return getDefaultExternalThemePathForFormat(defaultExternalThemeFormat) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func getDefaultExternalThemePathForFormat(format string) string { | |
| 125 | + | switch format { | |
| 126 | + | case defaultExternalThemeTemplateFormat: | |
| 127 | + | return getDefaultExternalThemeTemplatePath() | |
| 128 | + | default: | |
| 129 | + | return getDefaultNoctaliaColorsPath() | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func getDefaultNoctaliaColorsPath() string { | |
| 134 | + | configHome, err := os.UserConfigDir() | |
| 135 | + | if err != nil { | |
| 136 | + | return "" | |
| 137 | + | } | |
| 138 | + | return filepath.Join(configHome, defaultExternalThemeProvider, defaultExternalThemeColorsFilename) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func getDefaultExternalThemeTemplatePath() string { | |
| 142 | + | configDir, err := GetConfigDir() | |
| 143 | + | if err != nil { | |
| 144 | + | return "" | |
| 145 | + | } | |
| 146 | + | return filepath.Join(configDir, defaultExternalThemeTemplateFilename) | |
| 147 | + | } | |
| 148 | + | ||
| 107 | 149 | // DefaultConfig returns the default configuration values for dumber. | |
| 108 | 150 | func DefaultConfig() *Config { | |
| 109 | 151 | browsingContextDefaults := defaultBrowsingContextConfig() | |
| @@ -164,6 +206,12 @@ func DefaultConfig() *Config { | |||
| 164 | 206 | Border: "#3f3f46", | |
| 165 | 207 | }, | |
| 166 | 208 | ColorScheme: "default", // default follows system theme | |
| 209 | + | ExternalTheme: entity.ExternalThemeConfig{ | |
| 210 | + | Enabled: false, | |
| 211 | + | Provider: defaultExternalThemeProvider, | |
| 212 | + | Format: defaultExternalThemeFormat, | |
| 213 | + | Path: getDefaultExternalThemePath(), | |
| 214 | + | }, | |
| 167 | 215 | }, | |
| 168 | 216 | Debug: DebugConfig{ | |
| 169 | 217 | EnableDevTools: true, | |
A internal/infrastructure/config/external_theme_test.go +285 -0
| @@ -0,0 +1,285 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | + | "github.com/spf13/viper" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestDefaultConfig_ExternalThemeDefaults(t *testing.T) { | |
| 15 | + | cfg := DefaultConfig() | |
| 16 | + | ||
| 17 | + | require.False(t, cfg.Appearance.ExternalTheme.Enabled) | |
| 18 | + | require.Equal(t, "noctalia", cfg.Appearance.ExternalTheme.Provider) | |
| 19 | + | require.Equal(t, "colors-json", cfg.Appearance.ExternalTheme.Format) | |
| 20 | + | require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements(cfg.Appearance.ExternalTheme.Path, 2)) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestManagerSetDefaults_ExternalThemeViperDefaults(t *testing.T) { | |
| 24 | + | mgr := &Manager{viper: viper.New()} | |
| 25 | + | mgr.setDefaults() | |
| 26 | + | ||
| 27 | + | require.False(t, mgr.viper.GetBool("appearance.external_theme.enabled")) | |
| 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")) | |
| 30 | + | require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements( | |
| 31 | + | mgr.viper.GetString("appearance.external_theme.path"), | |
| 32 | + | 2, | |
| 33 | + | )) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestManagerLoad_ExternalThemeEnabledEmptyPathUsesDefaultColorsJSON(t *testing.T) { | |
| 37 | + | configHome := t.TempDir() | |
| 38 | + | t.Setenv("ENV", "") | |
| 39 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |
| 40 | + | ||
| 41 | + | configDir := filepath.Join(configHome, appName) | |
| 42 | + | require.NoError(t, os.MkdirAll(configDir, dirPerm)) | |
| 43 | + | require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(` | |
| 44 | + | [appearance.external_theme] | |
| 45 | + | enabled = true | |
| 46 | + | provider = 'noctalia' | |
| 47 | + | path = '' | |
| 48 | + | `), filePerm)) | |
| 49 | + | ||
| 50 | + | mgr, err := NewManager() | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | require.NoError(t, mgr.Load()) | |
| 53 | + | ||
| 54 | + | externalTheme := mgr.Get().Appearance.ExternalTheme | |
| 55 | + | require.True(t, externalTheme.Enabled) | |
| 56 | + | require.Equal(t, "noctalia", externalTheme.Provider) | |
| 57 | + | require.Equal(t, "colors-json", externalTheme.Format) | |
| 58 | + | require.Equal(t, filepath.Join(configHome, "noctalia", "colors.json"), externalTheme.Path) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestManagerLoad_ExternalThemeDumberJSONEmptyPathUsesTemplateDefault(t *testing.T) { | |
| 62 | + | configHome := t.TempDir() | |
| 63 | + | t.Setenv("ENV", "") | |
| 64 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |
| 65 | + | ||
| 66 | + | configDir := filepath.Join(configHome, appName) | |
| 67 | + | require.NoError(t, os.MkdirAll(configDir, dirPerm)) | |
| 68 | + | require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(` | |
| 69 | + | [appearance.external_theme] | |
| 70 | + | enabled = true | |
| 71 | + | provider = 'noctalia' | |
| 72 | + | format = 'dumber-json' | |
| 73 | + | path = '' | |
| 74 | + | `), filePerm)) | |
| 75 | + | ||
| 76 | + | mgr, err := NewManager() | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | require.NoError(t, mgr.Load()) | |
| 79 | + | ||
| 80 | + | externalTheme := mgr.Get().Appearance.ExternalTheme | |
| 81 | + | require.True(t, externalTheme.Enabled) | |
| 82 | + | require.Equal(t, "noctalia", externalTheme.Provider) | |
| 83 | + | require.Equal(t, "dumber-json", externalTheme.Format) | |
| 84 | + | require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestValidateConfig_ExternalTheme(t *testing.T) { | |
| 88 | + | tests := []struct { | |
| 89 | + | name string | |
| 90 | + | mutate func(*Config) | |
| 91 | + | wantErr bool | |
| 92 | + | wantText string | |
| 93 | + | }{ | |
| 94 | + | { | |
| 95 | + | name: "disabled empty path validates", | |
| 96 | + | mutate: func(cfg *Config) { | |
| 97 | + | cfg.Appearance.ExternalTheme.Path = "" | |
| 98 | + | }, | |
| 99 | + | }, | |
| 100 | + | { | |
| 101 | + | name: "enabled colors-json with path validates", | |
| 102 | + | mutate: func(cfg *Config) { | |
| 103 | + | cfg.Appearance.ExternalTheme.Enabled = true | |
| 104 | + | cfg.Appearance.ExternalTheme.Format = "colors-json" | |
| 105 | + | cfg.Appearance.ExternalTheme.Path = "/tmp/colors.json" | |
| 106 | + | }, | |
| 107 | + | }, | |
| 108 | + | { | |
| 109 | + | name: "enabled dumber-json with path validates", | |
| 110 | + | mutate: func(cfg *Config) { | |
| 111 | + | cfg.Appearance.ExternalTheme.Enabled = true | |
| 112 | + | cfg.Appearance.ExternalTheme.Format = "dumber-json" | |
| 113 | + | cfg.Appearance.ExternalTheme.Path = "/tmp/theme.json" | |
| 114 | + | }, | |
| 115 | + | }, | |
| 116 | + | { | |
| 117 | + | name: "enabled empty path uses format-specific default", | |
| 118 | + | mutate: func(cfg *Config) { | |
| 119 | + | cfg.Appearance.ExternalTheme.Enabled = true | |
| 120 | + | cfg.Appearance.ExternalTheme.Path = "" | |
| 121 | + | }, | |
| 122 | + | }, | |
| 123 | + | { | |
| 124 | + | name: "enabled rejects unsupported provider", | |
| 125 | + | mutate: func(cfg *Config) { | |
| 126 | + | cfg.Appearance.ExternalTheme.Enabled = true | |
| 127 | + | cfg.Appearance.ExternalTheme.Provider = "other" | |
| 128 | + | cfg.Appearance.ExternalTheme.Path = "/tmp/theme.json" | |
| 129 | + | }, | |
| 130 | + | wantErr: true, | |
| 131 | + | wantText: "appearance.external_theme.provider", | |
| 132 | + | }, | |
| 133 | + | { | |
| 134 | + | name: "enabled rejects unsupported format", | |
| 135 | + | mutate: func(cfg *Config) { | |
| 136 | + | cfg.Appearance.ExternalTheme.Enabled = true | |
| 137 | + | cfg.Appearance.ExternalTheme.Format = "toml" | |
| 138 | + | cfg.Appearance.ExternalTheme.Path = "/tmp/theme.json" | |
| 139 | + | }, | |
| 140 | + | wantErr: true, | |
| 141 | + | wantText: "appearance.external_theme.format", | |
| 142 | + | }, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | for _, tt := range tests { | |
| 146 | + | t.Run(tt.name, func(t *testing.T) { | |
| 147 | + | cfg := DefaultConfig() | |
| 148 | + | tt.mutate(cfg) | |
| 149 | + | normalizeConfig(cfg) | |
| 150 | + | ||
| 151 | + | err := validateConfig(cfg) | |
| 152 | + | if tt.wantErr { | |
| 153 | + | require.Error(t, err) | |
| 154 | + | require.Contains(t, err.Error(), tt.wantText) | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestNormalizeConfig_ExternalTheme(t *testing.T) { | |
| 163 | + | cfg := DefaultConfig() | |
| 164 | + | cfg.Appearance.ExternalTheme.Provider = " NOCTALIA " | |
| 165 | + | cfg.Appearance.ExternalTheme.Format = " COLORS-JSON " | |
| 166 | + | cfg.Appearance.ExternalTheme.Path = " /tmp/colors.json " | |
| 167 | + | ||
| 168 | + | normalizeConfig(cfg) | |
| 169 | + | ||
| 170 | + | require.Equal(t, "noctalia", cfg.Appearance.ExternalTheme.Provider) | |
| 171 | + | require.Equal(t, "colors-json", cfg.Appearance.ExternalTheme.Format) | |
| 172 | + | require.Equal(t, "/tmp/colors.json", cfg.Appearance.ExternalTheme.Path) | |
| 173 | + | ||
| 174 | + | cfg.Appearance.ExternalTheme.Provider = "" | |
| 175 | + | cfg.Appearance.ExternalTheme.Format = "" | |
| 176 | + | normalizeConfig(cfg) | |
| 177 | + | require.Equal(t, "noctalia", cfg.Appearance.ExternalTheme.Provider) | |
| 178 | + | require.Equal(t, "colors-json", cfg.Appearance.ExternalTheme.Format) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestSchemaProvider_ExternalThemeKeys(t *testing.T) { | |
| 182 | + | keys := NewSchemaProvider().GetSchema() | |
| 183 | + | byKey := map[string]bool{} | |
| 184 | + | for _, key := range keys { | |
| 185 | + | if key.Key == "appearance.external_theme.provider" { | |
| 186 | + | require.Equal(t, SectionAppearance, key.Section) | |
| 187 | + | require.Equal(t, "noctalia", key.Default) | |
| 188 | + | require.Equal(t, []string{"noctalia"}, key.Values) | |
| 189 | + | } | |
| 190 | + | if key.Key == "appearance.external_theme.format" { | |
| 191 | + | require.Equal(t, SectionAppearance, key.Section) | |
| 192 | + | require.Equal(t, "colors-json", key.Default) | |
| 193 | + | require.Equal(t, []string{"colors-json", "dumber-json"}, key.Values) | |
| 194 | + | } | |
| 195 | + | if key.Key == "appearance.external_theme.path" { | |
| 196 | + | require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements(key.Default, 2)) | |
| 197 | + | } | |
| 198 | + | byKey[key.Key] = true | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | for _, key := range []string{ | |
| 202 | + | "appearance.external_theme.enabled", | |
| 203 | + | "appearance.external_theme.provider", | |
| 204 | + | "appearance.external_theme.format", | |
| 205 | + | "appearance.external_theme.path", | |
| 206 | + | } { | |
| 207 | + | require.True(t, byKey[key], "missing schema key %s", key) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestWriteConfigOrdered_IncludesExternalThemeTable(t *testing.T) { | |
| 212 | + | path := filepath.Join(t.TempDir(), "config.toml") | |
| 213 | + | cfg := DefaultConfig() | |
| 214 | + | ||
| 215 | + | require.NoError(t, WriteConfigOrdered(cfg, path)) | |
| 216 | + | content, err := os.ReadFile(path) | |
| 217 | + | require.NoError(t, err) | |
| 218 | + | require.Contains(t, string(content), "[appearance.external_theme]") | |
| 219 | + | require.Contains(t, string(content), "enabled = false") | |
| 220 | + | require.Contains(t, string(content), "provider = 'noctalia'") | |
| 221 | + | require.Contains(t, string(content), "format = 'colors-json'") | |
| 222 | + | require.Contains(t, string(content), "colors.json") | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestBuildSystemviewConfigPayload_ProjectsExternalTheme(t *testing.T) { | |
| 226 | + | cfg := DefaultConfig() | |
| 227 | + | cfg.Appearance.ExternalTheme.Enabled = true | |
| 228 | + | cfg.Appearance.ExternalTheme.Path = "/tmp/colors.json" | |
| 229 | + | ||
| 230 | + | payload := BuildSystemviewConfigPayload(cfg, nil) | |
| 231 | + | ||
| 232 | + | require.True(t, payload.Appearance.ExternalTheme.Enabled) | |
| 233 | + | require.Equal(t, "noctalia", payload.Appearance.ExternalTheme.Provider) | |
| 234 | + | require.Equal(t, "colors-json", payload.Appearance.ExternalTheme.Format) | |
| 235 | + | require.Equal(t, "/tmp/colors.json", payload.Appearance.ExternalTheme.Path) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func lastPathElements(path string, count int) string { | |
| 239 | + | cleaned := filepath.Clean(path) | |
| 240 | + | parts := make([]string, 0, count) | |
| 241 | + | for len(parts) < count && cleaned != "." && cleaned != string(filepath.Separator) { | |
| 242 | + | parts = append([]string{filepath.Base(cleaned)}, parts...) | |
| 243 | + | cleaned = filepath.Dir(cleaned) | |
| 244 | + | } | |
| 245 | + | return filepath.Join(parts...) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestWebUIConfigGatewaySaveWebUIConfig_ExternalThemeRoundTrip(t *testing.T) { | |
| 249 | + | path := filepath.Join(t.TempDir(), "config.toml") | |
| 250 | + | require.NoError(t, os.WriteFile(path, []byte(""), 0o644)) | |
| 251 | + | ||
| 252 | + | v := viper.New() | |
| 253 | + | v.SetConfigFile(path) | |
| 254 | + | require.NoError(t, v.ReadInConfig()) | |
| 255 | + | ||
| 256 | + | previousGlobal := globalManager | |
| 257 | + | defer func() { globalManager = previousGlobal }() | |
| 258 | + | ||
| 259 | + | mgr := &Manager{config: DefaultConfig(), viper: v} | |
| 260 | + | globalManager = mgr | |
| 261 | + | ||
| 262 | + | webCfg := dto.WebUIConfig{ | |
| 263 | + | Appearance: buildSystemviewAppearancePayload(DefaultConfig().Appearance), | |
| 264 | + | } | |
| 265 | + | webCfg.Appearance.ExternalTheme = dto.WebUIExternalThemeConfig{ | |
| 266 | + | Enabled: true, | |
| 267 | + | Provider: " NOCTALIA ", | |
| 268 | + | Format: " COLORS-JSON ", | |
| 269 | + | Path: " /tmp/colors.json ", | |
| 270 | + | } | |
| 271 | + | webCfg.DefaultUIScale = DefaultConfig().DefaultUIScale | |
| 272 | + | webCfg.DefaultSearchEngine = DefaultConfig().DefaultSearchEngine | |
| 273 | + | webCfg.SearchShortcuts = map[string]dto.SearchShortcut{} | |
| 274 | + | for key, shortcut := range DefaultConfig().SearchShortcuts { | |
| 275 | + | webCfg.SearchShortcuts[key] = dto.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description} | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | require.NoError(t, NewWebUIConfigGateway(mgr).SaveWebUIConfig(context.Background(), webCfg)) | |
| 279 | + | ||
| 280 | + | saved := mgr.Get() | |
| 281 | + | require.True(t, saved.Appearance.ExternalTheme.Enabled) | |
| 282 | + | require.Equal(t, "noctalia", saved.Appearance.ExternalTheme.Provider) | |
| 283 | + | require.Equal(t, "colors-json", saved.Appearance.ExternalTheme.Format) | |
| 284 | + | require.Equal(t, "/tmp/colors.json", saved.Appearance.ExternalTheme.Path) | |
| 285 | + | } | |
M internal/infrastructure/config/loader.go +20 -0
| @@ -429,6 +429,22 @@ func normalizeEngineConfig(config *Config) { | |||
| 429 | 429 | } | |
| 430 | 430 | ||
| 431 | 431 | func normalizeAppearance(config *Config) { | |
| 432 | + | config.Appearance.ExternalTheme.Provider = strings.ToLower(strings.TrimSpace(config.Appearance.ExternalTheme.Provider)) | |
| 433 | + | config.Appearance.ExternalTheme.Format = strings.ToLower(strings.TrimSpace(config.Appearance.ExternalTheme.Format)) | |
| 434 | + | config.Appearance.ExternalTheme.Path = strings.TrimSpace(config.Appearance.ExternalTheme.Path) | |
| 435 | + | if config.Appearance.ExternalTheme.Provider == "" { | |
| 436 | + | config.Appearance.ExternalTheme.Provider = defaultExternalThemeProvider | |
| 437 | + | } | |
| 438 | + | if config.Appearance.ExternalTheme.Format == "" { | |
| 439 | + | config.Appearance.ExternalTheme.Format = defaultExternalThemeFormat | |
| 440 | + | } | |
| 441 | + | if config.Appearance.ExternalTheme.Enabled && | |
| 442 | + | config.Appearance.ExternalTheme.Provider == defaultExternalThemeProvider && | |
| 443 | + | isSupportedExternalThemeFormat(config.Appearance.ExternalTheme.Format) && | |
| 444 | + | config.Appearance.ExternalTheme.Path == "" { | |
| 445 | + | config.Appearance.ExternalTheme.Path = getDefaultExternalThemePathForFormat(config.Appearance.ExternalTheme.Format) | |
| 446 | + | } | |
| 447 | + | ||
| 432 | 448 | switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) { | |
| 433 | 449 | case strings.ToLower(ThemePreferDark): | |
| 434 | 450 | config.Appearance.ColorScheme = ThemePreferDark | |
| @@ -640,6 +656,10 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) { | |||
| 640 | 656 | m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette) | |
| 641 | 657 | m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette) | |
| 642 | 658 | m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme) | |
| 659 | + | m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled) | |
| 660 | + | m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider) | |
| 661 | + | m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format) | |
| 662 | + | m.viper.SetDefault("appearance.external_theme.path", defaults.Appearance.ExternalTheme.Path) | |
| 643 | 663 | } | |
| 644 | 664 | ||
| 645 | 665 | func (m *Manager) setZoomAndScaleDefaults(defaults *Config) { | |
M internal/infrastructure/config/schema_provider.go +30 -0
| @@ -175,6 +175,36 @@ func (*SchemaProvider) getAppearanceKeys(defaults *Config) []entity.ConfigKeyInf | |||
| 175 | 175 | Description: "Dark theme color palette (background, surface, surface_variant, text, muted, accent, border)", | |
| 176 | 176 | Section: SectionAppearance, | |
| 177 | 177 | }, | |
| 178 | + | { | |
| 179 | + | Key: "appearance.external_theme.enabled", | |
| 180 | + | Type: "bool", | |
| 181 | + | Default: fmt.Sprintf("%t", defaults.Appearance.ExternalTheme.Enabled), | |
| 182 | + | Description: "Enable loading theme settings from an external provider", | |
| 183 | + | Section: SectionAppearance, | |
| 184 | + | }, | |
| 185 | + | { | |
| 186 | + | Key: "appearance.external_theme.provider", | |
| 187 | + | Type: "string", | |
| 188 | + | Default: defaults.Appearance.ExternalTheme.Provider, | |
| 189 | + | Description: "External theme provider", | |
| 190 | + | Values: []string{"noctalia"}, | |
| 191 | + | Section: SectionAppearance, | |
| 192 | + | }, | |
| 193 | + | { | |
| 194 | + | Key: "appearance.external_theme.format", | |
| 195 | + | Type: "string", | |
| 196 | + | Default: defaults.Appearance.ExternalTheme.Format, | |
| 197 | + | Description: "External theme file format", | |
| 198 | + | Values: []string{"colors-json", "dumber-json"}, | |
| 199 | + | Section: SectionAppearance, | |
| 200 | + | }, | |
| 201 | + | { | |
| 202 | + | Key: "appearance.external_theme.path", | |
| 203 | + | Type: "string", | |
| 204 | + | Default: defaults.Appearance.ExternalTheme.Path, | |
| 205 | + | Description: "Path to the external theme file", | |
| 206 | + | Section: SectionAppearance, | |
| 207 | + | }, | |
| 178 | 208 | } | |
| 179 | 209 | } | |
| 180 | 210 | ||
M internal/infrastructure/config/validation.go +29 -0
| @@ -104,9 +104,38 @@ func validateAppearance(config *Config) []string { | |||
| 104 | 104 | if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 { | |
| 105 | 105 | validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0") | |
| 106 | 106 | } | |
| 107 | + | validationErrors = append(validationErrors, validateExternalTheme(config)...) | |
| 107 | 108 | return validationErrors | |
| 108 | 109 | } | |
| 109 | 110 | ||
| 111 | + | func validateExternalTheme(config *Config) []string { | |
| 112 | + | externalTheme := config.Appearance.ExternalTheme | |
| 113 | + | if !externalTheme.Enabled { | |
| 114 | + | return nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | var validationErrors []string | |
| 118 | + | if externalTheme.Provider != defaultExternalThemeProvider { | |
| 119 | + | validationErrors = append(validationErrors, "appearance.external_theme.provider must be one of: noctalia") | |
| 120 | + | } | |
| 121 | + | if !isSupportedExternalThemeFormat(externalTheme.Format) { | |
| 122 | + | validationErrors = append(validationErrors, "appearance.external_theme.format must be one of: colors-json, dumber-json") | |
| 123 | + | } | |
| 124 | + | if strings.TrimSpace(externalTheme.Path) == "" { | |
| 125 | + | validationErrors = append(validationErrors, "appearance.external_theme.path is required when external_theme is enabled") | |
| 126 | + | } | |
| 127 | + | return validationErrors | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func isSupportedExternalThemeFormat(format string) bool { | |
| 131 | + | switch format { | |
| 132 | + | case defaultExternalThemeFormat, defaultExternalThemeTemplateFormat: | |
| 133 | + | return true | |
| 134 | + | default: | |
| 135 | + | return false | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 110 | 139 | func validateSearchEngine(config *Config) []string { | |
| 111 | 140 | if config.DefaultSearchEngine == "" { | |
| 112 | 141 | return []string{"default_search_engine cannot be empty"} | |
M internal/infrastructure/config/webui_gateway.go +4 -0
| @@ -64,6 +64,10 @@ 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 | 71 | current.DefaultUIScale = cfg.DefaultUIScale | |
| 68 | 72 | current.DefaultSearchEngine = cfg.DefaultSearchEngine | |
| 69 | 73 | if len(cfg.SearchShortcuts) == 0 { | |
M internal/infrastructure/config/webui_payload.go +6 -0
| @@ -58,6 +58,12 @@ func buildSystemviewAppearancePayload(appearance AppearanceConfig) dto.WebUIAppe | |||
| 58 | 58 | ColorScheme: appearance.ColorScheme, | |
| 59 | 59 | LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette), | |
| 60 | 60 | DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette), | |
| 61 | + | ExternalTheme: dto.WebUIExternalThemeConfig{ | |
| 62 | + | Enabled: appearance.ExternalTheme.Enabled, | |
| 63 | + | Provider: appearance.ExternalTheme.Provider, | |
| 64 | + | Format: appearance.ExternalTheme.Format, | |
| 65 | + | Path: appearance.ExternalTheme.Path, | |
| 66 | + | }, | |
| 61 | 67 | } | |
| 62 | 68 | } | |
| 63 | 69 | ||
A internal/infrastructure/externaltheme/noctalia/source.go +364 -0
| @@ -0,0 +1,364 @@ | |||
| 1 | + | package noctalia | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strings" | |
| 13 | + | "sync" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/dumber/internal/application/port" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | providerName = "noctalia" | |
| 21 | + | formatName = formatColorsJSON | |
| 22 | + | formatColorsJSON = "colors-json" | |
| 23 | + | formatDumberJSON = "dumber-json" | |
| 24 | + | colorsJSONName = "Noctalia colors.json" | |
| 25 | + | dumberJSONName = "Noctalia Dumber JSON" | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | var _ port.ExternalThemeSource = (*FileSource)(nil) | |
| 29 | + | ||
| 30 | + | // FileSource reads a Noctalia external theme file. | |
| 31 | + | // | |
| 32 | + | // The default colors-json format reads Noctalia's native colors.json directly. | |
| 33 | + | // The dumber-json format remains available for explicit Dumber-specific user templates. | |
| 34 | + | type FileSource struct { | |
| 35 | + | mu sync.RWMutex | |
| 36 | + | enabled bool | |
| 37 | + | path string | |
| 38 | + | format string | |
| 39 | + | identity string | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // NewFileSource creates a Noctalia colors-json file source. | |
| 43 | + | func NewFileSource(enabled bool, path string) *FileSource { | |
| 44 | + | source := &FileSource{} | |
| 45 | + | source.Configure(entity.ExternalThemeConfig{ | |
| 46 | + | Enabled: enabled, | |
| 47 | + | Provider: providerName, | |
| 48 | + | Format: formatName, | |
| 49 | + | Path: path, | |
| 50 | + | }) | |
| 51 | + | return source | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // NewFileSourceFromConfig creates a file source from external theme config. | |
| 55 | + | func NewFileSourceFromConfig(cfg entity.ExternalThemeConfig) *FileSource { | |
| 56 | + | source := &FileSource{} | |
| 57 | + | source.Configure(cfg) | |
| 58 | + | return source | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Configure updates the source from a normalized or raw external theme config snapshot. | |
| 62 | + | func (s *FileSource) Configure(cfg entity.ExternalThemeConfig) { | |
| 63 | + | provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format) | |
| 64 | + | enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format) | |
| 65 | + | path := strings.TrimSpace(cfg.Path) | |
| 66 | + | ||
| 67 | + | s.mu.Lock() | |
| 68 | + | defer s.mu.Unlock() | |
| 69 | + | s.enabled = enabled | |
| 70 | + | s.path = path | |
| 71 | + | s.format = format | |
| 72 | + | s.identity = externalThemeIdentity(enabled, provider, format, path) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // ExternalThemeIdentity returns the current normalized external theme identity. | |
| 76 | + | func (s *FileSource) ExternalThemeIdentity() string { | |
| 77 | + | s.mu.RLock() | |
| 78 | + | defer s.mu.RUnlock() | |
| 79 | + | return s.identity | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // IsEnabled returns whether this source should be used. | |
| 83 | + | func (s *FileSource) IsEnabled() bool { | |
| 84 | + | s.mu.RLock() | |
| 85 | + | defer s.mu.RUnlock() | |
| 86 | + | return s.enabled | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Get reads and parses the configured Noctalia theme file. | |
| 90 | + | func (s *FileSource) Get(ctx context.Context) (*entity.ExternalTheme, error) { | |
| 91 | + | s.mu.RLock() | |
| 92 | + | enabled := s.enabled | |
| 93 | + | pathConfig := s.path | |
| 94 | + | format := s.format | |
| 95 | + | s.mu.RUnlock() | |
| 96 | + | ||
| 97 | + | if !enabled { | |
| 98 | + | return nil, nil | |
| 99 | + | } | |
| 100 | + | if err := ctx.Err(); err != nil { | |
| 101 | + | return nil, err | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | path, err := ExpandPath(pathConfig) | |
| 105 | + | if err != nil { | |
| 106 | + | return nil, err | |
| 107 | + | } | |
| 108 | + | if path == "" { | |
| 109 | + | return nil, errors.New("noctalia theme path is empty") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | data, err := os.ReadFile(path) | |
| 113 | + | if err != nil { | |
| 114 | + | return nil, fmt.Errorf("read noctalia theme file: %w", err) | |
| 115 | + | } | |
| 116 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 117 | + | return nil, ctxErr | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | switch format { | |
| 121 | + | case formatColorsJSON: | |
| 122 | + | return ParseColorsJSON(data) | |
| 123 | + | case formatDumberJSON: | |
| 124 | + | return ParseDumberJSON(data) | |
| 125 | + | default: | |
| 126 | + | return nil, fmt.Errorf("unsupported noctalia theme format %q", format) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // ExpandPath expands environment variables, leading ~, and cleans the path. | |
| 131 | + | func ExpandPath(path string) (string, error) { | |
| 132 | + | expanded := os.ExpandEnv(strings.TrimSpace(path)) | |
| 133 | + | if expanded == "" { | |
| 134 | + | return "", nil | |
| 135 | + | } | |
| 136 | + | if expanded == "~" || strings.HasPrefix(expanded, "~/") { | |
| 137 | + | home, err := os.UserHomeDir() | |
| 138 | + | if err != nil { | |
| 139 | + | return "", fmt.Errorf("expand home directory: %w", err) | |
| 140 | + | } | |
| 141 | + | if expanded == "~" { | |
| 142 | + | expanded = home | |
| 143 | + | } else { | |
| 144 | + | expanded = filepath.Join(home, expanded[2:]) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | return filepath.Clean(expanded), nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // ParseColorsJSON parses Noctalia's native colors.json file. | |
| 151 | + | // | |
| 152 | + | // Noctalia colors.json represents the active palette only, so Dumber applies the | |
| 153 | + | // mapped palette to both light and dark resolved palettes. The mapper uses | |
| 154 | + | // optional mShadow/mHover roles when present to preserve background/surface/input | |
| 155 | + | // contrast, while falling back to older required roles for compatibility. | |
| 156 | + | func ParseColorsJSON(data []byte) (*entity.ExternalTheme, error) { | |
| 157 | + | var raw colorsThemeJSON | |
| 158 | + | decoder := json.NewDecoder(bytes.NewReader(data)) | |
| 159 | + | if err := decoder.Decode(&raw); err != nil { | |
| 160 | + | return nil, fmt.Errorf("parse noctalia colors json: %w", err) | |
| 161 | + | } | |
| 162 | + | if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { | |
| 163 | + | return nil, errors.New("parse noctalia colors json: trailing data") | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | palette, err := raw.toPalette() | |
| 167 | + | if err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | light := palette | |
| 171 | + | dark := palette | |
| 172 | + | return &entity.ExternalTheme{ | |
| 173 | + | Name: colorsJSONName, | |
| 174 | + | Provider: providerName, | |
| 175 | + | LightPalette: &light, | |
| 176 | + | DarkPalette: &dark, | |
| 177 | + | }, nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // ParseDumberJSON parses Noctalia user-template-generated Dumber JSON. | |
| 181 | + | func ParseDumberJSON(data []byte) (*entity.ExternalTheme, error) { | |
| 182 | + | var raw dumberThemeJSON | |
| 183 | + | decoder := json.NewDecoder(bytes.NewReader(data)) | |
| 184 | + | if err := decoder.Decode(&raw); err != nil { | |
| 185 | + | return nil, fmt.Errorf("parse noctalia dumber json: %w", err) | |
| 186 | + | } | |
| 187 | + | if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { | |
| 188 | + | return nil, errors.New("parse noctalia dumber json: trailing data") | |
| 189 | + | } | |
| 190 | + | if raw.Light == nil { | |
| 191 | + | return nil, errors.New("parse noctalia dumber json: missing light palette") | |
| 192 | + | } | |
| 193 | + | if raw.Dark == nil { | |
| 194 | + | return nil, errors.New("parse noctalia dumber json: missing dark palette") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | light := raw.Light.toEntity() | |
| 198 | + | dark := raw.Dark.toEntity() | |
| 199 | + | if err := validatePalette(&light, "light"); err != nil { | |
| 200 | + | return nil, err | |
| 201 | + | } | |
| 202 | + | if err := validatePalette(&dark, "dark"); err != nil { | |
| 203 | + | return nil, err | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | return &entity.ExternalTheme{ | |
| 207 | + | Name: firstNonEmpty(raw.Name, raw.Source, dumberJSONName), | |
| 208 | + | Provider: providerName, | |
| 209 | + | LightPalette: &light, | |
| 210 | + | DarkPalette: &dark, | |
| 211 | + | }, nil | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | type colorsThemeJSON struct { | |
| 215 | + | MPrimary *string `json:"mPrimary"` | |
| 216 | + | MSurface *string `json:"mSurface"` | |
| 217 | + | MOnSurface *string `json:"mOnSurface"` | |
| 218 | + | MSurfaceVariant *string `json:"mSurfaceVariant"` | |
| 219 | + | MOnSurfaceVariant *string `json:"mOnSurfaceVariant"` | |
| 220 | + | MOutline *string `json:"mOutline"` | |
| 221 | + | MShadow *string `json:"mShadow"` | |
| 222 | + | MHover *string `json:"mHover"` | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func (raw colorsThemeJSON) toPalette() (entity.ColorPalette, error) { | |
| 226 | + | surface, err := requiredColorsHex("mSurface", raw.MSurface) | |
| 227 | + | if err != nil { | |
| 228 | + | return entity.ColorPalette{}, err | |
| 229 | + | } | |
| 230 | + | surfaceVariantBase, err := requiredColorsHex("mSurfaceVariant", raw.MSurfaceVariant) | |
| 231 | + | if err != nil { | |
| 232 | + | return entity.ColorPalette{}, err | |
| 233 | + | } | |
| 234 | + | text, err := requiredColorsHex("mOnSurface", raw.MOnSurface) | |
| 235 | + | if err != nil { | |
| 236 | + | return entity.ColorPalette{}, err | |
| 237 | + | } | |
| 238 | + | muted, err := requiredColorsHex("mOnSurfaceVariant", raw.MOnSurfaceVariant) | |
| 239 | + | if err != nil { | |
| 240 | + | return entity.ColorPalette{}, err | |
| 241 | + | } | |
| 242 | + | accent, err := requiredColorsHex("mPrimary", raw.MPrimary) | |
| 243 | + | if err != nil { | |
| 244 | + | return entity.ColorPalette{}, err | |
| 245 | + | } | |
| 246 | + | border, err := requiredColorsHex("mOutline", raw.MOutline) | |
| 247 | + | if err != nil { | |
| 248 | + | return entity.ColorPalette{}, err | |
| 249 | + | } | |
| 250 | + | background, err := optionalColorsHex("mShadow", raw.MShadow, surface) | |
| 251 | + | if err != nil { | |
| 252 | + | return entity.ColorPalette{}, err | |
| 253 | + | } | |
| 254 | + | surfaceVariant, err := optionalColorsHex("mHover", raw.MHover, surfaceVariantBase) | |
| 255 | + | if err != nil { | |
| 256 | + | return entity.ColorPalette{}, err | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | return entity.ColorPalette{ | |
| 260 | + | Background: background, | |
| 261 | + | Surface: surface, | |
| 262 | + | SurfaceVariant: surfaceVariant, | |
| 263 | + | Text: text, | |
| 264 | + | Muted: muted, | |
| 265 | + | Accent: accent, | |
| 266 | + | Border: border, | |
| 267 | + | }, nil | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func requiredColorsHex(field string, value *string) (string, error) { | |
| 271 | + | if value == nil || *value == "" { | |
| 272 | + | return "", fmt.Errorf("parse noctalia colors json: missing %s", field) | |
| 273 | + | } | |
| 274 | + | return validateColorsHex(field, *value) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func optionalColorsHex(field string, value *string, fallback string) (string, error) { | |
| 278 | + | if value == nil { | |
| 279 | + | return fallback, nil | |
| 280 | + | } | |
| 281 | + | return validateColorsHex(field, *value) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func validateColorsHex(field, value string) (string, error) { | |
| 285 | + | if !entity.IsValidHex(value) { | |
| 286 | + | return "", fmt.Errorf("parse noctalia colors json: %s: not a valid CSS-safe hex color (#RRGGBB)", field) | |
| 287 | + | } | |
| 288 | + | return value, nil | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | type dumberThemeJSON struct { | |
| 292 | + | Source string `json:"source"` | |
| 293 | + | Mode string `json:"mode"` | |
| 294 | + | Name string `json:"name"` | |
| 295 | + | Light *paletteJSON `json:"light"` | |
| 296 | + | Dark *paletteJSON `json:"dark"` | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | type paletteJSON struct { | |
| 300 | + | Background string `json:"background"` | |
| 301 | + | Surface string `json:"surface"` | |
| 302 | + | SurfaceVariant string `json:"surface_variant"` | |
| 303 | + | Text string `json:"text"` | |
| 304 | + | Muted string `json:"muted"` | |
| 305 | + | Accent string `json:"accent"` | |
| 306 | + | Border string `json:"border"` | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (p paletteJSON) toEntity() entity.ColorPalette { | |
| 310 | + | return entity.ColorPalette{ | |
| 311 | + | Background: p.Background, | |
| 312 | + | Surface: p.Surface, | |
| 313 | + | SurfaceVariant: p.SurfaceVariant, | |
| 314 | + | Text: p.Text, | |
| 315 | + | Muted: p.Muted, | |
| 316 | + | Accent: p.Accent, | |
| 317 | + | Border: p.Border, | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func validatePalette(p *entity.ColorPalette, prefix string) error { | |
| 322 | + | warnings := entity.ValidatePaletteOverrideHex(p, prefix) | |
| 323 | + | if len(warnings) == 0 { | |
| 324 | + | return nil | |
| 325 | + | } | |
| 326 | + | return fmt.Errorf("parse noctalia dumber json: %s: %s", warnings[0].Field, warnings[0].Message) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func firstNonEmpty(values ...string) string { | |
| 330 | + | for _, value := range values { | |
| 331 | + | if strings.TrimSpace(value) != "" { | |
| 332 | + | return value | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | return "" | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func normalizeProviderAndFormat(providerInput, formatInput string) (string, string) { | |
| 339 | + | provider := strings.ToLower(strings.TrimSpace(providerInput)) | |
| 340 | + | if provider == "" { | |
| 341 | + | provider = providerName | |
| 342 | + | } | |
| 343 | + | format := strings.ToLower(strings.TrimSpace(formatInput)) | |
| 344 | + | if format == "" { | |
| 345 | + | format = formatName | |
| 346 | + | } | |
| 347 | + | return provider, format | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | func isSupportedFormat(format string) bool { | |
| 351 | + | switch format { | |
| 352 | + | case formatColorsJSON, formatDumberJSON: | |
| 353 | + | return true | |
| 354 | + | default: | |
| 355 | + | return false | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | func externalThemeIdentity(enabled bool, provider, format, path string) string { | |
| 360 | + | if !enabled { | |
| 361 | + | return "" | |
| 362 | + | } | |
| 363 | + | return provider + "\x00" + format + "\x00" + strings.TrimSpace(path) | |
| 364 | + | } | |
A internal/infrastructure/externaltheme/noctalia/source_test.go +357 -0
| @@ -0,0 +1,357 @@ | |||
| 1 | + | package noctalia | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestParseColorsJSONValidActivePalette(t *testing.T) { | |
| 13 | + | theme, err := ParseColorsJSON(noctaliaColorsJSON()) | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatalf("ParseColorsJSON() error = %v", err) | |
| 16 | + | } | |
| 17 | + | if theme.Provider != "noctalia" { | |
| 18 | + | t.Fatalf("Provider = %q, want noctalia", theme.Provider) | |
| 19 | + | } | |
| 20 | + | if theme.Name == "" { | |
| 21 | + | t.Fatal("Name is empty, want source metadata") | |
| 22 | + | } | |
| 23 | + | want := entity.ColorPalette{ | |
| 24 | + | Background: "#000000", | |
| 25 | + | Surface: "#0c0c0c", | |
| 26 | + | SurfaceVariant: "#282828", | |
| 27 | + | Text: "#ffffff", | |
| 28 | + | Muted: "#a0a0a0", | |
| 29 | + | Accent: "#ffc799", | |
| 30 | + | Border: "#505050", | |
| 31 | + | } | |
| 32 | + | if theme.LightPalette == nil || *theme.LightPalette != want { | |
| 33 | + | t.Fatalf("LightPalette = %+v, want %+v", theme.LightPalette, want) | |
| 34 | + | } | |
| 35 | + | if theme.DarkPalette == nil || *theme.DarkPalette != want { | |
| 36 | + | t.Fatalf("DarkPalette = %+v, want active Noctalia palette %+v", theme.DarkPalette, want) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestParseColorsJSONDocsExample(t *testing.T) { | |
| 41 | + | path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-colors.json") | |
| 42 | + | data, err := os.ReadFile(path) | |
| 43 | + | if err != nil { | |
| 44 | + | t.Fatalf("ReadFile(%q) error = %v", path, err) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | theme, err := ParseColorsJSON(data) | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatalf("ParseColorsJSON(docs example) error = %v", err) | |
| 50 | + | } | |
| 51 | + | if theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" { | |
| 52 | + | t.Fatalf("parsed docs example = %+v", theme) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestParseColorsJSONFallsBackWhenNuanceFieldsAreAbsent(t *testing.T) { | |
| 57 | + | theme, err := ParseColorsJSON([]byte(`{ | |
| 58 | + | "mPrimary":"#ffc799", | |
| 59 | + | "mSurface":"#0c0c0c", | |
| 60 | + | "mOnSurface":"#ffffff", | |
| 61 | + | "mSurfaceVariant":"#1c1c1c", | |
| 62 | + | "mOnSurfaceVariant":"#a0a0a0", | |
| 63 | + | "mOutline":"#505050" | |
| 64 | + | }`)) | |
| 65 | + | if err != nil { | |
| 66 | + | t.Fatalf("ParseColorsJSON() error = %v", err) | |
| 67 | + | } | |
| 68 | + | if theme.DarkPalette.Background != "#0c0c0c" { | |
| 69 | + | t.Fatalf("Background = %q, want mSurface fallback", theme.DarkPalette.Background) | |
| 70 | + | } | |
| 71 | + | if theme.DarkPalette.SurfaceVariant != "#1c1c1c" { | |
| 72 | + | t.Fatalf("SurfaceVariant = %q, want mSurfaceVariant fallback", theme.DarkPalette.SurfaceVariant) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestParseColorsJSONRequiresMappedFields(t *testing.T) { | |
| 77 | + | cases := map[string]string{ | |
| 78 | + | "missing surface": `{"mPrimary":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`, | |
| 79 | + | "missing surface variant": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`, | |
| 80 | + | "missing text": `{"mPrimary":"#111111","mSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`, | |
| 81 | + | "missing muted": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOutline":"#555555"}`, | |
| 82 | + | "missing accent": `{"mSurface":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`, | |
| 83 | + | "missing border": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOnSurfaceVariant":"#555555"}`, | |
| 84 | + | } | |
| 85 | + | for name, input := range cases { | |
| 86 | + | t.Run(name, func(t *testing.T) { | |
| 87 | + | if _, err := ParseColorsJSON([]byte(input)); err == nil { | |
| 88 | + | t.Fatal("ParseColorsJSON() error = nil, want missing field error") | |
| 89 | + | } | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestParseColorsJSONInvalidHexRejection(t *testing.T) { | |
| 95 | + | if _, err := ParseColorsJSON([]byte(`{"mPrimary":"ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050"}`)); err == nil { | |
| 96 | + | t.Fatal("ParseColorsJSON() error = nil, want invalid hex error") | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestParseColorsJSONInvalidOptionalNuanceHexRejection(t *testing.T) { | |
| 101 | + | cases := map[string]string{ | |
| 102 | + | "invalid shadow": `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mShadow":"000000"}`, | |
| 103 | + | "invalid hover": `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mHover":"282828"}`, | |
| 104 | + | } | |
| 105 | + | for name, input := range cases { | |
| 106 | + | t.Run(name, func(t *testing.T) { | |
| 107 | + | if _, err := ParseColorsJSON([]byte(input)); err == nil { | |
| 108 | + | t.Fatal("ParseColorsJSON() error = nil, want optional hex validation error") | |
| 109 | + | } | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestParseColorsJSONRejectsTrailingTokens(t *testing.T) { | |
| 115 | + | if _, err := ParseColorsJSON(append(noctaliaColorsJSON(), []byte(` {}`)...)); err == nil { | |
| 116 | + | t.Fatal("ParseColorsJSON() error = nil, want trailing data error") | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestParseDumberJSONValidFullPalette(t *testing.T) { | |
| 121 | + | json := []byte(`{ | |
| 122 | + | "name":"Night Theme", | |
| 123 | + | "source":"noctalia-template", | |
| 124 | + | "mode":"dark", | |
| 125 | + | "light":{"background":"#FFFFFF","surface":"#F0F0F0","surface_variant":"#E0E0E0","text":"#111111","muted":"#777777","accent":"#3366CC","border":"#CCCCCC"}, | |
| 126 | + | "dark":{"background":"#000000","surface":"#101010","surface_variant":"#202020","text":"#EEEEEE","muted":"#999999","accent":"#AABBCC","border":"#333333"} | |
| 127 | + | }`) | |
| 128 | + | ||
| 129 | + | theme, err := ParseDumberJSON(json) | |
| 130 | + | if err != nil { | |
| 131 | + | t.Fatalf("ParseDumberJSON() error = %v", err) | |
| 132 | + | } | |
| 133 | + | if theme.Provider != "noctalia" { | |
| 134 | + | t.Fatalf("Provider = %q, want noctalia", theme.Provider) | |
| 135 | + | } | |
| 136 | + | if theme.Name != "Night Theme" { | |
| 137 | + | t.Fatalf("Name = %q, want metadata name", theme.Name) | |
| 138 | + | } | |
| 139 | + | if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Accent != "#AABBCC" { | |
| 140 | + | t.Fatalf("parsed palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestParseDumberJSONDocsExample(t *testing.T) { | |
| 145 | + | path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-dumber-theme.json") | |
| 146 | + | data, err := os.ReadFile(path) | |
| 147 | + | if err != nil { | |
| 148 | + | t.Fatalf("ReadFile(%q) error = %v", path, err) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | theme, err := ParseDumberJSON(data) | |
| 152 | + | if err != nil { | |
| 153 | + | t.Fatalf("ParseDumberJSON(docs example) error = %v", err) | |
| 154 | + | } | |
| 155 | + | if theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" { | |
| 156 | + | t.Fatalf("parsed docs example = %+v", theme) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestParseDumberJSONPartialPalette(t *testing.T) { | |
| 161 | + | theme, err := ParseDumberJSON([]byte(`{ | |
| 162 | + | "light":{"background":"#FFFFFF","accent":"#112233"}, | |
| 163 | + | "dark":{"text":"#EEEEEE"} | |
| 164 | + | }`)) | |
| 165 | + | if err != nil { | |
| 166 | + | t.Fatalf("ParseDumberJSON() error = %v", err) | |
| 167 | + | } | |
| 168 | + | if theme.LightPalette.Surface != "" || theme.DarkPalette.Background != "" { | |
| 169 | + | t.Fatalf("missing fields should remain empty: %+v / %+v", theme.LightPalette, theme.DarkPalette) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestParseDumberJSONRequiresLightAndDarkObjects(t *testing.T) { | |
| 174 | + | cases := map[string]string{ | |
| 175 | + | "missing light": `{"dark":{}}`, | |
| 176 | + | "missing dark": `{"light":{}}`, | |
| 177 | + | } | |
| 178 | + | for name, input := range cases { | |
| 179 | + | t.Run(name, func(t *testing.T) { | |
| 180 | + | if _, err := ParseDumberJSON([]byte(input)); err == nil { | |
| 181 | + | t.Fatal("ParseDumberJSON() error = nil, want error") | |
| 182 | + | } | |
| 183 | + | }) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func TestParseDumberJSONMetadataNameFallsBackToSource(t *testing.T) { | |
| 188 | + | theme, err := ParseDumberJSON([]byte(`{"source":"noctalia-template","light":{},"dark":{}}`)) | |
| 189 | + | if err != nil { | |
| 190 | + | t.Fatalf("ParseDumberJSON() error = %v", err) | |
| 191 | + | } | |
| 192 | + | if theme.Name != "noctalia-template" { | |
| 193 | + | t.Fatalf("Name = %q, want source fallback", theme.Name) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestParseDumberJSONValidHexAcceptance(t *testing.T) { | |
| 198 | + | _, err := ParseDumberJSON([]byte(`{"light":{"accent":"#aBc123"},"dark":{"accent":"#ABC123"}}`)) | |
| 199 | + | if err != nil { | |
| 200 | + | t.Fatalf("ParseDumberJSON() error = %v", err) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestParseDumberJSONInvalidHexRejection(t *testing.T) { | |
| 205 | + | if _, err := ParseDumberJSON([]byte(`{"light":{"accent":"ABC123"},"dark":{}}`)); err == nil { | |
| 206 | + | t.Fatal("ParseDumberJSON() error = nil, want invalid hex error") | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func TestParseDumberJSONMalformedRejection(t *testing.T) { | |
| 211 | + | if _, err := ParseDumberJSON([]byte(`{"light":`)); err == nil { | |
| 212 | + | t.Fatal("ParseDumberJSON() error = nil, want malformed JSON error") | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestParseDumberJSONRejectsTrailingTokens(t *testing.T) { | |
| 217 | + | if _, err := ParseDumberJSON([]byte(`{"light":{},"dark":{}} {}`)); err == nil { | |
| 218 | + | t.Fatal("ParseDumberJSON() error = nil, want trailing data error") | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestExpandPath(t *testing.T) { | |
| 223 | + | t.Setenv("NOCTALIA_THEME_TEST", "theme.json") | |
| 224 | + | home := t.TempDir() | |
| 225 | + | t.Setenv("HOME", home) | |
| 226 | + | ||
| 227 | + | got, err := ExpandPath("~/$NOCTALIA_THEME_TEST") | |
| 228 | + | if err != nil { | |
| 229 | + | t.Fatalf("ExpandPath() error = %v", err) | |
| 230 | + | } | |
| 231 | + | want := filepath.Join(home, "theme.json") | |
| 232 | + | if got != want { | |
| 233 | + | t.Fatalf("ExpandPath() = %q, want %q", got, want) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestFileSourceDisabledBehavior(t *testing.T) { | |
| 238 | + | source := NewFileSource(false, "/does/not/exist") | |
| 239 | + | if source.IsEnabled() { | |
| 240 | + | t.Fatal("IsEnabled() = true, want false") | |
| 241 | + | } | |
| 242 | + | theme, err := source.Get(context.Background()) | |
| 243 | + | if err != nil { | |
| 244 | + | t.Fatalf("Get() error = %v", err) | |
| 245 | + | } | |
| 246 | + | if theme != nil { | |
| 247 | + | t.Fatalf("Get() theme = %+v, want nil", theme) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func TestFileSourceReadsColorsJSONByDefault(t *testing.T) { | |
| 252 | + | path := filepath.Join(t.TempDir(), "colors.json") | |
| 253 | + | if err := os.WriteFile(path, noctaliaColorsJSON(), 0o600); err != nil { | |
| 254 | + | t.Fatalf("WriteFile() error = %v", err) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | theme, err := NewFileSource(true, path).Get(context.Background()) | |
| 258 | + | if err != nil { | |
| 259 | + | t.Fatalf("Get() error = %v", err) | |
| 260 | + | } | |
| 261 | + | if theme.LightPalette.Accent != "#ffc799" || theme.DarkPalette.Background != "#000000" || theme.DarkPalette.SurfaceVariant != "#282828" { | |
| 262 | + | t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestFileSourceReadsDumberJSONWhenConfigured(t *testing.T) { | |
| 267 | + | path := filepath.Join(t.TempDir(), "theme.json") | |
| 268 | + | if err := os.WriteFile(path, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil { | |
| 269 | + | t.Fatalf("WriteFile() error = %v", err) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | source := NewFileSourceFromConfig(entity.ExternalThemeConfig{ | |
| 273 | + | Enabled: true, | |
| 274 | + | Provider: "noctalia", | |
| 275 | + | Format: "dumber-json", | |
| 276 | + | Path: path, | |
| 277 | + | }) | |
| 278 | + | theme, err := source.Get(context.Background()) | |
| 279 | + | if err != nil { | |
| 280 | + | t.Fatalf("Get() error = %v", err) | |
| 281 | + | } | |
| 282 | + | if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Background != "#000000" { | |
| 283 | + | t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func TestFileSourceConfigureUpdatesSettings(t *testing.T) { | |
| 288 | + | colorsPath := filepath.Join(t.TempDir(), "colors.json") | |
| 289 | + | if err := os.WriteFile(colorsPath, noctaliaColorsJSON(), 0o600); err != nil { | |
| 290 | + | t.Fatalf("WriteFile(colors) error = %v", err) | |
| 291 | + | } | |
| 292 | + | dumberPath := filepath.Join(t.TempDir(), "theme.json") | |
| 293 | + | if err := os.WriteFile(dumberPath, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil { | |
| 294 | + | t.Fatalf("WriteFile(dumber) error = %v", err) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | source := NewFileSourceFromConfig(entity.ExternalThemeConfig{}) | |
| 298 | + | if source.IsEnabled() { | |
| 299 | + | t.Fatal("IsEnabled() = true, want false") | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | source.Configure(entity.ExternalThemeConfig{ | |
| 303 | + | Enabled: true, | |
| 304 | + | Provider: " NOCTALIA ", | |
| 305 | + | Format: " COLORS-JSON ", | |
| 306 | + | Path: colorsPath, | |
| 307 | + | }) | |
| 308 | + | if !source.IsEnabled() { | |
| 309 | + | t.Fatal("IsEnabled() = false for colors-json, want true") | |
| 310 | + | } | |
| 311 | + | if _, err := source.Get(context.Background()); err != nil { | |
| 312 | + | t.Fatalf("Get() colors-json after Configure error = %v", err) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | source.Configure(entity.ExternalThemeConfig{ | |
| 316 | + | Enabled: true, | |
| 317 | + | Provider: "noctalia", | |
| 318 | + | Format: "dumber-json", | |
| 319 | + | Path: dumberPath, | |
| 320 | + | }) | |
| 321 | + | if !source.IsEnabled() { | |
| 322 | + | t.Fatal("IsEnabled() = false for dumber-json, want true") | |
| 323 | + | } | |
| 324 | + | if _, err := source.Get(context.Background()); err != nil { | |
| 325 | + | t.Fatalf("Get() dumber-json after Configure error = %v", err) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "other", Format: "colors-json", Path: colorsPath}) | |
| 329 | + | if source.IsEnabled() { | |
| 330 | + | t.Fatal("IsEnabled() = true for unsupported provider, want false") | |
| 331 | + | } | |
| 332 | + | source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "noctalia", Format: "toml", Path: colorsPath}) | |
| 333 | + | if source.IsEnabled() { | |
| 334 | + | t.Fatal("IsEnabled() = true for unsupported format, want false") | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func noctaliaColorsJSON() []byte { | |
| 339 | + | return []byte(`{ | |
| 340 | + | "mError": "#ff8080", | |
| 341 | + | "mHover": "#282828", | |
| 342 | + | "mOnError": "#000000", | |
| 343 | + | "mOnHover": "#ffffff", | |
| 344 | + | "mOnPrimary": "#000000", | |
| 345 | + | "mOnSecondary": "#000000", | |
| 346 | + | "mOnSurface": "#ffffff", | |
| 347 | + | "mOnSurfaceVariant": "#a0a0a0", | |
| 348 | + | "mOnTertiary": "#000000", | |
| 349 | + | "mOutline": "#505050", | |
| 350 | + | "mPrimary": "#ffc799", | |
| 351 | + | "mSecondary": "#99ffe4", | |
| 352 | + | "mShadow": "#000000", | |
| 353 | + | "mSurface": "#0c0c0c", | |
| 354 | + | "mSurfaceVariant": "#1c1c1c", | |
| 355 | + | "mTertiary": "#fbadff" | |
| 356 | + | }`) | |
| 357 | + | } | |
A internal/infrastructure/externaltheme/noctalia/watcher.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | package noctalia | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "log/slog" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sync" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | "github.com/fsnotify/fsnotify" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const defaultDebounceDelay = 75 * time.Millisecond | |
| 18 | + | ||
| 19 | + | // FileWatcher watches one Noctalia external theme file. | |
| 20 | + | // | |
| 21 | + | // It watches the file's parent directory so atomic writes and renames are | |
| 22 | + | // observed, then filters events to the configured file path. | |
| 23 | + | type FileWatcher struct { | |
| 24 | + | mu sync.Mutex | |
| 25 | + | delay time.Duration | |
| 26 | + | watcher *fsnotify.Watcher | |
| 27 | + | path string | |
| 28 | + | stopCtx context.Context | |
| 29 | + | stopFunc context.CancelFunc | |
| 30 | + | running bool | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // NewFileWatcher creates a file watcher with the default debounce delay. | |
| 34 | + | func NewFileWatcher() *FileWatcher { | |
| 35 | + | return &FileWatcher{delay: defaultDebounceDelay} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Start starts watching the configured external theme path. | |
| 39 | + | // | |
| 40 | + | // Calling Start with the same enabled path while already running is a no-op. | |
| 41 | + | // Calling Start with a different enabled path restarts the underlying watcher. | |
| 42 | + | // Disabled, unsupported, or empty-path config stops any existing watcher. | |
| 43 | + | func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error { | |
| 44 | + | path, enabled, err := watchPathFromConfig(cfg) | |
| 45 | + | if err != nil { | |
| 46 | + | _ = w.Stop() | |
| 47 | + | return err | |
| 48 | + | } | |
| 49 | + | if !enabled || path == "" { | |
| 50 | + | return w.Stop() | |
| 51 | + | } | |
| 52 | + | if onChange == nil { | |
| 53 | + | return errors.New("noctalia file watcher callback is nil") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | w.mu.Lock() | |
| 57 | + | defer w.mu.Unlock() | |
| 58 | + | if w.running && w.path == path { | |
| 59 | + | return nil | |
| 60 | + | } | |
| 61 | + | if stopErr := w.stopLocked(); stopErr != nil { | |
| 62 | + | return fmt.Errorf("stop previous noctalia theme watcher: %w", stopErr) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | parent := filepath.Dir(path) | |
| 66 | + | info, statErr := os.Stat(parent) | |
| 67 | + | if statErr != nil { | |
| 68 | + | return fmt.Errorf("watch noctalia theme parent directory: %w", statErr) | |
| 69 | + | } else if !info.IsDir() { | |
| 70 | + | return fmt.Errorf("watch noctalia theme parent directory: %s is not a directory", parent) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | watcher, err := fsnotify.NewWatcher() | |
| 74 | + | if err != nil { | |
| 75 | + | return fmt.Errorf("create noctalia theme watcher: %w", err) | |
| 76 | + | } | |
| 77 | + | if err := watcher.Add(parent); err != nil { | |
| 78 | + | _ = watcher.Close() | |
| 79 | + | return fmt.Errorf("watch noctalia theme parent directory: %w", err) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | runCtx, cancel := context.WithCancel(ctx) | |
| 83 | + | w.watcher = watcher | |
| 84 | + | w.path = path | |
| 85 | + | w.stopCtx = runCtx | |
| 86 | + | w.stopFunc = cancel | |
| 87 | + | w.running = true | |
| 88 | + | ||
| 89 | + | go w.run(runCtx, watcher, path, onChange) | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Stop stops the current watcher. It is safe to call repeatedly. | |
| 94 | + | func (w *FileWatcher) Stop() error { | |
| 95 | + | w.mu.Lock() | |
| 96 | + | defer w.mu.Unlock() | |
| 97 | + | return w.stopLocked() | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (w *FileWatcher) stopLocked() error { | |
| 101 | + | if !w.running { | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | if w.stopFunc != nil { | |
| 105 | + | w.stopFunc() | |
| 106 | + | } | |
| 107 | + | err := w.watcher.Close() | |
| 108 | + | w.watcher = nil | |
| 109 | + | w.path = "" | |
| 110 | + | w.stopCtx = nil | |
| 111 | + | w.stopFunc = nil | |
| 112 | + | w.running = false | |
| 113 | + | return err | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (w *FileWatcher) run(ctx context.Context, watcher *fsnotify.Watcher, path string, onChange func()) { | |
| 117 | + | var timer *time.Timer | |
| 118 | + | var timerC <-chan time.Time | |
| 119 | + | defer func() { | |
| 120 | + | if timer != nil { | |
| 121 | + | timer.Stop() | |
| 122 | + | } | |
| 123 | + | w.mu.Lock() | |
| 124 | + | if w.watcher == watcher { | |
| 125 | + | _ = w.stopLocked() | |
| 126 | + | } | |
| 127 | + | w.mu.Unlock() | |
| 128 | + | }() | |
| 129 | + | ||
| 130 | + | for { | |
| 131 | + | select { | |
| 132 | + | case <-ctx.Done(): | |
| 133 | + | return | |
| 134 | + | case event, ok := <-watcher.Events: | |
| 135 | + | if !ok { | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | if !isRelevantFileEvent(event, path) { | |
| 139 | + | continue | |
| 140 | + | } | |
| 141 | + | if timer == nil { | |
| 142 | + | timer = time.NewTimer(w.delay) | |
| 143 | + | timerC = timer.C | |
| 144 | + | } else { | |
| 145 | + | if !timer.Stop() { | |
| 146 | + | select { | |
| 147 | + | case <-timer.C: | |
| 148 | + | default: | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | timer.Reset(w.delay) | |
| 152 | + | timerC = timer.C | |
| 153 | + | } | |
| 154 | + | case err, ok := <-watcher.Errors: | |
| 155 | + | if !ok { | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | slog.Warn("noctalia theme watcher error", "error", err) | |
| 159 | + | case <-timerC: | |
| 160 | + | timerC = nil | |
| 161 | + | onChange() | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func watchPathFromConfig(cfg entity.ExternalThemeConfig) (string, bool, error) { | |
| 167 | + | provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format) | |
| 168 | + | enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format) | |
| 169 | + | if !enabled { | |
| 170 | + | return "", false, nil | |
| 171 | + | } | |
| 172 | + | path, err := ExpandPath(cfg.Path) | |
| 173 | + | if err != nil { | |
| 174 | + | return "", false, err | |
| 175 | + | } | |
| 176 | + | return path, path != "", nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func isRelevantFileEvent(event fsnotify.Event, path string) bool { | |
| 180 | + | if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 { | |
| 181 | + | return false | |
| 182 | + | } | |
| 183 | + | return filepath.Clean(event.Name) == path | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (w *FileWatcher) isRunningForTest() bool { | |
| 187 | + | w.mu.Lock() | |
| 188 | + | defer w.mu.Unlock() | |
| 189 | + | return w.running | |
| 190 | + | } | |
A internal/infrastructure/externaltheme/noctalia/watcher_test.go +263 -0
| @@ -0,0 +1,263 @@ | |||
| 1 | + | package noctalia | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestFileWatcherStartStopAndContextCancellation(t *testing.T) { | |
| 14 | + | path := writeThemeFile(t, t.TempDir(), "theme.json") | |
| 15 | + | watcher := newFastWatcher() | |
| 16 | + | ||
| 17 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 18 | + | if err := watcher.Start(ctx, watcherConfig(path), func() {}); err != nil { | |
| 19 | + | t.Fatalf("Start() error = %v", err) | |
| 20 | + | } | |
| 21 | + | if !watcher.isRunningForTest() { | |
| 22 | + | t.Fatal("watcher is not running after Start") | |
| 23 | + | } | |
| 24 | + | if err := watcher.Stop(); err != nil { | |
| 25 | + | t.Fatalf("Stop() error = %v", err) | |
| 26 | + | } | |
| 27 | + | if err := watcher.Stop(); err != nil { | |
| 28 | + | t.Fatalf("second Stop() error = %v", err) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | if err := watcher.Start(ctx, watcherConfig(path), func() {}); err != nil { | |
| 32 | + | t.Fatalf("Start() after Stop error = %v", err) | |
| 33 | + | } | |
| 34 | + | cancel() | |
| 35 | + | waitUntil(t, func() bool { return !watcher.isRunningForTest() }) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestFileWatcherStartSamePathDoesNotDuplicateWatcher(t *testing.T) { | |
| 39 | + | dir := t.TempDir() | |
| 40 | + | path := writeThemeFile(t, dir, "theme.json") | |
| 41 | + | watcher := newFastWatcher() | |
| 42 | + | changes := make(chan struct{}, 4) | |
| 43 | + | ||
| 44 | + | if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil { | |
| 45 | + | t.Fatalf("Start() error = %v", err) | |
| 46 | + | } | |
| 47 | + | if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil { | |
| 48 | + | t.Fatalf("second Start() same path error = %v", err) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | writeThemeFile(t, dir, "theme.json") | |
| 52 | + | waitForChange(t, changes) | |
| 53 | + | assertNoChange(t, changes, 60*time.Millisecond) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestFileWatcherPathChangeRestartsAndDisabledStops(t *testing.T) { | |
| 57 | + | dir := t.TempDir() | |
| 58 | + | first := writeThemeFile(t, dir, "first.json") | |
| 59 | + | second := writeThemeFile(t, dir, "second.json") | |
| 60 | + | watcher := newFastWatcher() | |
| 61 | + | changes := make(chan string, 8) | |
| 62 | + | ||
| 63 | + | if err := watcher.Start(context.Background(), watcherConfig(first), func() { changes <- "first" }); err != nil { | |
| 64 | + | t.Fatalf("Start(first) error = %v", err) | |
| 65 | + | } | |
| 66 | + | if err := watcher.Start(context.Background(), watcherConfig(second), func() { changes <- "second" }); err != nil { | |
| 67 | + | t.Fatalf("Start(second) error = %v", err) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | writeThemeFile(t, dir, "first.json") | |
| 71 | + | assertNoStringChange(t, changes, 80*time.Millisecond) | |
| 72 | + | writeThemeFile(t, dir, "second.json") | |
| 73 | + | if got := waitForStringChange(t, changes); got != "second" { | |
| 74 | + | t.Fatalf("callback = %q, want second", got) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | cfg := watcherConfig(second) | |
| 78 | + | cfg.Enabled = false | |
| 79 | + | if err := watcher.Start(context.Background(), cfg, func() { changes <- "disabled" }); err != nil { | |
| 80 | + | t.Fatalf("Start(disabled) error = %v", err) | |
| 81 | + | } | |
| 82 | + | if watcher.isRunningForTest() { | |
| 83 | + | t.Fatal("watcher running after disabled config") | |
| 84 | + | } | |
| 85 | + | writeThemeFile(t, dir, "second.json") | |
| 86 | + | assertNoStringChange(t, changes, 80*time.Millisecond) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestFileWatcherDebouncesBurstToOneCallback(t *testing.T) { | |
| 90 | + | dir := t.TempDir() | |
| 91 | + | path := writeThemeFile(t, dir, "theme.json") | |
| 92 | + | watcher := newFastWatcher() | |
| 93 | + | changes := make(chan struct{}, 8) | |
| 94 | + | ||
| 95 | + | if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil { | |
| 96 | + | t.Fatalf("Start() error = %v", err) | |
| 97 | + | } | |
| 98 | + | for range 5 { | |
| 99 | + | writeThemeFile(t, dir, "theme.json") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | waitForChange(t, changes) | |
| 103 | + | assertNoChange(t, changes, 80*time.Millisecond) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestFileWatcherAtomicRenameWriteEmitsCallback(t *testing.T) { | |
| 107 | + | dir := t.TempDir() | |
| 108 | + | path := writeThemeFile(t, dir, "theme.json") | |
| 109 | + | watcher := newFastWatcher() | |
| 110 | + | changes := make(chan struct{}, 4) | |
| 111 | + | ||
| 112 | + | if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil { | |
| 113 | + | t.Fatalf("Start() error = %v", err) | |
| 114 | + | } | |
| 115 | + | tmp := filepath.Join(dir, "theme.json.tmp") | |
| 116 | + | if err := os.WriteFile(tmp, []byte(`{"light":{},"dark":{}}`), 0o600); err != nil { | |
| 117 | + | t.Fatalf("WriteFile(tmp) error = %v", err) | |
| 118 | + | } | |
| 119 | + | if err := os.Rename(tmp, path); err != nil { | |
| 120 | + | t.Fatalf("Rename() error = %v", err) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | waitForChange(t, changes) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestFileWatcherInvalidOrEmptyPathBehavior(t *testing.T) { | |
| 127 | + | watcher := newFastWatcher() | |
| 128 | + | if err := watcher.Start(context.Background(), watcherConfig(""), nil); err != nil { | |
| 129 | + | t.Fatalf("Start(empty path) error = %v", err) | |
| 130 | + | } | |
| 131 | + | if watcher.isRunningForTest() { | |
| 132 | + | t.Fatal("watcher running for empty path") | |
| 133 | + | } | |
| 134 | + | missingParent := filepath.Join(t.TempDir(), "missing", "theme.json") | |
| 135 | + | if err := watcher.Start(context.Background(), watcherConfig(missingParent), func() {}); err == nil { | |
| 136 | + | t.Fatal("Start(missing parent) error = nil, want error") | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestWatchPathFromConfigSupportsNoctaliaFormats(t *testing.T) { | |
| 141 | + | path := filepath.Join(t.TempDir(), "theme.json") | |
| 142 | + | for _, format := range []string{"colors-json", "dumber-json"} { | |
| 143 | + | t.Run(format, func(t *testing.T) { | |
| 144 | + | got, enabled, err := watchPathFromConfig(entity.ExternalThemeConfig{ | |
| 145 | + | Enabled: true, | |
| 146 | + | Provider: " NOCTALIA ", | |
| 147 | + | Format: " " + format + " ", | |
| 148 | + | Path: path, | |
| 149 | + | }) | |
| 150 | + | if err != nil { | |
| 151 | + | t.Fatalf("watchPathFromConfig() error = %v", err) | |
| 152 | + | } | |
| 153 | + | if !enabled { | |
| 154 | + | t.Fatal("enabled = false, want true") | |
| 155 | + | } | |
| 156 | + | if got != path { | |
| 157 | + | t.Fatalf("path = %q, want %q", got, path) | |
| 158 | + | } | |
| 159 | + | }) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | _, enabled, err := watchPathFromConfig(entity.ExternalThemeConfig{ | |
| 163 | + | Enabled: true, | |
| 164 | + | Provider: "noctalia", | |
| 165 | + | Format: "toml", | |
| 166 | + | Path: path, | |
| 167 | + | }) | |
| 168 | + | if err != nil { | |
| 169 | + | t.Fatalf("watchPathFromConfig(unsupported) error = %v", err) | |
| 170 | + | } | |
| 171 | + | if enabled { | |
| 172 | + | t.Fatal("enabled = true for unsupported format, want false") | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestFileWatcherInvalidPathStopsPreviousWatcher(t *testing.T) { | |
| 177 | + | dir := t.TempDir() | |
| 178 | + | path := writeThemeFile(t, dir, "theme.json") | |
| 179 | + | watcher := newFastWatcher() | |
| 180 | + | changes := make(chan struct{}, 4) | |
| 181 | + | ||
| 182 | + | if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil { | |
| 183 | + | t.Fatalf("Start(valid) error = %v", err) | |
| 184 | + | } | |
| 185 | + | missingParent := filepath.Join(dir, "missing", "theme.json") | |
| 186 | + | if err := watcher.Start(context.Background(), watcherConfig(missingParent), func() { changes <- struct{}{} }); err == nil { | |
| 187 | + | t.Fatal("Start(missing parent) error = nil, want error") | |
| 188 | + | } | |
| 189 | + | if watcher.isRunningForTest() { | |
| 190 | + | t.Fatal("watcher running after invalid path") | |
| 191 | + | } | |
| 192 | + | writeThemeFile(t, dir, "theme.json") | |
| 193 | + | assertNoChange(t, changes, 80*time.Millisecond) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func newFastWatcher() *FileWatcher { | |
| 197 | + | watcher := NewFileWatcher() | |
| 198 | + | watcher.delay = 20 * time.Millisecond | |
| 199 | + | return watcher | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func watcherConfig(path string) entity.ExternalThemeConfig { | |
| 203 | + | return entity.ExternalThemeConfig{Enabled: true, Provider: providerName, Format: formatName, Path: path} | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func writeThemeFile(t *testing.T, dir, name string) string { | |
| 207 | + | t.Helper() | |
| 208 | + | path := filepath.Join(dir, name) | |
| 209 | + | if err := os.WriteFile(path, []byte(`{"light":{},"dark":{}}`), 0o600); err != nil { | |
| 210 | + | t.Fatalf("WriteFile(%s) error = %v", name, err) | |
| 211 | + | } | |
| 212 | + | return path | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func waitForChange(t *testing.T, ch <-chan struct{}) { | |
| 216 | + | t.Helper() | |
| 217 | + | select { | |
| 218 | + | case <-ch: | |
| 219 | + | case <-time.After(500 * time.Millisecond): | |
| 220 | + | t.Fatal("timed out waiting for callback") | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func assertNoChange(t *testing.T, ch <-chan struct{}, d time.Duration) { | |
| 225 | + | t.Helper() | |
| 226 | + | select { | |
| 227 | + | case <-ch: | |
| 228 | + | t.Fatal("unexpected callback") | |
| 229 | + | case <-time.After(d): | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func waitForStringChange(t *testing.T, ch <-chan string) string { | |
| 234 | + | t.Helper() | |
| 235 | + | select { | |
| 236 | + | case got := <-ch: | |
| 237 | + | return got | |
| 238 | + | case <-time.After(500 * time.Millisecond): | |
| 239 | + | t.Fatal("timed out waiting for callback") | |
| 240 | + | } | |
| 241 | + | return "" | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) { | |
| 245 | + | t.Helper() | |
| 246 | + | select { | |
| 247 | + | case got := <-ch: | |
| 248 | + | t.Fatalf("unexpected callback %q", got) | |
| 249 | + | case <-time.After(d): | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func waitUntil(t *testing.T, condition func() bool) { | |
| 254 | + | t.Helper() | |
| 255 | + | deadline := time.Now().Add(500 * time.Millisecond) | |
| 256 | + | for time.Now().Before(deadline) { | |
| 257 | + | if condition() { | |
| 258 | + | return | |
| 259 | + | } | |
| 260 | + | time.Sleep(5 * time.Millisecond) | |
| 261 | + | } | |
| 262 | + | t.Fatal("condition not met before timeout") | |
| 263 | + | } | |
M internal/ui/app.go +103 -31
| @@ -3072,6 +3072,11 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 3072 | 3072 | ||
| 3073 | 3073 | // Stop accepting relaunches before teardown starts. | |
| 3074 | 3074 | a.closeBrowserLaunchRelayListener() | |
| 3075 | + | if a.deps != nil && a.deps.ExternalThemeWatcher != nil { | |
| 3076 | + | if err := a.deps.ExternalThemeWatcher.Stop(); err != nil { | |
| 3077 | + | log.Warn().Err(err).Msg("failed to stop external theme watcher") | |
| 3078 | + | } | |
| 3079 | + | } | |
| 3075 | 3080 | ||
| 3076 | 3081 | // Cancel context to signal all goroutines | |
| 3077 | 3082 | a.cancel(errors.New("application shutdown")) | |
| @@ -5001,12 +5006,14 @@ func (a *App) UpdateOmniboxZoom(factor float64) { | |||
| 5001 | 5006 | func (a *App) initConfigWatcher(ctx context.Context) { | |
| 5002 | 5007 | log := logging.FromContext(ctx) | |
| 5003 | 5008 | ||
| 5009 | + | a.syncExternalThemeWatcher(ctx) | |
| 5010 | + | ||
| 5004 | 5011 | if a.deps.WatchConfig == nil || a.deps.OnConfigChange == nil { | |
| 5005 | - | log.Debug().Msg("no config watcher available, skipping") | |
| 5012 | + | log.Debug().Msg("no config watcher available, skipping config file watcher") | |
| 5006 | 5013 | return | |
| 5007 | 5014 | } | |
| 5008 | 5015 | ||
| 5009 | - | // Start viper watcher | |
| 5016 | + | // Start viper watcher. | |
| 5010 | 5017 | if err := a.deps.WatchConfig(); err != nil { | |
| 5011 | 5018 | log.Warn().Err(err).Msg("failed to start config watcher") | |
| 5012 | 5019 | return | |
| @@ -5016,6 +5023,7 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||
| 5016 | 5023 | // deps.Config is updated in-place before this callback fires. | |
| 5017 | 5024 | a.deps.OnConfigChange(func() { | |
| 5018 | 5025 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 5026 | + | a.syncExternalThemeWatcher(ctx) | |
| 5019 | 5027 | a.applyAppearanceConfig(ctx) | |
| 5020 | 5028 | for _, bw := range a.browserWindows { | |
| 5021 | 5029 | if bw == nil { | |
| @@ -5036,6 +5044,21 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||
| 5036 | 5044 | log.Debug().Msg("config watcher initialized") | |
| 5037 | 5045 | } | |
| 5038 | 5046 | ||
| 5047 | + | func (a *App) syncExternalThemeWatcher(ctx context.Context) { | |
| 5048 | + | if a == nil || a.deps == nil || a.deps.Config == nil || a.deps.ExternalThemeWatcher == nil { | |
| 5049 | + | return | |
| 5050 | + | } | |
| 5051 | + | log := logging.FromContext(ctx) | |
| 5052 | + | cfg := a.deps.Config.Appearance.ExternalTheme | |
| 5053 | + | if err := a.deps.ExternalThemeWatcher.Start(ctx, cfg, func() { | |
| 5054 | + | a.dispatchOnMainThread("ui.external_theme_reload", func() { | |
| 5055 | + | a.applyAppearanceConfig(ctx) | |
| 5056 | + | }) | |
| 5057 | + | }); err != nil { | |
| 5058 | + | log.Warn().Err(err).Msg("failed to start external theme watcher") | |
| 5059 | + | } | |
| 5060 | + | } | |
| 5061 | + | ||
| 5039 | 5062 | func (a *App) applyAppearanceConfig(ctx context.Context) { | |
| 5040 | 5063 | log := logging.FromContext(ctx) | |
| 5041 | 5064 | if a.deps == nil || a.deps.Config == nil { | |
| @@ -5057,43 +5080,92 @@ func (a *App) applyAppearanceConfig(ctx context.Context) { | |||
| 5057 | 5080 | a.contentCoord.ApplySettingsToAll(ctx) | |
| 5058 | 5081 | } | |
| 5059 | 5082 | ||
| 5060 | - | // Update GTK theme and injected WebUI theme vars | |
| 5061 | - | if a.deps.Theme != nil { | |
| 5062 | - | var display *gdk.Display | |
| 5063 | - | if a.mainWindow != nil && a.mainWindow.Window() != nil { | |
| 5064 | - | display = a.mainWindow.Window().GetDisplay() | |
| 5065 | - | } | |
| 5066 | - | a.deps.Theme.UpdateFromConfig(ctx, &cfg.Appearance, cfg.DefaultUIScale, &cfg.Workspace.Styling, display) | |
| 5083 | + | a.applyThemeAppearance(ctx) | |
| 5067 | 5084 | ||
| 5068 | - | var inj port.ContentInjector | |
| 5069 | - | if a.engine != nil { | |
| 5070 | - | inj = a.engine.ContentInjector() | |
| 5071 | - | } | |
| 5085 | + | log.Info().Msg("appearance config updated") | |
| 5086 | + | } | |
| 5072 | 5087 | ||
| 5073 | - | if inj != nil { | |
| 5074 | - | findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette()) | |
| 5075 | - | if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil { | |
| 5076 | - | log.Warn().Err(err).Msg("failed to update find highlight CSS") | |
| 5077 | - | } | |
| 5078 | - | } | |
| 5088 | + | func (a *App) applyThemeAppearance(ctx context.Context) { | |
| 5089 | + | log := logging.FromContext(ctx) | |
| 5090 | + | if a.deps == nil || a.deps.Config == nil || a.deps.Theme == nil { | |
| 5091 | + | return | |
| 5092 | + | } | |
| 5093 | + | cfg := a.deps.Config | |
| 5079 | 5094 | ||
| 5080 | - | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj) | |
| 5081 | - | cssText := a.deps.Theme.GetWebUIThemeCSS() | |
| 5082 | - | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil { | |
| 5083 | - | log.Warn().Err(err).Msg("failed to update WebUI theme CSS") | |
| 5084 | - | } | |
| 5095 | + | var display *gdk.Display | |
| 5096 | + | if a.mainWindow != nil && a.mainWindow.Window() != nil { | |
| 5097 | + | display = a.mainWindow.Window().GetDisplay() | |
| 5098 | + | } | |
| 5085 | 5099 | ||
| 5086 | - | if a.contentCoord != nil && inj != nil { | |
| 5087 | - | a.contentCoord.RefreshInjectedScriptsToAll(ctx) | |
| 5088 | - | } | |
| 5100 | + | preference := port.ColorSchemePreference{PrefersDark: a.deps.Theme.PrefersDark(), Source: "theme"} | |
| 5101 | + | if a.deps.ColorResolver != nil { | |
| 5102 | + | preference = a.deps.ColorResolver.Refresh() | |
| 5103 | + | } | |
| 5104 | + | if a.deps.ExternalThemeSource != nil { | |
| 5105 | + | a.deps.ExternalThemeSource.Configure(cfg.Appearance.ExternalTheme) | |
| 5106 | + | } | |
| 5107 | + | resolveThemeUC := a.deps.ResolveThemeUC | |
| 5108 | + | if resolveThemeUC == nil { | |
| 5109 | + | resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource) | |
| 5110 | + | } | |
| 5111 | + | resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig( | |
| 5112 | + | &cfg.Appearance, | |
| 5113 | + | cfg.DefaultUIScale, | |
| 5114 | + | &cfg.Workspace.Styling, | |
| 5115 | + | preference, | |
| 5116 | + | )) | |
| 5117 | + | if err != nil { | |
| 5118 | + | log.Warn().Err(err).Msg("failed to resolve theme") | |
| 5119 | + | } else { | |
| 5120 | + | logThemeResolution(ctx, resolved.Theme) | |
| 5121 | + | a.deps.Theme.UpdateFromResolved(ctx, resolved.Theme, display) | |
| 5122 | + | a.setupPoolBackgroundColor(ctx) | |
| 5123 | + | } | |
| 5124 | + | ||
| 5125 | + | var inj port.ContentInjector | |
| 5126 | + | if a.engine != nil { | |
| 5127 | + | inj = a.engine.ContentInjector() | |
| 5128 | + | } | |
| 5089 | 5129 | ||
| 5090 | - | // Apply WebUI theme to already-loaded dumb:// pages | |
| 5091 | - | if a.contentCoord != nil { | |
| 5092 | - | a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText) | |
| 5130 | + | if inj != nil { | |
| 5131 | + | findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette()) | |
| 5132 | + | if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil { | |
| 5133 | + | log.Warn().Err(err).Msg("failed to update find highlight CSS") | |
| 5093 | 5134 | } | |
| 5094 | 5135 | } | |
| 5095 | 5136 | ||
| 5096 | - | log.Info().Msg("appearance config updated") | |
| 5137 | + | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj) | |
| 5138 | + | cssText := a.deps.Theme.GetWebUIThemeCSS() | |
| 5139 | + | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil { | |
| 5140 | + | log.Warn().Err(err).Msg("failed to update WebUI theme CSS") | |
| 5141 | + | } | |
| 5142 | + | ||
| 5143 | + | if a.contentCoord != nil && inj != nil { | |
| 5144 | + | a.contentCoord.RefreshInjectedScriptsToAll(ctx) | |
| 5145 | + | } | |
| 5146 | + | ||
| 5147 | + | // Apply WebUI theme to already-loaded dumb:// pages | |
| 5148 | + | if a.contentCoord != nil { | |
| 5149 | + | a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText) | |
| 5150 | + | } | |
| 5151 | + | } | |
| 5152 | + | ||
| 5153 | + | func logThemeResolution(ctx context.Context, resolved entity.ResolvedTheme) { | |
| 5154 | + | log := logging.FromContext(ctx) | |
| 5155 | + | for _, warning := range resolved.Warnings { | |
| 5156 | + | log.Warn().Str("field", warning.Field).Msg(warning.Message) | |
| 5157 | + | } | |
| 5158 | + | event := log.Info(). | |
| 5159 | + | Str("theme_source", string(resolved.ThemeSource.Kind)). | |
| 5160 | + | Str("color_scheme_source", resolved.ColorSchemeSource). | |
| 5161 | + | Bool("prefers_dark", resolved.PrefersDark) | |
| 5162 | + | if resolved.ThemeSource.Provider != "" { | |
| 5163 | + | event = event.Str("provider", resolved.ThemeSource.Provider) | |
| 5164 | + | } | |
| 5165 | + | if resolved.ThemeSource.LastGood { | |
| 5166 | + | event = event.Bool("last_good", true) | |
| 5167 | + | } | |
| 5168 | + | event.Msg("theme resolved") | |
| 5097 | 5169 | } | |
| 5098 | 5170 | ||
| 5099 | 5171 | func (a *App) initFilteringAsync(ctx context.Context) { | |
M internal/ui/deps.go +6 -3
| @@ -28,9 +28,12 @@ type Dependencies struct { | |||
| 28 | 28 | OnCrashReportsDetected func([]string) | |
| 29 | 29 | ||
| 30 | 30 | // Theme and color scheme management | |
| 31 | - | Theme *theme.Manager | |
| 32 | - | ColorResolver port.ColorSchemeResolver | |
| 33 | - | AdwaitaDetector port.ToolkitAvailabilityNotifier | |
| 31 | + | Theme *theme.Manager | |
| 32 | + | ResolveThemeUC *usecase.ResolveThemeUseCase | |
| 33 | + | ExternalThemeSource port.ConfigurableExternalThemeSource | |
| 34 | + | ExternalThemeWatcher port.ExternalThemeWatcher | |
| 35 | + | ColorResolver port.ColorSchemeResolver | |
| 36 | + | AdwaitaDetector port.ToolkitAvailabilityNotifier | |
| 34 | 37 | ||
| 35 | 38 | // XDG paths | |
| 36 | 39 | XDG port.XDGPaths | |
A internal/ui/external_theme_reload_test.go +270 -0
| @@ -0,0 +1,270 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | + | "github.com/bnema/dumber/internal/shared/syncdispatch" | |
| 16 | + | uitheme "github.com/bnema/dumber/internal/ui/theme" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type recordingExternalThemeWatcher struct { | |
| 20 | + | starts int | |
| 21 | + | stops int | |
| 22 | + | cfg entity.ExternalThemeConfig | |
| 23 | + | trigger func() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (w *recordingExternalThemeWatcher) Start(_ context.Context, cfg entity.ExternalThemeConfig, onChange func()) error { | |
| 27 | + | w.starts++ | |
| 28 | + | w.cfg = cfg | |
| 29 | + | w.trigger = onChange | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (w *recordingExternalThemeWatcher) Stop() error { | |
| 34 | + | w.stops++ | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type mutableExternalThemeSource struct { | |
| 39 | + | cfg entity.ExternalThemeConfig | |
| 40 | + | theme *entity.ExternalTheme | |
| 41 | + | err error | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (s *mutableExternalThemeSource) Configure(cfg entity.ExternalThemeConfig) { | |
| 45 | + | s.cfg = cfg | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (s *mutableExternalThemeSource) ExternalThemeIdentity() string { | |
| 49 | + | if !s.cfg.Enabled { | |
| 50 | + | return "" | |
| 51 | + | } | |
| 52 | + | return s.cfg.Provider + "|" + s.cfg.Format + "|" + s.cfg.Path | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (s *mutableExternalThemeSource) IsEnabled() bool { | |
| 56 | + | return s.cfg.Enabled | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (s *mutableExternalThemeSource) Get(context.Context) (*entity.ExternalTheme, error) { | |
| 60 | + | if !s.IsEnabled() { | |
| 61 | + | return nil, nil | |
| 62 | + | } | |
| 63 | + | return s.theme, s.err | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func immediateDispatchForExternalThemeTest(label string, fn func()) syncdispatch.SyncDispatchResult { | |
| 67 | + | fn() | |
| 68 | + | return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted} | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestExternalThemeWatcherCallbackAppliesThemeThroughSharedPath(t *testing.T) { | |
| 72 | + | ctx := context.Background() | |
| 73 | + | cfg := config.DefaultConfig() | |
| 74 | + | cfg.Appearance.ColorScheme = "prefer-dark" | |
| 75 | + | cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{ | |
| 76 | + | Enabled: true, | |
| 77 | + | Provider: "noctalia", | |
| 78 | + | Format: "dumber-json", | |
| 79 | + | Path: "/tmp/theme.json", | |
| 80 | + | } | |
| 81 | + | source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")} | |
| 82 | + | watcher := &recordingExternalThemeWatcher{} | |
| 83 | + | manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette)) | |
| 84 | + | engine := portmocks.NewMockEngine(t) | |
| 85 | + | var backgroundUpdated bool | |
| 86 | + | engine.EXPECT().UpdateSettings(mock.Anything, mock.Anything).Return(nil).Once() | |
| 87 | + | engine.EXPECT().UpdateAppearance(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 88 | + | Run(func(_ context.Context, r, g, b, alpha float64) { | |
| 89 | + | backgroundUpdated = true | |
| 90 | + | require.InDelta(t, 0.0, r, 0.000001) | |
| 91 | + | require.InDelta(t, 0.0, g, 0.000001) | |
| 92 | + | require.InDelta(t, 0.0, b, 0.000001) | |
| 93 | + | require.InDelta(t, 1.0, alpha, 0.000001) | |
| 94 | + | }). | |
| 95 | + | Return(nil). | |
| 96 | + | Once() | |
| 97 | + | engine.EXPECT().ContentInjector().Return(nil).Once() | |
| 98 | + | app := &App{ | |
| 99 | + | deps: &Dependencies{ | |
| 100 | + | Config: cfg, | |
| 101 | + | Theme: manager, | |
| 102 | + | ResolveThemeUC: usecase.NewResolveThemeUseCase(source), | |
| 103 | + | ExternalThemeSource: source, | |
| 104 | + | ExternalThemeWatcher: watcher, | |
| 105 | + | }, | |
| 106 | + | dispatchOnMainThread: immediateDispatchForExternalThemeTest, | |
| 107 | + | engine: engine, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | app.syncExternalThemeWatcher(ctx) | |
| 111 | + | require.Equal(t, 1, watcher.starts) | |
| 112 | + | require.Equal(t, cfg.Appearance.ExternalTheme, watcher.cfg) | |
| 113 | + | require.NotNil(t, watcher.trigger) | |
| 114 | + | ||
| 115 | + | watcher.trigger() | |
| 116 | + | ||
| 117 | + | require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background) | |
| 118 | + | require.True(t, backgroundUpdated, "theme reload should refresh engine background color") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestExternalThemeReloadKeepsLastGoodAndDisablingClearsIt(t *testing.T) { | |
| 122 | + | ctx := context.Background() | |
| 123 | + | cfg := config.DefaultConfig() | |
| 124 | + | cfg.Appearance.ColorScheme = "prefer-dark" | |
| 125 | + | cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{ | |
| 126 | + | Enabled: true, | |
| 127 | + | Provider: "noctalia", | |
| 128 | + | Format: "dumber-json", | |
| 129 | + | Path: "/tmp/theme.json", | |
| 130 | + | } | |
| 131 | + | source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")} | |
| 132 | + | manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette)) | |
| 133 | + | app := &App{ | |
| 134 | + | deps: &Dependencies{ | |
| 135 | + | Config: cfg, | |
| 136 | + | Theme: manager, | |
| 137 | + | ResolveThemeUC: usecase.NewResolveThemeUseCase(source), | |
| 138 | + | ExternalThemeSource: source, | |
| 139 | + | }, | |
| 140 | + | dispatchOnMainThread: immediateDispatchForExternalThemeTest, | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | app.applyAppearanceConfig(ctx) | |
| 144 | + | require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background) | |
| 145 | + | ||
| 146 | + | source.theme = &entity.ExternalTheme{ | |
| 147 | + | Name: "broken", | |
| 148 | + | Provider: "noctalia", | |
| 149 | + | LightPalette: &entity.ColorPalette{Accent: "not-a-hex"}, | |
| 150 | + | DarkPalette: &entity.ColorPalette{Background: "also-bad"}, | |
| 151 | + | } | |
| 152 | + | app.applyAppearanceConfig(ctx) | |
| 153 | + | require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background) | |
| 154 | + | ||
| 155 | + | cfg.Appearance.ExternalTheme.Enabled = false | |
| 156 | + | app.applyAppearanceConfig(ctx) | |
| 157 | + | require.Equal(t, cfg.Appearance.DarkPalette.Background, app.deps.Theme.GetCurrentPalette().Background) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestExternalThemeReloadKeepsLastGoodOnReadError(t *testing.T) { | |
| 161 | + | ctx := context.Background() | |
| 162 | + | cfg := config.DefaultConfig() | |
| 163 | + | cfg.Appearance.ColorScheme = "prefer-dark" | |
| 164 | + | cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{ | |
| 165 | + | Enabled: true, | |
| 166 | + | Provider: "noctalia", | |
| 167 | + | Format: "dumber-json", | |
| 168 | + | Path: "/tmp/theme.json", | |
| 169 | + | } | |
| 170 | + | source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")} | |
| 171 | + | manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette)) | |
| 172 | + | app := &App{ | |
| 173 | + | deps: &Dependencies{ | |
| 174 | + | Config: cfg, | |
| 175 | + | Theme: manager, | |
| 176 | + | ResolveThemeUC: usecase.NewResolveThemeUseCase(source), | |
| 177 | + | ExternalThemeSource: source, | |
| 178 | + | }, | |
| 179 | + | dispatchOnMainThread: immediateDispatchForExternalThemeTest, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | app.applyAppearanceConfig(ctx) | |
| 183 | + | require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background) | |
| 184 | + | ||
| 185 | + | source.theme = nil | |
| 186 | + | source.err = errors.New("temporary read failure") | |
| 187 | + | app.applyAppearanceConfig(ctx) | |
| 188 | + | require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestExternalThemeReloadRecoversAfterMalformedUpdate(t *testing.T) { | |
| 192 | + | ctx := context.Background() | |
| 193 | + | cfg := config.DefaultConfig() | |
| 194 | + | cfg.Appearance.ColorScheme = "prefer-dark" | |
| 195 | + | cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{ | |
| 196 | + | Enabled: true, | |
| 197 | + | Provider: "noctalia", | |
| 198 | + | Format: "dumber-json", | |
| 199 | + | Path: "/tmp/theme.json", | |
| 200 | + | } | |
| 201 | + | source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")} | |
| 202 | + | manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette)) | |
| 203 | + | app := &App{ | |
| 204 | + | deps: &Dependencies{ | |
| 205 | + | Config: cfg, | |
| 206 | + | Theme: manager, | |
| 207 | + | ResolveThemeUC: usecase.NewResolveThemeUseCase(source), | |
| 208 | + | ExternalThemeSource: source, | |
| 209 | + | }, | |
| 210 | + | dispatchOnMainThread: immediateDispatchForExternalThemeTest, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | app.applyAppearanceConfig(ctx) | |
| 214 | + | require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background) | |
| 215 | + | ||
| 216 | + | source.theme = &entity.ExternalTheme{ | |
| 217 | + | Name: "broken", | |
| 218 | + | Provider: "noctalia", | |
| 219 | + | LightPalette: &entity.ColorPalette{Accent: "not-a-hex"}, | |
| 220 | + | DarkPalette: &entity.ColorPalette{Background: "also-bad"}, | |
| 221 | + | } | |
| 222 | + | app.applyAppearanceConfig(ctx) | |
| 223 | + | require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background) | |
| 224 | + | ||
| 225 | + | source.theme = externalThemeWithDarkBackground("#123456") | |
| 226 | + | app.applyAppearanceConfig(ctx) | |
| 227 | + | require.Equal(t, "#123456", app.deps.Theme.GetCurrentPalette().Background) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestOnShutdownStopsExternalThemeWatcher(t *testing.T) { | |
| 231 | + | ctx, cancel := context.WithCancelCause(context.Background()) | |
| 232 | + | watcher := &recordingExternalThemeWatcher{} | |
| 233 | + | app := &App{ | |
| 234 | + | deps: &Dependencies{ExternalThemeWatcher: watcher}, | |
| 235 | + | cancel: cancel, | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | app.onShutdown(ctx) | |
| 239 | + | ||
| 240 | + | require.Equal(t, 1, watcher.stops) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func resolvedThemeForUITest(light, dark entity.ColorPalette) entity.ResolvedTheme { | |
| 244 | + | return entity.ResolvedTheme{ | |
| 245 | + | LightPalette: light, | |
| 246 | + | DarkPalette: dark, | |
| 247 | + | ActivePalette: dark, | |
| 248 | + | PrefersDark: true, | |
| 249 | + | ColorSchemeSource: "test", | |
| 250 | + | ThemeSource: entity.ThemeSourceMetadata{Kind: entity.ThemeSourceConfig}, | |
| 251 | + | Fonts: entity.DefaultThemeFonts(), | |
| 252 | + | UIScale: 1, | |
| 253 | + | ModeColors: entity.DefaultThemeModeColors(), | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func externalThemeWithDarkBackground(background string) *entity.ExternalTheme { | |
| 258 | + | return &entity.ExternalTheme{ | |
| 259 | + | Name: "Noctalia", | |
| 260 | + | Provider: "noctalia", | |
| 261 | + | LightPalette: &entity.ColorPalette{ | |
| 262 | + | Background: "#eeeeee", | |
| 263 | + | Accent: "#112233", | |
| 264 | + | }, | |
| 265 | + | DarkPalette: &entity.ColorPalette{ | |
| 266 | + | Background: background, | |
| 267 | + | Accent: "#445566", | |
| 268 | + | }, | |
| 269 | + | } | |
| 270 | + | } | |
M internal/ui/systemviews/app.go +5 -1
| @@ -794,7 +794,11 @@ func (a *App) loadShellTheme(ctx context.Context) { | |||
| 794 | 794 | ||
| 795 | 795 | config, err := a.deps.Config.Current(ctx) | |
| 796 | 796 | if err == nil { | |
| 797 | - | a.shellTheme = resolveShellTheme(config.Appearance) | |
| 797 | + | appearance := config.Appearance | |
| 798 | + | if config.ResolvedAppearance != nil { | |
| 799 | + | appearance = *config.ResolvedAppearance | |
| 800 | + | } | |
| 801 | + | a.shellTheme = resolveShellTheme(appearance) | |
| 798 | 802 | } | |
| 799 | 803 | } | |
| 800 | 804 | ||
M internal/ui/systemviews/app_test.go +48 -0
| @@ -642,6 +642,12 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||
| 642 | 642 | Accent: "#66aaff", | |
| 643 | 643 | Border: "#333333", | |
| 644 | 644 | }, | |
| 645 | + | ExternalTheme: dto.WebUIExternalThemeConfig{ | |
| 646 | + | Enabled: true, | |
| 647 | + | Provider: "noctalia", | |
| 648 | + | Format: "dumber-json", | |
| 649 | + | Path: "/tmp/noctalia-dumber.json", | |
| 650 | + | }, | |
| 645 | 651 | }, | |
| 646 | 652 | Performance: dto.SystemviewPerformancePayload{ | |
| 647 | 653 | Profile: "balanced", | |
| @@ -707,6 +713,13 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||
| 707 | 713 | assert.Contains(t, app.renderedHTML, "Inter") | |
| 708 | 714 | assert.Contains(t, app.renderedHTML, "appearance.light_palette.background") | |
| 709 | 715 | assert.Contains(t, app.renderedHTML, "#ffffff") | |
| 716 | + | assert.Contains(t, app.renderedHTML, "appearance.external_theme.enabled") | |
| 717 | + | assert.Contains(t, app.renderedHTML, "appearance.external_theme.provider") | |
| 718 | + | assert.Contains(t, app.renderedHTML, "appearance.external_theme.format") | |
| 719 | + | assert.Contains(t, app.renderedHTML, "appearance.external_theme.path") | |
| 720 | + | assert.Contains(t, app.renderedHTML, "noctalia") | |
| 721 | + | assert.Contains(t, app.renderedHTML, "dumber-json") | |
| 722 | + | assert.Contains(t, app.renderedHTML, "/tmp/noctalia-dumber.json") | |
| 710 | 723 | assert.Contains(t, app.renderedHTML, "performance.profile") | |
| 711 | 724 | assert.Contains(t, app.renderedHTML, "balanced") | |
| 712 | 725 | assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads") | |
| @@ -761,6 +774,13 @@ func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) { | |||
| 761 | 774 | require.NoError(t, app.LoadInitial(context.Background())) | |
| 762 | 775 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`) | |
| 763 | 776 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`) | |
| 777 | + | assert.Contains(t, app.renderedHTML, `name="external_theme_enabled"`) | |
| 778 | + | assert.Contains(t, app.renderedHTML, `name="external_theme_provider"`) | |
| 779 | + | assert.Contains(t, app.renderedHTML, `name="external_theme_format"`) | |
| 780 | + | assert.Contains(t, app.renderedHTML, `name="external_theme_path"`) | |
| 781 | + | assert.Contains(t, app.renderedHTML, `value="noctalia"`) | |
| 782 | + | assert.Contains(t, app.renderedHTML, `value="dumber-json"`) | |
| 783 | + | assert.Contains(t, app.renderedHTML, `/tmp/noctalia-dumber.json`) | |
| 764 | 784 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`) | |
| 765 | 785 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`) | |
| 766 | 786 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`) | |
| @@ -796,6 +816,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 796 | 816 | Data: map[string]string{ | |
| 797 | 817 | "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono", | |
| 798 | 818 | "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light", | |
| 819 | + | "external_theme_enabled": "yes", "external_theme_provider": " noctalia ", "external_theme_format": " dumber-json ", "external_theme_path": " /tmp/noctalia-dumber.json ", | |
| 799 | 820 | "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd", | |
| 800 | 821 | "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333", | |
| 801 | 822 | }, | |
| @@ -803,6 +824,10 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 803 | 824 | assert.True(t, service.calledSave) | |
| 804 | 825 | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | |
| 805 | 826 | assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001) | |
| 827 | + | assert.True(t, service.savedConfig.Appearance.ExternalTheme.Enabled) | |
| 828 | + | assert.Equal(t, "noctalia", service.savedConfig.Appearance.ExternalTheme.Provider) | |
| 829 | + | assert.Equal(t, "dumber-json", service.savedConfig.Appearance.ExternalTheme.Format) | |
| 830 | + | assert.Equal(t, "/tmp/noctalia-dumber.json", service.savedConfig.Appearance.ExternalTheme.Path) | |
| 806 | 831 | assert.Contains(t, dom.HTML(), "Saved appearance settings") | |
| 807 | 832 | assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`) | |
| 808 | 833 | ||
| @@ -876,6 +901,28 @@ func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) { | |||
| 876 | 901 | assert.False(t, service.calledSave) | |
| 877 | 902 | } | |
| 878 | 903 | ||
| 904 | + | func TestAppLoadInitialConfigRouteUsesResolvedAppearanceOnlyForShellTheme(t *testing.T) { | |
| 905 | + | t.Parallel() | |
| 906 | + | ||
| 907 | + | cfg := testConfigPayload() | |
| 908 | + | resolved := cfg.Appearance | |
| 909 | + | resolved.DarkPalette.Background = "#222222" | |
| 910 | + | resolved.DarkPalette.SurfaceVariant = "#444444" | |
| 911 | + | cfg.ResolvedAppearance = &resolved | |
| 912 | + | service := &fakeConfigService{ | |
| 913 | + | current: cfg, | |
| 914 | + | keybindings: port.KeybindingsConfig{}, | |
| 915 | + | } | |
| 916 | + | ||
| 917 | + | app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"}) | |
| 918 | + | ||
| 919 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 920 | + | assert.Contains(t, app.renderedHTML, `--sv-background: #222222;`) | |
| 921 | + | assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #444444;`) | |
| 922 | + | assert.Contains(t, app.renderedHTML, `value="#111111"`) | |
| 923 | + | assert.Contains(t, app.renderedHTML, `value="#2a2a2a"`) | |
| 924 | + | } | |
| 925 | + | ||
| 879 | 926 | func testConfigPayload() dto.SystemviewConfigPayload { | |
| 880 | 927 | return dto.SystemviewConfigPayload{ | |
| 881 | 928 | EngineType: "webkit", | |
| @@ -887,6 +934,7 @@ func testConfigPayload() dto.SystemviewConfigPayload { | |||
| 887 | 934 | DefaultFontSize: 16, | |
| 888 | 935 | LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"}, | |
| 889 | 936 | DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"}, | |
| 937 | + | ExternalTheme: dto.WebUIExternalThemeConfig{Enabled: true, Provider: "noctalia", Format: "dumber-json", Path: "/tmp/noctalia-dumber.json"}, | |
| 890 | 938 | }, | |
| 891 | 939 | DefaultUIScale: 1, | |
| 892 | 940 | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |
M internal/ui/systemviews/config.templ +8 -0
| @@ -39,6 +39,14 @@ templ AppearanceForm(config dto.SystemviewConfigPayload) { | |||
| 39 | 39 | <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option> | |
| 40 | 40 | <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option> | |
| 41 | 41 | </select></label> | |
| 42 | + | <fieldset class="sv-fieldset"> | |
| 43 | + | <legend>External theme</legend> | |
| 44 | + | <p class="sv-meta">Provider noctalia supports colors-json (default Noctalia colors.json) and dumber-json (advanced user template).</p> | |
| 45 | + | <label><span>Enable external theme</span><input name="external_theme_enabled" type="checkbox" checked?={ config.Appearance.ExternalTheme.Enabled }/></label> | |
| 46 | + | <label><span>Provider</span><input name="external_theme_provider" type="text" value={ config.Appearance.ExternalTheme.Provider } placeholder="noctalia"/></label> | |
| 47 | + | <label><span>Format</span><input name="external_theme_format" type="text" value={ config.Appearance.ExternalTheme.Format } placeholder="colors-json"/></label> | |
| 48 | + | <label><span>Path</span><input name="external_theme_path" type="text" value={ config.Appearance.ExternalTheme.Path } placeholder="~/.config/noctalia/colors.json"/></label> | |
| 49 | + | </fieldset> | |
| 42 | 50 | @PaletteFields("Light palette", "light", config.Appearance.LightPalette) | |
| 43 | 51 | @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette) | |
| 44 | 52 | <div class="sv-button-row sv-form-actions"> | |
M internal/ui/systemviews/config_actions.go +13 -0
| @@ -102,6 +102,10 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) | |||
| 102 | 102 | cfg.Appearance.GtkFont = strings.TrimSpace(data["gtk_font"]) | |
| 103 | 103 | cfg.Appearance.DefaultFontSize = fontSize | |
| 104 | 104 | cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"]) | |
| 105 | + | cfg.Appearance.ExternalTheme.Enabled = configCheckboxEnabled(data["external_theme_enabled"]) | |
| 106 | + | cfg.Appearance.ExternalTheme.Provider = strings.TrimSpace(data["external_theme_provider"]) | |
| 107 | + | cfg.Appearance.ExternalTheme.Format = strings.TrimSpace(data["external_theme_format"]) | |
| 108 | + | cfg.Appearance.ExternalTheme.Path = strings.TrimSpace(data["external_theme_path"]) | |
| 105 | 109 | cfg.Appearance.LightPalette = paletteFromForm(data, "light") | |
| 106 | 110 | cfg.Appearance.DarkPalette = paletteFromForm(data, "dark") | |
| 107 | 111 | cfg.DefaultUIScale = uiScale | |
| @@ -113,6 +117,15 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) | |||
| 113 | 117 | return nil | |
| 114 | 118 | } | |
| 115 | 119 | ||
| 120 | + | func configCheckboxEnabled(value string) bool { | |
| 121 | + | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 122 | + | case "on", "true", "1", "yes": | |
| 123 | + | return true | |
| 124 | + | default: | |
| 125 | + | return false | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 116 | 129 | func (a *App) resetAppearanceConfig(ctx context.Context) error { | |
| 117 | 130 | cfg, err := a.editableConfig(ctx) | |
| 118 | 131 | if err != nil { | |
M internal/ui/systemviews/config_templ.go +327 -278 generated file: collapsed
M internal/ui/theme/css.go +3 -3
| @@ -256,7 +256,7 @@ window.standalone-omnibox-window { | |||
| 256 | 256 | /* Search entry field */ | |
| 257 | 257 | entry.omnibox-entry, | |
| 258 | 258 | entry.omnibox-entry > text { | |
| 259 | - | background-color: alpha(var(--bg), 0.88); | |
| 259 | + | background-color: var(--surface-variant); | |
| 260 | 260 | background-image: none; | |
| 261 | 261 | color: var(--text); | |
| 262 | 262 | caret-color: var(--accent); | |
| @@ -278,7 +278,7 @@ entry.omnibox-entry:focus > text, | |||
| 278 | 278 | entry.omnibox-entry:focus-within > text, | |
| 279 | 279 | entry.omnibox-entry:focus-visible > text { | |
| 280 | 280 | border-color: var(--accent); | |
| 281 | - | background-color: shade(var(--bg), 1.05); | |
| 281 | + | background-color: shade(var(--surface-variant), 1.08); | |
| 282 | 282 | background-image: none; | |
| 283 | 283 | outline-style: none; | |
| 284 | 284 | outline-width: 0px; | |
| @@ -463,7 +463,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus > text, | |||
| 463 | 463 | entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text, | |
| 464 | 464 | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text { | |
| 465 | 465 | border-color: var(--accent); | |
| 466 | - | background-color: shade(var(--bg), 1.05); | |
| 466 | + | background-color: shade(var(--surface-variant), 1.08); | |
| 467 | 467 | background-image: none; | |
| 468 | 468 | } | |
| 469 | 469 | ||
M internal/ui/theme/css_test.go +12 -5
| @@ -101,20 +101,27 @@ func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) { | |||
| 101 | 101 | } | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) { | |
| 104 | + | func TestGenerateCSS_OmniboxSearchEntryUsesControlSurface(t *testing.T) { | |
| 105 | 105 | css := GenerateCSS(DefaultDarkPalette()) | |
| 106 | 106 | ||
| 107 | 107 | containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`) | |
| 108 | 108 | if !containerRe.MatchString(css) { | |
| 109 | 109 | t.Fatalf("expected omnibox container to use the header surface color") | |
| 110 | 110 | } | |
| 111 | - | entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*alpha\(var\(--bg\),\s*0\.88\);[^}]*background-image:\s*none;`) | |
| 111 | + | entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*var\(--surface-variant\);[^}]*background-image:\s*none;`) | |
| 112 | 112 | if !entryRe.MatchString(css) { | |
| 113 | - | t.Fatalf("expected omnibox entry and its text node to use the darker background fill") | |
| 113 | + | t.Fatalf("expected omnibox entry and its text node to use the control surface fill") | |
| 114 | 114 | } | |
| 115 | - | focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--bg\),\s*1\.05\);`) | |
| 115 | + | focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--surface-variant\),\s*1\.08\);`) | |
| 116 | 116 | if !focusedEntryRe.MatchString(css) { | |
| 117 | - | t.Fatalf("expected focused omnibox entry text node to keep the darker bg-based fill") | |
| 117 | + | t.Fatalf("expected focused omnibox entry text node to keep a distinct control-surface fill") | |
| 118 | + | } | |
| 119 | + | bangActiveRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\.omnibox-entry-bang-active[^\{]*\{[^}]*background-color:\s*shade\(var\(--surface-variant\),\s*1\.08\);`) | |
| 120 | + | if !bangActiveRe.MatchString(css) { | |
| 121 | + | t.Fatalf("expected bang-active omnibox entry to keep a distinct control-surface fill") | |
| 122 | + | } | |
| 123 | + | if strings.Contains(css, "background-color: alpha(var(--bg), 0.88);") || strings.Contains(css, "background-color: shade(var(--bg), 1.05);") { | |
| 124 | + | t.Fatalf("expected omnibox entry fills not to derive from the page background") | |
| 118 | 125 | } | |
| 119 | 126 | scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`) | |
| 120 | 127 | if !scrolledRe.MatchString(css) { | |
M internal/ui/theme/manager.go +37 -157
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "math" | |
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 11 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -14,106 +13,51 @@ import ( | |||
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | 15 | // Manager handles theme state and CSS application. | |
| 16 | + | // Theme precedence and fallback are resolved before data reaches this adapter. | |
| 17 | 17 | type Manager struct { | |
| 18 | - | scheme string // "light", "dark", "system" | |
| 19 | - | prefersDark bool // Resolved dark mode preference | |
| 20 | - | lightPalette Palette // Light theme colors | |
| 21 | - | darkPalette Palette // Dark theme colors | |
| 22 | - | uiScale float64 // UI scaling factor (1.0 = 100%) | |
| 23 | - | fonts FontConfig | |
| 24 | - | gtkFont string | |
| 25 | - | modeColors ModeColors // Modal mode indicator colors | |
| 26 | - | cssProvider *gtk.CssProvider | |
| 27 | - | appliedFont string | |
| 28 | - | colorResolver port.ColorSchemeResolver // Resolver for dynamic detection | |
| 18 | + | prefersDark bool // Resolved dark mode preference | |
| 19 | + | lightPalette Palette // Light theme colors | |
| 20 | + | darkPalette Palette // Dark theme colors | |
| 21 | + | uiScale float64 // UI scaling factor (1.0 = 100%) | |
| 22 | + | fonts FontConfig | |
| 23 | + | gtkFont string | |
| 24 | + | modeColors ModeColors // Modal mode indicator colors | |
| 25 | + | cssProvider *gtk.CssProvider | |
| 26 | + | appliedFont string | |
| 29 | 27 | } | |
| 30 | 28 | ||
| 31 | - | // NewManager creates a new theme manager from configuration. | |
| 32 | - | // The ColorSchemeResolver is required for proper color scheme detection. | |
| 33 | - | // | |
| 34 | - | // appearance controls fonts, palette overrides and color scheme. | |
| 35 | - | // uiScale is the GTK UI scale factor (0 or negative means use default 1.0). | |
| 36 | - | // styling controls mode indicator colors; may be nil to use defaults. | |
| 37 | - | func NewManager( | |
| 38 | - | ctx context.Context, | |
| 39 | - | appearance *entity.AppearanceConfig, | |
| 40 | - | uiScale float64, | |
| 41 | - | styling *entity.WorkspaceStylingConfig, | |
| 42 | - | resolver port.ColorSchemeResolver, | |
| 43 | - | ) *Manager { | |
| 29 | + | // NewManager creates a new theme manager from an already-resolved theme. | |
| 30 | + | func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager { | |
| 44 | 31 | log := logging.FromContext(ctx) | |
| 45 | 32 | ||
| 46 | - | // Determine color scheme preference | |
| 47 | - | scheme := "system" | |
| 48 | - | if appearance != nil && appearance.ColorScheme != "" { | |
| 49 | - | scheme = appearance.ColorScheme | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // Resolve whether we should use dark mode via resolver | |
| 53 | - | pref := resolver.Resolve() | |
| 54 | - | prefersDark := pref.PrefersDark | |
| 55 | - | ||
| 56 | - | // Build palettes from config or defaults | |
| 57 | - | var lightPalette, darkPalette Palette | |
| 58 | - | if appearance != nil { | |
| 59 | - | lightPalette = PaletteFromConfig(&appearance.LightPalette, false) | |
| 60 | - | darkPalette = PaletteFromConfig(&appearance.DarkPalette, true) | |
| 61 | - | } else { | |
| 62 | - | lightPalette = DefaultLightPalette() | |
| 63 | - | darkPalette = DefaultDarkPalette() | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // Get UI scale factor (default to 1.0 if not set) | |
| 67 | - | if uiScale <= 0 { | |
| 68 | - | uiScale = 1.0 | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | fonts := DefaultFontConfig() | |
| 72 | - | gtkFont := DefaultGTKFont() | |
| 73 | - | if appearance != nil { | |
| 74 | - | fonts.SansFont = Coalesce(appearance.SansFont, fonts.SansFont) | |
| 75 | - | fonts.MonospaceFont = Coalesce(appearance.MonospaceFont, fonts.MonospaceFont) | |
| 76 | - | gtkFont = Coalesce(appearance.GtkFont, gtkFont) | |
| 77 | - | fonts.GtkFont = gtkFont | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | // Build mode colors from config | |
| 81 | - | modeColors := modeColorsFromStyling(styling) | |
| 33 | + | m := &Manager{} | |
| 34 | + | m.applyResolvedTheme(resolved) | |
| 82 | 35 | ||
| 83 | 36 | log.Debug(). | |
| 84 | - | Str("scheme", scheme). | |
| 85 | - | Bool("prefers_dark", prefersDark). | |
| 86 | - | Float64("ui_scale", uiScale). | |
| 87 | - | Str("sans_font", fonts.SansFont). | |
| 88 | - | Str("monospace_font", fonts.MonospaceFont). | |
| 89 | - | Str("gtk_font", gtkFont). | |
| 37 | + | Bool("prefers_dark", m.prefersDark). | |
| 38 | + | Float64("ui_scale", m.uiScale). | |
| 39 | + | Str("sans_font", m.fonts.SansFont). | |
| 40 | + | Str("monospace_font", m.fonts.MonospaceFont). | |
| 41 | + | Str("gtk_font", m.gtkFont). | |
| 90 | 42 | Msg("theme manager initialized") | |
| 91 | 43 | ||
| 92 | - | return &Manager{ | |
| 93 | - | scheme: scheme, | |
| 94 | - | prefersDark: prefersDark, | |
| 95 | - | lightPalette: lightPalette, | |
| 96 | - | darkPalette: darkPalette, | |
| 97 | - | uiScale: uiScale, | |
| 98 | - | fonts: fonts, | |
| 99 | - | gtkFont: gtkFont, | |
| 100 | - | modeColors: modeColors, | |
| 101 | - | colorResolver: resolver, | |
| 102 | - | } | |
| 44 | + | return m | |
| 103 | 45 | } | |
| 104 | 46 | ||
| 105 | - | // modeColorsFromStyling extracts mode colors from styling config, using defaults for missing values. | |
| 106 | - | func modeColorsFromStyling(styling *entity.WorkspaceStylingConfig) ModeColors { | |
| 107 | - | defaults := DefaultModeColors() | |
| 108 | - | if styling == nil { | |
| 109 | - | return defaults | |
| 47 | + | func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) { | |
| 48 | + | m.prefersDark = resolved.PrefersDark | |
| 49 | + | m.lightPalette = PaletteFromEntity(resolved.LightPalette, false) | |
| 50 | + | m.darkPalette = PaletteFromEntity(resolved.DarkPalette, true) | |
| 51 | + | m.uiScale = resolved.UIScale | |
| 52 | + | if m.uiScale <= 0 { | |
| 53 | + | m.uiScale = 1.0 | |
| 110 | 54 | } | |
| 111 | - | return ModeColors{ | |
| 112 | - | PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode), | |
| 113 | - | TabMode: Coalesce(styling.TabModeColor, defaults.TabMode), | |
| 114 | - | SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode), | |
| 115 | - | ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode), | |
| 55 | + | m.fonts = FontConfigFromEntity(resolved.Fonts) | |
| 56 | + | if m.fonts.GtkFont == "" { | |
| 57 | + | m.fonts.GtkFont = DefaultGTKFont() | |
| 116 | 58 | } | |
| 59 | + | m.gtkFont = m.fonts.GtkFont | |
| 60 | + | m.modeColors = ModeColorsFromEntity(resolved.ModeColors) | |
| 117 | 61 | } | |
| 118 | 62 | ||
| 119 | 63 | // PrefersDark returns true if dark mode is active. | |
| @@ -121,7 +65,7 @@ func (m *Manager) PrefersDark() bool { | |||
| 121 | 65 | return m.prefersDark | |
| 122 | 66 | } | |
| 123 | 67 | ||
| 124 | - | // GetCurrentPalette returns the active palette based on current scheme. | |
| 68 | + | // GetCurrentPalette returns the active palette based on current preference. | |
| 125 | 69 | func (m *Manager) GetCurrentPalette() Palette { | |
| 126 | 70 | if m.prefersDark { | |
| 127 | 71 | return m.darkPalette | |
| @@ -230,83 +174,19 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||
| 230 | 174 | Msg("theme CSS applied to display") | |
| 231 | 175 | } | |
| 232 | 176 | ||
| 233 | - | // SetColorScheme changes the active color scheme at runtime. | |
| 234 | - | func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) { | |
| 177 | + | // UpdateFromResolved updates the theme manager state from a resolved theme. | |
| 178 | + | func (m *Manager) UpdateFromResolved(ctx context.Context, resolved entity.ResolvedTheme, display *gdk.Display) { | |
| 235 | 179 | log := logging.FromContext(ctx) | |
| 236 | 180 | ||
| 237 | - | m.scheme = scheme | |
| 238 | - | pref := m.colorResolver.Refresh() | |
| 239 | - | m.prefersDark = pref.PrefersDark | |
| 240 | - | ||
| 241 | - | log.Info(). | |
| 242 | - | Str("scheme", scheme). | |
| 243 | - | Bool("prefers_dark", m.prefersDark). | |
| 244 | - | Str("source", pref.Source). | |
| 245 | - | Msg("color scheme changed") | |
| 246 | - | ||
| 247 | - | // Re-apply CSS if display is available | |
| 248 | - | if display != nil { | |
| 249 | - | m.ApplyToDisplay(ctx, display) | |
| 250 | - | } | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | // UpdateFromConfig updates the theme manager state from new config values. | |
| 254 | - | // | |
| 255 | - | // appearance controls fonts, palette overrides and color scheme. | |
| 256 | - | // uiScale is the GTK UI scale factor (0 or negative means keep existing scale). | |
| 257 | - | // styling controls mode indicator colors; may be nil to keep existing colors. | |
| 258 | - | func (m *Manager) UpdateFromConfig( | |
| 259 | - | ctx context.Context, | |
| 260 | - | appearance *entity.AppearanceConfig, | |
| 261 | - | uiScale float64, | |
| 262 | - | styling *entity.WorkspaceStylingConfig, | |
| 263 | - | display *gdk.Display, | |
| 264 | - | ) { | |
| 265 | - | log := logging.FromContext(ctx) | |
| 266 | - | ||
| 267 | - | if appearance == nil { | |
| 268 | - | return | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | // Update scheme and resolve prefersDark via resolver | |
| 272 | - | scheme := "system" | |
| 273 | - | if appearance.ColorScheme != "" { | |
| 274 | - | scheme = appearance.ColorScheme | |
| 275 | - | } | |
| 276 | - | m.scheme = scheme | |
| 277 | - | pref := m.colorResolver.Refresh() | |
| 278 | - | m.prefersDark = pref.PrefersDark | |
| 279 | - | ||
| 280 | - | // Update palettes | |
| 281 | - | m.lightPalette = PaletteFromConfig(&appearance.LightPalette, false) | |
| 282 | - | m.darkPalette = PaletteFromConfig(&appearance.DarkPalette, true) | |
| 283 | - | ||
| 284 | - | // Update UI scale | |
| 285 | - | if uiScale > 0 { | |
| 286 | - | m.uiScale = uiScale | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | defaults := DefaultFontConfig() | |
| 290 | - | m.gtkFont = Coalesce(appearance.GtkFont, DefaultGTKFont()) | |
| 291 | - | m.fonts = FontConfig{ | |
| 292 | - | SansFont: Coalesce(appearance.SansFont, defaults.SansFont), | |
| 293 | - | MonospaceFont: Coalesce(appearance.MonospaceFont, defaults.MonospaceFont), | |
| 294 | - | GtkFont: m.gtkFont, | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | // Update mode colors | |
| 298 | - | if styling != nil { | |
| 299 | - | m.modeColors = modeColorsFromStyling(styling) | |
| 300 | - | } | |
| 181 | + | m.applyResolvedTheme(resolved) | |
| 301 | 182 | ||
| 302 | 183 | log.Info(). | |
| 303 | - | Str("scheme", m.scheme). | |
| 304 | 184 | Bool("prefers_dark", m.prefersDark). | |
| 305 | 185 | Float64("ui_scale", m.uiScale). | |
| 306 | 186 | Str("sans_font", m.fonts.SansFont). | |
| 307 | 187 | Str("monospace_font", m.fonts.MonospaceFont). | |
| 308 | 188 | Str("gtk_font", m.gtkFont). | |
| 309 | - | Msg("theme manager updated from config") | |
| 189 | + | Msg("theme manager updated from resolved theme") | |
| 310 | 190 | ||
| 311 | 191 | // Re-apply CSS if display is available | |
| 312 | 192 | if display != nil { | |
M internal/ui/theme/manager_test.go +103 -255
| @@ -4,315 +4,163 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/application/port" | |
| 8 | - | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | 7 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 8 | "github.com/stretchr/testify/assert" | |
| 11 | 9 | "github.com/stretchr/testify/require" | |
| 12 | 10 | ) | |
| 13 | 11 | ||
| 14 | - | func TestNewManager_DarkMode(t *testing.T) { | |
| 15 | - | ctx := context.Background() | |
| 16 | - | appearance := &entity.AppearanceConfig{ | |
| 17 | - | ColorScheme: "default", | |
| 12 | + | func resolvedThemeFixture(prefersDark bool) entity.ResolvedTheme { | |
| 13 | + | light := entity.ColorPalette{ | |
| 14 | + | Background: "#ffffff", | |
| 15 | + | Surface: "#f8f8f8", | |
| 16 | + | SurfaceVariant: "#eeeeee", | |
| 17 | + | Text: "#111111", | |
| 18 | + | Muted: "#666666", | |
| 19 | + | Accent: "#0055ff", | |
| 20 | + | Border: "#dddddd", | |
| 18 | 21 | } | |
| 19 | - | ||
| 20 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 21 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 22 | - | PrefersDark: true, | |
| 23 | - | Source: "test", | |
| 24 | - | }) | |
| 25 | - | ||
| 26 | - | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 27 | - | ||
| 28 | - | assert.True(t, manager.PrefersDark()) | |
| 29 | - | assert.Equal(t, "default", manager.scheme) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func TestNewManager_LightMode(t *testing.T) { | |
| 33 | - | ctx := context.Background() | |
| 34 | - | appearance := &entity.AppearanceConfig{ | |
| 35 | - | ColorScheme: "default", | |
| 22 | + | dark := entity.ColorPalette{ | |
| 23 | + | Background: "#101010", | |
| 24 | + | Surface: "#181818", | |
| 25 | + | SurfaceVariant: "#242424", | |
| 26 | + | Text: "#f5f5f5", | |
| 27 | + | Muted: "#999999", | |
| 28 | + | Accent: "#66aaff", | |
| 29 | + | Border: "#333333", | |
| 30 | + | } | |
| 31 | + | active := light | |
| 32 | + | if prefersDark { | |
| 33 | + | active = dark | |
| 34 | + | } | |
| 35 | + | return entity.ResolvedTheme{ | |
| 36 | + | LightPalette: light, | |
| 37 | + | DarkPalette: dark, | |
| 38 | + | ActivePalette: active, | |
| 39 | + | PrefersDark: prefersDark, | |
| 40 | + | ColorSchemeSource: "test", | |
| 41 | + | ThemeSource: entity.ThemeSourceMetadata{Kind: entity.ThemeSourceConfig}, | |
| 42 | + | Fonts: entity.ThemeFonts{ | |
| 43 | + | SansFont: "Inter", | |
| 44 | + | SerifFont: "Georgia", | |
| 45 | + | MonospaceFont: "JetBrains Mono", | |
| 46 | + | GtkFont: "Adwaita Sans", | |
| 47 | + | DefaultSize: 16, | |
| 48 | + | }, | |
| 49 | + | UIScale: 1.5, | |
| 50 | + | ModeColors: entity.ThemeModeColors{ | |
| 51 | + | PaneMode: "#ff0000", | |
| 52 | + | TabMode: "#00ff00", | |
| 53 | + | SessionMode: "#0000ff", | |
| 54 | + | ResizeMode: "#ffff00", | |
| 55 | + | }, | |
| 36 | 56 | } | |
| 37 | - | ||
| 38 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 39 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 40 | - | PrefersDark: false, | |
| 41 | - | Source: "test", | |
| 42 | - | }) | |
| 43 | - | ||
| 44 | - | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 45 | - | ||
| 46 | - | assert.False(t, manager.PrefersDark()) | |
| 47 | 57 | } | |
| 48 | 58 | ||
| 49 | - | func TestNewManager_WithNilConfig(t *testing.T) { | |
| 59 | + | func TestNewManager_UsesResolvedThemeFields(t *testing.T) { | |
| 50 | 60 | ctx := context.Background() | |
| 61 | + | resolved := resolvedThemeFixture(true) | |
| 51 | 62 | ||
| 52 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 53 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 54 | - | PrefersDark: true, | |
| 55 | - | Source: "fallback", | |
| 56 | - | }) | |
| 57 | - | ||
| 58 | - | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 63 | + | manager := NewManager(ctx, resolved) | |
| 59 | 64 | ||
| 60 | 65 | require.NotNil(t, manager) | |
| 61 | 66 | assert.True(t, manager.PrefersDark()) | |
| 62 | - | assert.Equal(t, "system", manager.scheme) | |
| 67 | + | assert.Equal(t, "#ffffff", manager.GetLightPalette().Background) | |
| 68 | + | assert.Equal(t, "#101010", manager.GetDarkPalette().Background) | |
| 69 | + | assert.Equal(t, manager.GetDarkPalette(), manager.GetCurrentPalette()) | |
| 70 | + | assert.Equal(t, "#ff0000", manager.GetModeColors().PaneMode) | |
| 71 | + | assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, manager.fonts) | |
| 72 | + | assert.Equal(t, "Adwaita Sans", manager.gtkFont) | |
| 73 | + | assert.InDelta(t, 1.5, manager.uiScale, 0.000001) | |
| 63 | 74 | } | |
| 64 | 75 | ||
| 65 | - | func TestNewManager_UsesConfigScheme(t *testing.T) { | |
| 76 | + | func TestNewManager_DefaultsInvalidScaleToOne(t *testing.T) { | |
| 66 | 77 | ctx := context.Background() | |
| 67 | - | appearance := &entity.AppearanceConfig{ | |
| 68 | - | ColorScheme: "prefer-dark", | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 72 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 73 | - | PrefersDark: true, | |
| 74 | - | Source: "config", | |
| 75 | - | }) | |
| 76 | - | ||
| 77 | - | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 78 | - | ||
| 79 | - | assert.Equal(t, "prefer-dark", manager.scheme) | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | func TestManager_GetCurrentPalette_Dark(t *testing.T) { | |
| 83 | - | ctx := context.Background() | |
| 84 | - | ||
| 85 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 86 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 87 | - | PrefersDark: true, | |
| 88 | - | Source: "test", | |
| 89 | - | }) | |
| 90 | - | ||
| 91 | - | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 92 | - | palette := manager.GetCurrentPalette() | |
| 93 | - | ||
| 94 | - | // Dark palette should be returned | |
| 95 | - | assert.Equal(t, manager.GetDarkPalette(), palette) | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | func TestManager_GetCurrentPalette_Light(t *testing.T) { | |
| 99 | - | ctx := context.Background() | |
| 100 | - | ||
| 101 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 102 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 103 | - | PrefersDark: false, | |
| 104 | - | Source: "test", | |
| 105 | - | }) | |
| 106 | - | ||
| 107 | - | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 108 | - | palette := manager.GetCurrentPalette() | |
| 109 | - | ||
| 110 | - | // Light palette should be returned | |
| 111 | - | assert.Equal(t, manager.GetLightPalette(), palette) | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | func TestManager_SetColorScheme(t *testing.T) { | |
| 115 | - | ctx := context.Background() | |
| 116 | - | ||
| 117 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 118 | - | // Initial resolve call | |
| 119 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 120 | - | PrefersDark: true, | |
| 121 | - | Source: "test", | |
| 122 | - | }) | |
| 123 | - | // Refresh call when SetColorScheme is called | |
| 124 | - | mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{ | |
| 125 | - | PrefersDark: false, | |
| 126 | - | Source: "config", | |
| 127 | - | }) | |
| 128 | - | ||
| 129 | - | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 130 | - | assert.True(t, manager.PrefersDark()) | |
| 78 | + | resolved := resolvedThemeFixture(true) | |
| 79 | + | resolved.UIScale = 0 | |
| 131 | 80 | ||
| 132 | - | // Change scheme - this calls Refresh on resolver | |
| 133 | - | manager.SetColorScheme(ctx, "prefer-light", nil) | |
| 81 | + | manager := NewManager(ctx, resolved) | |
| 134 | 82 | ||
| 135 | - | assert.False(t, manager.PrefersDark()) | |
| 136 | - | assert.Equal(t, "prefer-light", manager.scheme) | |
| 83 | + | require.NotNil(t, manager) | |
| 84 | + | assert.InDelta(t, 1.0, manager.uiScale, 0.000001) | |
| 137 | 85 | } | |
| 138 | 86 | ||
| 139 | - | func TestManager_UpdateFromConfig(t *testing.T) { | |
| 87 | + | func TestManager_UpdateFromResolvedReplacesResolvedValues(t *testing.T) { | |
| 140 | 88 | ctx := context.Background() | |
| 141 | - | appearance := &entity.AppearanceConfig{ | |
| 142 | - | ColorScheme: "default", | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 146 | - | // Initial resolve | |
| 147 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 148 | - | PrefersDark: true, | |
| 149 | - | Source: "test", | |
| 150 | - | }) | |
| 151 | - | // Refresh when UpdateFromConfig is called | |
| 152 | - | mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{ | |
| 153 | - | PrefersDark: false, | |
| 154 | - | Source: "config", | |
| 155 | - | }) | |
| 89 | + | manager := NewManager(ctx, resolvedThemeFixture(true)) | |
| 90 | + | updated := resolvedThemeFixture(false) | |
| 91 | + | updated.LightPalette.Background = "#abcdef" | |
| 92 | + | updated.DarkPalette.Background = "#123456" | |
| 93 | + | updated.Fonts.SansFont = "Recursive" | |
| 94 | + | updated.UIScale = 2.0 | |
| 95 | + | updated.ModeColors.PaneMode = "#654321" | |
| 156 | 96 | ||
| 157 | - | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 158 | - | assert.True(t, manager.PrefersDark()) | |
| 159 | - | ||
| 160 | - | // Update with new config | |
| 161 | - | newAppearance := &entity.AppearanceConfig{ | |
| 162 | - | ColorScheme: "prefer-light", | |
| 163 | - | } | |
| 164 | - | manager.UpdateFromConfig(ctx, newAppearance, 0, nil, nil) | |
| 97 | + | manager.UpdateFromResolved(ctx, updated, nil) | |
| 165 | 98 | ||
| 166 | 99 | assert.False(t, manager.PrefersDark()) | |
| 167 | - | assert.Equal(t, "prefer-light", manager.scheme) | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | func TestManager_UpdateFromConfig_NilConfig(t *testing.T) { | |
| 171 | - | ctx := context.Background() | |
| 172 | - | ||
| 173 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 174 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 175 | - | PrefersDark: true, | |
| 176 | - | Source: "test", | |
| 177 | - | }) | |
| 178 | - | ||
| 179 | - | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 180 | - | initialScheme := manager.scheme | |
| 181 | - | ||
| 182 | - | // UpdateFromConfig with nil appearance should be a no-op | |
| 183 | - | manager.UpdateFromConfig(ctx, nil, 0, nil, nil) | |
| 184 | - | ||
| 185 | - | assert.Equal(t, initialScheme, manager.scheme) | |
| 100 | + | assert.Equal(t, "#abcdef", manager.GetLightPalette().Background) | |
| 101 | + | assert.Equal(t, "#123456", manager.GetDarkPalette().Background) | |
| 102 | + | assert.Equal(t, manager.GetLightPalette(), manager.GetCurrentPalette()) | |
| 103 | + | assert.Equal(t, "Recursive", manager.fonts.SansFont) | |
| 104 | + | assert.InDelta(t, 2.0, manager.uiScale, 0.000001) | |
| 105 | + | assert.Equal(t, "#654321", manager.GetModeColors().PaneMode) | |
| 186 | 106 | } | |
| 187 | 107 | ||
| 188 | 108 | func TestManager_GetWebUIThemeCSS(t *testing.T) { | |
| 189 | 109 | ctx := context.Background() | |
| 110 | + | manager := NewManager(ctx, resolvedThemeFixture(true)) | |
| 190 | 111 | ||
| 191 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 192 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 193 | - | PrefersDark: true, | |
| 194 | - | Source: "test", | |
| 195 | - | }) | |
| 196 | - | ||
| 197 | - | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 198 | 112 | css := manager.GetWebUIThemeCSS() | |
| 199 | 113 | ||
| 200 | - | // Should contain both light and dark variables | |
| 201 | 114 | assert.Contains(t, css, ":root{") | |
| 202 | 115 | assert.Contains(t, css, ".dark{") | |
| 116 | + | assert.Contains(t, css, "--background: #ffffff") | |
| 117 | + | assert.Contains(t, css, "--background: #101010") | |
| 203 | 118 | } | |
| 204 | 119 | ||
| 205 | - | func TestManager_GetBackgroundRGBA(t *testing.T) { | |
| 120 | + | func TestManager_GetBackgroundRGBAUsesCurrentPalette(t *testing.T) { | |
| 206 | 121 | ctx := context.Background() | |
| 122 | + | manager := NewManager(ctx, resolvedThemeFixture(true)) | |
| 207 | 123 | ||
| 208 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 209 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 210 | - | PrefersDark: true, | |
| 211 | - | Source: "test", | |
| 212 | - | }) | |
| 213 | - | ||
| 214 | - | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 215 | 124 | r, g, b, a := manager.GetBackgroundRGBA() | |
| 216 | 125 | ||
| 217 | - | // Should return valid RGBA values (0-1 range) | |
| 218 | - | assert.GreaterOrEqual(t, r, float32(0)) | |
| 219 | - | assert.LessOrEqual(t, r, float32(1)) | |
| 220 | - | assert.GreaterOrEqual(t, g, float32(0)) | |
| 221 | - | assert.LessOrEqual(t, g, float32(1)) | |
| 222 | - | assert.GreaterOrEqual(t, b, float32(0)) | |
| 223 | - | assert.LessOrEqual(t, b, float32(1)) | |
| 224 | - | assert.GreaterOrEqual(t, a, float32(0)) | |
| 225 | - | assert.LessOrEqual(t, a, float32(1)) | |
| 126 | + | assert.InDelta(t, float32(0x10)/255, r, 0.0001) | |
| 127 | + | assert.InDelta(t, float32(0x10)/255, g, 0.0001) | |
| 128 | + | assert.InDelta(t, float32(0x10)/255, b, 0.0001) | |
| 129 | + | assert.InDelta(t, float32(1), a, 0.0001) | |
| 226 | 130 | } | |
| 227 | 131 | ||
| 228 | - | func TestManager_CustomPalettes(t *testing.T) { | |
| 229 | - | ctx := context.Background() | |
| 230 | - | appearance := &entity.AppearanceConfig{ | |
| 231 | - | LightPalette: entity.ColorPalette{ | |
| 232 | - | Background: "#ffffff", | |
| 233 | - | Text: "#000000", | |
| 234 | - | }, | |
| 235 | - | DarkPalette: entity.ColorPalette{ | |
| 236 | - | Background: "#000000", | |
| 237 | - | Text: "#ffffff", | |
| 238 | - | }, | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 242 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 243 | - | PrefersDark: true, | |
| 244 | - | Source: "test", | |
| 245 | - | }) | |
| 246 | - | ||
| 247 | - | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 248 | - | ||
| 249 | - | lightPalette := manager.GetLightPalette() | |
| 250 | - | darkPalette := manager.GetDarkPalette() | |
| 132 | + | func TestPaletteFromEntityAddsSemanticDefaults(t *testing.T) { | |
| 133 | + | palette := PaletteFromEntity(entity.ColorPalette{Background: "#ffffff", Text: "#111111"}, false) | |
| 251 | 134 | ||
| 252 | - | assert.Equal(t, "#ffffff", lightPalette.Background) | |
| 253 | - | assert.Equal(t, "#000000", lightPalette.Text) | |
| 254 | - | assert.Equal(t, "#000000", darkPalette.Background) | |
| 255 | - | assert.Equal(t, "#ffffff", darkPalette.Text) | |
| 135 | + | assert.Equal(t, "#ffffff", palette.Background) | |
| 136 | + | assert.Equal(t, "#111111", palette.Text) | |
| 137 | + | assert.Equal(t, DefaultLightPalette().Success, palette.Success) | |
| 138 | + | assert.Equal(t, DefaultLightPalette().Warning, palette.Warning) | |
| 139 | + | assert.Equal(t, DefaultLightPalette().Destructive, palette.Destructive) | |
| 256 | 140 | } | |
| 257 | 141 | ||
| 258 | - | func TestManager_UIScale(t *testing.T) { | |
| 259 | - | ctx := context.Background() | |
| 260 | - | ||
| 261 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 262 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 263 | - | PrefersDark: true, | |
| 264 | - | Source: "test", | |
| 142 | + | func TestModeColorsFromEntity(t *testing.T) { | |
| 143 | + | modeColors := ModeColorsFromEntity(entity.ThemeModeColors{ | |
| 144 | + | PaneMode: "#111111", | |
| 145 | + | TabMode: "#222222", | |
| 146 | + | SessionMode: "#333333", | |
| 147 | + | ResizeMode: "#444444", | |
| 265 | 148 | }) | |
| 266 | 149 | ||
| 267 | - | manager := NewManager(ctx, nil, 1.5, nil, mockResolver) | |
| 268 | - | ||
| 269 | - | // UI scale should be stored (we can't directly access it, but we can verify | |
| 270 | - | // the manager was created without error) | |
| 271 | - | require.NotNil(t, manager) | |
| 150 | + | assert.Equal(t, "#111111", modeColors.PaneMode) | |
| 151 | + | assert.Equal(t, "#222222", modeColors.TabMode) | |
| 152 | + | assert.Equal(t, "#333333", modeColors.SessionMode) | |
| 153 | + | assert.Equal(t, "#444444", modeColors.ResizeMode) | |
| 272 | 154 | } | |
| 273 | 155 | ||
| 274 | - | func TestManager_Fonts(t *testing.T) { | |
| 275 | - | ctx := context.Background() | |
| 276 | - | appearance := &entity.AppearanceConfig{ | |
| 156 | + | func TestFontConfigFromEntity(t *testing.T) { | |
| 157 | + | fonts := FontConfigFromEntity(entity.ThemeFonts{ | |
| 277 | 158 | SansFont: "Inter", | |
| 278 | 159 | MonospaceFont: "JetBrains Mono", | |
| 279 | 160 | GtkFont: "Adwaita Sans", | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 283 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 284 | - | PrefersDark: true, | |
| 285 | - | Source: "test", | |
| 286 | 161 | }) | |
| 287 | 162 | ||
| 288 | - | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 289 | - | ||
| 290 | - | // Fonts should be stored (manager creation should succeed) | |
| 291 | - | require.NotNil(t, manager) | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | func TestManager_ModeColors(t *testing.T) { | |
| 295 | - | ctx := context.Background() | |
| 296 | - | styling := &entity.WorkspaceStylingConfig{ | |
| 297 | - | PaneModeColor: "#ff0000", | |
| 298 | - | TabModeColor: "#00ff00", | |
| 299 | - | SessionModeColor: "#0000ff", | |
| 300 | - | ResizeModeColor: "#ffff00", | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 304 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| 305 | - | PrefersDark: true, | |
| 306 | - | Source: "test", | |
| 307 | - | }) | |
| 308 | - | ||
| 309 | - | manager := NewManager(ctx, nil, 1.0, styling, mockResolver) | |
| 310 | - | modeColors := manager.GetModeColors() | |
| 311 | - | ||
| 312 | - | assert.Equal(t, "#ff0000", modeColors.PaneMode) | |
| 313 | - | assert.Equal(t, "#00ff00", modeColors.TabMode) | |
| 314 | - | assert.Equal(t, "#0000ff", modeColors.SessionMode) | |
| 315 | - | assert.Equal(t, "#ffff00", modeColors.ResizeMode) | |
| 163 | + | assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, fonts) | |
| 316 | 164 | } | |
| 317 | 165 | ||
| 318 | 166 | func TestDefaultGTKFont(t *testing.T) { | |
M internal/ui/theme/palette.go +41 -0
| @@ -112,6 +112,47 @@ func PaletteFromConfig(cfg *entity.ColorPalette, isDark bool) Palette { | |||
| 112 | 112 | } | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | + | // PaletteFromEntity creates a CSS adapter palette from a resolved domain palette. | |
| 116 | + | // Resolved palettes already contain validated color tokens; this adapter only adds | |
| 117 | + | // semantic status colors used by GTK/WebUI CSS. | |
| 118 | + | func PaletteFromEntity(p entity.ColorPalette, isDark bool) Palette { | |
| 119 | + | defaults := DefaultLightPalette() | |
| 120 | + | if isDark { | |
| 121 | + | defaults = DefaultDarkPalette() | |
| 122 | + | } | |
| 123 | + | return Palette{ | |
| 124 | + | Background: p.Background, | |
| 125 | + | Surface: p.Surface, | |
| 126 | + | SurfaceVariant: p.SurfaceVariant, | |
| 127 | + | Text: p.Text, | |
| 128 | + | Muted: p.Muted, | |
| 129 | + | Accent: p.Accent, | |
| 130 | + | Border: p.Border, | |
| 131 | + | Success: defaults.Success, | |
| 132 | + | Warning: defaults.Warning, | |
| 133 | + | Destructive: defaults.Destructive, | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // ModeColorsFromEntity creates CSS adapter mode colors from resolved domain mode colors. | |
| 138 | + | func ModeColorsFromEntity(colors entity.ThemeModeColors) ModeColors { | |
| 139 | + | return ModeColors{ | |
| 140 | + | PaneMode: colors.PaneMode, | |
| 141 | + | TabMode: colors.TabMode, | |
| 142 | + | SessionMode: colors.SessionMode, | |
| 143 | + | ResizeMode: colors.ResizeMode, | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // FontConfigFromEntity creates CSS adapter font config from resolved domain fonts. | |
| 148 | + | func FontConfigFromEntity(fonts entity.ThemeFonts) FontConfig { | |
| 149 | + | return FontConfig{ | |
| 150 | + | SansFont: fonts.SansFont, | |
| 151 | + | MonospaceFont: fonts.MonospaceFont, | |
| 152 | + | GtkFont: fonts.GtkFont, | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 115 | 156 | // Coalesce returns the first non-empty string. | |
| 116 | 157 | func Coalesce(values ...string) string { | |
| 117 | 158 | for _, v := range values { | |