diff --git a/README.md b/README.md index 33f6c139..af789da7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ A visual theming application for [Omarchy](https://omarchy.org). Extract colors ### Wallpaper Tools - Search and download wallpapers from wallhaven.cc directly in the app - Export favorite wallpapers as a ZIP archive with source metadata +- Apply a blurred wallpaper variant while extraction uses the original image - Full wallpaper editor with blur, exposure, sharpen, vignette, grain, and color toning - 12 one-click image presets: Cinematic, Vintage, Film, Dramatic, and more diff --git a/app.go b/app.go index ba2f5015..82b55247 100644 --- a/app.go +++ b/app.go @@ -179,6 +179,9 @@ func (a *App) ExtractColors(path string, lightMode bool, mode string) ([16]strin } a.state.SetPalette(palette) a.state.NativeColors = map[string]string{} + if a.state.WallpaperPath != path { + a.state.WallpaperBlur = false + } a.state.WallpaperPath = path a.state.LightMode = lightMode a.state.ExtractionMode = mode @@ -246,6 +249,7 @@ func (a *App) SetExtractionMode(mode string) { type SyncStateRequest struct { Palette []string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + WallpaperBlur bool `json:"wallpaperBlur"` LightMode bool `json:"lightMode"` ExtendedColors map[string]string `json:"extendedColors"` NativeColors map[string]string `json:"nativeColors"` @@ -270,6 +274,7 @@ func (a *App) SyncState(req SyncStateRequest) error { a.state.SetAdjustedPalette(p) } a.state.WallpaperPath = req.WallpaperPath + a.state.WallpaperBlur = req.WallpaperBlur a.state.LightMode = req.LightMode if req.ExtendedColors != nil { a.state.ExtendedColors = req.ExtendedColors @@ -356,6 +361,7 @@ func (a *App) ComputeVariables(paletteSlice []string, extendedColors map[string] type ApplyThemeRequest struct { Palette []string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + WallpaperBlur bool `json:"wallpaperBlur"` LightMode bool `json:"lightMode"` AdditionalImages []string `json:"additionalImages"` ExtendedColors map[string]string `json:"extendedColors"` @@ -377,6 +383,7 @@ func (a *App) ApplyTheme(req ApplyThemeRequest) (*theme.ApplyResult, error) { state := &theme.ThemeState{ Palette: palette, WallpaperPath: req.WallpaperPath, + WallpaperBlur: req.WallpaperBlur, LightMode: req.LightMode, ColorRoles: roles, ExtendedColors: req.ExtendedColors, @@ -396,6 +403,7 @@ type SaveAndApplyThemeRequest struct { UpdateExisting bool `json:"updateExisting"` Palette []string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + WallpaperBlur bool `json:"wallpaperBlur"` LightMode bool `json:"lightMode"` AdditionalImages []string `json:"additionalImages"` ExtendedColors map[string]string `json:"extendedColors"` @@ -421,6 +429,7 @@ func (a *App) SaveAndApplyTheme(req SaveAndApplyThemeRequest) (*theme.ApplyResul state := &theme.ThemeState{ Palette: palette, WallpaperPath: req.WallpaperPath, + WallpaperBlur: req.WallpaperBlur, LightMode: req.LightMode, ColorRoles: roles, ExtendedColors: req.ExtendedColors, @@ -436,7 +445,7 @@ func (a *App) SaveAndApplyTheme(req SaveAndApplyThemeRequest) (*theme.ApplyResul isOmarchy := theme.IsOmarchyInstalled() targetDir := filepath.Join(platform.SavedThemesDir(), name) if isOmarchy { - targetDir = filepath.Join(platform.OmarchyThemesDir(), name) + targetDir = filepath.Join(omarchy.UserThemesDir(), name) if !req.UpdateExisting && omarchy.ThemeExists(name) { return nil, fmt.Errorf("Omarchy theme %q already exists", name) } @@ -448,30 +457,44 @@ func (a *App) SaveAndApplyTheme(req SaveAndApplyThemeRequest) (*theme.ApplyResul } else if !os.IsNotExist(err) { return nil, fmt.Errorf("check theme folder: %w", err) } - var generateErr error if isOmarchy { - generateErr = a.writer.GenerateOmarchyV4Only(state, req.Settings, targetDir) - } else { - generateErr = a.writer.GenerateOnly(state, req.Settings, targetDir) + return a.writer.SaveAndApplyOmarchyTheme(state, req.Settings, name) + } + if err := a.writer.GenerateOnly(state, req.Settings, targetDir); err != nil { + return nil, fmt.Errorf("save theme: %w", err) } - if generateErr != nil { - return nil, fmt.Errorf("save theme: %w", generateErr) + return a.writer.ApplyTheme(state, req.Settings) +} + +// ThemeFolderExists checks the output adapter's destination for a saved theme. +func (a *App) ThemeFolderExists(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + if !theme.ValidOmarchyThemeName(name) { + return false } - if !isOmarchy { - return a.writer.ApplyTheme(state, req.Settings) + root := platform.SavedThemesDir() + if omarchy.IsInstalled() { + root = omarchy.UserThemesDir() } - wallpaper := "" - if state.WallpaperPath != "" { - wallpaper = filepath.Join(targetDir, "backgrounds", filepath.Base(state.WallpaperPath)) + info, err := os.Stat(filepath.Join(root, name)) + return err == nil && info.IsDir() +} + +// BlurWallpaper prepares a cached preview of the derived wallpaper. +func (a *App) BlurWallpaper(path string) (string, error) { + return wallpaper.CreateBlurredVariant(path, platform.BlurDir()) +} + +// ApplyWallpaperOnly preserves the active theme and changes its background. +func (a *App) ApplyWallpaperOnly(path string) error { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("wallpaper must be a readable regular file") } - if err := omarchy.ActivateTheme(name, wallpaper); err != nil { - return nil, fmt.Errorf("activate theme: %w", err) + if err := wallpaper.ValidateImageFile(path); err != nil { + return err } - return &theme.ApplyResult{ - Success: true, - IsOmarchy: true, - ThemePath: targetDir, - }, nil + return omarchy.SetBackground(path) } // ClearTheme removes the Aether theme and reverts to the default. @@ -502,6 +525,7 @@ func (a *App) ListBlueprints() ([]map[string]interface{}, error) { "palette": map[string]interface{}{ "colors": bp.Palette.Colors, "wallpaper": bp.Palette.Wallpaper, + "wallpaperBlur": bp.Palette.WallpaperBlur, "lightMode": bp.Palette.LightMode, "mode": bp.Palette.Mode, "lockedColors": bp.Palette.LockedColors, @@ -523,6 +547,7 @@ type SaveBlueprintRequest struct { Name string `json:"name"` Palette []string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + WallpaperBlur bool `json:"wallpaperBlur"` LightMode bool `json:"lightMode"` AdditionalImages []string `json:"additionalImages"` LockedColors []int `json:"lockedColors"` @@ -538,6 +563,7 @@ func (a *App) SaveBlueprint(req SaveBlueprintRequest) error { Palette: blueprint.PaletteData{ Colors: req.Palette, Wallpaper: req.WallpaperPath, + WallpaperBlur: req.WallpaperBlur, LightMode: req.LightMode, AdditionalImages: req.AdditionalImages, LockedColors: req.LockedColors, @@ -606,6 +632,7 @@ func (a *App) LoadBlueprint(name string) error { a.state.SetPalette(palette) a.state.WallpaperPath = a.resolveWallpaper(bp.Palette) + a.state.WallpaperBlur = bp.Palette.WallpaperBlur a.state.LightMode = bp.Palette.LightMode if bp.Palette.AdditionalImages != nil { a.state.AdditionalImages = bp.Palette.AdditionalImages @@ -650,6 +677,7 @@ func (a *App) ApplyBlueprint(name string) (*theme.ApplyResult, error) { a.state.SetPalette(palette) a.state.WallpaperPath = a.resolveWallpaper(bp.Palette) + a.state.WallpaperBlur = bp.Palette.WallpaperBlur a.state.LightMode = bp.Palette.LightMode if bp.Palette.AdditionalImages != nil { a.state.AdditionalImages = bp.Palette.AdditionalImages @@ -1109,6 +1137,7 @@ type ExportThemeRequest struct { IncludedApps []string `json:"includedApps"` Palette []string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + WallpaperBlur bool `json:"wallpaperBlur"` LightMode bool `json:"lightMode"` AdditionalImages []string `json:"additionalImages"` ExtendedColors map[string]string `json:"extendedColors"` @@ -1169,6 +1198,7 @@ func (a *App) ExportTheme(req ExportThemeRequest) (string, error) { state := &theme.ThemeState{ Palette: palette, WallpaperPath: req.WallpaperPath, + WallpaperBlur: req.WallpaperBlur, LightMode: req.LightMode, ColorRoles: roles, ExtendedColors: req.ExtendedColors, @@ -1231,6 +1261,7 @@ type ImportResult struct { Name string `json:"name"` Path string `json:"path"` WallpaperPath string `json:"wallpaperPath"` + WallpaperBlur bool `json:"wallpaperBlur"` LightMode bool `json:"lightMode"` IconTheme icontheme.Selection `json:"iconTheme"` } @@ -1317,6 +1348,7 @@ func (a *App) importFile(path, fileType string) (*ImportResult, error) { a.state.NativeColors = bp.Palette.NativeColors a.state.SetPalette(palette) a.state.WallpaperPath = a.resolveWallpaper(bp.Palette) + a.state.WallpaperBlur = bp.Palette.WallpaperBlur a.state.LightMode = bp.Palette.LightMode iconTheme, err := bp.IconThemeSelection() if err != nil { @@ -1332,6 +1364,7 @@ func (a *App) importFile(path, fileType string) (*ImportResult, error) { Name: bp.Name, Path: savedPath, WallpaperPath: a.state.WallpaperPath, + WallpaperBlur: a.state.WallpaperBlur, LightMode: a.state.LightMode, IconTheme: a.state.IconTheme, }, nil @@ -1482,6 +1515,7 @@ func (a *App) HandleIPC(req ipc.Request) ipc.Response { result, err := a.ApplyTheme(ApplyThemeRequest{ Palette: a.state.Palette[:], WallpaperPath: a.state.WallpaperPath, + WallpaperBlur: a.state.WallpaperBlur, LightMode: a.state.LightMode, AdditionalImages: a.state.AdditionalImages, ExtendedColors: a.state.ExtendedColors, @@ -1537,6 +1571,7 @@ func (a *App) HandleIPC(req ipc.Request) ipc.Response { return ipc.Response{OK: false, Error: "set-wallpaper requires a path"} } a.state.WallpaperPath = req.Path + a.state.WallpaperBlur = false a.emitIPCStateChanged() return ipc.Response{OK: true, Wallpaper: req.Path} @@ -1580,6 +1615,7 @@ func (a *App) emitIPCStateChanged() { "lightMode": a.state.LightMode, "mode": a.state.ExtractionMode, "wallpaper": a.state.WallpaperPath, + "wallpaperBlur": a.state.WallpaperBlur, "appOverrides": a.state.AppOverrides, "additionalImages": a.state.AdditionalImages, "adjustments": a.state.Adjustments, diff --git a/app_themes_test.go b/app_themes_test.go new file mode 100644 index 00000000..2994afd3 --- /dev/null +++ b/app_themes_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + + "aether/internal/omarchy" + "aether/internal/platform" + "aether/internal/theme" +) + +func TestThemeFolderExists(t *testing.T) { + for _, native := range []bool{false, true} { + t.Run(map[bool]string{false: "standalone", true: "native"}[native], func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + bin := t.TempDir() + t.Setenv("PATH", bin) + root := platform.SavedThemesDir() + if native { + if err := os.WriteFile(filepath.Join(bin, "omarchy"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + root = omarchy.UserThemesDir() + } + app := NewApp() + if app.ThemeFolderExists("midnight") { + t.Fatal("missing folder exists") + } + if err := os.MkdirAll(filepath.Join(root, "midnight"), 0o755); err != nil { + t.Fatal(err) + } + if !app.ThemeFolderExists(" Midnight ") { + t.Fatal("existing folder is missing") + } + for _, bad := range []string{"", "-x", "foo/bar", "foo bar", "..", "."} { + if app.ThemeFolderExists(bad) { + t.Errorf("invalid folder name accepted: %q", bad) + } + } + }) + } +} + +func TestBlueprintKeepsWallpaperSourceAndBlurIntent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + app := NewApp() + source := writeTestImage(t, t.TempDir(), "source.png", 16, 16) + if err := app.SaveBlueprint(SaveBlueprintRequest{Name: "Blurred", Palette: theme.DefaultPalette[:], WallpaperPath: source, WallpaperBlur: true}); err != nil { + t.Fatal(err) + } + if err := app.LoadBlueprint("Blurred"); err != nil { + t.Fatal(err) + } + if app.state.WallpaperPath != source || !app.state.WallpaperBlur { + t.Fatal("blueprint loses the source or blur intent") + } + listed, err := app.ListBlueprints() + if err != nil { + t.Fatal(err) + } + palette := listed[0]["palette"].(map[string]interface{}) + if palette["wallpaper"] != source || palette["wallpaperBlur"] != true { + t.Fatalf("listed wallpaper = %+v", palette) + } +} + +func TestWallpaperOnlyPreservesActiveTheme(t *testing.T) { + home, bin := t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) + t.Setenv("PATH", bin) + log := filepath.Join(t.TempDir(), "commands") + t.Setenv("AETHER_COMMAND_LOG", log) + script := "#!/bin/sh\nprintf '%s|%s\\n' \"$#\" \"$*\" >> \"$AETHER_COMMAND_LOG\"\n" + if err := os.WriteFile(filepath.Join(bin, "omarchy"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + active := filepath.Join(omarchy.CurrentStateDir(), "theme.name") + if err := os.MkdirAll(filepath.Dir(active), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(active, []byte("existing-theme\n"), 0o600); err != nil { + t.Fatal(err) + } + source := writeTestImage(t, t.TempDir(), "wall paper.png", 16, 16) + app := NewApp() + before := app.state.Palette + if err := app.ApplyWallpaperOnly(source); err != nil { + t.Fatal(err) + } + commands, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + if got, want := strings.TrimSpace(string(commands)), "4|theme bg set "+source; got != want { + t.Fatalf("commands = %q, want %q", got, want) + } + current, err := os.ReadFile(active) + if err != nil || string(current) != "existing-theme\n" || app.state.Palette != before { + t.Fatal("wallpaper-only operation changes the theme") + } + if _, err := os.Stat(platform.OmarchyThemeDir()); !os.IsNotExist(err) { + t.Fatalf("wallpaper-only operation creates a theme: %v", err) + } +} + +func TestSaveAndApplyThemeActivatesTheRenderedVariant(t *testing.T) { + home, bin := t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) + t.Setenv("PATH", bin) + log := filepath.Join(t.TempDir(), "commands") + t.Setenv("AETHER_COMMAND_LOG", log) + script := "#!/bin/sh\nprintf '%s|%s\\n' \"$OMARCHY_THEME_SKIP_BACKGROUND\" \"$*\" >> \"$AETHER_COMMAND_LOG\"\n" + if err := os.WriteFile(filepath.Join(bin, "omarchy"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + source := writeTestImage(t, t.TempDir(), "source.png", 16, 16) + app := NewApp() + result, err := app.SaveAndApplyTheme(SaveAndApplyThemeRequest{Name: "blur-theme", Palette: theme.DefaultPalette[:], WallpaperPath: source, WallpaperBlur: true}) + if err != nil || result == nil || !result.Success { + t.Fatalf("save and apply: %+v, %v", result, err) + } + commands, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(commands)), "\n") + if len(lines) != 2 || !strings.Contains(lines[0], "source-blurred-") || lines[1] != "1|theme set blur-theme" { + t.Fatalf("wrong activation: %q", lines) + } + if _, err := os.Stat(filepath.Join(result.ThemePath, "backgrounds", "source.png")); err != nil { + t.Fatal("original image is missing from the theme") + } +} + +func writeTestImage(t *testing.T, dir, name string, w, h int) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetRGBA(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 0xff}) + } + } + path := filepath.Join(dir, name) + f, err := os.Create(path) + if err != nil { + t.Fatalf("create test image: %v", err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatalf("encode test image: %v", err) + } + return path +} + +func TestBlurWallpaperCreatesVariant(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + app := NewApp() + src := writeTestImage(t, t.TempDir(), "photo.png", 128, 80) + + got, err := app.BlurWallpaper(src) + if err != nil { + t.Fatalf("BlurWallpaper: %v", err) + } + if got == "" { + t.Fatal("BlurWallpaper returned empty path") + } + if _, err := os.Stat(got); err != nil { + t.Fatalf("variant not created: %v", err) + } + ext := filepath.Ext(got) + if ext != ".jpg" { + t.Errorf("variant ext = %q; want .jpg", ext) + } +} + +func TestBlurWallpaperRejectsNonImage(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + app := NewApp() + txt := filepath.Join(t.TempDir(), "notes.txt") + if err := os.WriteFile(txt, []byte("not an image"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := app.BlurWallpaper(txt); err == nil { + t.Error("BlurWallpaper() error = nil; want error for non-image file") + } +} + +func TestBlurWallpaperRejectsMissingFile(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + app := NewApp() + if _, err := app.BlurWallpaper("/nonexistent/photo.png"); err == nil { + t.Error("BlurWallpaper() error = nil; want error for missing file") + } +} diff --git a/cli/blueprints.go b/cli/blueprints.go index 9dea93e5..ec444128 100644 --- a/cli/blueprints.go +++ b/cli/blueprints.go @@ -34,12 +34,13 @@ func runListBlueprints(args []string) int { if jsonOut { type entry struct { - Name string `json:"name"` - Colors []string `json:"colors"` - LightMode bool `json:"lightMode"` - Wallpaper string `json:"wallpaper,omitempty"` - Timestamp int64 `json:"timestamp"` - IconTheme icontheme.Selection `json:"iconTheme"` + Name string `json:"name"` + Colors []string `json:"colors"` + LightMode bool `json:"lightMode"` + Wallpaper string `json:"wallpaper,omitempty"` + WallpaperBlur bool `json:"wallpaperBlur,omitempty"` + Timestamp int64 `json:"timestamp"` + IconTheme icontheme.Selection `json:"iconTheme"` } out := make([]entry, len(blueprints)) for i, bp := range blueprints { @@ -48,12 +49,13 @@ func runListBlueprints(args []string) int { return printErrorJSON(fmt.Sprintf("Blueprint %q has invalid iconTheme: %v", bp.Name, err)) } out[i] = entry{ - Name: bp.Name, - Colors: bp.Palette.Colors, - LightMode: bp.Palette.LightMode, - Wallpaper: bp.Palette.Wallpaper, - Timestamp: bp.Timestamp, - IconTheme: iconTheme, + Name: bp.Name, + Colors: bp.Palette.Colors, + LightMode: bp.Palette.LightMode, + Wallpaper: bp.Palette.Wallpaper, + WallpaperBlur: bp.Palette.WallpaperBlur, + Timestamp: bp.Timestamp, + IconTheme: iconTheme, } } return printJSON(map[string]interface{}{ @@ -141,6 +143,7 @@ func runApplyBlueprint(args []string, templatesFS embed.FS) int { state := &theme.ThemeState{ Palette: palette, WallpaperPath: wallpaperPath, + WallpaperBlur: bp.Palette.WallpaperBlur, LightMode: lightMode, ColorRoles: colorRoles, ExtendedColors: bp.Palette.ExtendedColors, diff --git a/cli/imports.go b/cli/imports.go index bbb89c8d..67eaea24 100644 --- a/cli/imports.go +++ b/cli/imports.go @@ -46,6 +46,7 @@ func applyImportedTheme(templatesFS embed.FS, bp *blueprint.Blueprint, palette [ writer := theme.NewWriter(templatesFS, "templates") state := theme.NewThemeState() state.WallpaperPath = wallpaperPath + state.WallpaperBlur = bp.Palette.WallpaperBlur state.LightMode = forceLight || bp.Palette.LightMode for k, v := range bp.Palette.ExtendedColors { state.ExtendedColors[k] = v diff --git a/cli/url_handler.go b/cli/url_handler.go index 5d9972d0..f8d5e77d 100644 --- a/cli/url_handler.go +++ b/cli/url_handler.go @@ -213,6 +213,7 @@ func buildURLImportState(imp *pending.Import) (*theme.ThemeState, error) { state := theme.NewThemeState() state.WallpaperPath = imp.Wallpaper + state.WallpaperBlur = bp.Palette.WallpaperBlur && imp.Wallpaper != "" for key, value := range bp.Palette.ExtendedColors { state.ExtendedColors[key] = value } diff --git a/docs/blueprints.md b/docs/blueprints.md index 686bb45b..cc3b2481 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -9,6 +9,7 @@ A blueprint stores: - **16-color palette** (hex values) - **Extended colors** (accent, cursor, selection) - **Wallpaper path** (local file or wallhaven URL) +- **Wallpaper blur choice**, with the original source path - **Color adjustments** (all slider values) - **App overrides** (per-app color customizations) - **Settings** (which apps to include) @@ -52,6 +53,7 @@ Blueprints are JSON files at: "palette": { "colors": ["#1a1b26", "#f7768e", "..."], "wallpaper": "/path/to/wallpaper.jpg", + "wallpaperBlur": false, "wallpaperUrl": "https://wallhaven.cc/...", "lightMode": false, "extendedColors": { @@ -72,6 +74,16 @@ Blueprints are JSON files at: } ``` +## Import an Omarchy theme + +Use `Import` on an Omarchy theme to load its colors, icon choice, and wallpapers into the editor. +Aether creates its own managed output when you save the result. +The source theme remains available in the theme library. + +The theme-folder dialog checks the selected name before it saves. +An existing folder requires `Update and Apply` confirmation. +Aether refuses to replace a theme folder that it does not manage. + ## Tips - Name blueprints descriptively (e.g., "Nord Dark", "Summer Vibes") diff --git a/docs/wallpaper-editor.md b/docs/wallpaper-editor.md index 302b2333..baeb4aec 100644 --- a/docs/wallpaper-editor.md +++ b/docs/wallpaper-editor.md @@ -8,6 +8,22 @@ Edit your wallpaper with professional filters before extracting colors. 2. Click the **edit icon** (pencil) next to the Extract button 3. The full-screen editor opens +## Source-preserving blur + +Use `Heavy blur wallpaper` in the main wallpaper preview to select a blurred desktop variant. +The wallpaper editor and color extraction continue to use the original image. +Use `Remove blur` to select the original image again. + +Blueprints store the source path and the blur choice. +Aether recreates the cached variant when needed. +Saved theme folders contain both the original image and the blurred variant. + +## Wallpaper-only changes + +On Omarchy, use `Wallpaper only` in Local, Wallhaven, or Favorites to change the background. +This action preserves the active theme and its colors. +It requires Omarchy's public background command. + ## Filter Categories ### Basic Adjustments diff --git a/external_import.go b/external_import.go index 378f540e..a954b6af 100644 --- a/external_import.go +++ b/external_import.go @@ -171,6 +171,7 @@ func (a *App) stageImportIntoState(expectedSourceURL string) (*pending.Import, e return nil, fmt.Errorf("import iconTheme: %w", iconThemeErr) } a.state.IconTheme = iconTheme + a.state.WallpaperBlur = bp.Palette.WallpaperBlur } if imp.Wallpaper != "" { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 604b102e..53e58c24 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -57,6 +57,7 @@ (VALID_TABS as readonly string[]).includes(t); import { setWallpaperPath, + setWallpaperBlur, setPalette, setExtendedColors, setNativeColors, @@ -82,7 +83,6 @@ import { applyTheme, applyThemeLive, - saveAndApplyTheme, requestThemeApply, saveThemeAsNew, undoAction, @@ -278,6 +278,7 @@ // re-extract on the same image doesn't clear overrides. setLastExtractedPath(s.wallpaperPath); } + setWallpaperBlur(!!s?.wallpaperBlur, true); } catch (e) { console.warn('GetInitialState failed:', e); } @@ -510,6 +511,7 @@ lightMode?: boolean; mode?: string; wallpaper?: string; + wallpaperBlur?: boolean; adjustments?: import('$lib/types/theme').Adjustments; appOverrides?: Record>; additionalImages?: string[]; @@ -534,6 +536,8 @@ if (state.wallpaper !== undefined) { setWallpaperPath(state.wallpaper); } + if (state.wallpaperBlur !== undefined) + setWallpaperBlur(state.wallpaperBlur, true); if (state.adjustments) { setAdjustments(state.adjustments); } @@ -591,9 +595,5 @@ setApplySaveDialogOpen(false)} - onsave={name => { - setApplySaveDialogOpen(false); - saveAndApplyTheme(name); - }} /> diff --git a/frontend/src/lib/actions/blueprintActions.ts b/frontend/src/lib/actions/blueprintActions.ts index 7ba44f36..0ecf67b7 100644 --- a/frontend/src/lib/actions/blueprintActions.ts +++ b/frontend/src/lib/actions/blueprintActions.ts @@ -7,6 +7,7 @@ import { setIconTheme, setLightMode, setWallpaperPath, + setWallpaperBlur, setAppOverrides, setAdditionalImages, setLastExtractedPath, @@ -33,6 +34,7 @@ export function loadBlueprintIntoEditor(bp: Blueprint): void { bp.palette.mode ? bp.palette.mode === 'light' : !!bp.palette.lightMode ); setWallpaperPath(bp.palette.wallpaper ?? ''); + setWallpaperBlur(!!bp.palette.wallpaperBlur, true); setAppOverrides(bp.appOverrides ?? {}); setAdditionalImages(bp.palette.additionalImages ?? []); setLastExtractedPath(bp.palette.wallpaper ?? ''); diff --git a/frontend/src/lib/actions/themeActions.ts b/frontend/src/lib/actions/themeActions.ts index 198f6e4e..ff85c6f8 100644 --- a/frontend/src/lib/actions/themeActions.ts +++ b/frontend/src/lib/actions/themeActions.ts @@ -12,6 +12,7 @@ import { getIsAdjusting, setIsExtracting, getWallpaperPath, + getWallpaperRevision, setWallpaperPath, setPaletteFromExtraction, getLightMode, @@ -60,7 +61,7 @@ function filterNativeAppOverrides( ); } -function captureApplyRequest() { +export function captureApplyRequest() { const settings = getSettings(); return { ...getThemeSnapshot(), @@ -191,34 +192,38 @@ export function saveThemeAsNew(): void { export async function saveAndApplyTheme( name: string, - updateExisting = false -): Promise { - if (getIsApplying()) return; - const request = captureApplyRequest(); + updateExisting = false, + request = captureApplyRequest() +): Promise { + if (getIsApplying()) return false; setIsApplying(true); try { - await runApply(request, {name, updateExisting}); + const result = await runApply(request, {name, updateExisting}); + if (!result?.success) return false; saveThemeFolder(name, request.wallpaperPath); showToast( updateExisting ? `Applied: ${name}` : `Saved and applied: ${name}` ); + return true; } catch (e: unknown) { showToast( - e instanceof Error ? e.message : 'Couldn’t save and apply theme' + typeof e === 'string' + ? e + : e instanceof Error + ? e.message + : 'Could not save and apply the theme' ); + return false; } finally { setIsApplying(false); } } -// Swap the wallpaper without re-extracting colors. Resolves remote URLs -// (Wallhaven) by downloading first, then runs the standard apply path so -// the new wallpaper goes out together with the current palette. +// Change the background without replacing the active Omarchy theme. export async function applyWallpaperOnly(originalPath: string): Promise { if (getIsApplying() || !originalPath) return; - const request = captureApplyRequest(); - const originalSignature = getThemeSignature(request); - request.wallpaperPath = originalPath; + const originalRevision = getWallpaperRevision(); + let path = originalPath; setIsApplying(true); try { if ( @@ -229,18 +234,24 @@ export async function applyWallpaperOnly(originalPath: string): Promise { const {DownloadWallpaper} = await import( '../../../wailsjs/go/main/App' ); - request.wallpaperPath = await DownloadWallpaper(originalPath); + path = await DownloadWallpaper(originalPath); } - // A slow download must not replace the wallpaper of a newly loaded theme. - if (getThemeSignature() === originalSignature) - setWallpaperPath(request.wallpaperPath); - const result = await runApply(request); - if (!result) return; + if (getWallpaperRevision() !== originalRevision) return; + const {ApplyWallpaperOnly} = await import( + '../../../wailsjs/go/main/App' + ); + if (getWallpaperRevision() !== originalRevision) return; + await ApplyWallpaperOnly(path); + if (getWallpaperRevision() === originalRevision) setWallpaperPath(path); + showToast('Wallpaper applied'); + } catch (error: unknown) { showToast( - result.success ? 'Wallpaper applied' : 'Wallpaper files generated' + typeof error === 'string' + ? error + : error instanceof Error + ? error.message + : 'Could not apply the wallpaper' ); - } catch { - showToast('Couldn’t apply wallpaper — see logs for details'); } finally { setIsApplying(false); } diff --git a/frontend/src/lib/components/blueprints/OmarchyThemes.svelte b/frontend/src/lib/components/blueprints/OmarchyThemes.svelte index dd61a2d4..cc875a30 100644 --- a/frontend/src/lib/components/blueprints/OmarchyThemes.svelte +++ b/frontend/src/lib/components/blueprints/OmarchyThemes.svelte @@ -230,7 +230,8 @@ Import {/if} + {pending ? 'Back' : 'Cancel'} save(!!pending)} + disabled={busy || (!pending && !validName(name))} + >{busy + ? 'Please wait…' + : pending + ? 'Update and Apply' + : 'Save and Apply'} diff --git a/frontend/src/lib/stores/history.svelte.ts b/frontend/src/lib/stores/history.svelte.ts index 3e09453e..348c3984 100644 --- a/frontend/src/lib/stores/history.svelte.ts +++ b/frontend/src/lib/stores/history.svelte.ts @@ -15,6 +15,8 @@ export interface PendingAdjustment { } export interface Snapshot { + wallpaperPath: string; + wallpaperBlur: boolean; palette: string[]; basePalette: string[]; extendedColors: Record; @@ -48,6 +50,8 @@ export function getCanRedo(): boolean { export function copySnapshot(snapshot: Snapshot): Snapshot { const pending = snapshot.pendingAdjustment; return { + wallpaperPath: snapshot.wallpaperPath, + wallpaperBlur: snapshot.wallpaperBlur, palette: [...snapshot.palette], basePalette: [...snapshot.basePalette], extendedColors: {...snapshot.extendedColors}, diff --git a/frontend/src/lib/stores/theme.svelte.ts b/frontend/src/lib/stores/theme.svelte.ts index 6a91ec61..2e092497 100644 --- a/frontend/src/lib/stores/theme.svelte.ts +++ b/frontend/src/lib/stores/theme.svelte.ts @@ -32,6 +32,9 @@ export function invalidateThemeRequests(cancelAdjustment = true): number { let palette = $state([...DEFAULT_PALETTE]); let basePalette = $state([...DEFAULT_PALETTE]); let wallpaperPath = $state(''); +let wallpaperBlur = $state(false); +let wallpaperRevision = $state(0); +let blurPreview = $state<{source: string; path: string} | null>(null); let lightMode = $state(false); let lockedColors = $state>({}); let selectedColors = $state>({}); // empty = all selected @@ -107,6 +110,36 @@ export function getBasePalette(): string[] { export function getWallpaperPath(): string { return wallpaperPath; } +export function getWallpaperBlur(): boolean { + return wallpaperBlur; +} +export function getWallpaperRevision(): number { + return wallpaperRevision; +} +export function getBlurredWallpaperPath(): string { + return wallpaperBlur && blurPreview?.source === wallpaperPath + ? blurPreview.path + : ''; +} +export function setBlurredWallpaper( + source: string, + path: string, + revision: number +): void { + if ( + wallpaperBlur && + source === wallpaperPath && + revision === wallpaperRevision + ) + blurPreview = {source, path}; +} +export function setWallpaperBlur(enabled: boolean, skipHistory = false): void { + if (wallpaperBlur === enabled) return; + endColorEditSessions(); + if (!skipHistory) pushState(getHistorySnapshot()); + wallpaperBlur = enabled; + wallpaperRevision++; +} export function getLightMode(): boolean { return lightMode; } @@ -193,6 +226,8 @@ export function getAppOverrides(): Record> { export function getHistorySnapshot(): Snapshot { return copySnapshot({ + wallpaperPath, + wallpaperBlur, palette, basePalette, extendedColors, @@ -210,6 +245,10 @@ export function restoreHistorySnapshot(snapshot: Snapshot): void { invalidateThemeRequests(); endColorEditSessions(); const restored = copySnapshot(snapshot); + wallpaperPath = restored.wallpaperPath; + wallpaperBlur = restored.wallpaperBlur; + blurPreview = null; + wallpaperRevision++; palette = restored.palette; basePalette = restored.basePalette; extendedColors = restored.extendedColors; @@ -344,6 +383,7 @@ const applyPendingAdjustment = debounce( export function getThemeSnapshot(): { palette: string[]; wallpaperPath: string; + wallpaperBlur: boolean; lightMode: boolean; extendedColors: Record; nativeColors: Record; @@ -354,6 +394,7 @@ export function getThemeSnapshot(): { return { palette: [...palette], wallpaperPath, + wallpaperBlur, lightMode, extendedColors: {...extendedColors}, nativeColors: {...nativeColors}, @@ -374,6 +415,7 @@ export function getThemeSignature(snapshot = getThemeSnapshot()): string { return JSON.stringify([ snapshot.palette, snapshot.wallpaperPath, + snapshot.wallpaperBlur, snapshot.lightMode, snapshot.extendedColors, snapshot.nativeColors, @@ -597,6 +639,9 @@ export function clearExtendedColor(key: string): void { } export function setWallpaperPath(path: string): void { + wallpaperRevision++; + wallpaperBlur = false; + blurPreview = null; invalidateThemeRequests(false); wallpaperPath = path; } @@ -645,7 +690,7 @@ export function swapMainWithAdditional(path: string): void { if (idx === -1) return; invalidateThemeRequests(false); const oldMain = wallpaperPath; - wallpaperPath = path; + setWallpaperPath(path); const next = [...additionalImages]; if (oldMain) { next[idx] = oldMain; @@ -691,6 +736,9 @@ export function reset(): void { palette = [...DEFAULT_PALETTE]; basePalette = [...DEFAULT_PALETTE]; wallpaperPath = ''; + wallpaperBlur = false; + wallpaperRevision++; + blurPreview = null; lightMode = false; lockedColors = {}; selectedColors = {}; diff --git a/frontend/src/lib/types/theme.ts b/frontend/src/lib/types/theme.ts index a83209d7..583303df 100644 --- a/frontend/src/lib/types/theme.ts +++ b/frontend/src/lib/types/theme.ts @@ -62,6 +62,7 @@ export function normalizeIconThemeSelection( export interface BlueprintPaletteData { colors: string[]; wallpaper?: string; + wallpaperBlur?: boolean; wallpaperUrl?: string; lightMode?: boolean; mode?: 'light' | 'dark' | ''; diff --git a/frontend/tests/apply-actions.test.ts b/frontend/tests/apply-actions.test.ts index 71f44c12..ed1e39cd 100644 --- a/frontend/tests/apply-actions.test.ts +++ b/frontend/tests/apply-actions.test.ts @@ -12,6 +12,7 @@ import {initOmarchyCapabilities} from '../src/lib/stores/omarchy.svelte'; import {STORAGE_KEYS} from '../src/lib/constants/storage'; import { ApplyTheme, + ApplyWallpaperOnly, SaveAndApplyTheme, DownloadWallpaper, } from '../wailsjs/go/main/App'; @@ -21,6 +22,7 @@ const success = {success: true, isOmarchy: false, themePath: '/theme'}; vi.mock('../wailsjs/go/main/App', () => ({ ApplyTheme: vi.fn(), + ApplyWallpaperOnly: vi.fn().mockResolvedValue(undefined), SaveAndApplyTheme: vi.fn(), DownloadWallpaper: vi.fn(), GetSettings: vi.fn().mockResolvedValue({}), @@ -40,6 +42,7 @@ beforeEach(async () => { setLiveApply(false); document.documentElement.classList.remove('light-mode'); vi.mocked(ApplyTheme).mockReset().mockResolvedValue(success); + vi.mocked(ApplyWallpaperOnly).mockReset().mockResolvedValue(undefined); vi.mocked(SaveAndApplyTheme).mockReset().mockResolvedValue(success); vi.mocked(DownloadWallpaper).mockReset(); vi.mocked(initOmarchyCapabilities).mockReset().mockResolvedValue(); @@ -49,6 +52,7 @@ test.each(['apply', 'save'] as const)( '%s captures before preflight and never marks or associates newer editor state', async action => { theme.setWallpaperPath('/original.png'); + theme.setWallpaperBlur(true, true); theme.setLightMode(true); theme.setAdditionalImages(['/extra.png']); theme.setExtendedColor('accent', '#123456'); @@ -118,14 +122,11 @@ test.each(['apply', 'save'] as const)( } ); -test('wallpaper download reserves the apply operation and cannot retarget a review-only blueprint', async () => { +test('a wallpaper download cannot replace a newly loaded blueprint', async () => { theme.setWallpaperPath('/original.png'); theme.setLightMode(true); - const original = theme.getThemeSnapshot(); const download = deferred(); - const issued = deferred(); vi.mocked(DownloadWallpaper).mockReturnValue(download.promise); - vi.mocked(ApplyTheme).mockReturnValue(issued.promise); const operation = applyWallpaperOnly('https://example.com/wallpaper.png'); await settle(); expect(theme.getIsApplying()).toBe(true); @@ -141,29 +142,39 @@ test('wallpaper download reserves the apply operation and cannot retarget a revi }, }); download.resolve('/downloaded.png'); - await settle(); - expect(ApplyTheme).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({...original, wallpaperPath: '/downloaded.png'}) - ); - expect(theme.getWallpaperPath()).toBe('/review.png'); - issued.resolve(success); await operation; - expect(theme.getLastAppliedSignature()).toBe( - theme.getThemeSignature({...original, wallpaperPath: '/downloaded.png'}) - ); - expect(theme.isDirty()).toBe(true); - expect(document.documentElement.classList.contains('light-mode')).toBe( - true - ); + expect(ApplyTheme).not.toHaveBeenCalled(); + expect(ApplyWallpaperOnly).not.toHaveBeenCalled(); + expect(theme.getWallpaperPath()).toBe('/review.png'); + expect(theme.getIsApplying()).toBe(false); }); -test('a local wallpaper apply marks the actual requested path', async () => { +test('a wallpaper-only change preserves colors and does not acknowledge a full theme apply', async () => { + theme.setWallpaperPath('/original.png'); + theme.setWallpaperBlur(true, true); + theme.markApplied(); + const original = theme.getThemeSnapshot(); + const applied = theme.getLastAppliedSignature(); await applyWallpaperOnly('/local.png'); - expect(ApplyTheme).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({wallpaperPath: '/local.png'}) + expect(ApplyWallpaperOnly).toHaveBeenCalledExactlyOnceWith('/local.png'); + expect(ApplyTheme).not.toHaveBeenCalled(); + expect(theme.getThemeSnapshot()).toEqual({ + ...original, + wallpaperPath: '/local.png', + wallpaperBlur: false, + }); + expect(theme.getLastAppliedSignature()).toBe(applied); +}); + +test('a failed wallpaper-only change preserves editor state', async () => { + theme.setWallpaperPath('/original.png'); + const before = theme.getThemeSnapshot(); + vi.mocked(ApplyWallpaperOnly).mockRejectedValueOnce( + new Error('background failed') ); - expect(theme.getWallpaperPath()).toBe('/local.png'); - expect(theme.isDirty()).toBe(false); + await applyWallpaperOnly('/missing.png'); + expect(theme.getThemeSnapshot()).toEqual(before); + expect(theme.getIsApplying()).toBe(false); }); test('failed apply/save requests do not acknowledge editor state or save a folder association', async () => { diff --git a/frontend/tests/apply-save-dialog.test.ts b/frontend/tests/apply-save-dialog.test.ts new file mode 100644 index 00000000..4568b1ca --- /dev/null +++ b/frontend/tests/apply-save-dialog.test.ts @@ -0,0 +1,106 @@ +import {beforeEach, expect, test, vi} from 'vitest'; +import {flushSync} from 'svelte'; +import ApplySaveDialog from '../src/lib/components/layout/ApplySaveDialog.svelte'; +import * as theme from '../src/lib/stores/theme.svelte'; +import {ThemeFolderExists, SaveAndApplyTheme} from '../wailsjs/go/main/App'; +import {button, deferred, render, settle} from './setup'; + +vi.mock('../wailsjs/go/main/App', () => ({ + ThemeFolderExists: vi.fn(), + SaveAndApplyTheme: vi.fn(), + ApplyTheme: vi.fn(), + GetSettings: vi.fn().mockResolvedValue({}), + SaveSettings: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../src/lib/stores/omarchy.svelte', () => ({ + initOmarchyCapabilities: vi.fn().mockResolvedValue(undefined), + getOmarchyAvailable: () => true, + getOmarchyCapabilities: () => ({overrideApps: ['kitty']}), +})); + +beforeEach(() => { + theme.reset(); + theme.setIsApplying(false); + theme.setWallpaperPath('/original.png'); + vi.mocked(ThemeFolderExists).mockReset().mockResolvedValue(false); + vi.mocked(SaveAndApplyTheme) + .mockReset() + .mockResolvedValue({ + success: true, + isOmarchy: true, + themePath: '/theme', + }); +}); + +function enter() { + window.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}) + ); + flushSync(); +} + +test('an existing folder requires a distinct confirmation and uses the captured state', async () => { + const check = deferred(); + vi.mocked(ThemeFolderExists).mockReturnValue(check.promise); + theme.setWallpaperBlur(true, true); + const onclose = vi.fn(); + const {target} = render(ApplySaveDialog, {open: true, onclose}); + enter(); + await settle(); + theme.setWallpaperPath('/later.png'); + theme.setIconTheme({mode: 'explicit', id: 'Later'}, true); + check.resolve(true); + await settle(); + enter(); + expect(SaveAndApplyTheme).not.toHaveBeenCalled(); + button(target, 'Update and Apply').click(); + await settle(); + expect(SaveAndApplyTheme).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + name: 'original', + updateExisting: true, + wallpaperPath: '/original.png', + wallpaperBlur: true, + iconTheme: {mode: 'automatic'}, + }) + ); + expect(onclose).toHaveBeenCalledTimes(1); +}); + +test('a failed existence check permits a retry and never starts a save', async () => { + vi.mocked(ThemeFolderExists).mockRejectedValueOnce( + new Error('lookup failed') + ); + const {target} = render(ApplySaveDialog, {open: true, onclose: vi.fn()}); + enter(); + await settle(); + expect(target.querySelector('[role="alert"]')?.textContent).toContain( + 'lookup failed' + ); + expect(SaveAndApplyTheme).not.toHaveBeenCalled(); + button(target, 'Save and Apply').click(); + await settle(); + expect(SaveAndApplyTheme).toHaveBeenCalledTimes(1); +}); + +test('source changes after the dialog closes cannot complete an old lookup', async () => { + const check = deferred(); + vi.mocked(ThemeFolderExists).mockReturnValue(check.promise); + const view = render(ApplySaveDialog, {open: true, onclose: vi.fn()}); + enter(); + await settle(); + await view.destroy(); + check.resolve(false); + await settle(); + expect(SaveAndApplyTheme).not.toHaveBeenCalled(); +}); + +test('the suggested name can be cleared and replaced', () => { + const {target} = render(ApplySaveDialog, {open: true, onclose: vi.fn()}); + const input = target.querySelector('input')!; + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + flushSync(); + expect(input.value).toBe(''); + expect(button(target, 'Save and Apply').disabled).toBe(true); +}); diff --git a/frontend/tests/icon-theme.test.ts b/frontend/tests/icon-theme.test.ts index 26966b68..b632f2a1 100644 --- a/frontend/tests/icon-theme.test.ts +++ b/frontend/tests/icon-theme.test.ts @@ -126,7 +126,7 @@ test('an Omarchy theme import replaces the previous icon selection', async () => const {target} = render(OmarchyThemes, {}); await settle(); [...target.querySelectorAll('button')] - .find(button => button.textContent?.trim() === 'Edit')! + .find(button => button.textContent?.trim() === 'Import')! .click(); expect(theme.getIconTheme()).toEqual({ mode: 'explicit', diff --git a/frontend/tests/save-dialog.test.ts b/frontend/tests/save-dialog.test.ts index 2ee82d2d..1603ded4 100644 --- a/frontend/tests/save-dialog.test.ts +++ b/frontend/tests/save-dialog.test.ts @@ -37,6 +37,7 @@ test('repeated Enter cannot bypass overwrite confirmation, and Override saves th vi.mocked(BlueprintExists).mockReturnValue(exists.promise); vi.mocked(SaveBlueprint).mockReturnValue(saved.promise); theme.setWallpaperPath('/original.png'); + theme.setWallpaperBlur(true, true); theme.setAppOverride('kitty', 'background', '#123456'); theme.setIconTheme({mode: 'explicit', id: 'Original-Icons'}, true); const originalPalette = [...theme.getPalette()]; @@ -72,6 +73,7 @@ test('repeated Enter cannot bypass overwrite confirmation, and Override saves th name: 'Original', palette: originalPalette, wallpaperPath: '/original.png', + wallpaperBlur: true, appOverrides: {kitty: {background: '#123456'}}, iconTheme: {mode: 'explicit', id: 'Original-Icons'}, }) diff --git a/frontend/tests/wallpaper-blur.test.ts b/frontend/tests/wallpaper-blur.test.ts new file mode 100644 index 00000000..9612b494 --- /dev/null +++ b/frontend/tests/wallpaper-blur.test.ts @@ -0,0 +1,120 @@ +import {beforeEach, expect, test, vi} from 'vitest'; +import WallpaperHero from '../src/lib/components/editor/WallpaperHero.svelte'; +import * as theme from '../src/lib/stores/theme.svelte'; +import {BlurWallpaper} from '../wailsjs/go/main/App'; +import {undoAction, redoAction} from '../src/lib/actions/themeActions'; +import {loadBlueprintIntoEditor} from '../src/lib/actions/blueprintActions'; +import {DEFAULT_PALETTE} from '../src/lib/types/theme'; +import {deferred, render, settle} from './setup'; + +vi.mock('../wailsjs/go/main/App', () => ({BlurWallpaper: vi.fn()})); +vi.mock('../src/lib/stores/imagecache.svelte', () => ({ + getCachedFullImage: (path: string) => path, + loadFullImage: vi.fn(), + isPending: () => false, +})); + +beforeEach(() => { + theme.reset(); + theme.setWallpaperPath('/source-a.png'); + vi.mocked(BlurWallpaper).mockReset().mockResolvedValue('/blur-a.jpg'); +}); + +test('the hero rejects an older blur result after another wallpaper is selected', async () => { + const old = deferred(); + vi.mocked(BlurWallpaper) + .mockReturnValueOnce(old.promise) + .mockResolvedValueOnce('/blur-b.jpg'); + const {target} = render(WallpaperHero, {}); + theme.setWallpaperBlur(true); + await settle(); + theme.setWallpaperPath('/source-b.png'); + theme.setWallpaperBlur(true); + await settle(); + expect(theme.getBlurredWallpaperPath()).toBe('/blur-b.jpg'); + old.resolve('/blur-a.jpg'); + await settle(); + expect(theme.getWallpaperPath()).toBe('/source-b.png'); + expect(theme.getBlurredWallpaperPath()).toBe('/blur-b.jpg'); + expect(target.querySelector('img')?.getAttribute('src')).toBe( + '/blur-b.jpg' + ); +}); + +test('blur can be disabled before its preview completes', async () => { + const pending = deferred(); + vi.mocked(BlurWallpaper).mockReturnValue(pending.promise); + const {target} = render(WallpaperHero, {}); + target + .querySelector( + '[aria-label="Heavy blur wallpaper"]' + )! + .click(); + await settle(); + target + .querySelector('[aria-label="Remove blur"]')! + .click(); + pending.resolve('/blur-a.jpg'); + await settle(); + expect(theme.getWallpaperBlur()).toBe(false); + expect(theme.getBlurredWallpaperPath()).toBe(''); + expect(target.querySelector('img')?.getAttribute('src')).toBe( + '/source-a.png' + ); +}); + +test('a repeated source path still rejects results from an older selection', () => { + theme.setWallpaperBlur(true); + const old = theme.getWallpaperRevision(); + theme.setWallpaperPath('/source-b.png'); + theme.setWallpaperPath('/source-a.png'); + theme.setWallpaperBlur(true); + theme.setBlurredWallpaper('/source-a.png', '/obsolete.jpg', old); + expect(theme.getBlurredWallpaperPath()).toBe(''); +}); + +test('history and blueprints preserve source-based blur intent', () => { + theme.setWallpaperBlur(true); + expect(theme.getThemeSnapshot()).toEqual( + expect.objectContaining({ + wallpaperPath: '/source-a.png', + wallpaperBlur: true, + }) + ); + const signature = theme.getThemeSignature(); + theme.setBlurredWallpaper( + '/source-a.png', + '/cached.jpg', + theme.getWallpaperRevision() + ); + expect(theme.getThemeSignature()).toBe(signature); + undoAction(); + expect(theme.getWallpaperBlur()).toBe(false); + redoAction(); + expect(theme.getWallpaperBlur()).toBe(true); + const blueprint = { + name: 'Saved', + timestamp: 0, + palette: { + colors: [...DEFAULT_PALETTE], + wallpaper: '/original.png', + wallpaperBlur: true, + }, + }; + loadBlueprintIntoEditor(blueprint); + expect(theme.getWallpaperPath()).toBe('/original.png'); + expect(theme.getWallpaperBlur()).toBe(true); + loadBlueprintIntoEditor({ + ...blueprint, + palette: {...blueprint.palette, wallpaperBlur: false}, + }); + expect(theme.getWallpaperBlur()).toBe(false); +}); + +test('swapping the main image clears the previous blur choice', () => { + theme.setWallpaperBlur(true); + theme.addAdditionalImage('/source-b.png'); + theme.swapMainWithAdditional('/source-b.png'); + expect(theme.getWallpaperPath()).toBe('/source-b.png'); + expect(theme.getWallpaperBlur()).toBe(false); +}); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 4013a37a..9d361a5a 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -23,8 +23,12 @@ export function ApplyTheme( arg1: main.ApplyThemeRequest ): Promise; +export function ApplyWallpaperOnly(arg1: string): Promise; + export function BlueprintExists(arg1: string): Promise; +export function BlurWallpaper(arg1: string): Promise; + export function CancelBatchProcessing(): Promise; export function CancelExternalImport(arg1: string): Promise; @@ -180,6 +184,8 @@ export function StartUpgrade(): Promise; export function SyncState(arg1: main.SyncStateRequest): Promise; +export function ThemeFolderExists(arg1: string): Promise; + export function ToggleFavorite( arg1: string, arg2: string, diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 132f4ce8..db775357 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -18,10 +18,18 @@ export function ApplyTheme(arg1) { return window['go']['main']['App']['ApplyTheme'](arg1); } +export function ApplyWallpaperOnly(arg1) { + return window['go']['main']['App']['ApplyWallpaperOnly'](arg1); +} + export function BlueprintExists(arg1) { return window['go']['main']['App']['BlueprintExists'](arg1); } +export function BlurWallpaper(arg1) { + return window['go']['main']['App']['BlurWallpaper'](arg1); +} + export function CancelBatchProcessing() { return window['go']['main']['App']['CancelBatchProcessing'](); } @@ -278,6 +286,10 @@ export function SyncState(arg1) { return window['go']['main']['App']['SyncState'](arg1); } +export function ThemeFolderExists(arg1) { + return window['go']['main']['App']['ThemeFolderExists'](arg1); +} + export function ToggleFavorite(arg1, arg2, arg3) { return window['go']['main']['App']['ToggleFavorite'](arg1, arg2, arg3); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index f6efa4ef..d408d597 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -220,6 +220,7 @@ export namespace main { export class ApplyThemeRequest { palette: string[]; wallpaperPath: string; + wallpaperBlur: boolean; lightMode: boolean; additionalImages: string[]; extendedColors: Record; @@ -236,6 +237,7 @@ export namespace main { if ('string' === typeof source) source = JSON.parse(source); this.palette = source['palette']; this.wallpaperPath = source['wallpaperPath']; + this.wallpaperBlur = source['wallpaperBlur']; this.lightMode = source['lightMode']; this.additionalImages = source['additionalImages']; this.extendedColors = source['extendedColors']; @@ -288,6 +290,7 @@ export namespace main { includedApps: string[]; palette: string[]; wallpaperPath: string; + wallpaperBlur: boolean; lightMode: boolean; additionalImages: string[]; extendedColors: Record; @@ -306,6 +309,7 @@ export namespace main { this.includedApps = source['includedApps']; this.palette = source['palette']; this.wallpaperPath = source['wallpaperPath']; + this.wallpaperBlur = source['wallpaperBlur']; this.lightMode = source['lightMode']; this.additionalImages = source['additionalImages']; this.extendedColors = source['extendedColors']; @@ -389,6 +393,7 @@ export namespace main { name: string; path: string; wallpaperPath: string; + wallpaperBlur: boolean; lightMode: boolean; iconTheme: icontheme.Selection; @@ -404,6 +409,7 @@ export namespace main { this.name = source['name']; this.path = source['path']; this.wallpaperPath = source['wallpaperPath']; + this.wallpaperBlur = source['wallpaperBlur']; this.lightMode = source['lightMode']; this.iconTheme = this.convertValues( source['iconTheme'], @@ -436,6 +442,7 @@ export namespace main { updateExisting: boolean; palette: string[]; wallpaperPath: string; + wallpaperBlur: boolean; lightMode: boolean; additionalImages: string[]; extendedColors: Record; @@ -454,6 +461,7 @@ export namespace main { this.updateExisting = source['updateExisting']; this.palette = source['palette']; this.wallpaperPath = source['wallpaperPath']; + this.wallpaperBlur = source['wallpaperBlur']; this.lightMode = source['lightMode']; this.additionalImages = source['additionalImages']; this.extendedColors = source['extendedColors']; @@ -493,6 +501,7 @@ export namespace main { name: string; palette: string[]; wallpaperPath: string; + wallpaperBlur: boolean; lightMode: boolean; additionalImages: string[]; lockedColors: number[]; @@ -511,6 +520,7 @@ export namespace main { this.name = source['name']; this.palette = source['palette']; this.wallpaperPath = source['wallpaperPath']; + this.wallpaperBlur = source['wallpaperBlur']; this.lightMode = source['lightMode']; this.additionalImages = source['additionalImages']; this.lockedColors = source['lockedColors']; @@ -547,6 +557,7 @@ export namespace main { export class SyncStateRequest { palette: string[]; wallpaperPath: string; + wallpaperBlur: boolean; lightMode: boolean; extendedColors: Record; nativeColors: Record; @@ -562,6 +573,7 @@ export namespace main { if ('string' === typeof source) source = JSON.parse(source); this.palette = source['palette']; this.wallpaperPath = source['wallpaperPath']; + this.wallpaperBlur = source['wallpaperBlur']; this.lightMode = source['lightMode']; this.extendedColors = source['extendedColors']; this.nativeColors = source['nativeColors']; @@ -788,6 +800,7 @@ export namespace theme { export class StateSnapshot { palette: string[]; wallpaperPath: string; + wallpaperBlur: boolean; lightMode: boolean; lockedColors: Record; colorRoles: template.ColorRoles; @@ -806,6 +819,7 @@ export namespace theme { if ('string' === typeof source) source = JSON.parse(source); this.palette = source['palette']; this.wallpaperPath = source['wallpaperPath']; + this.wallpaperBlur = source['wallpaperBlur']; this.lightMode = source['lightMode']; this.lockedColors = source['lockedColors']; this.colorRoles = this.convertValues( diff --git a/internal/blueprint/model.go b/internal/blueprint/model.go index d1775334..07e52976 100644 --- a/internal/blueprint/model.go +++ b/internal/blueprint/model.go @@ -60,6 +60,7 @@ func (b *Blueprint) UnmarshalJSON(data []byte) error { type PaletteData struct { Colors []string `json:"colors"` Wallpaper string `json:"wallpaper,omitempty"` + WallpaperBlur bool `json:"wallpaperBlur,omitempty"` WallpaperURL string `json:"wallpaperUrl,omitempty"` LightMode bool `json:"lightMode,omitempty"` Mode string `json:"mode,omitempty"` // "light"/"dark"/"" — preserves the three-valued mode LightMode collapses diff --git a/internal/blueprint/validate.go b/internal/blueprint/validate.go index decdfafa..27ac68ae 100644 --- a/internal/blueprint/validate.go +++ b/internal/blueprint/validate.go @@ -11,6 +11,9 @@ func validateBlueprint(bp *Blueprint) error { if bp == nil { return fmt.Errorf("blueprint is empty") } + if bp.Palette.WallpaperBlur && bp.Palette.Wallpaper == "" && bp.Palette.WallpaperURL == "" { + return fmt.Errorf("wallpaper blur requires a source image") + } if len(bp.Palette.Colors) < 16 { return fmt.Errorf("palette has %d colors; want at least 16", len(bp.Palette.Colors)) } diff --git a/internal/omarchy/background.go b/internal/omarchy/background.go new file mode 100644 index 00000000..2b323f78 --- /dev/null +++ b/internal/omarchy/background.go @@ -0,0 +1,31 @@ +package omarchy + +import ( + "fmt" + "os" + "path/filepath" + + "aether/internal/platform" +) + +// SetBackground changes the native background without activating another theme. +func SetBackground(path string) error { + if !IsInstalled() { + return fmt.Errorf("a background-only change requires Omarchy") + } + abs, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolve wallpaper: %w", err) + } + info, err := os.Stat(abs) + if err != nil { + return fmt.Errorf("inspect wallpaper: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("wallpaper is not a regular file") + } + if _, err := platform.RunSync("omarchy", "theme", "bg", "set", abs); err != nil { + return fmt.Errorf("set Omarchy background: %w", err) + } + return nil +} diff --git a/internal/platform/paths.go b/internal/platform/paths.go index 358b2f2e..04fed93a 100644 --- a/internal/platform/paths.go +++ b/internal/platform/paths.go @@ -96,6 +96,11 @@ func ColorCacheDir() string { return filepath.Join(CacheDir(), "color-cache") } +// BlurDir returns ~/.cache/aether/blur. +func BlurDir() string { + return filepath.Join(CacheDir(), "blur") +} + // EnsureAllDirs creates all directories required by Aether. It does not create // WallpaperDir because that is user-managed. func EnsureAllDirs() error { @@ -110,6 +115,7 @@ func EnsureAllDirs() error { DownloadDir(), ThumbnailDir(), ColorCacheDir(), + BlurDir(), } for _, d := range dirs { if err := os.MkdirAll(d, 0755); err != nil { diff --git a/internal/theme/applier.go b/internal/theme/applier.go index b3adfd13..59d73f4b 100644 --- a/internal/theme/applier.go +++ b/internal/theme/applier.go @@ -5,10 +5,10 @@ import ( "log" "os" "path/filepath" - "strings" "aether/internal/omarchy" "aether/internal/platform" + "aether/internal/wallpaper" ) // IsOmarchyInstalled reports whether the public Omarchy CLI is available. @@ -46,19 +46,9 @@ func HandleLightModeMarker(themeDir string, lightMode bool) error { return err } -// imageExtensions are still-image formats that Go's image.Decode handles natively. -var imageExtensions = map[string]bool{ - ".jpg": true, - ".jpeg": true, - ".png": true, - ".gif": true, - ".bmp": true, - ".webp": true, -} - // IsImageFile returns true for still-image formats that can be decoded directly. func IsImageFile(path string) bool { - return imageExtensions[strings.ToLower(filepath.Ext(path))] + return wallpaper.IsImageFile(path) } // ClearTheme removes Aether's standalone override and restores the last native diff --git a/internal/theme/omarchy_install.go b/internal/theme/omarchy_install.go index 789428b2..1b6b575f 100644 --- a/internal/theme/omarchy_install.go +++ b/internal/theme/omarchy_install.go @@ -17,6 +17,18 @@ func ValidOmarchyThemeName(name string) bool { return omarchyThemeNamePattern.MatchString(name) } +// SaveAndApplyOmarchyTheme keeps bundle replacement and activation in one transaction. +func (w *Writer) SaveAndApplyOmarchyTheme(state *ThemeState, settings Settings, name string) (*ApplyResult, error) { + if !ValidOmarchyThemeName(name) { + return nil, fmt.Errorf("invalid Omarchy theme name %q", name) + } + target := filepath.Join(omarchy.UserThemesDir(), name) + if err := w.generateOmarchyTheme(state, settings, target, name); err != nil { + return nil, err + } + return &ApplyResult{Success: true, IsOmarchy: true, ThemePath: target}, nil +} + // InstallOmarchyTheme generates a new named Omarchy theme and activates it. // Existing themes are never overwritten by web imports. func (w *Writer) InstallOmarchyTheme(state *ThemeState, settings Settings, name string) error { diff --git a/internal/theme/omarchy_install_test.go b/internal/theme/omarchy_install_test.go index 4156eadb..8f4bf612 100644 --- a/internal/theme/omarchy_install_test.go +++ b/internal/theme/omarchy_install_test.go @@ -14,10 +14,12 @@ func TestInstallOmarchyThemeCreatesAndActivatesNewTheme(t *testing.T) { binDir := t.TempDir() omarchyDir := t.TempDir() activatedPath := filepath.Join(t.TempDir(), "activated") + bgSetPath := filepath.Join(t.TempDir(), "bgset") t.Setenv("HOME", home) t.Setenv("OMARCHY_PATH", omarchyDir) t.Setenv("AETHER_TEST_ACTIVATED", activatedPath) + t.Setenv("AETHER_TEST_BGSET", bgSetPath) t.Setenv("PATH", binDir) if err := os.MkdirAll(filepath.Join(omarchyDir, "shell"), 0o755); err != nil { t.Fatal(err) @@ -25,13 +27,22 @@ func TestInstallOmarchyThemeCreatesAndActivatesNewTheme(t *testing.T) { if err := os.WriteFile(filepath.Join(omarchyDir, "shell", "shell.qml"), nil, 0o600); err != nil { t.Fatal(err) } - script := "#!/bin/sh\nprintf '%s' \"$3\" > \"$AETHER_TEST_ACTIVATED\"\n" + script := "#!/bin/sh\nif [ \"$1 $2 $3\" = \"theme bg set\" ]; then\n printf '%s' \"$4\" > \"$AETHER_TEST_BGSET\"\nelif [ \"$1 $2\" = \"theme set\" ]; then\n printf '%s' \"$3\" > \"$AETHER_TEST_ACTIVATED\"\nfi\n" if err := os.WriteFile(filepath.Join(binDir, "omarchy"), []byte(script), 0o755); err != nil { t.Fatal(err) } + // A wallpaper so the theme has a background to apply. + srcDir := t.TempDir() + wallpaper := filepath.Join(srcDir, "photo.png") + if err := os.WriteFile(wallpaper, []byte("fake image bytes"), 0o644); err != nil { + t.Fatal(err) + } + state := NewThemeState() + state.WallpaperPath = wallpaper + writer := NewWriter(omarchyV4TestTemplates, "testdata/v4") - if err := writer.InstallOmarchyTheme(NewThemeState(), Settings{}, "web-theme"); err != nil { + if err := writer.InstallOmarchyTheme(state, Settings{}, "web-theme"); err != nil { t.Fatal(err) } data, err := os.ReadFile(activatedPath) @@ -45,7 +56,18 @@ func TestInstallOmarchyThemeCreatesAndActivatesNewTheme(t *testing.T) { t.Fatalf("installed theme missing: %v", err) } - err = writer.InstallOmarchyTheme(NewThemeState(), Settings{}, "web-theme") + // The theme's own wallpaper copy must have been applied explicitly — + // omarchy-theme-set alone cycles backgrounds and may pick a stock image. + applied, err := os.ReadFile(bgSetPath) + if err != nil { + t.Fatalf("wallpaper was not applied: %v", err) + } + want := filepath.Join(omarchy.UserThemesDir(), "web-theme", "backgrounds", "photo.png") + if string(applied) != want { + t.Errorf("applied wallpaper = %q; want %q", applied, want) + } + + err = writer.InstallOmarchyTheme(state, Settings{}, "web-theme") if err == nil || !strings.Contains(err.Error(), "already exists") { t.Fatalf("second install error = %v; want already-exists error", err) } diff --git a/internal/theme/state.go b/internal/theme/state.go index d0d0a404..21b9e405 100644 --- a/internal/theme/state.go +++ b/internal/theme/state.go @@ -8,19 +8,25 @@ import ( // ThemeState holds all mutable state for the current theme. type ThemeState struct { - Palette [16]string `json:"palette"` - BasePalette [16]string `json:"basePalette"` - WallpaperPath string `json:"wallpaperPath"` - LightMode bool `json:"lightMode"` - LockedColors map[int]bool `json:"lockedColors"` - Adjustments color.Adjustments `json:"adjustments"` - ColorRoles template.ColorRoles `json:"colorRoles"` - ExtendedColors map[string]string `json:"extendedColors"` - NativeColors map[string]string `json:"nativeColors"` - ExtractionMode string `json:"extractionMode"` - AdditionalImages []string `json:"additionalImages"` - AppOverrides map[string]map[string]string `json:"appOverrides"` - IconTheme icontheme.Selection `json:"iconTheme"` + Palette [16]string `json:"palette"` + BasePalette [16]string `json:"basePalette"` + WallpaperPath string `json:"wallpaperPath"` + WallpaperBlur bool `json:"wallpaperBlur"` + // OriginalWallpaperPath is the unblurred source image when + // WallpaperPath is a derived variant (e.g. the heavy-blur JPEG). Both + // are copied into the theme's backgrounds so the desktop cycler can + // switch between them. Empty when WallpaperPath is the source itself. + OriginalWallpaperPath string `json:"originalWallpaperPath"` + LightMode bool `json:"lightMode"` + LockedColors map[int]bool `json:"lockedColors"` + Adjustments color.Adjustments `json:"adjustments"` + ColorRoles template.ColorRoles `json:"colorRoles"` + ExtendedColors map[string]string `json:"extendedColors"` + NativeColors map[string]string `json:"nativeColors"` + ExtractionMode string `json:"extractionMode"` + AdditionalImages []string `json:"additionalImages"` + AppOverrides map[string]map[string]string `json:"appOverrides"` + IconTheme icontheme.Selection `json:"iconTheme"` } // DefaultPalette is the Catppuccin-inspired default 16-color palette. @@ -89,6 +95,7 @@ func (s *ThemeState) SetColor(index int, hex string) { type StateSnapshot struct { Palette [16]string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + WallpaperBlur bool `json:"wallpaperBlur"` LightMode bool `json:"lightMode"` LockedColors map[int]bool `json:"lockedColors"` ColorRoles template.ColorRoles `json:"colorRoles"` @@ -131,6 +138,7 @@ func (s *ThemeState) Snapshot() StateSnapshot { return StateSnapshot{ Palette: s.Palette, WallpaperPath: s.WallpaperPath, + WallpaperBlur: s.WallpaperBlur, LightMode: s.LightMode, LockedColors: locked, ColorRoles: s.ColorRoles, diff --git a/internal/theme/wallpaper_variant.go b/internal/theme/wallpaper_variant.go new file mode 100644 index 00000000..846dd516 --- /dev/null +++ b/internal/theme/wallpaper_variant.go @@ -0,0 +1,24 @@ +package theme + +import ( + "fmt" + + "aether/internal/platform" + "aether/internal/wallpaper" +) + +// materializeWallpaper keeps the editor source intact and resolves a derived output copy. +func materializeWallpaper(state *ThemeState) (*ThemeState, error) { + if !state.WallpaperBlur { + return state, nil + } + path, err := wallpaper.CreateBlurredVariant(state.WallpaperPath, platform.BlurDir()) + if err != nil { + return nil, fmt.Errorf("create wallpaper variant: %w", err) + } + rendered := *state + rendered.WallpaperPath = path + rendered.OriginalWallpaperPath = state.WallpaperPath + rendered.WallpaperBlur = false + return &rendered, nil +} diff --git a/internal/theme/wallpaper_variant_test.go b/internal/theme/wallpaper_variant_test.go new file mode 100644 index 00000000..728a4747 --- /dev/null +++ b/internal/theme/wallpaper_variant_test.go @@ -0,0 +1,55 @@ +package theme + +import ( + "bytes" + "image" + "image/png" + "os" + "path/filepath" + "testing" + + "aether/internal/platform" +) + +func TestGenerateWallpaperVariantPreservesSourceAndRestoresCache(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var data bytes.Buffer + if err := png.Encode(&data, image.NewRGBA(image.Rect(0, 0, 16, 16))); err != nil { + t.Fatal(err) + } + source := filepath.Join(t.TempDir(), "source.png") + if err := os.WriteFile(source, data.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + state := NewThemeState() + state.WallpaperPath = source + state.WallpaperBlur = true + writer := NewWriter(omarchyV4TestTemplates, "testdata/v4") + for _, native := range []bool{false, true} { + output := t.TempDir() + generate := writer.GenerateOnly + if native { + generate = writer.GenerateOmarchyV4Only + } + for i := 0; i < 2; i++ { + if err := generate(state, DefaultApplySettings(), output); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(filepath.Join(output, "backgrounds")) + if err != nil || len(entries) != 2 { + t.Fatalf("backgrounds = %v, error = %v", entries, err) + } + copied, err := os.ReadFile(filepath.Join(output, "backgrounds", "source.png")) + if err != nil || !bytes.Equal(copied, data.Bytes()) { + t.Fatal("original image changes") + } + if state.WallpaperPath != source || !state.WallpaperBlur || state.OriginalWallpaperPath != "" { + t.Fatal("generation changes editor source state") + } + if err := os.RemoveAll(platform.BlurDir()); err != nil { + t.Fatal(err) + } + } + } +} diff --git a/internal/theme/writer.go b/internal/theme/writer.go index f72416d5..4877064d 100644 --- a/internal/theme/writer.go +++ b/internal/theme/writer.go @@ -162,7 +162,7 @@ func prepareThemeDir(targetDir string, state *ThemeState) (string, error) { return "", err } - if state.WallpaperPath == "" && len(state.AdditionalImages) == 0 { + if state.WallpaperPath == "" && state.OriginalWallpaperPath == "" && len(state.AdditionalImages) == 0 { return "", nil } @@ -185,6 +185,18 @@ func prepareThemeDir(targetDir string, state *ThemeState) (string, error) { // Sources may be inside the live backgrounds directory, including symlinks. sources := append([]string{state.WallpaperPath}, state.AdditionalImages...) + if original := state.OriginalWallpaperPath; original != "" && original != state.WallpaperPath { + found := false + for _, source := range sources { + if source == original { + found = true + break + } + } + if !found { + sources = append(sources, original) + } + } seen := make(map[string]string, len(sources)) for i, src := range sources { if i == 0 && src == "" { @@ -323,6 +335,10 @@ func (w *Writer) GenerateOmarchyV4Only(state *ThemeState, settings Settings, out } func (w *Writer) generateOmarchyTheme(state *ThemeState, settings Settings, outputPath, activateName string) error { + state, err := materializeWallpaper(state) + if err != nil { + return err + } if err := validateIconTheme(state.IconTheme, settings.includesApp("icons")); err != nil { return err } @@ -389,6 +405,10 @@ func (w *Writer) generateOmarchyTheme(state *ThemeState, settings Settings, outp // ApplyTheme generates all theme files and applies the theme to the system. func (w *Writer) ApplyTheme(state *ThemeState, settings Settings) (*ApplyResult, error) { + state, err := materializeWallpaper(state) + if err != nil { + return nil, err + } if err := validateIconTheme(state.IconTheme, settings.includesApp("icons")); err != nil { return nil, err } @@ -437,6 +457,10 @@ func (w *Writer) ApplyTheme(state *ThemeState, settings Settings) (*ApplyResult, // GenerateOnly generates theme files to the specified output path without // applying them (no symlinks, no service restarts, no omarchy activation). func (w *Writer) GenerateOnly(state *ThemeState, settings Settings, outputPath string) error { + state, err := materializeWallpaper(state) + if err != nil { + return err + } if err := validateIconTheme(state.IconTheme, settings.includesApp("icons")); err != nil { return err } diff --git a/internal/theme/writer_test.go b/internal/theme/writer_test.go index e64edeb2..58bf8733 100644 --- a/internal/theme/writer_test.go +++ b/internal/theme/writer_test.go @@ -14,6 +14,73 @@ import ( //go:embed testdata/v4 var omarchyV4TestTemplates embed.FS +// TestPrepareThemeDirCopiesWallpaperVariants covers the blur pair: the +// applied variant (WallpaperPath) and the unblurred source +// (OriginalWallpaperPath) must both land in backgrounds/ when they differ, +// and the returned destination must be the applied variant. +func TestPrepareThemeDirCopiesWallpaperVariants(t *testing.T) { + srcDir := t.TempDir() + original := filepath.Join(srcDir, "photo.png") + blurred := filepath.Join(srcDir, "photo-blurred-a1b2c3d4.jpg") + if err := os.WriteFile(original, []byte("original-bytes"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(blurred, []byte("blurred-bytes"), 0o644); err != nil { + t.Fatal(err) + } + + targetDir := t.TempDir() + dest, err := prepareThemeDir(targetDir, &ThemeState{ + WallpaperPath: blurred, + OriginalWallpaperPath: original, + }) + if err != nil { + t.Fatal(err) + } + + bgDir := filepath.Join(targetDir, "backgrounds") + wantDest := filepath.Join(bgDir, "photo-blurred-a1b2c3d4.jpg") + if dest != wantDest { + t.Errorf("dest = %q, want %q", dest, wantDest) + } + + got, err := os.ReadFile(wantDest) + if err != nil { + t.Fatalf("blurred variant missing: %v", err) + } + if string(got) != "blurred-bytes" { + t.Error("blurred variant content mismatch") + } + + got, err = os.ReadFile(filepath.Join(bgDir, "photo.png")) + if err != nil { + t.Fatalf("original wallpaper missing: %v", err) + } + if string(got) != "original-bytes" { + t.Error("original wallpaper content mismatch") + } + + // Identical paths (blur off) must not duplicate the file. + onlyDir := t.TempDir() + dest, err = prepareThemeDir(onlyDir, &ThemeState{ + WallpaperPath: original, + OriginalWallpaperPath: original, + }) + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(filepath.Join(onlyDir, "backgrounds")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Errorf("backgrounds has %d entries with identical paths, want 1", len(entries)) + } + if dest != filepath.Join(onlyDir, "backgrounds", "photo.png") { + t.Errorf("dest = %q, want the original copy", dest) + } +} + func TestPrepareThemeDirRemovesLegacyGTKStylesheet(t *testing.T) { targetDir := t.TempDir() legacyFile := filepath.Join(targetDir, "gtk.css") @@ -216,8 +283,7 @@ func TestGenerateOmarchyV4OnlyRemovesLegacyFiles(t *testing.T) { state := NewThemeState() state.ColorRoles.Background = "#1e1e2e" state.ColorRoles.Magenta = "#ff0000" - settings := Settings{IncludedApps: map[string]bool{"icons": true}} - if err := writer.GenerateOmarchyV4Only(state, settings, themeDir); err != nil { + if err := writer.GenerateOmarchyV4Only(state, Settings{IncludedApps: map[string]bool{"icons": true}}, themeDir); err != nil { t.Fatal(err) } diff --git a/internal/wallpaper/blur.go b/internal/wallpaper/blur.go new file mode 100644 index 00000000..28965548 --- /dev/null +++ b/internal/wallpaper/blur.go @@ -0,0 +1,241 @@ +package wallpaper + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "image" + "image/jpeg" + "math" + "os" + "path/filepath" + "strings" + + xdraw "golang.org/x/image/draw" + _ "golang.org/x/image/webp" // register WebP decoder + + "aether/internal/platform" +) + +// Heavy-blur variant generation. The blurred file is what gets applied as +// the desktop wallpaper; the palette pipeline keeps sampling the untouched +// original, so extraction results are identical with and without blur. +const ( + // blurWorkSize caps the longest edge the blur is computed at. Heavy + // Gaussian blur erases fine detail, so computing at full resolution + // only costs time — the result is upscaled afterwards. + blurWorkSize = 640 + // blurSigma is the Gaussian sigma at the working resolution (~5% of + // the image width — a very heavy blur once scaled to screen size). + blurSigma = 32.0 + // blurMaxOutputSize caps the upscaled variant's longest edge. + blurMaxOutputSize = 2560 + // blurJPEGQuality for the encoded variant. + blurJPEGQuality = 92 +) + +var blurSlots = make(chan struct{}, 1) + +// CreateBlurredVariant decodes the image at srcPath, applies a heavy Gaussian +// blur and writes a JPEG variant into destDir. The source file is never +// modified — callers keep using it for color extraction and editing. +// The variant file name is derived from the source path, size, mtime and +// blur parameters, so unchanged images reuse their cached variant. +// Returns the path of the blurred variant. +func CreateBlurredVariant(srcPath, destDir string) (string, error) { + if !IsImageFile(srcPath) { + return "", fmt.Errorf("unsupported image file: %s", srcPath) + } + blurSlots <- struct{}{} + defer func() { <-blurSlots }() + srcInfo, err := os.Stat(srcPath) + if err != nil { + return "", fmt.Errorf("stat image: %w", err) + } + if !srcInfo.Mode().IsRegular() { + return "", fmt.Errorf("image source is not a regular file") + } + + key := fmt.Sprintf("v1|%s|%d|%d|%d|%g", srcPath, srcInfo.Size(), + srcInfo.ModTime().UnixNano(), blurWorkSize, blurSigma) + sum := sha256.Sum256([]byte(key)) + + // Human-readable name (shown by desktop background cyclers when the + // variant is copied into a theme folder): -blurred-.jpg + base := strings.TrimSuffix(filepath.Base(srcPath), filepath.Ext(srcPath)) + if base == "" || base == "." || base == "/" { + base = "wallpaper" + } + outPath := filepath.Join(destDir, fmt.Sprintf("%s-blurred-%s.jpg", base, hex.EncodeToString(sum[:8]))) + + // Reuse the cached variant when it exists and is fully written. + if outInfo, err := os.Stat(outPath); err == nil && outInfo.Size() > 0 { + return outPath, nil + } + + src, err := loadImage(srcPath) + if err != nil { + return "", fmt.Errorf("load image: %w", err) + } + + blurred := heavyGaussianBlur(src) + + if err := platform.EnsureDir(destDir); err != nil { + return "", fmt.Errorf("create blur cache dir: %w", err) + } + + // Write via a temp file + rename so a crash mid-encode can never leave + // a truncated file that later passes the cache check above. + tmp, err := os.CreateTemp(destDir, ".blur-*") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + if err := jpeg.Encode(tmp, blurred, &jpeg.Options{Quality: blurJPEGQuality}); err != nil { + tmp.Close() + _ = os.Remove(tmpName) + return "", fmt.Errorf("encode blurred image: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return "", fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tmpName, outPath); err != nil { + _ = os.Remove(tmpName) + return "", fmt.Errorf("finalize blurred image: %w", err) + } + + return outPath, nil +} + +// heavyGaussianBlur downscales the image, runs a separable Gaussian blur at +// the working resolution and scales the result back up. Working at reduced +// resolution makes a blur of this strength fast without any visible +// difference — fine detail is gone either way. +func heavyGaussianBlur(src image.Image) image.Image { + bounds := src.Bounds() + srcW := bounds.Dx() + srcH := bounds.Dy() + if srcW < 1 || srcH < 1 { + return src + } + + // Downscale only — never upscale small sources before blurring. + scale := math.Min(1, float64(blurWorkSize)/math.Max(float64(srcW), float64(srcH))) + workW := max(1, int(math.Round(float64(srcW)*scale))) + workH := max(1, int(math.Round(float64(srcH)*scale))) + + work := image.NewRGBA(image.Rect(0, 0, workW, workH)) + xdraw.CatmullRom.Scale(work, work.Bounds(), src, bounds, xdraw.Over, nil) + + // Wallpapers are opaque: flatten alpha (premultiplied RGBA over black + // is just alpha=255) so channel blurring can't bleed transparency. + for i := 3; i < len(work.Pix); i += 4 { + work.Pix[i] = 0xff + } + + gaussianBlurRGBA(work, blurSigma) + + // Upscale back to (a capped version of) the original dimensions. + outW, outH := srcW, srcH + if maxOut := max(srcW, srcH); maxOut > blurMaxOutputSize { + adjust := float64(blurMaxOutputSize) / float64(maxOut) + outW = max(1, int(math.Round(float64(srcW)*adjust))) + outH = max(1, int(math.Round(float64(srcH)*adjust))) + } + if outW == workW && outH == workH { + return work + } + + out := image.NewRGBA(image.Rect(0, 0, outW, outH)) + xdraw.CatmullRom.Scale(out, out.Bounds(), work, work.Bounds(), xdraw.Over, nil) + return out +} + +// gaussianBlurRGBA blurs img in place with a separable Gaussian kernel. +// Edges are handled by clamping sample coordinates (replicate). The image +// is treated as opaque: alpha passes through untouched. +func gaussianBlurRGBA(img *image.RGBA, sigma float64) { + if sigma <= 0 { + return + } + radius := int(math.Ceil(sigma * 3)) + if radius < 1 { + return + } + + kernel := make([]float64, 2*radius+1) + norm := 0.0 + for i := range kernel { + x := float64(i - radius) + kernel[i] = math.Exp(-(x * x) / (2 * sigma * sigma)) + norm += kernel[i] + } + for i := range kernel { + kernel[i] /= norm + } + + w := img.Rect.Dx() + h := img.Rect.Dy() + pix := img.Pix + stride := img.Stride + + // Horizontal pass into scratch, then vertical pass back into pix. + scratch := make([]float32, w*h*4) + for y := 0; y < h; y++ { + row := pix[y*stride : y*stride+w*4] + out := scratch[y*w*4 : (y+1)*w*4] + for x := 0; x < w; x++ { + var r, g, b float64 + for k, kv := range kernel { + sx := x + k - radius + if sx < 0 { + sx = 0 + } else if sx >= w { + sx = w - 1 + } + o := sx * 4 + r += kv * float64(row[o]) + g += kv * float64(row[o+1]) + b += kv * float64(row[o+2]) + } + o := x * 4 + out[o] = float32(r) + out[o+1] = float32(g) + out[o+2] = float32(b) + out[o+3] = float32(row[o+3]) + } + } + for y := 0; y < h; y++ { + out := pix[y*stride : y*stride+w*4] + for x := 0; x < w; x++ { + var r, g, b float64 + for k, kv := range kernel { + sy := y + k - radius + if sy < 0 { + sy = 0 + } else if sy >= h { + sy = h - 1 + } + t := scratch[(sy*w+x)*4:] + r += kv * float64(t[0]) + g += kv * float64(t[1]) + b += kv * float64(t[2]) + } + o := x * 4 + out[o] = clampByte(r) + out[o+1] = clampByte(g) + out[o+2] = clampByte(b) + } + } +} + +func clampByte(v float64) byte { + if v <= 0 { + return 0 + } + if v >= 255 { + return 255 + } + return byte(math.Round(v)) +} diff --git a/internal/wallpaper/blur_test.go b/internal/wallpaper/blur_test.go new file mode 100644 index 00000000..db60c302 --- /dev/null +++ b/internal/wallpaper/blur_test.go @@ -0,0 +1,168 @@ +package wallpaper + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "testing" + "time" +) + +// writeTestPNG writes a PNG with a sharp two-half pattern (left black, right +// white) — a worst case for blur, since a heavy blur must smear it gray. +func writeTestPNG(t *testing.T, dir, name string, w, h int) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + c := color.RGBA{B: 0xff, A: 0xff} + if x < w/2 { + c = color.RGBA{A: 0xff} + } + img.SetRGBA(x, y, c) + } + } + path := filepath.Join(dir, name) + f, err := os.Create(path) + if err != nil { + t.Fatalf("create test image: %v", err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatalf("encode test image: %v", err) + } + return path +} + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return data +} + +func TestCreateBlurredVariant(t *testing.T) { + srcDir := t.TempDir() + outDir := t.TempDir() + src := writeTestPNG(t, srcDir, "wall.png", 200, 120) + srcBytes := mustReadFile(t, src) + + got, err := CreateBlurredVariant(src, outDir) + if err != nil { + t.Fatalf("CreateBlurredVariant: %v", err) + } + + if filepath.Dir(got) != outDir { + t.Errorf("variant written outside destDir: %s", got) + } + if filepath.Ext(got) != ".jpg" { + t.Errorf("variant should be .jpg, got %s", got) + } + + data := mustReadFile(t, got) + if len(data) == 0 { + t.Fatal("variant file is empty") + } + if _, err := jpeg.Decode(bytes.NewReader(data)); err != nil { + t.Fatalf("variant is not a decodable JPEG: %v", err) + } + + decoded, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode variant: %v", err) + } + b := decoded.Bounds() + if b.Dx() != 200 || b.Dy() != 120 { + t.Errorf("variant dims = %dx%d, want 200x120", b.Dx(), b.Dy()) + } + + // A sharp black/white split must be smeared towards gray by a heavy blur. + center := decoded.At(150, 60) + r, g, bl, _ := center.RGBA() + if r >= 65000 && g >= 65000 && bl >= 65000 { + t.Errorf("right half still pure white at (150,60): blur had no effect") + } + + // Source must be untouched. + if !bytes.Equal(srcBytes, mustReadFile(t, src)) { + t.Error("source file was modified") + } +} + +func TestCreateBlurredVariantCaches(t *testing.T) { + srcDir := t.TempDir() + outDir := t.TempDir() + src := writeTestPNG(t, srcDir, "wall.png", 64, 64) + + first, err := CreateBlurredVariant(src, outDir) + if err != nil { + t.Fatalf("first call: %v", err) + } + info, err := os.Stat(first) + if err != nil { + t.Fatalf("stat variant: %v", err) + } + + second, err := CreateBlurredVariant(src, outDir) + if err != nil { + t.Fatalf("second call: %v", err) + } + if second != first { + t.Errorf("cache miss: got %s, want %s", second, first) + } + again, err := os.Stat(first) + if err != nil { + t.Fatalf("re-stat variant: %v", err) + } + if !again.ModTime().Equal(info.ModTime()) { + t.Error("cached variant was rewritten") + } +} + +func TestCreateBlurredVariantRegeneratedAfterEdit(t *testing.T) { + srcDir := t.TempDir() + outDir := t.TempDir() + src := writeTestPNG(t, srcDir, "wall.png", 64, 64) + + first, err := CreateBlurredVariant(src, outDir) + if err != nil { + t.Fatalf("first call: %v", err) + } + + // Rewrite the source (editor flow) — mtime changes, so a new variant + // must be generated instead of reusing the stale cache entry. + future := time.Now().Add(2 * time.Hour) + if err := os.Chtimes(src, future, future); err != nil { + t.Fatalf("chtimes: %v", err) + } + + second, err := CreateBlurredVariant(src, outDir) + if err != nil { + t.Fatalf("second call: %v", err) + } + if second == first { + t.Error("edited source reused stale blurred variant") + } +} + +func TestCreateBlurredVariantErrors(t *testing.T) { + outDir := t.TempDir() + + if _, err := CreateBlurredVariant(filepath.Join(outDir, "missing.png"), outDir); err == nil { + t.Error("expected error for missing file") + } + + txt := filepath.Join(outDir, "notes.txt") + if err := os.WriteFile(txt, []byte("not an image"), 0644); err != nil { + t.Fatal(err) + } + if _, err := CreateBlurredVariant(txt, outDir); err == nil { + t.Error("expected error for non-image file") + } +} diff --git a/internal/wallpaper/formats.go b/internal/wallpaper/formats.go new file mode 100644 index 00000000..5c466e5f --- /dev/null +++ b/internal/wallpaper/formats.go @@ -0,0 +1,15 @@ +package wallpaper + +import ( + "path/filepath" + "strings" +) + +// IsImageFile reports whether the filename has a supported still-image extension. +func IsImageFile(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp": + return true + } + return false +} diff --git a/internal/wallpaper/local.go b/internal/wallpaper/local.go index dc5a3304..13abd71d 100644 --- a/internal/wallpaper/local.go +++ b/internal/wallpaper/local.go @@ -6,7 +6,6 @@ import ( "strings" "aether/internal/platform" - "aether/internal/theme" ) // WallpaperInfo describes a local wallpaper image file. @@ -44,7 +43,7 @@ func ScanDirectory(dir string) ([]WallpaperInfo, error) { return nil } - if !theme.IsImageFile(path) { + if !IsImageFile(path) { return nil }