Fix Windows Anki startup and overlay regressions (#128)

This commit is contained in:
2026-06-14 20:51:56 -07:00
committed by GitHub
parent aa8eb753f6
commit 70da3ee8bd
28 changed files with 1322 additions and 47 deletions
@@ -32,6 +32,7 @@ import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
import { hasLiveSeparateWindow } from './settings-window-z-order';
import { tickWindowsOverlayPointerInteraction } from './windows-overlay-pointer-interaction';
export interface VisibleOverlayInteractionRuntimeDeps {
overlayManager: {
@@ -89,6 +90,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let windowsOverlayPointerInteractionActive = false;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
@@ -122,6 +124,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
function resetVisibleOverlayInputState(): void {
visibleOverlayInteractionActive = false;
windowsOverlayPointerInteractionActive = false;
linuxOverlayInputShapeActive = false;
linuxOverlayPointerInteractionStateApplied = false;
resetLinuxVisibleOverlayStartupInputPrimer();
@@ -538,6 +541,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
maybePollWindowsVisibleOverlayForegroundProcess();
tickWindowsOverlayPointerInteractionNow();
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
}
@@ -571,6 +575,56 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
}
}
function shouldSuspendWindowsOverlayPointerInteraction(): boolean {
return (
deps.getModalInputExclusive() ||
deps.getStatsOverlayVisible() ||
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows())
);
}
function updateWindowsOverlayPointerInteractionActive(active: boolean): void {
windowsOverlayPointerInteractionActive = active;
visibleOverlayInteractionActive = active;
const mainWindow = overlayManager.getMainWindow();
if (
process.platform !== 'win32' ||
!mainWindow ||
mainWindow.isDestroyed() ||
!mainWindow.isVisible()
) {
deps.updateVisibleOverlayVisibility();
return;
}
if (active) {
mainWindow.setIgnoreMouseEvents(false);
} else {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
}
}
const windowsOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getCursorScreenPoint: () => screen.getCursorScreenPoint(),
getSubtitleMeasurement: () => overlayContentMeasurementStore.getLatestByLayer('visible'),
shouldSuspend: shouldSuspendWindowsOverlayPointerInteraction,
getInteractionActive: () => windowsOverlayPointerInteractionActive,
setInteractionActive: updateWindowsOverlayPointerInteractionActive,
};
function tickWindowsOverlayPointerInteractionNow(): void {
if (process.platform !== 'win32') {
return;
}
if (!windowsOverlayPointerInteractionActive && visibleOverlayInteractionActive) {
return;
}
tickWindowsOverlayPointerInteraction(windowsOverlayPointerInteractionDeps);
}
ensureWindowsVisibleOverlayForegroundPollLoop();
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
@@ -811,10 +865,12 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
updateLinuxOverlayPointerInteractionActive,
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
requestLinuxOverlayZOrderFollow,
tickWindowsOverlayPointerInteractionNow,
tickLinuxOverlayPointerInteractionNow,
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
setVisibleOverlayInteractionActive: (active: boolean) => {
visibleOverlayInteractionActive = active;
windowsOverlayPointerInteractionActive = false;
},
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
getLastWindowsVisibleOverlayForegroundProcessName: () =>
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isCursorOverWindowsOverlayInteractiveRect,
resolveDesiredWindowsOverlayInteractive,
tickWindowsOverlayPointerInteraction,
type WindowsOverlayPointerInteractionDeps,
} from './windows-overlay-pointer-interaction';
import type { OverlayContentMeasurement } from '../../types';
const BOUNDS = { x: 100, y: 100, width: 1920, height: 1080 };
const MEASUREMENT: OverlayContentMeasurement = {
layer: 'visible',
measuredAtMs: 1,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 800, y: 900, width: 320, height: 80 },
};
function makeDeps(overrides: Partial<WindowsOverlayPointerInteractionDeps>): {
deps: WindowsOverlayPointerInteractionDeps;
state: { active: boolean };
} {
const state = { active: false };
const deps: WindowsOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({
isDestroyed: () => false,
isVisible: () => true,
getBounds: () => BOUNDS,
}),
getCursorScreenPoint: () => ({ x: 1000, y: 1040 }),
getSubtitleMeasurement: () => MEASUREMENT,
shouldSuspend: () => false,
getInteractionActive: () => state.active,
setInteractionActive: (active) => {
state.active = active;
},
...overrides,
};
return { deps, state };
}
test('isCursorOverWindowsOverlayInteractiveRect hit-tests measured overlay rects', () => {
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 1000, y: 1040 }, BOUNDS, MEASUREMENT),
true,
);
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 500, y: 1040 }, BOUNDS, MEASUREMENT),
false,
);
});
test('isCursorOverWindowsOverlayInteractiveRect scales viewport px to window px', () => {
const scaled = { ...BOUNDS, width: 3840, height: 2160 };
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 1700, y: 1900 }, scaled, MEASUREMENT),
true,
);
});
test('isCursorOverWindowsOverlayInteractiveRect uses separate interactive rects', () => {
const measurement: OverlayContentMeasurement = {
layer: 'visible',
measuredAtMs: 1,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 700, y: 40, width: 520, height: 940 },
interactiveRects: [
{ x: 700, y: 40, width: 520, height: 80 },
{ x: 760, y: 900, width: 400, height: 80 },
],
};
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 300 }, BOUNDS, measurement),
false,
);
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 180 }, BOUNDS, measurement),
true,
);
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 1060 }, BOUNDS, measurement),
true,
);
});
test('resolveDesiredWindowsOverlayInteractive: interactive over subtitle, passthrough off it', () => {
assert.equal(resolveDesiredWindowsOverlayInteractive(makeDeps({}).deps), true);
assert.equal(
resolveDesiredWindowsOverlayInteractive(
makeDeps({ getCursorScreenPoint: () => ({ x: 200, y: 200 }) }).deps,
),
false,
);
});
test('resolveDesiredWindowsOverlayInteractive returns null while another surface owns input', () => {
assert.equal(
resolveDesiredWindowsOverlayInteractive(makeDeps({ shouldSuspend: () => true }).deps),
null,
);
assert.equal(
resolveDesiredWindowsOverlayInteractive(makeDeps({ getMainWindow: () => null }).deps),
null,
);
});
test('tickWindowsOverlayPointerInteraction toggles only the fallback-owned state', () => {
const calls: boolean[] = [];
const { deps, state } = makeDeps({
setInteractionActive: (active) => {
calls.push(active);
state.active = active;
},
});
tickWindowsOverlayPointerInteraction(deps);
tickWindowsOverlayPointerInteraction(deps);
assert.deepEqual(calls, [true]);
deps.getCursorScreenPoint = () => ({ x: 200, y: 200 });
tickWindowsOverlayPointerInteraction(deps);
assert.deepEqual(calls, [true, false]);
});
test('tickWindowsOverlayPointerInteraction leaves renderer-owned state alone while suspended', () => {
const calls: boolean[] = [];
const { deps } = makeDeps({
getInteractionActive: () => true,
shouldSuspend: () => true,
setInteractionActive: (active) => calls.push(active),
});
tickWindowsOverlayPointerInteraction(deps);
assert.deepEqual(calls, []);
});
@@ -0,0 +1,99 @@
import type { OverlayContentMeasurement, OverlayContentRect } from '../../types';
type PointerPoint = { x: number; y: number };
type PointerRect = { x: number; y: number; width: number; height: number };
type PointerInteractionWindow = {
isDestroyed: () => boolean;
isVisible: () => boolean;
getBounds: () => PointerRect;
};
export type WindowsOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => PointerInteractionWindow | null;
getCursorScreenPoint: () => PointerPoint;
getSubtitleMeasurement: () => OverlayContentMeasurement | null;
getRendererInteractiveHint?: () => boolean;
/** True when a modal/stats/separate window owns input. */
shouldSuspend: () => boolean;
getInteractionActive: () => boolean;
setInteractionActive: (active: boolean) => void;
};
// Match Linux fallback padding so hover survives tiny measurement/cursor gaps.
const SUBTITLE_HIT_PADDING_PX = 6;
function measuredRectsForInput(
measurement: OverlayContentMeasurement | null,
): OverlayContentRect[] {
if (!measurement) return [];
return Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.length > 0
? measurement.interactiveRects
: measurement.contentRect
? [measurement.contentRect]
: [];
}
function isCursorOverRect(
cursor: PointerPoint,
bounds: PointerRect,
viewport: { width: number; height: number },
rect: OverlayContentRect,
): boolean {
if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
if (!(viewport.width > 0) || !(viewport.height > 0)) return false;
if (!(rect.width > 0) || !(rect.height > 0)) return false;
const scaleX = bounds.width / viewport.width;
const scaleY = bounds.height / viewport.height;
const left = bounds.x + rect.x * scaleX - SUBTITLE_HIT_PADDING_PX;
const top = bounds.y + rect.y * scaleY - SUBTITLE_HIT_PADDING_PX;
const right = left + rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2;
const bottom = top + rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2;
return cursor.x >= left && cursor.x <= right && cursor.y >= top && cursor.y <= bottom;
}
export function isCursorOverWindowsOverlayInteractiveRect(
cursor: PointerPoint,
bounds: PointerRect,
measurement: OverlayContentMeasurement | null,
): boolean {
if (!measurement) return false;
return measuredRectsForInput(measurement).some((rect) =>
isCursorOverRect(cursor, bounds, measurement.viewport, rect),
);
}
/**
* Returns the desired Windows overlay mouse-input state, or null when another surface
* currently owns interaction and the fallback should not touch BrowserWindow passthrough.
*/
export function resolveDesiredWindowsOverlayInteractive(
deps: WindowsOverlayPointerInteractionDeps,
): boolean | null {
if (!deps.getVisibleOverlayVisible()) return false;
if (deps.shouldSuspend()) return null;
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return null;
}
if (deps.getRendererInteractiveHint?.()) return true;
return isCursorOverWindowsOverlayInteractiveRect(
deps.getCursorScreenPoint(),
mainWindow.getBounds(),
deps.getSubtitleMeasurement(),
);
}
export function tickWindowsOverlayPointerInteraction(
deps: WindowsOverlayPointerInteractionDeps,
): void {
const desired = resolveDesiredWindowsOverlayInteractive(deps);
if (desired === null) return;
if (deps.getInteractionActive() === desired) return;
deps.setInteractionActive(desired);
}