gloomberb 0.9.1 → 0.9.2
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/components/chart/comparison-stock-chart/helpers.ts +0 -32
- package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
- package/src/components/chart/comparison-stock-chart/legend.tsx +41 -44
- package/src/components/chart/comparison-stock-chart/view.tsx +46 -6
- package/src/components/layout/detached-pane-shell.tsx +5 -2
- package/src/components/layout/header.tsx +13 -5
- package/src/components/layout/window-controls.tsx +19 -10
- package/src/components/price-performance.tsx +175 -0
- package/src/market-data/performance.test.ts +54 -0
- package/src/market-data/performance.ts +109 -0
- package/src/plugins/builtin/comparison-chart/index.test.tsx +88 -9
- package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
- package/src/plugins/builtin/congress-trades/footer.ts +14 -8
- package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
- package/src/plugins/builtin/congress-trades/model.ts +29 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
- package/src/plugins/builtin/congress-trades/table.tsx +10 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
- package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
- package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
- package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
- package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
- package/src/renderers/electrobun/bun/index.ts +9 -2
- package/src/renderers/electrobun/shared/protocol.ts +1 -0
- package/src/renderers/electrobun/view/main.tsx +2 -1
- package/src/renderers/electrobun/view/styles.css +9 -8
- package/src/renderers/electrobun/view/ui-host.tsx +102 -77
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -2,8 +2,6 @@ import type { PricePoint } from "../../../types/financials";
|
|
|
2
2
|
import type { LocalPlotPointer } from "../core/pointer";
|
|
3
3
|
import type { ComparisonChartRenderMode } from "../core/types";
|
|
4
4
|
import type { projectComparisonChartData } from "../comparison/data";
|
|
5
|
-
import { formatPercentRaw } from "../../../utils/format";
|
|
6
|
-
import { formatMarketPriceWithCurrency } from "../../../market-data/market/format";
|
|
7
5
|
|
|
8
6
|
export function clamp(value: number, min: number, max: number): number {
|
|
9
7
|
return Math.max(min, Math.min(max, value));
|
|
@@ -42,36 +40,6 @@ export function buildComparisonNativeBitmapKey(
|
|
|
42
40
|
].join("::");
|
|
43
41
|
}
|
|
44
42
|
|
|
45
|
-
export function getLegendColumns(width: number): number {
|
|
46
|
-
if (width >= 110) return 3;
|
|
47
|
-
if (width >= 72) return 2;
|
|
48
|
-
return 1;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function clipText(text: string, width: number): string {
|
|
52
|
-
if (width <= 0) return "";
|
|
53
|
-
if (text.length <= width) return text.padEnd(width);
|
|
54
|
-
if (width <= 3) return text.slice(0, width);
|
|
55
|
-
return `${text.slice(0, width - 3)}...`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function formatLegendSummary(
|
|
59
|
-
symbol: string,
|
|
60
|
-
rawValue: number | null,
|
|
61
|
-
baseValue: number | null,
|
|
62
|
-
currency: string,
|
|
63
|
-
priceRange: number | undefined,
|
|
64
|
-
): string {
|
|
65
|
-
if (rawValue === null) return `${symbol} waiting`;
|
|
66
|
-
const price = formatMarketPriceWithCurrency(rawValue, currency, {
|
|
67
|
-
minimumFractionDigits: 2,
|
|
68
|
-
precisionOffset: 1,
|
|
69
|
-
priceRange,
|
|
70
|
-
});
|
|
71
|
-
if (baseValue === null || baseValue === 0) return `${symbol} ${price}`;
|
|
72
|
-
return `${symbol} ${price} ${formatPercentRaw(((rawValue - baseValue) / baseValue) * 100)}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
43
|
export function getInitialComparisonMode(mode: string | undefined): ComparisonChartRenderMode {
|
|
76
44
|
return mode === "line" ? "line" : "area";
|
|
77
45
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ChartAxisMode } from "../core/types";
|
|
2
|
-
import { getLegendColumns } from "./helpers";
|
|
3
2
|
|
|
4
3
|
export interface ComparisonChartLayoutMetrics {
|
|
5
4
|
axisGap: number;
|
|
@@ -36,15 +35,16 @@ export function resolveComparisonChartLayoutMetrics({
|
|
|
36
35
|
const controlRows = 2;
|
|
37
36
|
const timeAxisRows = 1;
|
|
38
37
|
const helpRows = 0;
|
|
39
|
-
const legendColumns =
|
|
40
|
-
const legendNeededRows = symbolCount > 0 ?
|
|
41
|
-
const
|
|
42
|
-
|
|
38
|
+
const legendColumns = 1;
|
|
39
|
+
const legendNeededRows = symbolCount > 0 ? symbolCount + 1 : 0;
|
|
40
|
+
const legendAvailableRows = Math.max(height - (headerRows + controlRows + timeAxisRows + helpRows + 6), 0);
|
|
41
|
+
const legendRows = legendNeededRows > 0 && legendAvailableRows > 0
|
|
42
|
+
? Math.min(5, Math.max(2, Math.min(legendAvailableRows, legendNeededRows)))
|
|
43
43
|
: 0;
|
|
44
44
|
const chartHeight = Math.max(height - headerRows - controlRows - timeAxisRows - helpRows - legendRows, 4);
|
|
45
45
|
const minChartWidth = 20;
|
|
46
46
|
const measurementChartWidth = Math.max(width - axisSectionWidthBudget - axisGap, minChartWidth);
|
|
47
|
-
const legendItemWidth = Math.max(
|
|
47
|
+
const legendItemWidth = Math.max(width, 20);
|
|
48
48
|
|
|
49
49
|
return {
|
|
50
50
|
axisGap,
|
|
@@ -1,73 +1,70 @@
|
|
|
1
|
-
import { Box, ScrollBox, Text, TextAttributes } from "../../../ui";
|
|
2
|
-
import { blendHex, colors } from "../../../theme/colors";
|
|
3
1
|
import type { ComparisonChartProjection } from "../comparison/data";
|
|
4
|
-
import {
|
|
2
|
+
import { PriceReturnTable, type PriceReturnTableRow } from "../../price-performance";
|
|
3
|
+
import type { PriceReturnField } from "../../../market-data/performance";
|
|
5
4
|
|
|
6
5
|
interface ComparisonChartLegendProps {
|
|
7
6
|
legendActiveIndex: number | null;
|
|
8
|
-
legendColumns: number;
|
|
9
7
|
legendItemWidth: number;
|
|
10
8
|
legendRows: number;
|
|
11
9
|
onOpenSymbol: (symbol: string) => void;
|
|
12
10
|
onSelectSymbol: (symbol: string) => void;
|
|
11
|
+
performanceBySymbol: ReadonlyMap<string, PriceReturnField[]>;
|
|
13
12
|
projection: ComparisonChartProjection;
|
|
14
13
|
selectedSymbol: string | null;
|
|
15
14
|
symbols: string[];
|
|
16
|
-
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildRangeReturnField(
|
|
18
|
+
item: ComparisonChartProjection["series"][number] | null,
|
|
19
|
+
legendActiveIndex: number | null,
|
|
20
|
+
): PriceReturnField {
|
|
21
|
+
const activeRaw = legendActiveIndex !== null
|
|
22
|
+
? item?.points[legendActiveIndex]?.rawValue ?? null
|
|
23
|
+
: item?.latestRawValue ?? null;
|
|
24
|
+
const baseValue = item?.baseValue ?? null;
|
|
25
|
+
const value = activeRaw != null && baseValue != null && baseValue !== 0
|
|
26
|
+
? (activeRaw - baseValue) / baseValue
|
|
27
|
+
: null;
|
|
28
|
+
return {
|
|
29
|
+
id: "RNG",
|
|
30
|
+
label: "Rng",
|
|
31
|
+
value,
|
|
32
|
+
};
|
|
17
33
|
}
|
|
18
34
|
|
|
19
35
|
export function ComparisonChartLegend({
|
|
20
36
|
legendActiveIndex,
|
|
21
|
-
legendColumns,
|
|
22
37
|
legendItemWidth,
|
|
23
38
|
legendRows,
|
|
24
39
|
onOpenSymbol,
|
|
25
40
|
onSelectSymbol,
|
|
41
|
+
performanceBySymbol,
|
|
26
42
|
projection,
|
|
27
43
|
selectedSymbol,
|
|
28
44
|
symbols,
|
|
29
|
-
visiblePriceRange,
|
|
30
45
|
}: ComparisonChartLegendProps) {
|
|
31
46
|
if (legendRows <= 0) return null;
|
|
32
47
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
const rows: PriceReturnTableRow[] = symbols.map((symbol) => {
|
|
49
|
+
const item = projection.series.find((entry) => entry.symbol === symbol) ?? null;
|
|
50
|
+
return {
|
|
51
|
+
symbol,
|
|
52
|
+
color: item?.color ?? "#888888",
|
|
53
|
+
fields: [
|
|
54
|
+
buildRangeReturnField(item, legendActiveIndex),
|
|
55
|
+
...(performanceBySymbol.get(symbol) ?? []),
|
|
56
|
+
],
|
|
57
|
+
selected: selectedSymbol === symbol,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
36
60
|
|
|
37
61
|
return (
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const activeRaw = legendActiveIndex !== null
|
|
46
|
-
? item?.points[legendActiveIndex]?.rawValue ?? null
|
|
47
|
-
: item?.latestRawValue ?? null;
|
|
48
|
-
const currency = item?.currency ?? "USD";
|
|
49
|
-
const summary = formatLegendSummary(symbol, activeRaw, item?.baseValue ?? null, currency, visiblePriceRange);
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<Box
|
|
53
|
-
key={symbol}
|
|
54
|
-
width={legendItemWidth}
|
|
55
|
-
backgroundColor={isSelected ? blendHex(colors.panel, colors.borderFocused, 0.18) : colors.panel}
|
|
56
|
-
onMouseMove={() => onSelectSymbol(symbol)}
|
|
57
|
-
onMouseDown={() => {
|
|
58
|
-
onSelectSymbol(symbol);
|
|
59
|
-
onOpenSymbol(symbol);
|
|
60
|
-
}}
|
|
61
|
-
>
|
|
62
|
-
<Text fg={item?.color ?? colors.textDim} attributes={isSelected ? TextAttributes.BOLD : 0}>
|
|
63
|
-
{clipText(`${isSelected ? ">" : " "} ${summary}`, legendItemWidth)}
|
|
64
|
-
</Text>
|
|
65
|
-
</Box>
|
|
66
|
-
);
|
|
67
|
-
})}
|
|
68
|
-
</Box>
|
|
69
|
-
))}
|
|
70
|
-
</Box>
|
|
71
|
-
</ScrollBox>
|
|
62
|
+
<PriceReturnTable
|
|
63
|
+
height={legendRows}
|
|
64
|
+
onOpenSymbol={onOpenSymbol}
|
|
65
|
+
onSelectSymbol={onSelectSymbol}
|
|
66
|
+
rows={rows}
|
|
67
|
+
width={legendItemWidth}
|
|
68
|
+
/>
|
|
72
69
|
);
|
|
73
70
|
}
|
|
@@ -5,6 +5,11 @@ import { blendHex, colors } from "../../../theme/colors";
|
|
|
5
5
|
import { useThemeColors } from "../../../theme/theme-context";
|
|
6
6
|
import { usePaneSettingValue } from "../../../state/app/context";
|
|
7
7
|
import { useQuoteStreaming } from "../../../state/hooks/quote-streaming";
|
|
8
|
+
import {
|
|
9
|
+
appendQuoteToPriceReturnHistory,
|
|
10
|
+
buildPriceReturnFields,
|
|
11
|
+
type PriceReturnField,
|
|
12
|
+
} from "../../../market-data/performance";
|
|
8
13
|
import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
|
|
9
14
|
import {
|
|
10
15
|
type ChartRendererPreference,
|
|
@@ -170,6 +175,27 @@ function useComparisonChartQuoteStreaming({
|
|
|
170
175
|
useQuoteStreaming(streamingTargets);
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
function getOneYearReturn(fields: readonly PriceReturnField[]): number | null {
|
|
179
|
+
return fields.find((field) => field.id === "1Y")?.value ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function sortSymbolsByOneYearReturn(
|
|
183
|
+
symbols: readonly string[],
|
|
184
|
+
performanceBySymbol: ReadonlyMap<string, PriceReturnField[]>,
|
|
185
|
+
): string[] {
|
|
186
|
+
const originalIndex = new Map(symbols.map((symbol, index) => [symbol, index]));
|
|
187
|
+
return [...symbols].sort((left, right) => {
|
|
188
|
+
const leftReturn = getOneYearReturn(performanceBySymbol.get(left) ?? []);
|
|
189
|
+
const rightReturn = getOneYearReturn(performanceBySymbol.get(right) ?? []);
|
|
190
|
+
if (leftReturn != null && rightReturn != null && leftReturn !== rightReturn) {
|
|
191
|
+
return rightReturn - leftReturn;
|
|
192
|
+
}
|
|
193
|
+
if (leftReturn != null && rightReturn == null) return -1;
|
|
194
|
+
if (leftReturn == null && rightReturn != null) return 1;
|
|
195
|
+
return (originalIndex.get(left) ?? 0) - (originalIndex.get(right) ?? 0);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
173
199
|
function ComparisonStockChartView({
|
|
174
200
|
paneId,
|
|
175
201
|
width,
|
|
@@ -250,6 +276,22 @@ function ComparisonStockChartView({
|
|
|
250
276
|
symbolSources,
|
|
251
277
|
viewState,
|
|
252
278
|
});
|
|
279
|
+
const performanceBySymbol = useMemo(() => {
|
|
280
|
+
const bySymbol = new Map<string, PriceReturnField[]>();
|
|
281
|
+
for (const source of symbolSources) {
|
|
282
|
+
const sourceHistory = appendQuoteToPriceReturnHistory(source.priceHistory, source.quote);
|
|
283
|
+
const fallbackHistory = series.find((entry) => entry.symbol === source.symbol)?.points ?? [];
|
|
284
|
+
bySymbol.set(
|
|
285
|
+
source.symbol,
|
|
286
|
+
buildPriceReturnFields(sourceHistory.length >= 2 ? sourceHistory : fallbackHistory),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return bySymbol;
|
|
290
|
+
}, [series, symbolSources]);
|
|
291
|
+
const summarySymbols = useMemo(
|
|
292
|
+
() => sortSymbolsByOneYearReturn(symbols, performanceBySymbol),
|
|
293
|
+
[performanceBySymbol, symbols],
|
|
294
|
+
);
|
|
253
295
|
|
|
254
296
|
useEffect(() => {
|
|
255
297
|
if (symbols.includes(viewState.selectedSymbol ?? "")) return;
|
|
@@ -311,7 +353,7 @@ function ComparisonStockChartView({
|
|
|
311
353
|
cursorY: viewState.cursorY,
|
|
312
354
|
renderer,
|
|
313
355
|
setViewState,
|
|
314
|
-
symbols,
|
|
356
|
+
symbols: summarySymbols,
|
|
315
357
|
});
|
|
316
358
|
const {
|
|
317
359
|
activePreset,
|
|
@@ -352,7 +394,6 @@ function ComparisonStockChartView({
|
|
|
352
394
|
result,
|
|
353
395
|
staticScene,
|
|
354
396
|
timeAxisLabel,
|
|
355
|
-
visiblePriceRange,
|
|
356
397
|
} = useComparisonChartRenderOutput({
|
|
357
398
|
axisMode,
|
|
358
399
|
chartColors,
|
|
@@ -386,7 +427,7 @@ function ComparisonStockChartView({
|
|
|
386
427
|
setResolution,
|
|
387
428
|
setViewState,
|
|
388
429
|
supportMap,
|
|
389
|
-
symbols,
|
|
430
|
+
symbols: summarySymbols,
|
|
390
431
|
updateDisplayCursorTarget,
|
|
391
432
|
viewState,
|
|
392
433
|
visibleDateWindow,
|
|
@@ -479,15 +520,14 @@ function ComparisonStockChartView({
|
|
|
479
520
|
legend={(
|
|
480
521
|
<ComparisonChartLegend
|
|
481
522
|
legendActiveIndex={legendActiveIndex}
|
|
482
|
-
legendColumns={legendColumns}
|
|
483
523
|
legendItemWidth={legendItemWidth}
|
|
484
524
|
legendRows={legendRows}
|
|
485
525
|
onOpenSymbol={onOpenSymbol}
|
|
486
526
|
onSelectSymbol={setSelectedSymbol}
|
|
527
|
+
performanceBySymbol={performanceBySymbol}
|
|
487
528
|
projection={projection}
|
|
488
529
|
selectedSymbol={viewState.selectedSymbol}
|
|
489
|
-
symbols={
|
|
490
|
-
visiblePriceRange={visiblePriceRange}
|
|
530
|
+
symbols={summarySymbols}
|
|
491
531
|
/>
|
|
492
532
|
)}
|
|
493
533
|
plotBitmaps={plotBitmaps}
|
|
@@ -13,7 +13,7 @@ import { PaneContent } from "./pane/content";
|
|
|
13
13
|
import { resolvePaneBodyFrame } from "./pane/sizing";
|
|
14
14
|
import { getPaneDisplayTitle } from "./pane/title";
|
|
15
15
|
import { TITLEBAR_OVERLAY_HEIGHT_PX, getTitlebarLeadingInset } from "./titlebar-overlay";
|
|
16
|
-
import { WindowControls } from "./window-controls";
|
|
16
|
+
import { WindowControls, WINDOWS_CONTROL_GROUP_WIDTH_PX } from "./window-controls";
|
|
17
17
|
import {
|
|
18
18
|
createDoubleEscapeCloseState,
|
|
19
19
|
recordDoubleEscapeClose,
|
|
@@ -174,8 +174,10 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
|
|
|
174
174
|
alignItems="center"
|
|
175
175
|
flexGrow={1}
|
|
176
176
|
minWidth={0}
|
|
177
|
+
backgroundColor={titleBackground}
|
|
177
178
|
paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}
|
|
178
179
|
paddingRight={showWindowControls ? 0 : 1}
|
|
180
|
+
style={{ position: "relative" }}
|
|
179
181
|
>
|
|
180
182
|
<Box flexGrow={1} minWidth={0} overflow="hidden">
|
|
181
183
|
<Text fg={paneTitleText(focused, true)} selectable={false} data-gloom-role="pane-title">{title}</Text>
|
|
@@ -192,7 +194,8 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
|
|
|
192
194
|
{" ... "}
|
|
193
195
|
</Text>
|
|
194
196
|
)}
|
|
195
|
-
{showWindowControls ? <
|
|
197
|
+
{showWindowControls ? <Box flexShrink={0} width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`} /> : null}
|
|
198
|
+
{showWindowControls ? <WindowControls windowKind="detached" /> : null}
|
|
196
199
|
</Box>
|
|
197
200
|
</Box>
|
|
198
201
|
<PaneBodyFrame layoutProps={bodyFrame.layoutProps} backgroundColor={background}>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Box, SpinnerMark, Text, TextAttributes, useUiCapabilities } from "../../ui";
|
|
2
|
-
import { useEffect, type ReactNode } from "react";
|
|
1
|
+
import { Box, SpinnerMark, Text, TextAttributes, useRendererHost, useUiCapabilities } from "../../ui";
|
|
2
|
+
import { useCallback, useEffect, type ReactNode } from "react";
|
|
3
3
|
import { blendHex, colors, priceColor } from "../../theme/colors";
|
|
4
4
|
import { useThemeColors } from "../../theme/theme-context";
|
|
5
5
|
import { useAppActive } from "../../state/app/activity";
|
|
@@ -18,7 +18,7 @@ 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
20
|
import { getTitlebarLeadingInset } from "./titlebar-overlay";
|
|
21
|
-
import { WindowControls } from "./window-controls";
|
|
21
|
+
import { WindowControls, WINDOWS_CONTROL_GROUP_WIDTH_PX } from "./window-controls";
|
|
22
22
|
|
|
23
23
|
const SPY_REFRESH_MS = 5 * 60_000; // 5 min
|
|
24
24
|
const UPDATE_NOTICE_DURATION_MS = 5_000;
|
|
@@ -124,6 +124,7 @@ function UpdateStatus() {
|
|
|
124
124
|
|
|
125
125
|
export function Header() {
|
|
126
126
|
useThemeColors();
|
|
127
|
+
const rendererHost = useRendererHost();
|
|
127
128
|
const baseCurrency = useAppSelector(selectBaseCurrency);
|
|
128
129
|
const appActive = useAppActive();
|
|
129
130
|
const { titleBarOverlay, windowControls } = useUiCapabilities();
|
|
@@ -150,6 +151,11 @@ export function Header() {
|
|
|
150
151
|
? `SPY ${formatMarketPrice(activeSpyQuote.price, { assetCategory: "ETF" })} ${formatPercentRaw(activeSpyQuote.changePercent)}`
|
|
151
152
|
: "SPY —";
|
|
152
153
|
|
|
154
|
+
const startWindowDrag = useCallback(() => {
|
|
155
|
+
if (!titleBarOverlay) return;
|
|
156
|
+
void rendererHost.startWindowDrag?.();
|
|
157
|
+
}, [rendererHost, titleBarOverlay]);
|
|
158
|
+
|
|
153
159
|
// Market status
|
|
154
160
|
const mktState = spyQuote?.marketState;
|
|
155
161
|
const mktLabel = mktState ? marketStateLabel(mktState) : "";
|
|
@@ -164,11 +170,12 @@ export function Header() {
|
|
|
164
170
|
backgroundColor={colors.header}
|
|
165
171
|
data-gloom-role="app-header"
|
|
166
172
|
data-titlebar-overlay="true"
|
|
167
|
-
|
|
173
|
+
onMouseDown={startWindowDrag}
|
|
168
174
|
style={{
|
|
169
175
|
boxShadow: `0 -1px 0 ${colors.header}, inset 0 1px 0 ${colors.header}`,
|
|
170
176
|
paddingLeft: 8,
|
|
171
177
|
paddingRight: showWindowControls ? 0 : 12,
|
|
178
|
+
position: "relative",
|
|
172
179
|
}}
|
|
173
180
|
>
|
|
174
181
|
<Box paddingLeft={titlebarLeadingInset} flexDirection="row" alignItems="center" gap={1}>
|
|
@@ -201,6 +208,7 @@ export function Header() {
|
|
|
201
208
|
<Box paddingRight={showWindowControls ? 1 : 0}>
|
|
202
209
|
<Text fg={colors.headerText}>{baseCurrency}</Text>
|
|
203
210
|
</Box>
|
|
211
|
+
{showWindowControls ? <Box flexShrink={0} width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`} /> : null}
|
|
204
212
|
{showWindowControls ? <WindowControls /> : null}
|
|
205
213
|
</Box>
|
|
206
214
|
);
|
|
@@ -213,7 +221,7 @@ export function Header() {
|
|
|
213
221
|
backgroundColor={colors.header}
|
|
214
222
|
data-gloom-role="app-header"
|
|
215
223
|
data-titlebar-overlay={titleBarOverlay ? "true" : undefined}
|
|
216
|
-
|
|
224
|
+
onMouseDown={startWindowDrag}
|
|
217
225
|
>
|
|
218
226
|
<Box paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}>
|
|
219
227
|
<Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Box, useRendererHost } from "../../ui";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
|
+
import { TITLEBAR_OVERLAY_HEIGHT_PX } from "./titlebar-overlay";
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
-
const
|
|
5
|
+
const WINDOWS_CONTROL_SIZE_PX = TITLEBAR_OVERLAY_HEIGHT_PX;
|
|
6
|
+
export const WINDOWS_CONTROL_GROUP_WIDTH_PX = WINDOWS_CONTROL_SIZE_PX * 3;
|
|
6
7
|
|
|
7
8
|
type WindowControlAction = "minimize" | "toggle-maximize" | "close";
|
|
8
9
|
|
|
@@ -44,7 +45,11 @@ function WindowControlIcon({ action }: { action: WindowControlAction }) {
|
|
|
44
45
|
);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
interface WindowControlsProps {
|
|
49
|
+
windowKind?: "main" | "detached";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function WindowControls({ windowKind = "main" }: WindowControlsProps) {
|
|
48
53
|
const rendererHost = useRendererHost();
|
|
49
54
|
|
|
50
55
|
const controlWindow = useCallback((action: WindowControlAction, event: { stopPropagation?: () => void; preventDefault?: () => void }) => {
|
|
@@ -57,11 +62,17 @@ export function WindowControls() {
|
|
|
57
62
|
flexDirection="row"
|
|
58
63
|
alignItems="stretch"
|
|
59
64
|
flexShrink={0}
|
|
60
|
-
|
|
65
|
+
width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`}
|
|
61
66
|
data-gloom-role="window-controls"
|
|
67
|
+
data-window-kind={windowKind}
|
|
62
68
|
aria-hidden={false}
|
|
63
69
|
style={{
|
|
64
|
-
|
|
70
|
+
position: "fixed",
|
|
71
|
+
top: 0,
|
|
72
|
+
right: 0,
|
|
73
|
+
height: `${WINDOWS_CONTROL_SIZE_PX}px`,
|
|
74
|
+
zIndex: 1000,
|
|
75
|
+
backgroundColor: "inherit",
|
|
65
76
|
}}
|
|
66
77
|
>
|
|
67
78
|
{WINDOWS_CONTROLS.map((control) => (
|
|
@@ -69,18 +80,16 @@ export function WindowControls() {
|
|
|
69
80
|
key={control.action}
|
|
70
81
|
alignItems="center"
|
|
71
82
|
justifyContent="center"
|
|
83
|
+
flexShrink={0}
|
|
84
|
+
width={`${WINDOWS_CONTROL_SIZE_PX}px`}
|
|
85
|
+
height="100%"
|
|
72
86
|
data-gloom-role="window-control"
|
|
73
87
|
data-window-control-action={control.action}
|
|
74
88
|
data-gloom-interactive="true"
|
|
75
89
|
role="button"
|
|
76
90
|
aria-label={control.label}
|
|
77
91
|
title={control.label}
|
|
78
|
-
className="electrobun-webkit-app-region-no-drag"
|
|
79
92
|
onMouseDown={(event: { stopPropagation?: () => void; preventDefault?: () => void }) => controlWindow(control.action, event)}
|
|
80
|
-
style={{
|
|
81
|
-
width: WINDOWS_CONTROL_WIDTH_PX,
|
|
82
|
-
height: "100%",
|
|
83
|
-
}}
|
|
84
93
|
>
|
|
85
94
|
<WindowControlIcon action={control.action} />
|
|
86
95
|
</Box>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Box, ScrollBox, Text } from "../ui";
|
|
2
|
+
import { blendHex, colors, priceColor } from "../theme/colors";
|
|
3
|
+
import { displayWidth, formatPercent, padTo } from "../utils/format";
|
|
4
|
+
import type { PriceReturnField } from "../market-data/performance";
|
|
5
|
+
|
|
6
|
+
const STRIP_COLUMN_GAP = 1;
|
|
7
|
+
const RETURN_CELL_MIN_WIDTH = 7;
|
|
8
|
+
const SYMBOL_COLUMN_MIN_WIDTH = 6;
|
|
9
|
+
const SYMBOL_COLUMN_MAX_WIDTH = 12;
|
|
10
|
+
|
|
11
|
+
export interface PriceReturnTableRow {
|
|
12
|
+
symbol: string;
|
|
13
|
+
color: string;
|
|
14
|
+
fields: PriceReturnField[];
|
|
15
|
+
selected: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatReturnValue(value: number | null): string {
|
|
19
|
+
return value == null ? "-" : formatPercent(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function returnValueColor(value: number | null): string {
|
|
23
|
+
return value == null ? colors.textMuted : priceColor(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasAnyReturn(fields: readonly PriceReturnField[]): boolean {
|
|
27
|
+
return fields.some((field) => field.value != null);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function chunkFields<T>(fields: readonly T[], size: number): T[][] {
|
|
31
|
+
const rows: T[][] = [];
|
|
32
|
+
for (let index = 0; index < fields.length; index += size) {
|
|
33
|
+
rows.push(fields.slice(index, index + size));
|
|
34
|
+
}
|
|
35
|
+
return rows;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function PriceReturnStrip({
|
|
39
|
+
fields,
|
|
40
|
+
width,
|
|
41
|
+
}: {
|
|
42
|
+
fields: PriceReturnField[];
|
|
43
|
+
width: number;
|
|
44
|
+
}) {
|
|
45
|
+
if (!hasAnyReturn(fields)) return null;
|
|
46
|
+
|
|
47
|
+
const columnCount = width >= 72
|
|
48
|
+
? Math.min(6, fields.length)
|
|
49
|
+
: width >= 54
|
|
50
|
+
? Math.min(4, fields.length)
|
|
51
|
+
: width >= 36
|
|
52
|
+
? Math.min(3, fields.length)
|
|
53
|
+
: Math.min(2, fields.length);
|
|
54
|
+
const availableWidth = Math.max(width - STRIP_COLUMN_GAP * Math.max(columnCount - 1, 0), columnCount);
|
|
55
|
+
const cellWidth = Math.max(RETURN_CELL_MIN_WIDTH, Math.floor(availableWidth / columnCount));
|
|
56
|
+
const rows = chunkFields(fields, columnCount);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Box flexDirection="column" width={width}>
|
|
60
|
+
{rows.map((row, rowIndex) => (
|
|
61
|
+
<Box key={rowIndex} flexDirection="row" height={1}>
|
|
62
|
+
{row.map((field, columnIndex) => {
|
|
63
|
+
const labelWidth = Math.min(3, Math.max(2, displayWidth(field.label)));
|
|
64
|
+
const valueWidth = Math.max(1, cellWidth - labelWidth);
|
|
65
|
+
return (
|
|
66
|
+
<Box key={field.id} flexDirection="row">
|
|
67
|
+
{columnIndex > 0 && <Box width={STRIP_COLUMN_GAP} />}
|
|
68
|
+
<Box flexDirection="row" width={cellWidth}>
|
|
69
|
+
<Text fg={colors.textDim}>{padTo(field.label, labelWidth)}</Text>
|
|
70
|
+
<Box width={valueWidth} overflow="hidden">
|
|
71
|
+
<Text fg={returnValueColor(field.value)}>{padTo(formatReturnValue(field.value), valueWidth, "right")}</Text>
|
|
72
|
+
</Box>
|
|
73
|
+
</Box>
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</Box>
|
|
78
|
+
))}
|
|
79
|
+
</Box>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getFieldById(fields: readonly PriceReturnField[], id: string): PriceReturnField | null {
|
|
84
|
+
return fields.find((field) => field.id === id) ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function chooseComparisonFieldIds(width: number, availableIds: readonly string[]): string[] {
|
|
88
|
+
const preferred = width >= 86
|
|
89
|
+
? ["RNG", "1M", "3M", "6M", "1Y", "3Y", "5Y"]
|
|
90
|
+
: width >= 72
|
|
91
|
+
? ["RNG", "1M", "3M", "1Y", "3Y", "5Y"]
|
|
92
|
+
: width >= 58
|
|
93
|
+
? ["RNG", "1M", "1Y", "3Y", "5Y"]
|
|
94
|
+
: width >= 44
|
|
95
|
+
? ["RNG", "1M", "1Y", "5Y"]
|
|
96
|
+
: ["1Y", "5Y"];
|
|
97
|
+
const available = new Set(availableIds);
|
|
98
|
+
return preferred.filter((id) => available.has(id));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function PriceReturnTable({
|
|
102
|
+
height,
|
|
103
|
+
onOpenSymbol,
|
|
104
|
+
onSelectSymbol,
|
|
105
|
+
rows,
|
|
106
|
+
width,
|
|
107
|
+
}: {
|
|
108
|
+
height: number;
|
|
109
|
+
onOpenSymbol: (symbol: string) => void;
|
|
110
|
+
onSelectSymbol: (symbol: string) => void;
|
|
111
|
+
rows: PriceReturnTableRow[];
|
|
112
|
+
width: number;
|
|
113
|
+
}) {
|
|
114
|
+
if (height <= 0 || rows.length === 0) return null;
|
|
115
|
+
|
|
116
|
+
const availableIds = rows[0]?.fields.map((field) => field.id) ?? [];
|
|
117
|
+
const fieldIds = chooseComparisonFieldIds(width, availableIds);
|
|
118
|
+
if (fieldIds.length === 0) return null;
|
|
119
|
+
|
|
120
|
+
const valueWidth = Math.max(
|
|
121
|
+
RETURN_CELL_MIN_WIDTH,
|
|
122
|
+
Math.floor((width - SYMBOL_COLUMN_MIN_WIDTH - fieldIds.length) / Math.max(fieldIds.length, 1)),
|
|
123
|
+
);
|
|
124
|
+
const symbolWidth = Math.min(
|
|
125
|
+
SYMBOL_COLUMN_MAX_WIDTH,
|
|
126
|
+
Math.max(SYMBOL_COLUMN_MIN_WIDTH, width - fieldIds.length * (valueWidth + 1)),
|
|
127
|
+
);
|
|
128
|
+
const fieldsById = rows[0]?.fields ?? [];
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<ScrollBox height={height} scrollY>
|
|
132
|
+
<Box flexDirection="column" width={width}>
|
|
133
|
+
<Box flexDirection="row" height={1}>
|
|
134
|
+
<Text fg={colors.textDim}>{padTo("Sym", symbolWidth)}</Text>
|
|
135
|
+
{fieldIds.map((fieldId) => {
|
|
136
|
+
const field = getFieldById(fieldsById, fieldId);
|
|
137
|
+
return (
|
|
138
|
+
<Box key={fieldId} flexDirection="row">
|
|
139
|
+
<Text fg={colors.textDim}>{" "}</Text>
|
|
140
|
+
<Text fg={colors.textDim}>{padTo(field?.label ?? fieldId, valueWidth, "right")}</Text>
|
|
141
|
+
</Box>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</Box>
|
|
145
|
+
|
|
146
|
+
{rows.map((row) => (
|
|
147
|
+
<Box
|
|
148
|
+
key={row.symbol}
|
|
149
|
+
flexDirection="row"
|
|
150
|
+
height={1}
|
|
151
|
+
width={width}
|
|
152
|
+
backgroundColor={row.selected ? blendHex(colors.panel, colors.borderFocused, 0.18) : colors.panel}
|
|
153
|
+
onMouseMove={() => onSelectSymbol(row.symbol)}
|
|
154
|
+
onMouseDown={() => {
|
|
155
|
+
onSelectSymbol(row.symbol);
|
|
156
|
+
onOpenSymbol(row.symbol);
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<Text fg={row.color}>{padTo(`${row.selected ? ">" : " "} ${row.symbol}`, symbolWidth)}</Text>
|
|
160
|
+
{fieldIds.map((fieldId) => {
|
|
161
|
+
const field = getFieldById(row.fields, fieldId);
|
|
162
|
+
const value = field?.value ?? null;
|
|
163
|
+
return (
|
|
164
|
+
<Box key={fieldId} flexDirection="row">
|
|
165
|
+
<Text fg={colors.textDim}>{" "}</Text>
|
|
166
|
+
<Text fg={returnValueColor(value)}>{padTo(formatReturnValue(value), valueWidth, "right")}</Text>
|
|
167
|
+
</Box>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</Box>
|
|
171
|
+
))}
|
|
172
|
+
</Box>
|
|
173
|
+
</ScrollBox>
|
|
174
|
+
);
|
|
175
|
+
}
|