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 | ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog) | 250 | ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog) | ||
| 251 | defer sessionCleanup() | 251 | defer sessionCleanup() | ||
| 252 | + | bootstrap.LogResolvedTheme(ctx, initResult.ResolvedTheme) | |||
| 252 | timer.Mark("session") | 253 | timer.Mark("session") | ||
| 253 | 254 | ||||
| 254 | log := logging.FromContext(ctx) | 255 | log := logging.FromContext(ctx) | ||
| @@ -326,6 +327,9 @@ func runStandaloneOmnibox() int { | |||||
| 326 | cfg, | 327 | cfg, | ||
| 327 | initResult.RuntimeProfile, | 328 | initResult.RuntimeProfile, | ||
| 328 | initResult.ThemeManager, | 329 | initResult.ThemeManager, | ||
| 330 | + | initResult.ResolveThemeUC, | |||
| 331 | + | initResult.ExternalThemeSource, | |||
| 332 | + | initResult.ExternalThemeWatcher, | |||
| 329 | initResult.ColorResolver, | 333 | initResult.ColorResolver, | ||
| 330 | initResult.AdwaitaDetector, | 334 | initResult.AdwaitaDetector, | ||
| 331 | nil, | 335 | nil, | ||
| @@ -471,7 +475,8 @@ func buildAndConfigureApp( | |||||
| 471 | ) (*ui.App, error) { | 475 | ) (*ui.App, error) { | ||
| 472 | uiDeps, err := buildUIDependencies( | 476 | uiDeps, err := buildUIDependencies( | ||
| 473 | ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager, | 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 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | 480 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | ||
| 476 | ) | 481 | ) | ||
| 477 | if err != nil { | 482 | if err != nil { | ||
| @@ -506,11 +511,12 @@ func initStackAndRepos( | |||||
| 506 | if needsEagerDB { | 511 | if needsEagerDB { | ||
| 507 | // Parallel phase 2: Database + engine initialize concurrently | 512 | // Parallel phase 2: Database + engine initialize concurrently | ||
| 508 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | 513 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | ||
| 509 | - | Ctx: ctx, | 514 | + | Ctx: ctx, |
| 510 | - | Config: cfg, | 515 | + | Config: cfg, |
| 511 | - | RuntimeProfile: initResult.RuntimeProfile, | 516 | + | RuntimeProfile: initResult.RuntimeProfile, |
| 512 | - | ThemeManager: initResult.ThemeManager, | 517 | + | ThemeManager: initResult.ThemeManager, |
| 513 | - | ColorResolver: initResult.ColorResolver, | 518 | + | ExternalThemeSource: initResult.ExternalThemeSource, |
| 519 | + | ColorResolver: initResult.ColorResolver, | |||
| 514 | }) | 520 | }) | ||
| 515 | if err != nil { | 521 | if err != nil { | ||
| 516 | return nil, nil, nil, err | 522 | return nil, nil, nil, err | ||
| @@ -520,12 +526,13 @@ func initStackAndRepos( | |||||
| 520 | 526 | ||||
| 521 | log := logging.FromContext(ctx) | 527 | log := logging.FromContext(ctx) | ||
| 522 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | 528 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | ||
| 523 | - | Ctx: ctx, | 529 | + | Ctx: ctx, |
| 524 | - | Config: cfg, | 530 | + | Config: cfg, |
| 525 | - | RuntimeProfile: initResult.RuntimeProfile, | 531 | + | RuntimeProfile: initResult.RuntimeProfile, |
| 526 | - | ThemeManager: initResult.ThemeManager, | 532 | + | ThemeManager: initResult.ThemeManager, |
| 527 | - | ColorResolver: initResult.ColorResolver, | 533 | + | ExternalThemeSource: initResult.ExternalThemeSource, |
| 528 | - | Logger: *log, | 534 | + | ColorResolver: initResult.ColorResolver, |
| 535 | + | Logger: *log, | |||
| 529 | }) | 536 | }) | ||
| 530 | if err != nil { | 537 | if err != nil { | ||
| 531 | return nil, nil, nil, err | 538 | return nil, nil, nil, err | ||
| @@ -834,6 +841,9 @@ func buildUIDependencies( | |||||
| 834 | cfg *config.Config, | 841 | cfg *config.Config, | ||
| 835 | runtimeProfile runtimeprofile.Profile, | 842 | runtimeProfile runtimeprofile.Profile, | ||
| 836 | themeManager *theme.Manager, | 843 | themeManager *theme.Manager, | ||
| 844 | + | resolveThemeUC *usecase.ResolveThemeUseCase, | |||
| 845 | + | externalThemeSource port.ConfigurableExternalThemeSource, | |||
| 846 | + | externalThemeWatcher port.ExternalThemeWatcher, | |||
| 837 | colorResolver port.ColorSchemeResolver, | 847 | colorResolver port.ColorSchemeResolver, | ||
| 838 | adwaitaDetector port.ToolkitAvailabilityNotifier, | 848 | adwaitaDetector port.ToolkitAvailabilityNotifier, | ||
| 839 | engine port.Engine, | 849 | engine port.Engine, | ||
| @@ -857,14 +867,17 @@ func buildUIDependencies( | |||||
| 857 | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | 867 | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | ||
| 858 | 868 | ||||
| 859 | uiDeps := &ui.Dependencies{ | 869 | uiDeps := &ui.Dependencies{ | ||
| 860 | - | Ctx: ctx, | 870 | + | Ctx: ctx, |
| 861 | - | Config: cfg, | 871 | + | Config: cfg, |
| 862 | - | InitialURL: initialURL, | 872 | + | InitialURL: initialURL, |
| 863 | - | RestoreSessionID: restoreSessionID, | 873 | + | RestoreSessionID: restoreSessionID, |
| 864 | - | StartupCrashReports: startupCrashReports, | 874 | + | StartupCrashReports: startupCrashReports, |
| 865 | - | Theme: themeManager, | 875 | + | Theme: themeManager, |
| 866 | - | ColorResolver: colorResolver, | 876 | + | ResolveThemeUC: resolveThemeUC, |
| 867 | - | AdwaitaDetector: adwaitaDetector, | 877 | + | ExternalThemeSource: externalThemeSource, |
| 878 | + | ExternalThemeWatcher: externalThemeWatcher, | |||
| 879 | + | ColorResolver: colorResolver, | |||
| 880 | + | AdwaitaDetector: adwaitaDetector, | |||
| 868 | XDG: xdg.New( | 881 | XDG: xdg.New( | ||
| 869 | runtimeProfile.Mode == runtimeprofile.ModeDev, | 882 | runtimeProfile.Mode == runtimeprofile.ModeDev, | ||
| 870 | bootstrap.ResolveXDGRuntimeDir(runtimeProfile), | 883 | bootstrap.ResolveXDGRuntimeDir(runtimeProfile), | ||
M docs/config/index.md +68 -0
| @@ -141,6 +141,10 @@ auto_open_on_new_pane = false | |||||
| 141 | | `appearance.monospace_font` | string | `"Fira Code"` | Monospace font | | 141 | | `appearance.monospace_font` | string | `"Fira Code"` | Monospace font | | ||
| 142 | | `appearance.default_font_size` | int | `16` | Font size in points | | 142 | | `appearance.default_font_size` | int | `16` | Font size in points | | ||
| 143 | | `appearance.color_scheme` | string | `"default"` | `prefer-dark`, `prefer-light`, `default` | | 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 | ### Color Palettes | 149 | ### Color Palettes | ||
| 146 | 150 | ||||
| @@ -168,6 +172,70 @@ accent = "#a8a8a8" | |||||
| 168 | border = "#363636" | 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 | ## Debug Options | 239 | ## Debug Options | ||
| 172 | 240 | ||||
| 173 | | Key | Type | Default | Description | | 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 | | `appearance.monospace_font` | string | `Fira Code` | | | 33 | | `appearance.monospace_font` | string | `Fira Code` | | | ||
| 34 | | `appearance.default_font_size` | int | `16` | | | 34 | | `appearance.default_font_size` | int | `16` | | | ||
| 35 | | `appearance.color_scheme` | string | `default` | `prefer-dark`, `prefer-light`, `default` | | 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 | | `appearance.light_palette.background` | string | `#f8f8f8` | | | 40 | | `appearance.light_palette.background` | string | `#f8f8f8` | | | ||
| 37 | | `appearance.light_palette.surface` | string | `#f2f2f2` | | | 41 | | `appearance.light_palette.surface` | string | `#f2f2f2` | | | ||
| 38 | | `appearance.light_palette.surface_variant` | string | `#ececec` | | | 42 | | `appearance.light_palette.surface_variant` | string | `#ececec` | | | ||
M internal/application/dto/webui_config.go +25 -20
| @@ -1,5 +1,7 @@ | |||||
| 1 | package dto | 1 | package dto | ||
| 2 | 2 | ||||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |||
| 4 | + | ||||
| 3 | // WebUIConfig represents the subset of configuration editable in dumb://config. | 5 | // WebUIConfig represents the subset of configuration editable in dumb://config. | ||
| 4 | type WebUIConfig struct { | 6 | type WebUIConfig struct { | ||
| 5 | Appearance WebUIAppearanceConfig `json:"appearance"` | 7 | Appearance WebUIAppearanceConfig `json:"appearance"` | ||
| @@ -43,7 +45,11 @@ type SystemviewPerformancePayload struct { | |||||
| 43 | 45 | ||||
| 44 | // SystemviewConfigPayload is the full JSON shape expected by dumb://config. | 46 | // SystemviewConfigPayload is the full JSON shape expected by dumb://config. | ||
| 45 | type SystemviewConfigPayload struct { | 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 | Performance SystemviewPerformancePayload `json:"performance"` | 53 | Performance SystemviewPerformancePayload `json:"performance"` | ||
| 48 | DefaultUIScale float64 `json:"default_ui_scale"` | 54 | DefaultUIScale float64 `json:"default_ui_scale"` | ||
| 49 | DefaultSearchEngine string `json:"default_search_engine"` | 55 | DefaultSearchEngine string `json:"default_search_engine"` | ||
| @@ -66,28 +72,27 @@ type WebUICustomPerformanceConfig struct { | |||||
| 66 | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | 72 | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | ||
| 67 | } | 73 | } | ||
| 68 | 74 | ||||
| 69 | - | type WebUIAppearanceConfig struct { | 75 | + | type WebUIAppearanceConfig = entity.AppearanceConfig |
| 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 | - | } | |||
| 79 | 76 | ||||
| 80 | - | type ColorPalette struct { | 77 | + | type WebUIExternalThemeConfig = entity.ExternalThemeConfig |
| 81 | - | Background string `json:"background"` | 78 | + | |
| 82 | - | Surface string `json:"surface"` | 79 | + | type ColorPalette = entity.ColorPalette |
| 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 | - | } | |||
| 89 | 80 | ||||
| 90 | type SearchShortcut struct { | 81 | type SearchShortcut struct { | ||
| 91 | URL string `json:"url"` | 82 | URL string `json:"url"` | ||
| 92 | Description string `json:"description"` | 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 | "github.com/bnema/dumber/internal/application/dto" | 7 | "github.com/bnema/dumber/internal/application/dto" | ||
| 8 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | // ReadSystemviewConfigUseCase exposes read-side systemview config payloads. | 12 | // ReadSystemviewConfigUseCase exposes read-side systemview config payloads. | ||
| 12 | type ReadSystemviewConfigUseCase struct { | 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 | // NewReadSystemviewConfigUseCase creates a new read systemview config use case. | 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 | if reader == nil { | 46 | if reader == nil { | ||
| 19 | panic("NewReadSystemviewConfigUseCase: reader is nil") | 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 | // ErrNilSystemviewConfigReader is returned when the config reader dependency is nil. | 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 | if uc == nil || uc.reader == nil { | 63 | if uc == nil || uc.reader == nil { | ||
| 30 | return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader | 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 | // Default returns the default systemview config payload. | 73 | // Default returns the default systemview config payload. | ||
| @@ -39,3 +77,49 @@ func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (dto.Systemv | |||||
| 39 | } | 77 | } | ||
| 40 | return uc.reader.Default(ctx) | 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 | // EngineInput holds the input for BuildEngine. | 24 | // EngineInput holds the input for BuildEngine. | ||
| 25 | type EngineInput struct { | 25 | type EngineInput struct { | ||
| 26 | - | Ctx context.Context | 26 | + | Ctx context.Context |
| 27 | - | Config *config.Config | 27 | + | Config *config.Config |
| 28 | - | RuntimeProfile runtimeprofile.Profile | 28 | + | RuntimeProfile runtimeprofile.Profile |
| 29 | - | ThemeManager *theme.Manager | 29 | + | ThemeManager *theme.Manager |
| 30 | - | ColorResolver port.ColorSchemeResolver | 30 | + | ExternalThemeSource port.ExternalThemeSource |
| 31 | - | Logger zerolog.Logger | 31 | + | ColorResolver port.ColorSchemeResolver |
| 32 | + | Logger zerolog.Logger | |||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | 35 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | ||
| 35 | func BuildEngine(input EngineInput) (port.Engine, error) { | 36 | func BuildEngine(input EngineInput) (port.Engine, error) { | ||
| 36 | cfg := input.Config | 37 | cfg := input.Config | ||
| 37 | systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor()) | 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 | buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) { | 47 | buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) { | ||
| 40 | return func() ([]byte, error) { | 48 | return func() ([]byte, error) { | ||
| 41 | payload, err := read(input.Ctx) | 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 | func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) { | 140 | func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) { | ||
| 124 | if profile.Engine == "" { | 141 | if profile.Engine == "" { | ||
| 125 | return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType) | 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 | "github.com/bnema/dumber/internal/application/port" | 12 | "github.com/bnema/dumber/internal/application/port" | ||
| 13 | "github.com/bnema/dumber/internal/application/usecase" | 13 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 14 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 14 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | 15 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/config" | 16 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/deps" | 17 | "github.com/bnema/dumber/internal/infrastructure/deps" | ||
| 17 | "github.com/bnema/dumber/internal/infrastructure/env" | 18 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 19 | + | "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia" | |||
| 18 | "github.com/bnema/dumber/internal/infrastructure/media" | 20 | "github.com/bnema/dumber/internal/infrastructure/media" | ||
| 19 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 21 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 20 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | 22 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | ||
| @@ -30,11 +32,15 @@ type DatabaseResult struct { | |||||
| 30 | 32 | ||||
| 31 | // ParallelInitResult holds the results of parallel initialization phase. | 33 | // ParallelInitResult holds the results of parallel initialization phase. | ||
| 32 | type ParallelInitResult struct { | 34 | type ParallelInitResult struct { | ||
| 33 | - | RuntimeProfile runtimeprofile.Profile | 35 | + | RuntimeProfile runtimeprofile.Profile |
| 34 | - | ThemeManager *theme.Manager | 36 | + | ThemeManager *theme.Manager |
| 35 | - | ColorResolver port.ColorSchemeResolver | 37 | + | ResolvedTheme entity.ResolvedTheme |
| 36 | - | AdwaitaDetector *colorscheme.AdwaitaDetector | 38 | + | ResolveThemeUC *usecase.ResolveThemeUseCase |
| 37 | - | Duration time.Duration | 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 | // DeferredInitResult holds results from deferred initialization checks. | 46 | // DeferredInitResult holds results from deferred initialization checks. | ||
| @@ -90,8 +96,9 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |||||
| 90 | // Returns the first fatal error encountered, or nil with the results. | 96 | // Returns the first fatal error encountered, or nil with the results. | ||
| 91 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | 97 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | ||
| 92 | var ( | 98 | var ( | ||
| 93 | - | dirsErr error | 99 | + | dirsErr error |
| 94 | - | wg sync.WaitGroup | 100 | + | themeErr error |
| 101 | + | wg sync.WaitGroup | |||
| 95 | ) | 102 | ) | ||
| 96 | 103 | ||||
| 97 | profile, err := ResolveRuntimeProfile(input.Config) | 104 | profile, err := ResolveRuntimeProfile(input.Config) | ||
| @@ -122,14 +129,28 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 122 | dirsErr = resolveWebKitDirs(profile) | 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 | var themeManager *theme.Manager | 136 | var themeManager *theme.Manager | ||
| 137 | + | var resolvedTheme entity.ResolvedTheme | |||
| 127 | go func() { | 138 | go func() { | ||
| 128 | defer wg.Done() | 139 | defer wg.Done() | ||
| 129 | - | themeManager = theme.NewManager( | 140 | + | preference := resolver.Resolve() |
| 130 | - | input.Ctx, &input.Config.Appearance, input.Config.DefaultUIScale, | 141 | + | resolved, err := resolveThemeUC.Execute(input.Ctx, usecase.ResolveThemeInputFromConfig( |
| 131 | - | &input.Config.Workspace.Styling, resolver, | 142 | + | &input.Config.Appearance, |
| 132 | - | ) | 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 | wg.Wait() | 156 | wg.Wait() | ||
| @@ -139,16 +160,42 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 139 | if dirsErr != nil { | 160 | if dirsErr != nil { | ||
| 140 | return nil, fmt.Errorf("resolve directories: %w", dirsErr) | 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 | return &ParallelInitResult{ | 167 | return &ParallelInitResult{ | ||
| 144 | - | RuntimeProfile: profile, | 168 | + | RuntimeProfile: profile, |
| 145 | - | ThemeManager: themeManager, | 169 | + | ThemeManager: themeManager, |
| 146 | - | ColorResolver: resolver, | 170 | + | ResolvedTheme: resolvedTheme, |
| 147 | - | AdwaitaDetector: adwaitaDetector, | 171 | + | ResolveThemeUC: resolveThemeUC, |
| 148 | - | Duration: duration, | 172 | + | ExternalThemeSource: externalThemeSource, |
| 173 | + | ExternalThemeWatcher: noctalia.NewFileWatcher(), | |||
| 174 | + | ColorResolver: resolver, | |||
| 175 | + | AdwaitaDetector: adwaitaDetector, | |||
| 176 | + | Duration: duration, | |||
| 149 | }, nil | 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 | // RunDeferredInit runs deferred initialization checks off the critical path. | 199 | // RunDeferredInit runs deferred initialization checks off the critical path. | ||
| 153 | // This includes runtime requirements and media checks. | 200 | // This includes runtime requirements and media checks. | ||
| 154 | func RunDeferredInit(input DeferredInitInput) DeferredInitResult { | 201 | func RunDeferredInit(input DeferredInitInput) DeferredInitResult { | ||
| @@ -275,11 +322,12 @@ type ParallelDBEngineResult struct { | |||||
| 275 | 322 | ||||
| 276 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | 323 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | ||
| 277 | type ParallelDBEngineInput struct { | 324 | type ParallelDBEngineInput struct { | ||
| 278 | - | Ctx context.Context | 325 | + | Ctx context.Context |
| 279 | - | Config *config.Config | 326 | + | Config *config.Config |
| 280 | - | RuntimeProfile runtimeprofile.Profile | 327 | + | RuntimeProfile runtimeprofile.Profile |
| 281 | - | ThemeManager *theme.Manager | 328 | + | ThemeManager *theme.Manager |
| 282 | - | ColorResolver port.ColorSchemeResolver | 329 | + | ExternalThemeSource port.ExternalThemeSource |
| 330 | + | ColorResolver port.ColorSchemeResolver | |||
| 283 | } | 331 | } | ||
| 284 | 332 | ||||
| 285 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | 333 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | ||
| @@ -314,12 +362,13 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, | |||||
| 314 | 362 | ||||
| 315 | // Engine on main thread (GTK requirement) | 363 | // Engine on main thread (GTK requirement) | ||
| 316 | engine, err := BuildEngine(EngineInput{ | 364 | engine, err := BuildEngine(EngineInput{ | ||
| 317 | - | Ctx: input.Ctx, | 365 | + | Ctx: input.Ctx, |
| 318 | - | Config: input.Config, | 366 | + | Config: input.Config, |
| 319 | - | RuntimeProfile: input.RuntimeProfile, | 367 | + | RuntimeProfile: input.RuntimeProfile, |
| 320 | - | ThemeManager: input.ThemeManager, | 368 | + | ThemeManager: input.ThemeManager, |
| 321 | - | ColorResolver: input.ColorResolver, | 369 | + | ExternalThemeSource: input.ExternalThemeSource, |
| 322 | - | Logger: *log, | 370 | + | ColorResolver: input.ColorResolver, |
| 371 | + | Logger: *log, | |||
| 323 | }) | 372 | }) | ||
| 324 | if err != nil { | 373 | if err != nil { | ||
| 325 | dbRes := <-dbCh | 374 | dbRes := <-dbCh | ||
M internal/cli/app.go +41 -2
| @@ -13,8 +13,10 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/bootstrap" | 13 | "github.com/bnema/dumber/internal/bootstrap" | ||
| 14 | "github.com/bnema/dumber/internal/cli/styles" | 14 | "github.com/bnema/dumber/internal/cli/styles" | ||
| 15 | "github.com/bnema/dumber/internal/domain/build" | 15 | "github.com/bnema/dumber/internal/domain/build" | ||
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 16 | "github.com/bnema/dumber/internal/domain/repository" | 17 | "github.com/bnema/dumber/internal/domain/repository" | ||
| 17 | "github.com/bnema/dumber/internal/infrastructure/config" | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 19 | + | "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia" | |||
| 18 | "github.com/bnema/dumber/internal/infrastructure/favicon" | 20 | "github.com/bnema/dumber/internal/infrastructure/favicon" | ||
| 19 | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | 21 | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | ||
| 20 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 22 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| @@ -56,8 +58,9 @@ func NewApp() (*App, error) { | |||||
| 56 | return nil, fmt.Errorf("resolve runtime profile: %w", err) | 58 | return nil, fmt.Errorf("resolve runtime profile: %w", err) | ||
| 57 | } | 59 | } | ||
| 58 | 60 | ||||
| 59 | - | // Create theme from config | 61 | + | // Create theme through the shared resolver. CLI keeps the historical dark default |
| 60 | - | theme := styles.NewTheme(cfg) | 62 | + | // unless config explicitly requests a light scheme. |
| 63 | + | theme := styles.NewThemeFromResolved(resolveCLITheme(cfg)) | |||
| 61 | 64 | ||||
| 62 | // Start with a quiet logger. If a browser session is active, we'll attach to it. | 65 | // Start with a quiet logger. If a browser session is active, we'll attach to it. | ||
| 63 | logLevel := cfg.Logging.Level | 66 | logLevel := cfg.Logging.Level | ||
| @@ -157,6 +160,42 @@ func (a *App) Close() error { | |||||
| 157 | return nil | 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 | // Ctx returns the application context with logger. | 199 | // Ctx returns the application context with logger. | ||
| 161 | func (a *App) Ctx() context.Context { | 200 | func (a *App) Ctx() context.Context { | ||
| 162 | return a.ctx | 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 | import ( | 4 | import ( | ||
| 5 | "github.com/charmbracelet/lipgloss" | 5 | "github.com/charmbracelet/lipgloss" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | "github.com/bnema/dumber/internal/infrastructure/config" | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| @@ -81,6 +82,18 @@ func NewTheme(cfg *config.Config) *Theme { | |||||
| 81 | return NewThemeFromPalette(p) | 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 | // NewThemeFromPalette creates a Theme from a ColorPalette. | 97 | // NewThemeFromPalette creates a Theme from a ColorPalette. | ||
| 85 | func NewThemeFromPalette(p config.ColorPalette) *Theme { | 98 | func NewThemeFromPalette(p config.ColorPalette) *Theme { | ||
| 86 | t := &Theme{ | 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 | // AppearanceConfig holds visual appearance settings. | 7 | // AppearanceConfig holds visual appearance settings. | ||
| 8 | type AppearanceConfig struct { | 8 | type AppearanceConfig struct { | ||
| 9 | - | SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font" json:"sans_font"` | 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"` | 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"` | 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"` | 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"` | 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"` | 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"` | 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"` | 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 | // ColorPalette defines the color scheme for a theme variant (light/dark). | 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 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 3 | // Default configuration constants | 10 | // Default configuration constants | ||
| 4 | const ( | 11 | const ( | ||
| 5 | // History defaults | 12 | // History defaults | ||
| @@ -14,7 +21,12 @@ const ( | |||||
| 14 | defaultMaxLogFiles = 100 // session log files | 21 | defaultMaxLogFiles = 100 // session log files | ||
| 15 | 22 | ||||
| 16 | // Appearance defaults | 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 | // Workspace defaults | 31 | // Workspace defaults | ||
| 20 | defaultNewPaneURL = "about:blank" | 32 | defaultNewPaneURL = "about:blank" | ||
| @@ -104,6 +116,36 @@ func getDefaultLogDir() string { | |||||
| 104 | return logDir | 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 | // DefaultConfig returns the default configuration values for dumber. | 149 | // DefaultConfig returns the default configuration values for dumber. | ||
| 108 | func DefaultConfig() *Config { | 150 | func DefaultConfig() *Config { | ||
| 109 | browsingContextDefaults := defaultBrowsingContextConfig() | 151 | browsingContextDefaults := defaultBrowsingContextConfig() | ||
| @@ -164,6 +206,12 @@ func DefaultConfig() *Config { | |||||
| 164 | Border: "#3f3f46", | 206 | Border: "#3f3f46", | ||
| 165 | }, | 207 | }, | ||
| 166 | ColorScheme: "default", // default follows system theme | 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 | Debug: DebugConfig{ | 216 | Debug: DebugConfig{ | ||
| 169 | EnableDevTools: true, | 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 | func normalizeAppearance(config *Config) { | 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 | switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) { | 448 | switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) { | ||
| 433 | case strings.ToLower(ThemePreferDark): | 449 | case strings.ToLower(ThemePreferDark): | ||
| 434 | config.Appearance.ColorScheme = ThemePreferDark | 450 | config.Appearance.ColorScheme = ThemePreferDark | ||
| @@ -640,6 +656,10 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) { | |||||
| 640 | m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette) | 656 | m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette) | ||
| 641 | m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette) | 657 | m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette) | ||
| 642 | m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme) | 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 | func (m *Manager) setZoomAndScaleDefaults(defaults *Config) { | 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 | Description: "Dark theme color palette (background, surface, surface_variant, text, muted, accent, border)", | 175 | Description: "Dark theme color palette (background, surface, surface_variant, text, muted, accent, border)", | ||
| 176 | Section: SectionAppearance, | 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 | if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 { | 104 | if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 { | ||
| 105 | validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0") | 105 | validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0") | ||
| 106 | } | 106 | } | ||
| 107 | + | validationErrors = append(validationErrors, validateExternalTheme(config)...) | |||
| 107 | return validationErrors | 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 | func validateSearchEngine(config *Config) []string { | 139 | func validateSearchEngine(config *Config) []string { | ||
| 111 | if config.DefaultSearchEngine == "" { | 140 | if config.DefaultSearchEngine == "" { | ||
| 112 | return []string{"default_search_engine cannot be empty"} | 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 | Accent: cfg.Appearance.DarkPalette.Accent, | 64 | Accent: cfg.Appearance.DarkPalette.Accent, | ||
| 65 | Border: cfg.Appearance.DarkPalette.Border, | 65 | Border: cfg.Appearance.DarkPalette.Border, | ||
| 66 | } | 66 | } | ||
| 67 | + | current.Appearance.ExternalTheme.Enabled = cfg.Appearance.ExternalTheme.Enabled | |||
| 68 | + | current.Appearance.ExternalTheme.Provider = cfg.Appearance.ExternalTheme.Provider | |||
| 69 | + | current.Appearance.ExternalTheme.Format = cfg.Appearance.ExternalTheme.Format | |||
| 70 | + | current.Appearance.ExternalTheme.Path = cfg.Appearance.ExternalTheme.Path | |||
| 67 | current.DefaultUIScale = cfg.DefaultUIScale | 71 | current.DefaultUIScale = cfg.DefaultUIScale | ||
| 68 | current.DefaultSearchEngine = cfg.DefaultSearchEngine | 72 | current.DefaultSearchEngine = cfg.DefaultSearchEngine | ||
| 69 | if len(cfg.SearchShortcuts) == 0 { | 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 | ColorScheme: appearance.ColorScheme, | 58 | ColorScheme: appearance.ColorScheme, | ||
| 59 | LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette), | 59 | LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette), | ||
| 60 | DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette), | 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 | // Stop accepting relaunches before teardown starts. | 3073 | // Stop accepting relaunches before teardown starts. | ||
| 3074 | a.closeBrowserLaunchRelayListener() | 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 | // Cancel context to signal all goroutines | 3081 | // Cancel context to signal all goroutines | ||
| 3077 | a.cancel(errors.New("application shutdown")) | 3082 | a.cancel(errors.New("application shutdown")) | ||
| @@ -5001,12 +5006,14 @@ func (a *App) UpdateOmniboxZoom(factor float64) { | |||||
| 5001 | func (a *App) initConfigWatcher(ctx context.Context) { | 5006 | func (a *App) initConfigWatcher(ctx context.Context) { | ||
| 5002 | log := logging.FromContext(ctx) | 5007 | log := logging.FromContext(ctx) | ||
| 5003 | 5008 | ||||
| 5009 | + | a.syncExternalThemeWatcher(ctx) | |||
| 5010 | + | ||||
| 5004 | if a.deps.WatchConfig == nil || a.deps.OnConfigChange == nil { | 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 | return | 5013 | return | ||
| 5007 | } | 5014 | } | ||
| 5008 | 5015 | ||||
| 5009 | - | // Start viper watcher | 5016 | + | // Start viper watcher. |
| 5010 | if err := a.deps.WatchConfig(); err != nil { | 5017 | if err := a.deps.WatchConfig(); err != nil { | ||
| 5011 | log.Warn().Err(err).Msg("failed to start config watcher") | 5018 | log.Warn().Err(err).Msg("failed to start config watcher") | ||
| 5012 | return | 5019 | return | ||
| @@ -5016,6 +5023,7 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||||
| 5016 | // deps.Config is updated in-place before this callback fires. | 5023 | // deps.Config is updated in-place before this callback fires. | ||
| 5017 | a.deps.OnConfigChange(func() { | 5024 | a.deps.OnConfigChange(func() { | ||
| 5018 | cb := glib.SourceFunc(func(_ uintptr) bool { | 5025 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 5026 | + | a.syncExternalThemeWatcher(ctx) | |||
| 5019 | a.applyAppearanceConfig(ctx) | 5027 | a.applyAppearanceConfig(ctx) | ||
| 5020 | for _, bw := range a.browserWindows { | 5028 | for _, bw := range a.browserWindows { | ||
| 5021 | if bw == nil { | 5029 | if bw == nil { | ||
| @@ -5036,6 +5044,21 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||||
| 5036 | log.Debug().Msg("config watcher initialized") | 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 | func (a *App) applyAppearanceConfig(ctx context.Context) { | 5062 | func (a *App) applyAppearanceConfig(ctx context.Context) { | ||
| 5040 | log := logging.FromContext(ctx) | 5063 | log := logging.FromContext(ctx) | ||
| 5041 | if a.deps == nil || a.deps.Config == nil { | 5064 | if a.deps == nil || a.deps.Config == nil { | ||
| @@ -5057,43 +5080,92 @@ func (a *App) applyAppearanceConfig(ctx context.Context) { | |||||
| 5057 | a.contentCoord.ApplySettingsToAll(ctx) | 5080 | a.contentCoord.ApplySettingsToAll(ctx) | ||
| 5058 | } | 5081 | } | ||
| 5059 | 5082 | ||||
| 5060 | - | // Update GTK theme and injected WebUI theme vars | 5083 | + | a.applyThemeAppearance(ctx) |
| 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) | |||
| 5067 | 5084 | ||||
| 5068 | - | var inj port.ContentInjector | 5085 | + | log.Info().Msg("appearance config updated") |
| 5069 | - | if a.engine != nil { | 5086 | + | } |
| 5070 | - | inj = a.engine.ContentInjector() | |||
| 5071 | - | } | |||
| 5072 | 5087 | ||||
| 5073 | - | if inj != nil { | 5088 | + | func (a *App) applyThemeAppearance(ctx context.Context) { |
| 5074 | - | findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette()) | 5089 | + | log := logging.FromContext(ctx) |
| 5075 | - | if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil { | 5090 | + | if a.deps == nil || a.deps.Config == nil || a.deps.Theme == nil { |
| 5076 | - | log.Warn().Err(err).Msg("failed to update find highlight CSS") | 5091 | + | return |
| 5077 | - | } | 5092 | + | } |
| 5078 | - | } | 5093 | + | cfg := a.deps.Config |
| 5079 | 5094 | ||||
| 5080 | - | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj) | 5095 | + | var display *gdk.Display |
| 5081 | - | cssText := a.deps.Theme.GetWebUIThemeCSS() | 5096 | + | if a.mainWindow != nil && a.mainWindow.Window() != nil { |
| 5082 | - | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil { | 5097 | + | display = a.mainWindow.Window().GetDisplay() |
| 5083 | - | log.Warn().Err(err).Msg("failed to update WebUI theme CSS") | 5098 | + | } |
| 5084 | - | } | |||
| 5085 | 5099 | ||||
| 5086 | - | if a.contentCoord != nil && inj != nil { | 5100 | + | preference := port.ColorSchemePreference{PrefersDark: a.deps.Theme.PrefersDark(), Source: "theme"} |
| 5087 | - | a.contentCoord.RefreshInjectedScriptsToAll(ctx) | 5101 | + | if a.deps.ColorResolver != nil { |
| 5088 | - | } | 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 | 5130 | + | if inj != nil { |
| 5091 | - | if a.contentCoord != nil { | 5131 | + | findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette()) |
| 5092 | - | a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText) | 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 | func (a *App) initFilteringAsync(ctx context.Context) { | 5171 | func (a *App) initFilteringAsync(ctx context.Context) { | ||
M internal/ui/deps.go +6 -3
| @@ -28,9 +28,12 @@ type Dependencies struct { | |||||
| 28 | OnCrashReportsDetected func([]string) | 28 | OnCrashReportsDetected func([]string) | ||
| 29 | 29 | ||||
| 30 | // Theme and color scheme management | 30 | // Theme and color scheme management | ||
| 31 | - | Theme *theme.Manager | 31 | + | Theme *theme.Manager |
| 32 | - | ColorResolver port.ColorSchemeResolver | 32 | + | ResolveThemeUC *usecase.ResolveThemeUseCase |
| 33 | - | AdwaitaDetector port.ToolkitAvailabilityNotifier | 33 | + | ExternalThemeSource port.ConfigurableExternalThemeSource |
| 34 | + | ExternalThemeWatcher port.ExternalThemeWatcher | |||
| 35 | + | ColorResolver port.ColorSchemeResolver | |||
| 36 | + | AdwaitaDetector port.ToolkitAvailabilityNotifier | |||
| 34 | 37 | ||||
| 35 | // XDG paths | 38 | // XDG paths | ||
| 36 | XDG port.XDGPaths | 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 | config, err := a.deps.Config.Current(ctx) | 795 | config, err := a.deps.Config.Current(ctx) | ||
| 796 | if err == nil { | 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 | Accent: "#66aaff", | 642 | Accent: "#66aaff", | ||
| 643 | Border: "#333333", | 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 | Performance: dto.SystemviewPerformancePayload{ | 652 | Performance: dto.SystemviewPerformancePayload{ | ||
| 647 | Profile: "balanced", | 653 | Profile: "balanced", | ||
| @@ -707,6 +713,13 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||||
| 707 | assert.Contains(t, app.renderedHTML, "Inter") | 713 | assert.Contains(t, app.renderedHTML, "Inter") | ||
| 708 | assert.Contains(t, app.renderedHTML, "appearance.light_palette.background") | 714 | assert.Contains(t, app.renderedHTML, "appearance.light_palette.background") | ||
| 709 | assert.Contains(t, app.renderedHTML, "#ffffff") | 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 | assert.Contains(t, app.renderedHTML, "performance.profile") | 723 | assert.Contains(t, app.renderedHTML, "performance.profile") | ||
| 711 | assert.Contains(t, app.renderedHTML, "balanced") | 724 | assert.Contains(t, app.renderedHTML, "balanced") | ||
| 712 | assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads") | 725 | assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads") | ||
| @@ -761,6 +774,13 @@ func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) { | |||||
| 761 | require.NoError(t, app.LoadInitial(context.Background())) | 774 | require.NoError(t, app.LoadInitial(context.Background())) | ||
| 762 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`) | 775 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`) | ||
| 763 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`) | 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 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`) | 784 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`) | ||
| 765 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`) | 785 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`) | ||
| 766 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`) | 786 | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`) | ||
| @@ -796,6 +816,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||||
| 796 | Data: map[string]string{ | 816 | Data: map[string]string{ | ||
| 797 | "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono", | 817 | "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono", | ||
| 798 | "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light", | 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 | "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd", | 820 | "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd", | ||
| 800 | "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333", | 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 | assert.True(t, service.calledSave) | 824 | assert.True(t, service.calledSave) | ||
| 804 | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | 825 | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | ||
| 805 | assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001) | 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 | assert.Contains(t, dom.HTML(), "Saved appearance settings") | 831 | assert.Contains(t, dom.HTML(), "Saved appearance settings") | ||
| 807 | assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`) | 832 | assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`) | ||
| 808 | 833 | ||||
| @@ -876,6 +901,28 @@ func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) { | |||||
| 876 | assert.False(t, service.calledSave) | 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 | func testConfigPayload() dto.SystemviewConfigPayload { | 926 | func testConfigPayload() dto.SystemviewConfigPayload { | ||
| 880 | return dto.SystemviewConfigPayload{ | 927 | return dto.SystemviewConfigPayload{ | ||
| 881 | EngineType: "webkit", | 928 | EngineType: "webkit", | ||
| @@ -887,6 +934,7 @@ func testConfigPayload() dto.SystemviewConfigPayload { | |||||
| 887 | DefaultFontSize: 16, | 934 | DefaultFontSize: 16, | ||
| 888 | LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"}, | 935 | LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"}, | ||
| 889 | DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"}, | 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 | DefaultUIScale: 1, | 939 | DefaultUIScale: 1, | ||
| 892 | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | 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 | <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option> | 39 | <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option> | ||
| 40 | <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option> | 40 | <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option> | ||
| 41 | </select></label> | 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 | @PaletteFields("Light palette", "light", config.Appearance.LightPalette) | 50 | @PaletteFields("Light palette", "light", config.Appearance.LightPalette) | ||
| 43 | @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette) | 51 | @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette) | ||
| 44 | <div class="sv-button-row sv-form-actions"> | 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 | cfg.Appearance.GtkFont = strings.TrimSpace(data["gtk_font"]) | 102 | cfg.Appearance.GtkFont = strings.TrimSpace(data["gtk_font"]) | ||
| 103 | cfg.Appearance.DefaultFontSize = fontSize | 103 | cfg.Appearance.DefaultFontSize = fontSize | ||
| 104 | cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"]) | 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 | cfg.Appearance.LightPalette = paletteFromForm(data, "light") | 109 | cfg.Appearance.LightPalette = paletteFromForm(data, "light") | ||
| 106 | cfg.Appearance.DarkPalette = paletteFromForm(data, "dark") | 110 | cfg.Appearance.DarkPalette = paletteFromForm(data, "dark") | ||
| 107 | cfg.DefaultUIScale = uiScale | 111 | cfg.DefaultUIScale = uiScale | ||
| @@ -113,6 +117,15 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) | |||||
| 113 | return nil | 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 | func (a *App) resetAppearanceConfig(ctx context.Context) error { | 129 | func (a *App) resetAppearanceConfig(ctx context.Context) error { | ||
| 117 | cfg, err := a.editableConfig(ctx) | 130 | cfg, err := a.editableConfig(ctx) | ||
| 118 | if err != nil { | 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 | /* Search entry field */ | 256 | /* Search entry field */ | ||
| 257 | entry.omnibox-entry, | 257 | entry.omnibox-entry, | ||
| 258 | entry.omnibox-entry > text { | 258 | entry.omnibox-entry > text { | ||
| 259 | - | background-color: alpha(var(--bg), 0.88); | 259 | + | background-color: var(--surface-variant); |
| 260 | background-image: none; | 260 | background-image: none; | ||
| 261 | color: var(--text); | 261 | color: var(--text); | ||
| 262 | caret-color: var(--accent); | 262 | caret-color: var(--accent); | ||
| @@ -278,7 +278,7 @@ entry.omnibox-entry:focus > text, | |||||
| 278 | entry.omnibox-entry:focus-within > text, | 278 | entry.omnibox-entry:focus-within > text, | ||
| 279 | entry.omnibox-entry:focus-visible > text { | 279 | entry.omnibox-entry:focus-visible > text { | ||
| 280 | border-color: var(--accent); | 280 | border-color: var(--accent); | ||
| 281 | - | background-color: shade(var(--bg), 1.05); | 281 | + | background-color: shade(var(--surface-variant), 1.08); |
| 282 | background-image: none; | 282 | background-image: none; | ||
| 283 | outline-style: none; | 283 | outline-style: none; | ||
| 284 | outline-width: 0px; | 284 | outline-width: 0px; | ||
| @@ -463,7 +463,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus > text, | |||||
| 463 | entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text, | 463 | entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text, | ||
| 464 | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text { | 464 | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text { | ||
| 465 | border-color: var(--accent); | 465 | border-color: var(--accent); | ||
| 466 | - | background-color: shade(var(--bg), 1.05); | 466 | + | background-color: shade(var(--surface-variant), 1.08); |
| 467 | background-image: none; | 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 | css := GenerateCSS(DefaultDarkPalette()) | 105 | css := GenerateCSS(DefaultDarkPalette()) | ||
| 106 | 106 | ||||
| 107 | containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`) | 107 | containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`) | ||
| 108 | if !containerRe.MatchString(css) { | 108 | if !containerRe.MatchString(css) { | ||
| 109 | t.Fatalf("expected omnibox container to use the header surface color") | 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 | if !entryRe.MatchString(css) { | 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 | if !focusedEntryRe.MatchString(css) { | 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 | scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`) | 126 | scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`) | ||
| 120 | if !scrolledRe.MatchString(css) { | 127 | if !scrolledRe.MatchString(css) { | ||
M internal/ui/theme/manager.go +37 -157
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "math" | 6 | "math" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 9 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 11 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 12 | "github.com/bnema/puregotk/v4/gdk" | 11 | "github.com/bnema/puregotk/v4/gdk" | ||
| @@ -14,106 +13,51 @@ import ( | |||||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| 16 | // Manager handles theme state and CSS application. | 15 | // Manager handles theme state and CSS application. | ||
| 16 | + | // Theme precedence and fallback are resolved before data reaches this adapter. | |||
| 17 | type Manager struct { | 17 | type Manager struct { | ||
| 18 | - | scheme string // "light", "dark", "system" | 18 | + | prefersDark bool // Resolved dark mode preference |
| 19 | - | prefersDark bool // Resolved dark mode preference | 19 | + | lightPalette Palette // Light theme colors |
| 20 | - | lightPalette Palette // Light theme colors | 20 | + | darkPalette Palette // Dark theme colors |
| 21 | - | darkPalette Palette // Dark theme colors | 21 | + | uiScale float64 // UI scaling factor (1.0 = 100%) |
| 22 | - | uiScale float64 // UI scaling factor (1.0 = 100%) | 22 | + | fonts FontConfig |
| 23 | - | fonts FontConfig | 23 | + | gtkFont string |
| 24 | - | gtkFont string | 24 | + | modeColors ModeColors // Modal mode indicator colors |
| 25 | - | modeColors ModeColors // Modal mode indicator colors | 25 | + | cssProvider *gtk.CssProvider |
| 26 | - | cssProvider *gtk.CssProvider | 26 | + | appliedFont string |
| 27 | - | appliedFont string | |||
| 28 | - | colorResolver port.ColorSchemeResolver // Resolver for dynamic detection | |||
| 29 | } | 27 | } | ||
| 30 | 28 | ||||
| 31 | - | // NewManager creates a new theme manager from configuration. | 29 | + | // NewManager creates a new theme manager from an already-resolved theme. |
| 32 | - | // The ColorSchemeResolver is required for proper color scheme detection. | 30 | + | func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager { |
| 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 { | |||
| 44 | log := logging.FromContext(ctx) | 31 | log := logging.FromContext(ctx) | ||
| 45 | 32 | ||||
| 46 | - | // Determine color scheme preference | 33 | + | m := &Manager{} |
| 47 | - | scheme := "system" | 34 | + | m.applyResolvedTheme(resolved) |
| 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) | |||
| 82 | 35 | ||||
| 83 | log.Debug(). | 36 | log.Debug(). | ||
| 84 | - | Str("scheme", scheme). | 37 | + | Bool("prefers_dark", m.prefersDark). |
| 85 | - | Bool("prefers_dark", prefersDark). | 38 | + | Float64("ui_scale", m.uiScale). |
| 86 | - | Float64("ui_scale", uiScale). | 39 | + | Str("sans_font", m.fonts.SansFont). |
| 87 | - | Str("sans_font", fonts.SansFont). | 40 | + | Str("monospace_font", m.fonts.MonospaceFont). |
| 88 | - | Str("monospace_font", fonts.MonospaceFont). | 41 | + | Str("gtk_font", m.gtkFont). |
| 89 | - | Str("gtk_font", gtkFont). | |||
| 90 | Msg("theme manager initialized") | 42 | Msg("theme manager initialized") | ||
| 91 | 43 | ||||
| 92 | - | return &Manager{ | 44 | + | return m |
| 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 | - | } | |||
| 103 | } | 45 | } | ||
| 104 | 46 | ||||
| 105 | - | // modeColorsFromStyling extracts mode colors from styling config, using defaults for missing values. | 47 | + | func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) { |
| 106 | - | func modeColorsFromStyling(styling *entity.WorkspaceStylingConfig) ModeColors { | 48 | + | m.prefersDark = resolved.PrefersDark |
| 107 | - | defaults := DefaultModeColors() | 49 | + | m.lightPalette = PaletteFromEntity(resolved.LightPalette, false) |
| 108 | - | if styling == nil { | 50 | + | m.darkPalette = PaletteFromEntity(resolved.DarkPalette, true) |
| 109 | - | return defaults | 51 | + | m.uiScale = resolved.UIScale |
| 52 | + | if m.uiScale <= 0 { | |||
| 53 | + | m.uiScale = 1.0 | |||
| 110 | } | 54 | } | ||
| 111 | - | return ModeColors{ | 55 | + | m.fonts = FontConfigFromEntity(resolved.Fonts) |
| 112 | - | PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode), | 56 | + | if m.fonts.GtkFont == "" { |
| 113 | - | TabMode: Coalesce(styling.TabModeColor, defaults.TabMode), | 57 | + | m.fonts.GtkFont = DefaultGTKFont() |
| 114 | - | SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode), | |||
| 115 | - | ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode), | |||
| 116 | } | 58 | } | ||
| 59 | + | m.gtkFont = m.fonts.GtkFont | |||
| 60 | + | m.modeColors = ModeColorsFromEntity(resolved.ModeColors) | |||
| 117 | } | 61 | } | ||
| 118 | 62 | ||||
| 119 | // PrefersDark returns true if dark mode is active. | 63 | // PrefersDark returns true if dark mode is active. | ||
| @@ -121,7 +65,7 @@ func (m *Manager) PrefersDark() bool { | |||||
| 121 | return m.prefersDark | 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 | func (m *Manager) GetCurrentPalette() Palette { | 69 | func (m *Manager) GetCurrentPalette() Palette { | ||
| 126 | if m.prefersDark { | 70 | if m.prefersDark { | ||
| 127 | return m.darkPalette | 71 | return m.darkPalette | ||
| @@ -230,83 +174,19 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||||
| 230 | Msg("theme CSS applied to display") | 174 | Msg("theme CSS applied to display") | ||
| 231 | } | 175 | } | ||
| 232 | 176 | ||||
| 233 | - | // SetColorScheme changes the active color scheme at runtime. | 177 | + | // UpdateFromResolved updates the theme manager state from a resolved theme. |
| 234 | - | func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) { | 178 | + | func (m *Manager) UpdateFromResolved(ctx context.Context, resolved entity.ResolvedTheme, display *gdk.Display) { |
| 235 | log := logging.FromContext(ctx) | 179 | log := logging.FromContext(ctx) | ||
| 236 | 180 | ||||
| 237 | - | m.scheme = scheme | 181 | + | m.applyResolvedTheme(resolved) |
| 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 | - | } | |||
| 301 | 182 | ||||
| 302 | log.Info(). | 183 | log.Info(). | ||
| 303 | - | Str("scheme", m.scheme). | |||
| 304 | Bool("prefers_dark", m.prefersDark). | 184 | Bool("prefers_dark", m.prefersDark). | ||
| 305 | Float64("ui_scale", m.uiScale). | 185 | Float64("ui_scale", m.uiScale). | ||
| 306 | Str("sans_font", m.fonts.SansFont). | 186 | Str("sans_font", m.fonts.SansFont). | ||
| 307 | Str("monospace_font", m.fonts.MonospaceFont). | 187 | Str("monospace_font", m.fonts.MonospaceFont). | ||
| 308 | Str("gtk_font", m.gtkFont). | 188 | Str("gtk_font", m.gtkFont). | ||
| 309 | - | Msg("theme manager updated from config") | 189 | + | Msg("theme manager updated from resolved theme") |
| 310 | 190 | ||||
| 311 | // Re-apply CSS if display is available | 191 | // Re-apply CSS if display is available | ||
| 312 | if display != nil { | 192 | if display != nil { | ||
M internal/ui/theme/manager_test.go +103 -255
| @@ -4,315 +4,163 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | - | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 7 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 10 | "github.com/stretchr/testify/assert" | 8 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 12 | ) | 10 | ) | ||
| 13 | 11 | ||||
| 14 | - | func TestNewManager_DarkMode(t *testing.T) { | 12 | + | func resolvedThemeFixture(prefersDark bool) entity.ResolvedTheme { |
| 15 | - | ctx := context.Background() | 13 | + | light := entity.ColorPalette{ |
| 16 | - | appearance := &entity.AppearanceConfig{ | 14 | + | Background: "#ffffff", |
| 17 | - | ColorScheme: "default", | 15 | + | Surface: "#f8f8f8", |
| 16 | + | SurfaceVariant: "#eeeeee", | |||
| 17 | + | Text: "#111111", | |||
| 18 | + | Muted: "#666666", | |||
| 19 | + | Accent: "#0055ff", | |||
| 20 | + | Border: "#dddddd", | |||
| 18 | } | 21 | } | ||
| 19 | - | 22 | + | dark := entity.ColorPalette{ | |
| 20 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | 23 | + | Background: "#101010", |
| 21 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | 24 | + | Surface: "#181818", |
| 22 | - | PrefersDark: true, | 25 | + | SurfaceVariant: "#242424", |
| 23 | - | Source: "test", | 26 | + | Text: "#f5f5f5", |
| 24 | - | }) | 27 | + | Muted: "#999999", |
| 25 | - | 28 | + | Accent: "#66aaff", | |
| 26 | - | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | 29 | + | Border: "#333333", |
| 27 | - | 30 | + | } | |
| 28 | - | assert.True(t, manager.PrefersDark()) | 31 | + | active := light |
| 29 | - | assert.Equal(t, "default", manager.scheme) | 32 | + | if prefersDark { |
| 30 | - | } | 33 | + | active = dark |
| 31 | - | 34 | + | } | |
| 32 | - | func TestNewManager_LightMode(t *testing.T) { | 35 | + | return entity.ResolvedTheme{ |
| 33 | - | ctx := context.Background() | 36 | + | LightPalette: light, |
| 34 | - | appearance := &entity.AppearanceConfig{ | 37 | + | DarkPalette: dark, |
| 35 | - | ColorScheme: "default", | 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 | ctx := context.Background() | 60 | ctx := context.Background() | ||
| 61 | + | resolved := resolvedThemeFixture(true) | |||
| 51 | 62 | ||||
| 52 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | 63 | + | manager := NewManager(ctx, resolved) |
| 53 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |||
| 54 | - | PrefersDark: true, | |||
| 55 | - | Source: "fallback", | |||
| 56 | - | }) | |||
| 57 | - | ||||
| 58 | - | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |||
| 59 | 64 | ||||
| 60 | require.NotNil(t, manager) | 65 | require.NotNil(t, manager) | ||
| 61 | assert.True(t, manager.PrefersDark()) | 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 | ctx := context.Background() | 77 | ctx := context.Background() | ||
| 67 | - | appearance := &entity.AppearanceConfig{ | 78 | + | resolved := resolvedThemeFixture(true) |
| 68 | - | ColorScheme: "prefer-dark", | 79 | + | resolved.UIScale = 0 |
| 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()) | |||
| 131 | 80 | ||||
| 132 | - | // Change scheme - this calls Refresh on resolver | 81 | + | manager := NewManager(ctx, resolved) |
| 133 | - | manager.SetColorScheme(ctx, "prefer-light", nil) | |||
| 134 | 82 | ||||
| 135 | - | assert.False(t, manager.PrefersDark()) | 83 | + | require.NotNil(t, manager) |
| 136 | - | assert.Equal(t, "prefer-light", manager.scheme) | 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 | ctx := context.Background() | 88 | ctx := context.Background() | ||
| 141 | - | appearance := &entity.AppearanceConfig{ | 89 | + | manager := NewManager(ctx, resolvedThemeFixture(true)) |
| 142 | - | ColorScheme: "default", | 90 | + | updated := resolvedThemeFixture(false) |
| 143 | - | } | 91 | + | updated.LightPalette.Background = "#abcdef" |
| 144 | - | 92 | + | updated.DarkPalette.Background = "#123456" | |
| 145 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | 93 | + | updated.Fonts.SansFont = "Recursive" |
| 146 | - | // Initial resolve | 94 | + | updated.UIScale = 2.0 |
| 147 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | 95 | + | updated.ModeColors.PaneMode = "#654321" |
| 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 | - | }) | |||
| 156 | 96 | ||||
| 157 | - | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | 97 | + | manager.UpdateFromResolved(ctx, updated, nil) |
| 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) | |||
| 165 | 98 | ||||
| 166 | assert.False(t, manager.PrefersDark()) | 99 | assert.False(t, manager.PrefersDark()) | ||
| 167 | - | assert.Equal(t, "prefer-light", manager.scheme) | 100 | + | assert.Equal(t, "#abcdef", manager.GetLightPalette().Background) |
| 168 | - | } | 101 | + | assert.Equal(t, "#123456", manager.GetDarkPalette().Background) |
| 169 | - | 102 | + | assert.Equal(t, manager.GetLightPalette(), manager.GetCurrentPalette()) | |
| 170 | - | func TestManager_UpdateFromConfig_NilConfig(t *testing.T) { | 103 | + | assert.Equal(t, "Recursive", manager.fonts.SansFont) |
| 171 | - | ctx := context.Background() | 104 | + | assert.InDelta(t, 2.0, manager.uiScale, 0.000001) |
| 172 | - | 105 | + | assert.Equal(t, "#654321", manager.GetModeColors().PaneMode) | |
| 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) | |||
| 186 | } | 106 | } | ||
| 187 | 107 | ||||
| 188 | func TestManager_GetWebUIThemeCSS(t *testing.T) { | 108 | func TestManager_GetWebUIThemeCSS(t *testing.T) { | ||
| 189 | ctx := context.Background() | 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 | css := manager.GetWebUIThemeCSS() | 112 | css := manager.GetWebUIThemeCSS() | ||
| 199 | 113 | ||||
| 200 | - | // Should contain both light and dark variables | |||
| 201 | assert.Contains(t, css, ":root{") | 114 | assert.Contains(t, css, ":root{") | ||
| 202 | assert.Contains(t, css, ".dark{") | 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 | ctx := context.Background() | 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 | r, g, b, a := manager.GetBackgroundRGBA() | 124 | r, g, b, a := manager.GetBackgroundRGBA() | ||
| 216 | 125 | ||||
| 217 | - | // Should return valid RGBA values (0-1 range) | 126 | + | assert.InDelta(t, float32(0x10)/255, r, 0.0001) |
| 218 | - | assert.GreaterOrEqual(t, r, float32(0)) | 127 | + | assert.InDelta(t, float32(0x10)/255, g, 0.0001) |
| 219 | - | assert.LessOrEqual(t, r, float32(1)) | 128 | + | assert.InDelta(t, float32(0x10)/255, b, 0.0001) |
| 220 | - | assert.GreaterOrEqual(t, g, float32(0)) | 129 | + | assert.InDelta(t, float32(1), a, 0.0001) |
| 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)) | |||
| 226 | } | 130 | } | ||
| 227 | 131 | ||||
| 228 | - | func TestManager_CustomPalettes(t *testing.T) { | 132 | + | func TestPaletteFromEntityAddsSemanticDefaults(t *testing.T) { |
| 229 | - | ctx := context.Background() | 133 | + | palette := PaletteFromEntity(entity.ColorPalette{Background: "#ffffff", Text: "#111111"}, false) |
| 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() | |||
| 251 | 134 | ||||
| 252 | - | assert.Equal(t, "#ffffff", lightPalette.Background) | 135 | + | assert.Equal(t, "#ffffff", palette.Background) |
| 253 | - | assert.Equal(t, "#000000", lightPalette.Text) | 136 | + | assert.Equal(t, "#111111", palette.Text) |
| 254 | - | assert.Equal(t, "#000000", darkPalette.Background) | 137 | + | assert.Equal(t, DefaultLightPalette().Success, palette.Success) |
| 255 | - | assert.Equal(t, "#ffffff", darkPalette.Text) | 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) { | 142 | + | func TestModeColorsFromEntity(t *testing.T) { |
| 259 | - | ctx := context.Background() | 143 | + | modeColors := ModeColorsFromEntity(entity.ThemeModeColors{ |
| 260 | - | 144 | + | PaneMode: "#111111", | |
| 261 | - | mockResolver := mocks.NewMockColorSchemeResolver(t) | 145 | + | TabMode: "#222222", |
| 262 | - | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | 146 | + | SessionMode: "#333333", |
| 263 | - | PrefersDark: true, | 147 | + | ResizeMode: "#444444", |
| 264 | - | Source: "test", | |||
| 265 | }) | 148 | }) | ||
| 266 | 149 | ||||
| 267 | - | manager := NewManager(ctx, nil, 1.5, nil, mockResolver) | 150 | + | assert.Equal(t, "#111111", modeColors.PaneMode) |
| 268 | - | 151 | + | assert.Equal(t, "#222222", modeColors.TabMode) | |
| 269 | - | // UI scale should be stored (we can't directly access it, but we can verify | 152 | + | assert.Equal(t, "#333333", modeColors.SessionMode) |
| 270 | - | // the manager was created without error) | 153 | + | assert.Equal(t, "#444444", modeColors.ResizeMode) |
| 271 | - | require.NotNil(t, manager) | |||
| 272 | } | 154 | } | ||
| 273 | 155 | ||||
| 274 | - | func TestManager_Fonts(t *testing.T) { | 156 | + | func TestFontConfigFromEntity(t *testing.T) { |
| 275 | - | ctx := context.Background() | 157 | + | fonts := FontConfigFromEntity(entity.ThemeFonts{ |
| 276 | - | appearance := &entity.AppearanceConfig{ | |||
| 277 | SansFont: "Inter", | 158 | SansFont: "Inter", | ||
| 278 | MonospaceFont: "JetBrains Mono", | 159 | MonospaceFont: "JetBrains Mono", | ||
| 279 | GtkFont: "Adwaita Sans", | 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) | 163 | + | assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, fonts) |
| 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) | |||
| 316 | } | 164 | } | ||
| 317 | 165 | ||||
| 318 | func TestDefaultGTKFont(t *testing.T) { | 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 | // Coalesce returns the first non-empty string. | 156 | // Coalesce returns the first non-empty string. | ||
| 116 | func Coalesce(values ...string) string { | 157 | func Coalesce(values ...string) string { | ||
| 117 | for _, v := range values { | 158 | for _, v := range values { | ||