Skip to content

Commit 86d1a54

Browse files
Midnight145sawkakilo-code-bot[bot]
authored
Turns app:globalhotkey into a dedicated quake mode (#3151)
closes #3138, closes #2128 --------- Co-authored-by: sawka <mike@commandline.dev> Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com>
1 parent fe58f5a commit 86d1a54

File tree

2 files changed

+209
-12
lines changed

2 files changed

+209
-12
lines changed

emain/emain-window.ts

Lines changed: 196 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { ClientService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services";
5+
import { waveEventSubscribeSingle } from "@/app/store/wps";
56
import { RpcApi } from "@/app/store/wshclientapi";
67
import { fireAndForget } from "@/util/util";
78
import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from "electron";
@@ -101,6 +102,13 @@ export const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindow
101102
// e.g. it persists when the app itself is not focused
102103
export let focusedWaveWindow: WaveBrowserWindow = null;
103104

105+
// quake window for toggle hotkey (show/hide behavior)
106+
let quakeWindow: WaveBrowserWindow | null = null;
107+
108+
export function getQuakeWindow(): WaveBrowserWindow | null {
109+
return quakeWindow;
110+
}
111+
104112
let cachedClientId: string = null;
105113
let hasCompletedFirstRelaunch = false;
106114

@@ -332,6 +340,9 @@ export class WaveBrowserWindow extends BaseWindow {
332340
if (focusedWaveWindow == this) {
333341
focusedWaveWindow = null;
334342
}
343+
if (quakeWindow == this) {
344+
quakeWindow = null;
345+
}
335346
this.removeAllChildViews();
336347
if (getGlobalIsRelaunching()) {
337348
console.log("win relaunching", this.waveWindowId);
@@ -704,6 +715,7 @@ export async function createBrowserWindow(
704715
}
705716
console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace);
706717
const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts);
718+
707719
if (workspace.activetabid) {
708720
await bwin.setActiveTab(workspace.activetabid, false, opts.isPrimaryStartupWindow ?? false);
709721
}
@@ -832,6 +844,9 @@ export async function createNewWaveWindow() {
832844
unamePlatform,
833845
isPrimaryStartupWindow: false,
834846
});
847+
if (quakeWindow == null) {
848+
quakeWindow = win;
849+
}
835850
win.show();
836851
recreatedWindow = true;
837852
}
@@ -845,6 +860,9 @@ export async function createNewWaveWindow() {
845860
unamePlatform,
846861
isPrimaryStartupWindow: false,
847862
});
863+
if (quakeWindow == null) {
864+
quakeWindow = newBrowserWindow;
865+
}
848866
newBrowserWindow.show();
849867
}
850868

@@ -887,6 +905,10 @@ export async function relaunchBrowserWindows() {
887905
foregroundWindow: windowId === primaryWindowId,
888906
});
889907
wins.push(win);
908+
if (windowId === primaryWindowId) {
909+
quakeWindow = win;
910+
console.log("designated quake window", win.waveWindowId);
911+
}
890912
}
891913
hasCompletedFirstRelaunch = true;
892914
for (const win of wins) {
@@ -895,22 +917,184 @@ export async function relaunchBrowserWindows() {
895917
}
896918
}
897919

920+
function getDisplayForQuakeToggle() {
921+
// We cannot reliably query the OS-wide active window in Electron.
922+
// Cursor position is the best cross-platform proxy for the user's active display.
923+
const cursorPoint = screen.getCursorScreenPoint();
924+
const displayAtCursor = screen
925+
.getAllDisplays()
926+
.find(
927+
(display) =>
928+
cursorPoint.x >= display.bounds.x &&
929+
cursorPoint.x < display.bounds.x + display.bounds.width &&
930+
cursorPoint.y >= display.bounds.y &&
931+
cursorPoint.y < display.bounds.y + display.bounds.height
932+
);
933+
return displayAtCursor ?? screen.getDisplayNearestPoint(cursorPoint);
934+
}
935+
936+
function moveWindowToDisplay(win: WaveBrowserWindow, targetDisplay: Electron.Display) {
937+
if (!win || !targetDisplay || win.isDestroyed()) {
938+
return;
939+
}
940+
const curBounds = win.getBounds();
941+
const sourceDisplay = screen.getDisplayMatching(curBounds);
942+
if (sourceDisplay.id === targetDisplay.id) {
943+
return;
944+
}
945+
946+
const sourceArea = sourceDisplay.workArea;
947+
const targetArea = targetDisplay.workArea;
948+
const nextHeight = Math.min(curBounds.height, targetArea.height);
949+
const nextWidth = Math.min(curBounds.width, targetArea.width);
950+
const maxXOffset = Math.max(0, targetArea.width - nextWidth);
951+
const maxYOffset = Math.max(0, targetArea.height - nextHeight);
952+
const sourceXOffset = curBounds.x - sourceArea.x;
953+
const sourceYOffset = curBounds.y - sourceArea.y;
954+
const nextX = targetArea.x + Math.min(Math.max(sourceXOffset, 0), maxXOffset);
955+
const nextY = targetArea.y + Math.min(Math.max(sourceYOffset, 0), maxYOffset);
956+
957+
win.setBounds({ ...curBounds, x: nextX, y: nextY, width: nextWidth, height: nextHeight });
958+
}
959+
960+
const FullscreenTransitionTimeoutMs = 2000;
961+
962+
// handles a theoretical race condition where the user spams the hotkey before the toggle finishes
963+
let quakeToggleInProgress = false;
964+
let quakeRestoreFullscreenOnShow = false;
965+
966+
function waitForFullscreenLeave(window: WaveBrowserWindow): Promise<void> {
967+
if (!window.isFullScreen()) {
968+
return Promise.resolve();
969+
}
970+
return new Promise((resolve, reject) => {
971+
// eslint-disable-next-line prefer-const
972+
let timeout: ReturnType<typeof setTimeout>;
973+
const onLeave = () => {
974+
clearTimeout(timeout);
975+
resolve();
976+
};
977+
timeout = setTimeout(() => {
978+
window.removeListener("leave-full-screen", onLeave);
979+
reject(new Error("fullscreen transition timeout"));
980+
}, FullscreenTransitionTimeoutMs);
981+
window.once("leave-full-screen", onLeave);
982+
});
983+
}
984+
985+
function waitForFullscreenEnter(window: WaveBrowserWindow): Promise<void> {
986+
if (window.isFullScreen()) {
987+
return Promise.resolve();
988+
}
989+
return new Promise((resolve, reject) => {
990+
// eslint-disable-next-line prefer-const
991+
let timeout: ReturnType<typeof setTimeout>;
992+
const onEnter = () => {
993+
clearTimeout(timeout);
994+
resolve();
995+
};
996+
timeout = setTimeout(() => {
997+
window.removeListener("enter-full-screen", onEnter);
998+
reject(new Error("fullscreen transition timeout"));
999+
}, FullscreenTransitionTimeoutMs);
1000+
window.once("enter-full-screen", onEnter);
1001+
});
1002+
}
1003+
1004+
async function quakeToggle() {
1005+
if (quakeToggleInProgress) {
1006+
return;
1007+
}
1008+
quakeToggleInProgress = true;
1009+
try {
1010+
let window = quakeWindow;
1011+
if (window?.isDestroyed()) {
1012+
quakeWindow = null;
1013+
window = null;
1014+
}
1015+
if (window == null) {
1016+
await createNewWaveWindow();
1017+
return;
1018+
}
1019+
// Some environments don't hide or move the window if it's fullscreen (even when hidden), so leave fullscreen first
1020+
if (window.isFullScreen()) {
1021+
// macos has a really long fullscreen animation and can have issues restoring from fullscreen, so we skip on macos
1022+
quakeRestoreFullscreenOnShow = process.platform !== "darwin";
1023+
const leavePromise = waitForFullscreenLeave(window);
1024+
window.setFullScreen(false);
1025+
try {
1026+
await leavePromise;
1027+
} catch {
1028+
// timeout — proceed anyway
1029+
}
1030+
if (window.isDestroyed()) {
1031+
return;
1032+
}
1033+
}
1034+
if (window.isVisible()) {
1035+
window.hide();
1036+
} else {
1037+
const targetDisplay = getDisplayForQuakeToggle();
1038+
moveWindowToDisplay(window, targetDisplay);
1039+
window.show();
1040+
if (quakeRestoreFullscreenOnShow) {
1041+
const enterPromise = waitForFullscreenEnter(window);
1042+
window.setFullScreen(true);
1043+
try {
1044+
await enterPromise;
1045+
} catch {
1046+
// timeout — proceed anyway
1047+
}
1048+
}
1049+
quakeRestoreFullscreenOnShow = false;
1050+
window.focus();
1051+
if (window.activeTabView?.webContents) {
1052+
window.activeTabView.webContents.focus();
1053+
}
1054+
}
1055+
} finally {
1056+
quakeToggleInProgress = false;
1057+
}
1058+
}
1059+
1060+
let currentRawGlobalHotKey: string = null;
1061+
let currentGlobalHotKey: string = null;
1062+
8981063
export function registerGlobalHotkey(rawGlobalHotKey: string) {
1064+
if (rawGlobalHotKey === currentRawGlobalHotKey) {
1065+
return;
1066+
}
1067+
if (currentGlobalHotKey != null) {
1068+
globalShortcut.unregister(currentGlobalHotKey);
1069+
currentGlobalHotKey = null;
1070+
currentRawGlobalHotKey = null;
1071+
}
1072+
if (!rawGlobalHotKey) {
1073+
return;
1074+
}
8991075
try {
9001076
const electronHotKey = waveKeyToElectronKey(rawGlobalHotKey);
901-
console.log("registering globalhotkey of ", electronHotKey);
902-
globalShortcut.register(electronHotKey, () => {
903-
const selectedWindow = focusedWaveWindow;
904-
const firstWaveWindow = getAllWaveWindows()[0];
905-
if (focusedWaveWindow) {
906-
selectedWindow.focus();
907-
} else if (firstWaveWindow) {
908-
firstWaveWindow.focus();
909-
} else {
910-
fireAndForget(createNewWaveWindow);
911-
}
1077+
const ok = globalShortcut.register(electronHotKey, () => {
1078+
fireAndForget(quakeToggle);
9121079
});
1080+
currentRawGlobalHotKey = rawGlobalHotKey;
1081+
currentGlobalHotKey = electronHotKey;
1082+
console.log("registered globalhotkey", rawGlobalHotKey, "=>", electronHotKey, "ok=", ok);
9131083
} catch (e) {
914-
console.log("error registering global hotkey: ", e);
1084+
console.log("error registering global hotkey", rawGlobalHotKey, ":", e);
9151085
}
9161086
}
1087+
1088+
export function initGlobalHotkeyEventSubscription() {
1089+
waveEventSubscribeSingle({
1090+
eventType: "config",
1091+
handler: (event) => {
1092+
try {
1093+
const hotkey = event?.data?.fullconfig?.settings?.["app:globalhotkey"];
1094+
registerGlobalHotkey(hotkey ?? null);
1095+
} catch (e) {
1096+
console.log("error handling config event for globalhotkey", e);
1097+
}
1098+
},
1099+
});
1100+
}

emain/emain.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ import {
4646
createNewWaveWindow,
4747
focusedWaveWindow,
4848
getAllWaveWindows,
49+
getQuakeWindow,
4950
getWaveWindowById,
5051
getWaveWindowByWorkspaceId,
52+
initGlobalHotkeyEventSubscription,
5153
registerGlobalHotkey,
5254
relaunchBrowserWindows,
5355
WaveBrowserWindow,
@@ -427,6 +429,16 @@ async function appMain() {
427429

428430
electronApp.on("activate", () => {
429431
const allWindows = getAllWaveWindows();
432+
const anyVisible = allWindows.some((w) => !w.isDestroyed() && w.isVisible());
433+
if (anyVisible) {
434+
return;
435+
}
436+
const qw = getQuakeWindow();
437+
if (qw != null && !qw.isDestroyed()) {
438+
qw.show();
439+
qw.focus();
440+
return;
441+
}
430442
if (allWindows.length === 0) {
431443
fireAndForget(createNewWaveWindow);
432444
}
@@ -445,6 +457,7 @@ async function appMain() {
445457
if (rawGlobalHotKey) {
446458
registerGlobalHotkey(rawGlobalHotKey);
447459
}
460+
initGlobalHotkeyEventSubscription();
448461
}
449462

450463
appMain().catch((e) => {

0 commit comments

Comments
 (0)