From 012de646a2408e5523e25dc50ed28b50aee1414b Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 19 May 2026 02:04:58 +0200 Subject: [PATCH 1/4] feat(ui): persistent zoom level with VS Code style shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands/preferences.rs | 57 ++++++++++++ src-tauri/src/lib.rs | 2 + src/components/layout/AppLayout.tsx | 5 ++ src/components/views/SettingsView.tsx | 93 +++++++++++++++++++ src/hooks/useUiZoom.ts | 125 ++++++++++++++++++++++++++ src/i18n/locales/ar.json | 7 ++ src/i18n/locales/de.json | 7 ++ src/i18n/locales/en.json | 7 ++ src/i18n/locales/es.json | 7 ++ src/i18n/locales/fr.json | 7 ++ src/i18n/locales/hi.json | 7 ++ src/i18n/locales/id.json | 7 ++ src/i18n/locales/it.json | 7 ++ src/i18n/locales/ja.json | 7 ++ src/i18n/locales/kr.json | 7 ++ src/i18n/locales/nl.json | 7 ++ src/i18n/locales/pt-BR.json | 7 ++ src/i18n/locales/pt.json | 7 ++ src/i18n/locales/ru.json | 7 ++ src/i18n/locales/tr.json | 7 ++ src/i18n/locales/zh-CN.json | 7 ++ src/i18n/locales/zh-TW.json | 7 ++ src/lib/tauri/preferences.ts | 21 +++++ 23 files changed, 422 insertions(+) create mode 100644 src/hooks/useUiZoom.ts diff --git a/src-tauri/src/commands/preferences.rs b/src-tauri/src/commands/preferences.rs index 5127343..597d7fc 100644 --- a/src-tauri/src/commands/preferences.rs +++ b/src-tauri/src/commands/preferences.rs @@ -113,3 +113,60 @@ pub async fn set_auto_start(app: tauri::AppHandle, enabled: bool) -> AppResult<( result.map_err(|err| crate::error::AppError::Other(format!("autostart: {err}")))?; Ok(()) } + +/// UI zoom level (1.0 = 100 %). Stored in `app_setting` because it's a +/// machine-level preference: a 4K user and a 1080p user on the same +/// box would never share a comfortable zoom, but switching profiles +/// on the same screen shouldn't reset the choice. +/// +/// The frontend reads this on boot via `getUiZoom`, applies it through +/// `getCurrentWebviewWindow().setZoom(level)`, and rewrites the row +/// whenever the user nudges the level via the Settings card or the +/// `Ctrl+=` / `Ctrl+-` / `Ctrl+0` shortcuts. The backend keeps it +/// stateless — no atomic mirror because nothing in the hot path needs +/// to read it. +const KEY_UI_ZOOM: &str = "ui.zoom_level"; + +/// Bounds shared with the frontend. The Settings UI clamps to the +/// same range; this is a server-side safety net so a stray +/// `set_ui_zoom(50)` from a future caller can't blow the layout away +/// (Tauri's `set_zoom` would accept it silently). +const UI_ZOOM_MIN: f64 = 0.5; +const UI_ZOOM_MAX: f64 = 2.0; + +#[tauri::command] +pub async fn get_ui_zoom(state: tauri::State<'_, AppState>) -> AppResult { + let raw: Option = + sqlx::query_scalar("SELECT value FROM app_setting WHERE key = ?") + .bind(KEY_UI_ZOOM) + .fetch_optional(&state.app_db) + .await?; + let zoom = raw + .as_deref() + .and_then(|s| s.parse::().ok()) + .filter(|v| v.is_finite()) + .map(|v| v.clamp(UI_ZOOM_MIN, UI_ZOOM_MAX)) + .unwrap_or(1.0); + Ok(zoom) +} + +#[tauri::command] +pub async fn set_ui_zoom(state: tauri::State<'_, AppState>, zoom: f64) -> AppResult<()> { + let clamped = if zoom.is_finite() { + zoom.clamp(UI_ZOOM_MIN, UI_ZOOM_MAX) + } else { + 1.0 + }; + sqlx::query( + "INSERT INTO app_setting (key, value, value_type, updated_at) + VALUES (?, ?, 'real', ?) + ON CONFLICT(key) DO UPDATE + SET value = excluded.value, updated_at = excluded.updated_at", + ) + .bind(KEY_UI_ZOOM) + .bind(format!("{clamped}")) + .bind(Utc::now().timestamp_millis()) + .execute(&state.app_db) + .await?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1fbc7bc..69919d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -552,6 +552,8 @@ pub fn run() { commands::preferences::set_minimize_to_tray, commands::preferences::get_auto_start, commands::preferences::set_auto_start, + commands::preferences::get_ui_zoom, + commands::preferences::set_ui_zoom, commands::tray::set_tray_labels, commands::lyrics::get_lyrics, commands::lyrics::fetch_lyrics, diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 84cba87..135b727 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -15,6 +15,7 @@ import { getProfileSetting, setProfileSetting } from "../../lib/tauri/profile"; import { Sidebar } from "./Sidebar"; import { useDragDropImport } from "../../hooks/useDragDropImport"; import { useGlobalShortcuts } from "../../hooks/useGlobalShortcuts"; +import { useUiZoom } from "../../hooks/useUiZoom"; import { useTranslation } from "react-i18next"; import { Loader2, Upload } from "lucide-react"; import { TopBar } from "./TopBar"; @@ -130,6 +131,10 @@ export function AppLayout() { // listener and re-reads bindings whenever Settings emits the // shortcuts-changed event. useGlobalShortcuts(); + // UI zoom: hydrate the persisted level on boot, apply through + // Tauri's WebView `setZoom`, and listen for Ctrl+= / Ctrl+- / + // Ctrl+0 so users can tune density without diving into Settings. + useUiZoom(); // History entries carry their payload (album/artist/genre/playlist id, // wrapped year) directly so back/forward restore the exact target the // user visited — not whatever target was set most recently. Without diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index ca519bc..9ba63fa 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -34,6 +34,7 @@ import { Upload, Activity, Gauge, + ZoomIn, } from "lucide-react"; import { getProfileSetting, setProfileSetting } from "../../lib/tauri/profile"; import type { ViewId } from "../../types"; @@ -105,9 +106,15 @@ import { import { getAutoStart, getMinimizeToTray, + getUiZoom, setAutoStart as persistAutoStart, setMinimizeToTray as persistMinimizeToTray, + UI_ZOOM_CHANGED_EVENT, + UI_ZOOM_MAX, + UI_ZOOM_MIN, + UI_ZOOM_STEP, } from "../../lib/tauri/preferences"; +import { applyUiZoom } from "../../hooks/useUiZoom"; import { DuplicatesModal } from "../common/DuplicatesModal"; import { BackupCard } from "./settings/BackupCard"; import { EqualizerCard } from "./settings/EqualizerCard"; @@ -344,6 +351,10 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { const [isRescanning, setIsRescanning] = useState(false); const [autoStart, setAutoStart] = useState(false); const [minimizeToTray, setMinimizeToTray] = useState(true); + // UI zoom slider value. Hydrated from `app_setting` and kept in + // sync with the `Ctrl+=` / `Ctrl+-` / `Ctrl+0` shortcuts via the + // window-level event the `useUiZoom` hook broadcasts. + const [uiZoom, setUiZoom] = useState(1); const [scanOnStart, setScanOnStart] = useState(false); const [singleClickPlay, setSingleClickPlay] = useState(false); // Visibility toggles for the sleep-timer / A-B loop icons in the @@ -375,6 +386,12 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { setAutoStart(v); }) .catch((err) => console.error("[Settings] load auto_start", err)); + getUiZoom() + .then((v) => { + if (cancelled) return; + setUiZoom(v); + }) + .catch((err) => console.error("[Settings] load ui_zoom", err)); getProfileSetting("library.scan_on_start") .then((v) => { if (cancelled) return; @@ -412,6 +429,33 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { }; }, []); + // Keep the slider in sync when the user nudges zoom from the + // keyboard shortcuts (`Ctrl+=` / `Ctrl+-` / `Ctrl+0`) — those land + // in `useUiZoom` which broadcasts the new level via the window + // event. + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (typeof detail === "number") setUiZoom(detail); + }; + window.addEventListener(UI_ZOOM_CHANGED_EVENT, handler); + return () => window.removeEventListener(UI_ZOOM_CHANGED_EVENT, handler); + }, []); + + const handleZoomDelta = useCallback( + (delta: number) => { + applyUiZoom(uiZoom + delta) + .then(setUiZoom) + .catch((err) => console.error("[Settings] applyUiZoom failed", err)); + }, + [uiZoom], + ); + const handleZoomReset = useCallback(() => { + applyUiZoom(1) + .then(setUiZoom) + .catch((err) => console.error("[Settings] applyUiZoom failed", err)); + }, []); + const handleToggleSingleClickPlay = useCallback(() => { const next = !singleClickPlay; setSingleClickPlay(next); @@ -1306,6 +1350,55 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { /> + {/* UI zoom — same shape as VS Code / browser zoom. The + -/+/reset cluster on the right is a thin control band so + users with cramped 1080p screens can shrink everything + while 4K users can bump it up. Hooked to the same + keyboard shortcuts (Ctrl+=, Ctrl+-, Ctrl+0) via the + `useUiZoom` hook mounted on AppLayout. */} +
+
+
+
+ + + +
+
+ {/* Lancement au démarrage */}
diff --git a/src/hooks/useUiZoom.ts b/src/hooks/useUiZoom.ts new file mode 100644 index 0000000..b2d247a --- /dev/null +++ b/src/hooks/useUiZoom.ts @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; + +import { + getUiZoom, + setUiZoom, + UI_ZOOM_MAX, + UI_ZOOM_MIN, + UI_ZOOM_STEP, + UI_ZOOM_CHANGED_EVENT, +} from "../lib/tauri/preferences"; + +/** + * Hydrate the persisted UI zoom level on mount, apply it through + * Tauri's `setZoom` so the WebView scales natively (text stays crisp + * — this is not a CSS `transform: scale`), and listen for the global + * keyboard shortcuts `Ctrl+=` / `Ctrl+-` / `Ctrl+0` so power users can + * tune density without opening Settings. + * + * Mounted once at the AppLayout level. The Settings card reads its + * own state independently and rebroadcasts via + * [`UI_ZOOM_CHANGED_EVENT`] whenever the user nudges the slider, so + * the two surfaces stay in sync. + */ +export function useUiZoom() { + const [zoom, setZoomState] = useState(1); + + // Initial hydration: read once from app_setting, apply to the + // webview, mirror in state. Any failure leaves the default 1.0 + // zoom so the app stays usable. + useEffect(() => { + let cancelled = false; + getUiZoom() + .then(async (z) => { + if (cancelled) return; + setZoomState(z); + try { + await getCurrentWebviewWindow().setZoom(z); + } catch (err) { + console.error("[useUiZoom] setZoom failed", err); + } + }) + .catch((err) => console.error("[useUiZoom] getUiZoom failed", err)); + return () => { + cancelled = true; + }; + }, []); + + // Keep in sync with the Settings card (or any other surface) that + // rebroadcasts a zoom change. The event carries the new level as + // `detail` so we don't re-fetch from the backend on every tick. + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (typeof detail === "number") setZoomState(detail); + }; + window.addEventListener(UI_ZOOM_CHANGED_EVENT, handler); + return () => window.removeEventListener(UI_ZOOM_CHANGED_EVENT, handler); + }, []); + + // Keyboard shortcuts. Bound at the window level so they work + // regardless of which view has focus. `Ctrl+=` (often the same + // physical key as `Ctrl++`) zooms in, `Ctrl+-` zooms out, `Ctrl+0` + // resets to 100 %. Mirrors VS Code / Discord / Slack / browser + // conventions. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + // Don't fight an input — let typing land normally. + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return; + } + if (!(e.ctrlKey || e.metaKey)) return; + let next: number | null = null; + if (e.key === "+" || e.key === "=") { + next = clamp(zoom + UI_ZOOM_STEP); + } else if (e.key === "-" || e.key === "_") { + next = clamp(zoom - UI_ZOOM_STEP); + } else if (e.key === "0") { + next = 1; + } + if (next == null) return; + e.preventDefault(); + apply(next).catch((err) => + console.error("[useUiZoom] shortcut apply failed", err), + ); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [zoom]); + + return zoom; +} + +/** + * Imperative setter exported so the Settings slider can drive the + * zoom directly (rather than dispatching through a state mutation + * chain). Clamps, calls Tauri's `setZoom`, persists, and broadcasts. + */ +export async function applyUiZoom(zoom: number): Promise { + return apply(clamp(zoom)); +} + +function clamp(v: number): number { + if (!Number.isFinite(v)) return 1; + // Round to one decimal so a long chain of `+0.1` doesn't drift to + // `0.99999…` floating-point noise that the user can't easily reset + // to 1.0 by eye. + const stepped = Math.round(v * 10) / 10; + return Math.min(UI_ZOOM_MAX, Math.max(UI_ZOOM_MIN, stepped)); +} + +async function apply(zoom: number): Promise { + await getCurrentWebviewWindow().setZoom(zoom); + await setUiZoom(zoom); + window.dispatchEvent( + new CustomEvent(UI_ZOOM_CHANGED_EVENT, { detail: zoom }), + ); + return zoom; +} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index b1619cc..544731d 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1323,6 +1323,13 @@ "saveFailed": "فشل الحفظ. حاول مجددًا.", "runFailed": "فشل النسخ الاحتياطي. راجع السجلات." } + }, + "uiZoom": { + "title": "تكبير الواجهة", + "subtitle": "تكبير الواجهة بالكامل (Ctrl+= / Ctrl+- / Ctrl+0 تعمل أيضًا)", + "decreaseAria": "تصغير", + "increaseAria": "تكبير", + "resetAria": "إعادة التكبير إلى 100 %" } }, "scanProgress": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 871e8fc..dda602e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1319,6 +1319,13 @@ "saveFailed": "Speichern fehlgeschlagen. Erneut versuchen.", "runFailed": "Backup fehlgeschlagen. Logs prüfen." } + }, + "uiZoom": { + "title": "Oberflächen-Zoom", + "subtitle": "Gesamte Oberfläche skalieren (Ctrl+= / Ctrl+- / Ctrl+0 funktionieren ebenfalls)", + "decreaseAria": "Verkleinern", + "increaseAria": "Vergrößern", + "resetAria": "Zoom auf 100 % zurücksetzen" } }, "scanProgress": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4197669..c464784 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1385,6 +1385,13 @@ "saveFailed": "Failed to save. Try again.", "runFailed": "Backup failed. Check the logs." } + }, + "uiZoom": { + "title": "UI zoom", + "subtitle": "Scale the whole interface (Ctrl+= / Ctrl+- / Ctrl+0 also work)", + "decreaseAria": "Zoom out", + "increaseAria": "Zoom in", + "resetAria": "Reset zoom to 100 %" } }, "spotify": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 72bbaea..69cdbc3 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1319,6 +1319,13 @@ "saveFailed": "Error al guardar. Vuelve a intentarlo.", "runFailed": "Falló la copia. Consulta los registros." } + }, + "uiZoom": { + "title": "Zoom de la interfaz", + "subtitle": "Escalar toda la interfaz (Ctrl+= / Ctrl+- / Ctrl+0 también funcionan)", + "decreaseAria": "Reducir zoom", + "increaseAria": "Aumentar zoom", + "resetAria": "Restablecer al 100 %" } }, "scanProgress": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 959d260..d64e488 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1385,6 +1385,13 @@ "spoken_word": "Voix parlée", "treble_booster": "Boost aigus" } + }, + "uiZoom": { + "title": "Zoom de l’interface", + "subtitle": "Mettre l’app à l’échelle (Ctrl+= / Ctrl+- / Ctrl+0 fonctionnent aussi)", + "decreaseAria": "Dézoomer l’interface", + "increaseAria": "Zoomer l’interface", + "resetAria": "Réinitialiser le zoom à 100 %" } }, "spotify": { diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 424f4eb..642be8c 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1319,6 +1319,13 @@ "saveFailed": "सहेजने में विफल। फिर से कोशिश करें।", "runFailed": "बैकअप विफल। लॉग देखें।" } + }, + "uiZoom": { + "title": "यूआई ज़ूम", + "subtitle": "पूरा इंटरफ़ेस स्केल करें (Ctrl+= / Ctrl+- / Ctrl+0 भी चलते हैं)", + "decreaseAria": "ज़ूम घटाएं", + "increaseAria": "ज़ूम बढ़ाएं", + "resetAria": "ज़ूम को 100 % पर रीसेट करें" } }, "scanProgress": { diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index c13c1fa..176e581 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1318,6 +1318,13 @@ "saveFailed": "Gagal menyimpan. Coba lagi.", "runFailed": "Cadangan gagal. Periksa log." } + }, + "uiZoom": { + "title": "Zoom antarmuka", + "subtitle": "Skala seluruh antarmuka (Ctrl+= / Ctrl+- / Ctrl+0 juga berfungsi)", + "decreaseAria": "Perkecil", + "increaseAria": "Perbesar", + "resetAria": "Reset zoom ke 100 %" } }, "scanProgress": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index f250098..6522d0c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1319,6 +1319,13 @@ "saveFailed": "Salvataggio non riuscito. Riprova.", "runFailed": "Backup non riuscito. Controlla i log." } + }, + "uiZoom": { + "title": "Zoom dell’interfaccia", + "subtitle": "Ridimensionare tutta l’interfaccia (Ctrl+= / Ctrl+- / Ctrl+0 funzionano anche)", + "decreaseAria": "Riduci zoom", + "increaseAria": "Aumenta zoom", + "resetAria": "Ripristina al 100 %" } }, "scanProgress": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c7273c1..950769a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1318,6 +1318,13 @@ "saveFailed": "保存に失敗しました。もう一度お試しください。", "runFailed": "バックアップに失敗しました。ログを確認してください。" } + }, + "uiZoom": { + "title": "UI ズーム", + "subtitle": "インターフェース全体を拡大縮小 (Ctrl+= / Ctrl+- / Ctrl+0 も利用可)", + "decreaseAria": "縮小", + "increaseAria": "拡大", + "resetAria": "ズームを 100 % にリセット" } }, "scanProgress": { diff --git a/src/i18n/locales/kr.json b/src/i18n/locales/kr.json index 4139d15..b26e894 100644 --- a/src/i18n/locales/kr.json +++ b/src/i18n/locales/kr.json @@ -1318,6 +1318,13 @@ "saveFailed": "저장에 실패했습니다. 다시 시도하세요.", "runFailed": "백업에 실패했습니다. 로그를 확인하세요." } + }, + "uiZoom": { + "title": "UI 줄", + "subtitle": "전체 인터페이스 스케일링 (Ctrl+= / Ctrl+- / Ctrl+0 동작)", + "decreaseAria": "축소", + "increaseAria": "확대", + "resetAria": "줄을 100 %로 재설정" } }, "scanProgress": { diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index e59555c..208d372 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1319,6 +1319,13 @@ "saveFailed": "Opslaan mislukt. Probeer opnieuw.", "runFailed": "Back-up mislukt. Controleer de logs." } + }, + "uiZoom": { + "title": "Interface-zoom", + "subtitle": "Schaal de hele interface (Ctrl+= / Ctrl+- / Ctrl+0 werken ook)", + "decreaseAria": "Uitzoomen", + "increaseAria": "Inzoomen", + "resetAria": "Zoom terugzetten op 100 %" } }, "scanProgress": { diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 689a336..b028f3e 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1319,6 +1319,13 @@ "saveFailed": "Falha ao salvar. Tente novamente.", "runFailed": "Falha no backup. Consulte os logs." } + }, + "uiZoom": { + "title": "Zoom da interface", + "subtitle": "Redimensionar toda a interface (Ctrl+= / Ctrl+- / Ctrl+0 também funcionam)", + "decreaseAria": "Diminuir zoom", + "increaseAria": "Aumentar zoom", + "resetAria": "Redefinir para 100 %" } }, "scanProgress": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5133926..440386d 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1319,6 +1319,13 @@ "saveFailed": "Falha ao guardar. Tenta de novo.", "runFailed": "Falha na cópia. Consulta os registos." } + }, + "uiZoom": { + "title": "Zoom da interface", + "subtitle": "Dimensionar toda a interface (Ctrl+= / Ctrl+- / Ctrl+0 também funcionam)", + "decreaseAria": "Diminuir zoom", + "increaseAria": "Aumentar zoom", + "resetAria": "Repor para 100 %" } }, "scanProgress": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ba7b495..0a989b5 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1321,6 +1321,13 @@ "saveFailed": "Не удалось сохранить. Повторите.", "runFailed": "Резервная копия не создана. См. логи." } + }, + "uiZoom": { + "title": "Масштаб интерфейса", + "subtitle": "Масштабировать весь интерфейс (Ctrl+= / Ctrl+- / Ctrl+0 тоже работают)", + "decreaseAria": "Уменьшить", + "increaseAria": "Увеличить", + "resetAria": "Сбросить масштаб до 100 %" } }, "scanProgress": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index f34a301..1870464 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1318,6 +1318,13 @@ "saveFailed": "Kaydedilemedi. Tekrar deneyin.", "runFailed": "Yedekleme başarısız. Logları kontrol edin." } + }, + "uiZoom": { + "title": "Arayüz yakınlaştırma", + "subtitle": "Tüm arayüzü ölçeklendir (Ctrl+= / Ctrl+- / Ctrl+0 da çalışır)", + "decreaseAria": "Uzaklaştır", + "increaseAria": "Yakınlaştır", + "resetAria": "Yakınlaştırmayı %100'e sıfırla" } }, "scanProgress": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 94a387c..f20b455 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1318,6 +1318,13 @@ "saveFailed": "保存失败。请重试。", "runFailed": "备份失败。请查看日志。" } + }, + "uiZoom": { + "title": "界面缩放", + "subtitle": "缩放整个界面 (Ctrl+= / Ctrl+- / Ctrl+0 同样生效)", + "decreaseAria": "缩小", + "increaseAria": "放大", + "resetAria": "重置缩放为 100 %" } }, "scanProgress": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index b939c54..1243e45 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1318,6 +1318,13 @@ "saveFailed": "儲存失敗。請重試。", "runFailed": "備份失敗。請檢查記錄。" } + }, + "uiZoom": { + "title": "介面縮放", + "subtitle": "縮放整個介面 (Ctrl+= / Ctrl+- / Ctrl+0 同樣生效)", + "decreaseAria": "縮小", + "increaseAria": "放大", + "resetAria": "重置縮放為 100 %" } }, "scanProgress": { diff --git a/src/lib/tauri/preferences.ts b/src/lib/tauri/preferences.ts index 45eb79e..ab152a0 100644 --- a/src/lib/tauri/preferences.ts +++ b/src/lib/tauri/preferences.ts @@ -15,3 +15,24 @@ export function getAutoStart(): Promise { export function setAutoStart(enabled: boolean): Promise { return invoke("set_auto_start", { enabled }); } + +/** UI zoom level. Backend stores it in `app_setting` clamped to + * [0.5, 2.0]; the frontend mirrors the same bounds on the Settings + * slider and the keyboard-shortcut step, so all writes go through + * the same validated path. */ +export const UI_ZOOM_MIN = 0.5; +export const UI_ZOOM_MAX = 2.0; +export const UI_ZOOM_STEP = 0.1; + +export function getUiZoom(): Promise { + return invoke("get_ui_zoom"); +} + +export function setUiZoom(zoom: number): Promise { + return invoke("set_ui_zoom", { zoom }); +} + +/** Window-level event the keyboard shortcut handler dispatches every + * time it nudges the zoom, so the Settings card stays in sync + * without us having to plumb a context through. */ +export const UI_ZOOM_CHANGED_EVENT = "waveflow:ui-zoom-changed"; From f8546a3663ded400eb31af6b16036d215a31255f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 19 May 2026 02:10:21 +0200 Subject: [PATCH 2/4] fix(ui-zoom): grant the webview set-zoom permission 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). --- src-tauri/capabilities/default.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a8d9dda..ae84cb1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "core:window:allow-unminimize", "core:window:allow-close", "core:webview:allow-create-webview-window", + "core:webview:allow-set-webview-zoom", "opener:default", "dialog:default", "updater:default" From da2df9c7ed7054ddba6384c79b1d0872bc741e75 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 19 May 2026 02:25:52 +0200 Subject: [PATCH 3/4] fix(ui-zoom): race-free shortcuts + always broadcast + Korean label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/views/SettingsView.tsx | 38 +++++++++++++------ src/hooks/useUiZoom.ts | 53 +++++++++++++++++++++------ src/i18n/locales/kr.json | 4 +- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index 9ba63fa..c18d91e 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -442,18 +442,34 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { return () => window.removeEventListener(UI_ZOOM_CHANGED_EVENT, handler); }, []); - const handleZoomDelta = useCallback( - (delta: number) => { - applyUiZoom(uiZoom + delta) - .then(setUiZoom) - .catch((err) => console.error("[Settings] applyUiZoom failed", err)); - }, - [uiZoom], - ); + // Functional setter so rapid clicks accumulate from the latest + // committed value (and not from the value React captured at the + // click that triggered the handler). The fire-and-forget + // `applyUiZoom` runs on the side; its broadcast event reconciles + // the local state once it lands, so any clamping the backend does + // is reflected here without a second `setUiZoom` call racing the + // optimistic one we return. + const handleZoomDelta = useCallback((delta: number) => { + setUiZoom((prev) => { + const next = Math.min( + UI_ZOOM_MAX, + Math.max(UI_ZOOM_MIN, Math.round((prev + delta) * 10) / 10), + ); + if (next === prev) return prev; + applyUiZoom(next).catch((err) => + console.error("[Settings] applyUiZoom failed", err), + ); + return next; + }); + }, []); const handleZoomReset = useCallback(() => { - applyUiZoom(1) - .then(setUiZoom) - .catch((err) => console.error("[Settings] applyUiZoom failed", err)); + setUiZoom((prev) => { + if (prev === 1) return prev; + applyUiZoom(1).catch((err) => + console.error("[Settings] applyUiZoom failed", err), + ); + return 1; + }); }, []); const handleToggleSingleClickPlay = useCallback(() => { diff --git a/src/hooks/useUiZoom.ts b/src/hooks/useUiZoom.ts index b2d247a..98c44b7 100644 --- a/src/hooks/useUiZoom.ts +++ b/src/hooks/useUiZoom.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { @@ -24,6 +24,11 @@ import { */ export function useUiZoom() { const [zoom, setZoomState] = useState(1); + // Ref mirror of the latest committed zoom. The keyboard handler + // reads from this rather than the closed-over `zoom` so rapid + // `Ctrl+=` presses accumulate correctly even before React has had + // a chance to flush a re-render between events. + const zoomRef = useRef(1); // Initial hydration: read once from app_setting, apply to the // webview, mirror in state. Any failure leaves the default 1.0 @@ -33,6 +38,7 @@ export function useUiZoom() { getUiZoom() .then(async (z) => { if (cancelled) return; + zoomRef.current = z; setZoomState(z); try { await getCurrentWebviewWindow().setZoom(z); @@ -52,7 +58,10 @@ export function useUiZoom() { useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; - if (typeof detail === "number") setZoomState(detail); + if (typeof detail === "number") { + zoomRef.current = detail; + setZoomState(detail); + } }; window.addEventListener(UI_ZOOM_CHANGED_EVENT, handler); return () => window.removeEventListener(UI_ZOOM_CHANGED_EVENT, handler); @@ -62,7 +71,8 @@ export function useUiZoom() { // regardless of which view has focus. `Ctrl+=` (often the same // physical key as `Ctrl++`) zooms in, `Ctrl+-` zooms out, `Ctrl+0` // resets to 100 %. Mirrors VS Code / Discord / Slack / browser - // conventions. + // conventions. The listener attaches once and reads through + // `zoomRef.current` so rapid keystrokes accumulate correctly. useEffect(() => { const onKey = (e: KeyboardEvent) => { // Don't fight an input — let typing land normally. @@ -76,23 +86,31 @@ export function useUiZoom() { return; } if (!(e.ctrlKey || e.metaKey)) return; + const current = zoomRef.current; let next: number | null = null; if (e.key === "+" || e.key === "=") { - next = clamp(zoom + UI_ZOOM_STEP); + next = clamp(current + UI_ZOOM_STEP); } else if (e.key === "-" || e.key === "_") { - next = clamp(zoom - UI_ZOOM_STEP); + next = clamp(current - UI_ZOOM_STEP); } else if (e.key === "0") { next = 1; } - if (next == null) return; + if (next == null || next === current) return; e.preventDefault(); - apply(next).catch((err) => - console.error("[useUiZoom] shortcut apply failed", err), - ); + // Optimistically commit the ref so a second key event a few ms + // later accumulates from the new value. The broadcast event + // sync's `zoom` state once apply() returns. + zoomRef.current = next; + apply(next).catch((err) => { + // Roll back so a future keystroke doesn't compound on top of + // a target we never actually reached. + zoomRef.current = current; + console.error("[useUiZoom] shortcut apply failed", err); + }); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [zoom]); + }, []); return zoom; } @@ -116,8 +134,21 @@ function clamp(v: number): number { } async function apply(zoom: number): Promise { + // Visual change first — if this throws (e.g. missing capability), + // we abort the whole chain and don't dispatch, because nothing on + // screen actually moved. await getCurrentWebviewWindow().setZoom(zoom); - await setUiZoom(zoom); + // Persist second. If this fails the visual zoom already happened, + // so we log and continue: dropping the broadcast here would leave + // the Settings card stuck at the old percentage while the WebView + // scaled. Better to surface the new value everywhere and only lose + // the persistence (the user sees their change in-session; the next + // boot reverts to the old persisted value). + try { + await setUiZoom(zoom); + } catch (err) { + console.error("[useUiZoom] persist failed (visual zoom applied)", err); + } window.dispatchEvent( new CustomEvent(UI_ZOOM_CHANGED_EVENT, { detail: zoom }), ); diff --git a/src/i18n/locales/kr.json b/src/i18n/locales/kr.json index b26e894..a7eeeae 100644 --- a/src/i18n/locales/kr.json +++ b/src/i18n/locales/kr.json @@ -1320,11 +1320,11 @@ } }, "uiZoom": { - "title": "UI 줄", + "title": "UI 확대/축소", "subtitle": "전체 인터페이스 스케일링 (Ctrl+= / Ctrl+- / Ctrl+0 동작)", "decreaseAria": "축소", "increaseAria": "확대", - "resetAria": "줄을 100 %로 재설정" + "resetAria": "확대/축소를 100 %로 재설정" } }, "scanProgress": { From 5040b70d4b4bc835beb914c7df7296d965d5e02e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 19 May 2026 02:44:20 +0200 Subject: [PATCH 4/4] fix(ui-zoom): persist the zoom across restarts + bounds-check broadcasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands/preferences.rs | 7 ++++++- src/components/views/SettingsView.tsx | 12 ++++++++++-- src/hooks/useUiZoom.ts | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/preferences.rs b/src-tauri/src/commands/preferences.rs index 597d7fc..06f6f5e 100644 --- a/src-tauri/src/commands/preferences.rs +++ b/src-tauri/src/commands/preferences.rs @@ -157,9 +157,14 @@ pub async fn set_ui_zoom(state: tauri::State<'_, AppState>, zoom: f64) -> AppRes } else { 1.0 }; + // `app_setting.value_type` CHECK constraint only accepts + // `'string' | 'int' | 'bool' | 'json'` (initial migration). We + // serialize the zoom as a stringified float anyway, so + // `'string'` is the honest tag — adding `'real'` would require + // a migration that none of the persisted keys actually need. sqlx::query( "INSERT INTO app_setting (key, value, value_type, updated_at) - VALUES (?, ?, 'real', ?) + VALUES (?, ?, 'string', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", ) diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index c18d91e..8235bd7 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -432,11 +432,19 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { // Keep the slider in sync when the user nudges zoom from the // keyboard shortcuts (`Ctrl+=` / `Ctrl+-` / `Ctrl+0`) — those land // in `useUiZoom` which broadcasts the new level via the window - // event. + // event. Same defensive bounds check as the hook: the event is + // public on `window` so we don't trust arbitrary numbers. useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; - if (typeof detail === "number") setUiZoom(detail); + if ( + typeof detail === "number" && + Number.isFinite(detail) && + detail >= UI_ZOOM_MIN && + detail <= UI_ZOOM_MAX + ) { + setUiZoom(detail); + } }; window.addEventListener(UI_ZOOM_CHANGED_EVENT, handler); return () => window.removeEventListener(UI_ZOOM_CHANGED_EVENT, handler); diff --git a/src/hooks/useUiZoom.ts b/src/hooks/useUiZoom.ts index 98c44b7..3828c9b 100644 --- a/src/hooks/useUiZoom.ts +++ b/src/hooks/useUiZoom.ts @@ -58,7 +58,15 @@ export function useUiZoom() { useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; - if (typeof detail === "number") { + // Defensive bounds check: the event is public on `window`, so + // any code in the page could dispatch a `detail: 999` that + // would otherwise propagate into state and the WebView. + if ( + typeof detail === "number" && + Number.isFinite(detail) && + detail >= UI_ZOOM_MIN && + detail <= UI_ZOOM_MAX + ) { zoomRef.current = detail; setZoomState(detail); } @@ -93,7 +101,10 @@ export function useUiZoom() { } else if (e.key === "-" || e.key === "_") { next = clamp(current - UI_ZOOM_STEP); } else if (e.key === "0") { - next = 1; + // Defensive: 1.0 is always inside [MIN, MAX] today, but + // routing through `clamp` keeps the three branches uniform + // and survives any future widening of the constants. + next = clamp(1); } if (next == null || next === current) return; e.preventDefault();