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" diff --git a/src-tauri/src/commands/preferences.rs b/src-tauri/src/commands/preferences.rs index 5127343..06f6f5e 100644 --- a/src-tauri/src/commands/preferences.rs +++ b/src-tauri/src/commands/preferences.rs @@ -113,3 +113,65 @@ 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 + }; + // `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 (?, ?, 'string', ?) + 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..8235bd7 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,57 @@ 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. 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" && + 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); + }, []); + + // 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(() => { + setUiZoom((prev) => { + if (prev === 1) return prev; + applyUiZoom(1).catch((err) => + console.error("[Settings] applyUiZoom failed", err), + ); + return 1; + }); + }, []); + const handleToggleSingleClickPlay = useCallback(() => { const next = !singleClickPlay; setSingleClickPlay(next); @@ -1306,6 +1374,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..3828c9b --- /dev/null +++ b/src/hooks/useUiZoom.ts @@ -0,0 +1,167 @@ +import { useEffect, useRef, 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); + // 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 + // zoom so the app stays usable. + useEffect(() => { + let cancelled = false; + getUiZoom() + .then(async (z) => { + if (cancelled) return; + zoomRef.current = z; + 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; + // 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); + } + }; + 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. 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. + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return; + } + if (!(e.ctrlKey || e.metaKey)) return; + const current = zoomRef.current; + let next: number | null = null; + if (e.key === "+" || e.key === "=") { + next = clamp(current + UI_ZOOM_STEP); + } else if (e.key === "-" || e.key === "_") { + next = clamp(current - UI_ZOOM_STEP); + } else if (e.key === "0") { + // 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(); + // 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); + }, []); + + 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 { + // 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); + // 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 }), + ); + 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..a7eeeae 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";