🐛 fix(window): 适配分辨率变化后的窗口边界

- 运行期检测普通窗口是否超出当前可用屏幕并自动收回
- 启动恢复时同步裁剪超过当前屏幕的窗口尺寸

Fixes #594
This commit is contained in:
Syngnat
2026-06-27 11:17:42 +08:00
parent bfb61c8449
commit 080ae0986a
4 changed files with 156 additions and 13 deletions

View File

@@ -350,9 +350,13 @@ describe('tool center menu entries', () => {
it('captures window state on startup and lifecycle events instead of waiting only for the polling interval', () => {
expect(appSource).toContain('const scheduleWindowStateSave = (delayMs = 120) => {');
expect(appSource).toContain('const scheduleWindowBoundsRepair = (delayMs = 80) => {');
expect(appSource).toContain('if (hydrated) {');
expect(appSource).toContain('scheduleWindowBoundsRepair(360);');
expect(appSource).toContain('scheduleWindowStateSave(320);');
expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {');
expect(appSource).toContain('scheduleWindowBoundsRepair();');
expect(appSource).toContain('scheduleWindowStateSave(260);');
expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);");
@@ -360,6 +364,15 @@ describe('tool center menu entries', () => {
expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });");
});
it('clamps normal runtime window bounds back into the visible screen after display changes', () => {
expect(appSource).toContain('const readCurrentVisibleViewport = () => ({');
expect(appSource).toContain('const repairRuntimeWindowBounds = async () => {');
expect(appSource).toContain('const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());');
expect(appSource).toContain("void emitWindowDiagnostic('adjust:runtime-window-bounds'");
expect(appSource).toContain('WindowSetSize(nextBounds.width, nextBounds.height);');
expect(appSource).toContain('WindowSetPosition(nextBounds.x, nextBounds.y);');
});
it('keeps titlebar double-click on maximise while shortcuts may enter macOS fullscreen', () => {
expect(appSource).toContain('const handleTitleBarWindowToggle = async (options?: { allowMacNativeFullscreen?: boolean }) => {');
expect(appSource).toContain('const allowMacNativeFullscreen = options?.allowMacNativeFullscreen === true;');

View File

@@ -154,6 +154,13 @@ const detectNavigatorPlatform = (): string => {
return navigator.userAgent || '';
};
const readCurrentVisibleViewport = () => ({
availWidth: window.screen?.availWidth || window.innerWidth || 0,
availHeight: window.screen?.availHeight || window.innerHeight || 0,
availLeft: (window.screen as Screen & { availLeft?: number })?.availLeft || 0,
availTop: (window.screen as Screen & { availTop?: number })?.availTop || 0,
});
const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnection[]): SavedConnection[] => {
const merged = new Map<string, SavedConnection>();
@@ -747,12 +754,7 @@ function App() {
const bounds = state.windowBounds;
if (!bounds || bounds.width < 400 || bounds.height < 300) return;
try {
const nextBounds = resolveVisibleStartupWindowBounds(bounds, {
availWidth: window.screen?.availWidth || 0,
availHeight: window.screen?.availHeight || 0,
availLeft: (window.screen as Screen & { availLeft?: number })?.availLeft || 0,
availTop: (window.screen as Screen & { availTop?: number })?.availTop || 0,
});
const nextBounds = resolveVisibleStartupWindowBounds(bounds, readCurrentVisibleViewport());
if (
nextBounds.x !== bounds.x ||
nextBounds.y !== bounds.y ||
@@ -797,6 +799,7 @@ function App() {
let cancelled = false;
let hydrated = useStore.persist.hasHydrated();
let eventSaveTimer: number | null = null;
let boundsRepairTimer: number | null = null;
let lastSaved = '';
const saveWindowState = async () => {
@@ -859,13 +862,79 @@ function App() {
}, delayMs);
};
const repairRuntimeWindowBounds = async () => {
if (cancelled || !hydrated) {
return;
}
try {
const [isFs, isMax] = await Promise.all([
safeWindowRuntimeCall(() => WindowIsFullscreen(), false),
safeWindowRuntimeCall(() => WindowIsMaximised(), false),
]);
if (isFs || isMax) {
return;
}
const [size, pos] = await Promise.all([
safeWindowRuntimeCall(() => WindowGetSize(), null),
safeWindowRuntimeCall(() => WindowGetPosition(), null),
]);
if (!size || !pos) {
return;
}
const currentBounds = {
width: Math.trunc(Number(size.w || 0)),
height: Math.trunc(Number(size.h || 0)),
x: Math.trunc(Number(pos.x || 0)),
y: Math.trunc(Number(pos.y || 0)),
};
if (currentBounds.width <= 0 || currentBounds.height <= 0) {
return;
}
const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());
if (
nextBounds.x === currentBounds.x &&
nextBounds.y === currentBounds.y &&
nextBounds.width === currentBounds.width &&
nextBounds.height === currentBounds.height
) {
return;
}
void emitWindowDiagnostic('adjust:runtime-window-bounds', {
from: currentBounds,
to: nextBounds,
});
WindowSetSize(nextBounds.width, nextBounds.height);
WindowSetPosition(nextBounds.x, nextBounds.y);
lastSaved = `${nextBounds.width},${nextBounds.height},${nextBounds.x},${nextBounds.y}`;
useStore.getState().setWindowBounds(nextBounds);
window.dispatchEvent(new Event('resize'));
} catch {
// Wails runtime window APIs are best-effort here.
}
};
const scheduleWindowBoundsRepair = (delayMs = 80) => {
if (cancelled || !hydrated) {
return;
}
if (boundsRepairTimer !== null) {
window.clearTimeout(boundsRepairTimer);
}
boundsRepairTimer = window.setTimeout(() => {
boundsRepairTimer = null;
void repairRuntimeWindowBounds();
}, delayMs);
};
const handleWindowRuntimeChange = () => {
scheduleWindowStateSave();
scheduleWindowBoundsRepair();
scheduleWindowStateSave(260);
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
scheduleWindowStateSave(120);
scheduleWindowBoundsRepair();
scheduleWindowStateSave(260);
}
};
@@ -874,6 +943,7 @@ function App() {
};
if (hydrated) {
scheduleWindowBoundsRepair(360);
scheduleWindowStateSave(320);
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
@@ -881,6 +951,7 @@ function App() {
return;
}
hydrated = true;
scheduleWindowBoundsRepair(360);
scheduleWindowStateSave(320);
});
@@ -898,6 +969,9 @@ function App() {
if (eventSaveTimer !== null) {
window.clearTimeout(eventSaveTimer);
}
if (boundsRepairTimer !== null) {
window.clearTimeout(boundsRepairTimer);
}
window.clearInterval(timer);
window.removeEventListener('resize', handleWindowRuntimeChange);
window.removeEventListener('focus', handleWindowRuntimeChange);

View File

@@ -23,4 +23,18 @@ describe('windowRestoreBounds', () => {
{ availWidth: 1600, availHeight: 900, availLeft: 0, availTop: 0 },
)).toEqual({ width: 900, height: 640, x: 350, y: 130 });
});
it('shrinks a restored window that is larger than the current visible screen', () => {
expect(resolveVisibleStartupWindowBounds(
{ width: 2560, height: 1440, x: 0, y: 0 },
{ availWidth: 1440, availHeight: 860, availLeft: 0, availTop: 25 },
)).toEqual({ width: 1440, height: 860, x: 0, y: 25 });
});
it('keeps oversized restored bounds inside an offset visible screen', () => {
expect(resolveVisibleStartupWindowBounds(
{ width: 2400, height: 1200, x: 1800, y: 60 },
{ availWidth: 1728, availHeight: 1040, availLeft: 1728, availTop: 40 },
)).toEqual({ width: 1728, height: 1040, x: 1728, y: 40 });
});
});

View File

@@ -14,6 +14,31 @@ type VisibleViewport = {
const MIN_VISIBLE_WIDTH = 160;
const MIN_VISIBLE_HEIGHT = 120;
const MIN_RESTORED_WIDTH = 400;
const MIN_RESTORED_HEIGHT = 300;
const resolveVisibleDimension = (
rawDimension: number,
visibleDimension: number,
minimumDimension: number,
): number => {
const dimension = Math.trunc(Number(rawDimension) || 0);
if (visibleDimension <= 0 || dimension <= 0) {
return dimension;
}
const minimum = Math.min(minimumDimension, visibleDimension);
return Math.min(Math.max(dimension, minimum), visibleDimension);
};
const clampPosition = (
position: number,
dimension: number,
visibleStart: number,
visibleDimension: number,
): number => {
const maxPosition = visibleStart + Math.max(0, visibleDimension - dimension);
return Math.min(Math.max(position, visibleStart), maxPosition);
};
export const resolveVisibleStartupWindowBounds = (
bounds: WindowRestoreBounds,
@@ -29,19 +54,36 @@ export const resolveVisibleStartupWindowBounds = (
const visibleTop = Math.trunc(Number(viewport.availTop) || 0);
const visibleRight = visibleLeft + visibleWidth;
const visibleBottom = visibleTop + visibleHeight;
const nextWidth = resolveVisibleDimension(bounds.width, visibleWidth, MIN_RESTORED_WIDTH);
const nextHeight = resolveVisibleDimension(bounds.height, visibleHeight, MIN_RESTORED_HEIGHT);
const resizedBounds: WindowRestoreBounds = {
...bounds,
width: nextWidth,
height: nextHeight,
};
const overlapWidth = Math.min(bounds.x + bounds.width, visibleRight) - Math.max(bounds.x, visibleLeft);
const overlapHeight = Math.min(bounds.y + bounds.height, visibleBottom) - Math.max(bounds.y, visibleTop);
const overlapWidth = Math.min(resizedBounds.x + resizedBounds.width, visibleRight) - Math.max(resizedBounds.x, visibleLeft);
const overlapHeight = Math.min(resizedBounds.y + resizedBounds.height, visibleBottom) - Math.max(resizedBounds.y, visibleTop);
const sizeChanged = resizedBounds.width !== bounds.width || resizedBounds.height !== bounds.height;
if (
!sizeChanged &&
overlapWidth >= Math.min(MIN_VISIBLE_WIDTH, bounds.width) &&
overlapHeight >= Math.min(MIN_VISIBLE_HEIGHT, bounds.height)
) {
return bounds;
}
if (sizeChanged && overlapWidth > 0 && overlapHeight > 0) {
return {
...resizedBounds,
x: clampPosition(resizedBounds.x, resizedBounds.width, visibleLeft, visibleWidth),
y: clampPosition(resizedBounds.y, resizedBounds.height, visibleTop, visibleHeight),
};
}
return {
...bounds,
x: visibleLeft + Math.max(0, Math.trunc((visibleWidth - bounds.width) / 2)),
y: visibleTop + Math.max(0, Math.trunc((visibleHeight - bounds.height) / 2)),
...resizedBounds,
x: visibleLeft + Math.max(0, Math.trunc((visibleWidth - resizedBounds.width) / 2)),
y: visibleTop + Math.max(0, Math.trunc((visibleHeight - resizedBounds.height) / 2)),
};
};