Skip to content

feat(ui): persistent zoom level with VS Code style shortcuts#66

Merged
InstaZDLL merged 4 commits into
mainfrom
feat/ui-zoom
May 19, 2026
Merged

feat(ui): persistent zoom level with VS Code style shortcuts#66
InstaZDLL merged 4 commits into
mainfrom
feat/ui-zoom

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 19, 2026

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: scale overlay. Tauri's setZoom calls 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 via useUiZoom mounted on AppLayout. Skipped when an input / textarea / contentEditable has focus so typing stays normal.
  • Settings → Général / 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.
  • BootuseUiZoom reads the persisted level on mount and applies it via getCurrentWebviewWindow().setZoom(level) so the user lands at their last density.

What changed

  • Backend (preferences.rs): two new commands 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.
  • Bridge (preferences.ts): TS wrappers + shared constants + a UI_ZOOM_CHANGED_EVENT for cross-surface sync.
  • Hook (useUiZoom.ts): owns hydration, keyboard shortcuts, and broadcast. Also exports an imperative applyUiZoom() so the Settings slider doesn't need to duplicate the persist + dispatch logic.
  • Settings card (SettingsView.tsx): row in the General section with the / 100 % / + cluster.
  • i18n: new settings.uiZoom.* keys (title / subtitle / aria labels) propagated to all 17 locales.

Test plan

  • cargo check, cargo clippy --all-targets — clean
  • bun run typecheck, bun run lint
  • At 1080p: shrink to 90 % via Settings → the layout breathes; reload → still at 90 %
  • At 4K: bump to 120 % via Ctrl+= → Settings chip updates to 120 %; Ctrl+0 → both back to 100 %
  • Focus the TopBar search → Ctrl+= types = instead of zooming (input takes priority)
  • Mini-player window: zoom applies only to the main window (each WebView is its own scope), as expected

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

  • Nouvelles fonctionnalités
    • Ajout d'une option de zoom de l'interface dans les paramètres généraux.
    • Raccourcis clavier pour ajuster le zoom : Ctrl/⌘ + « + »/« - » et « 0 » pour réinitialiser à 100%.
    • Le niveau de zoom est mémorisé entre les sessions.
    • Support du zoom en 16 langues.

Review Change Stack

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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

📝 Walkthrough

Walkthrough

Implé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.

Changes

Zoom UI persisté et synchronisé

Layer / File(s) Summary
Commandes Tauri et persistance backend
src-tauri/src/commands/preferences.rs, src-tauri/src/lib.rs
Commandes get_ui_zoom et set_ui_zoom pour lire/écrire une préférence persistée dans app_setting avec validation, clamping (0.5–2.0), valeur par défaut (1.0), et horodatage. Enregistrement dans le handler Tauri.
Hook useUiZoom et API publique
src/hooks/useUiZoom.ts, src/lib/tauri/preferences.ts
Hook React useUiZoom avec hydratation au montage, application au WebView, écoute d'événement global UI_ZOOM_CHANGED_EVENT, raccourcis clavier (Ctrl/⌘ ± et 0), et fonction publique applyUiZoom pour appliquer/persister/broadcaster le zoom.
Intégration UI et synchronisation
src/components/layout/AppLayout.tsx, src/components/views/SettingsView.tsx
Hydratation du zoom au démarrage (AppLayout), contrôles UI (boutons −/% reset/+), chargement initial et synchronisation événementielle avec gestion d'erreur dans SettingsView.
Traductions multilingues
src/i18n/locales/*.json (16 fichiers)
Ajout de clés settings.uiZoom (titre, sous-titre, libellés ARIA décroître/augmenter/réinitialiser) dans toutes les locales (ar, de, en, es, fr, hi, id, it, ja, kr, nl, pt, pt-BR, ru, tr, zh-CN, zh-TW).

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🎛️ Zoom in, zoom out, réglage subtil,
Clavier et boutons dansent au rythme gentil,
Persistence en base, événements qui brodent,
Seize langues chantent : "Le zoom s'écoute !"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Le titre suit les conventions Conventional Commits et décrit clairement la fonctionnalité principale : une persistance du zoom avec des raccourcis VS Code.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed La description suit le format requis avec résumé clair, plan de test détaillé et lien à l'issue. Tous les éléments critiques sont présents.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ui-zoom

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added scope: frontend React/Vite frontend (src/) scope: backend Rust/Tauri backend (src-tauri/) scope: i18n Translations (src/i18n/) type: feat New feature size: l 200-500 lines labels May 19, 2026
@InstaZDLL InstaZDLL self-assigned this May 19, 2026
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).
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between d6c4642 and 012de64.

📒 Files selected for processing (23)
  • src-tauri/src/commands/preferences.rs
  • src-tauri/src/lib.rs
  • src/components/layout/AppLayout.tsx
  • src/components/views/SettingsView.tsx
  • src/hooks/useUiZoom.ts
  • src/i18n/locales/ar.json
  • src/i18n/locales/de.json
  • src/i18n/locales/en.json
  • src/i18n/locales/es.json
  • src/i18n/locales/fr.json
  • src/i18n/locales/hi.json
  • src/i18n/locales/id.json
  • src/i18n/locales/it.json
  • src/i18n/locales/ja.json
  • src/i18n/locales/kr.json
  • src/i18n/locales/nl.json
  • src/i18n/locales/pt-BR.json
  • src/i18n/locales/pt.json
  • src/i18n/locales/ru.json
  • src/i18n/locales/tr.json
  • src/i18n/locales/zh-CN.json
  • src/i18n/locales/zh-TW.json
  • src/lib/tauri/preferences.ts

Comment thread src/components/views/SettingsView.tsx Outdated
Comment thread src/hooks/useUiZoom.ts Outdated
Comment thread src/hooks/useUiZoom.ts
Comment thread src/i18n/locales/kr.json Outdated
InstaZDLL added 2 commits May 19, 2026 02:25
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.
@InstaZDLL InstaZDLL merged commit 0bc07c9 into main May 19, 2026
13 checks passed
@InstaZDLL InstaZDLL deleted the feat/ui-zoom branch May 19, 2026 00:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: backend Rust/Tauri backend (src-tauri/) scope: frontend React/Vite frontend (src/) scope: i18n Translations (src/i18n/) size: l 200-500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant