gloomberb 0.7.0 → 0.7.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 +1 -1
- package/src/app.tsx +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +63 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +42 -0
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +6 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, ContextMenuProvider, useNativeRenderer, useRendererHost } from "./ui";
|
|
2
2
|
import { ToastViewport, useToastHost } from "./ui/toast";
|
|
3
|
-
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
3
|
+
import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } from "react";
|
|
4
4
|
import { useShortcut } from "./react/input";
|
|
5
5
|
import {
|
|
6
6
|
AppProvider,
|
|
@@ -23,7 +23,7 @@ import { OnboardingWizard } from "./components/onboarding/onboarding-wizard";
|
|
|
23
23
|
import { useDialog, useDialogState } from "./ui/dialog";
|
|
24
24
|
import { PluginRegistry } from "./plugins/registry";
|
|
25
25
|
import type { TickerRepository } from "./data/ticker-repository";
|
|
26
|
-
import {
|
|
26
|
+
import { ThemeProvider, useThemeColors } from "./theme/theme-context";
|
|
27
27
|
import {
|
|
28
28
|
createPaneInstance,
|
|
29
29
|
findPaneInstance,
|
|
@@ -45,7 +45,7 @@ import type { TickerRecord } from "./types/ticker";
|
|
|
45
45
|
import type { DataProvider } from "./types/data-provider";
|
|
46
46
|
import type { TickerFinancials } from "./types/financials";
|
|
47
47
|
import type { BrokerAccount } from "./types/trading";
|
|
48
|
-
import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopWindowBridge } from "./types/desktop-window";
|
|
48
|
+
import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "./types/desktop-window";
|
|
49
49
|
import type { DesktopApplicationMenuBridge } from "./types/desktop-menu";
|
|
50
50
|
import { resolveTickerSearch, upsertTickerFromSearchResult } from "./utils/ticker-search";
|
|
51
51
|
|
|
@@ -95,6 +95,10 @@ import {
|
|
|
95
95
|
createPaneTemplateOrThrow,
|
|
96
96
|
} from "./components/command-bar/workflow-ops";
|
|
97
97
|
import { getPaneTemplateDisplayLabel } from "./components/command-bar/pane-template-display";
|
|
98
|
+
import {
|
|
99
|
+
resolveTickerNavigationDetailPane,
|
|
100
|
+
shouldFocusTickerNavigationTarget,
|
|
101
|
+
} from "./plugins/ticker-navigation";
|
|
98
102
|
import { debugLog } from "./utils/debug-log";
|
|
99
103
|
import type { MarketDataCoordinator } from "./market-data/coordinator";
|
|
100
104
|
import { instrumentFromTicker } from "./market-data/request-types";
|
|
@@ -136,6 +140,15 @@ interface AppInnerProps {
|
|
|
136
140
|
desktopApplicationMenuBridge?: DesktopApplicationMenuBridge;
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
function ThemedAppRoot({ children }: { children: ReactNode }) {
|
|
144
|
+
const themeColors = useThemeColors();
|
|
145
|
+
return (
|
|
146
|
+
<Box flexDirection="column" flexGrow={1} backgroundColor={themeColors.bg}>
|
|
147
|
+
{children}
|
|
148
|
+
</Box>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
139
152
|
function AppInner({
|
|
140
153
|
pluginRegistry,
|
|
141
154
|
tickerRepository,
|
|
@@ -511,6 +524,96 @@ function AppInner({
|
|
|
511
524
|
});
|
|
512
525
|
}, [performRefreshQuote]);
|
|
513
526
|
|
|
527
|
+
const refreshTickersBatch = useCallback((entries: Array<{ ticker: TickerRecord; priority: number }>) => {
|
|
528
|
+
const runnable = entries.filter(({ ticker }) => {
|
|
529
|
+
const symbol = ticker.metadata.ticker;
|
|
530
|
+
return !refreshInFlight.has(symbol) && !pendingRefreshesRef.current.financials.has(symbol);
|
|
531
|
+
});
|
|
532
|
+
if (runnable.length === 0) return;
|
|
533
|
+
for (const { ticker } of runnable) {
|
|
534
|
+
pendingRefreshesRef.current.financials.add(ticker.metadata.ticker);
|
|
535
|
+
}
|
|
536
|
+
const priority = Math.min(...runnable.map((entry) => entry.priority));
|
|
537
|
+
refreshQueueRef.current.queue.enqueue({
|
|
538
|
+
key: `financials-batch:${priority}:${runnable.map(({ ticker }) => ticker.metadata.ticker).join(",")}`,
|
|
539
|
+
priority,
|
|
540
|
+
run: async () => {
|
|
541
|
+
const instrumentEntries = runnable.flatMap(({ ticker }) => {
|
|
542
|
+
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
|
|
543
|
+
return instrument ? [{ ticker, instrument }] : [];
|
|
544
|
+
});
|
|
545
|
+
for (const { ticker } of runnable) {
|
|
546
|
+
const symbol = ticker.metadata.ticker;
|
|
547
|
+
refreshInFlight.add(symbol);
|
|
548
|
+
dispatch({ type: "SET_REFRESHING", symbol, refreshing: true });
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
const entries = await marketData.loadSnapshotsBatch(instrumentEntries.map((entry) => entry.instrument));
|
|
552
|
+
entries.forEach((entry, index) => {
|
|
553
|
+
const ticker = instrumentEntries[index]?.ticker;
|
|
554
|
+
const data = entry.data ?? entry.lastGoodData;
|
|
555
|
+
if (ticker && data) {
|
|
556
|
+
pluginRegistry.events.emit("ticker:refreshed", { symbol: ticker.metadata.ticker, financials: data });
|
|
557
|
+
const currency = data.quote?.currency;
|
|
558
|
+
if (currency) void marketData.loadFxRate(currency).catch(() => {});
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
void marketData.loadFxRate(state.config.baseCurrency).catch(() => {});
|
|
562
|
+
} finally {
|
|
563
|
+
for (const { ticker } of runnable) {
|
|
564
|
+
const symbol = ticker.metadata.ticker;
|
|
565
|
+
refreshInFlight.delete(symbol);
|
|
566
|
+
pendingRefreshesRef.current.financials.delete(symbol);
|
|
567
|
+
dispatch({ type: "SET_REFRESHING", symbol, refreshing: false });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
}, [dispatch, marketData, pluginRegistry.events, state.config.baseCurrency]);
|
|
573
|
+
|
|
574
|
+
const refreshQuotesBatch = useCallback((entries: Array<{ ticker: TickerRecord; priority: number }>) => {
|
|
575
|
+
const runnable = entries.filter(({ ticker }) => {
|
|
576
|
+
const symbol = ticker.metadata.ticker;
|
|
577
|
+
return !refreshInFlight.has(symbol)
|
|
578
|
+
&& !quoteRefreshInFlight.has(symbol)
|
|
579
|
+
&& !pendingRefreshesRef.current.financials.has(symbol)
|
|
580
|
+
&& !pendingRefreshesRef.current.quotes.has(symbol);
|
|
581
|
+
});
|
|
582
|
+
if (runnable.length === 0) return;
|
|
583
|
+
for (const { ticker } of runnable) {
|
|
584
|
+
pendingRefreshesRef.current.quotes.add(ticker.metadata.ticker);
|
|
585
|
+
}
|
|
586
|
+
const priority = Math.min(...runnable.map((entry) => entry.priority));
|
|
587
|
+
refreshQueueRef.current.queue.enqueue({
|
|
588
|
+
key: `quotes-batch:${priority}:${runnable.map(({ ticker }) => ticker.metadata.ticker).join(",")}`,
|
|
589
|
+
priority,
|
|
590
|
+
run: async () => {
|
|
591
|
+
const instrumentEntries = runnable.flatMap(({ ticker }) => {
|
|
592
|
+
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
|
|
593
|
+
return instrument ? [{ ticker, instrument }] : [];
|
|
594
|
+
});
|
|
595
|
+
for (const { ticker } of runnable) {
|
|
596
|
+
quoteRefreshInFlight.add(ticker.metadata.ticker);
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
const entries = await marketData.loadQuotesBatch(instrumentEntries.map((entry) => entry.instrument));
|
|
600
|
+
entries.forEach((entry) => {
|
|
601
|
+
const quote = entry.data ?? entry.lastGoodData;
|
|
602
|
+
const currency = quote?.currency;
|
|
603
|
+
if (currency) void marketData.loadFxRate(currency).catch(() => {});
|
|
604
|
+
});
|
|
605
|
+
void marketData.loadFxRate(state.config.baseCurrency).catch(() => {});
|
|
606
|
+
} finally {
|
|
607
|
+
for (const { ticker } of runnable) {
|
|
608
|
+
const symbol = ticker.metadata.ticker;
|
|
609
|
+
quoteRefreshInFlight.delete(symbol);
|
|
610
|
+
pendingRefreshesRef.current.quotes.delete(symbol);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
}, [marketData, state.config.baseCurrency]);
|
|
616
|
+
|
|
514
617
|
const primeCachedFinancials = useCallback((entries: Array<{ ticker: TickerRecord; financials: TickerFinancials }>) => {
|
|
515
618
|
const primeEntries = entries.flatMap(({ ticker, financials }) => {
|
|
516
619
|
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
|
|
@@ -721,6 +824,8 @@ function AppInner({
|
|
|
721
824
|
primeCachedFinancials,
|
|
722
825
|
refreshTicker,
|
|
723
826
|
refreshQuote,
|
|
827
|
+
refreshTickersBatch,
|
|
828
|
+
refreshQuotesBatch,
|
|
724
829
|
autoImportBrokerPositions,
|
|
725
830
|
persistedBrokerAccounts,
|
|
726
831
|
}), {
|
|
@@ -732,7 +837,7 @@ function AppInner({
|
|
|
732
837
|
// Will show empty state
|
|
733
838
|
}
|
|
734
839
|
})();
|
|
735
|
-
}, [autoImportBrokerPositions, dataProvider, dispatch, primeCachedFinancials, tickerRepository, refreshQuote, refreshTicker, sessionSnapshot, state.config, state.initialized]);
|
|
840
|
+
}, [autoImportBrokerPositions, dataProvider, dispatch, primeCachedFinancials, tickerRepository, refreshQuote, refreshQuotesBatch, refreshTicker, refreshTickersBatch, sessionSnapshot, state.config, state.initialized]);
|
|
736
841
|
|
|
737
842
|
const focusedTickerSymbol = getFocusedTickerSymbol(state);
|
|
738
843
|
|
|
@@ -1273,8 +1378,9 @@ function AppInner({
|
|
|
1273
1378
|
activatePane(instance.instanceId, nextLayout);
|
|
1274
1379
|
};
|
|
1275
1380
|
|
|
1276
|
-
pluginRegistry.navigateTickerFn = (rawSymbol) => {
|
|
1381
|
+
pluginRegistry.navigateTickerFn = (rawSymbol, options) => {
|
|
1277
1382
|
if (isDetachedWindow) return;
|
|
1383
|
+
const sourcePaneId = options?.sourcePaneId ?? stateRef.current.focusedPaneId;
|
|
1278
1384
|
(async () => {
|
|
1279
1385
|
try {
|
|
1280
1386
|
// Resolve or create the ticker in the local database
|
|
@@ -1297,45 +1403,31 @@ function AppInner({
|
|
|
1297
1403
|
}
|
|
1298
1404
|
}
|
|
1299
1405
|
|
|
1300
|
-
// Active panel resolution — navigate the
|
|
1301
|
-
// 1. If the
|
|
1302
|
-
// 2. If a ticker-detail follows the
|
|
1406
|
+
// Active panel resolution — navigate the initiating or linked detail pane:
|
|
1407
|
+
// 1. If the source pane IS a ticker-detail, retarget it directly
|
|
1408
|
+
// 2. If a ticker-detail follows the source pane, retarget that
|
|
1303
1409
|
// 3. Any follow-mode ticker-detail in the layout
|
|
1304
1410
|
// 4. Any ticker-detail in the layout
|
|
1305
1411
|
// 5. Fall back to pinning a new pane
|
|
1306
1412
|
const currentState = stateRef.current;
|
|
1307
1413
|
const currentLayout = currentState.config.layout;
|
|
1308
|
-
const
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
inst.paneId === "ticker-detail"
|
|
1320
|
-
&& inst.binding?.kind === "follow"
|
|
1321
|
-
&& inst.binding.sourceInstanceId === focused
|
|
1322
|
-
&& isPaneInLayout(currentLayout, inst.instanceId),
|
|
1323
|
-
)
|
|
1324
|
-
?? currentLayout.instances.find((inst) =>
|
|
1325
|
-
inst.paneId === "ticker-detail"
|
|
1326
|
-
&& inst.binding?.kind === "follow"
|
|
1327
|
-
&& isPaneInLayout(currentLayout, inst.instanceId),
|
|
1328
|
-
)
|
|
1329
|
-
?? currentLayout.instances.find((inst) =>
|
|
1330
|
-
inst.paneId === "ticker-detail"
|
|
1331
|
-
&& isPaneInLayout(currentLayout, inst.instanceId),
|
|
1332
|
-
);
|
|
1414
|
+
const detailPane = resolveTickerNavigationDetailPane(currentLayout, sourcePaneId);
|
|
1415
|
+
const focusIfStillOwned = (paneId: string, layout: LayoutConfig) => {
|
|
1416
|
+
if (!shouldFocusTickerNavigationTarget({
|
|
1417
|
+
sourcePaneId,
|
|
1418
|
+
currentFocusedPaneId: stateRef.current.focusedPaneId,
|
|
1419
|
+
targetPaneId: paneId,
|
|
1420
|
+
})) {
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
activatePane(paneId, layout);
|
|
1424
|
+
};
|
|
1333
1425
|
|
|
1334
1426
|
if (detailPane) {
|
|
1335
1427
|
if (detailPane.binding?.kind === "follow") {
|
|
1336
1428
|
const sourceId = detailPane.binding.sourceInstanceId;
|
|
1337
1429
|
dispatch({ type: "UPDATE_PANE_STATE", paneId: sourceId, patch: { cursorSymbol: symbol } });
|
|
1338
|
-
|
|
1430
|
+
focusIfStillOwned(detailPane.instanceId, currentLayout);
|
|
1339
1431
|
} else {
|
|
1340
1432
|
const nextLayout = {
|
|
1341
1433
|
...currentLayout,
|
|
@@ -1346,9 +1438,13 @@ function AppInner({
|
|
|
1346
1438
|
)),
|
|
1347
1439
|
};
|
|
1348
1440
|
persistLayout(nextLayout);
|
|
1349
|
-
|
|
1441
|
+
focusIfStillOwned(detailPane.instanceId, nextLayout);
|
|
1350
1442
|
}
|
|
1351
|
-
} else {
|
|
1443
|
+
} else if (shouldFocusTickerNavigationTarget({
|
|
1444
|
+
sourcePaneId,
|
|
1445
|
+
currentFocusedPaneId: stateRef.current.focusedPaneId,
|
|
1446
|
+
targetPaneId: null,
|
|
1447
|
+
})) {
|
|
1352
1448
|
pluginRegistry.pinTicker(symbol, { floating: false });
|
|
1353
1449
|
}
|
|
1354
1450
|
} catch (err) {
|
|
@@ -1497,20 +1593,20 @@ function AppInner({
|
|
|
1497
1593
|
if (desktopWindowBridge?.kind === "detached" && desktopWindowBridge.paneId) {
|
|
1498
1594
|
return (
|
|
1499
1595
|
<ContextMenuProvider pluginRegistry={pluginRegistry}>
|
|
1500
|
-
<
|
|
1596
|
+
<ThemedAppRoot>
|
|
1501
1597
|
<DetachedPaneShell
|
|
1502
1598
|
pluginRegistry={pluginRegistry}
|
|
1503
1599
|
desktopWindowBridge={{ ...desktopWindowBridge, kind: "detached", paneId: desktopWindowBridge.paneId }}
|
|
1504
1600
|
/>
|
|
1505
1601
|
<ToastViewport position="bottom-right" />
|
|
1506
|
-
</
|
|
1602
|
+
</ThemedAppRoot>
|
|
1507
1603
|
</ContextMenuProvider>
|
|
1508
1604
|
);
|
|
1509
1605
|
}
|
|
1510
1606
|
|
|
1511
1607
|
return (
|
|
1512
1608
|
<ContextMenuProvider pluginRegistry={pluginRegistry}>
|
|
1513
|
-
<
|
|
1609
|
+
<ThemedAppRoot>
|
|
1514
1610
|
<Header />
|
|
1515
1611
|
<Shell
|
|
1516
1612
|
pluginRegistry={pluginRegistry}
|
|
@@ -1528,7 +1624,7 @@ function AppInner({
|
|
|
1528
1624
|
/>
|
|
1529
1625
|
)}
|
|
1530
1626
|
<ToastViewport position="bottom-right" />
|
|
1531
|
-
</
|
|
1627
|
+
</ThemedAppRoot>
|
|
1532
1628
|
</ContextMenuProvider>
|
|
1533
1629
|
);
|
|
1534
1630
|
}
|
|
@@ -1540,6 +1636,7 @@ interface AppProps {
|
|
|
1540
1636
|
desktopWindowBridge?: DesktopWindowBridge;
|
|
1541
1637
|
desktopApplicationMenuBridge?: DesktopApplicationMenuBridge;
|
|
1542
1638
|
desktopSnapshot?: DesktopSharedStateSnapshot | null;
|
|
1639
|
+
desktopThemePreview?: DesktopThemePreviewState | null;
|
|
1543
1640
|
}
|
|
1544
1641
|
|
|
1545
1642
|
export function App({
|
|
@@ -1549,6 +1646,7 @@ export function App({
|
|
|
1549
1646
|
desktopWindowBridge,
|
|
1550
1647
|
desktopApplicationMenuBridge,
|
|
1551
1648
|
desktopSnapshot = null,
|
|
1649
|
+
desktopThemePreview = null,
|
|
1552
1650
|
}: AppProps) {
|
|
1553
1651
|
const renderer = useNativeRenderer();
|
|
1554
1652
|
const effectiveInitialConfig = useMemo(() => {
|
|
@@ -1579,9 +1677,6 @@ export function App({
|
|
|
1579
1677
|
});
|
|
1580
1678
|
const [showOnboarding, setShowOnboarding] = useState(!effectiveInitialConfig.onboardingComplete);
|
|
1581
1679
|
|
|
1582
|
-
// Keep the shared palette aligned before this render builds any JSX that reads `colors`.
|
|
1583
|
-
syncTheme(config.theme);
|
|
1584
|
-
|
|
1585
1680
|
useEffect(() => bindAppActivity(renderer), [renderer]);
|
|
1586
1681
|
|
|
1587
1682
|
const services = useMemo(() => {
|
|
@@ -1637,14 +1732,16 @@ export function App({
|
|
|
1637
1732
|
|
|
1638
1733
|
if (showOnboarding) {
|
|
1639
1734
|
return (
|
|
1640
|
-
<
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1735
|
+
<ThemeProvider themeId={config.theme}>
|
|
1736
|
+
<OnboardingWizard
|
|
1737
|
+
config={config}
|
|
1738
|
+
pluginRegistry={services.pluginRegistry}
|
|
1739
|
+
onComplete={(updatedConfig) => {
|
|
1740
|
+
setConfig(updatedConfig);
|
|
1741
|
+
setShowOnboarding(false);
|
|
1742
|
+
}}
|
|
1743
|
+
/>
|
|
1744
|
+
</ThemeProvider>
|
|
1648
1745
|
);
|
|
1649
1746
|
}
|
|
1650
1747
|
|
|
@@ -1655,6 +1752,7 @@ export function App({
|
|
|
1655
1752
|
sessionSnapshot={sessionSnapshot}
|
|
1656
1753
|
desktopBridge={desktopWindowBridge}
|
|
1657
1754
|
desktopSnapshot={desktopSnapshot}
|
|
1755
|
+
initialThemePreview={desktopThemePreview}
|
|
1658
1756
|
>
|
|
1659
1757
|
<AppInner
|
|
1660
1758
|
pluginRegistry={services.pluginRegistry}
|
|
@@ -26,6 +26,8 @@ export function assetDataProvider(provider: AssetDataProvider): AssetDataCapabil
|
|
|
26
26
|
operations: {
|
|
27
27
|
canProvide: op((input: any) => provider.canProvide?.(input.ticker, input.exchange, input.context) ?? true, "query"),
|
|
28
28
|
getCachedFinancialsForTargets: op((input: any) => provider.getCachedFinancialsForTargets?.(input.targets ?? [], input.options) ?? new Map()),
|
|
29
|
+
getQuotesBatch: op((input: any) => provider.getQuotesBatch?.(input.targets ?? [], input.options) ?? Promise.resolve([])),
|
|
30
|
+
getTickerFinancialsBatch: op((input: any) => provider.getTickerFinancialsBatch?.(input.targets ?? [], input.options) ?? Promise.resolve([])),
|
|
29
31
|
getTickerFinancials: op((input: any) => provider.getTickerFinancials(input.ticker, input.exchange, input.context)),
|
|
30
32
|
getQuote: op((input: any) => provider.getQuote(input.ticker, input.exchange, input.context)),
|
|
31
33
|
getExchangeRate: op((input: any) => provider.getExchangeRate(input.fromCurrency)),
|
|
@@ -95,6 +97,7 @@ export function newsProvider(options: {
|
|
|
95
97
|
supports: op((input: any) => options.provider.supports?.(input.query) ?? true, "query"),
|
|
96
98
|
getCachedNews: op((input: any) => options.provider.getCachedNews?.(input.query) ?? [], "query"),
|
|
97
99
|
fetchNews: op((input: any) => options.provider.fetchNews(input.query), "query"),
|
|
100
|
+
fetchNewsStory: op((input: any) => options.provider.fetchNewsStory?.(input.storyId) ?? null, "query"),
|
|
98
101
|
},
|
|
99
102
|
};
|
|
100
103
|
}
|
|
@@ -157,10 +157,14 @@ export function createPixelBuffer(width: number, heightPixels: number): PixelBuf
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
function setPixel(buf: PixelBuffer, x: number, y: number, color: string, layer: number) {
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
const px = Math.round(x);
|
|
161
|
+
const py = Math.round(y);
|
|
162
|
+
if (px >= 0 && px < buf.width && py >= 0 && py < buf.height) {
|
|
163
|
+
const row = buf.pixels[py];
|
|
164
|
+
if (!row) return;
|
|
165
|
+
const existing = row[px];
|
|
162
166
|
if (!existing || layer >= existing.layer) {
|
|
163
|
-
|
|
167
|
+
row[px] = { color, layer };
|
|
164
168
|
}
|
|
165
169
|
}
|
|
166
170
|
}
|
|
@@ -178,17 +182,22 @@ export function drawLine(
|
|
|
178
182
|
color: string,
|
|
179
183
|
layer: number = LAYER_DATA,
|
|
180
184
|
) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
+
const startX = Math.round(x0);
|
|
186
|
+
const startY = Math.round(y0);
|
|
187
|
+
const endX = Math.round(x1);
|
|
188
|
+
const endY = Math.round(y1);
|
|
189
|
+
if (![startX, startY, endX, endY].every(Number.isFinite)) return;
|
|
190
|
+
let dx = Math.abs(endX - startX);
|
|
191
|
+
let dy = Math.abs(endY - startY);
|
|
192
|
+
const sx = startX < endX ? 1 : -1;
|
|
193
|
+
const sy = startY < endY ? 1 : -1;
|
|
185
194
|
let err = dx - dy;
|
|
186
|
-
let x =
|
|
187
|
-
let y =
|
|
195
|
+
let x = startX;
|
|
196
|
+
let y = startY;
|
|
188
197
|
|
|
189
198
|
while (true) {
|
|
190
199
|
setPixel(buf, x, y, color, layer);
|
|
191
|
-
if (x ===
|
|
200
|
+
if (x === endX && y === endY) break;
|
|
192
201
|
const e2 = 2 * err;
|
|
193
202
|
if (e2 > -dy) {
|
|
194
203
|
err -= dy;
|
|
@@ -492,11 +501,14 @@ export function drawGridLines(
|
|
|
492
501
|
yPositions: number[],
|
|
493
502
|
color: string,
|
|
494
503
|
) {
|
|
495
|
-
for (const
|
|
504
|
+
for (const rawY of yPositions) {
|
|
505
|
+
const y = Math.round(rawY);
|
|
496
506
|
if (y < 0 || y >= buf.height) continue;
|
|
497
507
|
// Dotted horizontal line — dot every 6th column (≈ every 3 terminal columns)
|
|
508
|
+
const row = buf.pixels[y];
|
|
509
|
+
if (!row) continue;
|
|
498
510
|
for (let x = 0; x < buf.width; x++) {
|
|
499
|
-
if (x % 6 === 0 && !
|
|
511
|
+
if (x % 6 === 0 && !row[x]) {
|
|
500
512
|
setPixel(buf, x, y, color, LAYER_GRID);
|
|
501
513
|
}
|
|
502
514
|
}
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
} from "./chart-data";
|
|
8
8
|
import { stepCursorTowards } from "./cursor-motion";
|
|
9
9
|
import { buildChartScene, buildCursorTimeAxisSegments, buildTimeAxis, formatAxisValue, formatCursorAxisValue, renderChart, resolveChartAxisWidth, resolveChartPalette } from "./chart-renderer";
|
|
10
|
+
import { buildCursorTimeAxisOverlay } from "./time-axis-label";
|
|
11
|
+
import { buildCursorPriceAxisOverlay } from "./price-axis-labels";
|
|
10
12
|
import type { PricePoint } from "../../types/financials";
|
|
11
13
|
import type { ChartRenderMode } from "./chart-types";
|
|
12
14
|
|
|
@@ -429,6 +431,47 @@ describe("renderChart", () => {
|
|
|
429
431
|
expect(segments.find((segment) => segment.highlighted)?.text).toBe("12:30");
|
|
430
432
|
});
|
|
431
433
|
|
|
434
|
+
test("positions desktop cursor x-axis overlay from exact pixels", () => {
|
|
435
|
+
const dates = Array.from({ length: 13 }, (_, index) => new Date(2026, 0, 5, 9, 30 + index * 30));
|
|
436
|
+
const width = 72;
|
|
437
|
+
const cursorPixelX = 291.5;
|
|
438
|
+
const cellWidthPx = 8;
|
|
439
|
+
const segments = buildCursorTimeAxisSegments({
|
|
440
|
+
timeLabels: buildTimeAxis(dates, width),
|
|
441
|
+
width,
|
|
442
|
+
cursorColumn: 36,
|
|
443
|
+
cursorDate: dates[6]!,
|
|
444
|
+
dates,
|
|
445
|
+
});
|
|
446
|
+
const overlay = buildCursorTimeAxisOverlay({
|
|
447
|
+
segments,
|
|
448
|
+
width,
|
|
449
|
+
cursorPixelX,
|
|
450
|
+
cellWidthPx,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
expect(overlay.label).toBe("12:30");
|
|
454
|
+
expect(overlay.baseText).toHaveLength(width);
|
|
455
|
+
expect(overlay.leftPercent).toBeCloseTo((cursorPixelX / (width * cellWidthPx - 1)) * 100, 5);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("positions desktop cursor y-axis overlay from exact pixels", () => {
|
|
459
|
+
const height = 8;
|
|
460
|
+
const cellHeightPx = 18;
|
|
461
|
+
const cursorPixelY = 45.25;
|
|
462
|
+
const overlay = buildCursorPriceAxisOverlay({
|
|
463
|
+
axisWidth: 7,
|
|
464
|
+
axisSectionWidth: 8,
|
|
465
|
+
height,
|
|
466
|
+
cursorPixelY,
|
|
467
|
+
cursorLabel: "$214.03",
|
|
468
|
+
cellHeightPx,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(overlay.labelText).toBe("$214.03 ");
|
|
472
|
+
expect(overlay.topPercent).toBeCloseTo((cursorPixelY / (height * cellHeightPx - 1)) * 100, 5);
|
|
473
|
+
});
|
|
474
|
+
|
|
432
475
|
test("shows second precision when the visible window reaches second-level data", () => {
|
|
433
476
|
const dates = Array.from({ length: 6 }, (_, index) => new Date(2026, 0, 5, 9, 30, index * 10));
|
|
434
477
|
|
|
@@ -119,6 +119,32 @@ describe("renderComparisonChart", () => {
|
|
|
119
119
|
expect(result.activeDate?.toISOString()).toBe("2024-01-03T00:00:00.000Z");
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
test("renders when precise desktop layout passes a fractional chart height", () => {
|
|
123
|
+
const projection = projectComparisonChartData([
|
|
124
|
+
makeSeries("AAPL", "#00ff00", [10, 12, 11, 13]),
|
|
125
|
+
], 12, {
|
|
126
|
+
panOffset: 0,
|
|
127
|
+
zoomLevel: 1,
|
|
128
|
+
renderMode: "area",
|
|
129
|
+
}, "percent");
|
|
130
|
+
|
|
131
|
+
const result = renderComparisonChart(projection, {
|
|
132
|
+
width: 12,
|
|
133
|
+
height: 6.5,
|
|
134
|
+
cursorX: null,
|
|
135
|
+
cursorY: null,
|
|
136
|
+
selectedSymbol: "AAPL",
|
|
137
|
+
colors: {
|
|
138
|
+
bgColor: "#000000",
|
|
139
|
+
gridColor: "#333333",
|
|
140
|
+
crosshairColor: "#ffffff",
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.lines.length).toBeGreaterThan(0);
|
|
145
|
+
expect(textLines(result).some((line) => /[^\s]/.test(line))).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
122
148
|
test("keeps terminal comparison chart cells transparent", () => {
|
|
123
149
|
const projection = projectComparisonChartData([
|
|
124
150
|
makeSeries("AAPL", "#00ff00", [10, 12, 11, 13]),
|
|
@@ -80,6 +80,11 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
80
80
|
return Math.max(min, Math.min(max, value));
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function normalizeCount(value: number, min: number): number {
|
|
84
|
+
if (!Number.isFinite(value)) return min;
|
|
85
|
+
return Math.max(Math.floor(value), min);
|
|
86
|
+
}
|
|
87
|
+
|
|
83
88
|
function getComparisonDotX(index: number, count: number, width: number): number {
|
|
84
89
|
if (count <= 1) return Math.round(Math.max(width - 1, 0) / 2);
|
|
85
90
|
return Math.round((index / (count - 1)) * Math.max(width - 1, 0));
|
|
@@ -260,15 +265,17 @@ export function buildComparisonChartScene(
|
|
|
260
265
|
|
|
261
266
|
const min = Math.min(...values);
|
|
262
267
|
const max = Math.max(...values);
|
|
268
|
+
const width = normalizeCount(opts.width, 1);
|
|
269
|
+
const height = normalizeCount(opts.height, 1);
|
|
263
270
|
const selectedSeries = getSelectedSeries(projection.series, opts.selectedSymbol);
|
|
264
|
-
const activeIdx = getActiveIndex(projection.dates.length,
|
|
271
|
+
const activeIdx = getActiveIndex(projection.dates.length, width, opts.cursorX);
|
|
265
272
|
const activeDate = projection.dates[activeIdx] ?? projection.dates[projection.dates.length - 1]!;
|
|
266
273
|
const selectedPoint = selectedSeries?.points[activeIdx] ?? null;
|
|
267
|
-
const chartRows =
|
|
274
|
+
const chartRows = height;
|
|
268
275
|
const range = max - min || 1;
|
|
269
276
|
const cursorX = opts.cursorX === null
|
|
270
277
|
? null
|
|
271
|
-
: clamp(opts.cursorX, 0, Math.max(
|
|
278
|
+
: clamp(opts.cursorX, 0, Math.max(width - 1, 0));
|
|
272
279
|
const fallbackValue = selectedPoint?.value ?? selectedSeries?.latestValue ?? min;
|
|
273
280
|
const cursorY = cursorX === null
|
|
274
281
|
? null
|
|
@@ -282,7 +289,7 @@ export function buildComparisonChartScene(
|
|
|
282
289
|
const cursorColumn = cursorX === null ? null : Math.round(cursorX);
|
|
283
290
|
const cursorDotX = cursorX === null
|
|
284
291
|
? null
|
|
285
|
-
: Math.round((cursorX / Math.max(
|
|
292
|
+
: Math.round((cursorX / Math.max(width - 1, 1)) * Math.max(width * 2 - 1, 0));
|
|
286
293
|
const cursorRow = cursorY === null ? null : Math.round(cursorY);
|
|
287
294
|
const crosshairValue = cursorY === null
|
|
288
295
|
? null
|
|
@@ -291,8 +298,8 @@ export function buildComparisonChartScene(
|
|
|
291
298
|
return {
|
|
292
299
|
dates: projection.dates,
|
|
293
300
|
series: projection.series,
|
|
294
|
-
width
|
|
295
|
-
height
|
|
301
|
+
width,
|
|
302
|
+
height,
|
|
296
303
|
chartRows,
|
|
297
304
|
mode: projection.effectiveMode,
|
|
298
305
|
axisMode: projection.effectiveAxisMode,
|
|
@@ -8,6 +8,7 @@ import { buildChartKey } from "../../market-data/selectors";
|
|
|
8
8
|
import { useAppSelector, usePaneSettingValue } from "../../state/app-context";
|
|
9
9
|
import { usePaneFooter, type PaneHint } from "../layout/pane-footer";
|
|
10
10
|
import { blendHex, colors, getComparisonSeriesColor, priceColor } from "../../theme/colors";
|
|
11
|
+
import { useThemeColors } from "../../theme/theme-context";
|
|
11
12
|
import type { BrokerContractRef } from "../../types/instrument";
|
|
12
13
|
import type { PricePoint } from "../../types/financials";
|
|
13
14
|
import { formatPercentRaw } from "../../utils/format";
|
|
@@ -95,8 +96,9 @@ import {
|
|
|
95
96
|
resolveNativeSurfaceVisibleRect,
|
|
96
97
|
type NativeSurfaceRenderableNode,
|
|
97
98
|
} from "./native/surface-visibility";
|
|
98
|
-
import {
|
|
99
|
+
import { formatDateShort, resolveChartAxisWidth, type StyledContent } from "./chart-renderer";
|
|
99
100
|
import { TimeAxisLabel } from "./time-axis-label";
|
|
101
|
+
import { PriceAxisLabels } from "./price-axis-labels";
|
|
100
102
|
|
|
101
103
|
const MODE_CHIPS: Record<ComparisonChartRenderMode, string> = {
|
|
102
104
|
area: "A",
|
|
@@ -516,6 +518,7 @@ function ComparisonStockChartView({
|
|
|
516
518
|
onOpenSymbol,
|
|
517
519
|
onEditTickers,
|
|
518
520
|
}: ComparisonStockChartViewProps) {
|
|
521
|
+
useThemeColors();
|
|
519
522
|
const renderer = useNativeRenderer();
|
|
520
523
|
const { canvasCharts, cellWidthPx = 8, cellHeightPx = 18, pixelRatio = 1 } = useUiCapabilities();
|
|
521
524
|
const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
|
|
@@ -1827,17 +1830,16 @@ function ComparisonStockChartView({
|
|
|
1827
1830
|
);
|
|
1828
1831
|
|
|
1829
1832
|
const axisBox = (
|
|
1830
|
-
<
|
|
1831
|
-
{
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
</Box>
|
|
1833
|
+
<PriceAxisLabels
|
|
1834
|
+
axisLabels={axisLabels}
|
|
1835
|
+
axisWidth={axisWidth}
|
|
1836
|
+
axisSectionWidth={axisSectionWidth}
|
|
1837
|
+
height={chartHeight}
|
|
1838
|
+
cursorRow={cursorRow}
|
|
1839
|
+
cursorPixelY={hasDisplayCursor ? displayCursor.pixelY : null}
|
|
1840
|
+
cursorLabel={cursorAxisLabel}
|
|
1841
|
+
cursorColor={chartColors.crosshairColor}
|
|
1842
|
+
/>
|
|
1841
1843
|
);
|
|
1842
1844
|
|
|
1843
1845
|
const legendRowsData = Array.from({ length: Math.ceil(symbols.length / legendColumns) }, (_, rowIndex) => (
|
|
@@ -1947,6 +1949,7 @@ function ComparisonStockChartView({
|
|
|
1947
1949
|
timeLabels={timeAxisLabel}
|
|
1948
1950
|
width={chartWidth}
|
|
1949
1951
|
cursorColumn={cursorTimeAxisColumn}
|
|
1952
|
+
cursorPixelX={hasDisplayCursor ? displayCursor.pixelX : null}
|
|
1950
1953
|
cursorDate={cursorTimeAxisDate}
|
|
1951
1954
|
dates={projection.dates}
|
|
1952
1955
|
cursorColor={chartColors.crosshairColor}
|