Skip to content
Source
Sign inSign up

QuitHold.ts

196 lines5.7 KB

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​
5export const QUIT_HOLD_DURATION_MS = 1200;
6export const QUIT_DOUBLE_PRESS_MS = 500;
7// Auto-repeat keydowns prove the hold: macOS drops a letter keyUp while Command is down,
8// so a release-based timer would quit anyway. Once held, quitting waits for Q keyUp or a
9// quiet grace period so trailing repeats cannot reach the next app.
10const QUIT_HOLD_RELEASE_GRACE_MS = 600;
11// A slow repeat rate can exceed the fixed grace. Waiting for two observed
12// cadences keeps the timer behind the next repeat without slowing normal rates.
13const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2;
14​
15export interface QuitHoldKeyInput {
16 readonly type: string;
17 readonly key: string;
18 readonly meta: boolean;
19 readonly control: boolean;
20 readonly alt: boolean;
21 readonly shift: boolean;
22 readonly isAutoRepeat: boolean;
24​
25export interface QuitShortcutOptions {
26 readonly platform: NodeJS.Platform;
27 readonly getMode: () => Promise<QuitConfirmationMode>;
28 readonly notify: (event: QuitShortcutHintEvent) => void;
29 readonly concealWindow: () => void;
30 readonly quit: () => void;
32​
33export function makeQuitShortcutHandler(
34 options: QuitShortcutOptions,
35): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void {
36 const modifierKey = options.platform === "darwin" ? "meta" : "control";
37 let watchdog: NodeJS.Timeout | undefined;
38 let holding = false;
39 let mode: QuitConfirmationMode | undefined;
40 let notified = false;
41 let armed = false;
42 let quitOnRelease = false;
43 let heldSince = 0;
44 let lastPressAt = 0;
45 let lastRepeatAt = 0;
46 let repeatCadenceMs = 0;
47 // A key release must not invalidate a pending direct-mode read; only superseding/cancelled presses do.
48 let generation = 0;
49​
50 const clearWatchdog = () => {
51 if (watchdog !== undefined) {
52 clearTimeout(watchdog);
53 watchdog = undefined;
54 }
55 };
56​
57 const release = (cancelPendingMode = true, keepDoublePressHint = false) => {
58 if (cancelPendingMode) generation += 1;
59 if (!holding && !notified) return;
60 const keepHint = keepDoublePressHint && mode === "double-click" && notified;
61 holding = false;
62 armed = false;
63 quitOnRelease = false;
64 lastRepeatAt = 0;
65 repeatCadenceMs = 0;
66 if (keepHint) return;
67​
68 mode = undefined;
69 clearWatchdog();
70 if (notified) {
71 notified = false;
72 options.notify({ state: "up" });
73 }
74 };
75​
76 const quitNow = () => {
77 release();
78 lastPressAt = 0;
79 options.quit();
80 };
81​
82 const quitAfterQuietPeriod = () => {
83 clearWatchdog();
84 const quietPeriodMs = Math.max(
85 QUIT_HOLD_RELEASE_GRACE_MS,
86 repeatCadenceMs * QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER,
87 );
88 watchdog = setTimeout(quitNow, quietPeriodMs);
89 };
90​
91 return (event, input) => {
92 const key = input.key.toLowerCase();
93 if (input.type === "keyUp") {
94 if (key === "q") {
95 const shouldQuit = quitOnRelease;
96 release(false, true);
97 if (shouldQuit) options.quit();
98 } else if (key === modifierKey) {
99 if (!quitOnRelease) {
100 release(false, true);
101 } else {
102 quitAfterQuietPeriod();
103 }
104 }
105 return;
106 }
107 if (input.type !== "keyDown") return;
108​
109 const modifierDown = options.platform === "darwin" ? input.meta : input.control;
110 if (input.isAutoRepeat && modifierDown && key === "q") {
111 const now = Date.now();
112 repeatCadenceMs = now - (lastRepeatAt === 0 ? heldSince : lastRepeatAt);
113 lastRepeatAt = now;
114 }
115 if (quitOnRelease) {
116 event.preventDefault();
117 if (key === "q") quitAfterQuietPeriod();
118 return;
119 }
120​
121 if (!modifierDown || input.alt || input.shift || key !== "q") {
122 if (key === modifierKey && !input.alt && !input.shift) return;
123​
124 // release() also runs on fresh keydown after a missing macOS keyUp.
125 if (!input.isAutoRepeat) {
126 lastPressAt = 0;
127 release();
128 }
129 return;
130 }
131​
132 event.preventDefault();
133​
134 if (input.isAutoRepeat) {
135 if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
136 armed = false;
137 quitOnRelease = true;
138 options.concealWindow();
139 quitAfterQuietPeriod();
140 }
141 return;
142 }
143​
144 const now = Date.now();
145 const previousPressAt = lastPressAt;
146 lastPressAt = now;
147 if (holding || notified) release();
148​
149 generation += 1;
150 if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) {
151 quitNow();
152 return;
153 }
154​
155 const pressGeneration = generation;
156 holding = true;
157 heldSince = now;
158 void options.getMode().then(
159 (resolvedMode) => {
160 if (generation !== pressGeneration) return;
161 if (resolvedMode === "direct") {
162 quitNow();
163 return;
164 }
165 if (resolvedMode === "double-click") {
166 const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now);
167 if (remainingMs <= 0) {
168 release();
169 return;
170 }
171 mode = resolvedMode;
172 notified = true;
173 options.notify({ state: "down", mode: resolvedMode });
174 watchdog = setTimeout(release, remainingMs);
175 return;
176 }
177​
178 if (!holding) return;
179​
180 mode = resolvedMode;
181 notified = true;
182 options.notify({ state: "down", mode: resolvedMode });
183​
184 armed = true;
185 watchdog = setTimeout(() => {
186 watchdog = undefined;
187 release();
188 }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
189 },
190 () => {
191 if (generation !== pressGeneration) return;
192 quitNow();
193 },
194 );
195 };