From 50c7d1d1e4d4a87ce8317293ef6b34cc85f55521 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Mon, 16 Feb 2026 18:35:56 -0700 Subject: [PATCH] feat(desktop): add UI zoom scaling Add zoom controls to Theme settings (slider 50-200%) with Tauri native webview zoom and font-size fallback. Keyboard shortcuts (Ctrl+=/Ctrl+-/Ctrl+0) wired in desktop shell. Zoom level persists in localStorage. --- src-tauri/capabilities/default.json | 1 + src/components/desktop/desktop-shell.tsx | 16 +++- src/components/settings/appearance-tab.tsx | 90 +++++++++++++++++++++- src/lib/desktop/shortcuts.ts | 18 +++++ src/lib/desktop/window-manager.ts | 46 +++++++++++ 5 files changed, 169 insertions(+), 2 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3d1eecb..6910ecf 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:app:default", "core:window:default", "core:webview:default", + "core:webview:allow-set-webview-zoom", "shell:allow-open", "sql:default", "sql:allow-load", diff --git a/src/components/desktop/desktop-shell.tsx b/src/components/desktop/desktop-shell.tsx index 4607737..85790f4 100644 --- a/src/components/desktop/desktop-shell.tsx +++ b/src/components/desktop/desktop-shell.tsx @@ -116,7 +116,21 @@ export function DesktopShell({ children }: DesktopShellProps) { const { registerShortcuts } = await import( "@/lib/desktop/shortcuts" ) - unregister = await registerShortcuts({ triggerSync }) + const { WindowManager } = await import("@/lib/desktop/window-manager") + unregister = await registerShortcuts({ + triggerSync, + onZoomIn: () => { + const current = WindowManager.getZoom() + WindowManager.setZoom(Math.round((current + 0.1) * 10) / 10) + }, + onZoomOut: () => { + const current = WindowManager.getZoom() + WindowManager.setZoom(Math.round((current - 0.1) * 10) / 10) + }, + onZoomReset: () => { + WindowManager.setZoom(1.0) + }, + }) } catch (error) { console.error("Failed to register desktop shortcuts:", error) } diff --git a/src/components/settings/appearance-tab.tsx b/src/components/settings/appearance-tab.tsx index bedf0c8..3395ae1 100755 --- a/src/components/settings/appearance-tab.tsx +++ b/src/components/settings/appearance-tab.tsx @@ -2,9 +2,10 @@ import * as React from "react" import { useTheme } from "next-themes" -import { Check, Moon, Sparkles, Sun, Trash2 } from "lucide-react" +import { Check, Moon, RotateCcw, Sparkles, Sun, Trash2 } from "lucide-react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" +import { Slider } from "@/components/ui/slider" import { cn } from "@/lib/utils" import { useCompassTheme } from "@/components/theme-provider" import { useAgentOptional } from "@/components/agent/chat-provider" @@ -172,6 +173,59 @@ export function AppearanceTab() { const isDark = resolvedTheme === "dark" + const [zoomLevel, setZoomLevel] = React.useState(1.0) + + // Load persisted zoom level on mount + React.useEffect(() => { + try { + const stored = localStorage.getItem("compass-zoom-level") + if (stored) { + const level = parseFloat(stored) + if (!isNaN(level) && level >= 0.5 && level <= 2.0) { + setZoomLevel(level) + } + } + } catch { + // localStorage not available + } + }, []) + + async function applyZoom(level: number): Promise { + const clamped = Math.min(2.0, Math.max(0.5, level)) + try { + localStorage.setItem("compass-zoom-level", String(clamped)) + } catch { + // localStorage not available + } + // Use Tauri native webview zoom (true browser-level zoom) + try { + const { invoke } = await import("@tauri-apps/api/core") + await invoke("plugin:webview|set_webview_zoom", { + label: "main", + scaleFactor: clamped, + }) + // Clear any CSS fallback + document.documentElement.style.fontSize = "" + return + } catch { + // Not in Tauri or permission denied — CSS fallback + } + // Fallback: scale root font-size (slightly thicker icons but functional) + document.documentElement.style.fontSize = `${clamped * 16}px` + } + + function handleZoomChange(value: number[]): void { + const level = value[0] + if (level === undefined) return + setZoomLevel(level) + void applyZoom(level) + } + + function handleZoomReset(): void { + setZoomLevel(1.0) + void applyZoom(1.0) + } + const allThemes = React.useMemo>( () => [...THEME_PRESETS, ...customThemes], [customThemes], @@ -246,6 +300,40 @@ export function AppearanceTab() { + {/* ui scale */} +
+
+

UI Scale

+
+ + {Math.round(zoomLevel * 100)}% + + {zoomLevel !== 1.0 && ( + + )} +
+
+ +
+ 50% + 200% +
+
+ {/* theme grid */}

Theme

diff --git a/src/lib/desktop/shortcuts.ts b/src/lib/desktop/shortcuts.ts index 08e7f3e..fec59f2 100644 --- a/src/lib/desktop/shortcuts.ts +++ b/src/lib/desktop/shortcuts.ts @@ -8,6 +8,9 @@ export interface ShortcutHandlers { onNew?: () => void onSearch?: () => void onSettings?: () => void + onZoomIn?: () => void + onZoomOut?: () => void + onZoomReset?: () => void } interface RegisteredShortcut { @@ -56,6 +59,18 @@ export async function registerShortcuts( ...(handlers.onSettings ? [{ shortcut: `${modifier}+,`, handler: handlers.onSettings }] : []), + // Zoom in: Cmd/Ctrl + = + ...(handlers.onZoomIn + ? [{ shortcut: `${modifier}+=`, handler: handlers.onZoomIn }] + : []), + // Zoom out: Cmd/Ctrl + - + ...(handlers.onZoomOut + ? [{ shortcut: `${modifier}+-`, handler: handlers.onZoomOut }] + : []), + // Zoom reset: Cmd/Ctrl + 0 + ...(handlers.onZoomReset + ? [{ shortcut: `${modifier}+0`, handler: handlers.onZoomReset }] + : []), ] // Register each shortcut @@ -128,4 +143,7 @@ export const SHORTCUTS = { reload: "Cmd/Ctrl + R", devTools: "Cmd/Ctrl + Shift + I", quit: "Cmd/Ctrl + Q", + zoomIn: "Cmd/Ctrl + =", + zoomOut: "Cmd/Ctrl + -", + zoomReset: "Cmd/Ctrl + 0", } as const diff --git a/src/lib/desktop/window-manager.ts b/src/lib/desktop/window-manager.ts index dff1e92..19bace7 100644 --- a/src/lib/desktop/window-manager.ts +++ b/src/lib/desktop/window-manager.ts @@ -13,6 +13,7 @@ export interface WindowState { } const WINDOW_STATE_KEY = "compass-window-state" +const ZOOM_LEVEL_KEY = "compass-zoom-level" // Internal state cache let cachedState: WindowState | null = null @@ -85,6 +86,9 @@ export const WindowManager = { // Also cache current state cachedState = await loadWindowStateFromStore() + + // Restore zoom level + await this.restoreZoom() } catch (error) { console.error("Failed to restore window state:", error) } @@ -175,4 +179,46 @@ export const WindowManager = { return true } }, + + // Set webview zoom via Tauri native API with CSS font-size fallback + async setZoom(level: number): Promise { + const clamped = Math.min(2.0, Math.max(0.5, level)) + try { + localStorage.setItem(ZOOM_LEVEL_KEY, String(clamped)) + } catch { + // localStorage not available + } + try { + const { invoke } = await import("@tauri-apps/api/core") + await invoke("plugin:webview|set_webview_zoom", { + label: "main", + scaleFactor: clamped, + }) + document.documentElement.style.fontSize = "" + } catch { + document.documentElement.style.fontSize = `${clamped * 16}px` + } + }, + + // Get stored zoom level (defaults to 1.0) + getZoom(): number { + try { + const stored = localStorage.getItem(ZOOM_LEVEL_KEY) + if (stored) { + const level = parseFloat(stored) + if (!isNaN(level) && level >= 0.5 && level <= 2.0) return level + } + } catch { + // localStorage not available + } + return 1.0 + }, + + // Restore zoom from stored level + async restoreZoom(): Promise { + const level = this.getZoom() + if (level !== 1.0) { + await this.setZoom(level) + } + }, }