Skip to content

Commit f4a9811

Browse files
authored
refactor: inline Positioner to drop electron-positioner dependency (#12)
1 parent 22ceefe commit f4a9811

9 files changed

Lines changed: 257 additions & 43 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
This module provides boilerplate for setting up a menubar application using Electron. All you have to do is point it at your `index.html` and `menubar` will handle the rest.
1515

16-
Only one dependency, and one peer-dependency.
16+
Zero runtime dependencies, only one peer-dependency.
1717

1818
✅ Works on macOS, Windows and most Linuxes. See [tested platforms](./PLATFORMS.md).
1919

@@ -67,7 +67,7 @@ The return value of `menubar()` is a `Menubar` class instance, which has these p
6767
- `app`: the [Electron App](https://electronjs.org/docs/api/app) instance,
6868
- `window`: the [Electron Browser Window](https://electronjs.org/docs/api/browser-window) instance,
6969
- `tray`: the [Electron Tray](https://electronjs.org/docs/api/tray) instance,
70-
- `positioner`: the [Electron Positioner](https://github.com/jenslind/electron-positioner) instance,
70+
- `positioner`: the `Positioner` instance used to compute the window's on-screen coordinates,
7171
- `setOption(option, value)`: change an option after menubar is created,
7272
- `getOption(option)`: get an menubar option,
7373
- `showWindow()`: show the menubar window,
@@ -93,7 +93,7 @@ You can pass an optional options object into the `menubar({ ... })` function:
9393
- `preloadWindow` (default false) - Create [BrowserWindow](https://electronjs.org/docs/api/browser-window#new-browserwindowoptions) instance before it is used -- increasing resource usage, but making the click on the menubar load faster.
9494
- `loadUrlOptions` - (default undefined) The options passed when loading the index URL in the menubar's browserWindow. Everything browserWindow.loadURL supports is supported; this object is simply passed onto [browserWindow.loadURL](https://electronjs.org/docs/api/browser-window#winloadurlurl-options)
9595
- `showOnAllWorkspaces` (default true) - Makes the window available on all OS X workspaces.
96-
- `windowPosition` (default trayCenter and trayBottomCenter on Windows) - Sets the window position (x and y will still override this), check [positioner docs](https://github.com/jenslind/electron-positioner#docs) for valid values.
96+
- `windowPosition` (default `trayCenter` on macOS/Linux, `trayBottomCenter` on Windows) - Sets the window position (`browserWindow.x` / `browserWindow.y` will still override this). Valid values: `trayLeft`, `trayBottomLeft`, `trayRight`, `trayBottomRight`, `trayCenter`, `trayBottomCenter`, `topLeft`, `topRight`, `bottomLeft`, `bottomRight`, `topCenter`, `bottomCenter`, `leftCenter`, `rightCenter`, `center`.
9797
- `showDockIcon` (default false) - Configure the visibility of the application dock icon.
9898
- `trigger` (default `'click'`) - Tray event that toggles the menubar window. One of `'click'`, `'right-click'`, or `'none'`. Use `'none'` to disable automatic toggling — useful when a single tray icon serves multiple windows. The window can still be shown by calling `mb.showWindow()` directly.
9999
- `showOnRightClick` (default false) - **Deprecated**, use `trigger: 'right-click'` instead. Show the window on 'right-click' event instead of regular 'click'.

bun.lock

Lines changed: 0 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,6 @@
6161
"test:visual": "bun run tests/visual/run.ts",
6262
"typecheck": "tsc --noEmit"
6363
},
64-
"dependencies": {
65-
"electron-positioner": "^4.1.0"
66-
},
6764
"devDependencies": {
6865
"@playwright/test": "^1.49.0",
6966
"@types/pngjs": "^6.0.5",

src/Menubar.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import fs from 'node:fs';
33
import path from 'node:path';
44

55
import { BrowserWindow, Tray } from 'electron';
6-
import Positioner from 'electron-positioner';
76

7+
import { Positioner } from './Positioner';
88
import type { Options } from './types';
99
import { cleanOptions } from './util/cleanOptions';
1010
import { getWindowPosition } from './util/getWindowPosition';
@@ -47,8 +47,8 @@ export class Menubar extends EventEmitter {
4747
}
4848

4949
/**
50-
* The [electron-positioner](https://github.com/jenslind/electron-positioner)
51-
* instance.
50+
* The {@link Positioner} instance used to compute where the menubar window
51+
* should appear on screen. Available after the `after-create-window` event.
5252
*/
5353
get positioner(): Positioner {
5454
if (!this._positioner) {

src/Positioner.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { BrowserWindow, Display, Rectangle } from 'electron';
2+
import { screen } from 'electron';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { Positioner, type WindowPosition } from './Positioner';
6+
7+
vi.mock('electron', () => import('./__mocks__/electron'));
8+
9+
const WORK_AREA = { x: 0, y: 0, width: 1920, height: 1080 };
10+
// Picked so no `tray*` position triggers the Windows overflow guard; that case
11+
// has its own dedicated test below.
12+
const TRAY_BOUNDS: Rectangle = { x: 1000, y: 0, width: 32, height: 22 };
13+
14+
function createWindow(size: [number, number] = [400, 400]): BrowserWindow {
15+
return { getSize: vi.fn(() => size) } as unknown as BrowserWindow;
16+
}
17+
18+
const displayWith = (workArea: Rectangle): Display =>
19+
({ workArea }) as unknown as Display;
20+
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
vi.mocked(screen.getDisplayMatching).mockReturnValue(displayWith(WORK_AREA));
24+
vi.mocked(screen.getDisplayNearestPoint).mockReturnValue(
25+
displayWith(WORK_AREA),
26+
);
27+
vi.mocked(screen.getCursorScreenPoint).mockReturnValue({ x: 0, y: 0 });
28+
});
29+
30+
describe('Positioner.calculate', () => {
31+
const expected: Record<WindowPosition, { x: number; y: number }> = {
32+
trayLeft: { x: 1000, y: 0 },
33+
trayBottomLeft: { x: 1000, y: 680 },
34+
trayRight: { x: 632, y: 0 },
35+
trayBottomRight: { x: 632, y: 680 },
36+
trayCenter: { x: 816, y: 0 },
37+
trayBottomCenter: { x: 816, y: 680 },
38+
topLeft: { x: 0, y: 0 },
39+
topRight: { x: 1520, y: 0 },
40+
bottomLeft: { x: 0, y: 680 },
41+
bottomRight: { x: 1520, y: 680 },
42+
topCenter: { x: 760, y: 0 },
43+
bottomCenter: { x: 760, y: 680 },
44+
leftCenter: { x: 0, y: 340 },
45+
rightCenter: { x: 1520, y: 340 },
46+
center: { x: 760, y: 340 },
47+
};
48+
49+
for (const [position, coords] of Object.entries(expected) as Array<
50+
[WindowPosition, { x: number; y: number }]
51+
>) {
52+
it(`computes ${position}`, () => {
53+
const positioner = new Positioner(createWindow());
54+
expect(positioner.calculate(position, TRAY_BOUNDS)).toEqual(coords);
55+
});
56+
}
57+
58+
it('resolves screen via getDisplayMatching when tray bounds are provided', () => {
59+
const positioner = new Positioner(createWindow());
60+
positioner.calculate('trayCenter', TRAY_BOUNDS);
61+
expect(screen.getDisplayMatching).toHaveBeenCalledWith(TRAY_BOUNDS);
62+
expect(screen.getDisplayNearestPoint).not.toHaveBeenCalled();
63+
});
64+
65+
it('falls back to the cursor display when no tray bounds are given', () => {
66+
const positioner = new Positioner(createWindow());
67+
positioner.calculate('center');
68+
expect(screen.getCursorScreenPoint).toHaveBeenCalled();
69+
expect(screen.getDisplayNearestPoint).toHaveBeenCalled();
70+
expect(screen.getDisplayMatching).not.toHaveBeenCalled();
71+
});
72+
73+
it('snaps tray positions back to topRight.x when they overflow the right edge', () => {
74+
const positioner = new Positioner(createWindow());
75+
const overflowing: Rectangle = { x: 1900, y: 0, width: 32, height: 22 };
76+
expect(positioner.calculate('trayRight', overflowing)).toEqual({
77+
x: 1520,
78+
y: 0,
79+
});
80+
});
81+
82+
it('leaves non-tray positions alone even when their x would overflow', () => {
83+
const positioner = new Positioner(createWindow([3000, 400]));
84+
expect(positioner.calculate('topRight')).toEqual({ x: -1080, y: 0 });
85+
});
86+
87+
it('throws a TypeError when called without a position', () => {
88+
const positioner = new Positioner(createWindow());
89+
expect(() =>
90+
(positioner as unknown as { calculate: () => unknown }).calculate(),
91+
).toThrow(TypeError);
92+
});
93+
});

src/Positioner.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { type BrowserWindow, type Rectangle, screen } from 'electron';
2+
3+
/**
4+
* Named anchor points for placing the menubar window. The `tray*` values are
5+
* relative to the tray icon's bounds; the rest are relative to the work area
6+
* of the display containing the cursor (or the tray, when bounds are given).
7+
*/
8+
export type WindowPosition =
9+
| 'trayLeft'
10+
| 'trayBottomLeft'
11+
| 'trayRight'
12+
| 'trayBottomRight'
13+
| 'trayCenter'
14+
| 'trayBottomCenter'
15+
| 'topLeft'
16+
| 'topRight'
17+
| 'bottomLeft'
18+
| 'bottomRight'
19+
| 'topCenter'
20+
| 'bottomCenter'
21+
| 'leftCenter'
22+
| 'rightCenter'
23+
| 'center';
24+
25+
/**
26+
* Computes `{x, y}` coordinates for placing a {@link BrowserWindow} at a named
27+
* position, optionally relative to a tray icon's bounds. Ported from
28+
* `electron-positioner@4.1.0` to drop the unmaintained runtime dependency.
29+
*/
30+
export class Positioner {
31+
private readonly browserWindow: BrowserWindow;
32+
33+
constructor(browserWindow: BrowserWindow) {
34+
this.browserWindow = browserWindow;
35+
}
36+
37+
calculate(
38+
position?: WindowPosition,
39+
trayBounds?: Rectangle,
40+
): { x: number; y: number } {
41+
if (!position) {
42+
throw new TypeError(
43+
'Positioner.calculate: a `position` argument is required.',
44+
);
45+
}
46+
47+
const screenSize = trayBounds
48+
? screen.getDisplayMatching(trayBounds).workArea
49+
: screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea;
50+
const [windowWidth, windowHeight] = this.browserWindow.getSize();
51+
const trayX = trayBounds?.x ?? Number.NaN;
52+
const trayWidth = trayBounds?.width ?? Number.NaN;
53+
54+
const positions: Record<WindowPosition, { x: number; y: number }> = {
55+
trayLeft: {
56+
x: Math.floor(trayX),
57+
y: screenSize.y,
58+
},
59+
trayBottomLeft: {
60+
x: Math.floor(trayX),
61+
y: Math.floor(screenSize.height - (windowHeight - screenSize.y)),
62+
},
63+
trayRight: {
64+
x: Math.floor(trayX - windowWidth + trayWidth),
65+
y: screenSize.y,
66+
},
67+
trayBottomRight: {
68+
x: Math.floor(trayX - windowWidth + trayWidth),
69+
y: Math.floor(screenSize.height - (windowHeight - screenSize.y)),
70+
},
71+
trayCenter: {
72+
x: Math.floor(trayX - windowWidth / 2 + trayWidth / 2),
73+
y: screenSize.y,
74+
},
75+
trayBottomCenter: {
76+
x: Math.floor(trayX - windowWidth / 2 + trayWidth / 2),
77+
y: Math.floor(screenSize.height - (windowHeight - screenSize.y)),
78+
},
79+
topLeft: {
80+
x: screenSize.x,
81+
y: screenSize.y,
82+
},
83+
topRight: {
84+
x: Math.floor(screenSize.x + (screenSize.width - windowWidth)),
85+
y: screenSize.y,
86+
},
87+
bottomLeft: {
88+
x: screenSize.x,
89+
y: Math.floor(screenSize.height - (windowHeight - screenSize.y)),
90+
},
91+
bottomRight: {
92+
x: Math.floor(screenSize.x + (screenSize.width - windowWidth)),
93+
y: Math.floor(screenSize.height - (windowHeight - screenSize.y)),
94+
},
95+
topCenter: {
96+
x: Math.floor(screenSize.x + (screenSize.width / 2 - windowWidth / 2)),
97+
y: screenSize.y,
98+
},
99+
bottomCenter: {
100+
x: Math.floor(screenSize.x + (screenSize.width / 2 - windowWidth / 2)),
101+
y: Math.floor(screenSize.height - (windowHeight - screenSize.y)),
102+
},
103+
leftCenter: {
104+
x: screenSize.x,
105+
y:
106+
screenSize.y +
107+
Math.floor(screenSize.height / 2) -
108+
Math.floor(windowHeight / 2),
109+
},
110+
rightCenter: {
111+
x: Math.floor(screenSize.x + (screenSize.width - windowWidth)),
112+
y:
113+
screenSize.y +
114+
Math.floor(screenSize.height / 2) -
115+
Math.floor(windowHeight / 2),
116+
},
117+
center: {
118+
x: Math.floor(screenSize.x + (screenSize.width / 2 - windowWidth / 2)),
119+
y: Math.floor(
120+
(screenSize.height + screenSize.y) / 2 - windowHeight / 2,
121+
),
122+
},
123+
};
124+
125+
const coords = positions[position];
126+
127+
// On Windows, a tray-relative position can push the window past the right
128+
// edge of the work area. Snap back to `topRight` x in that case so it stays
129+
// visible. See https://github.com/jenslind/electron-positioner.
130+
if (position.startsWith('tray')) {
131+
if (coords.x + windowWidth > screenSize.width + screenSize.x) {
132+
return { x: positions.topRight.x, y: coords.y };
133+
}
134+
}
135+
136+
return coords;
137+
}
138+
}

src/__mocks__/electron.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,28 @@ export const app: {
1818

1919
export class BrowserWindow {
2020
destroy: Mock = vi.fn();
21+
getSize: Mock = vi.fn(() => [400, 400]);
2122
loadURL: Mock = vi.fn();
2223
on: Mock = vi.fn();
24+
setPosition: Mock = vi.fn();
2325
setVisibleOnAllWorkspaces: Mock = vi.fn();
26+
show: Mock = vi.fn();
2427
}
2528

2629
export class Tray {
2730
on: Mock = vi.fn();
2831
removeListener: Mock = vi.fn();
2932
setToolTip: Mock = vi.fn();
3033
}
34+
35+
const defaultWorkArea = { x: 0, y: 0, width: 1920, height: 1080 };
36+
37+
export const screen: {
38+
getCursorScreenPoint: Mock;
39+
getDisplayMatching: Mock;
40+
getDisplayNearestPoint: Mock;
41+
} = {
42+
getCursorScreenPoint: vi.fn(() => ({ x: 0, y: 0 })),
43+
getDisplayMatching: vi.fn(() => ({ workArea: defaultWorkArea })),
44+
getDisplayNearestPoint: vi.fn(() => ({ workArea: defaultWorkArea })),
45+
};

src/ambient.d.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

src/types.ts

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type {
44
Tray,
55
} from 'electron';
66

7+
import type { WindowPosition } from './Positioner';
8+
79
/**
810
* Options for creating a menubar application
911
*/
@@ -93,23 +95,8 @@ export interface Options {
9395
*/
9496
tray?: Tray;
9597
/**
96-
* Sets the window position (x and y will still override this), check
97-
* electron-positioner docs for valid values.
98+
* Sets the window position (x and y will still override this). See
99+
* {@link WindowPosition} for the list of valid values.
98100
*/
99-
windowPosition?:
100-
| 'trayLeft'
101-
| 'trayBottomLeft'
102-
| 'trayRight'
103-
| 'trayBottomRight'
104-
| 'trayCenter'
105-
| 'trayBottomCenter'
106-
| 'topLeft'
107-
| 'topRight'
108-
| 'bottomLeft'
109-
| 'bottomRight'
110-
| 'topCenter'
111-
| 'bottomCenter'
112-
| 'leftCenter'
113-
| 'rightCenter'
114-
| 'center';
101+
windowPosition?: WindowPosition;
115102
}

0 commit comments

Comments
 (0)