1// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime.
2
3import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts";
4
5// The quit accelerator is intercepted in before-input-event, which runs
6// before the native menu accelerator. Quitting from the application menu is
7// untouched and always quits immediately.
8export const QUIT_HOLD_DURATION_MS = 1200;
9export const QUIT_DOUBLE_PRESS_MS = 500;
10// "Still held" is proven by auto-repeat keydowns, not by the absence of a
11// release: macOS suppresses a letter keyUp while the command key is down, so a
12// tap release can go completely unseen and a release-based timer would quit
13// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after
14// repeats stop so they cannot reach the next app. Keyboards with
15// auto-repeat disabled must use a double press or the application menu Quit action.
16// Supporting holds without repeats requires a native physical key-state check.
17export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
18// A slow repeat rate can exceed the fixed grace. Waiting for two observed
19// cadences keeps the timer behind the next repeat without slowing normal rates.
20const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2;
21
22export interface QuitHoldKeyInput {
23 readonly type: string;
24 readonly key: string;
25 readonly meta: boolean;
26 readonly control: boolean;
27 readonly alt: boolean;
28 readonly shift: boolean;
29 readonly isAutoRepeat: boolean;
30}
31
32export interface QuitShortcutOptions {
33 readonly platform: NodeJS.Platform;
34 readonly getMode: () => Promise<QuitConfirmationMode>;
35 readonly notify: (event: QuitShortcutHintEvent) => void;
36 readonly concealWindow: () => void;
37 readonly quit: () => void;
38}
39
40export function makeQuitShortcutHandler(
41 options: QuitShortcutOptions,
42): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void {
43 const modifierKey = options.platform === "darwin" ? "meta" : "control";
44 let watchdog: NodeJS.Timeout | undefined;
45 let holding = false;
46 let mode: QuitConfirmationMode | undefined;
47 let notified = false;
48 // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed.
49 let armed = false;
50 let quitOnRelease = false;
51 let heldSince = 0;
52 let lastPressAt = 0;
53 let lastRepeatAt = 0;
54 let repeatCadenceMs = 0;
55 // Incremented when a press is superseded or explicitly cancelled. A plain
56 // key release does not invalidate its pending mode read: a direct-mode
57 // press must still quit after that read settles.
58 let generation = 0;
59
60 const clearWatchdog = () => {
61 if (watchdog !== undefined) {
62 clearTimeout(watchdog);
63 watchdog = undefined;
64 }
65 };
66
67 const release = (cancelPendingMode = true, keepDoublePressHint = false) => {
68 if (cancelPendingMode) generation += 1;
69 if (!holding && !notified) return;
70 const keepHint = keepDoublePressHint && mode === "double-click" && notified;
71 holding = false;
72 armed = false;
73 quitOnRelease = false;
74 lastRepeatAt = 0;
75 repeatCadenceMs = 0;
76 if (keepHint) return;
77
78 mode = undefined;
79 clearWatchdog();
80 if (notified) {
81 notified = false;
82 options.notify({ state: "up" });
83 }
84 };
85
86 // Dismisses any overlay first so a cancelled quit cannot leave a stale hint.
87 const quitNow = () => {
88 release();
89 lastPressAt = 0;
90 options.quit();
91 };
92
93 const quitAfterQuietPeriod = () => {
94 clearWatchdog();
95 const quietPeriodMs = Math.max(
96 QUIT_HOLD_RELEASE_GRACE_MS,
97 repeatCadenceMs * QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER,
98 );
99 watchdog = setTimeout(quitNow, quietPeriodMs);
100 };
101
102 return (event, input) => {
103 const key = input.key.toLowerCase();
104 if (input.type === "keyUp") {
105 if (key === "q") {
106 const shouldQuit = quitOnRelease;
107 release(false, true);
108 if (shouldQuit) options.quit();
109 } else if (key === modifierKey) {
110 if (!quitOnRelease) {
111 release(false, true);
112 } else {
113 quitAfterQuietPeriod();
114 }
115 }
116 return;
117 }
118 if (input.type !== "keyDown") return;
119
120 const modifierDown = options.platform === "darwin" ? input.meta : input.control;
121 if (input.isAutoRepeat && modifierDown && key === "q") {
122 const now = Date.now();
123 repeatCadenceMs = now - (lastRepeatAt === 0 ? heldSince : lastRepeatAt);
124 lastRepeatAt = now;
125 }
126 if (quitOnRelease) {
127 event.preventDefault();
128 // A Q keydown proves the key is still down whether or not the modifier
129 // is still held, so it only pushes the quiet period back.
130 if (key === "q") quitAfterQuietPeriod();
131 return;
132 }
133
134 if (!modifierDown || input.alt || input.shift || key !== "q") {
135 // Re-pressing the platform modifier is the first half of a second full
136 // quit shortcut, so it must not cancel an active double-press window.
137 if (key === modifierKey && !input.alt && !input.shift) return;
138
139 // Other keys cancel the hold and the first tap, even after release.
140 // Keep this separate from release(), which also runs when a fresh Q
141 // keydown follows a keyUp that macOS did not deliver.
142 if (!input.isAutoRepeat) {
143 lastPressAt = 0;
144 release();
145 }
146 return;
147 }
148
149 event.preventDefault();
150
151 if (input.isAutoRepeat) {
152 if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
153 armed = false;
154 quitOnRelease = true;
155 options.concealWindow();
156 quitAfterQuietPeriod();
157 }
158 return;
159 }
160
161 const now = Date.now();
162 const previousPressAt = lastPressAt;
163 lastPressAt = now;
164 // A fresh keydown supersedes the current physical hold or the hint kept
165 // alive after a detected release.
166 if (holding || notified) release();
167
168 generation += 1;
169 // Every mode accepts two presses. Quit before reading settings so a slow
170 // read cannot delay the second press. Repeats never reach this branch.
171 if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) {
172 quitNow();
173 return;
174 }
175
176 const pressGeneration = generation;
177 holding = true;
178 heldSince = now;
179 void options.getMode().then(
180 (resolvedMode) => {
181 if (generation !== pressGeneration) return;
182 if (resolvedMode === "direct") {
183 quitNow();
184 return;
185 }
186 if (resolvedMode === "double-click") {
187 const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now);
188 if (remainingMs <= 0) {
189 release();
190 return;
191 }
192 mode = resolvedMode;
193 notified = true;
194 options.notify({ state: "down", mode: resolvedMode });
195 watchdog = setTimeout(release, remainingMs);
196 return;
197 }
198
199 // A hold cannot be armed after its physical press has ended.
200 if (!holding) return;
201
202 mode = resolvedMode;
203 notified = true;
204 options.notify({ state: "down", mode: resolvedMode });
205
206 armed = true;
207 // No auto-repeat by then means the key was released (possibly with a
208 // suppressed keyUp) or repeat is disabled; either way, don't quit.
209 watchdog = setTimeout(() => {
210 watchdog = undefined;
211 release();
212 }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
213 },
214 // A failed settings read must never strand the quit request.
215 () => {
216 if (generation !== pressGeneration) return;
217 quitNow();
218 },
219 );
220 };
221}