diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 8dfd31789e..3d75d67f71 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -3,6 +3,7 @@ import { ClientService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; +import { waveEventSubscribeSingle } from "@/app/store/wps"; import { fireAndForget } from "@/util/util"; import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from "electron"; import { globalEvents } from "emain/emain-events"; @@ -101,6 +102,13 @@ export const waveWindowMap = new Map(); // 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,141 @@ 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 }); +} + +// small delay on fullscreen toggle to ensure that the OS has finished the fullscreen transition on its end +const PreQuakeFullscreenDelayMs = 120; +const PostQuakeFullscreenDelayMs = 80; + +// handles a theoretical race condition where the user spams the hotkey before the toggle finishes +let quakeToggleInProgress = false; + +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(); + // quake mode: toggle visibility of the designated quake window + const window = quakeWindow; + if (window && !window.isDestroyed()) { + if (window.isVisible()) { + window.hide(); } else { - fireAndForget(createNewWaveWindow); + const targetDisplay = getDisplayForQuakeToggle(); + // Some environments don't move the window if it's fullscreen, so we have to toggle fullscreen before the move + const wasFullscreen = window.isFullScreen(); + if (wasFullscreen) { + window.setFullScreen(false); + await delay(PreQuakeFullscreenDelayMs); + if (window.isDestroyed()) { + return; + } + } + moveWindowToDisplay(window, targetDisplay); + window.show(); + if (wasFullscreen) { + await delay(PostQuakeFullscreenDelayMs); + if (window.isDestroyed()) { + return; + } + moveWindowToDisplay(window, targetDisplay); + window.setFullScreen(true); + } + window.focus(); + if (window.activeTabView?.webContents) { + window.activeTabView.webContents.focus(); + } } + } else if (window == null) { + // no quake window yet, create one + await createNewWaveWindow(); + } else { + // quake window was destroyed, clear it + quakeWindow = null; + await createNewWaveWindow(); + } + } 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: ", 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); + } + }, + }); +} diff --git a/emain/emain.ts b/emain/emain.ts index 7a2b0a0710..8b08178aec 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -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) => { diff --git a/package-lock.json b/package-lock.json index c6c3fc35eb..d4fc4ab31c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.4-beta.2", + "version": "0.14.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.4-beta.2", + "version": "0.14.4", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [