Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/desktop/src/window/QuitHold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,55 @@ describe("makeQuitShortcutHandler", () => {
expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
});

it("quits when a completed hold goes quiet without release events", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
await harness.holdFor(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS * 2);

// If neither keyUp reaches the handler, continued repeats must keep the
// app alive. Once they stop, the quiet period is the release signal.
expect(harness.quit).not.toHaveBeenCalled();
vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);

expect(harness.quit).toHaveBeenCalledTimes(1);
expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
});

it("waits for slow repeats to stop before quitting", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));

vi.advanceTimersByTime(300);
await harness.send(makeInput({ isAutoRepeat: true }));
vi.advanceTimersByTime(900);
await harness.send(makeInput({ isAutoRepeat: true }));

vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
expect(harness.quit).not.toHaveBeenCalled();

vi.advanceTimersByTime(300);
await harness.send(makeInput({ isAutoRepeat: true }));
vi.advanceTimersByTime(1_799);
expect(harness.quit).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(harness.quit).toHaveBeenCalledTimes(1);
});

it("uses the initial repeat delay when the first repeat completes the hold", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));

vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + 100);
await harness.send(makeInput({ isAutoRepeat: true }));

vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
expect(harness.quit).not.toHaveBeenCalled();
vi.advanceTimersByTime(1_999);
expect(harness.quit).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(harness.quit).toHaveBeenCalledTimes(1);
});

it("waits for Q release when Cmd is released first", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
Expand Down
35 changes: 30 additions & 5 deletions apps/desktop/src/window/QuitHold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ export const QUIT_DOUBLE_PRESS_MS = 500;
// release: macOS suppresses a letter keyUp while the command key is down, so a
// tap release can go completely unseen and a release-based timer would quit
// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after
// modifier keyUp so repeats cannot reach the next app. Keyboards with
// repeats stop so they cannot reach the next app. Keyboards with
// auto-repeat disabled fall back to the application menu Quit action.
export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
// A slow repeat rate can exceed the fixed grace. Waiting for two observed
// cadences keeps the timer behind the next repeat without slowing normal rates.
const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2;

export interface QuitHoldKeyInput {
readonly type: string;
Expand Down Expand Up @@ -45,6 +48,8 @@ export function makeQuitShortcutHandler(
let quitOnRelease = false;
let heldSince = 0;
let lastPressAt = 0;
let lastRepeatAt = 0;
let repeatCadenceMs = 0;
// Incremented when a press is superseded or explicitly cancelled. A plain
// key release does not invalidate its pending mode read: direct mode and a
// completed second press must still be honored after that read settles.
Expand All @@ -64,6 +69,8 @@ export function makeQuitShortcutHandler(
holding = false;
armed = false;
quitOnRelease = false;
lastRepeatAt = 0;
repeatCadenceMs = 0;
if (keepHint) return;

mode = undefined;
Expand All @@ -80,6 +87,15 @@ export function makeQuitShortcutHandler(
options.quit();
};

const quitAfterQuietPeriod = () => {
clearWatchdog();
const quietPeriodMs = Math.max(
QUIT_HOLD_RELEASE_GRACE_MS,
repeatCadenceMs * QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER,
);
watchdog = setTimeout(quitNow, quietPeriodMs);
};

return (event, input) => {
const key = input.key.toLowerCase();
if (input.type === "keyUp") {
Expand All @@ -91,20 +107,29 @@ export function makeQuitShortcutHandler(
if (!quitOnRelease) {
release(false, true);
} else {
watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS);
quitAfterQuietPeriod();
}
}
return;
}
if (input.type !== "keyDown") return;

const modifierDown = options.platform === "darwin" ? input.meta : input.control;
if (input.isAutoRepeat && modifierDown && key === "q") {
const now = Date.now();
repeatCadenceMs = now - (lastRepeatAt === 0 ? heldSince : lastRepeatAt);
lastRepeatAt = now;
}
if (quitOnRelease && input.isAutoRepeat && key === "q") {
event.preventDefault();
clearWatchdog();
if (modifierDown) {
quitAfterQuietPeriod();
} else {
clearWatchdog();
}
return;
}

const modifierDown = options.platform === "darwin" ? input.meta : input.control;
if (!modifierDown || input.alt || input.shift || key !== "q") {
// Re-pressing the platform modifier is the first half of a second full
// quit shortcut, so it must not cancel an active double-press window.
Expand All @@ -129,7 +154,7 @@ export function makeQuitShortcutHandler(
if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
armed = false;
quitOnRelease = true;
clearWatchdog();
quitAfterQuietPeriod();
}
return;
}
Expand Down
Loading