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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  3. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  4. package/src/components/chart/comparison-stock-chart/legend.tsx +41 -44
  5. package/src/components/chart/comparison-stock-chart/view.tsx +46 -6
  6. package/src/components/layout/detached-pane-shell.tsx +5 -2
  7. package/src/components/layout/header.tsx +13 -5
  8. package/src/components/layout/window-controls.tsx +19 -10
  9. package/src/components/price-performance.tsx +175 -0
  10. package/src/market-data/performance.test.ts +54 -0
  11. package/src/market-data/performance.ts +109 -0
  12. package/src/plugins/builtin/comparison-chart/index.test.tsx +88 -9
  13. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  14. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  15. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  16. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  17. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  18. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  19. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  20. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  21. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
  22. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  23. package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
  24. package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
  25. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  26. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
  27. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  28. package/src/renderers/electrobun/bun/index.ts +9 -2
  29. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  30. package/src/renderers/electrobun/view/main.tsx +2 -1
  31. package/src/renderers/electrobun/view/styles.css +9 -8
  32. package/src/renderers/electrobun/view/ui-host.tsx +102 -77
  33. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Market research and portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -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 = getLegendColumns(width);
40
- const legendNeededRows = symbolCount > 0 ? Math.ceil(symbolCount / legendColumns) : 0;
41
- const legendRows = legendNeededRows > 0
42
- ? Math.min(4, Math.max(Math.min(height - (headerRows + controlRows + timeAxisRows + helpRows + 4), legendNeededRows), 1))
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(Math.floor((width - Math.max(legendColumns - 1, 0)) / legendColumns), 20);
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 { clipText, formatLegendSummary } from "./helpers";
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
- visiblePriceRange: number | undefined;
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 legendRowsData = Array.from({ length: Math.ceil(symbols.length / legendColumns) }, (_, rowIndex) => (
34
- symbols.slice(rowIndex * legendColumns, rowIndex * legendColumns + legendColumns)
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
- <ScrollBox height={legendRows} scrollY>
39
- <Box flexDirection="column">
40
- {legendRowsData.map((legendRow, rowIndex) => (
41
- <Box key={`legend-row:${rowIndex}`} flexDirection="row" gap={1}>
42
- {legendRow.map((symbol) => {
43
- const item = projection.series.find((entry) => entry.symbol === symbol) ?? null;
44
- const isSelected = selectedSymbol === symbol;
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={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 ? <WindowControls /> : null}
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
- className="electrobun-webkit-app-region-drag"
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
- className={titleBarOverlay ? "electrobun-webkit-app-region-drag" : undefined}
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 WINDOWS_CONTROL_WIDTH_PX = 30;
5
- const WINDOWS_CONTROL_TRAILING_PADDING_PX = 10;
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
- export function WindowControls() {
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
- className="electrobun-webkit-app-region-no-drag"
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
- paddingRight: WINDOWS_CONTROL_TRAILING_PADDING_PX,
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
+ }