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

@@ -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");
}
});
});