1package noctalia
2
3import (
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
19const (
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
28var _ 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.
34type 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.
43func 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.
55func 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.
62func (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 identityPath := path
67 if expandedPath, err := ExpandPath(path); err == nil {
68 identityPath = expandedPath
69 }
70
71 s.mu.Lock()
72 defer s.mu.Unlock()
73 s.enabled = enabled
74 s.path = path
75 s.format = format
76 s.identity = externalThemeIdentity(enabled, provider, format, identityPath)
77}
78
79// ExternalThemeIdentity returns the current normalized external theme identity.
80func (s *FileSource) ExternalThemeIdentity() string {
81 s.mu.RLock()
82 defer s.mu.RUnlock()
83 return s.identity
84}
85
86// IsEnabled returns whether this source should be used.
87func (s *FileSource) IsEnabled() bool {
88 s.mu.RLock()
89 defer s.mu.RUnlock()
90 return s.enabled
91}
92
93// Get reads and parses the configured Noctalia theme file.
94func (s *FileSource) Get(ctx context.Context) (*entity.ExternalTheme, error) {
95 s.mu.RLock()
96 enabled := s.enabled
97 pathConfig := s.path
98 format := s.format
99 s.mu.RUnlock()
100
101 if !enabled {
102 return nil, nil
103 }
104 if err := ctx.Err(); err != nil {
105 return nil, err
106 }
107
108 path, err := ExpandPath(pathConfig)
109 if err != nil {
110 return nil, err
111 }
112 if path == "" {
113 return nil, errors.New("noctalia theme path is empty")
114 }
115
116 data, err := os.ReadFile(path)
117 if err != nil {
118 return nil, fmt.Errorf("read noctalia theme file: %w", err)
119 }
120 if ctxErr := ctx.Err(); ctxErr != nil {
121 return nil, ctxErr
122 }
123
124 switch format {
125 case formatColorsJSON:
126 return ParseColorsJSON(data)
127 case formatDumberJSON:
128 return ParseDumberJSON(data)
129 default:
130 return nil, fmt.Errorf("unsupported noctalia theme format %q", format)
131 }
132}
133
134// ExpandPath expands environment variables, leading ~, and cleans the path.
135func ExpandPath(path string) (string, error) {
136 expanded := os.ExpandEnv(strings.TrimSpace(path))
137 if expanded == "" {
138 return "", nil
139 }
140 if expanded == "~" || strings.HasPrefix(expanded, "~/") {
141 home, err := os.UserHomeDir()
142 if err != nil {
143 return "", fmt.Errorf("expand home directory: %w", err)
144 }
145 if expanded == "~" {
146 expanded = home
147 } else {
148 expanded = filepath.Join(home, expanded[2:])
149 }
150 }
151 return filepath.Clean(expanded), nil
152}
153
154// ParseColorsJSON parses Noctalia's native colors.json file.
155//
156// Noctalia colors.json represents the active palette only, so Dumber applies the
157// mapped palette to both light and dark resolved palettes. The mapper uses
158// optional mShadow/mHover roles when present to preserve background/surface/input
159// contrast, while falling back to older required roles for compatibility.
160func ParseColorsJSON(data []byte) (*entity.ExternalTheme, error) {
161 var raw colorsThemeJSON
162 decoder := json.NewDecoder(bytes.NewReader(data))
163 if err := decoder.Decode(&raw); err != nil {
164 return nil, fmt.Errorf("parse noctalia colors json: %w", err)
165 }
166 if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
167 return nil, errors.New("parse noctalia colors json: trailing data")
168 }
169
170 palette, err := raw.toPalette()
171 if err != nil {
172 return nil, err
173 }
174 light := palette
175 dark := palette
176 return &entity.ExternalTheme{
177 Name: colorsJSONName,
178 Provider: providerName,
179 LightPalette: &light,
180 DarkPalette: &dark,
181 }, nil
182}
183
184// ParseDumberJSON parses Noctalia user-template-generated Dumber JSON.
185func ParseDumberJSON(data []byte) (*entity.ExternalTheme, error) {
186 var raw dumberThemeJSON
187 decoder := json.NewDecoder(bytes.NewReader(data))
188 if err := decoder.Decode(&raw); err != nil {
189 return nil, fmt.Errorf("parse noctalia dumber json: %w", err)
190 }
191 if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
192 return nil, errors.New("parse noctalia dumber json: trailing data")
193 }
194 if raw.Light == nil {
195 return nil, errors.New("parse noctalia dumber json: missing light palette")
196 }
197 if raw.Dark == nil {
198 return nil, errors.New("parse noctalia dumber json: missing dark palette")
199 }
200
201 light := raw.Light.toEntity()
202 dark := raw.Dark.toEntity()
203 if err := validatePalette(&light, "light"); err != nil {
204 return nil, err
205 }
206 if err := validatePalette(&dark, "dark"); err != nil {
207 return nil, err
208 }
209
210 return &entity.ExternalTheme{
211 Name: firstNonEmpty(raw.Name, raw.Source, dumberJSONName),
212 Provider: providerName,
213 LightPalette: &light,
214 DarkPalette: &dark,
215 }, nil
216}
217
218type colorsThemeJSON struct {
219 MPrimary *string `json:"mPrimary"`
220 MSurface *string `json:"mSurface"`
221 MOnSurface *string `json:"mOnSurface"`
222 MSurfaceVariant *string `json:"mSurfaceVariant"`
223 MOnSurfaceVariant *string `json:"mOnSurfaceVariant"`
224 MOutline *string `json:"mOutline"`
225 MShadow *string `json:"mShadow"`
226 MHover *string `json:"mHover"`
227}
228
229func (raw colorsThemeJSON) toPalette() (entity.ColorPalette, error) {
230 surface, err := requiredColorsHex("mSurface", raw.MSurface)
231 if err != nil {
232 return entity.ColorPalette{}, err
233 }
234 surfaceVariantBase, err := requiredColorsHex("mSurfaceVariant", raw.MSurfaceVariant)
235 if err != nil {
236 return entity.ColorPalette{}, err
237 }
238 text, err := requiredColorsHex("mOnSurface", raw.MOnSurface)
239 if err != nil {
240 return entity.ColorPalette{}, err
241 }
242 muted, err := requiredColorsHex("mOnSurfaceVariant", raw.MOnSurfaceVariant)
243 if err != nil {
244 return entity.ColorPalette{}, err
245 }
246 accent, err := requiredColorsHex("mPrimary", raw.MPrimary)
247 if err != nil {
248 return entity.ColorPalette{}, err
249 }
250 border, err := requiredColorsHex("mOutline", raw.MOutline)
251 if err != nil {
252 return entity.ColorPalette{}, err
253 }
254 background, err := optionalColorsHex("mShadow", raw.MShadow, surface)
255 if err != nil {
256 return entity.ColorPalette{}, err
257 }
258 surfaceVariant, err := optionalColorsHex("mHover", raw.MHover, surfaceVariantBase)
259 if err != nil {
260 return entity.ColorPalette{}, err
261 }
262
263 return entity.ColorPalette{
264 Background: background,
265 Surface: surface,
266 SurfaceVariant: surfaceVariant,
267 Text: text,
268 Muted: muted,
269 Accent: accent,
270 Border: border,
271 }, nil
272}
273
274func requiredColorsHex(field string, value *string) (string, error) {
275 if value == nil || *value == "" {
276 return "", fmt.Errorf("parse noctalia colors json: missing %s", field)
277 }
278 return validateColorsHex(field, *value)
279}
280
281func optionalColorsHex(field string, value *string, fallback string) (string, error) {
282 if value == nil {
283 return fallback, nil
284 }
285 return validateColorsHex(field, *value)
286}
287
288func validateColorsHex(field, value string) (string, error) {
289 if !entity.IsValidHex(value) {
290 return "", fmt.Errorf("parse noctalia colors json: %s: not a valid CSS-safe hex color (#RRGGBB)", field)
291 }
292 return value, nil
293}
294
295type dumberThemeJSON struct {
296 Source string `json:"source"`
297 Mode string `json:"mode"`
298 Name string `json:"name"`
299 Light *paletteJSON `json:"light"`
300 Dark *paletteJSON `json:"dark"`
301}
302
303type paletteJSON struct {
304 Background string `json:"background"`
305 Surface string `json:"surface"`
306 SurfaceVariant string `json:"surface_variant"`
307 Text string `json:"text"`
308 Muted string `json:"muted"`
309 Accent string `json:"accent"`
310 Border string `json:"border"`
311}
312
313func (p paletteJSON) toEntity() entity.ColorPalette {
314 return entity.ColorPalette{
315 Background: p.Background,
316 Surface: p.Surface,
317 SurfaceVariant: p.SurfaceVariant,
318 Text: p.Text,
319 Muted: p.Muted,
320 Accent: p.Accent,
321 Border: p.Border,
322 }
323}
324
325func validatePalette(p *entity.ColorPalette, prefix string) error {
326 warnings := entity.ValidatePaletteOverrideHex(p, prefix)
327 if len(warnings) == 0 {
328 return nil
329 }
330 return fmt.Errorf("parse noctalia dumber json: %s: %s", warnings[0].Field, warnings[0].Message)
331}
332
333func firstNonEmpty(values ...string) string {
334 for _, value := range values {
335 if strings.TrimSpace(value) != "" {
336 return value
337 }
338 }
339 return ""
340}
341
342func normalizeProviderAndFormat(providerInput, formatInput string) (string, string) {
343 provider := strings.ToLower(strings.TrimSpace(providerInput))
344 if provider == "" {
345 provider = providerName
346 }
347 format := strings.ToLower(strings.TrimSpace(formatInput))
348 if format == "" {
349 format = formatName
350 }
351 return provider, format
352}
353
354func isSupportedFormat(format string) bool {
355 switch format {
356 case formatColorsJSON, formatDumberJSON:
357 return true
358 default:
359 return false
360 }
361}
362
363func externalThemeIdentity(enabled bool, provider, format, path string) string {
364 if !enabled {
365 return ""
366 }
367 return provider + "\x00" + format + "\x00" + strings.TrimSpace(path)
368}