gloomberb 0.9.0 → 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 (51) hide show
  1. package/package.json +6 -2
  2. package/patches/electrobun@1.18.1.patch +85 -0
  3. package/src/app/global-shortcuts.test.tsx +12 -0
  4. package/src/app/global-shortcuts.ts +1 -1
  5. package/src/assets/gloomberb-logo-windows.ico +0 -0
  6. package/src/assets/gloomberb-logo-windows.png +0 -0
  7. package/src/assets/gloomberb-logo.ico +0 -0
  8. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  9. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  10. package/src/components/chart/comparison-stock-chart/legend.tsx +41 -44
  11. package/src/components/chart/comparison-stock-chart/view.tsx +46 -6
  12. package/src/components/layout/detached-pane-shell.tsx +11 -4
  13. package/src/components/layout/header.tsx +25 -10
  14. package/src/components/layout/titlebar-overlay.ts +16 -0
  15. package/src/components/layout/window-controls.tsx +99 -0
  16. package/src/components/price-performance.tsx +175 -0
  17. package/src/market-data/performance.test.ts +54 -0
  18. package/src/market-data/performance.ts +109 -0
  19. package/src/plugins/builtin/comparison-chart/index.test.tsx +88 -9
  20. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  21. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  22. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  23. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  24. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  25. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  26. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  27. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  28. package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
  29. package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
  30. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +51 -3
  31. package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
  32. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  33. package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
  34. package/src/renderers/electrobun/bun/desktop/window-controls.ts +73 -0
  35. package/src/renderers/electrobun/bun/desktop/window-style.ts +31 -0
  36. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  37. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +70 -0
  38. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  39. package/src/renderers/electrobun/bun/index.ts +36 -7
  40. package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
  41. package/src/renderers/electrobun/bun/window/frame.ts +5 -0
  42. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  43. package/src/renderers/electrobun/view/build-assets.ts +2 -1
  44. package/src/renderers/electrobun/view/host/text.tsx +29 -9
  45. package/src/renderers/electrobun/view/main.tsx +2 -1
  46. package/src/renderers/electrobun/view/styles.css +29 -8
  47. package/src/renderers/electrobun/view/ui-host.tsx +113 -74
  48. package/src/ui/host.tsx +2 -0
  49. package/src/updater.test.ts +47 -2
  50. package/src/updater.ts +67 -10
  51. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.9.0",
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",
@@ -21,6 +21,7 @@
21
21
  "desktop:dev": "electrobun dev --watch",
22
22
  "desktop:build": "electrobun build",
23
23
  "desktop:release": "electrobun build --env=stable",
24
+ "desktop:release:windows": "electrobun build --env=stable --targets=win-x64",
24
25
  "test": "bun test",
25
26
  "release": "./scripts/release.sh"
26
27
  },
@@ -40,6 +41,8 @@
40
41
  "@types/bun": "^1.3.14",
41
42
  "@types/react-dom": "^19.2.3",
42
43
  "electrobun": "^1.18.1",
44
+ "png-to-ico": "^2.1.8",
45
+ "rcedit": "^4.0.1",
43
46
  "react-dom": "19.2.7"
44
47
  },
45
48
  "peerDependencies": {
@@ -59,6 +62,7 @@
59
62
  "web-tree-sitter": "0.25.10"
60
63
  },
61
64
  "patchedDependencies": {
62
- "@opentui/core@0.3.2": "patches/@opentui%2Fcore@0.3.2.patch"
65
+ "@opentui/core@0.3.2": "patches/@opentui%2Fcore@0.3.2.patch",
66
+ "electrobun@1.18.1": "patches/electrobun@1.18.1.patch"
63
67
  }
64
68
  }
@@ -0,0 +1,85 @@
1
+ diff --git a/dist/api/bun/core/BrowserWindow.ts b/dist/api/bun/core/BrowserWindow.ts
2
+ index c26c2c977ee1fc25a8dc144b85bac8626a45f572..8920a2905380228c23cba66c20d9bfd19ee13e02 100644
3
+ --- a/dist/api/bun/core/BrowserWindow.ts
4
+ +++ b/dist/api/bun/core/BrowserWindow.ts
5
+ @@ -8,9 +8,18 @@ import { type RPCWithTransport } from "../../shared/rpc.js";
6
+ import { getNextWindowId } from "./windowIds";
7
+ import { GpuWindowMap } from "./GpuWindow";
8
+ import { WGPUView } from "./WGPUView";
9
+ +import { existsSync } from "fs";
10
+ +import { resolve } from "path";
11
+
12
+ const buildConfig = await BuildConfig.get();
13
+
14
+ +function defaultWindowIconPath(): string | null {
15
+ + if (process.platform !== "win32") return null;
16
+ +
17
+ + const iconPath = resolve("../Resources/app.ico");
18
+ + return existsSync(iconPath) ? iconPath : null;
19
+ +}
20
+ +
21
+ export type WindowOptionsType<T = undefined> = {
22
+ trafficLightOffset?: {
23
+ x: number;
24
+ @@ -28,6 +37,7 @@ export type WindowOptionsType<T = undefined> = {
25
+ html: string | null;
26
+ preload: string | null;
27
+ viewsRoot: string | null;
28
+ + iconPath?: string | null;
29
+ renderer: "native" | "cef";
30
+ rpc?: T;
31
+ styleMask?: {};
32
+ @@ -124,6 +134,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
33
+ html: string | null = null;
34
+ preload: string | null = null;
35
+ viewsRoot: string | null = null;
36
+ + iconPath: string | null = null;
37
+ renderer: "native" | "cef" = "native";
38
+ transparent: boolean = false;
39
+ passthrough: boolean = false;
40
+ @@ -155,6 +166,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
41
+ this.html = options.html || null;
42
+ this.preload = options.preload || null;
43
+ this.viewsRoot = options.viewsRoot || null;
44
+ + this.iconPath = options.iconPath ?? defaultWindowIconPath();
45
+ this.renderer = options.renderer || defaultOptions.renderer;
46
+ this.transparent = options.transparent ?? false;
47
+ this.passthrough = options.passthrough ?? false;
48
+ @@ -181,6 +193,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
49
+ id: this.id,
50
+ title: this.title,
51
+ url: this.url || "",
52
+ + iconPath: this.iconPath,
53
+ frame: {
54
+ width: this.frame.width,
55
+ height: this.frame.height,
56
+ diff --git a/dist/api/bun/proc/native.ts b/dist/api/bun/proc/native.ts
57
+ index f0f6c2532ba2eb467ec1d452a25c835b6db72661..ae77cf58d32392ad4cc8e2103ac7ed27baae8bea 100644
58
+ --- a/dist/api/bun/proc/native.ts
59
+ +++ b/dist/api/bun/proc/native.ts
60
+ @@ -829,6 +829,7 @@ const _ffiImpl = {
61
+ id: number;
62
+ url: string | null;
63
+ title: string;
64
+ + iconPath?: string | null;
65
+ frame: {
66
+ width: number;
67
+ height: number;
68
+ @@ -862,6 +863,7 @@ const _ffiImpl = {
69
+ id,
70
+ url: _url,
71
+ title,
72
+ + iconPath,
73
+ frame: { x, y, width, height },
74
+ styleMask: {
75
+ Borderless,
76
+ @@ -926,6 +928,9 @@ const _ffiImpl = {
77
+ }
78
+
79
+ native_.symbols.setWindowTitle(windowPtr, toCString(title));
80
+ + if (iconPath) {
81
+ + native_.symbols.setWindowIcon(windowPtr, toCString(iconPath));
82
+ + }
83
+ if (!hidden) {
84
+ native_.symbols.showWindow(windowPtr, activate);
85
+ }
@@ -135,6 +135,18 @@ describe("useAppGlobalShortcuts", () => {
135
135
  expect(event.propagationStopped).toBe(true);
136
136
  });
137
137
 
138
+ test("toggles the command bar with Ctrl-K", async () => {
139
+ const actions: AppAction[] = [];
140
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-global-shortcuts"));
141
+ await renderHarness(state, createRegistry(), (action) => actions.push(action));
142
+
143
+ const event = await emitKeypress({ name: "k", ctrl: true });
144
+
145
+ expect(actions).toEqual([{ type: "TOGGLE_COMMAND_BAR" }]);
146
+ expect(event.defaultPrevented).toBe(true);
147
+ expect(event.propagationStopped).toBe(true);
148
+ });
149
+
138
150
  test("opens ticker search with backtick", async () => {
139
151
  const actions: AppAction[] = [];
140
152
  const state = createInitialState(createDefaultConfig("/tmp/gloomberb-global-shortcuts"));
@@ -52,7 +52,7 @@ export function useAppGlobalShortcuts({
52
52
 
53
53
  if (!isDetachedWindow && (
54
54
  (event.name === "p" && event.ctrl)
55
- || (event.name === "k" && (event.meta || event.super))
55
+ || (event.name === "k" && (event.ctrl || event.meta || event.super))
56
56
  )) {
57
57
  event.preventDefault();
58
58
  event.stopPropagation();
Binary file
@@ -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}
@@ -12,7 +12,8 @@ import { PaneBodyFrame, getPaneWindowAttributes } from "./pane/frame";
12
12
  import { PaneContent } from "./pane/content";
13
13
  import { resolvePaneBodyFrame } from "./pane/sizing";
14
14
  import { getPaneDisplayTitle } from "./pane/title";
15
- import { TITLEBAR_OVERLAY_HEIGHT_PX, TITLEBAR_TRAFFIC_LIGHT_WIDTH } from "./titlebar-overlay";
15
+ import { TITLEBAR_OVERLAY_HEIGHT_PX, getTitlebarLeadingInset } from "./titlebar-overlay";
16
+ import { WindowControls, WINDOWS_CONTROL_GROUP_WIDTH_PX } from "./window-controls";
16
17
  import {
17
18
  createDoubleEscapeCloseState,
18
19
  recordDoubleEscapeClose,
@@ -41,7 +42,9 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
41
42
  typeof document === "undefined" ? true : document.hasFocus()
42
43
  ));
43
44
  const { width, height } = useViewport();
44
- const { cellHeightPx = 18, nativePaneChrome, titleBarOverlay } = useUiCapabilities();
45
+ const { cellHeightPx = 18, nativePaneChrome, titleBarOverlay, windowControls } = useUiCapabilities();
46
+ const showWindowControls = windowControls === "windows";
47
+ const titlebarLeadingInset = titleBarOverlay ? getTitlebarLeadingInset() : 0;
45
48
  const instance = useAppSelector((state) => findPaneInstance(state.config.layout, desktopWindowBridge.paneId) ?? null);
46
49
  const paneDef = instance ? pluginRegistry.panes.get(instance.paneId) ?? null : null;
47
50
  const hasPaneSettings = !!instance && pluginRegistry.hasPaneSettings(instance.instanceId);
@@ -171,8 +174,10 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
171
174
  alignItems="center"
172
175
  flexGrow={1}
173
176
  minWidth={0}
174
- paddingLeft={titleBarOverlay ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 1}
175
- paddingRight={1}
177
+ backgroundColor={titleBackground}
178
+ paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}
179
+ paddingRight={showWindowControls ? 0 : 1}
180
+ style={{ position: "relative" }}
176
181
  >
177
182
  <Box flexGrow={1} minWidth={0} overflow="hidden">
178
183
  <Text fg={paneTitleText(focused, true)} selectable={false} data-gloom-role="pane-title">{title}</Text>
@@ -189,6 +194,8 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
189
194
  {" ... "}
190
195
  </Text>
191
196
  )}
197
+ {showWindowControls ? <Box flexShrink={0} width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`} /> : null}
198
+ {showWindowControls ? <WindowControls windowKind="detached" /> : null}
192
199
  </Box>
193
200
  </Box>
194
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";
@@ -17,7 +17,8 @@ import { formatPercentRaw } from "../../utils/format";
17
17
  import { formatMarketPrice } from "../../market-data/market/format";
18
18
  import { marketStateLabel, marketStateColor, getActiveQuoteDisplay } from "../../market-data/market/status";
19
19
  import { VERSION } from "../../version";
20
- import { TITLEBAR_TRAFFIC_LIGHT_WIDTH } from "./titlebar-overlay";
20
+ import { getTitlebarLeadingInset } from "./titlebar-overlay";
21
+ import { WindowControls, WINDOWS_CONTROL_GROUP_WIDTH_PX } from "./window-controls";
21
22
 
22
23
  const SPY_REFRESH_MS = 5 * 60_000; // 5 min
23
24
  const UPDATE_NOTICE_DURATION_MS = 5_000;
@@ -123,9 +124,12 @@ function UpdateStatus() {
123
124
 
124
125
  export function Header() {
125
126
  useThemeColors();
127
+ const rendererHost = useRendererHost();
126
128
  const baseCurrency = useAppSelector(selectBaseCurrency);
127
129
  const appActive = useAppActive();
128
- const { titleBarOverlay } = useUiCapabilities();
130
+ const { titleBarOverlay, windowControls } = useUiCapabilities();
131
+ const showWindowControls = windowControls === "windows";
132
+ const titlebarLeadingInset = titleBarOverlay ? getTitlebarLeadingInset() : 0;
129
133
  const spyQuoteEntry = useQuoteEntry("SPY", null);
130
134
  const spyQuote = useResolvedEntryValue(spyQuoteEntry);
131
135
 
@@ -147,6 +151,11 @@ export function Header() {
147
151
  ? `SPY ${formatMarketPrice(activeSpyQuote.price, { assetCategory: "ETF" })} ${formatPercentRaw(activeSpyQuote.changePercent)}`
148
152
  : "SPY —";
149
153
 
154
+ const startWindowDrag = useCallback(() => {
155
+ if (!titleBarOverlay) return;
156
+ void rendererHost.startWindowDrag?.();
157
+ }, [rendererHost, titleBarOverlay]);
158
+
150
159
  // Market status
151
160
  const mktState = spyQuote?.marketState;
152
161
  const mktLabel = mktState ? marketStateLabel(mktState) : "";
@@ -161,14 +170,15 @@ export function Header() {
161
170
  backgroundColor={colors.header}
162
171
  data-gloom-role="app-header"
163
172
  data-titlebar-overlay="true"
164
- className="electrobun-webkit-app-region-drag"
173
+ onMouseDown={startWindowDrag}
165
174
  style={{
166
175
  boxShadow: `0 -1px 0 ${colors.header}, inset 0 1px 0 ${colors.header}`,
167
176
  paddingLeft: 8,
168
- paddingRight: 12,
177
+ paddingRight: showWindowControls ? 0 : 12,
178
+ position: "relative",
169
179
  }}
170
180
  >
171
- <Box paddingLeft={TITLEBAR_TRAFFIC_LIGHT_WIDTH} flexDirection="row" alignItems="center" gap={1}>
181
+ <Box paddingLeft={titlebarLeadingInset} flexDirection="row" alignItems="center" gap={1}>
172
182
  <Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
173
183
  Gloomberb
174
184
  </Text>
@@ -195,7 +205,11 @@ export function Header() {
195
205
  <Box paddingRight={1}>
196
206
  <Text fg={spyColor}>{spyText}</Text>
197
207
  </Box>
198
- <Text fg={colors.headerText}>{baseCurrency}</Text>
208
+ <Box paddingRight={showWindowControls ? 1 : 0}>
209
+ <Text fg={colors.headerText}>{baseCurrency}</Text>
210
+ </Box>
211
+ {showWindowControls ? <Box flexShrink={0} width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`} /> : null}
212
+ {showWindowControls ? <WindowControls /> : null}
199
213
  </Box>
200
214
  );
201
215
  }
@@ -207,9 +221,9 @@ export function Header() {
207
221
  backgroundColor={colors.header}
208
222
  data-gloom-role="app-header"
209
223
  data-titlebar-overlay={titleBarOverlay ? "true" : undefined}
210
- className={titleBarOverlay ? "electrobun-webkit-app-region-drag" : undefined}
224
+ onMouseDown={startWindowDrag}
211
225
  >
212
- <Box paddingLeft={titleBarOverlay ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 1}>
226
+ <Box paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}>
213
227
  <Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
214
228
  Gloomberb v{VERSION}
215
229
  </Text>
@@ -230,6 +244,7 @@ export function Header() {
230
244
  {baseCurrency}
231
245
  </Text>
232
246
  </Box>
247
+ {showWindowControls ? <WindowControls /> : null}
233
248
  </Box>
234
249
  );
235
250
  }
@@ -1,2 +1,18 @@
1
1
  export const TITLEBAR_TRAFFIC_LIGHT_WIDTH = 8;
2
2
  export const TITLEBAR_OVERLAY_HEIGHT_PX = 28;
3
+
4
+ function currentPlatform(): string {
5
+ const globalWithNavigator = globalThis as typeof globalThis & {
6
+ navigator?: {
7
+ platform?: string;
8
+ userAgentData?: { platform?: string };
9
+ };
10
+ };
11
+ return globalWithNavigator.navigator?.userAgentData?.platform
12
+ ?? globalWithNavigator.navigator?.platform
13
+ ?? "";
14
+ }
15
+
16
+ export function getTitlebarLeadingInset(platform = currentPlatform()): number {
17
+ return /mac/i.test(platform) ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 0;
18
+ }