From e42e0711a9bf3817de49fc221b9865bda4640cb5 Mon Sep 17 00:00:00 2001 From: t8y2 Date: Tue, 22 Sep 2026 14:26:09 +0800 Subject: [PATCH] fix(settings): coalesce appearance option previews Closes #9914 --- .../editor/EditorSettingsDialog.vue | 69 ++++++++++++++----- ...ditorSettingsDialog.previewCleanup.spec.ts | 13 +++- .../common/__tests__/delayedPreview.spec.ts | 52 ++++++++++++++ apps/desktop/src/lib/common/delayedPreview.ts | 30 ++++++++ 4 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/lib/common/__tests__/delayedPreview.spec.ts create mode 100644 apps/desktop/src/lib/common/delayedPreview.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index cd57d8569d..a45e2738e4 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -282,6 +282,7 @@ import { buildFontFamilyOptions, displayFontFamily, isPresetFontFamily, loadSyst import { buildAppSupportInfoRows, formatAppSupportInfoForClipboard, type AppSupportInfoLabels } from "@/lib/app/supportInfo"; import { useUiFontFamilyPreview } from "@/composables/useUiFontFamilyPreview"; import { useMeasuredWidth } from "@/composables/useMeasuredWidth"; +import { createDelayedPreview } from "@/lib/common/delayedPreview"; import { DateTimePatterns, normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter"; import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE } from "@/lib/dataGrid/paginationPageSize"; import { MAX_QUERY_RESULT_MAX_ROWS } from "@/lib/dataGrid/queryResultRowLimit"; @@ -304,27 +305,56 @@ const promptTemplateStore = usePromptTemplateStore(); const tunnelProfileStore = useTunnelProfileStore(); const { isDark, themeMode, themePalette, activeCustomUiColors, cornerStyle, setThemeMode, setThemePalette, previewThemePalette, clearThemePalettePreview, setCustomUiColors, resetCustomUiColors, setCornerStyle } = useTheme(); const { previewUiFontFamily, clearUiFontFamilyPreview } = useUiFontFamilyPreview(); +const APPEARANCE_POINTER_PREVIEW_DELAY_MS = 80; +const themePaletteOptionPreview = createDelayedPreview(previewThemePalette, APPEARANCE_POINTER_PREVIEW_DELAY_MS); +const uiFontOptionPreview = createDelayedPreview(previewUiFontFamily, APPEARANCE_POINTER_PREVIEW_DELAY_MS); +const localeOptionPreview = createDelayedPreview((value) => void previewLocale(value), APPEARANCE_POINTER_PREVIEW_DELAY_MS); function updateCustomUiColor(key: keyof AppCustomUiColors, value: string) { setCustomUiColors({ ...activeCustomUiColors.value, [key]: value }); } function onThemePaletteSelect(value: unknown) { - if (typeof value === "string") setThemePalette(value as AppThemePalette); + if (typeof value !== "string") return; + themePaletteOptionPreview.cancel(); + setThemePalette(value as AppThemePalette); } function onThemePaletteOpenChange(open: boolean) { - if (!open) clearThemePalettePreview(); + if (!open) clearThemePaletteOptionPreview(); +} + +function scheduleThemePalettePreview(value: AppThemePalette) { + themePaletteOptionPreview.schedule(value); +} + +function previewThemePaletteOption(value: AppThemePalette) { + themePaletteOptionPreview.runNow(value); +} + +function clearThemePaletteOptionPreview() { + themePaletteOptionPreview.cancel(); + clearThemePalettePreview(); +} + +function scheduleUiFontOptionPreview(value: string) { + uiFontOptionPreview.schedule(value); } function previewUiFontOption(value: string | undefined) { - if (value) previewUiFontFamily(value); + if (value) uiFontOptionPreview.runNow(value); } function restoreUiFontFamilyPreview() { + uiFontOptionPreview.cancel(); previewUiFontFamily(editUiFontFamily.value); } +function clearUiFontOptionPreview() { + uiFontOptionPreview.cancel(); + clearUiFontFamilyPreview(); +} + function onUiFontFamilyOpenChange(open: boolean) { if (open) { void loadSystemFontOptions(); @@ -334,10 +364,15 @@ function onUiFontFamilyOpenChange(open: boolean) { } function previewLocaleOption(locale: Locale) { - void previewLocale(locale); + localeOptionPreview.runNow(locale); +} + +function scheduleLocaleOptionPreview(locale: Locale) { + localeOptionPreview.schedule(locale); } function restoreLocaleOptionPreview() { + localeOptionPreview.cancel(); void restoreLocalePreview(); } @@ -1857,8 +1892,8 @@ watch( editSidebarTablePageSize.value = settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE; } else { historyRetention.discard(); - clearThemePalettePreview(); - clearUiFontFamilyPreview(); + clearThemePaletteOptionPreview(); + clearUiFontOptionPreview(); restoreLocaleOptionPreview(); } }, @@ -1869,8 +1904,8 @@ watch( () => settingsStore.settingsPageActive, (active) => { if (isSettingsPage.value && !active) { - clearThemePalettePreview(); - clearUiFontFamilyPreview(); + clearThemePaletteOptionPreview(); + clearUiFontOptionPreview(); restoreLocaleOptionPreview(); } }, @@ -2590,7 +2625,7 @@ function onTableFontFamilyChange(v: any) { function onUiFontFamilyChange(v: any) { if (typeof v === "string") { editUiFontFamily.value = v; - previewUiFontFamily(v); + uiFontOptionPreview.runNow(v); } } @@ -2656,7 +2691,9 @@ function onDeleteConnectionTabHandlingModeChange(v: any) { } function onLocaleChange(v: any) { - if (typeof v === "string") void setLocale(v as Locale); + if (typeof v !== "string") return; + localeOptionPreview.cancel(); + void setLocale(v as Locale); } function onUiScaleChange(value: unknown) { @@ -4476,8 +4513,8 @@ onMounted(() => { }); onUnmounted(() => { - clearThemePalettePreview(); - clearUiFontFamilyPreview(); + clearThemePaletteOptionPreview(); + clearUiFontOptionPreview(); restoreLocaleOptionPreview(); cleanupTableColumnTemplatePointerDrag(); cleanupTruncationObservers(); @@ -6555,7 +6592,7 @@ onUnmounted(() => { - +
{{ locale.flag }} @@ -6587,8 +6624,8 @@ onUnmounted(() => { - - + +
{{ option.label }} @@ -6661,7 +6698,7 @@ onUnmounted(() => { :content-style="{ fontFamily: editUiFontFamily || DEFAULT_UI_FONT_FAMILY }" @update:model-value="onUiFontFamilyChange" @update:open="onUiFontFamilyOpenChange" - @option-hover="previewUiFontOption" + @option-hover="scheduleUiFontOptionPreview" @option-highlight="previewUiFontOption" @option-leave="restoreUiFontFamilyPreview" > diff --git a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.previewCleanup.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.previewCleanup.spec.ts index 96c7930d39..2716496296 100644 --- a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.previewCleanup.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.previewCleanup.spec.ts @@ -20,10 +20,19 @@ describe("EditorSettingsDialog preview cleanup", () => { expect(dialogSource).toContain("restoreLocaleOptionPreview();"); }); - it("keeps language hover preview while scale stays an unapplied draft", () => { + it("coalesces pointer previews while keeping keyboard previews immediate", () => { expect(dialogSource).toContain('@update:open="onLocaleOpenChange"'); expect(dialogSource).toContain('@pointerleave="restoreLocaleOptionPreview"'); - expect(dialogSource).toContain('@pointerenter="previewLocaleOption(locale.value)"'); + expect(dialogSource).toContain('@pointerenter="scheduleLocaleOptionPreview(locale.value)"'); + expect(dialogSource).toContain('@focus="previewLocaleOption(locale.value)"'); + expect(dialogSource).toContain('@pointerenter="scheduleThemePalettePreview(option.value)"'); + expect(dialogSource).toContain('@focus="previewThemePaletteOption(option.value)"'); + expect(dialogSource).toContain('@option-hover="scheduleUiFontOptionPreview"'); + expect(dialogSource).toContain('@option-highlight="previewUiFontOption"'); + expect(dialogSource).toContain("createDelayedPreview"); + }); + + it("keeps scale as an unapplied draft", () => { expect(dialogSource).toContain('@update:model-value="onUiScaleChange"'); expect(dialogSource).not.toContain("useUiScalePreview"); expect(dialogSource).not.toContain("previewUiScaleOption"); diff --git a/apps/desktop/src/lib/common/__tests__/delayedPreview.spec.ts b/apps/desktop/src/lib/common/__tests__/delayedPreview.spec.ts new file mode 100644 index 0000000000..e0548de9ba --- /dev/null +++ b/apps/desktop/src/lib/common/__tests__/delayedPreview.spec.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDelayedPreview } from "../delayedPreview"; + +describe("createDelayedPreview", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("coalesces rapid pointer previews to the latest value", () => { + const apply = vi.fn(); + const preview = createDelayedPreview(apply, 80); + + preview.schedule("first"); + preview.schedule("second"); + preview.schedule("last"); + vi.advanceTimersByTime(79); + + expect(apply).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + + expect(apply).toHaveBeenCalledTimes(1); + expect(apply).toHaveBeenCalledWith("last"); + }); + + it("cancels pending work before an immediate keyboard or selection preview", () => { + const apply = vi.fn(); + const preview = createDelayedPreview(apply, 80); + + preview.schedule("pointer"); + preview.runNow("keyboard"); + vi.runAllTimers(); + + expect(apply).toHaveBeenCalledTimes(1); + expect(apply).toHaveBeenCalledWith("keyboard"); + }); + + it("does not apply a preview after cleanup", () => { + const apply = vi.fn(); + const preview = createDelayedPreview(apply, 80); + + preview.schedule("stale"); + preview.cancel(); + vi.runAllTimers(); + + expect(apply).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/lib/common/delayedPreview.ts b/apps/desktop/src/lib/common/delayedPreview.ts new file mode 100644 index 0000000000..959356c1e9 --- /dev/null +++ b/apps/desktop/src/lib/common/delayedPreview.ts @@ -0,0 +1,30 @@ +export interface DelayedPreview { + schedule(value: T): void; + runNow(value: T): void; + cancel(): void; +} + +export function createDelayedPreview(apply: (value: T) => void, delayMs: number): DelayedPreview { + let timer: ReturnType | undefined; + + function cancel() { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + } + + return { + schedule(value) { + cancel(); + timer = setTimeout(() => { + timer = undefined; + apply(value); + }, delayMs); + }, + runNow(value) { + cancel(); + apply(value); + }, + cancel, + }; +}