mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 08:18:52 +00:00
feat(app): add configurable global background wallpaper
This commit is contained in:
@@ -140,6 +140,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import type { HistoryEntry } from "@/lib/backend/tauri";
|
||||
import { resolveDefaultAiSchema, type AiAction } from "@/lib/ai/ai";
|
||||
import { useBackgroundImage } from "@/composables/useBackgroundImage";
|
||||
import ExternalSqlFileChangeDialog from "@/components/editor/ExternalSqlFileChangeDialog.vue";
|
||||
import { resolveWindowContext } from "@/lib/app/windowContext";
|
||||
import { openDetachedTabWindow } from "@/lib/app/detachedTabWindow";
|
||||
@@ -172,6 +173,7 @@ const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { active: appBackgroundActive, backgroundObjectUrl: appBackgroundObjectUrl, backgroundImageStyle: appBackgroundImageStyle } = useBackgroundImage(settingsStore);
|
||||
const { uiFontFamilyPreview } = useUiFontFamilyPreview();
|
||||
const savedSqlStore = useSavedSqlStore();
|
||||
const promptTemplateStore = usePromptTemplateStore();
|
||||
@@ -3386,6 +3388,9 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<LoginPage v-if="setupRequired || (needsAuth && !authenticated)" :setup-mode="setupRequired" @authenticated="onLoginSuccess" />
|
||||
<div v-show="!setupRequired && (!needsAuth || authenticated)" class="fixed inset-0 h-screen w-screen overflow-hidden">
|
||||
<div v-if="appBackgroundActive && appBackgroundObjectUrl" data-app-background class="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||
<div class="h-full w-full" :style="appBackgroundImageStyle"></div>
|
||||
</div>
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<div class="h-screen w-screen max-w-full min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden" :class="{ 'dbx-desktop-window-frame': drawDesktopWindowFrame }" :style="appUiFontFamilyStyle">
|
||||
<AppToolbar
|
||||
|
||||
@@ -131,6 +131,9 @@ import {
|
||||
forgetWebdavSyncSecretsPassphrase,
|
||||
forgetWebdavSavedPassword,
|
||||
getAppSupportInfo,
|
||||
checkBackgroundImage,
|
||||
clearBackgroundImage,
|
||||
saveBackgroundImage,
|
||||
loadMaxAgentTurns,
|
||||
saveMaxAgentTurns,
|
||||
loadMaxRetries,
|
||||
@@ -194,6 +197,7 @@ import McpAuthorizationStepper from "@/components/settings/McpAuthorizationStepp
|
||||
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
|
||||
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
|
||||
import { APP_CUSTOM_UI_COLOR_DEFS, APP_THEME_PALETTES, type AppCornerStyle, type AppCustomUiColors, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
|
||||
import { BACKGROUND_IMAGE_DISPLAY_MODES, BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES, defaultBackgroundImageSettings, normalizeBackgroundImageDisplayMode, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
|
||||
import {
|
||||
editorSettingsDraftChanged,
|
||||
editorSettingsDraftFromSettings,
|
||||
@@ -420,6 +424,12 @@ const editTableFontFamily = ref(settingsStore.editorSettings.tableFontFamily);
|
||||
const editUiFontFamily = ref(settingsStore.editorSettings.uiFontFamily);
|
||||
const editUiScale = ref(settingsStore.editorSettings.uiScale);
|
||||
const editTheme = ref(settingsStore.editorSettings.theme);
|
||||
const editBackgroundImage = ref<BackgroundImageSettings>(cloneBackgroundImageDraft(settingsStore.editorSettings.backgroundImage));
|
||||
const backgroundImageTransparencyPercent = computed(() => Math.round((1 - editBackgroundImage.value.opacity) * 100));
|
||||
const backgroundImageFileMissing = ref(false);
|
||||
// Set when the draft clears a configured image; the stored copy is only
|
||||
// deleted once the change is actually applied.
|
||||
let pendingBackgroundImageCleanup: string | null = null;
|
||||
const editCustomThemes = ref<CustomTheme[]>([...settingsStore.editorSettings.customThemes]);
|
||||
const editActiveCustomThemeId = ref(settingsStore.editorSettings.activeCustomThemeId);
|
||||
const editDataGridTypeColorSchemes = ref<DataGridTypeColorScheme[]>(cloneDataGridTypeColorSchemes(settingsStore.editorSettings.dataGridTypeColorSchemes));
|
||||
@@ -664,6 +674,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
uiFontFamily: editUiFontFamily.value,
|
||||
uiScale: editUiScale.value,
|
||||
theme: editTheme.value,
|
||||
backgroundImage: editBackgroundImage.value,
|
||||
customThemes: editCustomThemes.value,
|
||||
activeCustomThemeId: editActiveCustomThemeId.value,
|
||||
executeMode: editExecuteMode.value,
|
||||
@@ -766,6 +777,86 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
const editEditorSettingsBase = ref<EditorSettingsDraft>(editorSettingsDraftFromSettings(settingsStore.editorSettings));
|
||||
const hasEditorDraftChanges = computed(() => editorSettingsDraftChanged(currentEditorSettingsDraft(), editEditorSettingsBase.value));
|
||||
|
||||
// --- Background image draft state ---
|
||||
function cloneBackgroundImageDraft(settings: BackgroundImageSettings): BackgroundImageSettings {
|
||||
return JSON.parse(JSON.stringify(settings)) as BackgroundImageSettings;
|
||||
}
|
||||
|
||||
async function checkBackgroundImageFileExists() {
|
||||
const filePath = editBackgroundImage.value.filePath;
|
||||
if (!filePath) {
|
||||
backgroundImageFileMissing.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
backgroundImageFileMissing.value = !(await checkBackgroundImage(filePath));
|
||||
} catch {
|
||||
backgroundImageFileMissing.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickBackgroundImage() {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: t("settings.backgroundImageFilterName"), extensions: ["png", "jpg", "jpeg", "webp", "bmp", "gif"] }],
|
||||
});
|
||||
if (Array.isArray(selected) || typeof selected !== "string" || !selected) return;
|
||||
try {
|
||||
const { stat } = await import("@tauri-apps/plugin-fs");
|
||||
const metadata = await stat(selected);
|
||||
if (metadata.size > BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES) {
|
||||
toast(t("settings.backgroundImageTooLarge"), 4000);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The Rust command re-validates; keep going so it can produce the error.
|
||||
}
|
||||
const info = await saveBackgroundImage(selected);
|
||||
editBackgroundImage.value = {
|
||||
...defaultBackgroundImageSettings(),
|
||||
opacity: editBackgroundImage.value.opacity,
|
||||
blur: editBackgroundImage.value.blur,
|
||||
displayMode: editBackgroundImage.value.displayMode,
|
||||
filePath: info.storedPath,
|
||||
fileName: info.fileName,
|
||||
};
|
||||
backgroundImageFileMissing.value = false;
|
||||
} catch (error) {
|
||||
// Surface every failure (missing command in a stale binary, fs scope, copy
|
||||
// errors): Tauri rejections are plain strings, so String() keeps the detail.
|
||||
console.error("[dbx] background image selection failed", error);
|
||||
toast(`${t("settings.backgroundImageSaveFailed")}: ${error instanceof Error ? error.message : String(error)}`, 6000);
|
||||
}
|
||||
}
|
||||
|
||||
function clearBackgroundImageDraft() {
|
||||
const previousPath = editBackgroundImage.value.filePath;
|
||||
editBackgroundImage.value = {
|
||||
...defaultBackgroundImageSettings(),
|
||||
opacity: editBackgroundImage.value.opacity,
|
||||
blur: editBackgroundImage.value.blur,
|
||||
displayMode: editBackgroundImage.value.displayMode,
|
||||
};
|
||||
backgroundImageFileMissing.value = false;
|
||||
if (previousPath) pendingBackgroundImageCleanup = previousPath;
|
||||
}
|
||||
|
||||
function updateBackgroundImageDisplayMode(mode: unknown) {
|
||||
editBackgroundImage.value = { ...editBackgroundImage.value, displayMode: normalizeBackgroundImageDisplayMode(mode) };
|
||||
}
|
||||
|
||||
// Slider shows surface transparency (100% = wallpaper most visible); the stored
|
||||
// field stays surface opacity, so the value is inverted on the way in/out.
|
||||
function updateBackgroundImageTransparency(transparencyPercent: number) {
|
||||
editBackgroundImage.value = { ...editBackgroundImage.value, opacity: Math.min(1, Math.max(0.05, 1 - transparencyPercent / 100)) };
|
||||
}
|
||||
|
||||
function updateBackgroundImageBlur(value: number) {
|
||||
editBackgroundImage.value = { ...editBackgroundImage.value, blur: Math.min(20, Math.max(0, Math.round(value))) };
|
||||
}
|
||||
|
||||
const snippetDialogOpen = ref(false);
|
||||
const snippetEditingId = ref<string | null>(null);
|
||||
const snippetForm = ref({ label: "", prefix: "", body: "" });
|
||||
@@ -1077,6 +1168,8 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editUiFontFamily.value = settingsStore.editorSettings.uiFontFamily;
|
||||
editUiScale.value = settingsStore.editorSettings.uiScale;
|
||||
editTheme.value = settingsStore.editorSettings.theme;
|
||||
editBackgroundImage.value = cloneBackgroundImageDraft(settingsStore.editorSettings.backgroundImage);
|
||||
pendingBackgroundImageCleanup = null;
|
||||
editCustomThemes.value = [...settingsStore.editorSettings.customThemes];
|
||||
editActiveCustomThemeId.value = settingsStore.editorSettings.activeCustomThemeId;
|
||||
editExecuteMode.value = settingsStore.editorSettings.executeMode;
|
||||
@@ -1499,6 +1592,9 @@ function applyEditorSettingsKeysToRefs(draft: EditorSettingsDraft, keys: readonl
|
||||
case "defaultTransactionMode":
|
||||
editDefaultTransactionMode.value = draft.defaultTransactionMode;
|
||||
break;
|
||||
case "backgroundImage":
|
||||
editBackgroundImage.value = cloneBackgroundImageDraft(draft.backgroundImage);
|
||||
break;
|
||||
default: {
|
||||
// Compile-time exhaustiveness over EDITOR_SETTINGS_DRAFT_KEYS: adding a
|
||||
// draft key without a ref assignment fails the build here.
|
||||
@@ -1632,6 +1728,11 @@ async function persistSettings() {
|
||||
await settingsStore.persistEditorSettings();
|
||||
editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings);
|
||||
}
|
||||
if (pendingBackgroundImageCleanup) {
|
||||
const cleanupPath = pendingBackgroundImageCleanup;
|
||||
pendingBackgroundImageCleanup = null;
|
||||
void clearBackgroundImage(cleanupPath).catch(() => {});
|
||||
}
|
||||
const metadataCacheMaxMemoryMb = normalizeMetadataCacheMemoryMb(editMetadataCacheMaxMemoryMb.value);
|
||||
await settingsStore.updateDesktopSettings({
|
||||
show_tray_icon: editShowTrayIcon.value,
|
||||
@@ -3757,6 +3858,7 @@ onMounted(() => {
|
||||
void refreshWebDavPasswordStatus();
|
||||
checkLayoutDescTruncation();
|
||||
initTruncationObservers();
|
||||
void checkBackgroundImageFileExists();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -6124,6 +6226,55 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isWeb" class="settings-appearance-group" data-background-image-settings>
|
||||
<Label>{{ t("settings.backgroundImage") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.backgroundImageDescription") }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" class="h-8" @click="pickBackgroundImage">
|
||||
{{ t("settings.backgroundImageChoose") }}
|
||||
</Button>
|
||||
<Button v-if="editBackgroundImage.filePath" type="button" variant="outline" size="sm" class="h-8" @click="clearBackgroundImageDraft">
|
||||
{{ t("settings.backgroundImageClear") }}
|
||||
</Button>
|
||||
<span v-if="editBackgroundImage.fileName" class="min-w-0 truncate text-xs text-muted-foreground" :title="editBackgroundImage.fileName">
|
||||
{{ editBackgroundImage.fileName }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-muted-foreground">{{ t("settings.backgroundImageNone") }}</span>
|
||||
</div>
|
||||
<p v-if="backgroundImageFileMissing" class="text-xs text-destructive">
|
||||
{{ t("settings.backgroundImageMissing") }}
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
<Label for="background-image-display-mode">{{ t("settings.backgroundImageDisplayMode") }}</Label>
|
||||
<Select :model-value="editBackgroundImage.displayMode" @update:model-value="updateBackgroundImageDisplayMode">
|
||||
<SelectTrigger id="background-image-display-mode" class="h-8 w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="mode in BACKGROUND_IMAGE_DISPLAY_MODES" :key="mode" :value="mode">
|
||||
{{ t(`settings.backgroundImageMode${mode.charAt(0).toUpperCase()}${mode.slice(1)}`) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label for="background-image-transparency" class="text-xs text-muted-foreground">{{ t("settings.backgroundImageTransparency") }}</Label>
|
||||
<span class="text-xs text-muted-foreground">{{ backgroundImageTransparencyPercent }}%</span>
|
||||
</div>
|
||||
<input id="background-image-transparency" type="range" min="0" max="95" step="1" :value="backgroundImageTransparencyPercent" class="w-full accent-primary" @input="updateBackgroundImageTransparency(Number(($event.target as HTMLInputElement).value))" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label for="background-image-blur" class="text-xs text-muted-foreground">{{ t("settings.backgroundImageBlur") }}</Label>
|
||||
<span class="text-xs text-muted-foreground">{{ editBackgroundImage.blur }}px</span>
|
||||
</div>
|
||||
<input id="background-image-blur" type="range" min="0" max="20" step="1" :value="editBackgroundImage.blur" class="w-full accent-primary" @input="updateBackgroundImageBlur(Number(($event.target as HTMLInputElement).value))" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
|
||||
const composableSource = readFileSync(new URL("../../../composables/useBackgroundImage.ts", import.meta.url), "utf8");
|
||||
const globalsCss = readFileSync(new URL("../../../../src/styles/globals.css", import.meta.url), "utf8");
|
||||
const queryEditorSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
|
||||
const welcomeScreenSource = readFileSync(new URL("../../layout/WelcomeScreen.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("global background image wiring", () => {
|
||||
it("mounts a single app-level background layer behind all content", () => {
|
||||
expect(appSource).toContain("data-app-background");
|
||||
expect(appSource).toMatch(/pointer-events-none fixed inset-0 -z-10/);
|
||||
expect(appSource).toContain("useBackgroundImage(settingsStore)");
|
||||
});
|
||||
|
||||
it("toggles the wallpaper class on <html> and clears CodeMirror backgrounds app-wide", () => {
|
||||
expect(composableSource).toContain('BACKGROUND_IMAGE_ACTIVE_CLASS = "dbx-bg-active"');
|
||||
expect(globalsCss).toContain("html.dbx-bg-active .cm-editor");
|
||||
expect(globalsCss).toContain("html.dbx-bg-active .cm-gutters");
|
||||
});
|
||||
|
||||
it("keeps card and popover surfaces opaque (readability contract)", () => {
|
||||
expect(composableSource).toContain("BACKGROUND_IMAGE_SURFACE_VARS");
|
||||
const appBackground = readFileSync(new URL("../../../lib/app/appBackgroundImage.ts", import.meta.url), "utf8");
|
||||
expect(appBackground).toContain('"--background"');
|
||||
expect(appBackground).toContain('"--sidebar"');
|
||||
expect(appBackground).not.toMatch(/"--card"|"--popover"/);
|
||||
});
|
||||
|
||||
it("removed the scoped per-area mode (editor/welcome no longer render their own layers)", () => {
|
||||
for (const source of [queryEditorSource, welcomeScreenSource]) {
|
||||
expect(source).not.toContain("useBackgroundImage");
|
||||
expect(source).not.toContain("data-query-editor-bg");
|
||||
expect(source).not.toContain("data-welcome-bg");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -6592,6 +6592,23 @@ export default {
|
||||
uiScaleDescription: "Scale the entire desktop UI for high-DPI displays. Changes apply immediately and are restored on next launch.",
|
||||
theme: "Theme",
|
||||
cornerStyle: "Corner style",
|
||||
backgroundImage: "Background image",
|
||||
backgroundImageDescription: "Set an image behind the whole app. Cards and dialogs stay opaque for readability.",
|
||||
backgroundImageChoose: "Choose image…",
|
||||
backgroundImageClear: "Clear",
|
||||
backgroundImageNone: "No image set",
|
||||
backgroundImageTransparency: "Transparency",
|
||||
backgroundImageDisplayMode: "Display mode",
|
||||
backgroundImageModeFill: "Fill",
|
||||
backgroundImageModeFit: "Fit",
|
||||
backgroundImageModeStretch: "Stretch",
|
||||
backgroundImageModeCenter: "Center",
|
||||
backgroundImageModeTile: "Tile",
|
||||
backgroundImageBlur: "Blur",
|
||||
backgroundImageMissing: "The image file no longer exists. Please choose it again.",
|
||||
backgroundImageTooLarge: "The image exceeds the 20 MB size limit.",
|
||||
backgroundImageSaveFailed: "Failed to save the background image.",
|
||||
backgroundImageFilterName: "Image",
|
||||
cornerStyleNone: "Square",
|
||||
cornerStyleSmall: "Small",
|
||||
cornerStyleLarge: "Large",
|
||||
|
||||
@@ -6183,6 +6183,23 @@ export default withEnglishFallback({
|
||||
uiScaleDescription: "Escala toda la interfaz de escritorio para pantallas de alta densidad. Los cambios se aplican al instante y se restauran al volver a abrir.",
|
||||
theme: "Tema",
|
||||
cornerStyle: "Estilo de esquinas",
|
||||
backgroundImage: "Imagen de fondo",
|
||||
backgroundImageDescription: "Establece una imagen detrás de toda la aplicación. Las tarjetas y diálogos permanecen opacos para facilitar la lectura.",
|
||||
backgroundImageChoose: "Elegir imagen…",
|
||||
backgroundImageClear: "Quitar",
|
||||
backgroundImageNone: "Sin imagen",
|
||||
backgroundImageTransparency: "Transparencia",
|
||||
backgroundImageDisplayMode: "Modo de visualización",
|
||||
backgroundImageModeFill: "Rellenar",
|
||||
backgroundImageModeFit: "Ajustar",
|
||||
backgroundImageModeStretch: "Estirar",
|
||||
backgroundImageModeCenter: "Centrar",
|
||||
backgroundImageModeTile: "Mosaico",
|
||||
backgroundImageBlur: "Desenfoque",
|
||||
backgroundImageMissing: "El archivo de imagen ya no existe. Selecciónalo de nuevo.",
|
||||
backgroundImageTooLarge: "La imagen supera el límite de 20 MB.",
|
||||
backgroundImageSaveFailed: "No se pudo guardar la imagen de fondo.",
|
||||
backgroundImageFilterName: "Imagen",
|
||||
cornerStyleNone: "Sin redondeo",
|
||||
cornerStyleSmall: "Pequeñas",
|
||||
cornerStyleLarge: "Grandes",
|
||||
|
||||
@@ -6183,6 +6183,23 @@ export default withEnglishFallback({
|
||||
uiScaleDescription: "Scala l'intera interfaccia utente desktop per display ad alta densità (High-DPI). Le modifiche si applicano immediatamente e vengono ripristinate al prossimo avvio.",
|
||||
theme: "Tema",
|
||||
cornerStyle: "Stile degli angoli",
|
||||
backgroundImage: "Immagine di sfondo",
|
||||
backgroundImageDescription: "Imposta un immagine dietro all'intera app. Le schede e le finestre di dialogo restano opache per leggibilità.",
|
||||
backgroundImageChoose: "Scegli immagine…",
|
||||
backgroundImageClear: "Rimuovi",
|
||||
backgroundImageNone: "Nessuna immagine impostata",
|
||||
backgroundImageTransparency: "Trasparenza",
|
||||
backgroundImageDisplayMode: "Modalità di visualizzazione",
|
||||
backgroundImageModeFill: "Riempi",
|
||||
backgroundImageModeFit: "Adatta",
|
||||
backgroundImageModeStretch: "Stira",
|
||||
backgroundImageModeCenter: "Al centro",
|
||||
backgroundImageModeTile: "Affianca",
|
||||
backgroundImageBlur: "Sfocatura",
|
||||
backgroundImageMissing: "Il file immagine non esiste più. Selezionalo di nuovo.",
|
||||
backgroundImageTooLarge: "L'immagine supera il limite di 20 MB.",
|
||||
backgroundImageSaveFailed: "Salvataggio dell'immagine di sfondo non riuscito.",
|
||||
backgroundImageFilterName: "Immagine",
|
||||
cornerStyleNone: "Senza arrotondamento",
|
||||
cornerStyleSmall: "Piccoli",
|
||||
cornerStyleLarge: "Grandi",
|
||||
|
||||
@@ -6221,6 +6221,23 @@ export default withEnglishFallback({
|
||||
uiScaleDescription: "高DPIディスプレイ向けにデスクトップUI全体をスケーリングします。変更は即座に適用され、次回起動時に復元されます。",
|
||||
theme: "テーマ",
|
||||
cornerStyle: "角の丸み",
|
||||
backgroundImage: "背景画像",
|
||||
backgroundImageDescription: "アプリ全体の背景に画像を設定します。カードとダイアログは読みやすさのため不透明のままです。",
|
||||
backgroundImageChoose: "画像を選択…",
|
||||
backgroundImageClear: "クリア",
|
||||
backgroundImageNone: "画像未設定",
|
||||
backgroundImageTransparency: "透明度",
|
||||
backgroundImageDisplayMode: "表示方法",
|
||||
backgroundImageModeFill: "フィル",
|
||||
backgroundImageModeFit: "合わせる",
|
||||
backgroundImageModeStretch: "引き伸ばし",
|
||||
backgroundImageModeCenter: "中央",
|
||||
backgroundImageModeTile: "タイル",
|
||||
backgroundImageBlur: "ぼかし",
|
||||
backgroundImageMissing: "画像ファイルが見つかりません。再度選択してください。",
|
||||
backgroundImageTooLarge: "画像が 20MB のサイズ制限を超えています。",
|
||||
backgroundImageSaveFailed: "背景画像の保存に失敗しました。",
|
||||
backgroundImageFilterName: "画像",
|
||||
cornerStyleNone: "なし",
|
||||
cornerStyleSmall: "小",
|
||||
cornerStyleLarge: "大",
|
||||
|
||||
@@ -5918,6 +5918,23 @@ export default withEnglishFallback({
|
||||
uiScaleDescription: "고해상도 디스플레이를 위해 전체 데스크톱 UI를 확장합니다. 변경 사항은 즉시 적용되며 다음 실행 시 복원됩니다.",
|
||||
theme: "테마",
|
||||
cornerStyle: "모서리 스타일",
|
||||
backgroundImage: "배경 이미지",
|
||||
backgroundImageDescription: "앱 전체 배경에 이미지를 설정합니다. 카드와 대화상자는 가독성을 위해 불투명하게 유지됩니다.",
|
||||
backgroundImageChoose: "이미지 선택…",
|
||||
backgroundImageClear: "지우기",
|
||||
backgroundImageNone: "이미지 없음",
|
||||
backgroundImageTransparency: "투명도",
|
||||
backgroundImageDisplayMode: "디스플레이 모드",
|
||||
backgroundImageModeFill: "채우기",
|
||||
backgroundImageModeFit: "맞춤",
|
||||
backgroundImageModeStretch: "늘리기",
|
||||
backgroundImageModeCenter: "가운데",
|
||||
backgroundImageModeTile: "타일",
|
||||
backgroundImageBlur: "흐림",
|
||||
backgroundImageMissing: "이미지 파일이 더 이상 존재하지 않습니다. 다시 선택해 주세요.",
|
||||
backgroundImageTooLarge: "이미지가 20MB 크기 제한을 초과합니다.",
|
||||
backgroundImageSaveFailed: "배경 이미지를 저장하지 못했습니다.",
|
||||
backgroundImageFilterName: "이미지",
|
||||
cornerStyleNone: "각형",
|
||||
cornerStyleSmall: "작게",
|
||||
cornerStyleLarge: "크게",
|
||||
|
||||
@@ -6185,6 +6185,23 @@ export default withEnglishFallback({
|
||||
uiScaleDescription: "Dimensione toda a UI do desktop para telas de alta resolução (high-DPI). As alterações são aplicadas imediatamente e restauradas na próxima inicialização.",
|
||||
theme: "Tema",
|
||||
cornerStyle: "Estilo dos cantos",
|
||||
backgroundImage: "Imagem de fundo",
|
||||
backgroundImageDescription: "Define uma imagem atrás de todo o aplicativo. Cartões e diálogos permanecem opacos para legibilidade.",
|
||||
backgroundImageChoose: "Escolher imagem…",
|
||||
backgroundImageClear: "Limpar",
|
||||
backgroundImageNone: "Nenhuma imagem definida",
|
||||
backgroundImageTransparency: "Transparência",
|
||||
backgroundImageDisplayMode: "Modo de exibição",
|
||||
backgroundImageModeFill: "Preencher",
|
||||
backgroundImageModeFit: "Ajustar",
|
||||
backgroundImageModeStretch: "Esticar",
|
||||
backgroundImageModeCenter: "Centralizar",
|
||||
backgroundImageModeTile: "Lado a lado",
|
||||
backgroundImageBlur: "Desfoque",
|
||||
backgroundImageMissing: "O arquivo de imagem não existe mais. Selecione-o novamente.",
|
||||
backgroundImageTooLarge: "A imagem excede o limite de 20 MB.",
|
||||
backgroundImageSaveFailed: "Falha ao salvar a imagem de fundo.",
|
||||
backgroundImageFilterName: "Imagem",
|
||||
cornerStyleNone: "Sem arredondamento",
|
||||
cornerStyleSmall: "Pequenos",
|
||||
cornerStyleLarge: "Grandes",
|
||||
|
||||
@@ -6487,6 +6487,23 @@ export default withEnglishFallback({
|
||||
uiScaleDescription: "Yüksek DPI ekranlar için tüm masaüstü arayüzünü ölçekleyin. Değişiklikler hemen uygulanır ve sonraki açılışta geri yüklenir.",
|
||||
theme: "Tema",
|
||||
cornerStyle: "Köşe biçemi",
|
||||
backgroundImage: "Arka plan resmi",
|
||||
backgroundImageDescription: "Tüm uygulamanın arkasına bir resim ayarlar. Kartlar ve iletişim kutuları okunabilirlik için opak kalır.",
|
||||
backgroundImageChoose: "Resim seç…",
|
||||
backgroundImageClear: "Temizle",
|
||||
backgroundImageNone: "Resim ayarlanmadı",
|
||||
backgroundImageTransparency: "Şeffaflık",
|
||||
backgroundImageDisplayMode: "Görüntü modu",
|
||||
backgroundImageModeFill: "Doldur",
|
||||
backgroundImageModeFit: "Sığdır",
|
||||
backgroundImageModeStretch: "Uzat",
|
||||
backgroundImageModeCenter: "Ortala",
|
||||
backgroundImageModeTile: "Döşe",
|
||||
backgroundImageBlur: "Bulanıklık",
|
||||
backgroundImageMissing: "Resim dosyası artık mevcut değil. Lütfen yeniden seçin.",
|
||||
backgroundImageTooLarge: "Resim 20 MB boyut sınırını aşıyor.",
|
||||
backgroundImageSaveFailed: "Arka plan resmi kaydedilemedi.",
|
||||
backgroundImageFilterName: "Resim",
|
||||
cornerStyleNone: "Köşeli",
|
||||
cornerStyleSmall: "Küçük",
|
||||
cornerStyleLarge: "Büyük",
|
||||
|
||||
@@ -6575,6 +6575,23 @@ export default withEnglishFallback({
|
||||
uiScaleDescription: "按比例缩放整个桌面端界面,适合高清屏;修改后立即生效,并在下次启动时恢复。",
|
||||
theme: "主题",
|
||||
cornerStyle: "圆角样式",
|
||||
backgroundImage: "背景图片",
|
||||
backgroundImageDescription: "为整个应用设置背景图片。卡片和对话框保持不透明以保证可读性。",
|
||||
backgroundImageChoose: "选择图片…",
|
||||
backgroundImageClear: "清除",
|
||||
backgroundImageNone: "未设置图片",
|
||||
backgroundImageTransparency: "透明度",
|
||||
backgroundImageDisplayMode: "显示方式",
|
||||
backgroundImageModeFill: "填充",
|
||||
backgroundImageModeFit: "适应",
|
||||
backgroundImageModeStretch: "拉伸",
|
||||
backgroundImageModeCenter: "居中",
|
||||
backgroundImageModeTile: "平铺",
|
||||
backgroundImageBlur: "模糊",
|
||||
backgroundImageMissing: "图片文件已不存在,请重新选择。",
|
||||
backgroundImageTooLarge: "图片超过 20MB 大小限制。",
|
||||
backgroundImageSaveFailed: "保存背景图片失败。",
|
||||
backgroundImageFilterName: "图片",
|
||||
cornerStyleNone: "无圆角",
|
||||
cornerStyleSmall: "小圆角",
|
||||
cornerStyleLarge: "大圆角",
|
||||
|
||||
@@ -5502,6 +5502,23 @@ export default withEnglishFallback({
|
||||
uiScaleDescription: "按比例縮放整個桌面端介面,適合高 DPI 螢幕;修改後立即生效,並在下次啟動時復原。",
|
||||
theme: "主題",
|
||||
cornerStyle: "圓角樣式",
|
||||
backgroundImage: "背景图片",
|
||||
backgroundImageDescription: "為整個應用設定背景圖片。卡片和對話框保持不透明以確保可讀性。",
|
||||
backgroundImageChoose: "選擇圖片…",
|
||||
backgroundImageClear: "清除",
|
||||
backgroundImageNone: "未設定圖片",
|
||||
backgroundImageTransparency: "透明度",
|
||||
backgroundImageDisplayMode: "顯示方式",
|
||||
backgroundImageModeFill: "填充",
|
||||
backgroundImageModeFit: "適應",
|
||||
backgroundImageModeStretch: "拉伸",
|
||||
backgroundImageModeCenter: "置中",
|
||||
backgroundImageModeTile: "平鋪",
|
||||
backgroundImageBlur: "模糊",
|
||||
backgroundImageMissing: "圖片檔案已不存在,請重新選擇。",
|
||||
backgroundImageTooLarge: "圖片超過 20MB 大小限制。",
|
||||
backgroundImageSaveFailed: "儲存背景圖片失敗。",
|
||||
backgroundImageFilterName: "圖片",
|
||||
cornerStyleNone: "無圓角",
|
||||
cornerStyleSmall: "小圓角",
|
||||
cornerStyleLarge: "大圓角",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_EDITOR_SETTINGS, normalizeEditorSettings } from "@/stores/settingsStore";
|
||||
import { defaultBackgroundImageSettings } from "@/lib/app/appBackgroundImage";
|
||||
|
||||
describe("normalizeEditorSettings backgroundImage", () => {
|
||||
it("merges default background image settings for legacy configs missing the field", () => {
|
||||
const normalized = normalizeEditorSettings({ fontFamily: "'Fira Code', monospace" } as never);
|
||||
expect(normalized.backgroundImage).toEqual(defaultBackgroundImageSettings());
|
||||
expect(normalized.fontFamily).toBe("'Fira Code', monospace");
|
||||
});
|
||||
|
||||
it("keeps configured background image values and clamps invalid ones", () => {
|
||||
const normalized = normalizeEditorSettings({
|
||||
backgroundImage: { filePath: "/data/background-image.jpg", fileName: "a.jpg", opacity: 0.4, blur: 6, displayMode: "tile" },
|
||||
} as never);
|
||||
expect(normalized.backgroundImage).toEqual({ filePath: "/data/background-image.jpg", fileName: "a.jpg", opacity: 0.4, blur: 6, displayMode: "tile" });
|
||||
|
||||
const clamped = normalizeEditorSettings({ backgroundImage: { opacity: 12, blur: -1 } } as never);
|
||||
expect(clamped.backgroundImage.opacity).toBe(1);
|
||||
expect(clamped.backgroundImage.blur).toBe(0);
|
||||
});
|
||||
|
||||
it("drops unknown fields from stored settings (showOn* from the removed scoped mode)", () => {
|
||||
const normalized = normalizeEditorSettings({
|
||||
backgroundImage: { filePath: "/data/background-image.jpg", fileName: "a.jpg", opacity: 0.5, blur: 0, showOnWelcome: true, showOnEditor: true },
|
||||
} as never);
|
||||
expect(normalized.backgroundImage).toEqual({ filePath: "/data/background-image.jpg", fileName: "a.jpg", opacity: 0.5, blur: 0, displayMode: "fill" });
|
||||
});
|
||||
|
||||
it("uses default settings for a brand-new store baseline", () => {
|
||||
expect(DEFAULT_EDITOR_SETTINGS.backgroundImage).toEqual(defaultBackgroundImageSettings());
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { aiConfigToItem, generateId, getConfigKey } from "@/lib/ai/aiConfigList";
|
||||
import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts";
|
||||
import { defaultBackgroundImageSettings, normalizeBackgroundImageSettings, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { setDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
|
||||
@@ -724,6 +725,7 @@ export interface EditorSettings {
|
||||
uiFontFamily: string;
|
||||
uiScale: number;
|
||||
theme: EditorTheme;
|
||||
backgroundImage: BackgroundImageSettings;
|
||||
customThemeColors: CustomThemeColors;
|
||||
customThemes: CustomTheme[];
|
||||
activeCustomThemeId: string;
|
||||
@@ -956,6 +958,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
||||
uiFontFamily: DEFAULT_UI_FONT_FAMILY,
|
||||
uiScale: 1,
|
||||
theme: "app",
|
||||
backgroundImage: defaultBackgroundImageSettings(),
|
||||
customThemeColors: { ...DEFAULT_CUSTOM_THEME_COLORS },
|
||||
customThemes: [...DEFAULT_CUSTOM_THEMES],
|
||||
activeCustomThemeId: "default",
|
||||
@@ -1586,6 +1589,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
||||
clickTableNavigationTarget: normalizeClickTableNavigationTarget(settings.clickTableNavigationTarget),
|
||||
completionTriggerMode: normalizeCompletionTriggerMode(settings.completionTriggerMode),
|
||||
defaultTransactionMode: normalizeDefaultTransactionMode(settings.defaultTransactionMode),
|
||||
backgroundImage: normalizeBackgroundImageSettings(settings.backgroundImage),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2089,6 +2093,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
if (partial.fontSize !== undefined) editorSettings.value.fontSize = partial.fontSize;
|
||||
if (partial.uiFontFamily !== undefined) editorSettings.value.uiFontFamily = normalizeFontFamily(partial.uiFontFamily, DEFAULT_EDITOR_SETTINGS.uiFontFamily);
|
||||
if (partial.uiScale !== undefined) editorSettings.value.uiScale = normalizeUiScale(partial.uiScale);
|
||||
if (partial.backgroundImage !== undefined) editorSettings.value.backgroundImage = normalizeBackgroundImageSettings(partial.backgroundImage);
|
||||
if (partial.theme !== undefined) editorSettings.value.theme = partial.theme;
|
||||
if (partial.customThemeColors !== undefined) {
|
||||
editorSettings.value.customThemeColors = {
|
||||
|
||||
@@ -2762,3 +2762,13 @@ html.theme-vscode .app-tab-scroll.wrap-mode .app-tab-pill[data-active-tab="false
|
||||
background-color: color-mix(in oklab, var(--destructive) 30%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Wallpaper mode (html.dbx-bg-active is toggled by useBackgroundImage):
|
||||
CodeMirror themes paint opaque editor backgrounds via EditorView.theme, so
|
||||
clear them app-wide to let the background image show through. Active line,
|
||||
selection, and search-highlight layers keep their own translucent colors
|
||||
and stay readable on top. */
|
||||
html.dbx-bg-active .cm-editor,
|
||||
html.dbx-bg-active .cm-gutters {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const BACKGROUND_IMAGE_BASE_NAME: &str = "background-image";
|
||||
pub(crate) const BACKGROUND_IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024;
|
||||
const SUPPORTED_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "webp", "bmp", "gif"];
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackgroundImageInfo {
|
||||
pub stored_path: String,
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
fn background_image_extension(source: &Path) -> Result<String, String> {
|
||||
let extension = source
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.ok_or_else(|| "unsupported background image: missing file extension".to_string())?;
|
||||
if SUPPORTED_EXTENSIONS.contains(&extension.as_str()) {
|
||||
Ok(extension)
|
||||
} else {
|
||||
Err(format!("unsupported background image extension: {extension} (allowed: png, jpg, jpeg, webp, bmp, gif)"))
|
||||
}
|
||||
}
|
||||
|
||||
fn background_image_path(data_dir: &Path, extension: &str) -> PathBuf {
|
||||
data_dir.join(format!("{BACKGROUND_IMAGE_BASE_NAME}.{extension}"))
|
||||
}
|
||||
|
||||
fn is_background_image_path(path: &Path) -> bool {
|
||||
path.file_name().and_then(|value| value.to_str()).is_some_and(|name| name.starts_with(BACKGROUND_IMAGE_BASE_NAME))
|
||||
}
|
||||
|
||||
fn remove_stale_background_images(data_dir: &Path, keep_extension: Option<&str>) {
|
||||
for extension in SUPPORTED_EXTENSIONS {
|
||||
if Some(extension) == keep_extension {
|
||||
continue;
|
||||
}
|
||||
let candidate = background_image_path(data_dir, extension);
|
||||
if candidate.exists() {
|
||||
if let Err(error) = std::fs::remove_file(&candidate) {
|
||||
log::warn!("Failed to remove stale background image {}: {error}", candidate.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_background_image_into(data_dir: &Path, source: &Path, extension: &str) -> Result<PathBuf, String> {
|
||||
let metadata =
|
||||
std::fs::metadata(source).map_err(|error| format!("failed to read background image metadata: {error}"))?;
|
||||
if metadata.len() > BACKGROUND_IMAGE_MAX_BYTES {
|
||||
return Err("background image exceeds the 20 MB size limit".to_string());
|
||||
}
|
||||
let target = background_image_path(data_dir, extension);
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| format!("failed to create data dir: {error}"))?;
|
||||
}
|
||||
std::fs::copy(source, &target).map_err(|error| format!("failed to copy background image: {error}"))?;
|
||||
remove_stale_background_images(data_dir, Some(extension));
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Copies the picked image into the resolved data dir so the background
|
||||
/// survives the original file being moved or deleted. The data dir must be
|
||||
/// resolved here (portable mode / DBX_DATA_DIR override) — the frontend only
|
||||
/// knows the default app data dir.
|
||||
#[tauri::command]
|
||||
pub async fn save_background_image(app: AppHandle, source_path: String) -> Result<BackgroundImageInfo, String> {
|
||||
let source = PathBuf::from(&source_path);
|
||||
let extension = background_image_extension(&source)?;
|
||||
let file_name = source.file_name().and_then(|value| value.to_str()).unwrap_or("background image").to_string();
|
||||
let default_data_dir = app.path().app_data_dir().map_err(|error| error.to_string())?;
|
||||
let data_dir = crate::data_dir::resolve_data_dir_with_mode(default_data_dir).data_dir;
|
||||
|
||||
let stored_path =
|
||||
tauri::async_runtime::spawn_blocking(move || copy_background_image_into(&data_dir, &source, &extension))
|
||||
.await
|
||||
.map_err(|error| format!("background image copy task failed: {error}"))??;
|
||||
|
||||
Ok(BackgroundImageInfo { stored_path: stored_path.to_string_lossy().to_string(), file_name })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_background_image(stored_path: String) -> Result<(), String> {
|
||||
let path = PathBuf::from(&stored_path);
|
||||
// The stored path always points at <data_dir>/background-image.<ext>; the
|
||||
// file-name guard keeps a corrupted config from deleting arbitrary files.
|
||||
if !is_background_image_path(&path) {
|
||||
return Err("refusing to clear unexpected background image path".to_string());
|
||||
}
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path).map_err(|error| format!("failed to remove background image: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the stored copy's bytes as base64. Reads go through an app command
|
||||
/// because the fs plugin's granted scopes don't reliably cover the resolved
|
||||
/// data dir (fs:allow-read-file ships without path scopes).
|
||||
#[tauri::command]
|
||||
pub async fn read_background_image(stored_path: String) -> Result<String, String> {
|
||||
let path = PathBuf::from(&stored_path);
|
||||
if !is_background_image_path(&path) {
|
||||
return Err("refusing to read unexpected background image path".to_string());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let bytes = std::fs::read(&path).map_err(|error| format!("failed to read background image: {error}"))?;
|
||||
Ok(BASE64_STANDARD.encode(bytes))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("background image read task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// Existence probe for the settings dialog's stale-file hint.
|
||||
#[tauri::command]
|
||||
pub async fn check_background_image(stored_path: String) -> Result<bool, String> {
|
||||
let path = PathBuf::from(&stored_path);
|
||||
if !is_background_image_path(&path) {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(path.is_file())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(label: &str) -> Self {
|
||||
let path = std::env::temp_dir().join(format!("dbx-tauri-{label}-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_image_extension_accepts_supported_and_rejects_others() {
|
||||
assert_eq!(background_image_extension(Path::new("/tmp/wall.png")).unwrap(), "png");
|
||||
assert_eq!(background_image_extension(Path::new("C:\\pic.JPG")).unwrap(), "jpg");
|
||||
assert!(background_image_extension(Path::new("no-extension")).is_err());
|
||||
assert!(background_image_extension(Path::new("vector.svg")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keeps_current_extension_and_cleans_stale_copies() {
|
||||
let temp = TempDir::new("background-image-copy");
|
||||
let data_dir = temp.0.join("data");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
std::fs::write(background_image_path(&data_dir, "png"), b"old").unwrap();
|
||||
let source = temp.0.join("fresh.jpg");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
|
||||
let stored = copy_background_image_into(&data_dir, &source, "jpg").unwrap();
|
||||
|
||||
assert_eq!(stored, background_image_path(&data_dir, "jpg"));
|
||||
assert!(stored.exists());
|
||||
assert!(!background_image_path(&data_dir, "png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_rejects_oversized_images() {
|
||||
let temp = TempDir::new("background-image-size");
|
||||
let source = temp.0.join("big.png");
|
||||
std::fs::write(&source, vec![0u8; BACKGROUND_IMAGE_MAX_BYTES as usize + 1]).unwrap();
|
||||
|
||||
let error = copy_background_image_into(&temp.0, &source, "png").unwrap_err();
|
||||
|
||||
assert!(error.contains("20 MB"));
|
||||
assert!(!background_image_path(&temp.0, "png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_guard_rejects_unexpected_file_names() {
|
||||
assert!(is_background_image_path(Path::new("/data/background-image.png")));
|
||||
assert!(!is_background_image_path(Path::new("/data/important.db")));
|
||||
assert!(!is_background_image_path(Path::new("/data/dbx.db")));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod agents;
|
||||
pub mod ai;
|
||||
pub mod ai_multi_config;
|
||||
pub mod app_settings;
|
||||
pub mod background_image;
|
||||
pub mod cloud_sync;
|
||||
pub mod config_cmd;
|
||||
pub mod connection;
|
||||
|
||||
@@ -1736,6 +1736,10 @@ pub fn run() {
|
||||
commands::app_settings::save_pinned_tree_node_ids,
|
||||
commands::app_settings::load_mcp_global_policy,
|
||||
commands::app_settings::save_mcp_global_policy,
|
||||
commands::background_image::save_background_image,
|
||||
commands::background_image::clear_background_image,
|
||||
commands::background_image::read_background_image,
|
||||
commands::background_image::check_background_image,
|
||||
commands::mcp_http_server::load_mcp_http_server_settings,
|
||||
commands::mcp_http_server::save_mcp_http_server_settings,
|
||||
commands::mcp_http_server::mcp_http_server_status,
|
||||
|
||||
Reference in New Issue
Block a user