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,156 @@
import { describe, expect, it } from "vitest";
import {
BACKGROUND_IMAGE_FILE_EXTENSIONS,
BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES,
BACKGROUND_IMAGE_SURFACE_VARS,
backgroundImageFileExtension,
backgroundImageStyle,
backgroundImageSurfaceAlpha,
defaultBackgroundImageSettings,
normalizeBackgroundImageSettings,
parseCssColorTriplet,
surfaceColorWithAlpha,
} from "@/lib/app/appBackgroundImage";
describe("backgroundImageFileExtension", () => {
it("extracts supported extensions case-insensitively", () => {
expect(backgroundImageFileExtension("wallpaper.PNG")).toBe("png");
expect(backgroundImageFileExtension("photo.Jpeg")).toBe("jpeg");
expect(backgroundImageFileExtension("/path/to/image.webp")).toBe("webp");
});
it("returns null for unsupported or missing extensions", () => {
expect(backgroundImageFileExtension("vector.svg")).toBeNull();
expect(backgroundImageFileExtension("no-extension")).toBeNull();
expect(backgroundImageFileExtension("")).toBeNull();
});
it("exposes the dialog filter extension list", () => {
expect(BACKGROUND_IMAGE_FILE_EXTENSIONS).toContain("png");
expect(BACKGROUND_IMAGE_FILE_EXTENSIONS).toContain("gif");
});
});
describe("normalizeBackgroundImageSettings", () => {
it("falls back to defaults for missing or invalid fields", () => {
expect(normalizeBackgroundImageSettings(undefined)).toEqual(defaultBackgroundImageSettings());
expect(normalizeBackgroundImageSettings(null)).toEqual(defaultBackgroundImageSettings());
expect(normalizeBackgroundImageSettings({})).toEqual(defaultBackgroundImageSettings());
});
it("keeps valid values and clamps out-of-range numbers", () => {
const settings = normalizeBackgroundImageSettings({
filePath: "/data/background-image.png",
fileName: "wall.png",
opacity: 0.75,
blur: 8,
displayMode: "tile",
});
expect(settings).toEqual({ filePath: "/data/background-image.png", fileName: "wall.png", opacity: 0.75, blur: 8, displayMode: "tile" });
const clamped = normalizeBackgroundImageSettings({ opacity: 5, blur: -3 });
expect(clamped.opacity).toBe(1);
expect(clamped.blur).toBe(0);
const belowMin = normalizeBackgroundImageSettings({ opacity: 0.001, blur: 99 });
expect(belowMin.opacity).toBe(0.05);
expect(belowMin.blur).toBe(20);
});
it("falls back to the default display mode for unknown values", () => {
expect(normalizeBackgroundImageSettings({ displayMode: "diagonal" }).displayMode).toBe("fill");
expect(normalizeBackgroundImageSettings({ displayMode: 42 }).displayMode).toBe("fill");
expect(normalizeBackgroundImageSettings({ displayMode: "tile" }).displayMode).toBe("tile");
});
it("treats blank or non-string paths as unset", () => {
expect(normalizeBackgroundImageSettings({ filePath: " " }).filePath).toBeNull();
expect(normalizeBackgroundImageSettings({ filePath: 42 }).filePath).toBeNull();
expect(normalizeBackgroundImageSettings({ fileName: "" }).fileName).toBeNull();
});
});
describe("backgroundImageStyle", () => {
const url = "blob:http://localhost/abc";
it("emits nothing without an object URL", () => {
expect(backgroundImageStyle({ displayMode: "fill", blur: 0 }, null)).toEqual({});
});
it("paints fill mode as cover by default", () => {
expect(backgroundImageStyle({ displayMode: "fill", blur: 0 }, url)).toEqual({
backgroundImage: `url("${url}")`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
});
expect(backgroundImageStyle({ displayMode: "nonsense" as never, blur: 0 }, url).backgroundSize).toBe("cover");
});
it("maps the other display modes to their background properties", () => {
expect(backgroundImageStyle({ displayMode: "fit", blur: 0 }, url)).toMatchObject({ backgroundSize: "contain", backgroundRepeat: "no-repeat" });
expect(backgroundImageStyle({ displayMode: "stretch", blur: 0 }, url)).toMatchObject({ backgroundSize: "100% 100%" });
expect(backgroundImageStyle({ displayMode: "center", blur: 0 }, url)).not.toHaveProperty("backgroundSize");
expect(backgroundImageStyle({ displayMode: "tile", blur: 0 }, url)).toMatchObject({ backgroundRepeat: "repeat" });
});
it("adds blur and a slight scale to hide transparent blur edges", () => {
expect(backgroundImageStyle({ displayMode: "fill", blur: 12 }, url)).toMatchObject({
filter: "blur(12px)",
transform: "scale(1.06)",
});
});
});
describe("backgroundImageSurfaceAlpha", () => {
it("maps the surface-opacity setting 1:1 and clamps", () => {
expect(backgroundImageSurfaceAlpha({ opacity: 0.8 })).toBe(0.8);
expect(backgroundImageSurfaceAlpha({ opacity: 3 })).toBe(1);
expect(backgroundImageSurfaceAlpha({ opacity: -1 })).toBe(0.05);
});
it("exposes the root surface variables that go translucent", () => {
expect(BACKGROUND_IMAGE_SURFACE_VARS).toContain("--background");
expect(BACKGROUND_IMAGE_SURFACE_VARS).toContain("--sidebar");
expect(BACKGROUND_IMAGE_SURFACE_VARS).not.toContain("--card");
expect(BACKGROUND_IMAGE_SURFACE_VARS).not.toContain("--popover");
});
});
describe("parseCssColorTriplet", () => {
it("parses rgb and hex forms, dropping any alpha", () => {
expect(parseCssColorTriplet("rgb(17 18 20)")).toEqual([17, 18, 20]);
expect(parseCssColorTriplet("rgb(17, 18, 20)")).toEqual([17, 18, 20]);
expect(parseCssColorTriplet("rgb(17 18 20 / 0.8)")).toEqual([17, 18, 20]);
expect(parseCssColorTriplet("#131416")).toEqual([19, 20, 22]);
expect(parseCssColorTriplet("#fff")).toEqual([255, 255, 255]);
});
it("returns null for unparsable input", () => {
expect(parseCssColorTriplet("")).toBeNull();
expect(parseCssColorTriplet("red")).toBeNull();
expect(parseCssColorTriplet("#12345")).toBeNull();
});
});
describe("surfaceColorWithAlpha", () => {
it("re-emits a color with the requested alpha", () => {
expect(surfaceColorWithAlpha("rgb(19 20 22)", 0.8)).toBe("rgb(19 20 22 / 0.8)");
expect(surfaceColorWithAlpha("#131416", 0.5)).toBe("rgb(19 20 22 / 0.5)");
expect(surfaceColorWithAlpha("nope", 0.5)).toBeNull();
});
it("keeps wide-gamut and hsl notations instead of converting to rgb", () => {
expect(surfaceColorWithAlpha("oklch(0.19 0.004 285)", 0.43)).toBe("oklch(0.19 0.004 285 / 0.43)");
expect(surfaceColorWithAlpha("oklch(0.19 0.004 285 / 0.9)", 0.2)).toBe("oklch(0.19 0.004 285 / 0.2)");
expect(surfaceColorWithAlpha("hsl(240 10% 3.9%)", 0.6)).toBe("hsl(240 10% 3.9% / 0.6)");
expect(surfaceColorWithAlpha("hsl(240, 10%, 3.9%)", 0.6)).toBe("hsl(240, 10%, 3.9%, 0.6)");
expect(surfaceColorWithAlpha("240 10% 3.9%", 0.6)).toBe("hsl(240 10% 3.9% / 0.6)");
});
});
describe("BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES", () => {
it("is 20 MB", () => {
expect(BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES).toBe(20 * 1024 * 1024);
});
});
@@ -0,0 +1,176 @@
export const BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES = 20 * 1024 * 1024;
export const BACKGROUND_IMAGE_MIN_OPACITY = 0.05;
export const BACKGROUND_IMAGE_MAX_OPACITY = 1;
// Surface opacity: how opaque the app surfaces render over the wallpaper.
// 1 = solid surfaces (no image visible), 0.05 = strongest wallpaper.
export const BACKGROUND_IMAGE_DEFAULT_OPACITY = 0.8;
export const BACKGROUND_IMAGE_MAX_BLUR_PX = 20;
export const BACKGROUND_IMAGE_DEFAULT_BLUR_PX = 0;
export const BACKGROUND_IMAGE_FILE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "bmp", "gif"] as const;
/** Classic desktop-wallpaper presets; default "fill" crops to cover the window. */
export const BACKGROUND_IMAGE_DISPLAY_MODES = ["fill", "fit", "stretch", "center", "tile"] as const;
export type BackgroundImageDisplayMode = (typeof BACKGROUND_IMAGE_DISPLAY_MODES)[number];
export const BACKGROUND_IMAGE_DEFAULT_DISPLAY_MODE: BackgroundImageDisplayMode = "fill";
export type BackgroundImageSettings = {
filePath: string | null;
fileName: string | null;
opacity: number;
blur: number;
displayMode: BackgroundImageDisplayMode;
};
export function defaultBackgroundImageSettings(): BackgroundImageSettings {
return {
filePath: null,
fileName: null,
opacity: BACKGROUND_IMAGE_DEFAULT_OPACITY,
blur: BACKGROUND_IMAGE_DEFAULT_BLUR_PX,
displayMode: BACKGROUND_IMAGE_DEFAULT_DISPLAY_MODE,
};
}
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
}
function normalizeOptionalText(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function normalizeBackgroundImageDisplayMode(value: unknown): BackgroundImageDisplayMode {
return (BACKGROUND_IMAGE_DISPLAY_MODES as readonly string[]).includes(value as string) ? (value as BackgroundImageDisplayMode) : BACKGROUND_IMAGE_DEFAULT_DISPLAY_MODE;
}
export function normalizeBackgroundImageSettings(value: unknown): BackgroundImageSettings {
const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
return {
filePath: normalizeOptionalText(source.filePath),
fileName: normalizeOptionalText(source.fileName),
opacity: clampNumber(source.opacity, BACKGROUND_IMAGE_MIN_OPACITY, BACKGROUND_IMAGE_MAX_OPACITY, BACKGROUND_IMAGE_DEFAULT_OPACITY),
blur: clampNumber(source.blur, 0, BACKGROUND_IMAGE_MAX_BLUR_PX, BACKGROUND_IMAGE_DEFAULT_BLUR_PX),
displayMode: normalizeBackgroundImageDisplayMode(source.displayMode),
};
}
/** Extension (lowercase, no dot) for dialog filters and stored-copy naming; null when the name has none. */
export function backgroundImageFileExtension(fileName: string): string | null {
const match = /\.([a-z0-9]+)$/i.exec(fileName.trim());
if (!match) return null;
const extension = match[1].toLowerCase();
return (BACKGROUND_IMAGE_FILE_EXTENSIONS as readonly string[]).includes(extension) ? extension : null;
}
/**
* Paint style for the wallpaper layer. Modes follow the classic desktop
* wallpaper presets: fill/fit/stretch scale the image, center paints it at
* its natural size, tile repeats it. Blur always scales the layer slightly
* so blurred edges never reveal transparent margins.
*/
export function backgroundImageStyle(settings: Pick<BackgroundImageSettings, "displayMode" | "blur">, objectUrl: string | null): Record<string, string> {
if (!objectUrl) return {};
const style: Record<string, string> = { backgroundImage: `url("${objectUrl}")` };
switch (settings.displayMode) {
case "fit":
style.backgroundSize = "contain";
style.backgroundPosition = "center";
style.backgroundRepeat = "no-repeat";
break;
case "stretch":
style.backgroundSize = "100% 100%";
style.backgroundRepeat = "no-repeat";
break;
case "center":
style.backgroundPosition = "center";
style.backgroundRepeat = "no-repeat";
break;
case "tile":
style.backgroundRepeat = "repeat";
break;
case "fill":
default:
style.backgroundSize = "cover";
style.backgroundPosition = "center";
style.backgroundRepeat = "no-repeat";
break;
}
if (settings.blur > 0) {
style.filter = `blur(${settings.blur}px)`;
style.transform = "scale(1.06)";
}
return style;
}
/**
* Root surface variables that become translucent while a wallpaper is active.
* Card/popover/dialog surfaces stay opaque on purpose: floating panels keep
* their text readable regardless of what the wallpaper shows behind them.
*/
export const BACKGROUND_IMAGE_SURFACE_VARS = ["--background", "--sidebar", "--muted", "--secondary", "--accent", "--dbx-chrome", "--dbx-chrome-muted", "--dbx-content", "--dbx-editor-toolbar", "--dbx-gutter", "--dbx-sidebar-header"] as const;
/** Alpha for the surface variables derived from the surface-opacity setting. */
export function backgroundImageSurfaceAlpha(settings: Pick<BackgroundImageSettings, "opacity">): number {
return clampNumber(settings.opacity, BACKGROUND_IMAGE_MIN_OPACITY, BACKGROUND_IMAGE_MAX_OPACITY, BACKGROUND_IMAGE_DEFAULT_OPACITY);
}
/** Parses "#rgb", "#rrggbb", "rgb(r g b)", "rgb(r, g, b)" and "rgb(r g b / a)" into an rgb triplet; null when unparsable. */
export function parseCssColorTriplet(value: string): [number, number, number] | null {
const trimmed = value.trim();
if (!trimmed) return null;
const rgbMatch = /^rgba?\((\d+)\s*,?\s+(\d+)\s*,?\s+(\d+)(?:\s*\/\s*[\d.]+)?\)$/i.exec(trimmed);
if (rgbMatch) return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])];
const hex = trimmed.startsWith("#") ? trimmed.slice(1) : null;
if (hex && (hex.length === 3 || hex.length === 6)) {
const expand =
hex.length === 3
? hex
.split("")
.map((char) => char + char)
.join("")
: hex;
return [parseInt(expand.slice(0, 2), 16), parseInt(expand.slice(2, 4), 16), parseInt(expand.slice(4, 6), 16)];
}
return null;
}
/**
* Re-emit a surface color with the given alpha, e.g. "rgb(17 18 20 / 0.8)".
* Keeps the source notation when possible: wide-gamut palettes declare their
* tokens in oklch (see the @supports block in globals.css) and converting them
* to rgb would clip them; hsl stays hsl, oklch stays oklch, hex/rgb become rgb.
* Also accepts raw "H S% L%" channel triplets as used by shadcn-style themes.
*/
export function surfaceColorWithAlpha(color: string, alpha: number): string | null {
const trimmed = color.trim();
if (!trimmed) return null;
const fnMatch = /^([a-zA-Z-]+)\(([^()]+)\)$/.exec(trimmed);
if (fnMatch) {
const fn = fnMatch[1].toLowerCase();
const inner = fnMatch[2].trim();
if (fn === "rgb" || fn === "rgba") {
const triplet = parseCssColorTriplet(trimmed);
return triplet ? `rgb(${triplet[0]} ${triplet[1]} ${triplet[2]} / ${alpha})` : null;
}
if (inner.includes(",")) {
const parts = inner.split(",").map((part) => part.trim());
if (parts.length !== 3 || parts.some((part) => !part)) return null;
return `${fn}(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
}
const base = inner.split("/")[0].trim();
if (!base) return null;
return `${fn}(${base} / ${alpha})`;
}
if (trimmed.startsWith("#")) {
const triplet = parseCssColorTriplet(trimmed);
return triplet ? `rgb(${triplet[0]} ${triplet[1]} ${triplet[2]} / ${alpha})` : null;
}
if (/^[\d.]+%?\s+[\d.]+%?\s+[\d.]+%?$/.test(trimmed)) {
return `hsl(${trimmed} / ${alpha})`;
}
return null;
}
+4
View File
@@ -326,6 +326,10 @@ export const loadPinnedTreeNodeIds = forward("loadPinnedTreeNodeIds");
export const savePinnedTreeNodeIds = forward("savePinnedTreeNodeIds");
export const loadEditorSettings = forward("loadEditorSettings");
export const saveEditorSettings = forward("saveEditorSettings");
export const saveBackgroundImage = forward("saveBackgroundImage");
export const clearBackgroundImage = forward("clearBackgroundImage");
export const readBackgroundImage = forward("readBackgroundImage");
export const checkBackgroundImage = forward("checkBackgroundImage");
export const loadOpenTabsState = forward("loadOpenTabsState");
export const saveOpenTabsState = forward("saveOpenTabsState");
export const saveDetachedTabHandoff = forward("saveDetachedTabHandoff");
+22
View File
@@ -1881,6 +1881,28 @@ export async function saveEditorSettings(settings: unknown): Promise<void> {
await saveBrowserAppState("editor_settings", settings);
}
// Background images are desktop-only: the web backend has no local data dir.
export interface BackgroundImageInfo {
storedPath: string;
fileName: string;
}
export async function saveBackgroundImage(_sourcePath: string): Promise<BackgroundImageInfo> {
throw new Error("Background images are only supported in the desktop app");
}
export async function clearBackgroundImage(_storedPath: string): Promise<void> {
throw new Error("Background images are only supported in the desktop app");
}
export async function readBackgroundImage(_storedPath: string): Promise<string> {
throw new Error("Background images are only supported in the desktop app");
}
export async function checkBackgroundImage(_storedPath: string): Promise<boolean> {
throw new Error("Background images are only supported in the desktop app");
}
export async function loadOpenTabsState(): Promise<OpenTabsStatePayload | null> {
const value = await loadBrowserAppState("open_tabs");
if (!value || typeof value !== "object") return null;
+21
View File
@@ -740,6 +740,27 @@ export async function saveEditorSettings(settings: unknown): Promise<void> {
return invoke("save_editor_settings", { settings });
}
export interface BackgroundImageInfo {
storedPath: string;
fileName: string;
}
export async function saveBackgroundImage(sourcePath: string): Promise<BackgroundImageInfo> {
return invoke("save_background_image", { sourcePath });
}
export async function clearBackgroundImage(storedPath: string): Promise<void> {
return invoke("clear_background_image", { storedPath });
}
export async function readBackgroundImage(storedPath: string): Promise<string> {
return invoke("read_background_image", { storedPath });
}
export async function checkBackgroundImage(storedPath: string): Promise<boolean> {
return invoke("check_background_image", { storedPath });
}
export async function loadOpenTabsState(): Promise<OpenTabsStatePayload | null> {
return invoke("load_open_tabs_state");
}
@@ -179,6 +179,18 @@ describe("settingsTransfer", () => {
expect(transferCategoryForKey("notARealSetting")).toBeUndefined();
});
it("round-trips the wallpaper settings under the appearance category", () => {
const settings: EditorSettings = {
...DEFAULT_EDITOR_SETTINGS,
backgroundImage: { ...DEFAULT_EDITOR_SETTINGS.backgroundImage, filePath: "/data/background-image.png", fileName: "wall.png", opacity: 0.42 },
};
const result = parseSettingsTransferFile(serializeSettingsTransfer(settings));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.editorSettings.backgroundImage).toEqual(settings.backgroundImage);
expect(transferCategoryForKey("backgroundImage")).toBe("appearance");
});
it("collects categories in display order without duplicates", () => {
expect(collectTransferCategories(["snippets", "theme", "shortcuts", "fontFamily"])).toEqual(["appearance", "shortcuts", "snippets"]);
});
@@ -1,4 +1,5 @@
import type { EditorSettings } from "@/stores/settingsStore";
import { normalizeBackgroundImageSettings } from "@/lib/app/appBackgroundImage";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit";
import { normalizeCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy";
@@ -11,6 +12,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"uiFontFamily",
"uiScale",
"theme",
"backgroundImage",
"customThemes",
"activeCustomThemeId",
"executeMode",
@@ -131,6 +133,7 @@ function normalizedDraftValue(key: EditorSettingsDraftKey, value: unknown): unkn
if (key === "queryResultMaxRows") return normalizeQueryResultMaxRowsDraft(value);
if (key === "completionTriggerMode") return normalizeCompletionTriggerMode(value);
if (key === "redisKeyTemplates") return normalizeRedisKeyTemplates(value);
if (key === "backgroundImage") return normalizeBackgroundImageSettings(value);
return value;
}
@@ -71,7 +71,7 @@ export function serializeSettingsTransfer(settings: EditorSettings, meta: Settin
const SETTINGS_TRANSFER_CATEGORY_ORDER: readonly SettingsTransferCategoryId[] = ["appearance", "editor", "formatter", "navigation", "data", "shortcuts", "snippets", "other"];
const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readonly EditorSettingsDraftKey[]> = {
appearance: ["fontFamily", "fontSize", "tableFontFamily", "uiFontFamily", "uiScale", "theme", "customThemes", "activeCustomThemeId", "toolbarItems", "updateNotificationsEnabled"],
appearance: ["fontFamily", "fontSize", "tableFontFamily", "uiFontFamily", "uiScale", "theme", "customThemes", "activeCustomThemeId", "backgroundImage", "toolbarItems", "updateNotificationsEnabled"],
editor: [
"executeMode",
"defaultTransactionMode",