| author | |
| committer | |
| log | b8262b41228e03b2e1e14b701be449034349c8ba |
| tree | 615055127f900902c7eacb6c81c287ee227c847f |
| parent | cde12790d721ab2a90471750fd75d9f5bf13f57d |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 159 insertions(+), 8 deletions(-)
apps/desktop/src/window/DesktopWindow.test.ts+23| ... | @@ -105,6 +105,8 @@ function makeFakeBrowserWindow() { | ... | @@ -105,6 +105,8 @@ function makeFakeBrowserWindow() { |
| 105 | restore: vi.fn(), | 105 | restore: vi.fn(), |
| 106 | setBackgroundColor: vi.fn(), | 106 | setBackgroundColor: vi.fn(), |
| 107 | setAutoHideCursor: vi.fn(), | 107 | setAutoHideCursor: vi.fn(), |
| 108 | setFullScreen: vi.fn(), | ||
| 109 | setOpacity: vi.fn(), | ||
| 108 | setTitle: vi.fn(), | 110 | setTitle: vi.fn(), |
| 109 | setTitleBarOverlay: vi.fn(), | 111 | setTitleBarOverlay: vi.fn(), |
| 110 | show: vi.fn(), | 112 | show: vi.fn(), |
| ... | @@ -127,6 +129,8 @@ function makeFakeBrowserWindow() { | ... | @@ -127,6 +129,8 @@ function makeFakeBrowserWindow() { |
| 127 | setZoomLevel: webContents.setZoomLevel, | 129 | setZoomLevel: webContents.setZoomLevel, |
| 128 | setBackgroundThrottling: webContents.setBackgroundThrottling, | 130 | setBackgroundThrottling: webContents.setBackgroundThrottling, |
| 129 | setAutoHideCursor: window.setAutoHideCursor, | 131 | setAutoHideCursor: window.setAutoHideCursor, |
| 132 | setFullScreen: window.setFullScreen, | ||
| 133 | setOpacity: window.setOpacity, | ||
| 130 | webContentsListeners, | 134 | webContentsListeners, |
| 131 | windowListeners, | 135 | windowListeners, |
| 132 | }; | 136 | }; |
| ... | @@ -394,6 +398,25 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n | ... | @@ -394,6 +398,25 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n |
| 394 | }); | 398 | }); |
| 395 | 399 | ||
| 396 | describe("DesktopWindow", () => { | 400 | describe("DesktopWindow", () => { |
| 401 | it("leaves fullscreen before concealing a pending quit", () => { | ||
| 402 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 403 | |||
| 404 | DesktopWindow.concealPendingQuitWindow(fakeWindow.window); | ||
| 405 | assert.deepEqual(fakeWindow.setOpacity.mock.calls, [[0]]); | ||
| 406 | |||
| 407 | fakeWindow.setOpacity.mockClear(); | ||
| 408 | fakeWindow.isFullScreen.mockReturnValue(true); | ||
| 409 | DesktopWindow.concealPendingQuitWindow(fakeWindow.window); | ||
| 410 | assert.deepEqual(fakeWindow.setFullScreen.mock.calls, [[false]]); | ||
| 411 | assert.deepEqual(fakeWindow.setOpacity.mock.calls, [[0]]); | ||
| 412 | |||
| 413 | fakeWindow.setOpacity.mockClear(); | ||
| 414 | fakeWindow.isFullScreen.mockReturnValue(false); | ||
| 415 | fakeWindow.isDestroyed.mockReturnValue(true); | ||
| 416 | DesktopWindow.concealPendingQuitWindow(fakeWindow.window); | ||
| 417 | assert.equal(fakeWindow.setOpacity.mock.calls.length, 0); | ||
| 418 | }); | ||
| 419 | |||
| 397 | it("restores bounds only when the window fits within a connected display", () => { | 420 | it("restores bounds only when the window fits within a connected display", () => { |
| 398 | const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; | 421 | const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; |
| 399 | const displays = [ | 422 | const displays = [ |
apps/desktop/src/window/DesktopWindow.ts+18| ... | @@ -207,6 +207,21 @@ export function isRetryableDevelopmentRendererLoadFailure(input: { | ... | @@ -207,6 +207,21 @@ export function isRetryableDevelopmentRendererLoadFailure(input: { |
| 207 | ); | 207 | ); |
| 208 | } | 208 | } |
| 209 | 209 | ||
| 210 | export function concealPendingQuitWindow( | ||
| 211 | window: Pick< | ||
| 212 | Electron.BrowserWindow, | ||
| 213 | "isDestroyed" | "isFullScreen" | "setFullScreen" | "setOpacity" | ||
| 214 | >, | ||
| 215 | ): void { | ||
| 216 | if (window.isDestroyed()) return; | ||
| 217 | if (window.isFullScreen()) { | ||
| 218 | window.setFullScreen(false); | ||
| 219 | } | ||
| 220 | // Electron implements window opacity on macOS and Windows. Linux keeps the | ||
| 221 | // release-gated quit behavior but cannot make the pending window disappear. | ||
| 222 | window.setOpacity(0); | ||
| 223 | } | ||
| 224 | |||
| 210 | function getWindowTitleBarOptions( | 225 | function getWindowTitleBarOptions( |
| 211 | shouldUseDarkColors: boolean, | 226 | shouldUseDarkColors: boolean, |
| 212 | platform: NodeJS.Platform, | 227 | platform: NodeJS.Platform, |
| ... | @@ -570,6 +585,9 @@ export const make = Effect.gen(function* () { | ... | @@ -570,6 +585,9 @@ export const make = Effect.gen(function* () { |
| 570 | window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); | 585 | window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); |
| 571 | } | 586 | } |
| 572 | }, | 587 | }, |
| 588 | // Keep the transparent window focused until the physical shortcut is | ||
| 589 | // released so its remaining repeats cannot reach the next app. | ||
| 590 | concealWindow: () => concealPendingQuitWindow(window), | ||
| 573 | quit: () => { | 591 | quit: () => { |
| 574 | void runPromise(electronApp.quit); | 592 | void runPromise(electronApp.quit); |
| 575 | }, | 593 | }, |
apps/desktop/src/window/QuitHold.test.ts+83-2| ... | @@ -32,11 +32,13 @@ function makeHarness(options?: { | ... | @@ -32,11 +32,13 @@ function makeHarness(options?: { |
| 32 | getMode?: () => Promise<QuitConfirmationMode>; | 32 | getMode?: () => Promise<QuitConfirmationMode>; |
| 33 | }) { | 33 | }) { |
| 34 | const notifications: Array<QuitShortcutHintEvent> = []; | 34 | const notifications: Array<QuitShortcutHintEvent> = []; |
| 35 | const concealWindow = vi.fn(); | ||
| 35 | const quit = vi.fn(); | 36 | const quit = vi.fn(); |
| 36 | const handler = makeQuitShortcutHandler({ | 37 | const handler = makeQuitShortcutHandler({ |
| 37 | platform: options?.platform ?? "darwin", | 38 | platform: options?.platform ?? "darwin", |
| 38 | getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")), | 39 | getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")), |
| 39 | notify: (event) => notifications.push(event), | 40 | notify: (event) => notifications.push(event), |
| 41 | concealWindow, | ||
| 40 | quit, | 42 | quit, |
| 41 | }); | 43 | }); |
| 42 | const preventDefault = vi.fn(); | 44 | const preventDefault = vi.fn(); |
| ... | @@ -57,7 +59,7 @@ function makeHarness(options?: { | ... | @@ -57,7 +59,7 @@ function makeHarness(options?: { |
| 57 | await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); | 59 | await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); |
| 58 | } | 60 | } |
| 59 | }; | 61 | }; |
| 60 | return { notifications, quit, preventDefault, send, holdFor }; | 62 | return { notifications, concealWindow, quit, preventDefault, send, holdFor }; |
| 61 | } | 63 | } |
| 62 | 64 | ||
| 63 | describe("makeQuitShortcutHandler", () => { | 65 | describe("makeQuitShortcutHandler", () => { |
| ... | @@ -82,10 +84,11 @@ describe("makeQuitShortcutHandler", () => { | ... | @@ -82,10 +84,11 @@ describe("makeQuitShortcutHandler", () => { |
| 82 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | 84 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); |
| 83 | }); | 85 | }); |
| 84 | 86 | ||
| 85 | it("quits after a completed hold is released", async () => { | 87 | it("conceals a completed hold, then quits after release", async () => { |
| 86 | const harness = makeHarness(); | 88 | const harness = makeHarness(); |
| 87 | await harness.send(makeInput({})); | 89 | await harness.send(makeInput({})); |
| 88 | await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); | 90 | await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); |
| 91 | expect(harness.concealWindow).toHaveBeenCalledTimes(1); | ||
| 89 | expect(harness.quit).not.toHaveBeenCalled(); | 92 | expect(harness.quit).not.toHaveBeenCalled(); |
| 90 | await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); | 93 | await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); |
| 91 | expect(harness.quit).not.toHaveBeenCalled(); | 94 | expect(harness.quit).not.toHaveBeenCalled(); |
| ... | @@ -94,6 +97,81 @@ describe("makeQuitShortcutHandler", () => { | ... | @@ -94,6 +97,81 @@ describe("makeQuitShortcutHandler", () => { |
| 94 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | 97 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); |
| 95 | }); | 98 | }); |
| 96 | 99 | ||
| 100 | it("keeps a concealed hold committed when another key is pressed", async () => { | ||
| 101 | const harness = makeHarness(); | ||
| 102 | await harness.send(makeInput({})); | ||
| 103 | await harness.holdFor(QUIT_HOLD_DURATION_MS); | ||
| 104 | |||
| 105 | await harness.send(makeInput({ key: "Shift", shift: true })); | ||
| 106 | expect(harness.concealWindow).toHaveBeenCalledTimes(1); | ||
| 107 | expect(harness.quit).not.toHaveBeenCalled(); | ||
| 108 | |||
| 109 | vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); | ||
| 110 | expect(harness.quit).toHaveBeenCalledTimes(1); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("keeps a concealed hold committed through a fresh Cmd+Q press", async () => { | ||
| 114 | const harness = makeHarness(); | ||
| 115 | await harness.send(makeInput({})); | ||
| 116 | await harness.holdFor(QUIT_HOLD_DURATION_MS); | ||
| 117 | |||
| 118 | await harness.send(makeInput({})); | ||
| 119 | expect(harness.concealWindow).toHaveBeenCalledTimes(1); | ||
| 120 | expect(harness.quit).not.toHaveBeenCalled(); | ||
| 121 | |||
| 122 | await harness.send(makeInput({ type: "keyUp" })); | ||
| 123 | expect(harness.quit).toHaveBeenCalledTimes(1); | ||
| 124 | }); | ||
| 125 | |||
| 126 | it("quits when a completed hold goes quiet without release events", async () => { | ||
| 127 | const harness = makeHarness(); | ||
| 128 | await harness.send(makeInput({})); | ||
| 129 | await harness.holdFor(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS * 2); | ||
| 130 | |||
| 131 | // If neither keyUp reaches the handler, continued repeats must keep the | ||
| 132 | // app alive. Once they stop, the quiet period is the release signal. | ||
| 133 | expect(harness.quit).not.toHaveBeenCalled(); | ||
| 134 | vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); | ||
| 135 | |||
| 136 | expect(harness.quit).toHaveBeenCalledTimes(1); | ||
| 137 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("waits for slow repeats to stop before quitting", async () => { | ||
| 141 | const harness = makeHarness(); | ||
| 142 | await harness.send(makeInput({})); | ||
| 143 | |||
| 144 | vi.advanceTimersByTime(300); | ||
| 145 | await harness.send(makeInput({ isAutoRepeat: true })); | ||
| 146 | vi.advanceTimersByTime(900); | ||
| 147 | await harness.send(makeInput({ isAutoRepeat: true })); | ||
| 148 | |||
| 149 | vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); | ||
| 150 | expect(harness.quit).not.toHaveBeenCalled(); | ||
| 151 | |||
| 152 | vi.advanceTimersByTime(300); | ||
| 153 | await harness.send(makeInput({ isAutoRepeat: true })); | ||
| 154 | vi.advanceTimersByTime(1_799); | ||
| 155 | expect(harness.quit).not.toHaveBeenCalled(); | ||
| 156 | vi.advanceTimersByTime(1); | ||
| 157 | expect(harness.quit).toHaveBeenCalledTimes(1); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("uses the initial repeat delay when the first repeat completes the hold", async () => { | ||
| 161 | const harness = makeHarness(); | ||
| 162 | await harness.send(makeInput({})); | ||
| 163 | |||
| 164 | vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + 100); | ||
| 165 | await harness.send(makeInput({ isAutoRepeat: true })); | ||
| 166 | |||
| 167 | vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); | ||
| 168 | expect(harness.quit).not.toHaveBeenCalled(); | ||
| 169 | vi.advanceTimersByTime(1_999); | ||
| 170 | expect(harness.quit).not.toHaveBeenCalled(); | ||
| 171 | vi.advanceTimersByTime(1); | ||
| 172 | expect(harness.quit).toHaveBeenCalledTimes(1); | ||
| 173 | }); | ||
| 174 | |||
| 97 | it("waits for Q release when Cmd is released first", async () => { | 175 | it("waits for Q release when Cmd is released first", async () => { |
| 98 | const harness = makeHarness(); | 176 | const harness = makeHarness(); |
| 99 | await harness.send(makeInput({})); | 177 | await harness.send(makeInput({})); |
| ... | @@ -115,6 +193,7 @@ describe("makeQuitShortcutHandler", () => { | ... | @@ -115,6 +193,7 @@ describe("makeQuitShortcutHandler", () => { |
| 115 | await harness.send(makeInput({ type: "keyUp" })); | 193 | await harness.send(makeInput({ type: "keyUp" })); |
| 116 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | 194 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); |
| 117 | vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); | 195 | vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); |
| 196 | expect(harness.concealWindow).not.toHaveBeenCalled(); | ||
| 118 | expect(harness.quit).not.toHaveBeenCalled(); | 197 | expect(harness.quit).not.toHaveBeenCalled(); |
| 119 | }); | 198 | }); |
| 120 | 199 | ||
| ... | @@ -130,6 +209,7 @@ describe("makeQuitShortcutHandler", () => { | ... | @@ -130,6 +209,7 @@ describe("makeQuitShortcutHandler", () => { |
| 130 | it("quits without showing a hint in direct mode", async () => { | 209 | it("quits without showing a hint in direct mode", async () => { |
| 131 | const harness = makeHarness({ mode: "direct" }); | 210 | const harness = makeHarness({ mode: "direct" }); |
| 132 | await harness.send(makeInput({})); | 211 | await harness.send(makeInput({})); |
| 212 | expect(harness.concealWindow).not.toHaveBeenCalled(); | ||
| 133 | expect(harness.quit).toHaveBeenCalledTimes(1); | 213 | expect(harness.quit).toHaveBeenCalledTimes(1); |
| 134 | expect(harness.notifications).toEqual([]); | 214 | expect(harness.notifications).toEqual([]); |
| 135 | }); | 215 | }); |
| ... | @@ -223,6 +303,7 @@ describe("makeQuitShortcutHandler", () => { | ... | @@ -223,6 +303,7 @@ describe("makeQuitShortcutHandler", () => { |
| 223 | await harness.send(makeInput({})); | 303 | await harness.send(makeInput({})); |
| 224 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); | 304 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); |
| 225 | await harness.send(makeInput({})); | 305 | await harness.send(makeInput({})); |
| 306 | expect(harness.concealWindow).not.toHaveBeenCalled(); | ||
| 226 | expect(harness.quit).toHaveBeenCalledTimes(1); | 307 | expect(harness.quit).toHaveBeenCalledTimes(1); |
| 227 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); | 308 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); |
| 228 | }); | 309 | }); |
apps/desktop/src/window/QuitHold.ts+35-6| ... | @@ -11,9 +11,12 @@ export const QUIT_DOUBLE_PRESS_MS = 500; | ... | @@ -11,9 +11,12 @@ export const QUIT_DOUBLE_PRESS_MS = 500; |
| 11 | // release: macOS suppresses a letter keyUp while the command key is down, so 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 | 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 | 13 | // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after |
| 14 | // modifier keyUp so repeats cannot reach the next app. Keyboards with | 14 | // repeats stop so they cannot reach the next app. Keyboards with |
| 15 | // auto-repeat disabled fall back to the application menu Quit action. | 15 | // auto-repeat disabled fall back to the application menu Quit action. |
| 16 | export const QUIT_HOLD_RELEASE_GRACE_MS = 600; | 16 | export const QUIT_HOLD_RELEASE_GRACE_MS = 600; |
| 17 | // A slow repeat rate can exceed the fixed grace. Waiting for two observed | ||
| 18 | // cadences keeps the timer behind the next repeat without slowing normal rates. | ||
| 19 | const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2; | ||
| 17 | 20 | ||
| 18 | export interface QuitHoldKeyInput { | 21 | export interface QuitHoldKeyInput { |
| 19 | readonly type: string; | 22 | readonly type: string; |
| ... | @@ -29,6 +32,7 @@ export interface QuitShortcutOptions { | ... | @@ -29,6 +32,7 @@ export interface QuitShortcutOptions { |
| 29 | readonly platform: NodeJS.Platform; | 32 | readonly platform: NodeJS.Platform; |
| 30 | readonly getMode: () => Promise<QuitConfirmationMode>; | 33 | readonly getMode: () => Promise<QuitConfirmationMode>; |
| 31 | readonly notify: (event: QuitShortcutHintEvent) => void; | 34 | readonly notify: (event: QuitShortcutHintEvent) => void; |
| 35 | readonly concealWindow: () => void; | ||
| 32 | readonly quit: () => void; | 36 | readonly quit: () => void; |
| 33 | } | 37 | } |
| 34 | 38 | ||
| ... | @@ -45,6 +49,8 @@ export function makeQuitShortcutHandler( | ... | @@ -45,6 +49,8 @@ export function makeQuitShortcutHandler( |
| 45 | let quitOnRelease = false; | 49 | let quitOnRelease = false; |
| 46 | let heldSince = 0; | 50 | let heldSince = 0; |
| 47 | let lastPressAt = 0; | 51 | let lastPressAt = 0; |
| 52 | let lastRepeatAt = 0; | ||
| 53 | let repeatCadenceMs = 0; | ||
| 48 | // Incremented when a press is superseded or explicitly cancelled. A plain | 54 | // Incremented when a press is superseded or explicitly cancelled. A plain |
| 49 | // key release does not invalidate its pending mode read: direct mode and a | 55 | // key release does not invalidate its pending mode read: direct mode and a |
| 50 | // completed second press must still be honored after that read settles. | 56 | // completed second press must still be honored after that read settles. |
| ... | @@ -64,6 +70,8 @@ export function makeQuitShortcutHandler( | ... | @@ -64,6 +70,8 @@ export function makeQuitShortcutHandler( |
| 64 | holding = false; | 70 | holding = false; |
| 65 | armed = false; | 71 | armed = false; |
| 66 | quitOnRelease = false; | 72 | quitOnRelease = false; |
| 73 | lastRepeatAt = 0; | ||
| 74 | repeatCadenceMs = 0; | ||
| 67 | if (keepHint) return; | 75 | if (keepHint) return; |
| 68 | 76 | ||
| 69 | mode = undefined; | 77 | mode = undefined; |
| ... | @@ -80,6 +88,15 @@ export function makeQuitShortcutHandler( | ... | @@ -80,6 +88,15 @@ export function makeQuitShortcutHandler( |
| 80 | options.quit(); | 88 | options.quit(); |
| 81 | }; | 89 | }; |
| 82 | 90 | ||
| 91 | const quitAfterQuietPeriod = () => { | ||
| 92 | clearWatchdog(); | ||
| 93 | const quietPeriodMs = Math.max( | ||
| 94 | QUIT_HOLD_RELEASE_GRACE_MS, | ||
| 95 | repeatCadenceMs * QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER, | ||
| 96 | ); | ||
| 97 | watchdog = setTimeout(quitNow, quietPeriodMs); | ||
| 98 | }; | ||
| 99 | |||
| 83 | return (event, input) => { | 100 | return (event, input) => { |
| 84 | const key = input.key.toLowerCase(); | 101 | const key = input.key.toLowerCase(); |
| 85 | if (input.type === "keyUp") { | 102 | if (input.type === "keyUp") { |
| ... | @@ -91,20 +108,31 @@ export function makeQuitShortcutHandler( | ... | @@ -91,20 +108,31 @@ export function makeQuitShortcutHandler( |
| 91 | if (!quitOnRelease) { | 108 | if (!quitOnRelease) { |
| 92 | release(false, true); | 109 | release(false, true); |
| 93 | } else { | 110 | } else { |
| 94 | watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS); | 111 | quitAfterQuietPeriod(); |
| 95 | } | 112 | } |
| 96 | } | 113 | } |
| 97 | return; | 114 | return; |
| 98 | } | 115 | } |
| 99 | if (input.type !== "keyDown") return; | 116 | if (input.type !== "keyDown") return; |
| 100 | 117 | ||
| 101 | if (quitOnRelease && input.isAutoRepeat && key === "q") { | 118 | const modifierDown = options.platform === "darwin" ? input.meta : input.control; |
| 119 | if (input.isAutoRepeat && modifierDown && key === "q") { | ||
| 120 | const now = Date.now(); | ||
| 121 | repeatCadenceMs = now - (lastRepeatAt === 0 ? heldSince : lastRepeatAt); | ||
| 122 | lastRepeatAt = now; | ||
| 123 | } | ||
| 124 | if (quitOnRelease) { | ||
| 102 | event.preventDefault(); | 125 | event.preventDefault(); |
| 103 | clearWatchdog(); | 126 | if (key === "q") { |
| 127 | if (modifierDown) { | ||
| 128 | quitAfterQuietPeriod(); | ||
| 129 | } else { | ||
| 130 | clearWatchdog(); | ||
| 131 | } | ||
| 132 | } | ||
| 104 | return; | 133 | return; |
| 105 | } | 134 | } |
| 106 | 135 | ||
| 107 | const modifierDown = options.platform === "darwin" ? input.meta : input.control; | ||
| 108 | if (!modifierDown || input.alt || input.shift || key !== "q") { | 136 | if (!modifierDown || input.alt || input.shift || key !== "q") { |
| 109 | // Re-pressing the platform modifier is the first half of a second full | 137 | // Re-pressing the platform modifier is the first half of a second full |
| 110 | // quit shortcut, so it must not cancel an active double-press window. | 138 | // quit shortcut, so it must not cancel an active double-press window. |
| ... | @@ -129,7 +157,8 @@ export function makeQuitShortcutHandler( | ... | @@ -129,7 +157,8 @@ export function makeQuitShortcutHandler( |
| 129 | if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { | 157 | if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { |
| 130 | armed = false; | 158 | armed = false; |
| 131 | quitOnRelease = true; | 159 | quitOnRelease = true; |
| 132 | clearWatchdog(); | 160 | options.concealWindow(); |
| 161 | quitAfterQuietPeriod(); | ||
| 133 | } | 162 | } |
| 134 | return; | 163 | return; |
| 135 | } | 164 | } |