fix(overlay): prevent field grouping modal from freezing overlay on Hyprland (#138)

This commit is contained in:
2026-07-06 22:13:14 -07:00
committed by GitHub
parent 35ca2afc6f
commit a042b04357
27 changed files with 878 additions and 65 deletions
@@ -245,6 +245,12 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
restoreOnModalClose: 'kiku',
preferModalWindow: true,
},
// Abandonment also asks the renderer hosting the modal to close its dialog.
{
channel: 'kiku:field-grouping-cancel',
restoreOnModalClose: undefined,
preferModalWindow: true,
},
],
);
assert.deepEqual(waitCalls, [
@@ -254,7 +260,76 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
assert.deepEqual(warnings, [
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
]);
assert.deepEqual(closed, ['kiku']);
// Once from the send-failure path inside sendKikuFieldGroupingRequest, once from the
// callback's abandonment cleanup. The real runtime guards this via the restore set, so
// the duplicate is a harmless no-op.
assert.deepEqual(closed, ['kiku', 'kiku']);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test('createFieldGroupingOverlayRuntime prepares overlay windows before opening the modal', async () => {
// The field grouping modal must run the same prerequisites as every other modal
// (openOverlayHostedModal) so it opens with the overlay runtime ready and the visible overlay
// window present — otherwise on Hyprland it fails to sit above / focus over fullscreen mpv.
const order: string[] = [];
const originalSetTimeout = globalThis.setTimeout;
// The modal acknowledges open below, so the callback stays pending and arms its response
// timeout; stub the timer so no real 90s handle leaks into the test runner.
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
try {
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: () => null,
setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
ensureOverlayStartupPrereqs: () => order.push('prereqs'),
ensureOverlayWindowsReadyForVisibilityActions: () => order.push('windows-ready'),
sendToVisibleOverlay: (channel) => {
order.push(`send:${channel}`);
return true;
},
waitForModalOpen: async () => {
order.push('wait');
return true;
},
});
// Do not await: an acknowledged modal leaves the choice pending until the user responds.
void runtime.createFieldGroupingCallback()({
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
});
// Let the async send + modal-open ack chain run.
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
}
assert.deepEqual(order, [
'prereqs',
'windows-ready',
'send:kiku:field-grouping-request',
'wait',
]);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
+32 -2
View File
@@ -1,4 +1,5 @@
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
interface WindowLike {
@@ -22,6 +23,15 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
handleOverlayModalClosed?: (modal: T) => void;
logWarn?: (message: string) => void;
/**
* Prepare the overlay runtime and (re)create the visible overlay window before opening the
* modal — the same prerequisites every other modal runs via `openOverlayHostedModal`. Without
* them the field grouping modal can open with no sibling overlay window present, and on
* Hyprland it then fails to sit above / take focus over fullscreen mpv the way the other
* modals do.
*/
ensureOverlayStartupPrereqs?: () => void;
ensureOverlayWindowsReadyForVisibilityActions?: () => void;
sendToVisibleOverlay?: (
channel: string,
payload?: unknown,
@@ -69,11 +79,16 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
data: KikuFieldGroupingRequestData,
): Promise<boolean> => {
const kikuModal = 'kiku' as T;
const sendOpen = (): boolean =>
sendToVisibleOverlay('kiku:field-grouping-request', data, {
const sendOpen = (): boolean => {
// Match every other modal's open path (openOverlayHostedModal): ensure the overlay runtime
// and visible overlay window exist before handing off to the dedicated modal window.
options.ensureOverlayStartupPrereqs?.();
options.ensureOverlayWindowsReadyForVisibilityActions?.();
return sendToVisibleOverlay('kiku:field-grouping-request', data, {
restoreOnModalClose: kikuModal,
preferModalWindow: true,
});
};
if (!options.waitForModalOpen) {
return sendOpen();
@@ -102,6 +117,20 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
return opened;
};
const dismissModalUi = (): void => {
const kikuModal = 'kiku' as T;
// Best-effort: tell the renderer hosting the modal to close its dialog. When the modal
// lives in the dedicated modal window this is redundant with the teardown below, but it
// also covers the case where the request was routed into the visible overlay.
sendToVisibleOverlay(IPC_CHANNELS.event.kikuFieldGroupingCancel, undefined, {
preferModalWindow: true,
});
// Reliable teardown of main-side modal state (restore set, main-overlay passthrough,
// dedicated modal window). This is what recovers the frozen overlay when a grouping
// request times out or fails to reach a visible modal.
options.handleOverlayModalClosed?.(kikuModal);
};
const createFieldGroupingCallback = (): ((
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>) => {
@@ -112,6 +141,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
setResolver: options.setResolver,
sendToVisibleOverlay,
sendKikuFieldGroupingRequest,
dismissModalUi,
});
};
+272
View File
@@ -0,0 +1,272 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { createFieldGroupingCallback } from './field-grouping';
function makeRequestData(): KikuFieldGroupingRequestData {
return {
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'a',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
};
}
/**
* Mirrors how main stores the resolver: it wraps the callback's resolver in a
* sequence-guarded closure, so the value read back is never identity-equal to the
* callback's own `finish`. The old `getResolver() === finish` clear-guard therefore
* never matched and leaked the resolver, wedging every later grouping attempt.
*/
function createWrappedResolverStore() {
let stored: ((choice: KikuFieldGroupingChoice) => void) | null = null;
return {
getResolver: () => stored,
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
stored = resolver ? (choice) => resolver(choice) : null;
},
respond: (choice: KikuFieldGroupingChoice) => {
stored?.(choice);
},
};
}
test('field grouping callback clears the wrapped resolver after a renderer response', async () => {
const store = createWrappedResolverStore();
let visible = false;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => visible,
setVisibleOverlayVisible: (next) => {
visible = next;
},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
});
const pending = callback(makeRequestData());
await Promise.resolve();
assert.notEqual(store.getResolver(), null);
const choice: KikuFieldGroupingChoice = {
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: true,
cancelled: false,
};
store.respond(choice);
assert.deepEqual(await pending, choice);
assert.equal(store.getResolver(), null);
});
test('field grouping callback does not reject the next request after a response', async () => {
const store = createWrappedResolverStore();
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
});
const first = callback(makeRequestData());
await Promise.resolve();
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
const firstChoice = await first;
assert.equal(firstChoice.cancelled, false);
// The second attempt must reach the renderer, not short-circuit to an instant cancel.
const second = callback(makeRequestData());
await Promise.resolve();
assert.notEqual(store.getResolver(), null);
store.respond({ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false });
const secondChoice = await second;
assert.equal(secondChoice.cancelled, false);
assert.equal(secondChoice.keepNoteId, 2);
});
test('field grouping callback dismisses the modal UI when the send fails', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
dismissed += 1;
},
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
});
test('field grouping callback handles modal dismiss failures on send failure', async () => {
const store = createWrappedResolverStore();
const originalConsoleError = console.error;
console.error = () => {};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(store.getResolver(), null);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback handles modal dismiss failures on timeout', async () => {
const store = createWrappedResolverStore();
const originalConsoleError = console.error;
console.error = () => {};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
responseTimeoutMs: 5,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(store.getResolver(), null);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback reports modal dismiss failures', async () => {
const store = createWrappedResolverStore();
const errors: unknown[] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args);
};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
});
await callback(makeRequestData());
assert.equal(errors.length, 1);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback dismisses the modal UI when the response times out', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 5,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
});
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 10000,
});
const pending = callback(makeRequestData());
await Promise.resolve();
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
await pending;
assert.equal(dismissed, 0);
});
test('field grouping callback rejects a concurrent request while one is pending', async () => {
const store = createWrappedResolverStore();
let sends = 0;
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => {
sends += 1;
return true;
},
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 10000,
});
const first = callback(makeRequestData());
await Promise.resolve();
assert.equal(sends, 1);
const second = await callback(makeRequestData());
assert.equal(second.cancelled, true);
assert.equal(sends, 1);
assert.equal(dismissed, 0);
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
await first;
});
+42 -29
View File
@@ -1,21 +1,41 @@
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
const DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS = 90000;
export function createFieldGroupingCallback(options: {
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
/**
* Tears down the modal UI when the request is abandoned without a renderer response
* (send failure or response timeout). Without this the dedicated modal window, its
* restore-set entry, and the forced main-overlay passthrough stay orphaned — which on
* Wayland leaves an invisible modal covering mpv and the overlay stuck unresponsive.
*/
dismissModalUi?: () => void;
responseTimeoutMs?: number;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
const cancelledChoice = (): KikuFieldGroupingChoice => ({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
const dismissModalUi = (): void => {
try {
options.dismissModalUi?.();
} catch (error) {
console.error('Failed to dismiss Kiku field grouping modal UI:', error);
}
};
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
return new Promise((resolve) => {
if (options.getResolver()) {
resolve({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
resolve(cancelledChoice());
return;
}
@@ -23,18 +43,26 @@ export function createFieldGroupingCallback(options: {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const finish = (choice: KikuFieldGroupingChoice): void => {
const finish = (choice: KikuFieldGroupingChoice, abandoned = false): void => {
if (settled) return;
settled = true;
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
if (options.getResolver() === finish) {
options.setResolver(null);
}
// Always release the resolver. Callers (main) wrap this in a sequence-guarded
// resolver, so an identity check against `finish` never matches and would leak
// the resolver — blocking every later grouping attempt with an instant cancel.
options.setResolver(null);
resolve(choice);
// When abandoned without a renderer response, tear down the modal window/state
// that the request path spun up. A normal response already routes through the
// renderer's close handler, so only the abandon paths need this.
if (abandoned) {
dismissModalUi();
}
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
options.setVisibleOverlayVisible(false);
}
@@ -45,32 +73,17 @@ export function createFieldGroupingCallback(options: {
(sent) => {
if (settled) return;
if (!sent) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
finish(cancelledChoice(), true);
return;
}
timeout = setTimeout(() => {
if (!settled) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
finish(cancelledChoice(), true);
}
}, 90000);
}, options.responseTimeoutMs ?? DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS);
},
() => {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
finish(cancelledChoice(), true);
},
);
});
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildHyprlandPlacementDispatches,
ensureHyprlandWindowFloatingByTitle,
ensureHyprlandWindowFloatingByTitleWithStatus,
findHyprlandWindowForPlacement,
hasHyprlandWindowPlacementBoundsMismatch,
shouldAttemptHyprlandWindowPlacement,
@@ -203,6 +204,67 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
);
});
test('ensureHyprlandWindowFloatingByTitleWithStatus reports not-applicable off Hyprland', () => {
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay Modal',
platform: 'linux',
env: {},
execFileSync: (() => {
throw new Error('should not query the compositor when placement is not applicable');
}) as never,
});
assert.deepEqual(status, { applicable: false, clientFound: false, dispatched: false });
});
test('ensureHyprlandWindowFloatingByTitleWithStatus reports pending when the client is not yet mapped', () => {
// The window has not been mapped by the compositor yet, so no client matches. Callers use
// this to keep retrying until the modal is actually placed above fullscreen mpv.
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay Modal',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 999,
execFileSync: ((command: string, args: string[]) => {
if (args.join(' ') === '-j clients') {
return JSON.stringify([]);
}
return '';
}) as never,
});
assert.deepEqual(status, { applicable: true, clientFound: false, dispatched: false });
});
test('ensureHyprlandWindowFloatingByTitleWithStatus reports the client found once mapped', () => {
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay Modal',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
execFileSync: ((command: string, args: string[]) => {
if (args.join(' ') === '-j clients') {
return JSON.stringify([
{
address: '0xmatch',
pid: 456,
title: 'SubMiner Overlay Modal',
mapped: true,
floating: false,
pinned: false,
},
]);
}
if (args.join(' ') === '-j status') {
return JSON.stringify({ configProvider: 'lua' });
}
return '';
}) as never,
});
assert.deepEqual(status, { applicable: true, clientFound: true, dispatched: true });
});
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
const calls: unknown[][] = [];
const placed = ensureHyprlandWindowFloatingByTitle({
+31 -4
View File
@@ -272,6 +272,21 @@ export function hasHyprlandWindowPlacementBoundsMismatch(options: {
}
}
/**
* Placement outcome for a single reconcile attempt.
* - `applicable`: false when not running under Hyprland (nothing to retry).
* - `clientFound`: whether a mapped Hyprland client matched. On Wayland the window maps
* asynchronously after `show()`, so early attempts can miss it — callers retry while this
* is false so the modal actually gets promoted above fullscreen mpv instead of staying
* invisible.
* - `dispatched`: whether any hyprctl dispatch was issued.
*/
export interface HyprlandPlacementStatus {
applicable: boolean;
clientFound: boolean;
dispatched: boolean;
}
export function ensureHyprlandWindowFloatingByTitle(options: {
title: string;
bounds?: HyprlandPlacementBounds | null;
@@ -281,8 +296,20 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
promote?: boolean;
execFileSync?: ExecFileSync;
}): boolean {
return ensureHyprlandWindowFloatingByTitleWithStatus(options).dispatched;
}
export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
title: string;
bounds?: HyprlandPlacementBounds | null;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
pid?: number;
promote?: boolean;
execFileSync?: ExecFileSync;
}): HyprlandPlacementStatus {
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
return false;
return { applicable: false, clientFound: false, dispatched: false };
}
const run = options.execFileSync ?? execFileSync;
@@ -293,7 +320,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
title: options.title,
});
if (!client) {
return false;
return { applicable: true, clientFound: false, dispatched: false };
}
const configProvider = detectHyprlandConfigProvider(run);
@@ -329,9 +356,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
// Best-effort reconciliation: the initial placement dispatches already ran.
}
}
return dispatches.length > 0;
return { applicable: true, clientFound: true, dispatched: dispatches.length > 0 };
} catch {
return false;
return { applicable: true, clientFound: false, dispatched: false };
}
}
+2
View File
@@ -65,12 +65,14 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
) => boolean;
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
dismissModalUi?: () => void;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
getResolver: options.getResolver,
setResolver: options.setResolver,
dismissModalUi: options.dismissModalUi,
sendRequestToVisibleOverlay: (data) =>
options.sendKikuFieldGroupingRequest
? options.sendKikuFieldGroupingRequest(data)
+8 -6
View File
@@ -1,6 +1,7 @@
import type { BrowserWindow } from 'electron';
import { RuntimeOptionState, WindowGeometry } from '../../types';
import { updateOverlayWindowBounds } from './overlay-window';
import type { HyprlandPlacementStatus } from './hyprland-window-placement';
export interface OverlayManager {
getMainWindow: () => BrowserWindow | null;
@@ -9,7 +10,7 @@ export interface OverlayManager {
setModalWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: () => BrowserWindow | null;
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getOverlayWindows: () => BrowserWindow[];
@@ -29,9 +30,12 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
let visibleOverlayVisible = false;
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => {
const updateWindowBounds = (
geometry: WindowGeometry,
window: BrowserWindow | null,
): HyprlandPlacementStatus => {
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
applyOverlayBounds(geometry, window, { promote });
return applyOverlayBounds(geometry, window, { promote });
};
return {
@@ -47,9 +51,7 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
setOverlayWindowBounds: (geometry) => {
updateWindowBounds(geometry, mainWindow);
},
setModalWindowBounds: (geometry) => {
updateWindowBounds(geometry, modalWindow);
},
setModalWindowBounds: (geometry) => updateWindowBounds(geometry, modalWindow),
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible;
+10 -4
View File
@@ -9,7 +9,11 @@ import {
handleOverlayWindowBlurred,
type OverlayWindowKind,
} from './overlay-window-input';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
import {
ensureHyprlandWindowFloatingByTitle,
ensureHyprlandWindowFloatingByTitleWithStatus,
type HyprlandPlacementStatus,
} from './hyprland-window-placement';
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
@@ -54,8 +58,10 @@ export function updateOverlayWindowBounds(
options: {
promote?: boolean;
} = {},
): void {
if (!geometry || !window || window.isDestroyed()) return;
): HyprlandPlacementStatus {
if (!geometry || !window || window.isDestroyed()) {
return { applicable: false, clientFound: false, dispatched: false };
}
const bounds = normalizeOverlayWindowBoundsForPlatform(
geometry,
process.platform,
@@ -63,7 +69,7 @@ export function updateOverlayWindowBounds(
window,
);
window.setBounds(bounds);
ensureHyprlandWindowFloatingByTitle({
return ensureHyprlandWindowFloatingByTitleWithStatus({
title: window.getTitle(),
bounds,
promote: options.promote,