diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0918ecc..4ae4ff8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -347,28 +347,30 @@ pub fn run() { .unwrap_or_default() .eq_ignore_ascii_case("true"); - // NOTE: we deliberately do NOT pass `--force-device-scale-factor=1`. + // NOTE: we deliberately do NOT pass any scale-related browser flags. // - // That flag (from #257's physical-px "DPI-immune" layout) pinned the - // webview to 1:1 physical pixels. But #326 switched the router's - // `fitWindow` to size the OS window with `LogicalSize`, which Tauri - // multiplies by the window's OS scale factor. With the webview still - // rendering 1:1, at >100% Windows scaling the window came out - // `scale`× larger than the 1:1 content → the reported "window too big, - // empty space around the content" bug. Letting the webview's - // `devicePixelRatio` follow the OS scale makes `LogicalSize` and the - // CSS-px measurements in `fitWindow` (which already assume - // `screen.availWidth` is CSS px) consistent, so the window matches the - // content at every scaling. + // `--force-device-scale-factor=1` (from #257's physical-px + // "DPI-immune" layout) pinned the webview to 1:1 physical pixels. + // But #326 switched the router's `fitWindow` to size the OS window + // with `LogicalSize`, which Tauri multiplies by the window's OS + // scale factor. With the webview still rendering 1:1, at >100% + // Windows scaling the window came out `scale`× larger than the 1:1 + // content → the reported "window too big, empty space around the + // content" bug (#337). Letting the webview's `devicePixelRatio` + // follow the OS scale makes `LogicalSize` and the CSS-px + // measurements in `fitWindow` consistent at every display scaling. // - // `--force-text-scale-factor=1` stays: it's an orthogonal accessibility - // guard (Windows "Text size") and does not touch device scaling. - let mut args = vec![ - // Treat Windows Accessibility "Text size" as 100% so a user's - // text-size setting does not inflate the app layout. - "--force-text-scale-factor=1".to_string(), - ]; - tracing::info!("forcing WebView2 text scale factor to 1 (device scale left to the OS)"); + // `--force-text-scale-factor=1` (which #337 kept as a "Text size" + // guard) is NOT a real Chromium switch — it matches nothing in the + // Chromium source and was silently ignored, so Windows + // Accessibility "Text size" ≥ 130% clipped the app again. The text + // scale reaches the webview via WebView2's rasterization scale + // (monitor DPI × text scale, surfaced as `devicePixelRatio`) and is + // now compensated in the frontend resizer instead: `fitWindow` + // multiplies its `LogicalSize` by the measured text-scale ratio so + // the window grows to fit the enlarged text rather than fighting it + // (see src/services/windowFit.ts). + let mut args: Vec = Vec::new(); if disable_hw_accel { args.push("--disable-gpu".to_string()); @@ -376,7 +378,9 @@ pub fn run() { tracing::info!("hardware acceleration disabled via Config.xml"); } - std::env::set_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", args.join(" ")); + if !args.is_empty() { + std::env::set_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", args.join(" ")); + } } let app_state = AppState::new(storage_root); diff --git a/src/router/index.ts b/src/router/index.ts index c3dc93d..9a79397 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -84,7 +84,12 @@ import { getCurrentWebview } from '@tauri-apps/api/webview' import { LogicalSize } from '@tauri-apps/api/dpi' import { registerSessionExpiredHandler } from '../services/invoke' -import { isWindowFitSuspended } from '../services/windowFit' +import { + isWindowFitSuspended, + setWindowScaleFactor, + setAppliedWebviewZoom, + textScaleFactor, +} from '../services/windowFit' /** * Resize the Tauri window to the given **logical** (DPI-independent) @@ -101,9 +106,16 @@ import { isWindowFitSuspended } from '../services/windowFit' * * Exported so individual pages can call it on mount when their content * height differs from the route meta default. + * + * The dimensions are treated as **CSS px** and multiplied by the system + * text scale (see {@link textScaleFactor}) so the window still contains + * its content when Windows Accessibility "Text size" is above 100%. */ export function resizeWindow(width: number, height: number): void { - void getCurrentWindow().setSize(new LogicalSize(Math.ceil(width), Math.ceil(height))) + const scale = textScaleFactor(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1) + void getCurrentWindow().setSize( + new LogicalSize(Math.ceil(width * scale), Math.ceil(height * scale)), + ) } import LoginPage from '../pages/LoginPage.vue' @@ -563,6 +575,29 @@ export function installRouterGuards(router: Router, deps: RouterGuardDeps): void let observer: ResizeObserver | null = null let pendingFrame: number | null = null + /* + * Track the OS window scale factor so `textScaleFactor()` can split + * `devicePixelRatio` (= DPI scale × text scale × zoom) into its parts. + * A text-scale change re-fits without extra wiring: it changes the + * webview's rasterization scale, which shrinks the CSS-px viewport, + * which fires the `resize` listener below. `typeof` guards keep the + * jsdom window mock (no scaleFactor/onScaleChanged) working. + */ + if (typeof appWindow.scaleFactor === 'function') { + void appWindow + .scaleFactor() + .then(setWindowScaleFactor) + .catch(() => {}) + } + if (typeof appWindow.onScaleChanged === 'function') { + void appWindow + .onScaleChanged((event) => { + setWindowScaleFactor(event.payload.scaleFactor) + scheduleOnNextPaint(fitWindow) + }) + .catch(() => {}) + } + /** * Available work area (screen minus taskbar) in **logical / CSS px**. * @@ -629,10 +664,17 @@ export function installRouterGuards(router: Router, deps: RouterGuardDeps): void const capH = Math.max(300, Math.floor(area.h * 0.95)) const zoom = Math.min(1, capW / naturalW, capH / naturalH) - const wLogical = Math.max(320, Math.round(naturalW * zoom)) - const hLogical = Math.max(300, Math.round(naturalH * zoom)) + // CSS px → Tauri logical px. At text size 100% this is 1 and the + // sizes pass through unchanged; at 130% the window grows 1.3× so the + // text-scaled content still fits (the accessibility regression from + // dropping #257's --force-device-scale-factor in #337). + const cssToLogical = textScaleFactor(window.devicePixelRatio || 1) + + const wLogical = Math.max(320, Math.round(naturalW * zoom * cssToLogical)) + const hLogical = Math.max(300, Math.round(naturalH * zoom * cssToLogical)) root.style.height = '100vh' void getCurrentWebview().setZoom(zoom) + setAppliedWebviewZoom(zoom) void appWindow.setSize(new LogicalSize(wLogical, hLogical)) } diff --git a/src/services/windowFit.ts b/src/services/windowFit.ts index d66754a..0e8b110 100644 --- a/src/services/windowFit.ts +++ b/src/services/windowFit.ts @@ -23,3 +23,79 @@ export function isWindowFitSuspended(): boolean { export function setWindowFitSuspended(value: boolean): void { suspended = value } + +/* + * --------------------------------------------------------------------- + * Windows Accessibility "Text size" compensation (issue: text size + * ≥ 130% clips the app; regressed when #337 removed #257's + * `--force-device-scale-factor=1`). + * + * WebView2's rasterization scale is `monitor DPI scale × system text + * scale`, and the webview surfaces the combined value as + * `devicePixelRatio`. Tauri's `LogicalSize`, however, multiplies by the + * *window's* scale factor only (monitor DPI). So at text size 130% one + * CSS px paints 1.3× larger than the logical px the window was sized + * in, and the content overflows the window by exactly that ratio. + * + * The fix: derive the text-scale ratio at runtime — + * + * textScale = devicePixelRatio / (windowScaleFactor × webviewZoom) + * + * — and multiply it into every `LogicalSize` the resizer emits. Big + * text stays big (accessibility respected, unlike #257's approach of + * forcing the whole webview to 100%), the window simply grows to fit, + * and at text size 100% the ratio is 1 so nothing changes. + * + * `webviewZoom` must be divided out because the router's `fitWindow` + * applies `setZoom(<1)` to shrink oversized content, and browser zoom + * also multiplies `devicePixelRatio`. + * + * State lives here (not in router/index.ts) so it is unit-testable + * without pulling the router's Tauri/page imports into jsdom. + */ + +let windowScaleFactor: number | null = null +let webviewZoom = 1 + +/** + * Record the OS window scale factor (from `Window.scaleFactor()` / + * `onScaleChanged`). Non-finite or non-positive values are ignored. + */ +export function setWindowScaleFactor(factor: number): void { + if (Number.isFinite(factor) && factor > 0) windowScaleFactor = factor +} + +/** + * Record the zoom the resizer last applied via `Webview.setZoom()` so + * it can be divided back out of `devicePixelRatio`. + */ +export function setAppliedWebviewZoom(zoom: number): void { + if (Number.isFinite(zoom) && zoom > 0) webviewZoom = zoom +} + +/** + * Multiplier converting CSS px to Tauri logical px — the system text + * scale (1.0 at 100%, 1.3 at 130%, …). + * + * Returns 1 until {@link setWindowScaleFactor} has reported a real OS + * scale (pre-IPC, jsdom), and clamps to [1, 3]: Windows offers + * 100–225% text size, so values outside that band are transient + * mid-resize readings or a user's persisted Ctrl±zoom, not a text + * scale — treated as 1 rather than propagated into the window size. + * (Persisted zoom-in > 1 within the band intentionally passes through: + * growing the window to fit zoomed content is the desired outcome + * there too.) + */ +export function textScaleFactor(devicePixelRatio: number): number { + if (windowScaleFactor === null || !Number.isFinite(devicePixelRatio) || devicePixelRatio <= 0) { + return 1 + } + const factor = devicePixelRatio / (windowScaleFactor * webviewZoom) + return factor >= 1 && factor <= 3 ? factor : 1 +} + +/** Test-only: return the module to its pristine pre-tracking state. */ +export function resetWindowScaleTrackingForTests(): void { + windowScaleFactor = null + webviewZoom = 1 +} diff --git a/tests/unit/services/windowFit.spec.ts b/tests/unit/services/windowFit.spec.ts new file mode 100644 index 0000000..060143b --- /dev/null +++ b/tests/unit/services/windowFit.spec.ts @@ -0,0 +1,90 @@ +/** + * Specs for the Windows Accessibility "Text size" compensation in + * services/windowFit.ts — the `textScaleFactor` maths that keeps the + * OS window sized to its text-scaled content (regression: text size + * ≥ 130% clipped the app after #337 dropped #257's + * `--force-device-scale-factor=1`). + */ + +import { describe, it, expect, beforeEach } from 'vitest' +import { + textScaleFactor, + setWindowScaleFactor, + setAppliedWebviewZoom, + resetWindowScaleTrackingForTests, +} from '../../../src/services/windowFit' + +describe('textScaleFactor', () => { + beforeEach(() => { + resetWindowScaleTrackingForTests() + }) + + it('returns 1 before the OS scale factor has been reported', () => { + // Pre-IPC / jsdom: no basis to split devicePixelRatio, so pass + // sizes through unchanged (the pre-fix behaviour). + expect(textScaleFactor(1.3)).toBe(1) + }) + + it('returns 1 at text size 100% on a 100% display', () => { + setWindowScaleFactor(1) + expect(textScaleFactor(1)).toBe(1) + }) + + it('extracts the text scale on a 100% display', () => { + // Windows text size 130%: devicePixelRatio = 1 × 1.3. + setWindowScaleFactor(1) + expect(textScaleFactor(1.3)).toBeCloseTo(1.3) + }) + + it('divides out the display scale at >100% scaling', () => { + // 150% display scaling with text size 130%: + // devicePixelRatio = 1.5 × 1.3 = 1.95, factor must be 1.3 (the + // display part is already handled by Tauri's LogicalSize). + setWindowScaleFactor(1.5) + expect(textScaleFactor(1.95)).toBeCloseTo(1.3) + }) + + it('returns 1 at >100% display scaling with text size 100%', () => { + // The #337 case that must NOT regress: window already matches + // content, no extra multiplication allowed. + setWindowScaleFactor(1.25) + expect(textScaleFactor(1.25)).toBe(1) + }) + + it('divides out the applied webview zoom', () => { + // fitWindow shrank oversized content to zoom 0.8 on a 100% display + // with text size 130%: devicePixelRatio = 1 × 1.3 × 0.8 = 1.04. + setWindowScaleFactor(1) + setAppliedWebviewZoom(0.8) + expect(textScaleFactor(1.04)).toBeCloseTo(1.3) + }) + + it('treats sub-1 ratios (persisted Ctrl-zoom-out) as 1', () => { + setWindowScaleFactor(1) + expect(textScaleFactor(0.8)).toBe(1) + }) + + it('treats ratios above 3 (junk readings) as 1', () => { + // Windows text size tops out at 225%; anything above 3 is a + // transient mid-resize reading, not a real text scale. + setWindowScaleFactor(1) + expect(textScaleFactor(3.5)).toBe(1) + }) + + it('ignores invalid scale factors and zooms', () => { + setWindowScaleFactor(0) + setWindowScaleFactor(Number.NaN) + expect(textScaleFactor(1.3)).toBe(1) // still untracked + + setWindowScaleFactor(1) + setAppliedWebviewZoom(0) + setAppliedWebviewZoom(Number.NaN) + expect(textScaleFactor(1.3)).toBeCloseTo(1.3) // zoom still 1 + }) + + it('ignores invalid devicePixelRatio readings', () => { + setWindowScaleFactor(1) + expect(textScaleFactor(0)).toBe(1) + expect(textScaleFactor(Number.NaN)).toBe(1) + }) +})