mirror of
https://github.com/wavetermdev/waveterm
synced 2026-04-21 14:37:16 +00:00
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>
This commit is contained in:
parent
fe58f5a781
commit
86d1a54b7f
2 changed files with 212 additions and 15 deletions
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ClientService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services";
|
||||
import { waveEventSubscribeSingle } from "@/app/store/wps";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from "electron";
|
||||
|
|
@ -101,6 +102,13 @@ export const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindow
|
|||
// e.g. it persists when the app itself is not focused
|
||||
export let focusedWaveWindow: WaveBrowserWindow = null;
|
||||
|
||||
// quake window for toggle hotkey (show/hide behavior)
|
||||
let quakeWindow: WaveBrowserWindow | null = null;
|
||||
|
||||
export function getQuakeWindow(): WaveBrowserWindow | null {
|
||||
return quakeWindow;
|
||||
}
|
||||
|
||||
let cachedClientId: string = null;
|
||||
let hasCompletedFirstRelaunch = false;
|
||||
|
||||
|
|
@ -332,6 +340,9 @@ export class WaveBrowserWindow extends BaseWindow {
|
|||
if (focusedWaveWindow == this) {
|
||||
focusedWaveWindow = null;
|
||||
}
|
||||
if (quakeWindow == this) {
|
||||
quakeWindow = null;
|
||||
}
|
||||
this.removeAllChildViews();
|
||||
if (getGlobalIsRelaunching()) {
|
||||
console.log("win relaunching", this.waveWindowId);
|
||||
|
|
@ -704,6 +715,7 @@ export async function createBrowserWindow(
|
|||
}
|
||||
console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace);
|
||||
const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts);
|
||||
|
||||
if (workspace.activetabid) {
|
||||
await bwin.setActiveTab(workspace.activetabid, false, opts.isPrimaryStartupWindow ?? false);
|
||||
}
|
||||
|
|
@ -832,6 +844,9 @@ export async function createNewWaveWindow() {
|
|||
unamePlatform,
|
||||
isPrimaryStartupWindow: false,
|
||||
});
|
||||
if (quakeWindow == null) {
|
||||
quakeWindow = win;
|
||||
}
|
||||
win.show();
|
||||
recreatedWindow = true;
|
||||
}
|
||||
|
|
@ -845,6 +860,9 @@ export async function createNewWaveWindow() {
|
|||
unamePlatform,
|
||||
isPrimaryStartupWindow: false,
|
||||
});
|
||||
if (quakeWindow == null) {
|
||||
quakeWindow = newBrowserWindow;
|
||||
}
|
||||
newBrowserWindow.show();
|
||||
}
|
||||
|
||||
|
|
@ -887,6 +905,10 @@ export async function relaunchBrowserWindows() {
|
|||
foregroundWindow: windowId === primaryWindowId,
|
||||
});
|
||||
wins.push(win);
|
||||
if (windowId === primaryWindowId) {
|
||||
quakeWindow = win;
|
||||
console.log("designated quake window", win.waveWindowId);
|
||||
}
|
||||
}
|
||||
hasCompletedFirstRelaunch = true;
|
||||
for (const win of wins) {
|
||||
|
|
@ -895,22 +917,184 @@ export async function relaunchBrowserWindows() {
|
|||
}
|
||||
}
|
||||
|
||||
export function registerGlobalHotkey(rawGlobalHotKey: string) {
|
||||
function getDisplayForQuakeToggle() {
|
||||
// We cannot reliably query the OS-wide active window in Electron.
|
||||
// Cursor position is the best cross-platform proxy for the user's active display.
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const displayAtCursor = screen
|
||||
.getAllDisplays()
|
||||
.find(
|
||||
(display) =>
|
||||
cursorPoint.x >= display.bounds.x &&
|
||||
cursorPoint.x < display.bounds.x + display.bounds.width &&
|
||||
cursorPoint.y >= display.bounds.y &&
|
||||
cursorPoint.y < display.bounds.y + display.bounds.height
|
||||
);
|
||||
return displayAtCursor ?? screen.getDisplayNearestPoint(cursorPoint);
|
||||
}
|
||||
|
||||
function moveWindowToDisplay(win: WaveBrowserWindow, targetDisplay: Electron.Display) {
|
||||
if (!win || !targetDisplay || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
const curBounds = win.getBounds();
|
||||
const sourceDisplay = screen.getDisplayMatching(curBounds);
|
||||
if (sourceDisplay.id === targetDisplay.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceArea = sourceDisplay.workArea;
|
||||
const targetArea = targetDisplay.workArea;
|
||||
const nextHeight = Math.min(curBounds.height, targetArea.height);
|
||||
const nextWidth = Math.min(curBounds.width, targetArea.width);
|
||||
const maxXOffset = Math.max(0, targetArea.width - nextWidth);
|
||||
const maxYOffset = Math.max(0, targetArea.height - nextHeight);
|
||||
const sourceXOffset = curBounds.x - sourceArea.x;
|
||||
const sourceYOffset = curBounds.y - sourceArea.y;
|
||||
const nextX = targetArea.x + Math.min(Math.max(sourceXOffset, 0), maxXOffset);
|
||||
const nextY = targetArea.y + Math.min(Math.max(sourceYOffset, 0), maxYOffset);
|
||||
|
||||
win.setBounds({ ...curBounds, x: nextX, y: nextY, width: nextWidth, height: nextHeight });
|
||||
}
|
||||
|
||||
const FullscreenTransitionTimeoutMs = 2000;
|
||||
|
||||
// handles a theoretical race condition where the user spams the hotkey before the toggle finishes
|
||||
let quakeToggleInProgress = false;
|
||||
let quakeRestoreFullscreenOnShow = false;
|
||||
|
||||
function waitForFullscreenLeave(window: WaveBrowserWindow): Promise<void> {
|
||||
if (!window.isFullScreen()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const onLeave = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
timeout = setTimeout(() => {
|
||||
window.removeListener("leave-full-screen", onLeave);
|
||||
reject(new Error("fullscreen transition timeout"));
|
||||
}, FullscreenTransitionTimeoutMs);
|
||||
window.once("leave-full-screen", onLeave);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForFullscreenEnter(window: WaveBrowserWindow): Promise<void> {
|
||||
if (window.isFullScreen()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const onEnter = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
timeout = setTimeout(() => {
|
||||
window.removeListener("enter-full-screen", onEnter);
|
||||
reject(new Error("fullscreen transition timeout"));
|
||||
}, FullscreenTransitionTimeoutMs);
|
||||
window.once("enter-full-screen", onEnter);
|
||||
});
|
||||
}
|
||||
|
||||
async function quakeToggle() {
|
||||
if (quakeToggleInProgress) {
|
||||
return;
|
||||
}
|
||||
quakeToggleInProgress = true;
|
||||
try {
|
||||
const electronHotKey = waveKeyToElectronKey(rawGlobalHotKey);
|
||||
console.log("registering globalhotkey of ", electronHotKey);
|
||||
globalShortcut.register(electronHotKey, () => {
|
||||
const selectedWindow = focusedWaveWindow;
|
||||
const firstWaveWindow = getAllWaveWindows()[0];
|
||||
if (focusedWaveWindow) {
|
||||
selectedWindow.focus();
|
||||
} else if (firstWaveWindow) {
|
||||
firstWaveWindow.focus();
|
||||
} else {
|
||||
fireAndForget(createNewWaveWindow);
|
||||
let window = quakeWindow;
|
||||
if (window?.isDestroyed()) {
|
||||
quakeWindow = null;
|
||||
window = null;
|
||||
}
|
||||
if (window == null) {
|
||||
await createNewWaveWindow();
|
||||
return;
|
||||
}
|
||||
// Some environments don't hide or move the window if it's fullscreen (even when hidden), so leave fullscreen first
|
||||
if (window.isFullScreen()) {
|
||||
// macos has a really long fullscreen animation and can have issues restoring from fullscreen, so we skip on macos
|
||||
quakeRestoreFullscreenOnShow = process.platform !== "darwin";
|
||||
const leavePromise = waitForFullscreenLeave(window);
|
||||
window.setFullScreen(false);
|
||||
try {
|
||||
await leavePromise;
|
||||
} catch {
|
||||
// timeout — proceed anyway
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log("error registering global hotkey: ", e);
|
||||
if (window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (window.isVisible()) {
|
||||
window.hide();
|
||||
} else {
|
||||
const targetDisplay = getDisplayForQuakeToggle();
|
||||
moveWindowToDisplay(window, targetDisplay);
|
||||
window.show();
|
||||
if (quakeRestoreFullscreenOnShow) {
|
||||
const enterPromise = waitForFullscreenEnter(window);
|
||||
window.setFullScreen(true);
|
||||
try {
|
||||
await enterPromise;
|
||||
} catch {
|
||||
// timeout — proceed anyway
|
||||
}
|
||||
}
|
||||
quakeRestoreFullscreenOnShow = false;
|
||||
window.focus();
|
||||
if (window.activeTabView?.webContents) {
|
||||
window.activeTabView.webContents.focus();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
quakeToggleInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
let currentRawGlobalHotKey: string = null;
|
||||
let currentGlobalHotKey: string = null;
|
||||
|
||||
export function registerGlobalHotkey(rawGlobalHotKey: string) {
|
||||
if (rawGlobalHotKey === currentRawGlobalHotKey) {
|
||||
return;
|
||||
}
|
||||
if (currentGlobalHotKey != null) {
|
||||
globalShortcut.unregister(currentGlobalHotKey);
|
||||
currentGlobalHotKey = null;
|
||||
currentRawGlobalHotKey = null;
|
||||
}
|
||||
if (!rawGlobalHotKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const electronHotKey = waveKeyToElectronKey(rawGlobalHotKey);
|
||||
const ok = globalShortcut.register(electronHotKey, () => {
|
||||
fireAndForget(quakeToggle);
|
||||
});
|
||||
currentRawGlobalHotKey = rawGlobalHotKey;
|
||||
currentGlobalHotKey = electronHotKey;
|
||||
console.log("registered globalhotkey", rawGlobalHotKey, "=>", electronHotKey, "ok=", ok);
|
||||
} catch (e) {
|
||||
console.log("error registering global hotkey", rawGlobalHotKey, ":", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function initGlobalHotkeyEventSubscription() {
|
||||
waveEventSubscribeSingle({
|
||||
eventType: "config",
|
||||
handler: (event) => {
|
||||
try {
|
||||
const hotkey = event?.data?.fullconfig?.settings?.["app:globalhotkey"];
|
||||
registerGlobalHotkey(hotkey ?? null);
|
||||
} catch (e) {
|
||||
console.log("error handling config event for globalhotkey", e);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,8 +46,10 @@ import {
|
|||
createNewWaveWindow,
|
||||
focusedWaveWindow,
|
||||
getAllWaveWindows,
|
||||
getQuakeWindow,
|
||||
getWaveWindowById,
|
||||
getWaveWindowByWorkspaceId,
|
||||
initGlobalHotkeyEventSubscription,
|
||||
registerGlobalHotkey,
|
||||
relaunchBrowserWindows,
|
||||
WaveBrowserWindow,
|
||||
|
|
@ -427,6 +429,16 @@ async function appMain() {
|
|||
|
||||
electronApp.on("activate", () => {
|
||||
const allWindows = getAllWaveWindows();
|
||||
const anyVisible = allWindows.some((w) => !w.isDestroyed() && w.isVisible());
|
||||
if (anyVisible) {
|
||||
return;
|
||||
}
|
||||
const qw = getQuakeWindow();
|
||||
if (qw != null && !qw.isDestroyed()) {
|
||||
qw.show();
|
||||
qw.focus();
|
||||
return;
|
||||
}
|
||||
if (allWindows.length === 0) {
|
||||
fireAndForget(createNewWaveWindow);
|
||||
}
|
||||
|
|
@ -445,6 +457,7 @@ async function appMain() {
|
|||
if (rawGlobalHotKey) {
|
||||
registerGlobalHotkey(rawGlobalHotKey);
|
||||
}
|
||||
initGlobalHotkeyEventSubscription();
|
||||
}
|
||||
|
||||
appMain().catch((e) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue