diff --git a/app.go b/app.go index 3a12dff..1ae88fb 100644 --- a/app.go +++ b/app.go @@ -222,6 +222,7 @@ func (a *App) SetExtractionMode(mode string) { type SyncStateRequest struct { Palette []string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + OriginalWallpaperPath string `json:"originalWallpaperPath"` LightMode bool `json:"lightMode"` ExtendedColors map[string]string `json:"extendedColors"` AppOverrides map[string]map[string]string `json:"appOverrides"` @@ -240,6 +241,7 @@ func (a *App) SyncState(req SyncStateRequest) { a.state.SetAdjustedPalette(p) } a.state.WallpaperPath = req.WallpaperPath + a.state.OriginalWallpaperPath = req.OriginalWallpaperPath a.state.LightMode = req.LightMode if req.ExtendedColors != nil { a.state.ExtendedColors = req.ExtendedColors @@ -321,6 +323,7 @@ func (a *App) ComputeVariables(paletteSlice []string, extendedColors map[string] type ApplyThemeRequest struct { Palette []string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + OriginalWallpaperPath string `json:"originalWallpaperPath"` LightMode bool `json:"lightMode"` AdditionalImages []string `json:"additionalImages"` ExtendedColors map[string]string `json:"extendedColors"` @@ -340,6 +343,7 @@ func (a *App) ApplyTheme(req ApplyThemeRequest) (*theme.ApplyResult, error) { state := &theme.ThemeState{ Palette: palette, WallpaperPath: req.WallpaperPath, + OriginalWallpaperPath: req.OriginalWallpaperPath, LightMode: req.LightMode, ColorRoles: roles, ExtendedColors: req.ExtendedColors, @@ -357,6 +361,7 @@ type SaveAndApplyThemeRequest struct { UpdateExisting bool `json:"updateExisting"` Palette []string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + OriginalWallpaperPath string `json:"originalWallpaperPath"` LightMode bool `json:"lightMode"` AdditionalImages []string `json:"additionalImages"` ExtendedColors map[string]string `json:"extendedColors"` @@ -380,6 +385,7 @@ func (a *App) SaveAndApplyTheme(req SaveAndApplyThemeRequest) (*theme.ApplyResul state := &theme.ThemeState{ Palette: palette, WallpaperPath: req.WallpaperPath, + OriginalWallpaperPath: req.OriginalWallpaperPath, LightMode: req.LightMode, ColorRoles: roles, ExtendedColors: req.ExtendedColors, @@ -401,7 +407,8 @@ func (a *App) SaveAndApplyTheme(req SaveAndApplyThemeRequest) (*theme.ApplyResul } else if !os.IsNotExist(err) { return nil, fmt.Errorf("check theme folder: %w", err) } - if err := a.writer.GenerateOmarchyV4Only(state, req.Settings, targetDir); err != nil { + wallpaperDest, err := a.writer.GenerateOmarchyV4Only(state, req.Settings, targetDir) + if err != nil { return nil, fmt.Errorf("save theme: %w", err) } if !theme.IsOmarchyInstalled() { @@ -415,6 +422,15 @@ func (a *App) SaveAndApplyTheme(req SaveAndApplyThemeRequest) (*theme.ApplyResul if _, err := platform.RunSync("omarchy-theme-set", name); err != nil { return nil, fmt.Errorf("activate theme: %w", err) } + // omarchy-theme-set cycles through a theme's bundled backgrounds (its own + // plus ours), so it can land on a stock image instead of the selected + // wallpaper. Apply our copy explicitly — the blurred variant when blur is + // enabled, since the request carries it as WallpaperPath. + if wallpaperDest != "" { + if err := theme.ApplyWallpaper(wallpaperDest); err != nil { + log.Printf("Warning: wallpaper application failed: %v", err) + } + } return &theme.ApplyResult{ Success: true, IsOmarchy: true, @@ -422,6 +438,30 @@ func (a *App) SaveAndApplyTheme(req SaveAndApplyThemeRequest) (*theme.ApplyResul }, nil } +// ThemeFolderExists reports whether a saved theme folder with the given name +// already exists. The frontend uses this to offer updating the folder in +// place instead of refusing to save. +func (a *App) ThemeFolderExists(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + // Same charset SaveAndApplyTheme accepts. + if name == "" || name[0] == '-' { + return false + } + for _, r := range name { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + for _, dir := range []string{platform.OmarchyThemesDir(), platform.SavedThemesDir()} { + // Theme folders are directories; match SaveAndApplyTheme's stat + // check rather than platform.FileExists (files only). + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return true + } + } + return false +} + // ClearTheme removes the Aether theme and reverts to the default. func (a *App) ClearTheme() error { return theme.ClearTheme() @@ -832,6 +872,17 @@ func (a *App) CancelBatchProcessing() { // File / Image Utilities // --------------------------------------------------------------------------- +// BlurWallpaper generates a heavily Gaussian-blurred JPEG variant of an image +// for use as the applied wallpaper. The original file is never modified, so +// color extraction keeps sampling the unblurred source. Returns the variant +// path (cached by source identity, so repeat calls are cheap). +func (a *App) BlurWallpaper(path string) (string, error) { + if !theme.IsImageFile(path) { + return "", fmt.Errorf("unsupported image file: %s", path) + } + return wallpaper.CreateBlurredVariant(path, platform.BlurDir()) +} + // ReadImageAsDataURL reads a local image file and returns it as a base64 data URL. // This is needed because webkit2gtk cannot load file:// paths directly. func (a *App) ReadImageAsDataURL(path string) (string, error) { @@ -937,6 +988,7 @@ type ExportThemeRequest struct { IncludedApps []string `json:"includedApps"` Palette []string `json:"palette"` WallpaperPath string `json:"wallpaperPath"` + OriginalWallpaperPath string `json:"originalWallpaperPath"` LightMode bool `json:"lightMode"` AdditionalImages []string `json:"additionalImages"` ExtendedColors map[string]string `json:"extendedColors"` @@ -982,6 +1034,7 @@ func (a *App) ExportTheme(req ExportThemeRequest) (string, error) { state := &theme.ThemeState{ Palette: palette, WallpaperPath: req.WallpaperPath, + OriginalWallpaperPath: req.OriginalWallpaperPath, LightMode: req.LightMode, ColorRoles: roles, ExtendedColors: req.ExtendedColors, @@ -1273,6 +1326,7 @@ func (a *App) HandleIPC(req ipc.Request) ipc.Response { result, err := a.ApplyTheme(ApplyThemeRequest{ Palette: a.state.Palette[:], WallpaperPath: a.state.WallpaperPath, + OriginalWallpaperPath: a.state.OriginalWallpaperPath, LightMode: a.state.LightMode, ExtendedColors: a.state.ExtendedColors, AppOverrides: a.state.AppOverrides, diff --git a/app_themes_test.go b/app_themes_test.go new file mode 100644 index 0000000..1565ea7 --- /dev/null +++ b/app_themes_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "testing" +) + +func TestThemeFolderExists(t *testing.T) { + configHome := t.TempDir() + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("HOME", home) + + app := NewApp() + + if app.ThemeFolderExists("midnight") { + t.Error("ThemeFolderExists() = true for missing folder") + } + + // Omarchy themes dir (~/.config/omarchy/themes). + omarchyDir := filepath.Join(configHome, "omarchy", "themes", "midnight") + if err := os.MkdirAll(omarchyDir, 0o755); err != nil { + t.Fatal(err) + } + if !app.ThemeFolderExists("midnight") { + t.Error("ThemeFolderExists() = false for existing omarchy theme folder") + } + + // Saved themes dir (~/.config/aether/themes), with case/whitespace + // normalization matching SaveAndApplyTheme's validation. + savedDir := filepath.Join(configHome, "aether", "themes", "dusk") + if err := os.MkdirAll(savedDir, 0o755); err != nil { + t.Fatal(err) + } + if !app.ThemeFolderExists(" Dusk ") { + t.Error("ThemeFolderExists() = false for existing saved theme folder") + } + + // Invalid names never resolve to a folder. + for _, bad := range []string{"", "-x", "foo/bar", "foo bar", "..", "."} { + if app.ThemeFolderExists(bad) { + t.Errorf("ThemeFolderExists(%q) = true; want false", bad) + } + } +} + +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/frontend/src/App.svelte b/frontend/src/App.svelte index e03ea2f..3497821 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -488,9 +488,9 @@ setApplySaveDialogOpen(false)} - onsave={name => { + onsave={(name, updateExisting) => { setApplySaveDialogOpen(false); - saveAndApplyTheme(name); + saveAndApplyTheme(name, updateExisting); }} /> diff --git a/frontend/src/lib/actions/themeActions.ts b/frontend/src/lib/actions/themeActions.ts index afc94ca..231cb14 100644 --- a/frontend/src/lib/actions/themeActions.ts +++ b/frontend/src/lib/actions/themeActions.ts @@ -10,6 +10,7 @@ import { getIsExtracting, setIsExtracting, getWallpaperPath, + getApplyWallpaperPath, setWallpaperPath, getPalette, setPalette, @@ -54,7 +55,8 @@ async function runApply(): Promise<{success: boolean}> { const {ApplyTheme} = await import('../../../wailsjs/go/main/App'); const result = await ApplyTheme({ palette: getPalette(), - wallpaperPath: getWallpaperPath(), + wallpaperPath: getApplyWallpaperPath(), + originalWallpaperPath: getWallpaperPath(), lightMode: getLightMode(), additionalImages: getAdditionalImages(), extendedColors: getExtendedColors(), @@ -150,7 +152,8 @@ export async function saveAndApplyTheme( name, updateExisting, palette: getPalette(), - wallpaperPath: getWallpaperPath(), + wallpaperPath: getApplyWallpaperPath(), + originalWallpaperPath: getWallpaperPath(), lightMode: getLightMode(), additionalImages: getAdditionalImages(), extendedColors: getExtendedColors(), diff --git a/frontend/src/lib/components/editor/WallpaperHero.svelte b/frontend/src/lib/components/editor/WallpaperHero.svelte index 82c60ce..430ad82 100644 --- a/frontend/src/lib/components/editor/WallpaperHero.svelte +++ b/frontend/src/lib/components/editor/WallpaperHero.svelte @@ -12,6 +12,9 @@ getExtractionMode, setAdjustments, getAdditionalImages, + getBlurredWallpaperPath, + setBlurredWallpaper, + clearBlurredWallpaper, } from '$lib/stores/theme.svelte'; import {DEFAULT_ADJUSTMENTS} from '$lib/types/theme'; import { @@ -34,9 +37,15 @@ $props(); let wallpaperPath = $derived(getWallpaperPath()); - let wallpaperImage = $derived(getCachedFullImage(wallpaperPath) || ''); + let blurredPath = $derived(getBlurredWallpaperPath()); + // The blurred variant is what gets applied, so it is what the hero shows. + // Extraction still uses wallpaperPath (the unblurred original). + let displayPath = $derived(blurredPath || wallpaperPath); + let wallpaperImage = $derived(getCachedFullImage(displayPath) || ''); let wallpaperName = $derived(wallpaperPath.split('/').pop() || ''); - let loading = $derived(isPending(wallpaperPath)); + let loading = $derived(isPending(displayPath)); + let blurred = $derived(!!blurredPath); + let isBlurring = $state(false); let previewOpen = $state(false); let eyedropperActive = $derived(getEyedropperActive()); let containerHeight = $derived(expanded ? 'h-[70vh]' : 'h-96'); @@ -60,7 +69,7 @@ let loupeHex = $state('#000000'); $effect(() => { - const path = getWallpaperPath(); + const path = displayPath; if (path && !getCachedFullImage(path)) { loadFullImage(path); } @@ -295,6 +304,33 @@ showToast('Failed to change wallpaper'); } } + + // Toggle a heavy Gaussian-blurred variant as the applied wallpaper. The + // blurred copy is generated by the backend into its cache; the source + // file is never modified, so Extract keeps sampling the original. + async function handleToggleBlur() { + const source = getWallpaperPath(); + if (!source || isBlurring) return; + if (getBlurredWallpaperPath()) { + clearBlurredWallpaper(); + showToast('Blur removed — the original will be applied'); + return; + } + isBlurring = true; + try { + const {BlurWallpaper} = + await import('../../../../wailsjs/go/main/App'); + const path = await BlurWallpaper(source); + setBlurredWallpaper(source, path); + showToast( + 'Wallpaper blurred — Apply uses the blurred copy; palette still extracts from the original' + ); + } catch { + showToast("Couldn't blur wallpaper"); + } finally { + isBlurring = false; + } + }
@@ -395,6 +431,35 @@ {/if} + + {folderExists ? 'Update and Apply' : 'Save and Apply'} +
diff --git a/frontend/src/lib/stores/theme.svelte.ts b/frontend/src/lib/stores/theme.svelte.ts index 68f1aa6..24e0f7f 100644 --- a/frontend/src/lib/stores/theme.svelte.ts +++ b/frontend/src/lib/stores/theme.svelte.ts @@ -10,6 +10,12 @@ import {pushState} from '$lib/stores/history.svelte'; let palette = $state([...DEFAULT_PALETTE]); let basePalette = $state([...DEFAULT_PALETTE]); let wallpaperPath = $state(''); +// Heavily-blurred variant of the wallpaper (backend-generated JPEG in the +// cache dir). When active, the hero previews it and Apply sends it as the +// wallpaper — while extraction keeps sampling wallpaperPath, the unblurred +// original. Tracked with its source path so a wallpaper change can +// invalidate a stale variant. +let blur = $state<{source: string; blurred: string} | null>(null); let lightMode = $state(false); let lockedColors = $state>({}); let selectedColors = $state>({}); // empty = all selected @@ -81,6 +87,20 @@ export function getBasePalette(): string[] { export function getWallpaperPath(): string { return wallpaperPath; } +export function getBlurredWallpaperPath(): string { + return blur?.blurred ?? ''; +} +export function setBlurredWallpaper(source: string, blurred: string): void { + blur = {source, blurred}; +} +export function clearBlurredWallpaper(): void { + blur = null; +} +// Path sent to ApplyTheme/SaveAndApplyTheme: the blurred variant when one +// is active for the current wallpaper, otherwise the original. +export function getApplyWallpaperPath(): string { + return blur?.blurred || wallpaperPath; +} export function getLightMode(): boolean { return lightMode; } @@ -142,6 +162,7 @@ export function getAppOverrides(): Record> { export function getThemeSnapshot(): { palette: string[]; wallpaperPath: string; + originalWallpaperPath: string; lightMode: boolean; extendedColors: Record; appOverrides: Record>; @@ -149,7 +170,12 @@ export function getThemeSnapshot(): { } { return { palette, - wallpaperPath, + // Mirrored for IPC readers (`aether status`) and CLI apply — report + // the blurred variant when one is active, since that is what an + // apply would set as the wallpaper. The unblurred original travels + // alongside so theme folders keep both for background cycling. + wallpaperPath: getApplyWallpaperPath(), + originalWallpaperPath: getWallpaperPath(), lightMode, extendedColors, appOverrides, @@ -163,6 +189,7 @@ export function getThemeSignature(): string { return JSON.stringify([ palette, wallpaperPath, + blur?.blurred ?? '', lightMode, extendedColors, appOverrides, @@ -351,6 +378,9 @@ export function clearExtendedColor(key: string): void { export function setWallpaperPath(path: string): void { wallpaperPath = path; + if (blur && blur.source !== path) { + blur = null; + } } export function setLightMode(enabled: boolean): void { lightMode = enabled; @@ -390,6 +420,9 @@ export function swapMainWithAdditional(path: string): void { if (idx === -1) return; const oldMain = wallpaperPath; wallpaperPath = path; + if (blur && blur.source !== path) { + blur = null; + } const next = [...additionalImages]; if (oldMain) { next[idx] = oldMain; @@ -432,6 +465,7 @@ export function reset(): void { palette = [...DEFAULT_PALETTE]; basePalette = [...DEFAULT_PALETTE]; wallpaperPath = ''; + blur = null; lightMode = false; lockedColors = {}; adjustments = {...DEFAULT_ADJUSTMENTS}; diff --git a/internal/platform/paths.go b/internal/platform/paths.go index 286f3a1..8186aa4 100644 --- a/internal/platform/paths.go +++ b/internal/platform/paths.go @@ -100,6 +100,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 { @@ -114,6 +119,7 @@ func EnsureAllDirs() error { DownloadDir(), ThumbnailDir(), ColorCacheDir(), + BlurDir(), } // Omarchy directories are Linux-only if runtime.GOOS == "linux" { diff --git a/internal/theme/omarchy_install.go b/internal/theme/omarchy_install.go index e66f392..17aa5f7 100644 --- a/internal/theme/omarchy_install.go +++ b/internal/theme/omarchy_install.go @@ -2,6 +2,7 @@ package theme import ( "fmt" + "log" "os" "path/filepath" "regexp" @@ -57,6 +58,14 @@ func (w *Writer) InstallOmarchyTheme(state *ThemeState, settings Settings, name if _, err := platform.RunSync("omarchy-theme-set", name); err != nil { return fmt.Errorf("activate Omarchy theme %q: %w", name, err) } + // omarchy-theme-set cycles through a theme's bundled backgrounds and may + // pick a stock image instead of the theme's own wallpaper — apply our + // copy explicitly. + if state.WallpaperPath != "" { + if err := ApplyWallpaper(filepath.Join(targetDir, "backgrounds", filepath.Base(state.WallpaperPath))); err != nil { + log.Printf("Warning: wallpaper application failed: %v", err) + } + } return nil } diff --git a/internal/theme/omarchy_install_test.go b/internal/theme/omarchy_install_test.go index c3b7332..e668a10 100644 --- a/internal/theme/omarchy_install_test.go +++ b/internal/theme/omarchy_install_test.go @@ -12,10 +12,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("XDG_CONFIG_HOME", configDir) 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) @@ -27,9 +29,27 @@ func TestInstallOmarchyThemeCreatesAndActivatesNewTheme(t *testing.T) { if err := os.WriteFile(filepath.Join(binDir, "omarchy-theme-set"), []byte(script), 0o755); err != nil { t.Fatal(err) } + // omarchy-shell marks the install as Omarchy v4, which makes wallpaper + // application go through omarchy-theme-bg-set. + if err := os.WriteFile(filepath.Join(binDir, "omarchy-shell"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + bgSetScript := "#!/bin/sh\nprintf '%s' \"$1\" > \"$AETHER_TEST_BGSET\"\n" + if err := os.WriteFile(filepath.Join(binDir, "omarchy-theme-bg-set"), []byte(bgSetScript), 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) @@ -43,7 +63,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(configDir, "omarchy", "themes", "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 a6c71cd..5b41f7a 100644 --- a/internal/theme/state.go +++ b/internal/theme/state.go @@ -10,6 +10,11 @@ type ThemeState struct { Palette [16]string `json:"palette"` BasePalette [16]string `json:"basePalette"` WallpaperPath string `json:"wallpaperPath"` + // 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"` diff --git a/internal/theme/writer.go b/internal/theme/writer.go index f6d4b4e..42731f2 100644 --- a/internal/theme/writer.go +++ b/internal/theme/writer.go @@ -152,6 +152,16 @@ func prepareThemeDir(targetDir string, state *ThemeState) (string, error) { } } + // When the applied wallpaper is a derived variant (e.g. the heavy-blur + // JPEG), also keep the unblurred original in backgrounds so the desktop + // background cycler can switch between the two. + if original := state.OriginalWallpaperPath; original != "" && original != state.WallpaperPath { + destPath := filepath.Join(bgDir, filepath.Base(original)) + if err := platform.CopyFile(original, destPath); err != nil { + log.Printf("Warning: could not copy original wallpaper: %v", err) + } + } + for i, src := range state.AdditionalImages { destPath := filepath.Join(bgDir, filepath.Base(src)) if err := platform.CopyFile(src, destPath); err != nil { @@ -249,18 +259,20 @@ func (w *Writer) processOmarchyV4Templates( } } -// GenerateOmarchyV4Only writes a reusable Omarchy v4 theme folder. App-specific -// templates are included only when targeted or needed by a color override. -func (w *Writer) GenerateOmarchyV4Only(state *ThemeState, settings Settings, outputPath string) error { +// GenerateOmarchyV4Only writes the files Omarchy v4 reads directly from a +// reusable theme folder. Omarchy generates all other app-specific files. +// Returns the wallpaper destination path ("" when no wallpaper was set). +func (w *Writer) GenerateOmarchyV4Only(state *ThemeState, settings Settings, outputPath string) (string, error) { variables := template.BuildVariables(state.ColorRoles, state.LightMode, state.ExtendedColors) if err := validateTemplateInputs(variables, state.AppOverrides); err != nil { - return err + return "", err } - if _, err := prepareOmarchyV4ThemeDir(outputPath, state); err != nil { - return err + wallpaperDest, err := prepareOmarchyV4ThemeDir(outputPath, state) + if err != nil { + return "", err } w.processOmarchyV4Templates(outputPath, variables, settings, state.AppOverrides, state.ExtendedColors) - return nil + return wallpaperDest, nil } // ApplyTheme generates all theme files and applies the theme to the system. diff --git a/internal/theme/writer_test.go b/internal/theme/writer_test.go index 2da5932..e9ed876 100644 --- a/internal/theme/writer_test.go +++ b/internal/theme/writer_test.go @@ -10,6 +10,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") @@ -238,8 +305,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 0000000..790876d --- /dev/null +++ b/internal/wallpaper/blur.go @@ -0,0 +1,231 @@ +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 +) + +// 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) { + srcInfo, err := os.Stat(srcPath) + if err != nil { + return "", fmt.Errorf("stat image: %w", err) + } + + 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 0000000..db60c30 --- /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") + } +}