feat(desktop): add global UI zoom settings and shortcuts

This commit is contained in:
t8y2 2026-05-28 17:43:06 +08:00
parent d35aeaa08c
commit f7f5758c85
14 changed files with 255 additions and 28 deletions

View File

@ -43,8 +43,11 @@ import {
isModRShortcut,
isNewQueryShortcut,
isObjectSourceSaveShortcutTarget,
isResetZoomShortcut,
isRefreshDataShortcut,
isSaveShortcut,
isZoomInShortcut,
isZoomOutShortcut,
} from "@/lib/keyboardShortcuts";
import { isPreviewTab } from "@/lib/tabPresentation";
import { supportsSqlFileExecution } from "@/lib/databaseCapabilities";
@ -224,6 +227,47 @@ const saveSqlFolders = computed(() => {
return tab ? savedSqlStore.listFolders(tab.connectionId) : [];
});
async function applyUiScale(scale: number) {
if (!isDesktop) return;
try {
const { getCurrentWebview } = await import("@tauri-apps/api/webview");
await getCurrentWebview().setZoom(scale);
} catch (error) {
console.warn("[DBX] Failed to apply UI scale", { scale, error });
}
}
function setGlobalUiScale(scale: number) {
settingsStore.updateEditorSettings({ uiScale: scale });
}
function zoomInUi() {
setGlobalUiScale(settingsStore.editorSettings.uiScale + 0.1);
}
function zoomOutUi() {
setGlobalUiScale(settingsStore.editorSettings.uiScale - 0.1);
}
function resetUiZoom() {
setGlobalUiScale(1);
}
function isGlobalUiZoomTarget(target: EventTarget | null): target is Element {
if (!(target instanceof Element)) return false;
if (target.closest("[data-query-editor-root], [data-cell-detail-editor-root], [data-object-source-editor]")) {
return true;
}
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return false;
}
return !target.closest("[contenteditable='true']");
}
watch(
() => queryStore.activeTabId,
(id) => {
@ -242,6 +286,14 @@ watch(
},
);
watch(
() => settingsStore.editorSettings.uiScale,
(scale) => {
void applyUiScale(scale);
},
{ immediate: true },
);
function toggleAiPanel() {
showAiPanel.value = !showAiPanel.value;
localStorage.setItem("dbx-ai-panel-open", String(showAiPanel.value));
@ -667,6 +719,26 @@ function handleKeydown(e: KeyboardEvent) {
e.stopPropagation();
return;
}
if (isDesktop && isGlobalUiZoomTarget(e.target)) {
if (isZoomInShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
zoomInUi();
return;
}
if (isZoomOutShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
zoomOutUi();
return;
}
if (isResetZoomShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
resetUiZoom();
return;
}
}
if (isDesktop && isBrowserReloadShortcut(e)) {
e.preventDefault();
e.stopPropagation();
@ -733,6 +805,7 @@ onMounted(async () => {
aiPanelReady.value = true;
});
applyTheme();
void applyUiScale(settingsStore.editorSettings.uiScale);
window.addEventListener("keydown", handleKeydown);
window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
if (isDesktop) {

View File

@ -87,6 +87,7 @@ const emit = defineEmits<{
// Local edit state
const editFontFamily = ref(settingsStore.editorSettings.fontFamily);
const editFontSize = ref(settingsStore.editorSettings.fontSize);
const editUiScale = ref(settingsStore.editorSettings.uiScale);
const editTheme = ref(settingsStore.editorSettings.theme);
const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
@ -105,6 +106,7 @@ const redisScanPageSizeOptions = [200, 1000, 5000, 10000];
const systemFonts = ref<string[]>([]);
const systemFontsLoading = ref(false);
const systemFontsLoaded = ref(false);
const uiScaleOptions = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
// --- Snippet state ---
const editSnippets = ref<SqlSnippet[]>(settingsStore.editorSettings.snippets.map((s) => ({ ...s })));
@ -219,6 +221,7 @@ watch(
if (open) {
editFontFamily.value = settingsStore.editorSettings.fontFamily;
editFontSize.value = settingsStore.editorSettings.fontSize;
editUiScale.value = settingsStore.editorSettings.uiScale;
editTheme.value = settingsStore.editorSettings.theme;
editExecuteMode.value = settingsStore.editorSettings.executeMode;
editWordWrap.value = settingsStore.editorSettings.wordWrap;
@ -256,6 +259,7 @@ function hasChanges(): boolean {
return (
editFontFamily.value !== settingsStore.editorSettings.fontFamily ||
editFontSize.value !== settingsStore.editorSettings.fontSize ||
editUiScale.value !== settingsStore.editorSettings.uiScale ||
editTheme.value !== settingsStore.editorSettings.theme ||
editExecuteMode.value !== settingsStore.editorSettings.executeMode ||
editWordWrap.value !== settingsStore.editorSettings.wordWrap ||
@ -280,6 +284,7 @@ async function applySettings() {
settingsStore.updateEditorSettings({
fontFamily: editFontFamily.value,
fontSize: editFontSize.value,
uiScale: editUiScale.value,
theme: editTheme.value,
executeMode: editExecuteMode.value,
wordWrap: editWordWrap.value,
@ -304,6 +309,7 @@ async function applySettings() {
function resetDefaults() {
editFontFamily.value = DEFAULT_EDITOR_SETTINGS.fontFamily;
editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize;
editUiScale.value = DEFAULT_EDITOR_SETTINGS.uiScale;
editTheme.value = DEFAULT_EDITOR_SETTINGS.theme;
editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
@ -1106,6 +1112,31 @@ watch(
</section>
<section v-else-if="activeSettingsTab === 'appearance'" class="flex flex-col gap-5 py-2">
<div class="space-y-2">
<Label>{{ t("settings.uiScale") }}</Label>
<Select
:model-value="String(editUiScale)"
@update:model-value="
(value: any) => {
const next = Number(value);
if (Number.isFinite(next)) editUiScale = next;
}
"
>
<SelectTrigger class="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="scale in uiScaleOptions" :key="scale" :value="String(scale)">
{{ Math.round(scale * 100) }}%
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">{{ t("settings.uiScaleDescription") }}</p>
</div>
<Separator />
<div class="space-y-2">
<Label>{{ t("settings.appLayout") }}</Label>
<div class="grid grid-cols-2 gap-2">

View File

@ -383,34 +383,6 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
]),
) ?? [],
codeMirrorKeymap.of([
{
key: "Mod-=",
run: () => {
zoomIn();
return true;
},
},
{
key: "Mod-+",
run: () => {
zoomIn();
return true;
},
},
{
key: "Mod--",
run: () => {
zoomOut();
return true;
},
},
{
key: "Mod-0",
run: () => {
resetZoom();
return true;
},
},
{
key: shortcutToCodeMirrorKey(shortcuts.acceptCompletion),
run: (view) => codeMirrorAcceptCompletion?.(view) ?? false,

View File

@ -1386,6 +1386,9 @@ export default {
noFontsFound: "No fonts found",
useCustomFont: "Use “{font}”",
fontSize: "Font Size",
uiScale: "UI Scale",
uiScaleDescription:
"Scale the entire desktop UI for high-DPI displays. Changes apply immediately and are restored on next launch.",
theme: "Theme",
selectTheme: "Select theme...",
followAppTheme: "Follow app theme",
@ -1480,6 +1483,9 @@ export default {
shortcutNewQuery: "New query",
shortcutCloseTab: "Close tab",
shortcutFocusSearch: "Focus search",
shortcutZoomInUi: "Zoom in UI",
shortcutZoomOutUi: "Zoom out UI",
shortcutResetUiZoom: "Reset UI zoom",
shortcutRefreshData: "Refresh data",
shortcutToggleTranspose: "Toggle transpose view",
shortcutCancelSearch: "Cancel search",

View File

@ -1280,6 +1280,9 @@ export default {
noFontsFound: "No se encontraron fuentes",
useCustomFont: "Usar “{font}”",
fontSize: "Tamaño de fuente",
uiScale: "Escala de interfaz",
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",
selectTheme: "Seleccionar tema...",
appLayout: "Diseño de la interfaz",
@ -1373,6 +1376,9 @@ export default {
shortcutNewQuery: "Nueva consulta",
shortcutCloseTab: "Cerrar pestaña",
shortcutFocusSearch: "Enfocar búsqueda",
shortcutZoomInUi: "Ampliar interfaz",
shortcutZoomOutUi: "Reducir interfaz",
shortcutResetUiZoom: "Restablecer zoom de interfaz",
shortcutRefreshData: "Actualizar datos",
shortcutToggleTranspose: "Alternar vista transpuesta",
shortcutCancelSearch: "Cancelar búsqueda",

View File

@ -1361,6 +1361,8 @@ export default {
noFontsFound: "未找到字体",
useCustomFont: "使用“{font}”",
fontSize: "字号",
uiScale: "界面缩放",
uiScaleDescription: "按比例缩放整个桌面端界面,适合高清屏;修改后立即生效,并在下次启动时恢复。",
theme: "主题",
selectTheme: "选择主题...",
followAppTheme: "跟随应用主题",
@ -1447,6 +1449,9 @@ export default {
shortcutNewQuery: "新建查询",
shortcutCloseTab: "关闭标签页",
shortcutFocusSearch: "聚焦搜索",
shortcutZoomInUi: "放大全局界面",
shortcutZoomOutUi: "缩小全局界面",
shortcutResetUiZoom: "重置全局界面缩放",
shortcutRefreshData: "刷新数据",
shortcutToggleTranspose: "切换转置视图",
shortcutCancelSearch: "取消搜索",

View File

@ -92,6 +92,27 @@ export function isModRShortcut(event: ShortcutLikeEvent): boolean {
return matchesShortcut(event, "Mod+R");
}
export function isZoomInShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
if (matchesShortcut(event, actionShortcut("zoomInUi", shortcuts))) return true;
if (event.isComposing || event.altKey) return false;
if (!event.metaKey && !event.ctrlKey) return false;
return normalizeKey(event.key) === "NumpadAdd" && !event.shiftKey;
}
export function isZoomOutShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
if (matchesShortcut(event, actionShortcut("zoomOutUi", shortcuts))) return true;
if (event.isComposing || event.altKey) return false;
if (!event.metaKey && !event.ctrlKey) return false;
return normalizeKey(event.key) === "NumpadSubtract" && !event.shiftKey;
}
export function isResetZoomShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
if (matchesShortcut(event, actionShortcut("resetUiZoom", shortcuts))) return true;
if (event.isComposing || event.altKey || event.shiftKey) return false;
if (!event.metaKey && !event.ctrlKey) return false;
return normalizeKey(event.key) === "Numpad0";
}
export function isToggleTransposeShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("toggleTranspose", shortcuts));
}

View File

@ -7,6 +7,9 @@ export type ShortcutActionId =
| "newQuery"
| "closeTab"
| "focusSearch"
| "zoomInUi"
| "zoomOutUi"
| "resetUiZoom"
| "find"
| "replace"
| "refreshData"
@ -73,6 +76,24 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "global",
defaultShortcut: "Mod+F",
},
{
id: "zoomInUi",
labelKey: "settings.shortcutZoomInUi",
scope: "global",
defaultShortcut: "Mod+=",
},
{
id: "zoomOutUi",
labelKey: "settings.shortcutZoomOutUi",
scope: "global",
defaultShortcut: "Mod+-",
},
{
id: "resetUiZoom",
labelKey: "settings.shortcutResetUiZoom",
scope: "global",
defaultShortcut: "Mod+0",
},
{
id: "find",
labelKey: "settings.shortcutFind",

View File

@ -172,6 +172,7 @@ export type EditorTheme =
export interface EditorSettings {
fontFamily: string;
fontSize: number;
uiScale: number;
theme: EditorTheme;
executeMode: "all" | "current";
wordWrap: boolean;
@ -222,6 +223,7 @@ export const FONT_FAMILIES: { value: string; label: string }[] = [
export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: 13,
uiScale: 1,
theme: "app",
executeMode: "all",
wordWrap: false,
@ -245,6 +247,13 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
export const STORAGE_KEY = "dbx-editor-settings";
const OLD_FONT_SIZE_KEY = "dbx-query-editor-font-size";
const MIN_UI_SCALE = 0.75;
const MAX_UI_SCALE = 2;
function normalizeUiScale(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_EDITOR_SETTINGS.uiScale;
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Math.round(value * 100) / 100));
}
function normalizeColumnFormatters(value: unknown): Record<string, ColumnFormatterConfig> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
@ -301,6 +310,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
return {
fontFamily: settings.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily,
fontSize: settings.fontSize ?? DEFAULT_EDITOR_SETTINGS.fontSize,
uiScale: normalizeUiScale(settings.uiScale),
theme: settings.theme && EDITOR_THEME_VALUES.has(settings.theme) ? settings.theme : DEFAULT_EDITOR_SETTINGS.theme,
executeMode: settings.executeMode ?? DEFAULT_EDITOR_SETTINGS.executeMode,
wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap,
@ -427,6 +437,7 @@ export const useSettingsStore = defineStore("settings", () => {
function updateEditorSettings(partial: Partial<EditorSettings>) {
if (partial.fontFamily !== undefined) editorSettings.value.fontFamily = partial.fontFamily;
if (partial.fontSize !== undefined) editorSettings.value.fontSize = partial.fontSize;
if (partial.uiScale !== undefined) editorSettings.value.uiScale = normalizeUiScale(partial.uiScale);
if (partial.theme !== undefined) editorSettings.value.theme = partial.theme;
if (partial.executeMode !== undefined) editorSettings.value.executeMode = partial.executeMode;
if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap;

View File

@ -0,0 +1,19 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import test from "node:test";
test("desktop capability allows setting the webview zoom", () => {
const capability = JSON.parse(readFileSync("src-tauri/capabilities/default.json", "utf8")) as {
permissions: string[];
};
assert.equal(capability.permissions.includes("core:webview:allow-set-webview-zoom"), true);
});
test("app logs webview zoom failures instead of swallowing them silently", () => {
const source = readFileSync("apps/desktop/src/App.vue", "utf8");
assert.match(source, /getCurrentWebview\(\)\.setZoom\(scale\)/);
assert.match(source, /console\.warn\("\[DBX\] Failed to apply UI scale"/);
assert.match(source, /applyUiScale\(settingsStore\.editorSettings\.uiScale\)/);
});

View File

@ -10,11 +10,14 @@ import {
isModRShortcut,
isNewQueryShortcut,
isObjectSourceSaveShortcutTarget,
isResetZoomShortcut,
isRefreshDataShortcut,
isSaveShortcut,
isCopyCurrentRowShortcut,
isDeleteCurrentRowShortcut,
isToggleTransposeShortcut,
isZoomInShortcut,
isZoomOutShortcut,
} from "../../apps/desktop/src/lib/keyboardShortcuts.ts";
import { shortcutToCodeMirrorKey } from "../../apps/desktop/src/lib/shortcutRegistry.ts";
@ -119,6 +122,27 @@ test("matches Mod-R without shift or alt for scoped refresh and replace", () =>
assert.equal(isModRShortcut({ key: "r", ctrlKey: true, altKey: true }), false);
});
test("matches desktop UI zoom shortcuts", () => {
assert.equal(isZoomInShortcut({ key: "=", ctrlKey: true }), true);
assert.equal(isZoomInShortcut({ key: "NumpadAdd", ctrlKey: true }), true);
assert.equal(isZoomOutShortcut({ key: "-", ctrlKey: true }), true);
assert.equal(isZoomOutShortcut({ key: "NumpadSubtract", metaKey: true }), true);
assert.equal(isResetZoomShortcut({ key: "0", ctrlKey: true }), true);
assert.equal(isResetZoomShortcut({ key: "Numpad0", metaKey: true }), true);
});
test("matches configurable desktop UI zoom shortcuts", () => {
assert.equal(isZoomInShortcut({ key: "i", ctrlKey: true }, { zoomInUi: "Mod+I" } as any), true);
assert.equal(isZoomOutShortcut({ key: "o", metaKey: true }, { zoomOutUi: "Mod+O" } as any), true);
assert.equal(isResetZoomShortcut({ key: "9", ctrlKey: true }, { resetUiZoom: "Mod+9" } as any), true);
});
test("ignores desktop UI zoom shortcuts with the wrong modifiers", () => {
assert.equal(isZoomInShortcut({ key: "=", ctrlKey: true, altKey: true }), false);
assert.equal(isZoomOutShortcut({ key: "-", isComposing: true, ctrlKey: true }), false);
assert.equal(isResetZoomShortcut({ key: "0", metaKey: true, shiftKey: true }), false);
});
test("ignores focus search shortcut while composing", () => {
assert.equal(isFocusSearchShortcut({ key: "f", ctrlKey: true, isComposing: true }), false);
});

View File

@ -25,6 +25,13 @@ test("query editor localizes the replace all button", () => {
assert.doesNotMatch(searchPanelSource, />\s*全部\s*</);
});
test("query editor no longer binds keyboard shortcuts for editor font zoom", () => {
assert.doesNotMatch(source, /key:\s*"Mod-="/);
assert.doesNotMatch(source, /key:\s*"Mod-\+"/);
assert.doesNotMatch(source, /key:\s*"Mod--"/);
assert.doesNotMatch(source, /key:\s*"Mod-0"/);
});
test("query editor exposes a context menu for executing selected SQL", () => {
assert.match(source, /ContextMenuContent/);
assert.match(source, /data-context-menu/);
@ -67,3 +74,13 @@ test("app keydown routes Mod-R directly before browser reload handling", () => {
assert.match(contentAreaSource, /dataGridRef\.value\?\.openCellDetailSearch\(\)/);
assert.match(contentAreaSource, /if \(target\.closest\("\[data-grid-root\]"\)\) return refreshData\(\)/);
});
test("app routes global UI zoom shortcuts across editor surfaces", () => {
assert.match(appSource, /const shortcuts = settingsStore\.editorSettings\.shortcuts;/);
assert.match(appSource, /isZoomInShortcut\(e, shortcuts\)/);
assert.match(appSource, /isZoomOutShortcut\(e, shortcuts\)/);
assert.match(appSource, /isResetZoomShortcut\(e, shortcuts\)/);
assert.match(appSource, /isGlobalUiZoomTarget\(e\.target\)/);
assert.match(appSource, /settingsStore\.updateEditorSettings\(\{\s*uiScale:\s*scale\s*\}\)/);
assert.match(appSource, /\[data-query-editor-root\], \[data-cell-detail-editor-root\], \[data-object-source-editor\]/);
});

View File

@ -40,6 +40,9 @@ test("defaults shortcut settings", () => {
assert.equal(settings.shortcuts.deleteCurrentRow, "Delete");
assert.equal(settings.shortcuts.newQuery, "Mod+T");
assert.equal(settings.shortcuts.focusSearch, "Mod+F");
assert.equal(settings.shortcuts.zoomInUi, "Mod+=");
assert.equal(settings.shortcuts.zoomOutUi, "Mod+-");
assert.equal(settings.shortcuts.resetUiZoom, "Mod+0");
assert.equal(settings.shortcuts.refreshData, "F5");
assert.equal(settings.shortcuts.toggleTranspose, "Tab");
});
@ -51,6 +54,7 @@ test("keeps saved shortcut overrides", () => {
copyCurrentRow: "Alt+Shift+D",
deleteCurrentRow: "Backspace",
newQuery: "Shift+Mod+N",
zoomInUi: "Alt+Mod+=",
} as any,
});
@ -58,6 +62,7 @@ test("keeps saved shortcut overrides", () => {
assert.equal(settings.shortcuts.copyCurrentRow, "Alt+Shift+D");
assert.equal(settings.shortcuts.deleteCurrentRow, "Backspace");
assert.equal(settings.shortcuts.newQuery, "Shift+Mod+N");
assert.equal(settings.shortcuts.zoomInUi, "Alt+Mod+=");
assert.equal(settings.shortcuts.saveSql, "Mod+S");
});
@ -180,3 +185,18 @@ test("infers legacy AI provider from saved endpoint and model", () => {
assert.equal(deepseek.endpoint, "https://api.deepseek.com/anthropic/v1/messages");
assert.equal(deepseek.model, "deepseek-v4-pro");
});
test("normalizeEditorSettings falls back to the default UI scale", () => {
const settings = normalizeEditorSettings({});
assert.equal(settings.uiScale, DEFAULT_EDITOR_SETTINGS.uiScale);
});
test("normalizeEditorSettings clamps UI scale into the supported range", () => {
assert.equal(normalizeEditorSettings({ uiScale: 0.2 }).uiScale, 0.75);
assert.equal(normalizeEditorSettings({ uiScale: 2.8 }).uiScale, 2);
});
test("normalizeEditorSettings keeps valid UI scales with two-decimal precision", () => {
assert.equal(normalizeEditorSettings({ uiScale: 1.125 }).uiScale, 1.13);
});

View File

@ -17,6 +17,7 @@
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:window:allow-start-dragging",
"core:webview:allow-set-webview-zoom",
"dialog:default",
"dialog:allow-save",
"dialog:allow-open",