Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 25 additions & 21 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,36 +347,40 @@ 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<String> = Vec::new();

if disable_hw_accel {
args.push("--disable-gpu".to_string());
args.push("--disable-gpu-compositing".to_string());
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);
Expand Down
50 changes: 46 additions & 4 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'
Expand Down Expand Up @@ -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**.
*
Expand Down Expand Up @@ -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))
}

Expand Down
76 changes: 76 additions & 0 deletions src/services/windowFit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
90 changes: 90 additions & 0 deletions tests/unit/services/windowFit.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})