feat(app): add configurable global background wallpaper

This commit is contained in:
miracle authored and GitHub committed 2026-09-08 11:19:24 +08:00
1 parent 5fb667716f
commit 39aa663ebf
28 files changed
+1240 -1

No files matched your search

@@ -0,0 +1,114 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { reactive } from "vue";
import { BACKGROUND_IMAGE_ACTIVE_CLASS, useBackgroundImage } from "@/composables/useBackgroundImage";
import { useTheme } from "@/composables/useTheme";
import { defaultBackgroundImageSettings, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
const readBackgroundImageMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/backend/api", () => ({
readBackgroundImage: readBackgroundImageMock,
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
function settingsWith(overrides: Partial<BackgroundImageSettings>) {
const base = defaultBackgroundImageSettings();
return { editorSettings: reactive({ backgroundImage: { ...base, ...overrides } }) } as Parameters<typeof useBackgroundImage>[0];
}
describe("useBackgroundImage", () => {
beforeEach(() => {
vi.resetModules();
readBackgroundImageMock.mockReset();
(globalThis as Record<string, unknown>).__TAURI_INTERNALS__ = {};
document.documentElement.className = "";
document.documentElement.removeAttribute("style");
});
it("reports no active background without a configured file", () => {
const bg = useBackgroundImage(settingsWith({}));
expect(bg.backgroundObjectUrl.value).toBeNull();
expect(bg.active.value).toBe(false);
expect(document.documentElement.classList.contains(BACKGROUND_IMAGE_ACTIVE_CLASS)).toBe(false);
});
it("loads the configured file into an object URL and toggles the global wallpaper class", async () => {
const createObjectURL = vi.fn(() => "blob:bg-1");
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, configurable: true, writable: true });
Object.defineProperty(URL, "revokeObjectURL", { value: revokeObjectURL, configurable: true, writable: true });
readBackgroundImageMock.mockResolvedValue("aGVsbG8=");
const bg = useBackgroundImage(settingsWith({ filePath: "/data/background-image.png", fileName: "wall.png" }));
await vi.waitFor(() => expect(bg.backgroundObjectUrl.value).toBe("blob:bg-1"));
expect(bg.active.value).toBe(true);
expect(document.documentElement.classList.contains(BACKGROUND_IMAGE_ACTIVE_CLASS)).toBe(true);
});
it("falls back to no background when the file cannot be read", async () => {
readBackgroundImageMock.mockRejectedValue(new Error("gone"));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const bg = useBackgroundImage(settingsWith({ filePath: "/data/background-image.png" }));
await vi.waitFor(() => expect(readBackgroundImageMock).toHaveBeenCalled());
await Promise.resolve();
expect(bg.backgroundObjectUrl.value).toBeNull();
expect(bg.active.value).toBe(false);
expect(document.documentElement.classList.contains(BACKGROUND_IMAGE_ACTIVE_CLASS)).toBe(false);
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it("tints custom palette surface colors from the custom color settings while active", async () => {
const theme = useTheme();
const originalMode = theme.themeMode.value;
const originalPalette = theme.themePalette.value;
const originalColors = { ...theme.customUiColors.value };
theme.setThemeMode("light");
theme.setCustomUiColors({ ...originalColors, background: "#123456", sidebar: "#654321" });
theme.setThemePalette("custom");
const createObjectURL = vi.fn(() => "blob:bg-custom");
Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, configurable: true, writable: true });
readBackgroundImageMock.mockResolvedValue("aGVsbG8=");
try {
const bg = useBackgroundImage(settingsWith({ filePath: "/data/background-image.png", opacity: 0.5 }));
await vi.waitFor(() => expect(bg.active.value).toBe(true));
await vi.waitFor(() => {
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(18 52 86 / 0.5)");
expect(document.documentElement.style.getPropertyValue("--sidebar")).toBe("rgb(101 67 33 / 0.5)");
});
} finally {
theme.setCustomUiColors(originalColors);
theme.setThemeMode(originalMode);
theme.setThemePalette(originalPalette);
}
});
it("re-emits custom palette inline surface colors when the wallpaper is inactive", async () => {
const theme = useTheme();
const originalMode = theme.themeMode.value;
const originalPalette = theme.themePalette.value;
const originalColors = { ...theme.customUiColors.value };
theme.setThemeMode("light");
theme.setCustomUiColors({ ...originalColors, background: "#123456", sidebar: "#654321" });
theme.setThemePalette("custom");
try {
// applyCustomUiColors has just written these inline values for the custom palette.
document.documentElement.style.setProperty("--background", "rgb(18 52 86)");
document.documentElement.style.setProperty("--sidebar", "rgb(101 67 33)");
const bg = useBackgroundImage(settingsWith({}));
await vi.waitFor(() => {
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(18 52 86)");
expect(document.documentElement.style.getPropertyValue("--sidebar")).toBe("rgb(101 67 33)");
});
expect(bg.active.value).toBe(false);
} finally {
theme.setCustomUiColors(originalColors);
theme.setThemeMode(originalMode);
theme.setThemePalette(originalPalette);
}
});
});
@@ -0,0 +1,140 @@
import { computed, ref, watch, watchPostEffect, type ComputedRef, type Ref } from "vue";
import { BACKGROUND_IMAGE_SURFACE_VARS, backgroundImageStyle, backgroundImageSurfaceAlpha, surfaceColorWithAlpha, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
import { APP_CUSTOM_UI_COLOR_DEFS, appCustomUiColorValue, deriveCustomUiColors } from "@/lib/app/appTheme";
import { readBackgroundImage } from "@/lib/backend/api";
import { useTheme } from "@/composables/useTheme";
import type { EditorSettings } from "@/stores/settingsStore";
type SettingsStoreLike = { editorSettings: EditorSettings };
export const BACKGROUND_IMAGE_ACTIVE_CLASS = "dbx-bg-active";
const SURFACE_VAR_NAMES = new Set<string>(BACKGROUND_IMAGE_SURFACE_VARS);
const backgroundObjectUrl = ref<string | null>(null);
let loadedFilePath: string | null = null;
let loadEpoch = 0;
let warnedOnce = false;
function base64ToBlob(base64: string): Blob {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes]);
}
async function loadBackgroundObjectUrl(settingsStore: SettingsStoreLike) {
const filePath = settingsStore.editorSettings.backgroundImage.filePath;
if (!filePath) {
loadedFilePath = filePath;
setAndRevokeObjectUrl(null);
return;
}
const epoch = ++loadEpoch;
try {
const base64 = await readBackgroundImage(filePath);
if (epoch !== loadEpoch) return;
loadedFilePath = filePath;
setAndRevokeObjectUrl(URL.createObjectURL(base64ToBlob(base64)));
} catch (error) {
if (epoch !== loadEpoch) return;
loadedFilePath = filePath;
setAndRevokeObjectUrl(null);
if (!warnedOnce) {
warnedOnce = true;
console.warn("[dbx] failed to load background image; falling back to plain surfaces", error);
}
}
}
function setAndRevokeObjectUrl(next: string | null) {
if (backgroundObjectUrl.value && backgroundObjectUrl.value !== next) {
URL.revokeObjectURL(backgroundObjectUrl.value);
}
backgroundObjectUrl.value = next;
}
export interface BackgroundImageComposable {
backgroundObjectUrl: Ref<string | null>;
backgroundSettings: ComputedRef<BackgroundImageSettings>;
active: ComputedRef<boolean>;
backgroundImageStyle: ComputedRef<Record<string, string>>;
}
export function useBackgroundImage(settingsStore: SettingsStoreLike): BackgroundImageComposable {
const backgroundSettings = computed(() => settingsStore.editorSettings.backgroundImage);
const { isDark, themePalette, activeCustomUiColors, cornerStyle } = useTheme();
void loadBackgroundObjectUrl(settingsStore);
watch(
() => backgroundSettings.value.filePath,
(filePath) => {
if (filePath === loadedFilePath) return;
void loadBackgroundObjectUrl(settingsStore);
},
);
const active = computed(() => Boolean(backgroundObjectUrl.value));
// Keeps the palette surface colors in sync while the wallpaper is active:
// clear the inline overrides first so the computed values below come from
// the freshly applied palette classes, then re-emit them with alpha. The
// theme refs read below are the reactive deps: any palette / mode / custom
// color change re-runs this effect right after applyTheme rewrote the
// class-based (and custom-palette inline) variables. Custom palettes own
// every surface variable as an inline style, so clearing them would fall
// back to the palette class colors; their base values are resolved from the
// custom color settings instead of the computed style.
const customSurfaceBaseColors = (): Map<string, string> => {
const base = new Map<string, string>();
if (themePalette.value !== "custom") return base;
const colors = activeCustomUiColors.value;
for (const def of APP_CUSTOM_UI_COLOR_DEFS) {
if (SURFACE_VAR_NAMES.has(def.varName)) {
base.set(def.varName, appCustomUiColorValue(colors[def.key]).color);
}
}
for (const [name, value] of Object.entries(deriveCustomUiColors(colors))) {
if (SURFACE_VAR_NAMES.has(name)) base.set(name, value);
}
return base;
};
watchPostEffect(() => {
if (typeof document === "undefined") return;
void isDark.value;
void themePalette.value;
void activeCustomUiColors.value;
void cornerStyle.value;
const doc = document.documentElement;
const alpha = backgroundImageSurfaceAlpha(backgroundSettings.value);
const isActive = active.value;
const customBase = customSurfaceBaseColors();
for (const varName of BACKGROUND_IMAGE_SURFACE_VARS) {
doc.style.removeProperty(varName);
}
doc.classList.toggle(BACKGROUND_IMAGE_ACTIVE_CLASS, isActive);
if (!isActive) {
// Re-emit the custom palette values the clear above removed.
for (const [varName, color] of customBase) {
doc.style.setProperty(varName, color);
}
return;
}
const computedStyle = getComputedStyle(doc);
for (const varName of BACKGROUND_IMAGE_SURFACE_VARS) {
const base = customBase.get(varName) ?? computedStyle.getPropertyValue(varName);
const tinted = surfaceColorWithAlpha(base, alpha);
if (tinted) doc.style.setProperty(varName, tinted);
}
});
return {
backgroundObjectUrl,
backgroundSettings,
active,
backgroundImageStyle: computed(() => backgroundImageStyle(backgroundSettings.value, backgroundObjectUrl.value)),
};
}