Skip to content

Commit 4d08ff7

Browse files
committed
feat: add setGlobalShortcut, toggleWindow and recenterOnTray helpers
1 parent 528631d commit 4d08ff7

4 files changed

Lines changed: 216 additions & 2 deletions

File tree

src/Menubar.spec.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { app, BrowserWindow, Tray } from 'electron';
1+
import { app, BrowserWindow, globalShortcut, Tray } from 'electron';
22
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
33

44
import { Menubar } from './Menubar';
@@ -177,6 +177,138 @@ describe('Menubar hideOnClose option', () => {
177177
});
178178
});
179179

180+
describe('Menubar global shortcut', () => {
181+
beforeEach(() => {
182+
vi.clearAllMocks();
183+
});
184+
185+
it('registers the configured accelerator on ready', () => {
186+
const mb = new Menubar(app, {
187+
preloadWindow: true,
188+
globalShortcut: 'CmdOrCtrl+Shift+G',
189+
});
190+
return new Promise<void>((resolve) => {
191+
mb.on('ready', () => {
192+
expect(globalShortcut.register).toHaveBeenCalledWith(
193+
'CmdOrCtrl+Shift+G',
194+
expect.any(Function),
195+
);
196+
resolve();
197+
});
198+
});
199+
});
200+
201+
it('unregisters the previous accelerator when replacing it', () => {
202+
const mb = new Menubar(app, {
203+
preloadWindow: true,
204+
globalShortcut: 'CmdOrCtrl+Shift+G',
205+
});
206+
return new Promise<void>((resolve) => {
207+
mb.on('ready', () => {
208+
mb.setGlobalShortcut('Alt+Space');
209+
expect(globalShortcut.unregister).toHaveBeenCalledWith(
210+
'CmdOrCtrl+Shift+G',
211+
);
212+
expect(globalShortcut.register).toHaveBeenLastCalledWith(
213+
'Alt+Space',
214+
expect.any(Function),
215+
);
216+
resolve();
217+
});
218+
});
219+
});
220+
221+
it('clears the accelerator when called with undefined', () => {
222+
const mb = new Menubar(app, {
223+
preloadWindow: true,
224+
globalShortcut: 'CmdOrCtrl+Shift+G',
225+
});
226+
return new Promise<void>((resolve) => {
227+
mb.on('ready', () => {
228+
(globalShortcut.register as Mock).mockClear();
229+
mb.setGlobalShortcut(undefined);
230+
expect(globalShortcut.unregister).toHaveBeenCalledWith(
231+
'CmdOrCtrl+Shift+G',
232+
);
233+
expect(globalShortcut.register).not.toHaveBeenCalled();
234+
resolve();
235+
});
236+
});
237+
});
238+
239+
it('does not retain a failed registration', () => {
240+
(globalShortcut.register as Mock).mockReturnValueOnce(false);
241+
const mb = new Menubar(app, { preloadWindow: true });
242+
return new Promise<void>((resolve) => {
243+
mb.on('ready', () => {
244+
const ok = mb.setGlobalShortcut('CmdOrCtrl+Shift+G');
245+
expect(ok).toBe(false);
246+
(globalShortcut.unregister as Mock).mockClear();
247+
mb.destroy();
248+
expect(globalShortcut.unregister).not.toHaveBeenCalled();
249+
resolve();
250+
});
251+
});
252+
});
253+
254+
it('unregisters on destroy()', () => {
255+
const mb = new Menubar(app, {
256+
preloadWindow: true,
257+
globalShortcut: 'CmdOrCtrl+Shift+G',
258+
});
259+
return new Promise<void>((resolve) => {
260+
mb.on('ready', () => {
261+
mb.destroy();
262+
expect(globalShortcut.unregister).toHaveBeenCalledWith(
263+
'CmdOrCtrl+Shift+G',
264+
);
265+
resolve();
266+
});
267+
});
268+
});
269+
});
270+
271+
describe('Menubar toggleWindow and recenterOnTray', () => {
272+
beforeEach(() => {
273+
vi.clearAllMocks();
274+
});
275+
276+
it('toggleWindow shows when hidden and hides when visible', () => {
277+
const mb = new Menubar(app, { preloadWindow: true });
278+
return new Promise<void>((resolve) => {
279+
mb.on('after-create-window', async () => {
280+
await mb.toggleWindow();
281+
expect(mb.window!.show).toHaveBeenCalledTimes(1);
282+
await mb.toggleWindow();
283+
expect(mb.window!.hide).toHaveBeenCalledTimes(1);
284+
resolve();
285+
});
286+
});
287+
});
288+
289+
it('recenterOnTray sets a new position from tray bounds', () => {
290+
const mb = new Menubar(app, { preloadWindow: true });
291+
return new Promise<void>((resolve) => {
292+
mb.on('after-create-window', () => {
293+
(mb.window!.setPosition as Mock).mockClear();
294+
mb.recenterOnTray();
295+
expect(mb.window!.setPosition).toHaveBeenCalled();
296+
resolve();
297+
});
298+
});
299+
});
300+
301+
it('recenterOnTray is a no-op without a window', () => {
302+
const mb = new Menubar(app, {});
303+
return new Promise<void>((resolve) => {
304+
mb.on('ready', () => {
305+
expect(() => mb.recenterOnTray()).not.toThrow();
306+
resolve();
307+
});
308+
});
309+
});
310+
});
311+
180312
describe('Menubar contextMenu option', () => {
181313
const originalPlatform = process.platform;
182314
const fakeMenu = { __menu: true } as unknown as Electron.Menu;

src/Menubar.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events';
22
import fs from 'node:fs';
33
import path from 'node:path';
44

5-
import { BrowserWindow, type Menu, Tray } from 'electron';
5+
import { BrowserWindow, globalShortcut, type Menu, Tray } from 'electron';
66

77
import { Positioner } from './Positioner';
88
import type { Options } from './types';
@@ -23,6 +23,7 @@ export class Menubar extends EventEmitter {
2323
private _cachedBounds?: Electron.Rectangle; // _cachedBounds are needed for double-clicked event
2424
private _options: Options;
2525
private _positioner: Positioner | undefined;
26+
private _shortcut?: Electron.Accelerator;
2627
private _tray?: Tray;
2728

2829
constructor(app: Electron.App, options?: Partial<Options>) {
@@ -99,6 +100,11 @@ export class Menubar extends EventEmitter {
99100
// intercepting it.
100101
this._isDestroyed = true;
101102

103+
if (this._shortcut) {
104+
globalShortcut.unregister(this._shortcut);
105+
this._shortcut = undefined;
106+
}
107+
102108
if (this._browserWindow) {
103109
this._browserWindow.destroy();
104110
this._browserWindow = undefined;
@@ -155,6 +161,59 @@ export class Menubar extends EventEmitter {
155161
this.refreshLinuxContextMenu();
156162
}
157163

164+
/**
165+
* Register a global keyboard accelerator that toggles the menubar window.
166+
* Replaces any previously registered shortcut owned by this Menubar.
167+
* Pass `undefined` to clear the current shortcut without registering a new
168+
* one. Returns whether the registration succeeded.
169+
*
170+
* @param accelerator - An Electron
171+
* [Accelerator](https://electronjs.org/docs/api/accelerator) string, or
172+
* `undefined` to clear.
173+
*/
174+
setGlobalShortcut(accelerator: Electron.Accelerator | undefined): boolean {
175+
if (this._shortcut) {
176+
globalShortcut.unregister(this._shortcut);
177+
this._shortcut = undefined;
178+
}
179+
this._options.globalShortcut = accelerator;
180+
if (!accelerator) {
181+
return true;
182+
}
183+
const ok = globalShortcut.register(accelerator, () => this.toggleWindow());
184+
if (ok) {
185+
this._shortcut = accelerator;
186+
}
187+
return ok;
188+
}
189+
190+
/**
191+
* Toggle the menubar window: hide it if visible, show it otherwise.
192+
* Resolves once the window finishes showing or hiding.
193+
*/
194+
async toggleWindow(): Promise<void> {
195+
if (this._browserWindow && this._isVisible) {
196+
this.hideWindow();
197+
return;
198+
}
199+
await this.showWindow();
200+
}
201+
202+
/**
203+
* Re-center the menubar window over the tray icon. Convenience wrapper for
204+
* `positioner.move('trayCenter', tray.getBounds())` that's safe to call
205+
* after the `after-create-window` event. No-op if the window doesn't
206+
* exist yet.
207+
*/
208+
recenterOnTray(): void {
209+
if (!this._browserWindow || !this._tray) {
210+
return;
211+
}
212+
const bounds = this._tray.getBounds();
213+
const { x, y } = this.positioner.calculate('trayCenter', bounds);
214+
this._browserWindow.setPosition(Math.round(x), Math.round(y));
215+
}
216+
158217
/**
159218
* Replace the tray context menu after construction. On Linux this also
160219
* re-publishes the menu to the SNI host, which is required after mutating
@@ -298,6 +357,10 @@ export class Menubar extends EventEmitter {
298357
this.bindContextMenu(this._options.contextMenu);
299358
}
300359

360+
if (this._options.globalShortcut) {
361+
this.setGlobalShortcut(this._options.globalShortcut);
362+
}
363+
301364
if (!this._options.windowPosition) {
302365
this._options.windowPosition = getWindowPosition(this.tray);
303366
}

src/__mocks__/electron.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ export class BrowserWindow {
2929
webContents: { on: Mock } = { on: vi.fn() };
3030
}
3131

32+
export const globalShortcut: {
33+
isRegistered: Mock;
34+
register: Mock;
35+
unregister: Mock;
36+
} = {
37+
isRegistered: vi.fn(() => false),
38+
register: vi.fn(() => true),
39+
unregister: vi.fn(),
40+
};
41+
3242
export class Tray {
3343
getBounds: Mock = vi.fn(() => ({ x: 0, y: 0, width: 32, height: 32 }));
3444
isDestroyed: Mock = vi.fn(() => false);

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ export interface Options {
4747
* The app source directory.
4848
*/
4949
dir: string;
50+
/**
51+
* Register this accelerator as a global shortcut that toggles the menubar
52+
* window. Calls
53+
* [`globalShortcut.register`](https://electronjs.org/docs/api/global-shortcut#globalshortcutregisteraccelerator-callback)
54+
* after `ready` and unregisters it on {@link Menubar.destroy}. The same
55+
* accelerator can be set or cleared later via
56+
* {@link Menubar.setGlobalShortcut}.
57+
*/
58+
globalShortcut?: Electron.Accelerator;
5059
/**
5160
* Hide the window on `close` instead of letting it be destroyed, so the
5261
* next tray click re-uses the same {@link BrowserWindow} instance. On

0 commit comments

Comments
 (0)