gloomberb 0.9.2 → 0.9.3
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/README.md +23 -2
- package/bin/gloomberb +1 -1
- package/package.json +1 -1
- package/src/app.tsx +10 -7
- package/src/capabilities/registry.ts +1 -0
- package/src/capabilities/types.ts +23 -0
- package/src/cli/commands/api.ts +119 -0
- package/src/cli/commands/automation.ts +173 -0
- package/src/cli/commands/command-utils.ts +53 -0
- package/src/cli/commands/market.ts +527 -0
- package/src/cli/commands/overview.ts +222 -0
- package/src/cli/commands/search.ts +39 -0
- package/src/cli/commands/system.ts +553 -0
- package/src/cli/commands/ticker.ts +104 -12
- package/src/cli/context.ts +35 -2
- package/src/cli/entry.ts +51 -0
- package/src/cli/errors.ts +56 -5
- package/src/cli/index.ts +73 -11
- package/src/cli/options.test.ts +38 -0
- package/src/cli/options.ts +95 -0
- package/src/cli/pane-functions/index.ts +10 -1
- package/src/cli/registry.test.ts +40 -0
- package/src/cli/registry.ts +46 -5
- package/src/cli/result.test.ts +82 -0
- package/src/cli/result.ts +149 -0
- package/src/cli/types.ts +11 -0
- package/src/cli.test.ts +11 -11
- package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
- package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
- package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
- package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
- package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
- package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
- package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
- package/src/components/chart/core/pane-settings.test.ts +27 -0
- package/src/components/chart/core/pane-settings.ts +60 -1
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
- package/src/components/chart/stock-chart/settings.ts +7 -3
- package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
- package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
- package/src/components/command-bar/surface/pane.test.tsx +76 -0
- package/src/components/data-table/view.tsx +13 -2
- package/src/components/feed-data-table/stack-view.tsx +9 -1
- package/src/components/index.ts +1 -1
- package/src/components/input-search-bar.test.tsx +103 -0
- package/src/components/input-search-bar.tsx +16 -0
- package/src/components/layout/shell/active-drag.ts +5 -1
- package/src/components/layout/shell/drag/runtime.ts +21 -3
- package/src/components/layout/shell/fullscreen.ts +10 -0
- package/src/components/layout/shell/index.test.tsx +395 -2
- package/src/components/layout/shell/index.tsx +128 -2
- package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
- package/src/components/layout/shell/pane/layers.tsx +26 -14
- package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
- package/src/components/layout/shell/shortcuts.ts +4 -0
- package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
- package/src/components/layout/shell/window-mode/index.ts +11 -3
- package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
- package/src/components/layout/status-bar.test.tsx +105 -0
- package/src/components/layout/status-bar.tsx +32 -6
- package/src/components/layout/transient-layout.tsx +57 -0
- package/src/components/layout/window-edit/mode.test.ts +76 -5
- package/src/components/layout/window-edit/mode.ts +47 -2
- package/src/components/layout/window-edit/presentation.ts +2 -1
- package/src/components/price-performance.tsx +9 -1
- package/src/core/app-services.ts +7 -4
- package/src/data/config/store/index.ts +1 -2
- package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
- package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
- package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
- package/src/plugins/builtin/news/wire/read-state.ts +9 -32
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
- package/src/plugins/builtin/shared/read-state.ts +43 -0
- package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
- package/src/plugins/builtin/substack/article-detail.tsx +39 -15
- package/src/plugins/builtin/substack/article-stack.tsx +6 -2
- package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
- package/src/plugins/builtin/substack/pane.tsx +8 -3
- package/src/plugins/builtin/substack/read-state.ts +55 -0
- package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
- package/src/plugins/pane-manager/docking.ts +9 -2
- package/src/plugins/pane-manager/queries.ts +8 -2
- package/src/plugins/prediction-markets/controller/index.ts +2 -0
- package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
- package/src/plugins/prediction-markets/pane.test.tsx +32 -0
- package/src/plugins/prediction-markets/pane.tsx +21 -1
- package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
- package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
- package/src/renderers/electrobun/bun/index.ts +13 -0
- package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
- package/src/renderers/electrobun/bun/window/focus.ts +5 -0
- package/src/renderers/electrobun/view/app-services.ts +6 -4
- package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
- package/src/renderers/electrobun/view/host/text.test.ts +22 -0
- package/src/renderers/electrobun/view/host/text.tsx +24 -20
- package/src/renderers/electrobun/view/ui-host.tsx +1 -1
- package/src/renderers/opentui/start.tsx +13 -5
- package/src/state/app/input-capture.ts +12 -0
- package/src/types/plugin.ts +28 -0
- package/src/utils/cli-output.ts +6 -0
- package/src/utils/search-focus-navigation.ts +24 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { usePluginState } from "../../runtime";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_READ_IDS,
|
|
5
|
+
markReadId,
|
|
6
|
+
normalizeReadIds,
|
|
7
|
+
sameStringArray,
|
|
8
|
+
} from "../shared/read-state";
|
|
9
|
+
|
|
10
|
+
export interface SubstackReadState {
|
|
11
|
+
articleIds: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SUBSTACK_READ_STATE_SCHEMA_VERSION = 1;
|
|
15
|
+
const READ_STATE_KEY = "read-articles";
|
|
16
|
+
const EMPTY_READ_STATE: SubstackReadState = { articleIds: [] };
|
|
17
|
+
|
|
18
|
+
export const MAX_READ_SUBSTACK_ARTICLE_IDS = DEFAULT_MAX_READ_IDS;
|
|
19
|
+
|
|
20
|
+
export function normalizeSubstackReadState(state: SubstackReadState): SubstackReadState {
|
|
21
|
+
return {
|
|
22
|
+
articleIds: normalizeReadIds(state.articleIds, MAX_READ_SUBSTACK_ARTICLE_IDS),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function markSubstackArticleRead(
|
|
27
|
+
state: SubstackReadState,
|
|
28
|
+
articleId: string,
|
|
29
|
+
): SubstackReadState {
|
|
30
|
+
const current = normalizeSubstackReadState(state).articleIds;
|
|
31
|
+
const articleIds = markReadId(current, articleId, MAX_READ_SUBSTACK_ARTICLE_IDS);
|
|
32
|
+
|
|
33
|
+
return sameStringArray(articleIds, state.articleIds) ? state : { articleIds };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useSubstackReadState() {
|
|
37
|
+
const [readState, setReadState] = usePluginState<SubstackReadState>(
|
|
38
|
+
READ_STATE_KEY,
|
|
39
|
+
EMPTY_READ_STATE,
|
|
40
|
+
{ schemaVersion: SUBSTACK_READ_STATE_SCHEMA_VERSION },
|
|
41
|
+
);
|
|
42
|
+
const normalizedReadState = useMemo(
|
|
43
|
+
() => normalizeSubstackReadState(readState),
|
|
44
|
+
[readState],
|
|
45
|
+
);
|
|
46
|
+
const readArticleIds = useMemo(
|
|
47
|
+
() => new Set(normalizedReadState.articleIds),
|
|
48
|
+
[normalizedReadState],
|
|
49
|
+
);
|
|
50
|
+
const markArticleRead = useCallback((articleId: string) => {
|
|
51
|
+
setReadState((current) => markSubstackArticleRead(current, articleId));
|
|
52
|
+
}, [setReadState]);
|
|
53
|
+
|
|
54
|
+
return { readArticleIds, markArticleRead };
|
|
55
|
+
}
|
|
@@ -224,6 +224,32 @@ describe("ThirteenFPane", () => {
|
|
|
224
224
|
).toBeUndefined();
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
test("moves from the first fund row to search with Up", async () => {
|
|
228
|
+
setHttpFetchTransport(async (url) => {
|
|
229
|
+
const path = new URL(String(url)).pathname;
|
|
230
|
+
if (path.endsWith("/topfunds")) {
|
|
231
|
+
return json([
|
|
232
|
+
{ cik: "1", name: "Alpha Capital", period_of_report: "2026-03-31", pnl: null },
|
|
233
|
+
]);
|
|
234
|
+
}
|
|
235
|
+
return json([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await act(async () => {
|
|
239
|
+
testSetup = await testRender(<Harness />, { width: 100, height: 20 });
|
|
240
|
+
});
|
|
241
|
+
await renderFrames();
|
|
242
|
+
|
|
243
|
+
await emitKeypress({ name: "up", sequence: "\u001B[A" });
|
|
244
|
+
await act(async () => {
|
|
245
|
+
await testSetup!.mockInput.typeText("BRK");
|
|
246
|
+
await testSetup!.renderOnce();
|
|
247
|
+
});
|
|
248
|
+
await renderFrames(2);
|
|
249
|
+
|
|
250
|
+
expect(testSetup!.captureCharFrame()).toContain("BRK");
|
|
251
|
+
});
|
|
252
|
+
|
|
227
253
|
test("rapid keyboard navigation activates the current rendered row", async () => {
|
|
228
254
|
setHttpFetchTransport(async (url) => {
|
|
229
255
|
const path = new URL(String(url)).pathname;
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
Tabs,
|
|
16
16
|
usePaneFooter,
|
|
17
17
|
type DataTableKeyEvent,
|
|
18
|
+
type DataTableRootKeyContext,
|
|
18
19
|
} from "../../../components";
|
|
19
20
|
import { useShortcut } from "../../../react/input";
|
|
20
21
|
import { usePaneSettingValue } from "../../../state/app/context";
|
|
@@ -23,6 +24,7 @@ import { TICKER_RESEARCH_PANE_ID } from "../../../types/config";
|
|
|
23
24
|
import type { PaneProps } from "../../../types/plugin";
|
|
24
25
|
import { isDetailBackNavigationKey } from "../../../utils/back-navigation";
|
|
25
26
|
import { isPlainKey } from "../../../utils/keyboard";
|
|
27
|
+
import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
|
|
26
28
|
import { truncateWithEllipsis } from "../../../utils/text-wrap";
|
|
27
29
|
import { usePluginPaneState, usePluginTickerActions } from "../../runtime";
|
|
28
30
|
import { loadBrowserRows, loadFilingPositions, loadFundDetail } from "./data";
|
|
@@ -303,6 +305,7 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
|
|
|
303
305
|
normalizeValue={trimSearchValue}
|
|
304
306
|
onFocus={focusSearch}
|
|
305
307
|
onBlur={blurSearch}
|
|
308
|
+
onNavigateDown={blurSearch}
|
|
306
309
|
onQueryChange={updateQuery}
|
|
307
310
|
/>
|
|
308
311
|
</Box>
|
|
@@ -312,7 +315,15 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
|
|
|
312
315
|
? "Loading 13F funds..."
|
|
313
316
|
: error ?? warning ?? "No 13F funds found.";
|
|
314
317
|
|
|
315
|
-
const handleRootKeyDown = useCallback((
|
|
318
|
+
const handleRootKeyDown = useCallback((
|
|
319
|
+
event: DataTableKeyEvent,
|
|
320
|
+
context: DataTableRootKeyContext,
|
|
321
|
+
) => {
|
|
322
|
+
if (context.selectedIndex <= 0 && isPlainArrowUp(event)) {
|
|
323
|
+
stopSearchFocusNavigation(event);
|
|
324
|
+
focusSearch();
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
316
327
|
if (event.name === "r") {
|
|
317
328
|
event.preventDefault?.();
|
|
318
329
|
event.stopPropagation?.();
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
findDockLeaf,
|
|
7
7
|
getNodeAtPath,
|
|
8
8
|
replaceNodeAtPath,
|
|
9
|
+
type DockGeometryOptions,
|
|
9
10
|
type DockTarget,
|
|
10
11
|
type LayoutBounds,
|
|
11
12
|
} from "./dock-tree";
|
|
@@ -116,11 +117,17 @@ export function applyDrop(layout: LayoutConfig, draggedId: string, dropTarget: D
|
|
|
116
117
|
return insertRelativeToLeaf(layout, draggedId, dropTarget.targetId, direction);
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
export function simulateDrop(
|
|
120
|
+
export function simulateDrop(
|
|
121
|
+
layout: LayoutConfig,
|
|
122
|
+
draggedId: string,
|
|
123
|
+
dropTarget: DropTarget,
|
|
124
|
+
bounds: LayoutBounds,
|
|
125
|
+
options?: DockGeometryOptions,
|
|
126
|
+
): LayoutSimulation {
|
|
120
127
|
const nextLayout = applyDrop(layout, draggedId, dropTarget);
|
|
121
128
|
return {
|
|
122
129
|
layout: nextLayout,
|
|
123
|
-
previewRect: getLeafRect(nextLayout, draggedId, bounds),
|
|
130
|
+
previewRect: getLeafRect(nextLayout, draggedId, bounds, options),
|
|
124
131
|
};
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
countColumnsFromGeometry,
|
|
11
11
|
findDockLeaf,
|
|
12
12
|
getDockLeafLayouts,
|
|
13
|
+
type DockGeometryOptions,
|
|
13
14
|
type LayoutBounds,
|
|
14
15
|
} from "./dock-tree";
|
|
15
16
|
|
|
@@ -20,8 +21,13 @@ export interface ResolvedPane {
|
|
|
20
21
|
path?: Array<0 | 1>;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
export function getLeafRect(
|
|
24
|
-
|
|
24
|
+
export function getLeafRect(
|
|
25
|
+
layout: LayoutConfig,
|
|
26
|
+
instanceId: string,
|
|
27
|
+
bounds: LayoutBounds,
|
|
28
|
+
options?: DockGeometryOptions,
|
|
29
|
+
): LayoutBounds | null {
|
|
30
|
+
return getDockLeafLayouts(layout, bounds, options).find((entry) => entry.instanceId === instanceId)?.rect ?? null;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export function resolveDocked(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { type InputRenderable, type ScrollBoxRenderable } from "../../../ui";
|
|
3
3
|
import { useViewport } from "../../../react/input";
|
|
4
|
+
import { useAppInputCapture } from "../../../state/app/input-capture";
|
|
4
5
|
import { usePaneInstance } from "../../../state/app/context";
|
|
5
6
|
import {
|
|
6
7
|
useDebouncedPluginPaneState,
|
|
@@ -101,6 +102,7 @@ export function usePredictionMarketsController({
|
|
|
101
102
|
const [searchFocused, setSearchFocused] = useState(false);
|
|
102
103
|
const [initialParamsApplied, setInitialParamsApplied] = useState(false);
|
|
103
104
|
const appViewport = useViewport();
|
|
105
|
+
useAppInputCapture(focused && searchFocused);
|
|
104
106
|
|
|
105
107
|
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
106
108
|
const headerScrollRef = useRef<ScrollBoxRenderable>(null);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, type RefObject } from "react";
|
|
2
2
|
import { type ScrollBoxRenderable } from "../../../ui";
|
|
3
3
|
import { useShortcut } from "../../../react/input";
|
|
4
|
+
import { isPlainArrowDown, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
|
|
4
5
|
import { getAdjacentPredictionCategoryId } from "../categories";
|
|
5
6
|
import { resolvePredictionKeyboardCommand } from "../keyboard";
|
|
6
7
|
import { getAdjacentPredictionVenueScope } from "../navigation";
|
|
@@ -16,7 +17,14 @@ import type {
|
|
|
16
17
|
interface PredictionKeyboardEvent {
|
|
17
18
|
name?: string;
|
|
18
19
|
sequence?: string;
|
|
20
|
+
ctrl?: boolean;
|
|
21
|
+
meta?: boolean;
|
|
22
|
+
super?: boolean;
|
|
23
|
+
alt?: boolean;
|
|
24
|
+
option?: boolean;
|
|
19
25
|
shift?: boolean;
|
|
26
|
+
defaultPrevented?: boolean;
|
|
27
|
+
propagationStopped?: boolean;
|
|
20
28
|
preventDefault?: () => void;
|
|
21
29
|
stopPropagation?: () => void;
|
|
22
30
|
}
|
|
@@ -62,6 +70,25 @@ export function usePredictionControllerKeyboard({
|
|
|
62
70
|
setVenue,
|
|
63
71
|
toggleWatchlist,
|
|
64
72
|
}: UsePredictionControllerKeyboardParams) {
|
|
73
|
+
const handleSearchNavigation = useCallback(
|
|
74
|
+
(event: PredictionKeyboardEvent) => {
|
|
75
|
+
if (!focused || !searchFocused) return;
|
|
76
|
+
|
|
77
|
+
if (isPlainArrowDown(event)) {
|
|
78
|
+
stopSearchFocusNavigation(event);
|
|
79
|
+
blurSearch();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (resolvePredictionKeyboardCommand(event) === "escape") {
|
|
84
|
+
event.stopPropagation?.();
|
|
85
|
+
event.preventDefault?.();
|
|
86
|
+
blurSearch();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[blurSearch, focused, searchFocused],
|
|
90
|
+
);
|
|
91
|
+
|
|
65
92
|
const cycleDetailOutcome = useCallback(
|
|
66
93
|
(direction: "previous" | "next") => {
|
|
67
94
|
if (detailTab !== "overview" || sortedOutcomeMarkets.length === 0) {
|
|
@@ -98,15 +125,11 @@ export function usePredictionControllerKeyboard({
|
|
|
98
125
|
|
|
99
126
|
const handleKeyboard = useCallback(
|
|
100
127
|
(event: PredictionKeyboardEvent) => {
|
|
128
|
+
if (event.defaultPrevented || event.propagationStopped) return;
|
|
101
129
|
if (!focused) return;
|
|
102
130
|
const command = resolvePredictionKeyboardCommand(event);
|
|
103
131
|
|
|
104
132
|
if (searchFocused) {
|
|
105
|
-
if (command === "escape") {
|
|
106
|
-
event.stopPropagation?.();
|
|
107
|
-
event.preventDefault?.();
|
|
108
|
-
blurSearch();
|
|
109
|
-
}
|
|
110
133
|
return;
|
|
111
134
|
}
|
|
112
135
|
|
|
@@ -216,5 +239,11 @@ export function usePredictionControllerKeyboard({
|
|
|
216
239
|
],
|
|
217
240
|
);
|
|
218
241
|
|
|
242
|
+
useShortcut(handleSearchNavigation, {
|
|
243
|
+
allowEditable: true,
|
|
244
|
+
enabled: focused && searchFocused,
|
|
245
|
+
phase: "before",
|
|
246
|
+
});
|
|
247
|
+
|
|
219
248
|
useShortcut(handleKeyboard);
|
|
220
249
|
}
|
|
@@ -265,6 +265,38 @@ describe("prediction markets pane interactions", () => {
|
|
|
265
265
|
expect(frame).not.toContain("\u2190 Back");
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
+
test("moves focus between search and the market table with arrows", async () => {
|
|
269
|
+
installPredictionMarketMocks();
|
|
270
|
+
|
|
271
|
+
testSetup = await testRender(<Harness />, { width: 120, height: 34 });
|
|
272
|
+
await flushFrames(testSetup);
|
|
273
|
+
|
|
274
|
+
await emitKeypress(testSetup, { name: "j", sequence: "j" });
|
|
275
|
+
await flushFrames(testSetup, 1);
|
|
276
|
+
|
|
277
|
+
await emitKeypress(testSetup, { name: "up", sequence: "\u001b[A" });
|
|
278
|
+
await flushFrames(testSetup);
|
|
279
|
+
|
|
280
|
+
let frame = testSetup.captureCharFrame();
|
|
281
|
+
expect(frame).toContain("/ search markets");
|
|
282
|
+
|
|
283
|
+
await emitKeypress(testSetup, { name: "up", sequence: "\u001b[A" });
|
|
284
|
+
await flushFrames(testSetup);
|
|
285
|
+
|
|
286
|
+
frame = testSetup.captureCharFrame();
|
|
287
|
+
expect(frame).toContain("? search markets");
|
|
288
|
+
|
|
289
|
+
await emitKeypress(testSetup, {
|
|
290
|
+
name: "down",
|
|
291
|
+
sequence: "\u001b[B",
|
|
292
|
+
targetEditable: true,
|
|
293
|
+
});
|
|
294
|
+
await flushFrames(testSetup);
|
|
295
|
+
|
|
296
|
+
frame = testSetup.captureCharFrame();
|
|
297
|
+
expect(frame).toContain("/ search markets");
|
|
298
|
+
});
|
|
299
|
+
|
|
268
300
|
test("supports detail outcome navigation and escape return from the keyboard", async () => {
|
|
269
301
|
attachPredictionMarketsPersistence(new MemoryPersistence());
|
|
270
302
|
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Box, Input, Text } from "../../ui";
|
|
2
2
|
import { useCallback, useMemo, useRef } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DataTableStackView,
|
|
5
|
+
Tabs,
|
|
6
|
+
usePaneFooter,
|
|
7
|
+
type DataTableKeyEvent,
|
|
8
|
+
type DataTableRootKeyContext,
|
|
9
|
+
} from "../../components";
|
|
4
10
|
import { createRowValueCache } from "../../components/ui/row-value-cache";
|
|
5
11
|
import type { PaneProps } from "../../types/plugin";
|
|
6
12
|
import { colors } from "../../theme/colors";
|
|
@@ -9,6 +15,7 @@ import { usePredictionMarketsController } from "./controller";
|
|
|
9
15
|
import { PredictionMarketDetailPane } from "./detail/pane";
|
|
10
16
|
import { getPredictionColumnValue } from "./metrics";
|
|
11
17
|
import { BROWSE_TABS, VENUE_TABS } from "./navigation";
|
|
18
|
+
import { isPlainArrowUp, stopSearchFocusNavigation } from "../../utils/search-focus-navigation";
|
|
12
19
|
import type {
|
|
13
20
|
PredictionBrowseTab,
|
|
14
21
|
PredictionCategoryId,
|
|
@@ -217,6 +224,18 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
|
|
|
217
224
|
watchlistedRowKeys,
|
|
218
225
|
]);
|
|
219
226
|
|
|
227
|
+
const handleRootKeyDown = useCallback((
|
|
228
|
+
event: DataTableKeyEvent,
|
|
229
|
+
context: DataTableRootKeyContext,
|
|
230
|
+
) => {
|
|
231
|
+
if (context.selectedIndex <= 0 && isPlainArrowUp(event)) {
|
|
232
|
+
stopSearchFocusNavigation(event);
|
|
233
|
+
controller.actions.focusSearch();
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}, [controller.actions.focusSearch]);
|
|
238
|
+
|
|
220
239
|
const detailContent =
|
|
221
240
|
controller.selectedSummary && controller.selectedRow ? (
|
|
222
241
|
<Box
|
|
@@ -271,6 +290,7 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
|
|
|
271
290
|
}}
|
|
272
291
|
onActivate={(row) =>
|
|
273
292
|
controller.actions.openSelectedRow(row.key)}
|
|
293
|
+
onRootKeyDown={handleRootKeyDown}
|
|
274
294
|
columns={controller.visibleColumns}
|
|
275
295
|
items={controller.visibleRows}
|
|
276
296
|
sortColumnId={controller.sortPreference.columnId}
|
|
@@ -30,6 +30,15 @@ describe("applicationMenuCommand", () => {
|
|
|
30
30
|
})).toEqual({ type: "open-devtools" });
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
test("parses the native quit command", () => {
|
|
34
|
+
expect(applicationMenuCommand({
|
|
35
|
+
data: {
|
|
36
|
+
action: ELECTROBUN_APPLICATION_MENU_ACTION,
|
|
37
|
+
data: { type: "quit" },
|
|
38
|
+
},
|
|
39
|
+
})).toEqual({ type: "quit" });
|
|
40
|
+
});
|
|
41
|
+
|
|
33
42
|
test("ignores unknown command types", () => {
|
|
34
43
|
expect(applicationMenuCommand({
|
|
35
44
|
data: {
|
|
@@ -9,4 +9,13 @@ describe("desktop application menu", () => {
|
|
|
9
9
|
test("keeps the native application menu on macOS", () => {
|
|
10
10
|
expect(buildDesktopApplicationMenu("darwin")).toEqual(buildApplicationMenu());
|
|
11
11
|
});
|
|
12
|
+
|
|
13
|
+
test("routes macOS quit through an explicit command item", () => {
|
|
14
|
+
const appMenu = buildApplicationMenu()[0]?.submenu;
|
|
15
|
+
expect(appMenu?.at(-1)).toMatchObject({
|
|
16
|
+
label: "Quit Gloomberb",
|
|
17
|
+
accelerator: "CmdOrCtrl+Q",
|
|
18
|
+
data: { type: "quit" },
|
|
19
|
+
});
|
|
20
|
+
});
|
|
12
21
|
});
|
|
@@ -6,7 +6,8 @@ const GITHUB_ISSUE_URL = "https://github.com/vincelwt/gloomberb/issues/new/choos
|
|
|
6
6
|
|
|
7
7
|
export type ElectrobunApplicationMenuCommand =
|
|
8
8
|
| DesktopApplicationMenuCommand
|
|
9
|
-
| { type: "open-devtools" }
|
|
9
|
+
| { type: "open-devtools" }
|
|
10
|
+
| { type: "quit" };
|
|
10
11
|
|
|
11
12
|
function commandItem(
|
|
12
13
|
label: string,
|
|
@@ -38,7 +39,7 @@ export function buildApplicationMenu(): ApplicationMenuItemConfig[] {
|
|
|
38
39
|
{ role: "hideOthers" },
|
|
39
40
|
{ role: "showAll" },
|
|
40
41
|
{ type: "divider" },
|
|
41
|
-
{
|
|
42
|
+
commandItem("Quit Gloomberb", { type: "quit" }, { accelerator: "CmdOrCtrl+Q" }),
|
|
42
43
|
],
|
|
43
44
|
},
|
|
44
45
|
{
|
|
@@ -281,6 +281,15 @@ function closeAllDetachedWindows(): void {
|
|
|
281
281
|
detachedWindowManager.closeAll();
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
function quitDesktopApp(): void {
|
|
285
|
+
closeAllDetachedWindows();
|
|
286
|
+
teardownServices();
|
|
287
|
+
const window = mainWindow;
|
|
288
|
+
mainWindow = null;
|
|
289
|
+
window?.close();
|
|
290
|
+
Utils.quit();
|
|
291
|
+
}
|
|
292
|
+
|
|
284
293
|
function controlWindowForRpcKey(windowKey: string | undefined, action: DesktopWindowControlAction): boolean {
|
|
285
294
|
const targetWindow = windowKey === MAIN_WINDOW_RPC_KEY
|
|
286
295
|
? mainWindow
|
|
@@ -446,6 +455,10 @@ ApplicationMenu.on("application-menu-clicked", (event: unknown) => {
|
|
|
446
455
|
openMainWindowDevTools();
|
|
447
456
|
return;
|
|
448
457
|
}
|
|
458
|
+
if (command.type === "quit") {
|
|
459
|
+
quitDesktopApp();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
449
462
|
if (!isWindowRpcReady(MAIN_WINDOW_RPC_KEY)) return;
|
|
450
463
|
getWindowRpc(MAIN_WINDOW_RPC_KEY)?.send["application-menu.select"]({ command });
|
|
451
464
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { detachedRpcKey, focusWindowForRpcKey, MAIN_WINDOW_RPC_KEY, type FocusableElectrobunWindow } from "./focus";
|
|
3
|
+
|
|
4
|
+
describe("Electrobun window focus routing", () => {
|
|
5
|
+
test("uses activate for the main window", () => {
|
|
6
|
+
const calls: string[] = [];
|
|
7
|
+
const mainWindow: FocusableElectrobunWindow = {
|
|
8
|
+
activate: () => calls.push("activate"),
|
|
9
|
+
focus: () => calls.push("focus"),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
expect(focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY, mainWindow, new Map())).toBe(true);
|
|
13
|
+
expect(calls).toEqual(["activate"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("uses activate for detached windows", () => {
|
|
17
|
+
const calls: string[] = [];
|
|
18
|
+
const detachedWindows = new Map<string, FocusableElectrobunWindow>([
|
|
19
|
+
["pane:1", { activate: () => calls.push("detached") }],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
expect(focusWindowForRpcKey(detachedRpcKey("pane:1"), null, detachedWindows)).toBe(true);
|
|
23
|
+
expect(calls).toEqual(["detached"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("falls back to focus for legacy window objects", () => {
|
|
27
|
+
const calls: string[] = [];
|
|
28
|
+
const mainWindow: FocusableElectrobunWindow = {
|
|
29
|
+
focus: () => calls.push("focus"),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
expect(focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY, mainWindow, new Map())).toBe(true);
|
|
33
|
+
expect(calls).toEqual(["focus"]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -3,6 +3,7 @@ export const MAIN_WINDOW_RPC_KEY = "main";
|
|
|
3
3
|
const DETACHED_WINDOW_RPC_PREFIX = "detached:";
|
|
4
4
|
|
|
5
5
|
export interface FocusableElectrobunWindow {
|
|
6
|
+
activate?: () => void;
|
|
6
7
|
focus?: () => void;
|
|
7
8
|
}
|
|
8
9
|
|
|
@@ -31,6 +32,10 @@ export function focusWindowForRpcKey(
|
|
|
31
32
|
detachedWindows: ReadonlyMap<string, FocusableElectrobunWindow>,
|
|
32
33
|
): boolean {
|
|
33
34
|
const window = resolveWindowForRpcKey(rpcKey, mainWindow, detachedWindows);
|
|
35
|
+
if (window?.activate) {
|
|
36
|
+
window.activate();
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
34
39
|
if (!window?.focus) return false;
|
|
35
40
|
window.focus();
|
|
36
41
|
return true;
|
|
@@ -7,7 +7,7 @@ import type { AppServices } from "../../../core/app-services";
|
|
|
7
7
|
import type { AppConfig } from "../../../types/config";
|
|
8
8
|
import { newsProvider } from "../../../capabilities";
|
|
9
9
|
import { debugLog } from "../../../utils/debug-log";
|
|
10
|
-
import { measurePerf } from "../../../utils/perf-marks";
|
|
10
|
+
import { measurePerf, measurePerfAsync } from "../../../utils/perf-marks";
|
|
11
11
|
import { getRendererBuiltinPlugins } from "../../../plugins/catalog-ui";
|
|
12
12
|
import { createRemoteAssetDataClient } from "./remote/asset-data-client";
|
|
13
13
|
import { RemotePersistence } from "./remote/persistence";
|
|
@@ -47,10 +47,11 @@ export function createAppServices({ config }: { config: AppConfig }): AppService
|
|
|
47
47
|
}));
|
|
48
48
|
|
|
49
49
|
const plugins = getRendererBuiltinPlugins();
|
|
50
|
+
const pluginReadyPromises: Promise<void>[] = [];
|
|
50
51
|
for (const plugin of plugins) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
pluginReadyPromises.push(measurePerfAsync("startup.services.register-plugin", () => (
|
|
53
|
+
pluginRegistry.register(plugin)
|
|
54
|
+
), { pluginId: plugin.id }));
|
|
54
55
|
}
|
|
55
56
|
measurePerf("startup.services.news-start", () => {
|
|
56
57
|
newsService.start();
|
|
@@ -65,6 +66,7 @@ export function createAppServices({ config }: { config: AppConfig }): AppService
|
|
|
65
66
|
marketData,
|
|
66
67
|
pluginRegistry,
|
|
67
68
|
newsService,
|
|
69
|
+
ready: Promise.all(pluginReadyPromises).then(() => {}),
|
|
68
70
|
destroy() {
|
|
69
71
|
setSharedMarketDataCoordinator(null);
|
|
70
72
|
setSharedNewsService(null);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { renderAsciiText, type AsciiFontName } from "../../../../ui/ascii-font";
|
|
2
|
+
|
|
3
|
+
const WEB_GLOOMBERB_WORDMARK = [
|
|
4
|
+
" ____ _ _ _ ",
|
|
5
|
+
" / ___| | ___ ___ _ __ ___ | |__ ___ _ __| |__ ",
|
|
6
|
+
"| | _| |/ _ \\ / _ \\| '_ ` _ \\| '_ \\ / _ \\ '__| '_ \\ ",
|
|
7
|
+
"| |_| | | (_) | (_) | | | | | | |_) | __/ | | |_) |",
|
|
8
|
+
" \\____|_|\\___/ \\___/|_| |_| |_|_.__/ \\___|_| |_.__/ ",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
type WebWordmarkVariant = "legacy" | "compat" | null;
|
|
12
|
+
|
|
13
|
+
function currentDesktopPlatform(): string {
|
|
14
|
+
const navigatorLike = (globalThis as {
|
|
15
|
+
navigator?: {
|
|
16
|
+
platform?: string;
|
|
17
|
+
userAgent?: string;
|
|
18
|
+
userAgentData?: { platform?: string };
|
|
19
|
+
};
|
|
20
|
+
}).navigator;
|
|
21
|
+
return [
|
|
22
|
+
navigatorLike?.userAgentData?.platform,
|
|
23
|
+
navigatorLike?.platform,
|
|
24
|
+
navigatorLike?.userAgent,
|
|
25
|
+
].filter(Boolean).join(" ");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isMacDesktopPlatform(desktopPlatform?: string): boolean {
|
|
29
|
+
const raw = desktopPlatform?.trim() || currentDesktopPlatform();
|
|
30
|
+
return /(darwin|mac|iphone|ipad|ipod)/i.test(raw);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function webAsciiTextWordmarkVariant(
|
|
34
|
+
text: string,
|
|
35
|
+
font: AsciiFontName = "tiny",
|
|
36
|
+
desktopPlatform?: string,
|
|
37
|
+
): WebWordmarkVariant {
|
|
38
|
+
if (font !== "wordmark" || text.trim().toLowerCase() !== "gloomberb") return null;
|
|
39
|
+
return isMacDesktopPlatform(desktopPlatform) ? "legacy" : "compat";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function webAsciiTextLines(
|
|
43
|
+
text: string,
|
|
44
|
+
font: AsciiFontName = "tiny",
|
|
45
|
+
desktopPlatform?: string,
|
|
46
|
+
): string[] {
|
|
47
|
+
return webAsciiTextWordmarkVariant(text, font, desktopPlatform) === "compat"
|
|
48
|
+
? WEB_GLOOMBERB_WORDMARK
|
|
49
|
+
: renderAsciiText(text, font);
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { renderAsciiText } from "../../../../ui/ascii-font";
|
|
3
|
+
import { webAsciiTextLines, webAsciiTextWordmarkVariant } from "./ascii-text";
|
|
4
|
+
|
|
5
|
+
describe("desktop web ASCII text", () => {
|
|
6
|
+
test("keeps the legacy Gloomberb wordmark on macOS", () => {
|
|
7
|
+
expect(webAsciiTextWordmarkVariant("Gloomberb", "wordmark", "darwin")).toBe("legacy");
|
|
8
|
+
expect(webAsciiTextWordmarkVariant("Gloomberb", "wordmark", "MacIntel")).toBe("legacy");
|
|
9
|
+
expect(webAsciiTextLines("Gloomberb", "wordmark", "darwin")).toEqual(
|
|
10
|
+
renderAsciiText("Gloomberb", "wordmark"),
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("uses the web-safe Gloomberb wordmark off macOS", () => {
|
|
15
|
+
const lines = webAsciiTextLines("Gloomberb", "wordmark", "win32");
|
|
16
|
+
|
|
17
|
+
expect(webAsciiTextWordmarkVariant("Gloomberb", "wordmark", "win32")).toBe("compat");
|
|
18
|
+
expect(lines).toHaveLength(5);
|
|
19
|
+
expect(lines[0]).toContain("____");
|
|
20
|
+
expect(lines).not.toEqual(renderAsciiText("Gloomberb", "wordmark"));
|
|
21
|
+
});
|
|
22
|
+
});
|