From 32d2fffdc533854185a9cf8dac36d9debe3a6ffd Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Sun, 29 Mar 2026 12:40:28 -0500 Subject: [PATCH] Fix: Zoom Issue (Bug #14) --- dev.moku.app.yml | 2 +- packaging/cargo-sources.json | 30 ++-- src-tauri/src/lib.rs | 124 +++++---------- src/App.svelte | 37 ++++- src/components/reader/Reader.svelte | 199 ++++++++++++++++++------ src/components/settings/Settings.svelte | 55 +++++-- src/store/state.svelte.ts | 27 +++- 7 files changed, 304 insertions(+), 170 deletions(-) diff --git a/dev.moku.app.yml b/dev.moku.app.yml index 43c7792..dbdcd02 100644 --- a/dev.moku.app.yml +++ b/dev.moku.app.yml @@ -181,7 +181,7 @@ modules: path: . - type: file path: packaging/frontend-dist.tar.gz - sha256: a6b7b0f57210ea15b1a7ef580be9f89a667d647373abca4f34fe017a5ac8c850 + sha256: 3f18e4cc9153e28fd9020f7de22aac6dad1891034833b683c4bc0f5d0e04fc2b - packaging/cargo-sources.json - type: inline dest: src-tauri/.cargo diff --git a/packaging/cargo-sources.json b/packaging/cargo-sources.json index a4e0415..ea953c2 100644 --- a/packaging/cargo-sources.json +++ b/packaging/cargo-sources.json @@ -4201,14 +4201,14 @@ { "type": "archive", "archive-type": "tar-gzip", - "url": "https://static.crates.io/crates/rustc-hash/rustc-hash-2.1.1.crate", - "sha256": "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d", - "dest": "cargo/vendor/rustc-hash-2.1.1" + "url": "https://static.crates.io/crates/rustc-hash/rustc-hash-2.1.2.crate", + "sha256": "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe", + "dest": "cargo/vendor/rustc-hash-2.1.2" }, { "type": "inline", - "contents": "{\"package\": \"357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d\", \"files\": {}}", - "dest": "cargo/vendor/rustc-hash-2.1.1", + "contents": "{\"package\": \"94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe\", \"files\": {}}", + "dest": "cargo/vendor/rustc-hash-2.1.2", "dest-filename": ".cargo-checksum.json" }, { @@ -7503,27 +7503,27 @@ { "type": "archive", "archive-type": "tar-gzip", - "url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.47.crate", - "sha256": "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87", - "dest": "cargo/vendor/zerocopy-0.8.47" + "url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.48.crate", + "sha256": "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9", + "dest": "cargo/vendor/zerocopy-0.8.48" }, { "type": "inline", - "contents": "{\"package\": \"efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87\", \"files\": {}}", - "dest": "cargo/vendor/zerocopy-0.8.47", + "contents": "{\"package\": \"eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9\", \"files\": {}}", + "dest": "cargo/vendor/zerocopy-0.8.48", "dest-filename": ".cargo-checksum.json" }, { "type": "archive", "archive-type": "tar-gzip", - "url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.47.crate", - "sha256": "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89", - "dest": "cargo/vendor/zerocopy-derive-0.8.47" + "url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.48.crate", + "sha256": "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4", + "dest": "cargo/vendor/zerocopy-derive-0.8.48" }, { "type": "inline", - "contents": "{\"package\": \"0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89\", \"files\": {}}", - "dest": "cargo/vendor/zerocopy-derive-0.8.47", + "contents": "{\"package\": \"70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4\", \"files\": {}}", + "dest": "cargo/vendor/zerocopy-derive-0.8.48", "dest-filename": ".cargo-checksum.json" }, { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cb9f0b4..cdd14be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -104,14 +104,14 @@ fn get_storage_info(downloads_path: String) -> Result { }) } +/// Returns the OS/monitor DPI scale factor for the window's current monitor. +/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K, +/// 1.25–1.5 on Windows displays with OS-level scaling applied. +/// The frontend multiplies this by the user's uiZoom preference to get the +/// final effective zoom applied to document.documentElement. #[tauri::command] -fn get_platform_ui_scale() -> f64 { - #[cfg(target_os = "windows")] - return 1.0; - #[cfg(target_os = "macos")] - return 1.0; - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - return 1.5; +fn get_platform_ui_scale(window: tauri::Window) -> f64 { + window.scale_factor().unwrap_or(1.0) } fn kill_tachidesk(app: &tauri::AppHandle) { @@ -248,22 +248,7 @@ struct ServerInvocation { working_dir: Option, } -#[cfg(not(target_os = "macos"))] -fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option) -> Option { - #[cfg(target_os = "windows")] - let java = bundle_dir.join("jre").join("bin").join("java.exe"); - - #[cfg(not(target_os = "windows"))] - let java = bundle_dir.join("jre").join("bin").join("java"); - - do_log(log, &format!("[find_java] checking path: {:?}", java)); - do_log(log, &format!("[find_java] exists: {}", java.exists())); - - if java.exists() { Some(java) } else { None } -} - fn do_log(log: &mut Option, msg: &str) { - eprintln!("{}", msg); if let Some(f) = log { let _ = writeln!(f, "{}", msg); } @@ -276,81 +261,50 @@ fn resolve_server_binary( ) -> Result { do_log(log, &format!("[resolve] binary arg = {:?}", binary)); + // 1. User-specified binary path if !binary.trim().is_empty() { - do_log(log, "[resolve] using user-supplied binary path"); - return Ok(ServerInvocation { - bin: binary.to_string(), - args: vec![], - working_dir: None, - }); + let path = strip_unc(PathBuf::from(binary.trim())); + do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists())); + if path.exists() { + return Ok(ServerInvocation { + bin: path.to_string_lossy().into_owned(), + args: vec![], + working_dir: path.parent().map(|p| p.to_path_buf()), + }); + } + return Err(SpawnError::NotConfigured( + format!("Configured binary not found: {}", path.display()), + )); } - let resource_dir = match app.path().resource_dir() { - Ok(p) => { - let stripped = strip_unc(p); - do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped)); - stripped - } - Err(e) => { - let msg = format!("resource_dir error: {e}"); - do_log(log, &format!("[resolve] ERROR: {}", msg)); - return Err(SpawnError::SpawnFailed(msg)); - } - }; - + // 2. Bundled sidecar (Windows / Linux AppImage) #[cfg(not(target_os = "macos"))] { - let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle"); - let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar"); - - do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir)); - do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists())); - do_log(log, &format!("[resolve] jar = {:?}", jar)); - do_log(log, &format!("[resolve] jar exists: {}", jar.exists())); - - match find_java_in_bundle(&bundle_dir, log) { - Some(java) => { - do_log(log, &format!("[resolve] java found: {:?}", java)); - if jar.exists() { - do_log(log, "[resolve] both java and jar found — using bundled JRE"); - return Ok(ServerInvocation { - bin: java.to_string_lossy().into_owned(), - args: vec![ - "-jar".to_string(), - jar.to_string_lossy().into_owned(), - ], - working_dir: Some(bundle_dir), - }); - } else { - do_log(log, "[resolve] java found but jar MISSING — skipping bundled path"); - } - } - None => { - do_log(log, "[resolve] java NOT found in bundle — skipping bundled path"); + let resource_dir = app.path().resource_dir().unwrap_or_default(); + let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"]; + for name in &candidates { + let p = resource_dir.join(name); + do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists())); + if p.exists() { + do_log(log, &format!("[resolve] using sidecar: {:?}", p)); + return Ok(ServerInvocation { + bin: p.to_string_lossy().into_owned(), + args: vec![], + working_dir: Some(resource_dir), + }); } } } + // 3. macOS app bundle — look in MacOS/ and Resources/ #[cfg(target_os = "macos")] { - // Tauri places externalBin sidecars next to the main binary in - // Contents/MacOS/, not in Contents/Resources/. Derive that path - // from resource_dir (Contents/Resources → Contents/MacOS). - let macos_dir = resource_dir.join("../MacOS") - .canonicalize() - .unwrap_or_else(|_| resource_dir.join("../MacOS")); + let resource_dir = app.path().resource_dir().unwrap_or_default(); + let macos_dir = resource_dir.parent() + .map(|p| p.join("MacOS")) + .unwrap_or_default(); - do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir)); - - // Tauri strips the target triple when installing externalBin sidecars - // into Contents/MacOS/, so the binary is always just "suwayomi-server" - // at runtime. The triple-suffixed names are only needed on disk at - // build time for Tauri to pick the right arch during bundling. - let candidates = [ - "suwayomi-server", - "suwayomi-server-aarch64-apple-darwin", - "suwayomi-server-x86_64-apple-darwin", - ]; + let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"]; // Search MacOS/ first (correct location), then Resources/ as fallback // for flat dev layouts where the script sits next to resources. diff --git a/src/App.svelte b/src/App.svelte index 72321be..74d330d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -74,13 +74,23 @@ let notConfigured = $state(false); let idle = $state(false); let devSplash = $state(false); - let platformScale = $state(1); + // The OS/monitor DPI scale factor for the current display. + // Queried from Rust (window.scale_factor()) on mount and updated live + // whenever the window moves to a different monitor via the scaleChanged event. + // 1.0 = standard display, 2.0 = HiDPI/4K, 1.25–1.5 = Windows scaled display. + let platformScale = $state(1.0); + + // effectiveZoom = platformScale × uiZoom (user preference, float, default 1.0) + // Applied to document.documentElement so the entire UI scales correctly. function applyZoom() { - const normalized = store.settings.uiScale * platformScale; - document.documentElement.style.zoom = `${normalized}%`; - document.documentElement.style.setProperty("--ui-scale", String(normalized)); - document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`); + const uiZoom = store.settings.uiZoom ?? 1.5; + const effective = platformScale * uiZoom; + const pct = effective * 100; + document.documentElement.style.zoom = `${pct}%`; + document.documentElement.style.setProperty("--ui-scale", String(effective)); + // visual-vh compensates for the zoom so 100vh-based calculations stay correct. + document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`); } let prevQueue: DownloadQueueItem[] = []; @@ -125,8 +135,9 @@ return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle)); }); + // Re-apply zoom whenever uiZoom setting or platformScale changes. $effect(() => { - store.settings.uiScale; platformScale; + store.settings.uiZoom; platformScale; applyZoom(); }); @@ -214,14 +225,25 @@ document.addEventListener("contextmenu", e => e.preventDefault()); (window as any).__mokuShowSplash = () => devSplash = true; - platformScale = await invoke("get_platform_ui_scale").catch(() => 1); + // Fetch the real monitor scale factor from Rust (window.scale_factor()). + // This reflects actual DPI — 2.0 on HiDPI, 1.25 on Windows scaled displays, etc. + platformScale = await invoke("get_platform_ui_scale").catch(() => 1.0); applyZoom(); store.isFullscreen = await win.isFullscreen(); + const unlistenResize = await win.onResized(async () => { store.isFullscreen = await win.isFullscreen(); }); + // Re-query the scale factor when the window moves to a different monitor. + // Tauri emits this event whenever the DPI changes (e.g. dragging window + // from a 1080p display to a 4K display). + const unlistenScale = await win.onScaleChanged(async (event) => { + platformScale = event.payload.scaleFactor; + applyZoom(); + }); + if (store.settings.autoStartServer) { invoke("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => { if (err?.kind === "NotConfigured") { @@ -240,6 +262,7 @@ return () => { cancelProbe = true; unlistenResize(); + unlistenScale(); if (store.settings.autoStartServer) invoke("kill_server").catch(() => {}); if (idleTimer) clearTimeout(idleTimer); if (pollInterval) clearInterval(pollInterval); diff --git a/src/components/reader/Reader.svelte b/src/components/reader/Reader.svelte index 805ed57..9d99ec7 100644 --- a/src/components/reader/Reader.svelte +++ b/src/components/reader/Reader.svelte @@ -1,6 +1,6 @@ @@ -625,16 +689,37 @@ {:else}{/if} {fitLabel} + +
- +
+ + + +
{#if zoomOpen}
- updateSettings({ maxPageWidth: Number(e.currentTarget.value) })} /> - +
+ { captureZoomAnchor(); updateSettings({ readerZoom: clampZoom(Number(e.currentTarget.value) / 100) }); restoreZoomAnchor(); }} /> +
+
+ {#each [50, 75, 100, 125, 150, 200] as pct} + + {/each} +
+
{/if}
+ @@ -666,7 +751,7 @@ bind:this={containerEl} class="viewer" class:strip={style === "longstrip"} - style="--max-page-width:{maxW}px" + style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""} role="presentation" tabindex="-1" onclick={handleTap} @@ -770,25 +855,51 @@ .mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); } .mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); } .mode-label { text-transform: capitalize; } + + /* ── Zoom controls ───────────────────────────────────────────────────────── */ .zoom-wrap { position: relative; flex-shrink: 0; } - .zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); } - .zoom-btn:hover { color: var(--text-secondary); background: var(--bg-raised); } - .zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; } - .zoom-slider { width: 140px; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; } + .zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; } + .zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); } + .zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } + .zoom-step-btn:disabled { opacity: 0.25; cursor: default; } + .zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); } + .zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); } + .zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; } + .zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); } + .zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; } .zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; } - .zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); } - .zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); } + .zoom-presets { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; } + .zoom-preset { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); padding: 3px 6px; border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); } + .zoom-preset:hover { color: var(--text-primary); background: var(--bg-overlay); } + .zoom-preset.active { color: var(--accent-fg); background: var(--accent-muted); } + .zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); } + .zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); } + .zoom-reset:disabled { opacity: 0.3; cursor: default; } + + /* ── Viewer ──────────────────────────────────────────────────────────────── */ .viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; } .viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; } .viewer:focus { outline: none; } .img { display: block; user-select: none; image-rendering: auto; } .img.optimize-contrast { image-rendering: -webkit-optimize-contrast; } - .fit-width { max-width: var(--max-page-width); width: 100%; height: auto; } - .fit-height { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; } - .fit-screen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; } + + /* + * Fit modes — all constrain within --effective-width (the zoom-adjusted + * container width). effectiveWidth is set as a CSS variable on .viewer + * so every fit class automatically respects the current zoom level. + * + * fit-width : fills up to effectiveWidth, never wider + * fit-height : constrained to viewport height; never taller, never wider than effectiveWidth + * fit-screen : fits within both axes (contain); never wider than effectiveWidth + * fit-original : natural image size, no constraint + */ + .fit-width { max-width: var(--effective-width, 100%); width: 100%; height: auto; } + .fit-height { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; } + .fit-screen { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; } .fit-original { max-width: none; width: auto; height: auto; } + .strip-gap { margin-bottom: 8px; } - .double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--max-page-width) * 2); width: 100%; } + .double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; } .page-half { flex: 1; min-width: 0; object-fit: contain; } .gap-left { margin-right: 2px; } .gap-right { margin-left: 2px; } diff --git a/src/components/settings/Settings.svelte b/src/components/settings/Settings.svelte index 59c45c0..a36d247 100644 --- a/src/components/settings/Settings.svelte +++ b/src/components/settings/Settings.svelte @@ -717,28 +717,30 @@

Interface Scale

- updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" /> + updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })} + class="scale-slider" /> { const n = parseInt(e.currentTarget.value, 10); - if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiScale: n }); + if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 }); }} onblur={(e) => { const n = parseInt(e.currentTarget.value, 10); - if (isNaN(n) || n < 50) { updateSettings({ uiScale: 50 }); e.currentTarget.value = "50"; } - else if (n > 200) { updateSettings({ uiScale: 200 }); e.currentTarget.value = "200"; } + if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = "50"; } + else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = "200"; } }} /> % - +

{#each [50,60,70,80,90,100,110,125,150,175,200] as v} - + {/each}

@@ -931,13 +933,40 @@
-
Max page widthPixel cap for fit-width mode.
-
- - {store.settings.maxPageWidth ?? 900}px - +
+ Default zoom + Starting zoom when opening a chapter. 100% = fills the reader. +
+
+ updateSettings({ readerZoom: Number(e.currentTarget.value) / 100 })} + class="scale-slider" /> + { + const n = parseInt(e.currentTarget.value, 10); + if (!isNaN(n) && n >= 10 && n <= 400) updateSettings({ readerZoom: n / 100 }); + }} + onblur={(e) => { + const n = parseInt(e.currentTarget.value, 10); + if (isNaN(n) || n < 10) { updateSettings({ readerZoom: 0.1 }); e.currentTarget.value = "10"; } + else if (n > 400) { updateSettings({ readerZoom: 4.0 }); e.currentTarget.value = "400"; } + }} + /> + % +
+

+ {#each [50, 75, 100, 125, 150, 200] as v} + + {/each} +