feat(ui): persistent zoom level with VS Code style shortcuts#66
Conversation
Adds a machine-wide UI zoom level that scales the entire WebView, addressing the 1080p crowding reported in #54: instead of hand- tuning every component for a specific viewport, users pick their own density. Defaults to 100 %, persisted in `app_setting`, clamped to [50 %, 200 %]. Three entry points, all writing through the same code path: - **`Ctrl+=` / `Ctrl+-` / `Ctrl+0`** — VS Code / Discord / Slack / browser convention. Bound at the window level via a new `useUiZoom` hook mounted on `AppLayout`. Skipped when an input, textarea or contentEditable has focus so typing stays normal. - **Settings → Général** — `-` / value chip / `+` cluster (chip doubles as a reset-to-100 % button). Subscribed to the same window event the shortcut handler broadcasts, so the two surfaces stay in sync without a context. - **Boot** — `useUiZoom` reads the persisted level on mount and applies it via `getCurrentWebviewWindow().setZoom(level)` so the user lands at their last density instead of always starting at 100 %. This is native WebView zoom (text crisp, hit-test geometry stays honest), not a `transform: scale` overlay. Tauri's `setZoom` calls into WebView2 / WebKitGTK directly. Backend: two new commands `get_ui_zoom` / `set_ui_zoom` in preferences.rs, server-side clamp matches the frontend bounds so a stray future caller can't blow the layout away. i18n: new `settings.uiZoom.*` keys propagated to all 17 locales.
📝 WalkthroughWalkthroughImplémentation complète d'une fonctionnalité de zoom UI persisté : backend Tauri avec commandes de lecture/écriture et validation, hook React avec hydratation et raccourcis clavier, intégration UI dans AppLayout et SettingsView, et traductions multilingues dans 16 langues. ChangesZoom UI persisté et synchronisé
Sequence DiagramsequenceDiagram
participant User
participant AppLayout
participant useUiZoom
participant SettingsView
participant Tauri API
participant Backend
Note over AppLayout,Backend: Au démarrage
AppLayout->>useUiZoom: initialiser hook
useUiZoom->>Tauri API: getUiZoom()
Tauri API->>Backend: lire app_setting
Backend-->>Tauri API: valeur persistée (ex. 1.2)
Tauri API-->>useUiZoom: zoom initial
useUiZoom->>useUiZoom: setZoom(1.2) WebView
Note over SettingsView: Utilisateur ouvre Settings
SettingsView->>Tauri API: getUiZoom()
Tauri API-->>SettingsView: 1.2
SettingsView->>SettingsView: afficher 120%
Note over User,Backend: Utilisateur clique +
User->>SettingsView: clic bouton augmenter
SettingsView->>useUiZoom: applyUiZoom(1.3)
useUiZoom->>Tauri API: setZoom(1.3)
useUiZoom->>Tauri API: setUiZoom(1.3)
Tauri API->>Backend: upsert app_setting
useUiZoom->>useUiZoom: dispatcher CustomEvent
SettingsView->>SettingsView: mettre à jour état local
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Tauri 2 gates `setZoom` behind a capability permission (the runtime throws "webview.set_webview_zoom not allowed" otherwise). The permission wasn't in the default capability so both the boot-time apply and the Settings card buttons no-op'd with a permission denial in the console. Add `core:webview:allow-set-webview-zoom` to the default capability (scoped to main + mini windows, same as the rest of the webview permissions in that file).
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/views/SettingsView.tsx`:
- Around line 445-452: handleZoomDelta closes over uiZoom so rapid clicks can
call applyUiZoom with the same base value causing duplicate/non-cumulative
updates; fix by computing the new zoom from the latest state inside the updater
and serializing concurrent requests: change handleZoomDelta to use a functional
state updater (call setUiZoom(prev => { const next = prev + delta;
applyUiZoom(next).then(() => /* no-op or keep in sync */).catch(err =>
console.error(...)); return next; })) or alternatively track an "isApplyingZoom"
flag to disable buttons while applyUiZoom is in-flight; update references to
handleZoomDelta, applyUiZoom, and setUiZoom accordingly so clicks always use the
latest zoom and prevent duplicate network calls.
In `@src/hooks/useUiZoom.ts`:
- Around line 66-95: The onKey listener closes over stale zoom and advances a
separate zoomRef immediately, causing race conditions and potential divergence
if apply() fails; change the logic in the onKey handler (inside useEffect /
function onKey) so you do not advance zoomRef until apply() succeeds — call
apply(next).then(() => { update zoomRef and then call setZoom/setUiZoom as
needed }) and handle errors without mutating zoomRef, or alternatively sync
zoomRef from the zoom state at the start of the effect (lines around zoomRef
usage and the useEffect containing onKey) so the listener always reads the
latest value; ensure any setZoom/setUiZoom only happens on successful apply() to
keep ref and state consistent.
- Around line 118-123: The apply function currently awaits setUiZoom and if that
fails the UI_ZOOM_CHANGED_EVENT is never emitted even though
getCurrentWebviewWindow().setZoom already changed the visual zoom; to fix, call
getCurrentWebviewWindow().setZoom(zoom) first, then attempt setUiZoom(zoom)
inside a try/catch that logs or handles the error but does not rethrow, and
finally always dispatch the CustomEvent(UI_ZOOM_CHANGED_EVENT, { detail: zoom
}); ensure the function still returns the zoom value and reference apply,
getCurrentWebviewWindow().setZoom, setUiZoom, and UI_ZOOM_CHANGED_EVENT when
locating the code.
In `@src/i18n/locales/kr.json`:
- Line 1323: Replace the incorrect Korean label "UI 줄" for the JSON value of the
"title" key with a clearer phrase meaning “UI zoom” (for example "UI 확대/축소");
locate the "title": "UI 줄" entry in the kr.json locale and update its string
value to "UI 확대/축소" (or another equivalent Korean phrase) so the translation
correctly reflects “zoom de l’interface”.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5d1728f0-f1ac-4cd8-9ac5-0289db0f0f8d
📒 Files selected for processing (23)
src-tauri/src/commands/preferences.rssrc-tauri/src/lib.rssrc/components/layout/AppLayout.tsxsrc/components/views/SettingsView.tsxsrc/hooks/useUiZoom.tssrc/i18n/locales/ar.jsonsrc/i18n/locales/de.jsonsrc/i18n/locales/en.jsonsrc/i18n/locales/es.jsonsrc/i18n/locales/fr.jsonsrc/i18n/locales/hi.jsonsrc/i18n/locales/id.jsonsrc/i18n/locales/it.jsonsrc/i18n/locales/ja.jsonsrc/i18n/locales/kr.jsonsrc/i18n/locales/nl.jsonsrc/i18n/locales/pt-BR.jsonsrc/i18n/locales/pt.jsonsrc/i18n/locales/ru.jsonsrc/i18n/locales/tr.jsonsrc/i18n/locales/zh-CN.jsonsrc/i18n/locales/zh-TW.jsonsrc/lib/tauri/preferences.ts
Three correctness fixes from review + the bad Korean translation. - **handleZoomDelta** in `SettingsView` closed over `uiZoom`, so a rapid click burst all started from the value React captured at the first click — three `+` taps yielded one `+0.1` instead of `+0.3`. Move to a functional `setUiZoom(prev => ...)` updater so rapid clicks accumulate from the latest committed value. Same treatment for `handleZoomReset`. The optimistic state update also means the percentage chip updates immediately even when `applyUiZoom` is pending or the backend persist later fails — the broadcast event reconciles any backend clamping post-hoc. - **Keyboard handler** in `useUiZoom` had the equivalent stale- closure issue: the `[zoom]` dep re-bound the listener on every change, but two `Ctrl+=` presses in the same tick still read the same value. Add a `zoomRef` that mirrors the committed zoom and is bumped optimistically before `apply()`; on apply failure the ref rolls back so a subsequent keystroke doesn't compound onto a target we never reached. The listener attaches once and reads through the ref. - **`apply()`** awaited `setUiZoom` (the IPC persist) before dispatching the broadcast event, so a persist failure left the WebView visually zoomed but the Settings chip stuck at the old percentage. Move the persist into a try/catch that logs and always dispatch the event afterwards — in-session UI stays consistent; the persistence is a best-effort second step. - **Korean translation**: `settings.uiZoom.title` shipped as "UI 줄" (literally "UI row/line") which is meaningless in context. Replace with "UI 확대/축소" (the standard Korean phrase for "zoom"). Also update `resetAria` to use the same word.
Primary bug (caught by review): the zoom level never survived an app restart. Root cause was a silent SQLite CHECK violation in `set_ui_zoom`: the row insert tagged `value_type = 'real'`, but the `app_setting` CHECK constraint only allows `'string' | 'int' | 'bool' | 'json'` (initial migration). sqlx returned the constraint error, `apply()` caught it (the second-step "persist failed (visual zoom applied)" branch added in the previous commit), the broadcast event still fired, the in-session UI looked correct — and the persisted row stayed empty. On boot `get_ui_zoom` saw no row and defaulted to 1.0. Fix: tag the persisted row as `'string'` (we serialize the float through `format!()` anyway, so that's the honest tag — no migration needed). Three smaller hardening fixes from the same review: - `useUiZoom` and `SettingsView` both listen to the public `UI_ZOOM_CHANGED_EVENT` on `window`. Any page-side script could dispatch `detail: 999` and the previous handlers would route it straight into state + the WebView. Add a `Number.isFinite` + `[UI_ZOOM_MIN, UI_ZOOM_MAX]` guard before accepting the value. - The `Ctrl+0` reset branch hard-coded `next = 1`; route it through `clamp(1)` for the same reason — uniformity with the other two branches and survival against any future widening of the constants.
Summary
Adds a machine-wide UI zoom level that scales the entire WebView. Addresses the 1080p crowding angle of #54 in a way the user controls — instead of hand-tuning every component for a specific viewport, users pick their own density (50 %–200 %, default 100 %, clamped server-side too).
Native WebView zoom (text stays crisp, hit-test geometry stays honest), not a
transform: scaleoverlay. Tauri'ssetZoomcalls into WebView2 / WebKitGTK directly.Three entry points, one code path
Ctrl+=/Ctrl+-/Ctrl+0— VS Code / Discord / Slack / browser convention. Bound at the window level viauseUiZoommounted onAppLayout. Skipped when an input / textarea / contentEditable has focus so typing stays normal.−/ value chip /+cluster (the chip doubles as a reset-to-100 % button). Subscribed to the same window event the shortcut handler broadcasts, so the two surfaces stay in sync without a context.useUiZoomreads the persisted level on mount and applies it viagetCurrentWebviewWindow().setZoom(level)so the user lands at their last density.What changed
get_ui_zoom/set_ui_zoom. Server-side clamp[0.5, 2.0]mirrors the frontend bounds so a stray future caller can't blow the layout away.UI_ZOOM_CHANGED_EVENTfor cross-surface sync.applyUiZoom()so the Settings slider doesn't need to duplicate the persist + dispatch logic.−/100 %/+cluster.settings.uiZoom.*keys (title / subtitle / aria labels) propagated to all 17 locales.Test plan
cargo check,cargo clippy --all-targets— cleanbun run typecheck,bun run lintCtrl+=→ Settings chip updates to120 %;Ctrl+0→ both back to 100 %Ctrl+=types=instead of zooming (input takes priority)Related
Closes the spirit of #54 sub-bug D more durably than #65 — users on any resolution can dial the density they want.
Summary by CodeRabbit