mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
fix(overlay): prevent field grouping modal from freezing overlay on Hyprland (#138)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible.
|
||||||
|
- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled".
|
||||||
|
- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed the Kiku field grouping modal misbehaving on Hyprland/Wayland while every other modal worked: its main-process wiring silently dropped the modal-open acknowledgement/retry, failure teardown, and warn-logging callbacks (they are optional, so the incomplete forward compiled cleanly), and it skipped the overlay prerequisites every other modal runs before opening. The field grouping modal now opens through the same path as the other modals — preparing the overlay runtime and visible overlay window first, then acknowledging/retrying the dedicated modal window — so it appears above and works over fullscreen mpv like the rest.
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -26,6 +26,7 @@ function createWorkflowHarness() {
|
|||||||
const deleted: number[][] = [];
|
const deleted: number[][] = [];
|
||||||
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
|
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
|
||||||
const statuses: string[] = [];
|
const statuses: string[] = [];
|
||||||
|
const osdMessages: string[] = [];
|
||||||
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
|
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
|
||||||
const mergeCalls: Array<{
|
const mergeCalls: Array<{
|
||||||
keepNoteId: number;
|
keepNoteId: number;
|
||||||
@@ -112,7 +113,9 @@ function createWorkflowHarness() {
|
|||||||
statuses.push(message);
|
statuses.push(message);
|
||||||
},
|
},
|
||||||
showNotification: async () => undefined,
|
showNotification: async () => undefined,
|
||||||
showOsdNotification: () => undefined,
|
showOsdNotification: (message: string) => {
|
||||||
|
osdMessages.push(message);
|
||||||
|
},
|
||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
logInfo: () => undefined,
|
logInfo: () => undefined,
|
||||||
truncateSentence: (value: string) => value,
|
truncateSentence: (value: string) => value,
|
||||||
@@ -125,6 +128,7 @@ function createWorkflowHarness() {
|
|||||||
addedTags,
|
addedTags,
|
||||||
rememberedMerges,
|
rememberedMerges,
|
||||||
statuses,
|
statuses,
|
||||||
|
osdMessages,
|
||||||
mergeCalls,
|
mergeCalls,
|
||||||
setManualChoice: (choice: typeof manualChoice) => {
|
setManualChoice: (choice: typeof manualChoice) => {
|
||||||
manualChoice = choice;
|
manualChoice = choice;
|
||||||
@@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
|
|||||||
assert.equal(harness.updates.length, 0);
|
assert.equal(harness.updates.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.setManualChoice({
|
||||||
|
keepNoteId: 0,
|
||||||
|
deleteNoteId: 0,
|
||||||
|
deleteDuplicate: true,
|
||||||
|
cancelled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handled = await harness.workflow.handleManual(1, 2, {
|
||||||
|
noteId: 2,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'word-2' },
|
||||||
|
Sentence: { value: 'line-2' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, false);
|
||||||
|
assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']);
|
||||||
|
assert.equal(harness.updates.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.setManualChoice({
|
||||||
|
keepNoteId: 1,
|
||||||
|
deleteNoteId: 2,
|
||||||
|
deleteDuplicate: true,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () => [];
|
||||||
|
|
||||||
|
const handled = await harness.workflow.handleManual(1, 2, {
|
||||||
|
noteId: 2,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'word-2' },
|
||||||
|
Sentence: { value: 'line-2' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, false);
|
||||||
|
assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']);
|
||||||
|
});
|
||||||
|
|
||||||
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
|
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.setManualChoice({
|
harness.setManualChoice({
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export class FieldGroupingWorkflow {
|
|||||||
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
|
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
|
||||||
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
||||||
if (!originalNotesInfo || originalNotesInfo.length === 0) {
|
if (!originalNotesInfo || originalNotesInfo.length === 0) {
|
||||||
|
// handleManual owns all user-facing notifications; callers must not re-notify on false.
|
||||||
|
this.deps.showOsdNotification('Field grouping failed: original card not found');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
|
|||||||
assert.deepEqual(harness.manualCalls, []);
|
assert.deepEqual(harness.manualCalls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => {
|
test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
kikuFieldGrouping: 'manual',
|
kikuFieldGrouping: 'manual',
|
||||||
noteIds: [9],
|
noteIds: [9],
|
||||||
@@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man
|
|||||||
expression: 'word-9',
|
expression: 'word-9',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled');
|
// The manual workflow already notifies about its outcome (cancelled/unavailable/failed);
|
||||||
|
// the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts.
|
||||||
|
assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
|
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
|
||||||
|
|||||||
@@ -156,15 +156,14 @@ export class FieldGroupingService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const handled = await this.deps.handleFieldGroupingManual(
|
// The manual workflow owns all user-facing notifications for its outcomes (cancelled,
|
||||||
|
// unavailable, failed) — re-notifying on a false return here duplicated them.
|
||||||
|
await this.deps.handleFieldGroupingManual(
|
||||||
duplicateNoteId,
|
duplicateNoteId,
|
||||||
noteId,
|
noteId,
|
||||||
noteInfo,
|
noteInfo,
|
||||||
expressionText,
|
expressionText,
|
||||||
);
|
);
|
||||||
if (!handled) {
|
|
||||||
this.deps.showOsdNotification('Field grouping cancelled');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Error triggering field grouping:', (error as Error).message);
|
log.error('Error triggering field grouping:', (error as Error).message);
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
|||||||
restoreOnModalClose: 'kiku',
|
restoreOnModalClose: 'kiku',
|
||||||
preferModalWindow: true,
|
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, [
|
assert.deepEqual(waitCalls, [
|
||||||
@@ -254,7 +260,76 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
|||||||
assert.deepEqual(warnings, [
|
assert.deepEqual(warnings, [
|
||||||
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
'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 {
|
} finally {
|
||||||
globalThis.setTimeout = originalSetTimeout;
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||||
|
|
||||||
interface WindowLike {
|
interface WindowLike {
|
||||||
@@ -22,6 +23,15 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
|||||||
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
|
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
|
||||||
handleOverlayModalClosed?: (modal: T) => void;
|
handleOverlayModalClosed?: (modal: T) => void;
|
||||||
logWarn?: (message: string) => 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?: (
|
sendToVisibleOverlay?: (
|
||||||
channel: string,
|
channel: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
@@ -69,11 +79,16 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const kikuModal = 'kiku' as T;
|
const kikuModal = 'kiku' as T;
|
||||||
const sendOpen = (): boolean =>
|
const sendOpen = (): boolean => {
|
||||||
sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
// 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,
|
restoreOnModalClose: kikuModal,
|
||||||
preferModalWindow: true,
|
preferModalWindow: true,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!options.waitForModalOpen) {
|
if (!options.waitForModalOpen) {
|
||||||
return sendOpen();
|
return sendOpen();
|
||||||
@@ -102,6 +117,20 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
|||||||
return opened;
|
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 = (): ((
|
const createFieldGroupingCallback = (): ((
|
||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>) => {
|
) => Promise<KikuFieldGroupingChoice>) => {
|
||||||
@@ -112,6 +141,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
|||||||
setResolver: options.setResolver,
|
setResolver: options.setResolver,
|
||||||
sendToVisibleOverlay,
|
sendToVisibleOverlay,
|
||||||
sendKikuFieldGroupingRequest,
|
sendKikuFieldGroupingRequest,
|
||||||
|
dismissModalUi,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -1,21 +1,41 @@
|
|||||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||||
|
|
||||||
|
const DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS = 90000;
|
||||||
|
|
||||||
export function createFieldGroupingCallback(options: {
|
export function createFieldGroupingCallback(options: {
|
||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||||
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
|
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> {
|
}): (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 async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (options.getResolver()) {
|
if (options.getResolver()) {
|
||||||
resolve({
|
resolve(cancelledChoice());
|
||||||
keepNoteId: 0,
|
|
||||||
deleteNoteId: 0,
|
|
||||||
deleteDuplicate: true,
|
|
||||||
cancelled: true,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,18 +43,26 @@ export function createFieldGroupingCallback(options: {
|
|||||||
let settled = false;
|
let settled = false;
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const finish = (choice: KikuFieldGroupingChoice): void => {
|
const finish = (choice: KikuFieldGroupingChoice, abandoned = false): void => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (timeout !== null) {
|
if (timeout !== null) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = null;
|
timeout = null;
|
||||||
}
|
}
|
||||||
if (options.getResolver() === finish) {
|
// Always release the resolver. Callers (main) wrap this in a sequence-guarded
|
||||||
options.setResolver(null);
|
// 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);
|
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()) {
|
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
|
||||||
options.setVisibleOverlayVisible(false);
|
options.setVisibleOverlayVisible(false);
|
||||||
}
|
}
|
||||||
@@ -45,32 +73,17 @@ export function createFieldGroupingCallback(options: {
|
|||||||
(sent) => {
|
(sent) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
finish({
|
finish(cancelledChoice(), true);
|
||||||
keepNoteId: 0,
|
|
||||||
deleteNoteId: 0,
|
|
||||||
deleteDuplicate: true,
|
|
||||||
cancelled: true,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
finish({
|
finish(cancelledChoice(), true);
|
||||||
keepNoteId: 0,
|
|
||||||
deleteNoteId: 0,
|
|
||||||
deleteDuplicate: true,
|
|
||||||
cancelled: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, 90000);
|
}, options.responseTimeoutMs ?? DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
finish({
|
finish(cancelledChoice(), true);
|
||||||
keepNoteId: 0,
|
|
||||||
deleteNoteId: 0,
|
|
||||||
deleteDuplicate: true,
|
|
||||||
cancelled: true,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
buildHyprlandPlacementDispatches,
|
buildHyprlandPlacementDispatches,
|
||||||
ensureHyprlandWindowFloatingByTitle,
|
ensureHyprlandWindowFloatingByTitle,
|
||||||
|
ensureHyprlandWindowFloatingByTitleWithStatus,
|
||||||
findHyprlandWindowForPlacement,
|
findHyprlandWindowForPlacement,
|
||||||
hasHyprlandWindowPlacementBoundsMismatch,
|
hasHyprlandWindowPlacementBoundsMismatch,
|
||||||
shouldAttemptHyprlandWindowPlacement,
|
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', () => {
|
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
|
||||||
const calls: unknown[][] = [];
|
const calls: unknown[][] = [];
|
||||||
const placed = ensureHyprlandWindowFloatingByTitle({
|
const placed = ensureHyprlandWindowFloatingByTitle({
|
||||||
|
|||||||
@@ -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: {
|
export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||||
title: string;
|
title: string;
|
||||||
bounds?: HyprlandPlacementBounds | null;
|
bounds?: HyprlandPlacementBounds | null;
|
||||||
@@ -281,8 +296,20 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
|||||||
promote?: boolean;
|
promote?: boolean;
|
||||||
execFileSync?: ExecFileSync;
|
execFileSync?: ExecFileSync;
|
||||||
}): boolean {
|
}): 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)) {
|
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
||||||
return false;
|
return { applicable: false, clientFound: false, dispatched: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = options.execFileSync ?? execFileSync;
|
const run = options.execFileSync ?? execFileSync;
|
||||||
@@ -293,7 +320,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
|||||||
title: options.title,
|
title: options.title,
|
||||||
});
|
});
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return false;
|
return { applicable: true, clientFound: false, dispatched: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const configProvider = detectHyprlandConfigProvider(run);
|
const configProvider = detectHyprlandConfigProvider(run);
|
||||||
@@ -329,9 +356,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
|||||||
// Best-effort reconciliation: the initial placement dispatches already ran.
|
// Best-effort reconciliation: the initial placement dispatches already ran.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dispatches.length > 0;
|
return { applicable: true, clientFound: true, dispatched: dispatches.length > 0 };
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return { applicable: true, clientFound: false, dispatched: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,12 +65,14 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
|||||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||||
) => boolean;
|
) => boolean;
|
||||||
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
|
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
|
||||||
|
dismissModalUi?: () => void;
|
||||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||||
return createFieldGroupingCallback({
|
return createFieldGroupingCallback({
|
||||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||||
getResolver: options.getResolver,
|
getResolver: options.getResolver,
|
||||||
setResolver: options.setResolver,
|
setResolver: options.setResolver,
|
||||||
|
dismissModalUi: options.dismissModalUi,
|
||||||
sendRequestToVisibleOverlay: (data) =>
|
sendRequestToVisibleOverlay: (data) =>
|
||||||
options.sendKikuFieldGroupingRequest
|
options.sendKikuFieldGroupingRequest
|
||||||
? options.sendKikuFieldGroupingRequest(data)
|
? options.sendKikuFieldGroupingRequest(data)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
||||||
import { updateOverlayWindowBounds } from './overlay-window';
|
import { updateOverlayWindowBounds } from './overlay-window';
|
||||||
|
import type { HyprlandPlacementStatus } from './hyprland-window-placement';
|
||||||
|
|
||||||
export interface OverlayManager {
|
export interface OverlayManager {
|
||||||
getMainWindow: () => BrowserWindow | null;
|
getMainWindow: () => BrowserWindow | null;
|
||||||
@@ -9,7 +10,7 @@ export interface OverlayManager {
|
|||||||
setModalWindow: (window: BrowserWindow | null) => void;
|
setModalWindow: (window: BrowserWindow | null) => void;
|
||||||
getOverlayWindow: () => BrowserWindow | null;
|
getOverlayWindow: () => BrowserWindow | null;
|
||||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus;
|
||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
@@ -29,9 +30,12 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
|||||||
let visibleOverlayVisible = false;
|
let visibleOverlayVisible = false;
|
||||||
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
|
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;
|
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
|
||||||
applyOverlayBounds(geometry, window, { promote });
|
return applyOverlayBounds(geometry, window, { promote });
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -47,9 +51,7 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
|||||||
setOverlayWindowBounds: (geometry) => {
|
setOverlayWindowBounds: (geometry) => {
|
||||||
updateWindowBounds(geometry, mainWindow);
|
updateWindowBounds(geometry, mainWindow);
|
||||||
},
|
},
|
||||||
setModalWindowBounds: (geometry) => {
|
setModalWindowBounds: (geometry) => updateWindowBounds(geometry, modalWindow),
|
||||||
updateWindowBounds(geometry, modalWindow);
|
|
||||||
},
|
|
||||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||||
setVisibleOverlayVisible: (visible) => {
|
setVisibleOverlayVisible: (visible) => {
|
||||||
visibleOverlayVisible = visible;
|
visibleOverlayVisible = visible;
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
handleOverlayWindowBlurred,
|
handleOverlayWindowBlurred,
|
||||||
type OverlayWindowKind,
|
type OverlayWindowKind,
|
||||||
} from './overlay-window-input';
|
} 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 { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
||||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||||
@@ -54,8 +58,10 @@ export function updateOverlayWindowBounds(
|
|||||||
options: {
|
options: {
|
||||||
promote?: boolean;
|
promote?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): void {
|
): HyprlandPlacementStatus {
|
||||||
if (!geometry || !window || window.isDestroyed()) return;
|
if (!geometry || !window || window.isDestroyed()) {
|
||||||
|
return { applicable: false, clientFound: false, dispatched: false };
|
||||||
|
}
|
||||||
const bounds = normalizeOverlayWindowBoundsForPlatform(
|
const bounds = normalizeOverlayWindowBoundsForPlatform(
|
||||||
geometry,
|
geometry,
|
||||||
process.platform,
|
process.platform,
|
||||||
@@ -63,7 +69,7 @@ export function updateOverlayWindowBounds(
|
|||||||
window,
|
window,
|
||||||
);
|
);
|
||||||
window.setBounds(bounds);
|
window.setBounds(bounds);
|
||||||
ensureHyprlandWindowFloatingByTitle({
|
return ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||||
title: window.getTitle(),
|
title: window.getTitle(),
|
||||||
bounds,
|
bounds,
|
||||||
promote: options.promote,
|
promote: options.promote,
|
||||||
|
|||||||
@@ -2324,6 +2324,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
|
|||||||
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||||
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
|
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
|
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions(),
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||||
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||||
})(),
|
})(),
|
||||||
|
|||||||
@@ -926,3 +926,156 @@ test('waitForModalOpen resolves false on timeout', async () => {
|
|||||||
|
|
||||||
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
|
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('modal placement reconcile retries until the Hyprland client is mapped', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
const timers: Array<() => void> = [];
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = ((cb: () => void) => {
|
||||||
|
timers.push(cb);
|
||||||
|
return { unref() {} };
|
||||||
|
}) as unknown as typeof globalThis.setTimeout;
|
||||||
|
|
||||||
|
const statuses: Array<{ applicable: boolean; clientFound: boolean }> = [];
|
||||||
|
try {
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => window as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {
|
||||||
|
// The compositor never maps the window, so every reconcile reports pending.
|
||||||
|
const status = { applicable: true, clientFound: false, dispatched: false };
|
||||||
|
statuses.push(status);
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'kiku:field-grouping-open',
|
||||||
|
{ test: true },
|
||||||
|
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||||
|
);
|
||||||
|
runtime.notifyOverlayModalOpened('kiku');
|
||||||
|
|
||||||
|
let iterations = 0;
|
||||||
|
while (timers.length > 0 && iterations < 50) {
|
||||||
|
const next = timers.shift();
|
||||||
|
next?.();
|
||||||
|
iterations += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reconcile ladder re-asserts placement across all six delays while the client
|
||||||
|
// stays unmapped, instead of the old single post-show attempt.
|
||||||
|
assert.ok(
|
||||||
|
statuses.length >= 6,
|
||||||
|
`expected at least 6 pending reconcile attempts, saw ${statuses.length}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal placement reconcile stops retrying once the client is mapped', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
const timers: Array<() => void> = [];
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = ((cb: () => void) => {
|
||||||
|
timers.push(cb);
|
||||||
|
return { unref() {} };
|
||||||
|
}) as unknown as typeof globalThis.setTimeout;
|
||||||
|
|
||||||
|
let reconcileCount = 0;
|
||||||
|
try {
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => window as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {
|
||||||
|
reconcileCount += 1;
|
||||||
|
// Client is already mapped, so placement is settled on the first attempt.
|
||||||
|
return { applicable: true, clientFound: true, dispatched: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'kiku:field-grouping-open',
|
||||||
|
{ test: true },
|
||||||
|
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||||
|
);
|
||||||
|
runtime.notifyOverlayModalOpened('kiku');
|
||||||
|
|
||||||
|
let iterations = 0;
|
||||||
|
while (timers.length > 0 && iterations < 50) {
|
||||||
|
const next = timers.shift();
|
||||||
|
next?.();
|
||||||
|
iterations += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No 6-deep ladder: a settled placement should not keep rescheduling.
|
||||||
|
assert.ok(
|
||||||
|
reconcileCount < 6,
|
||||||
|
`expected the ladder to stop early, saw ${reconcileCount} reconcile attempts`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal placement reconcile cancels stale retry ladder after a newer visible modal interaction', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
type TimerEntry = { active: boolean; callback: () => void };
|
||||||
|
const timers: TimerEntry[] = [];
|
||||||
|
const activeTimerCount = () => timers.filter((timer) => timer.active).length;
|
||||||
|
const runNextActiveTimer = () => {
|
||||||
|
const timer = timers.find((candidate) => candidate.active);
|
||||||
|
if (!timer) return;
|
||||||
|
timer.active = false;
|
||||||
|
timer.callback();
|
||||||
|
};
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
const originalClearTimeout = globalThis.clearTimeout;
|
||||||
|
globalThis.setTimeout = ((cb: () => void) => {
|
||||||
|
const timer = { active: true, callback: cb, unref() {} };
|
||||||
|
timers.push(timer);
|
||||||
|
return timer;
|
||||||
|
}) as unknown as typeof globalThis.setTimeout;
|
||||||
|
globalThis.clearTimeout = ((timeout: TimerEntry | undefined) => {
|
||||||
|
if (timeout) {
|
||||||
|
timeout.active = false;
|
||||||
|
}
|
||||||
|
}) as unknown as typeof globalThis.clearTimeout;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => window as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => ({ applicable: true, clientFound: false, dispatched: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'kiku:field-grouping-open',
|
||||||
|
{ test: true },
|
||||||
|
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||||
|
);
|
||||||
|
runtime.notifyOverlayModalOpened('kiku');
|
||||||
|
assert.equal(activeTimerCount(), 1);
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'kiku:field-grouping-open',
|
||||||
|
{ test: true },
|
||||||
|
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||||
|
);
|
||||||
|
assert.equal(activeTimerCount(), 2);
|
||||||
|
|
||||||
|
runNextActiveTimer();
|
||||||
|
|
||||||
|
assert.equal(activeTimerCount(), 1, 'stale retry should not schedule a continuation');
|
||||||
|
} finally {
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
globalThis.clearTimeout = originalClearTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
|
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
|
||||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
||||||
|
|
||||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||||
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS = 50;
|
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
|
||||||
|
// before the compositor has a client to place, leaving the modal buried under fullscreen mpv.
|
||||||
|
// Re-assert placement across this ladder until the Hyprland client is found (or attempts run out).
|
||||||
|
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS = [50, 120, 250, 500, 900, 1400];
|
||||||
|
|
||||||
function requestOverlayApplicationFocus(): void {
|
function requestOverlayApplicationFocus(): void {
|
||||||
try {
|
try {
|
||||||
@@ -31,7 +35,7 @@ export interface OverlayWindowResolver {
|
|||||||
getModalWindow: () => BrowserWindow | null;
|
getModalWindow: () => BrowserWindow | null;
|
||||||
createModalWindow: () => BrowserWindow | null;
|
createModalWindow: () => BrowserWindow | null;
|
||||||
getModalGeometry: () => WindowGeometry;
|
getModalGeometry: () => WindowGeometry;
|
||||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverlayModalRuntime {
|
export interface OverlayModalRuntime {
|
||||||
@@ -73,6 +77,7 @@ export function createOverlayModalRuntimeService(
|
|||||||
let modalWindowPrimedForImmediateShow = false;
|
let modalWindowPrimedForImmediateShow = false;
|
||||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||||
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
||||||
|
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
|
||||||
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
||||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||||
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
||||||
@@ -145,21 +150,47 @@ export function createOverlayModalRuntimeService(
|
|||||||
window.moveTop();
|
window.moveTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const reconcileModalWindowBounds = (window: BrowserWindow): void => {
|
const reconcileModalWindowBounds = (window: BrowserWindow): HyprlandPlacementStatus | void => {
|
||||||
const modalWindow = deps.getModalWindow();
|
const modalWindow = deps.getModalWindow();
|
||||||
if (!modalWindow || modalWindow !== window || window.isDestroyed()) {
|
if (!modalWindow || modalWindow !== window || window.isDestroyed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
return deps.setModalWindowBounds(deps.getModalGeometry());
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleModalWindowBoundsReconcile = (window: BrowserWindow): void => {
|
const nextModalWindowBoundsReconcileGeneration = (window: BrowserWindow): number => {
|
||||||
|
const generation = (modalWindowBoundsReconcileGenerations.get(window) ?? 0) + 1;
|
||||||
|
modalWindowBoundsReconcileGenerations.set(window, generation);
|
||||||
|
return generation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCurrentModalWindowBoundsReconcileGeneration = (
|
||||||
|
window: BrowserWindow,
|
||||||
|
generation: number,
|
||||||
|
): boolean => modalWindowBoundsReconcileGenerations.get(window) === generation;
|
||||||
|
|
||||||
|
const scheduleModalWindowBoundsReconcile = (
|
||||||
|
window: BrowserWindow,
|
||||||
|
generation: number,
|
||||||
|
attempt = 0,
|
||||||
|
): void => {
|
||||||
|
if (attempt >= MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
if (!isCurrentModalWindowBoundsReconcileGeneration(window, generation)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (window.isDestroyed() || !window.isVisible()) {
|
if (window.isDestroyed() || !window.isVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconcileModalWindowBounds(window);
|
const status = reconcileModalWindowBounds(window);
|
||||||
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS);
|
// Keep retrying only while a Hyprland placement is applicable but the compositor has
|
||||||
|
// not mapped the window yet. Once the client is found (or we're not on Hyprland), stop.
|
||||||
|
if (status && status.applicable && !status.clientFound) {
|
||||||
|
scheduleModalWindowBoundsReconcile(window, generation, attempt + 1);
|
||||||
|
}
|
||||||
|
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS[attempt]);
|
||||||
timeout.unref?.();
|
timeout.unref?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,8 +237,9 @@ export function createOverlayModalRuntimeService(
|
|||||||
if (!window.webContents.isFocused()) {
|
if (!window.webContents.isFocused()) {
|
||||||
window.webContents.focus();
|
window.webContents.focus();
|
||||||
}
|
}
|
||||||
|
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
||||||
reconcileModalWindowBounds(window);
|
reconcileModalWindowBounds(window);
|
||||||
scheduleModalWindowBoundsReconcile(window);
|
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||||
@@ -219,8 +251,9 @@ export function createOverlayModalRuntimeService(
|
|||||||
if (window.isVisible()) {
|
if (window.isVisible()) {
|
||||||
window.focus();
|
window.focus();
|
||||||
window.webContents.focus();
|
window.webContents.focus();
|
||||||
|
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
||||||
reconcileModalWindowBounds(window);
|
reconcileModalWindowBounds(window);
|
||||||
scheduleModalWindowBoundsReconcile(window);
|
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,50 @@ test('field grouping overlay main deps builder maps window visibility and resolv
|
|||||||
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
|
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
|
||||||
assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
|
assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('field grouping overlay main deps builder forwards modal open/teardown/prereq wiring', () => {
|
||||||
|
// Regression: these are optional on the runtime options, so a missing forward compiled
|
||||||
|
// silently and left the field grouping modal with no ack/retry, no teardown, and no prereqs —
|
||||||
|
// the reason the earlier recovery fixes never took effect.
|
||||||
|
const calls: string[] = [];
|
||||||
|
const waitForModalOpen = async (modal: 'kiku', timeoutMs: number): Promise<boolean> => {
|
||||||
|
calls.push(`wait:${modal}:${timeoutMs}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const handleOverlayModalClosed = (modal: 'kiku'): void => {
|
||||||
|
calls.push(`closed:${modal}`);
|
||||||
|
};
|
||||||
|
const logWarn = (message: string): void => {
|
||||||
|
calls.push(`warn:${message}`);
|
||||||
|
};
|
||||||
|
const ensureOverlayStartupPrereqs = (): void => {
|
||||||
|
calls.push('prereqs');
|
||||||
|
};
|
||||||
|
const ensureOverlayWindowsReadyForVisibilityActions = (): void => {
|
||||||
|
calls.push('windows-ready');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = createBuildFieldGroupingOverlayMainDepsHandler<'kiku'>({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: () => null,
|
||||||
|
setResolver: () => {},
|
||||||
|
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
|
||||||
|
waitForModalOpen,
|
||||||
|
handleOverlayModalClosed,
|
||||||
|
logWarn,
|
||||||
|
ensureOverlayStartupPrereqs,
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
|
sendToActiveOverlayWindow: () => true,
|
||||||
|
})();
|
||||||
|
|
||||||
|
assert.equal(deps.waitForModalOpen, waitForModalOpen);
|
||||||
|
assert.equal(deps.handleOverlayModalClosed, handleOverlayModalClosed);
|
||||||
|
assert.equal(deps.logWarn, logWarn);
|
||||||
|
assert.equal(deps.ensureOverlayStartupPrereqs, ensureOverlayStartupPrereqs);
|
||||||
|
assert.equal(
|
||||||
|
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
|
|||||||
getResolver: () => deps.getResolver(),
|
getResolver: () => deps.getResolver(),
|
||||||
setResolver: (resolver) => deps.setResolver(resolver),
|
setResolver: (resolver) => deps.setResolver(resolver),
|
||||||
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),
|
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),
|
||||||
|
// These are optional on the runtime options, so a missing forward compiles silently — but
|
||||||
|
// dropping them left the field grouping modal with no modal-open ack/retry, no teardown on
|
||||||
|
// failure, and no warn logging, which is why the earlier recovery fixes never took effect.
|
||||||
|
waitForModalOpen: deps.waitForModalOpen,
|
||||||
|
handleOverlayModalClosed: deps.handleOverlayModalClosed,
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions:
|
||||||
|
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
sendToVisibleOverlay: (
|
sendToVisibleOverlay: (
|
||||||
channel: string,
|
channel: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ test('overlay modal runtime main deps builder maps window resolvers', () => {
|
|||||||
getModalWindow: () => modalWindow as never,
|
getModalWindow: () => modalWindow as never,
|
||||||
createModalWindow: () => modalWindow as never,
|
createModalWindow: () => modalWindow as never,
|
||||||
getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
|
getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
|
||||||
setModalWindowBounds: (geometry) =>
|
setModalWindowBounds: (geometry) => {
|
||||||
calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`),
|
calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`);
|
||||||
|
},
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.getMainWindow(), mainWindow);
|
assert.equal(deps.getMainWindow(), mainWindow);
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ const onKikuFieldGroupingRequestEvent =
|
|||||||
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
||||||
(payload) => payload as KikuFieldGroupingRequestData,
|
(payload) => payload as KikuFieldGroupingRequestData,
|
||||||
);
|
);
|
||||||
|
const onKikuFieldGroupingCancelEvent = createQueuedIpcListener(
|
||||||
|
IPC_CHANNELS.event.kikuFieldGroupingCancel,
|
||||||
|
);
|
||||||
const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>(
|
const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>(
|
||||||
IPC_CHANNELS.event.subtitleSet,
|
IPC_CHANNELS.event.subtitleSet,
|
||||||
(payload) => payload as SubtitleData,
|
(payload) => payload as SubtitleData,
|
||||||
@@ -396,6 +399,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
|
||||||
|
|
||||||
onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent,
|
onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent,
|
||||||
|
onKikuFieldGroupingCancel: onKikuFieldGroupingCancelEvent,
|
||||||
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
|
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request),
|
||||||
|
|
||||||
|
|||||||
@@ -565,6 +565,13 @@ function registerModalOpenHandlers(): void {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
window.electronAPI.onKikuFieldGroupingCancel(() => {
|
||||||
|
runGuarded('kiku:field-grouping-cancel', () => {
|
||||||
|
// Main already settled the choice (timeout/failure); just close the dialog. Using the
|
||||||
|
// plain close path avoids sending a second, redundant response back to main.
|
||||||
|
kikuModal.closeKikuFieldGroupingModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerKeyboardCommandHandlers(): void {
|
function registerKeyboardCommandHandlers(): void {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export const IPC_CHANNELS = {
|
|||||||
secondarySubtitleMode: 'secondary-subtitle:mode',
|
secondarySubtitleMode: 'secondary-subtitle:mode',
|
||||||
subsyncOpenManual: 'subsync:open-manual',
|
subsyncOpenManual: 'subsync:open-manual',
|
||||||
kikuFieldGroupingRequest: 'kiku:field-grouping-request',
|
kikuFieldGroupingRequest: 'kiku:field-grouping-request',
|
||||||
|
kikuFieldGroupingCancel: 'kiku:field-grouping-cancel',
|
||||||
runtimeOptionsChanged: 'runtime-options:changed',
|
runtimeOptionsChanged: 'runtime-options:changed',
|
||||||
runtimeOptionsOpen: 'runtime-options:open',
|
runtimeOptionsOpen: 'runtime-options:open',
|
||||||
jimakuOpen: 'jimaku:open',
|
jimakuOpen: 'jimaku:open',
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ export interface ElectronAPI {
|
|||||||
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
|
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
|
||||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||||
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
||||||
|
onKikuFieldGroupingCancel: (callback: () => void) => void;
|
||||||
kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
|
kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
|
||||||
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void;
|
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void;
|
||||||
getRuntimeOptions: () => Promise<RuntimeOptionState[]>;
|
getRuntimeOptions: () => Promise<RuntimeOptionState[]>;
|
||||||
|
|||||||
Reference in New Issue
Block a user