gloomberb 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/patches/electrobun@1.18.1.patch +85 -0
- package/src/app/global-shortcuts.test.tsx +12 -0
- package/src/app/global-shortcuts.ts +1 -1
- package/src/assets/gloomberb-logo-windows.ico +0 -0
- package/src/assets/gloomberb-logo-windows.png +0 -0
- package/src/assets/gloomberb-logo.ico +0 -0
- package/src/components/layout/detached-pane-shell.tsx +8 -4
- package/src/components/layout/header.tsx +13 -6
- package/src/components/layout/titlebar-overlay.ts +16 -0
- package/src/components/layout/window-controls.tsx +90 -0
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +43 -2
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +64 -0
- package/src/renderers/electrobun/bun/desktop/window-style.ts +5 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +123 -0
- package/src/renderers/electrobun/bun/index.ts +28 -6
- package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
- package/src/renderers/electrobun/bun/window/frame.ts +5 -0
- package/src/renderers/electrobun/view/build-assets.ts +2 -1
- package/src/renderers/electrobun/view/host/text.tsx +29 -9
- package/src/renderers/electrobun/view/styles.css +20 -0
- package/src/renderers/electrobun/view/ui-host.tsx +14 -0
- package/src/ui/host.tsx +2 -0
- package/src/updater.test.ts +47 -2
- package/src/updater.ts +67 -10
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gloomberb",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Market research and portfolio tracker for the terminal",
|
|
5
5
|
"module": "src/index.tsx",
|
|
6
6
|
"type": "module",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"desktop:dev": "electrobun dev --watch",
|
|
22
22
|
"desktop:build": "electrobun build",
|
|
23
23
|
"desktop:release": "electrobun build --env=stable",
|
|
24
|
+
"desktop:release:windows": "electrobun build --env=stable --targets=win-x64",
|
|
24
25
|
"test": "bun test",
|
|
25
26
|
"release": "./scripts/release.sh"
|
|
26
27
|
},
|
|
@@ -40,6 +41,8 @@
|
|
|
40
41
|
"@types/bun": "^1.3.14",
|
|
41
42
|
"@types/react-dom": "^19.2.3",
|
|
42
43
|
"electrobun": "^1.18.1",
|
|
44
|
+
"png-to-ico": "^2.1.8",
|
|
45
|
+
"rcedit": "^4.0.1",
|
|
43
46
|
"react-dom": "19.2.7"
|
|
44
47
|
},
|
|
45
48
|
"peerDependencies": {
|
|
@@ -59,6 +62,7 @@
|
|
|
59
62
|
"web-tree-sitter": "0.25.10"
|
|
60
63
|
},
|
|
61
64
|
"patchedDependencies": {
|
|
62
|
-
"@opentui/core@0.3.2": "patches/@opentui%2Fcore@0.3.2.patch"
|
|
65
|
+
"@opentui/core@0.3.2": "patches/@opentui%2Fcore@0.3.2.patch",
|
|
66
|
+
"electrobun@1.18.1": "patches/electrobun@1.18.1.patch"
|
|
63
67
|
}
|
|
64
68
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
diff --git a/dist/api/bun/core/BrowserWindow.ts b/dist/api/bun/core/BrowserWindow.ts
|
|
2
|
+
index c26c2c977ee1fc25a8dc144b85bac8626a45f572..8920a2905380228c23cba66c20d9bfd19ee13e02 100644
|
|
3
|
+
--- a/dist/api/bun/core/BrowserWindow.ts
|
|
4
|
+
+++ b/dist/api/bun/core/BrowserWindow.ts
|
|
5
|
+
@@ -8,9 +8,18 @@ import { type RPCWithTransport } from "../../shared/rpc.js";
|
|
6
|
+
import { getNextWindowId } from "./windowIds";
|
|
7
|
+
import { GpuWindowMap } from "./GpuWindow";
|
|
8
|
+
import { WGPUView } from "./WGPUView";
|
|
9
|
+
+import { existsSync } from "fs";
|
|
10
|
+
+import { resolve } from "path";
|
|
11
|
+
|
|
12
|
+
const buildConfig = await BuildConfig.get();
|
|
13
|
+
|
|
14
|
+
+function defaultWindowIconPath(): string | null {
|
|
15
|
+
+ if (process.platform !== "win32") return null;
|
|
16
|
+
+
|
|
17
|
+
+ const iconPath = resolve("../Resources/app.ico");
|
|
18
|
+
+ return existsSync(iconPath) ? iconPath : null;
|
|
19
|
+
+}
|
|
20
|
+
+
|
|
21
|
+
export type WindowOptionsType<T = undefined> = {
|
|
22
|
+
trafficLightOffset?: {
|
|
23
|
+
x: number;
|
|
24
|
+
@@ -28,6 +37,7 @@ export type WindowOptionsType<T = undefined> = {
|
|
25
|
+
html: string | null;
|
|
26
|
+
preload: string | null;
|
|
27
|
+
viewsRoot: string | null;
|
|
28
|
+
+ iconPath?: string | null;
|
|
29
|
+
renderer: "native" | "cef";
|
|
30
|
+
rpc?: T;
|
|
31
|
+
styleMask?: {};
|
|
32
|
+
@@ -124,6 +134,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
|
|
33
|
+
html: string | null = null;
|
|
34
|
+
preload: string | null = null;
|
|
35
|
+
viewsRoot: string | null = null;
|
|
36
|
+
+ iconPath: string | null = null;
|
|
37
|
+
renderer: "native" | "cef" = "native";
|
|
38
|
+
transparent: boolean = false;
|
|
39
|
+
passthrough: boolean = false;
|
|
40
|
+
@@ -155,6 +166,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
|
|
41
|
+
this.html = options.html || null;
|
|
42
|
+
this.preload = options.preload || null;
|
|
43
|
+
this.viewsRoot = options.viewsRoot || null;
|
|
44
|
+
+ this.iconPath = options.iconPath ?? defaultWindowIconPath();
|
|
45
|
+
this.renderer = options.renderer || defaultOptions.renderer;
|
|
46
|
+
this.transparent = options.transparent ?? false;
|
|
47
|
+
this.passthrough = options.passthrough ?? false;
|
|
48
|
+
@@ -181,6 +193,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
|
|
49
|
+
id: this.id,
|
|
50
|
+
title: this.title,
|
|
51
|
+
url: this.url || "",
|
|
52
|
+
+ iconPath: this.iconPath,
|
|
53
|
+
frame: {
|
|
54
|
+
width: this.frame.width,
|
|
55
|
+
height: this.frame.height,
|
|
56
|
+
diff --git a/dist/api/bun/proc/native.ts b/dist/api/bun/proc/native.ts
|
|
57
|
+
index f0f6c2532ba2eb467ec1d452a25c835b6db72661..ae77cf58d32392ad4cc8e2103ac7ed27baae8bea 100644
|
|
58
|
+
--- a/dist/api/bun/proc/native.ts
|
|
59
|
+
+++ b/dist/api/bun/proc/native.ts
|
|
60
|
+
@@ -829,6 +829,7 @@ const _ffiImpl = {
|
|
61
|
+
id: number;
|
|
62
|
+
url: string | null;
|
|
63
|
+
title: string;
|
|
64
|
+
+ iconPath?: string | null;
|
|
65
|
+
frame: {
|
|
66
|
+
width: number;
|
|
67
|
+
height: number;
|
|
68
|
+
@@ -862,6 +863,7 @@ const _ffiImpl = {
|
|
69
|
+
id,
|
|
70
|
+
url: _url,
|
|
71
|
+
title,
|
|
72
|
+
+ iconPath,
|
|
73
|
+
frame: { x, y, width, height },
|
|
74
|
+
styleMask: {
|
|
75
|
+
Borderless,
|
|
76
|
+
@@ -926,6 +928,9 @@ const _ffiImpl = {
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
native_.symbols.setWindowTitle(windowPtr, toCString(title));
|
|
80
|
+
+ if (iconPath) {
|
|
81
|
+
+ native_.symbols.setWindowIcon(windowPtr, toCString(iconPath));
|
|
82
|
+
+ }
|
|
83
|
+
if (!hidden) {
|
|
84
|
+
native_.symbols.showWindow(windowPtr, activate);
|
|
85
|
+
}
|
|
@@ -135,6 +135,18 @@ describe("useAppGlobalShortcuts", () => {
|
|
|
135
135
|
expect(event.propagationStopped).toBe(true);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
test("toggles the command bar with Ctrl-K", async () => {
|
|
139
|
+
const actions: AppAction[] = [];
|
|
140
|
+
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-global-shortcuts"));
|
|
141
|
+
await renderHarness(state, createRegistry(), (action) => actions.push(action));
|
|
142
|
+
|
|
143
|
+
const event = await emitKeypress({ name: "k", ctrl: true });
|
|
144
|
+
|
|
145
|
+
expect(actions).toEqual([{ type: "TOGGLE_COMMAND_BAR" }]);
|
|
146
|
+
expect(event.defaultPrevented).toBe(true);
|
|
147
|
+
expect(event.propagationStopped).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
138
150
|
test("opens ticker search with backtick", async () => {
|
|
139
151
|
const actions: AppAction[] = [];
|
|
140
152
|
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-global-shortcuts"));
|
|
@@ -52,7 +52,7 @@ export function useAppGlobalShortcuts({
|
|
|
52
52
|
|
|
53
53
|
if (!isDetachedWindow && (
|
|
54
54
|
(event.name === "p" && event.ctrl)
|
|
55
|
-
|| (event.name === "k" && (event.meta || event.super))
|
|
55
|
+
|| (event.name === "k" && (event.ctrl || event.meta || event.super))
|
|
56
56
|
)) {
|
|
57
57
|
event.preventDefault();
|
|
58
58
|
event.stopPropagation();
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -12,7 +12,8 @@ import { PaneBodyFrame, getPaneWindowAttributes } from "./pane/frame";
|
|
|
12
12
|
import { PaneContent } from "./pane/content";
|
|
13
13
|
import { resolvePaneBodyFrame } from "./pane/sizing";
|
|
14
14
|
import { getPaneDisplayTitle } from "./pane/title";
|
|
15
|
-
import { TITLEBAR_OVERLAY_HEIGHT_PX,
|
|
15
|
+
import { TITLEBAR_OVERLAY_HEIGHT_PX, getTitlebarLeadingInset } from "./titlebar-overlay";
|
|
16
|
+
import { WindowControls } from "./window-controls";
|
|
16
17
|
import {
|
|
17
18
|
createDoubleEscapeCloseState,
|
|
18
19
|
recordDoubleEscapeClose,
|
|
@@ -41,7 +42,9 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
|
|
|
41
42
|
typeof document === "undefined" ? true : document.hasFocus()
|
|
42
43
|
));
|
|
43
44
|
const { width, height } = useViewport();
|
|
44
|
-
const { cellHeightPx = 18, nativePaneChrome, titleBarOverlay } = useUiCapabilities();
|
|
45
|
+
const { cellHeightPx = 18, nativePaneChrome, titleBarOverlay, windowControls } = useUiCapabilities();
|
|
46
|
+
const showWindowControls = windowControls === "windows";
|
|
47
|
+
const titlebarLeadingInset = titleBarOverlay ? getTitlebarLeadingInset() : 0;
|
|
45
48
|
const instance = useAppSelector((state) => findPaneInstance(state.config.layout, desktopWindowBridge.paneId) ?? null);
|
|
46
49
|
const paneDef = instance ? pluginRegistry.panes.get(instance.paneId) ?? null : null;
|
|
47
50
|
const hasPaneSettings = !!instance && pluginRegistry.hasPaneSettings(instance.instanceId);
|
|
@@ -171,8 +174,8 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
|
|
|
171
174
|
alignItems="center"
|
|
172
175
|
flexGrow={1}
|
|
173
176
|
minWidth={0}
|
|
174
|
-
paddingLeft={titleBarOverlay ?
|
|
175
|
-
paddingRight={1}
|
|
177
|
+
paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}
|
|
178
|
+
paddingRight={showWindowControls ? 0 : 1}
|
|
176
179
|
>
|
|
177
180
|
<Box flexGrow={1} minWidth={0} overflow="hidden">
|
|
178
181
|
<Text fg={paneTitleText(focused, true)} selectable={false} data-gloom-role="pane-title">{title}</Text>
|
|
@@ -189,6 +192,7 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
|
|
|
189
192
|
{" ... "}
|
|
190
193
|
</Text>
|
|
191
194
|
)}
|
|
195
|
+
{showWindowControls ? <WindowControls /> : null}
|
|
192
196
|
</Box>
|
|
193
197
|
</Box>
|
|
194
198
|
<PaneBodyFrame layoutProps={bodyFrame.layoutProps} backgroundColor={background}>
|
|
@@ -17,7 +17,8 @@ import { formatPercentRaw } from "../../utils/format";
|
|
|
17
17
|
import { formatMarketPrice } from "../../market-data/market/format";
|
|
18
18
|
import { marketStateLabel, marketStateColor, getActiveQuoteDisplay } from "../../market-data/market/status";
|
|
19
19
|
import { VERSION } from "../../version";
|
|
20
|
-
import {
|
|
20
|
+
import { getTitlebarLeadingInset } from "./titlebar-overlay";
|
|
21
|
+
import { WindowControls } from "./window-controls";
|
|
21
22
|
|
|
22
23
|
const SPY_REFRESH_MS = 5 * 60_000; // 5 min
|
|
23
24
|
const UPDATE_NOTICE_DURATION_MS = 5_000;
|
|
@@ -125,7 +126,9 @@ export function Header() {
|
|
|
125
126
|
useThemeColors();
|
|
126
127
|
const baseCurrency = useAppSelector(selectBaseCurrency);
|
|
127
128
|
const appActive = useAppActive();
|
|
128
|
-
const { titleBarOverlay } = useUiCapabilities();
|
|
129
|
+
const { titleBarOverlay, windowControls } = useUiCapabilities();
|
|
130
|
+
const showWindowControls = windowControls === "windows";
|
|
131
|
+
const titlebarLeadingInset = titleBarOverlay ? getTitlebarLeadingInset() : 0;
|
|
129
132
|
const spyQuoteEntry = useQuoteEntry("SPY", null);
|
|
130
133
|
const spyQuote = useResolvedEntryValue(spyQuoteEntry);
|
|
131
134
|
|
|
@@ -165,10 +168,10 @@ export function Header() {
|
|
|
165
168
|
style={{
|
|
166
169
|
boxShadow: `0 -1px 0 ${colors.header}, inset 0 1px 0 ${colors.header}`,
|
|
167
170
|
paddingLeft: 8,
|
|
168
|
-
paddingRight: 12,
|
|
171
|
+
paddingRight: showWindowControls ? 0 : 12,
|
|
169
172
|
}}
|
|
170
173
|
>
|
|
171
|
-
<Box paddingLeft={
|
|
174
|
+
<Box paddingLeft={titlebarLeadingInset} flexDirection="row" alignItems="center" gap={1}>
|
|
172
175
|
<Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
|
|
173
176
|
Gloomberb
|
|
174
177
|
</Text>
|
|
@@ -195,7 +198,10 @@ export function Header() {
|
|
|
195
198
|
<Box paddingRight={1}>
|
|
196
199
|
<Text fg={spyColor}>{spyText}</Text>
|
|
197
200
|
</Box>
|
|
198
|
-
<
|
|
201
|
+
<Box paddingRight={showWindowControls ? 1 : 0}>
|
|
202
|
+
<Text fg={colors.headerText}>{baseCurrency}</Text>
|
|
203
|
+
</Box>
|
|
204
|
+
{showWindowControls ? <WindowControls /> : null}
|
|
199
205
|
</Box>
|
|
200
206
|
);
|
|
201
207
|
}
|
|
@@ -209,7 +215,7 @@ export function Header() {
|
|
|
209
215
|
data-titlebar-overlay={titleBarOverlay ? "true" : undefined}
|
|
210
216
|
className={titleBarOverlay ? "electrobun-webkit-app-region-drag" : undefined}
|
|
211
217
|
>
|
|
212
|
-
<Box paddingLeft={titleBarOverlay ?
|
|
218
|
+
<Box paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}>
|
|
213
219
|
<Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
|
|
214
220
|
Gloomberb v{VERSION}
|
|
215
221
|
</Text>
|
|
@@ -230,6 +236,7 @@ export function Header() {
|
|
|
230
236
|
{baseCurrency}
|
|
231
237
|
</Text>
|
|
232
238
|
</Box>
|
|
239
|
+
{showWindowControls ? <WindowControls /> : null}
|
|
233
240
|
</Box>
|
|
234
241
|
);
|
|
235
242
|
}
|
|
@@ -1,2 +1,18 @@
|
|
|
1
1
|
export const TITLEBAR_TRAFFIC_LIGHT_WIDTH = 8;
|
|
2
2
|
export const TITLEBAR_OVERLAY_HEIGHT_PX = 28;
|
|
3
|
+
|
|
4
|
+
function currentPlatform(): string {
|
|
5
|
+
const globalWithNavigator = globalThis as typeof globalThis & {
|
|
6
|
+
navigator?: {
|
|
7
|
+
platform?: string;
|
|
8
|
+
userAgentData?: { platform?: string };
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
return globalWithNavigator.navigator?.userAgentData?.platform
|
|
12
|
+
?? globalWithNavigator.navigator?.platform
|
|
13
|
+
?? "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getTitlebarLeadingInset(platform = currentPlatform()): number {
|
|
17
|
+
return /mac/i.test(platform) ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 0;
|
|
18
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Box, useRendererHost } from "../../ui";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
|
|
4
|
+
const WINDOWS_CONTROL_WIDTH_PX = 30;
|
|
5
|
+
const WINDOWS_CONTROL_TRAILING_PADDING_PX = 10;
|
|
6
|
+
|
|
7
|
+
type WindowControlAction = "minimize" | "toggle-maximize" | "close";
|
|
8
|
+
|
|
9
|
+
const WINDOWS_CONTROLS: Array<{
|
|
10
|
+
action: WindowControlAction;
|
|
11
|
+
label: string;
|
|
12
|
+
}> = [
|
|
13
|
+
{ action: "minimize", label: "Minimize" },
|
|
14
|
+
{ action: "toggle-maximize", label: "Maximize" },
|
|
15
|
+
{ action: "close", label: "Close" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function stopMouse(event: { stopPropagation?: () => void; preventDefault?: () => void }) {
|
|
19
|
+
event.stopPropagation?.();
|
|
20
|
+
event.preventDefault?.();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function WindowControlIcon({ action }: { action: WindowControlAction }) {
|
|
24
|
+
if (action === "minimize") {
|
|
25
|
+
return (
|
|
26
|
+
<svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
|
|
27
|
+
<path d="M2.5 6.5H9.5" stroke="currentColor" strokeWidth="1.25" strokeLinecap="square" />
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (action === "toggle-maximize") {
|
|
33
|
+
return (
|
|
34
|
+
<svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
|
|
35
|
+
<rect x="3" y="3" width="6" height="6" stroke="currentColor" strokeWidth="1.25" />
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
|
|
42
|
+
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
|
|
43
|
+
</svg>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function WindowControls() {
|
|
48
|
+
const rendererHost = useRendererHost();
|
|
49
|
+
|
|
50
|
+
const controlWindow = useCallback((action: WindowControlAction, event: { stopPropagation?: () => void; preventDefault?: () => void }) => {
|
|
51
|
+
stopMouse(event);
|
|
52
|
+
void rendererHost.controlWindow?.(action);
|
|
53
|
+
}, [rendererHost]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Box
|
|
57
|
+
flexDirection="row"
|
|
58
|
+
alignItems="stretch"
|
|
59
|
+
flexShrink={0}
|
|
60
|
+
className="electrobun-webkit-app-region-no-drag"
|
|
61
|
+
data-gloom-role="window-controls"
|
|
62
|
+
aria-hidden={false}
|
|
63
|
+
style={{
|
|
64
|
+
paddingRight: WINDOWS_CONTROL_TRAILING_PADDING_PX,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{WINDOWS_CONTROLS.map((control) => (
|
|
68
|
+
<Box
|
|
69
|
+
key={control.action}
|
|
70
|
+
alignItems="center"
|
|
71
|
+
justifyContent="center"
|
|
72
|
+
data-gloom-role="window-control"
|
|
73
|
+
data-window-control-action={control.action}
|
|
74
|
+
data-gloom-interactive="true"
|
|
75
|
+
role="button"
|
|
76
|
+
aria-label={control.label}
|
|
77
|
+
title={control.label}
|
|
78
|
+
className="electrobun-webkit-app-region-no-drag"
|
|
79
|
+
onMouseDown={(event: { stopPropagation?: () => void; preventDefault?: () => void }) => controlWindow(control.action, event)}
|
|
80
|
+
style={{
|
|
81
|
+
width: WINDOWS_CONTROL_WIDTH_PX,
|
|
82
|
+
height: "100%",
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<WindowControlIcon action={control.action} />
|
|
86
|
+
</Box>
|
|
87
|
+
))}
|
|
88
|
+
</Box>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildApplicationMenu, buildDesktopApplicationMenu } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("desktop application menu", () => {
|
|
5
|
+
test("hides the application menu on Windows to preserve vertical space", () => {
|
|
6
|
+
expect(buildDesktopApplicationMenu("win32")).toEqual([]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("keeps the native application menu on macOS", () => {
|
|
10
|
+
expect(buildDesktopApplicationMenu("darwin")).toEqual(buildApplicationMenu());
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -121,3 +121,7 @@ export function buildApplicationMenu(): ApplicationMenuItemConfig[] {
|
|
|
121
121
|
},
|
|
122
122
|
];
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
export function buildDesktopApplicationMenu(platform = process.platform): ApplicationMenuItemConfig[] {
|
|
126
|
+
return platform === "win32" ? [] : buildApplicationMenu();
|
|
127
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
detachedRpcKey,
|
|
14
14
|
focusWindowForRpcKey,
|
|
15
|
+
paneIdFromDetachedRpcKey,
|
|
15
16
|
} from "../window/focus";
|
|
16
17
|
import {
|
|
17
18
|
applyWindowMoveEvent,
|
|
@@ -22,6 +23,11 @@ import {
|
|
|
22
23
|
type WindowResizeEvent,
|
|
23
24
|
} from "./window-events";
|
|
24
25
|
import type { DesktopStateBroadcaster, DesktopStateRpc } from "./state-broadcaster";
|
|
26
|
+
import { applyWindowsWindowIcon } from "./windows-icons";
|
|
27
|
+
import { desktopTitleBarStyle } from "./window-style";
|
|
28
|
+
|
|
29
|
+
const INITIAL_DOCK_SUPPRESSION_MS = 800;
|
|
30
|
+
const WINDOW_CONTROL_DOCK_SUPPRESSION_MS = 5_000;
|
|
25
31
|
|
|
26
32
|
interface DesktopDetachedWindowManagerOptions<Rpc extends DesktopStateRpc> {
|
|
27
33
|
createRpc: (key: string) => Rpc;
|
|
@@ -45,6 +51,8 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
45
51
|
private readonly dockTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
46
52
|
private readonly closingPanes = new Set<string>();
|
|
47
53
|
private readonly pendingMoveFlush = new Set<string>();
|
|
54
|
+
private readonly suppressDockUntil = new Map<string, number>();
|
|
55
|
+
private readonly windowControlDockSuppressed = new Set<string>();
|
|
48
56
|
|
|
49
57
|
constructor(private readonly options: DesktopDetachedWindowManagerOptions<Rpc>) {}
|
|
50
58
|
|
|
@@ -56,6 +64,19 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
56
64
|
return focusWindowForRpcKey(rpcKey, this.options.getMainWindow(), this.windows);
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
getWindowForRpcKey(rpcKey: string | undefined): BrowserWindow | null {
|
|
68
|
+
const paneId = paneIdFromDetachedRpcKey(rpcKey);
|
|
69
|
+
return paneId ? this.windows.get(paneId) ?? null : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
suppressAutoDockForRpcKey(rpcKey: string | undefined): void {
|
|
73
|
+
const paneId = paneIdFromDetachedRpcKey(rpcKey);
|
|
74
|
+
if (!paneId || !this.windows.has(paneId)) return;
|
|
75
|
+
this.windowControlDockSuppressed.add(paneId);
|
|
76
|
+
this.suppressDockUntil.set(paneId, Date.now() + WINDOW_CONTROL_DOCK_SUPPRESSION_MS);
|
|
77
|
+
this.options.stateBroadcaster.clearDockPreview(paneId);
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
resolveFrame(instanceId: string): WindowFrame {
|
|
60
81
|
const detachedEntry = this.options.getConfig().layout.detached.find((entry) => entry.instanceId === instanceId);
|
|
61
82
|
if (detachedEntry) {
|
|
@@ -151,6 +172,8 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
151
172
|
this.clearTimer(this.frameTimers, instanceId);
|
|
152
173
|
this.clearTimer(this.dockTimers, instanceId);
|
|
153
174
|
this.pendingMoveFlush.delete(instanceId);
|
|
175
|
+
this.suppressDockUntil.delete(instanceId);
|
|
176
|
+
this.windowControlDockSuppressed.delete(instanceId);
|
|
154
177
|
this.options.unregisterWindowRpc(detachedRpcKey(instanceId));
|
|
155
178
|
}
|
|
156
179
|
|
|
@@ -160,18 +183,21 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
160
183
|
): BrowserWindow {
|
|
161
184
|
const rpc = this.options.createRpc(detachedRpcKey(instanceId));
|
|
162
185
|
const initialFrame = normalizeWindowFrameWithMinimum(frame, DEFAULT_WINDOW_FRAME, DETACHED_WINDOW_MIN_SIZE);
|
|
186
|
+
const title = this.resolveTitle(instanceId);
|
|
163
187
|
const window = new BrowserWindow({
|
|
164
|
-
title
|
|
188
|
+
title,
|
|
165
189
|
frame: initialFrame,
|
|
166
190
|
url: "views://mainview/index.html",
|
|
167
191
|
renderer: "native",
|
|
168
192
|
rpc: rpc as never,
|
|
169
|
-
titleBarStyle:
|
|
193
|
+
titleBarStyle: desktopTitleBarStyle(),
|
|
170
194
|
navigationRules: JSON.stringify(["views://*"]),
|
|
171
195
|
sandbox: false,
|
|
172
196
|
});
|
|
197
|
+
applyWindowsWindowIcon(title);
|
|
173
198
|
updateWindowFrameCache(window, initialFrame, DETACHED_WINDOW_MIN_SIZE);
|
|
174
199
|
this.windows.set(instanceId, window);
|
|
200
|
+
this.suppressDockUntil.set(instanceId, Date.now() + INITIAL_DOCK_SUPPRESSION_MS);
|
|
175
201
|
|
|
176
202
|
(window as any).on?.("close", () => {
|
|
177
203
|
const shouldIgnore = this.closingPanes.delete(instanceId);
|
|
@@ -230,6 +256,21 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
230
256
|
}, 120));
|
|
231
257
|
|
|
232
258
|
this.clearTimer(this.dockTimers, instanceId);
|
|
259
|
+
if (this.windowControlDockSuppressed.has(instanceId)) {
|
|
260
|
+
this.options.stateBroadcaster.clearDockPreview(instanceId);
|
|
261
|
+
if (!edge) {
|
|
262
|
+
this.windowControlDockSuppressed.delete(instanceId);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const suppressDockUntil = this.suppressDockUntil.get(instanceId) ?? 0;
|
|
268
|
+
if (Date.now() < suppressDockUntil) {
|
|
269
|
+
this.options.stateBroadcaster.clearDockPreview(instanceId);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.suppressDockUntil.delete(instanceId);
|
|
273
|
+
|
|
233
274
|
if (!edge) {
|
|
234
275
|
this.options.stateBroadcaster.clearDockPreview(instanceId);
|
|
235
276
|
return;
|
|
@@ -3,10 +3,13 @@ import { ContextMenu, Utils, type BrowserWindow } from "electrobun/bun";
|
|
|
3
3
|
import { buildSoundCommand } from "../../../../notifications/app-notifier";
|
|
4
4
|
import type { DesktopRestartMessage } from "../../shared/protocol";
|
|
5
5
|
import { getContextMenuRequestId, normalizeContextMenuItems } from "../context-menu/normalize";
|
|
6
|
+
import { MAIN_WINDOW_RPC_KEY } from "../window/focus";
|
|
7
|
+
import type { DesktopWindowControlAction } from "./window-controls";
|
|
6
8
|
|
|
7
9
|
interface DesktopHostRequestOptions<TRpc> {
|
|
8
10
|
clearMainWindow: () => void;
|
|
9
11
|
closeAllDetachedWindows: () => void;
|
|
12
|
+
controlWindowForRpcKey: (windowKey: string | undefined, action: DesktopWindowControlAction) => boolean;
|
|
10
13
|
focusWindowForRpcKey: (windowKey: string) => void;
|
|
11
14
|
getMainWindow: () => BrowserWindow | null;
|
|
12
15
|
getRpcWindowKey: (rpc: TRpc) => string | undefined;
|
|
@@ -37,9 +40,17 @@ function playNotificationSound(sound: string | undefined): void {
|
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
function normalizeWindowControlAction(action: unknown): DesktopWindowControlAction {
|
|
44
|
+
if (action === "minimize" || action === "toggle-maximize" || action === "close") {
|
|
45
|
+
return action;
|
|
46
|
+
}
|
|
47
|
+
throw new Error("host.windowControl requires a valid action.");
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
export function handleDesktopHostRequest<TRpc>({
|
|
41
51
|
clearMainWindow,
|
|
42
52
|
closeAllDetachedWindows,
|
|
53
|
+
controlWindowForRpcKey,
|
|
43
54
|
focusWindowForRpcKey,
|
|
44
55
|
getMainWindow,
|
|
45
56
|
getRpcWindowKey,
|
|
@@ -69,6 +80,26 @@ export function handleDesktopHostRequest<TRpc>({
|
|
|
69
80
|
Utils.quit();
|
|
70
81
|
return null;
|
|
71
82
|
}
|
|
83
|
+
case "host.windowControl": {
|
|
84
|
+
const action = normalizeWindowControlAction(payload.action);
|
|
85
|
+
const windowKey = getRpcWindowKey(rpc);
|
|
86
|
+
if (action === "close" && windowKey === MAIN_WINDOW_RPC_KEY) {
|
|
87
|
+
closeAllDetachedWindows();
|
|
88
|
+
teardownServices();
|
|
89
|
+
const mainWindow = getMainWindow();
|
|
90
|
+
if (mainWindow) {
|
|
91
|
+
mainWindow.close();
|
|
92
|
+
clearMainWindow();
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
Utils.quit();
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (!controlWindowForRpcKey(windowKey, action)) {
|
|
99
|
+
throw new Error("No desktop window is registered for this request.");
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
72
103
|
case "host.openExternal":
|
|
73
104
|
if (typeof payload.url !== "string") {
|
|
74
105
|
throw new Error("host.openExternal requires a URL.");
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { applyDesktopWindowControl } from "./window-controls";
|
|
3
|
+
|
|
4
|
+
type WindowCall =
|
|
5
|
+
| { type: "close" }
|
|
6
|
+
| { type: "minimize" }
|
|
7
|
+
| { type: "maximize" }
|
|
8
|
+
| { type: "unmaximize" }
|
|
9
|
+
| { type: "setFrame"; frame: { x: number; y: number; width: number; height: number } };
|
|
10
|
+
|
|
11
|
+
function createWindow() {
|
|
12
|
+
const calls: WindowCall[] = [];
|
|
13
|
+
const frame = { x: 120, y: 80, width: 640, height: 420 };
|
|
14
|
+
return {
|
|
15
|
+
calls,
|
|
16
|
+
window: {
|
|
17
|
+
frame,
|
|
18
|
+
close: () => calls.push({ type: "close" }),
|
|
19
|
+
minimize: () => calls.push({ type: "minimize" }),
|
|
20
|
+
maximize: () => calls.push({ type: "maximize" }),
|
|
21
|
+
unmaximize: () => calls.push({ type: "unmaximize" }),
|
|
22
|
+
getFrame: () => frame,
|
|
23
|
+
setFrame: (x: number, y: number, width: number, height: number) => calls.push({
|
|
24
|
+
type: "setFrame",
|
|
25
|
+
frame: { x, y, width, height },
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("applyDesktopWindowControl", () => {
|
|
32
|
+
test("minimizes and closes windows", () => {
|
|
33
|
+
const target = createWindow();
|
|
34
|
+
|
|
35
|
+
applyDesktopWindowControl(target.window, "minimize");
|
|
36
|
+
applyDesktopWindowControl(target.window, "close");
|
|
37
|
+
|
|
38
|
+
expect(target.calls).toEqual([{ type: "minimize" }, { type: "close" }]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("toggles maximize from the custom control state", () => {
|
|
42
|
+
const target = createWindow();
|
|
43
|
+
|
|
44
|
+
applyDesktopWindowControl(target.window, "toggle-maximize");
|
|
45
|
+
applyDesktopWindowControl(target.window, "toggle-maximize");
|
|
46
|
+
|
|
47
|
+
expect(target.calls).toEqual([
|
|
48
|
+
{ type: "maximize" },
|
|
49
|
+
{ type: "unmaximize" },
|
|
50
|
+
{ type: "setFrame", frame: { x: 120, y: 80, width: 640, height: 420 } },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type DesktopWindowControlAction = "minimize" | "toggle-maximize" | "close";
|
|
2
|
+
|
|
3
|
+
interface DesktopWindowFrame {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ControllableDesktopWindow {
|
|
11
|
+
frame?: Partial<DesktopWindowFrame> | null;
|
|
12
|
+
close?: () => void;
|
|
13
|
+
minimize?: () => void;
|
|
14
|
+
maximize?: () => void;
|
|
15
|
+
unmaximize?: () => void;
|
|
16
|
+
getFrame?: () => Partial<DesktopWindowFrame> | null;
|
|
17
|
+
setFrame?: (x: number, y: number, width: number, height: number) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const maximizedWindowRestoreFrames = new WeakMap<ControllableDesktopWindow, DesktopWindowFrame | null>();
|
|
21
|
+
|
|
22
|
+
function normalizeFrame(frame: Partial<DesktopWindowFrame> | null | undefined): DesktopWindowFrame | null {
|
|
23
|
+
if (!frame) return null;
|
|
24
|
+
const { x, y, width, height } = frame;
|
|
25
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return { x, y, width, height };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readWindowFrame(window: ControllableDesktopWindow): DesktopWindowFrame | null {
|
|
32
|
+
return normalizeFrame(window.getFrame?.() ?? window.frame);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function restoreWindowFrame(window: ControllableDesktopWindow, frame: DesktopWindowFrame | null): void {
|
|
36
|
+
if (!frame) return;
|
|
37
|
+
window.setFrame?.(frame.x, frame.y, frame.width, frame.height);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function applyDesktopWindowControl(
|
|
41
|
+
window: ControllableDesktopWindow,
|
|
42
|
+
action: DesktopWindowControlAction,
|
|
43
|
+
): void {
|
|
44
|
+
if (action === "close") {
|
|
45
|
+
maximizedWindowRestoreFrames.delete(window);
|
|
46
|
+
window.close?.();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (action === "minimize") {
|
|
51
|
+
window.minimize?.();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const restoreFrame = maximizedWindowRestoreFrames.get(window);
|
|
56
|
+
if (maximizedWindowRestoreFrames.has(window)) {
|
|
57
|
+
maximizedWindowRestoreFrames.delete(window);
|
|
58
|
+
window.unmaximize?.();
|
|
59
|
+
restoreWindowFrame(window, restoreFrame);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
maximizedWindowRestoreFrames.set(window, readWindowFrame(window));
|
|
63
|
+
window.maximize?.();
|
|
64
|
+
}
|