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.
This commit is contained in:
Nicholai Vogel 2026-02-16 18:35:56 -07:00
parent 4cebbb73e8
commit 50c7d1d1e4
5 changed files with 169 additions and 2 deletions

View File

@ -7,6 +7,7 @@
"core:app:default", "core:app:default",
"core:window:default", "core:window:default",
"core:webview:default", "core:webview:default",
"core:webview:allow-set-webview-zoom",
"shell:allow-open", "shell:allow-open",
"sql:default", "sql:default",
"sql:allow-load", "sql:allow-load",

View File

@ -116,7 +116,21 @@ export function DesktopShell({ children }: DesktopShellProps) {
const { registerShortcuts } = await import( const { registerShortcuts } = await import(
"@/lib/desktop/shortcuts" "@/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) { } catch (error) {
console.error("Failed to register desktop shortcuts:", error) console.error("Failed to register desktop shortcuts:", error)
} }

View File

@ -2,9 +2,10 @@
import * as React from "react" import * as React from "react"
import { useTheme } from "next-themes" 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 { toast } from "sonner"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Slider } from "@/components/ui/slider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useCompassTheme } from "@/components/theme-provider" import { useCompassTheme } from "@/components/theme-provider"
import { useAgentOptional } from "@/components/agent/chat-provider" import { useAgentOptional } from "@/components/agent/chat-provider"
@ -172,6 +173,59 @@ export function AppearanceTab() {
const isDark = resolvedTheme === "dark" 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<void> {
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<ReadonlyArray<ThemeDefinition>>( const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>(
() => [...THEME_PRESETS, ...customThemes], () => [...THEME_PRESETS, ...customThemes],
[customThemes], [customThemes],
@ -246,6 +300,40 @@ export function AppearanceTab() {
</div> </div>
</div> </div>
{/* ui scale */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">UI Scale</p>
<div className="flex items-center gap-2">
<span className="text-sm tabular-nums text-muted-foreground">
{Math.round(zoomLevel * 100)}%
</span>
{zoomLevel !== 1.0 && (
<button
type="button"
onClick={handleZoomReset}
className="flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="size-3" />
Reset
</button>
)}
</div>
</div>
<Slider
value={[zoomLevel]}
onValueChange={handleZoomChange}
min={0.5}
max={2.0}
step={0.1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>50%</span>
<span>200%</span>
</div>
</div>
{/* theme grid */} {/* theme grid */}
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium">Theme</p> <p className="text-sm font-medium">Theme</p>

View File

@ -8,6 +8,9 @@ export interface ShortcutHandlers {
onNew?: () => void onNew?: () => void
onSearch?: () => void onSearch?: () => void
onSettings?: () => void onSettings?: () => void
onZoomIn?: () => void
onZoomOut?: () => void
onZoomReset?: () => void
} }
interface RegisteredShortcut { interface RegisteredShortcut {
@ -56,6 +59,18 @@ export async function registerShortcuts(
...(handlers.onSettings ...(handlers.onSettings
? [{ shortcut: `${modifier}+,`, handler: 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 // Register each shortcut
@ -128,4 +143,7 @@ export const SHORTCUTS = {
reload: "Cmd/Ctrl + R", reload: "Cmd/Ctrl + R",
devTools: "Cmd/Ctrl + Shift + I", devTools: "Cmd/Ctrl + Shift + I",
quit: "Cmd/Ctrl + Q", quit: "Cmd/Ctrl + Q",
zoomIn: "Cmd/Ctrl + =",
zoomOut: "Cmd/Ctrl + -",
zoomReset: "Cmd/Ctrl + 0",
} as const } as const

View File

@ -13,6 +13,7 @@ export interface WindowState {
} }
const WINDOW_STATE_KEY = "compass-window-state" const WINDOW_STATE_KEY = "compass-window-state"
const ZOOM_LEVEL_KEY = "compass-zoom-level"
// Internal state cache // Internal state cache
let cachedState: WindowState | null = null let cachedState: WindowState | null = null
@ -85,6 +86,9 @@ export const WindowManager = {
// Also cache current state // Also cache current state
cachedState = await loadWindowStateFromStore() cachedState = await loadWindowStateFromStore()
// Restore zoom level
await this.restoreZoom()
} catch (error) { } catch (error) {
console.error("Failed to restore window state:", error) console.error("Failed to restore window state:", error)
} }
@ -175,4 +179,46 @@ export const WindowManager = {
return true return true
} }
}, },
// Set webview zoom via Tauri native API with CSS font-size fallback
async setZoom(level: number): Promise<void> {
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<void> {
const level = this.getZoom()
if (level !== 1.0) {
await this.setZoom(level)
}
},
} }