22// SPDX-License-Identifier: Apache-2.0
33
44import { ClientService , ObjectService , WindowService , WorkspaceService } from "@/app/store/services" ;
5+ import { waveEventSubscribeSingle } from "@/app/store/wps" ;
56import { RpcApi } from "@/app/store/wshclientapi" ;
67import { fireAndForget } from "@/util/util" ;
78import { 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
102103export 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+
104112let cachedClientId : string = null ;
105113let 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+
8981063export 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+ }
0 commit comments