gloomberb 0.9.4 → 0.9.6

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 (138) hide show
  1. package/README.md +49 -8
  2. package/package.json +4 -3
  3. package/patches/@opentui%2Fcore@0.3.2.patch +231 -29
  4. package/patches/react-reconciler@0.33.0.patch +10 -0
  5. package/src/app/global-shortcuts.test.tsx +20 -1
  6. package/src/app/global-shortcuts.ts +2 -0
  7. package/src/app/pane-runtime/index.tsx +11 -4
  8. package/src/app/pane-runtime/pane-settings-runtime.tsx +1 -1
  9. package/src/app.tsx +41 -17
  10. package/src/cli/commands/remote.ts +206 -0
  11. package/src/cli/entry.ts +8 -1
  12. package/src/cli/index.ts +2 -0
  13. package/src/cli/native-smoke.ts +5 -0
  14. package/src/components/chart/core/keyboard.ts +10 -1
  15. package/src/components/chart/native/bitmap-support.test.ts +27 -0
  16. package/src/components/chart/native/bitmap-support.ts +55 -0
  17. package/src/components/chart/native/kitty/index.test.ts +47 -0
  18. package/src/components/chart/native/surface/visibility.ts +8 -1
  19. package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
  20. package/src/components/chart/static/chart/bitmap.ts +16 -9
  21. package/src/components/chart/static/chart/surface.test.tsx +59 -4
  22. package/src/components/chart/static/chart/surface.tsx +4 -0
  23. package/src/components/chart/stock-chart/index.test.ts +7 -0
  24. package/src/components/command-bar/list/view.tsx +48 -1
  25. package/src/components/command-bar/multi-select-picker.tsx +7 -0
  26. package/src/components/command-bar/routing/navigation-state.ts +10 -1
  27. package/src/components/command-bar/surface/index.test.tsx +14 -0
  28. package/src/components/command-bar/theme-picker.tsx +91 -79
  29. package/src/components/command-bar/workflow/field-row.tsx +30 -5
  30. package/src/components/data-table/view.tsx +0 -12
  31. package/src/components/index.ts +7 -0
  32. package/src/components/layout/floating-pane.tsx +3 -3
  33. package/src/components/layout/pane/footer/index.tsx +23 -0
  34. package/src/components/layout/pane/header.tsx +5 -0
  35. package/src/components/layout/pane/index.tsx +3 -3
  36. package/src/components/layout/shell/action-menu-overlay.tsx +1 -1
  37. package/src/components/layout/shell/cursor-occlusion.ts +9 -1
  38. package/src/components/layout/shell/index.test.tsx +2 -0
  39. package/src/components/layout/shell/index.tsx +16 -4
  40. package/src/components/layout/shell/pane/actions.ts +21 -7
  41. package/src/components/layout/shell/pane/layers.tsx +2 -2
  42. package/src/components/layout/status-bar.tsx +5 -5
  43. package/src/components/metric-treemap/index.tsx +317 -0
  44. package/src/components/metric-treemap/layout.test.ts +77 -0
  45. package/src/components/metric-treemap/layout.ts +476 -0
  46. package/src/components/pane-settings-dialog/desktop.tsx +1 -1
  47. package/src/components/price-performance.tsx +1 -1
  48. package/src/components/price-sparkline/view.tsx +15 -14
  49. package/src/components/speedometer-gauge/terminal.tsx +18 -15
  50. package/src/components/table-view-shared.tsx +0 -10
  51. package/src/components/ticker/list-table-view.tsx +1 -7
  52. package/src/components/ticker/list-table.test.tsx +0 -4
  53. package/src/components/toggle-list.tsx +10 -0
  54. package/src/components/ui/button.tsx +12 -0
  55. package/src/components/ui/data-table/index.tsx +51 -0
  56. package/src/components/ui/data-table/opentui/index.tsx +4 -10
  57. package/src/components/ui/data-table/types.ts +6 -4
  58. package/src/components/ui/fields.tsx +18 -0
  59. package/src/components/ui/list-view.tsx +84 -4
  60. package/src/components/ui/message-composer.tsx +22 -0
  61. package/src/components/ui/tabs.tsx +46 -2
  62. package/src/components/ui/ui.test.tsx +0 -9
  63. package/src/core/state/app/layout-reducer.ts +7 -1
  64. package/src/core/state/app/layout.ts +6 -0
  65. package/src/core/state/app/state.ts +23 -1
  66. package/src/core/state/app/types.ts +4 -2
  67. package/src/plugins/builtin/account-management/form-components.tsx +2 -2
  68. package/src/plugins/builtin/alerts/pane.tsx +1 -1
  69. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  70. package/src/plugins/builtin/buildout/pane/index.tsx +1 -1
  71. package/src/plugins/builtin/chat/channels.ts +5 -18
  72. package/src/plugins/builtin/chat/chat.test.tsx +1 -7
  73. package/src/plugins/builtin/chat/message/desktop.tsx +1 -1
  74. package/src/plugins/builtin/chat/message/inline-tokens.tsx +1 -1
  75. package/src/plugins/builtin/chat/message/terminal.tsx +2 -2
  76. package/src/plugins/builtin/chat/sidebar.tsx +1 -1
  77. package/src/plugins/builtin/chat/status-widget.tsx +2 -8
  78. package/src/plugins/builtin/cloud/auth-actions.tsx +2 -2
  79. package/src/plugins/builtin/cloud/plugin.tsx +4 -13
  80. package/src/plugins/builtin/correlation/matrix/symbol-cell.tsx +1 -1
  81. package/src/plugins/builtin/debug/index.tsx +3 -4
  82. package/src/plugins/builtin/help/components.tsx +1 -1
  83. package/src/plugins/builtin/holders/treemap.tsx +28 -297
  84. package/src/plugins/builtin/holders/types.ts +0 -34
  85. package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
  86. package/src/plugins/builtin/market-heatmap/data.ts +296 -0
  87. package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
  88. package/src/plugins/builtin/options/table.ts +5 -5
  89. package/src/plugins/builtin/plugin-groups.ts +2 -0
  90. package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
  91. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +41 -0
  92. package/src/plugins/builtin/portfolio-list/pane/index.tsx +78 -22
  93. package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
  94. package/src/plugins/ibkr/gateway/service/index.ts +6 -9
  95. package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
  96. package/src/plugins/ibkr/trade/ticket-panel.tsx +1 -1
  97. package/src/remote/app-host.tsx +77 -0
  98. package/src/remote/client.ts +34 -0
  99. package/src/remote/command-bar.ts +98 -0
  100. package/src/remote/controller-utils.ts +111 -0
  101. package/src/remote/controller.test.ts +365 -0
  102. package/src/remote/controller.ts +434 -0
  103. package/src/remote/endpoint.ts +41 -0
  104. package/src/remote/json-patch.test.ts +31 -0
  105. package/src/remote/json-patch.ts +89 -0
  106. package/src/remote/layout-helpers.ts +62 -0
  107. package/src/remote/resources.ts +186 -0
  108. package/src/remote/revision.ts +18 -0
  109. package/src/remote/schema.ts +143 -0
  110. package/src/remote/semantic-helpers.ts +161 -0
  111. package/src/remote/semantic-tree.tsx +112 -0
  112. package/src/remote/server.test.ts +50 -0
  113. package/src/remote/server.ts +121 -0
  114. package/src/remote/types.ts +119 -0
  115. package/src/renderers/electrobun/bun/index.ts +55 -1
  116. package/src/renderers/electrobun/shared/protocol.ts +11 -1
  117. package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
  118. package/src/renderers/electrobun/view/build-assets.ts +1 -0
  119. package/src/renderers/electrobun/view/data-table/index.tsx +0 -8
  120. package/src/renderers/electrobun/view/data-table/row.tsx +2 -12
  121. package/src/renderers/electrobun/view/host/box.tsx +11 -1
  122. package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
  123. package/src/renderers/electrobun/view/host/mouse.ts +2 -0
  124. package/src/renderers/electrobun/view/host/style.ts +1 -0
  125. package/src/renderers/electrobun/view/main.tsx +5 -1
  126. package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
  127. package/src/renderers/electrobun/view/styles.css +7 -0
  128. package/src/renderers/opentui/chart-surface.test.tsx +101 -0
  129. package/src/renderers/opentui/chart-surface.tsx +15 -20
  130. package/src/renderers/opentui/native-smoke.ts +1 -0
  131. package/src/renderers/opentui/start.tsx +32 -0
  132. package/src/sources/yahoo-finance/http.ts +31 -4
  133. package/src/state/app/context/index.test.tsx +47 -0
  134. package/src/state/app/context/index.tsx +48 -18
  135. package/src/ui/host.tsx +1 -0
  136. package/src/ui/index.tsx +183 -7
  137. package/src/version.ts +1 -1
  138. package/src/plugins/builtin/holders/treemap-layout.ts +0 -231
@@ -2,6 +2,7 @@ import { Box, Span, Text, TextAttributes, useUiCapabilities } from "../../../../
2
2
  import { useRef } from "react";
3
3
  import { colors, blendHex } from "../../../../theme/colors";
4
4
  import { getShortcutHintWidth, ShortcutHint } from "../../../ui/shortcut-hint";
5
+ import { useRemoteUiNode } from "../../../../remote/semantic-tree";
5
6
  import {
6
7
  EMPTY_FOOTER,
7
8
  hasPaneFooterContent,
@@ -50,6 +51,15 @@ function stopMouseEvent(event?: { stopPropagation?: () => void; preventDefault?:
50
51
 
51
52
  function SegmentView({ segment }: { segment: PaneFooterSegment }) {
52
53
  const interactive = !!segment.onPress && !segment.disabled;
54
+ useRemoteUiNode(interactive ? {
55
+ role: "pane-footer-segment",
56
+ label: segment.parts.map((part) => part.text).join(" "),
57
+ disabled: segment.disabled,
58
+ actions: {
59
+ press: () => segment.onPress?.(),
60
+ },
61
+ metadata: { id: segment.id },
62
+ } : null);
53
63
  const attributes = segment.parts.some((part) => part.bold) || interactive ? TextAttributes.BOLD : 0;
54
64
  const triggerMouseDownRef = useRef(false);
55
65
  const startSegmentPress = (event?: { stopPropagation?: () => void; preventDefault?: () => void }) => {
@@ -93,6 +103,19 @@ function totalHintsWidth(hints: PaneHint[]): number {
93
103
  }
94
104
 
95
105
  function HintView({ hint, prefixSpace }: { hint: PaneHint; prefixSpace: boolean }) {
106
+ useRemoteUiNode({
107
+ role: "pane-hint",
108
+ label: `${hint.key}${hint.label}`,
109
+ disabled: hint.disabled,
110
+ actions: {
111
+ press: hint.onPress ? () => hint.onPress?.() : undefined,
112
+ },
113
+ metadata: {
114
+ id: hint.id,
115
+ key: hint.key,
116
+ label: hint.label,
117
+ },
118
+ });
96
119
  return (
97
120
  <ShortcutHint
98
121
  hotkey={hint.key}
@@ -14,6 +14,7 @@ interface PaneHeaderProps {
14
14
  windowModeSelected?: boolean;
15
15
  floating?: boolean;
16
16
  showActions?: boolean;
17
+ onHeaderMouseMove?: (event: any) => void;
17
18
  onHeaderMouseDown?: (event: any) => void;
18
19
  onHeaderMouseDrag?: (event: any) => void;
19
20
  onHeaderMouseDragEnd?: (event: any) => void;
@@ -111,6 +112,7 @@ export function PaneHeader({
111
112
  windowModeSelected = false,
112
113
  floating = false,
113
114
  showActions = false,
115
+ onHeaderMouseMove,
114
116
  onHeaderMouseDown,
115
117
  onHeaderMouseDrag,
116
118
  onHeaderMouseDragEnd,
@@ -143,6 +145,7 @@ export function PaneHeader({
143
145
  data-focused={focused ? "true" : "false"}
144
146
  data-window-mode-selected={windowModeSelected ? "true" : "false"}
145
147
  onMouseDown={onHeaderMouseDown}
148
+ onMouseMove={onHeaderMouseMove}
146
149
  onMouseDrag={onHeaderMouseDrag}
147
150
  onMouseDragEnd={onHeaderMouseDragEnd}
148
151
  onContextMenu={onHeaderContextMenu}
@@ -226,6 +229,7 @@ export function PaneHeader({
226
229
  backgroundColor={backgroundColor}
227
230
  flexDirection="row"
228
231
  onMouseDown={handleTerminalHeaderMouseDown}
232
+ onMouseMove={onHeaderMouseMove}
229
233
  onMouseDrag={onHeaderMouseDrag}
230
234
  onMouseDragEnd={onHeaderMouseDragEnd}
231
235
  >
@@ -263,6 +267,7 @@ export function PaneHeader({
263
267
  backgroundColor={backgroundColor}
264
268
  flexDirection="row"
265
269
  onMouseDown={handleTerminalHeaderMouseDown}
270
+ onMouseMove={onHeaderMouseMove}
266
271
  onMouseDrag={onHeaderMouseDrag}
267
272
  onMouseDragEnd={onHeaderMouseDragEnd}
268
273
  >
@@ -16,7 +16,7 @@ interface PaneWrapperProps {
16
16
  flexGrow?: number;
17
17
  showActions?: boolean;
18
18
  onMouseDown?: (event: any) => void;
19
- onMouseMove?: (event: any) => void;
19
+ onHeaderMouseMove?: (event: any) => void;
20
20
  onHeaderMouseDown?: (event: any) => void;
21
21
  onHeaderMouseDrag?: (event: any) => void;
22
22
  onHeaderMouseDragEnd?: (event: any) => void;
@@ -36,7 +36,7 @@ export function PaneWrapper({
36
36
  flexGrow,
37
37
  showActions = false,
38
38
  onMouseDown,
39
- onMouseMove,
39
+ onHeaderMouseMove,
40
40
  onHeaderMouseDown,
41
41
  onHeaderMouseDrag,
42
42
  onHeaderMouseDragEnd,
@@ -76,7 +76,6 @@ export function PaneWrapper({
76
76
  showBorderColor: true,
77
77
  })}
78
78
  onMouseDown={onMouseDown}
79
- onMouseMove={onMouseMove}
80
79
  >
81
80
  {title && (
82
81
  <PaneHeader
@@ -85,6 +84,7 @@ export function PaneWrapper({
85
84
  focused={focused}
86
85
  windowModeSelected={windowModeSelected}
87
86
  showActions={showActions}
87
+ onHeaderMouseMove={onHeaderMouseMove}
88
88
  onHeaderMouseDown={onHeaderMouseDown}
89
89
  onHeaderMouseDrag={onHeaderMouseDrag}
90
90
  onHeaderMouseDragEnd={onHeaderMouseDragEnd}
@@ -52,7 +52,7 @@ export function ShellActionMenuOverlay({
52
52
  height={1}
53
53
  width={innerWidth}
54
54
  backgroundColor={hovered ? colors.selected : colors.panel}
55
- onMouseMove={() => onHoverItem(item.id)}
55
+ onMouseOver={() => onHoverItem(item.id)}
56
56
  onMouseDown={(mouseEvent: any) => {
57
57
  mouseEvent.stopPropagation();
58
58
  mouseEvent.preventDefault();
@@ -23,6 +23,8 @@ interface ResolveShellCursorOcclusionRectsOptions {
23
23
  width: number;
24
24
  }
25
25
 
26
+ const MAX_RENDERABLE_ANCESTOR_DEPTH = 256;
27
+
26
28
  export function resolveShellCursorOcclusionRects({
27
29
  contentHeight,
28
30
  dragFloatingRect,
@@ -102,7 +104,13 @@ function readRenderableBounds(renderable: unknown): LayoutBounds | null {
102
104
 
103
105
  function readRenderablePaneId(renderable: unknown): string | null {
104
106
  let current = renderable;
105
- while (current && typeof current === "object") {
107
+ const seen = new Set<object>();
108
+ let depth = 0;
109
+
110
+ while (current && typeof current === "object" && !seen.has(current)) {
111
+ if (depth >= MAX_RENDERABLE_ANCESTOR_DEPTH) return null;
112
+ seen.add(current);
113
+ depth += 1;
106
114
  const record = current as Record<string, unknown>;
107
115
  const paneId = record["data-gloom-pane-id"];
108
116
  if (typeof paneId === "string") return paneId;
@@ -1271,6 +1271,7 @@ describe("Shell", () => {
1271
1271
  layouts: [{ name: "Default", layout: cloneLayout(mixedLayout) }],
1272
1272
  }),
1273
1273
  focusedPaneId: "ticker-detail:main",
1274
+ previousFocusedPaneId: "portfolio-list:main",
1274
1275
  };
1275
1276
  const actions: Array<any> = [];
1276
1277
 
@@ -1294,6 +1295,7 @@ describe("Shell", () => {
1294
1295
  expect(updateLayout?.layout.instances.map((instance: { instanceId: string }) => instance.instanceId)).toEqual(["portfolio-list:main"]);
1295
1296
  expect(updateLayout?.layout.floating).toEqual([]);
1296
1297
  expect(updateLayout?.layout.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
1298
+ expect(updateLayout?.focusedPaneId).toBe("portfolio-list:main");
1297
1299
  });
1298
1300
 
1299
1301
  test("closes the focused floating pane with Cmd+W", async () => {
@@ -95,6 +95,7 @@ export function Shell({
95
95
  const config = useAppSelector((state) => state.config);
96
96
  const paneState = useAppSelector((state) => state.paneState);
97
97
  const focusedPaneId = useAppSelector(selectFocusedPaneId);
98
+ const previousFocusedPaneId = useAppSelector((state) => state.previousFocusedPaneId);
98
99
  const activePanel = useAppSelector((state) => state.activePanel);
99
100
  const commandBarOpen = useAppSelector(selectCommandBarOpen);
100
101
  const inputCaptured = useAppSelector((state) => state.inputCaptured);
@@ -154,15 +155,18 @@ export function Shell({
154
155
  ), [nativePaneChrome]);
155
156
  const bounds = useMemo<LayoutBounds>(() => ({ x: 0, y: 0, width, height: contentHeight }), [contentHeight, width]);
156
157
 
157
- const persistLayout = useCallback((nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => {
158
+ const persistLayout = useCallback((nextLayout: LayoutConfig, options?: { pushHistory?: boolean; focusedPaneId?: string | null }) => {
158
159
  if (options?.pushHistory !== false) {
159
160
  dispatch({ type: "PUSH_LAYOUT_HISTORY" });
160
161
  }
161
- dispatch({ type: "UPDATE_LAYOUT", layout: nextLayout });
162
+ const hasFocusTarget = !!options && Object.prototype.hasOwnProperty.call(options, "focusedPaneId");
163
+ dispatch(hasFocusTarget
164
+ ? { type: "UPDATE_LAYOUT", layout: nextLayout, focusedPaneId: options.focusedPaneId ?? null }
165
+ : { type: "UPDATE_LAYOUT", layout: nextLayout });
162
166
  scheduleConfigSave(syncConfigActiveLayoutState(
163
167
  { ...config, layout: nextLayout },
164
168
  paneState,
165
- focusedPaneId,
169
+ hasFocusTarget ? (options.focusedPaneId ?? null) : focusedPaneId,
166
170
  activePanel,
167
171
  ));
168
172
  }, [activePanel, config, dispatch, focusedPaneId, paneState]);
@@ -261,6 +265,7 @@ export function Shell({
261
265
  nativePaneChrome,
262
266
  paneMap,
263
267
  persistLayout,
268
+ previousFocusedPaneId,
264
269
  pluginRegistry,
265
270
  rendererHost,
266
271
  visibleLayout,
@@ -525,7 +530,14 @@ export function Shell({
525
530
  height={nativePaneChrome ? undefined : contentHeight}
526
531
  position={nativePaneChrome ? "relative" : undefined}
527
532
  overflow="hidden"
528
- {...(!nativePaneChrome ? { onMouse: handleMouse } : {})}
533
+ {...(!nativePaneChrome
534
+ ? {
535
+ onMouseDown: handleMouse,
536
+ onMouseDrag: handleMouse,
537
+ onMouseDragEnd: handleMouse,
538
+ onMouseUp: handleMouse,
539
+ }
540
+ : {})}
529
541
  >
530
542
  <Box
531
543
  position="absolute"
@@ -14,6 +14,15 @@ import type { LayoutConfig } from "../../../../types/config";
14
14
  import type { RendererHost } from "../../../../ui";
15
15
  import { capturePaneScreenshotPngBase64 } from "../../../../utils/dom-screenshot";
16
16
 
17
+ function removedFocusRestoreOptions(
18
+ layout: LayoutConfig,
19
+ currentFocusedPaneId: string | null,
20
+ paneId: string | null,
21
+ ): { focusedPaneId: string } | undefined {
22
+ if (currentFocusedPaneId && isPaneInLayout(layout, currentFocusedPaneId)) return undefined;
23
+ return paneId && isPaneInLayout(layout, paneId) ? { focusedPaneId: paneId } : undefined;
24
+ }
25
+
17
26
  interface UseShellPaneActionsOptions {
18
27
  closePaneMenu: () => void;
19
28
  contentHeight: number;
@@ -22,7 +31,8 @@ interface UseShellPaneActionsOptions {
22
31
  focusPane: (paneId: string) => void;
23
32
  nativePaneChrome: boolean;
24
33
  paneMap: Map<string, ResolvedPane>;
25
- persistLayout: (nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => void;
34
+ persistLayout: (nextLayout: LayoutConfig, options?: { pushHistory?: boolean; focusedPaneId?: string | null }) => void;
35
+ previousFocusedPaneId: string | null;
26
36
  pluginRegistry: PluginRegistry;
27
37
  rendererHost: RendererHost;
28
38
  visibleLayout: LayoutConfig;
@@ -38,6 +48,7 @@ export function useShellPaneActions({
38
48
  nativePaneChrome,
39
49
  paneMap,
40
50
  persistLayout,
51
+ previousFocusedPaneId,
41
52
  pluginRegistry,
42
53
  rendererHost,
43
54
  visibleLayout,
@@ -70,15 +81,17 @@ export function useShellPaneActions({
70
81
 
71
82
  const closeFocusedPane = useCallback(() => {
72
83
  if (!focusedPaneId || !isPaneInLayout(visibleLayout, focusedPaneId)) return false;
73
- persistLayout(removePane(visibleLayout, focusedPaneId));
84
+ const nextLayout = removePane(visibleLayout, focusedPaneId);
85
+ persistLayout(nextLayout, removedFocusRestoreOptions(nextLayout, focusedPaneId, previousFocusedPaneId));
74
86
  return true;
75
- }, [focusedPaneId, persistLayout, visibleLayout]);
87
+ }, [focusedPaneId, persistLayout, previousFocusedPaneId, visibleLayout]);
76
88
 
77
89
  const closeAllFloatingPanes = useCallback(() => {
78
90
  if (visibleLayout.floating.length === 0) return false;
79
- persistLayout(removeFloatingPanes(visibleLayout));
91
+ const nextLayout = removeFloatingPanes(visibleLayout);
92
+ persistLayout(nextLayout, removedFocusRestoreOptions(nextLayout, focusedPaneId, previousFocusedPaneId));
80
93
  return true;
81
- }, [persistLayout, visibleLayout]);
94
+ }, [focusedPaneId, persistLayout, previousFocusedPaneId, visibleLayout]);
82
95
 
83
96
  const copyFocusedPaneScreenshot = useCallback(() => {
84
97
  if (!focusedPaneId || !nativePaneChrome || !rendererHost.copyPngImage) return false;
@@ -118,8 +131,9 @@ export function useShellPaneActions({
118
131
  }, [contentHeight, persistLayout, visibleLayout, width]);
119
132
 
120
133
  const handleFloatingClose = useCallback((paneId: string) => {
121
- persistLayout(removePane(visibleLayout, paneId));
122
- }, [persistLayout, visibleLayout]);
134
+ const nextLayout = removePane(visibleLayout, paneId);
135
+ persistLayout(nextLayout, removedFocusRestoreOptions(nextLayout, focusedPaneId, previousFocusedPaneId));
136
+ }, [focusedPaneId, persistLayout, previousFocusedPaneId, visibleLayout]);
123
137
 
124
138
  return {
125
139
  closeAllFloatingPanes,
@@ -132,7 +132,7 @@ export function ShellPaneLayers({
132
132
  windowModeSelected={windowModeSelected}
133
133
  footer={footer}
134
134
  onMouseDown={nativePaneChrome ? (event) => handleNativePaneMouseDown(leaf.instanceId, event) : undefined}
135
- onMouseMove={() => setHoveredPaneIfChanged(leaf.instanceId)}
135
+ onHeaderMouseMove={() => setHoveredPaneIfChanged(leaf.instanceId)}
136
136
  onHeaderMouseDown={nativePaneChrome && !transientFocusActive ? (event) => startNativeDockedDrag(leaf.instanceId, rect, event) : undefined}
137
137
  onHeaderMouseDrag={nativePaneChrome && !transientFocusActive ? handleNativeDrag : undefined}
138
138
  onHeaderMouseDragEnd={nativePaneChrome && !transientFocusActive ? handleNativeDrag : undefined}
@@ -192,7 +192,7 @@ export function ShellPaneLayers({
192
192
  showActions={showActions}
193
193
  footer={footer}
194
194
  onMouseDown={nativePaneChrome ? (event) => handleNativePaneMouseDown(pane.instance.instanceId, event) : undefined}
195
- onMouseMove={() => setHoveredPaneIfChanged(pane.instance.instanceId)}
195
+ onHeaderMouseMove={() => setHoveredPaneIfChanged(pane.instance.instanceId)}
196
196
  onHeaderMouseDown={nativePaneChrome ? (event) => startNativeFloatingDrag(pane.instance.instanceId, preview, event) : undefined}
197
197
  onHeaderMouseDrag={nativePaneChrome ? handleNativeDrag : undefined}
198
198
  onHeaderMouseDragEnd={nativePaneChrome ? handleNativeDrag : undefined}
@@ -364,7 +364,7 @@ function CommandBarHint({
364
364
  <Text
365
365
  fg={hovered ? colors.text : colors.textDim}
366
366
  {...(!nativePaneChrome ? { bg: hovered ? hoverBg() : undefined } : {})}
367
- onMouseMove={() => setHoveredControl((current) => (current === "command-bar" ? current : "command-bar"))}
367
+ onMouseOver={() => setHoveredControl((current) => (current === "command-bar" ? current : "command-bar"))}
368
368
  onMouseDown={openCommandBar}
369
369
  {...(nativePaneChrome ? { "data-gloom-interactive": "true" } : {})}
370
370
  >
@@ -385,7 +385,7 @@ function NativeGridlockTip({
385
385
  <Text
386
386
  fg={hoveredControl === "gridlock-tip" ? colors.textBright : colors.borderFocused}
387
387
  attributes={TextAttributes.BOLD}
388
- onMouseMove={() => setHoveredControl((current) => (current === "gridlock-tip" ? current : "gridlock-tip"))}
388
+ onMouseOver={() => setHoveredControl((current) => (current === "gridlock-tip" ? current : "gridlock-tip"))}
389
389
  onMouseDown={handleGridlockTip}
390
390
  data-gloom-interactive="true"
391
391
  >
@@ -393,7 +393,7 @@ function NativeGridlockTip({
393
393
  </Text>
394
394
  <Text
395
395
  fg={hoveredControl === "gridlock-tip-dismiss" ? colors.text : colors.textDim}
396
- onMouseMove={() => setHoveredControl((current) => (current === "gridlock-tip-dismiss" ? current : "gridlock-tip-dismiss"))}
396
+ onMouseOver={() => setHoveredControl((current) => (current === "gridlock-tip-dismiss" ? current : "gridlock-tip-dismiss"))}
397
397
  onMouseDown={dismissGridlockTip}
398
398
  data-gloom-interactive="true"
399
399
  >
@@ -415,14 +415,14 @@ function TerminalGridlockTip({
415
415
  <Box width={1} />
416
416
  <Box
417
417
  backgroundColor={hoveredControl === "gridlock-tip" ? hoverBg() : colors.header}
418
- onMouseMove={() => setHoveredControl((current) => (current === "gridlock-tip" ? current : "gridlock-tip"))}
418
+ onMouseOver={() => setHoveredControl((current) => (current === "gridlock-tip" ? current : "gridlock-tip"))}
419
419
  onMouseDown={handleGridlockTip}
420
420
  >
421
421
  <Text fg={colors.headerText}> Gridlock All </Text>
422
422
  </Box>
423
423
  <Text
424
424
  fg={hoveredControl === "gridlock-tip-dismiss" ? colors.text : colors.textDim}
425
- onMouseMove={() => setHoveredControl((current) => (current === "gridlock-tip-dismiss" ? current : "gridlock-tip-dismiss"))}
425
+ onMouseOver={() => setHoveredControl((current) => (current === "gridlock-tip-dismiss" ? current : "gridlock-tip-dismiss"))}
426
426
  onMouseDown={dismissGridlockTip}
427
427
  >
428
428
  {" x"}
@@ -0,0 +1,317 @@
1
+ import { useMemo, useState, type CSSProperties } from "react";
2
+ import { Box, Text, TextAttributes, useUiCapabilities } from "../../ui";
3
+ import { blendHex, colors, priceColor } from "../../theme/colors";
4
+ import { blendForContrast, higherContrast } from "../../theme/color-utils";
5
+ import { padTo } from "../../utils/format";
6
+ import {
7
+ buildMetricTreemap,
8
+ buildMetricTreemapRects,
9
+ type FloatMetricTreemapTile,
10
+ type MetricTreemapItem,
11
+ type MetricTreemapTile,
12
+ } from "./layout";
13
+
14
+ export {
15
+ buildMetricTreemapNavigationTiles,
16
+ findMetricTreemapNeighbor,
17
+ type MetricTreemapDirection,
18
+ type MetricTreemapItem,
19
+ } from "./layout";
20
+
21
+ type PreventableMouseEvent = { preventDefault(): void };
22
+
23
+ export interface MetricTreemapSurfaceProps<T> {
24
+ items: MetricTreemapItem<T>[];
25
+ width: number;
26
+ height: number;
27
+ selectedId: string | null;
28
+ onSelect: (item: MetricTreemapItem<T>) => void;
29
+ onActivate?: (item: MetricTreemapItem<T>) => void;
30
+ emptyStateTitle?: string;
31
+ }
32
+
33
+ function metricTileColor(item: MetricTreemapItem): string {
34
+ if (item.colorValue == null) return colors.neutral;
35
+ return priceColor(item.colorValue);
36
+ }
37
+
38
+ function desktopMetricTileColor(item: MetricTreemapItem): string {
39
+ if (item.colorValue == null || item.colorValue === 0) {
40
+ return colors.neutral;
41
+ }
42
+ const intensity = Math.max(0.38, Math.min(0.88, Math.log1p(Math.abs(item.colorValue)) / Math.log1p(30)));
43
+ return blendHex(colors.panel, item.colorValue > 0 ? colors.positive : colors.negative, intensity);
44
+ }
45
+
46
+ const TILE_TEXT_MIN_CONTRAST = 4.5;
47
+
48
+ function tileTextColor(backgroundColor: string): string {
49
+ const preferred = higherContrast(
50
+ higherContrast(colors.text, colors.textBright, backgroundColor),
51
+ colors.selectedText,
52
+ backgroundColor,
53
+ );
54
+ const fallback = higherContrast("#000000", "#ffffff", backgroundColor);
55
+ return blendForContrast(preferred, backgroundColor, fallback, TILE_TEXT_MIN_CONTRAST);
56
+ }
57
+
58
+ function visibleLines(item: MetricTreemapItem): string[] {
59
+ return [
60
+ item.label,
61
+ item.primaryText,
62
+ item.secondaryText,
63
+ item.tertiaryText,
64
+ ].filter((line): line is string => !!line);
65
+ }
66
+
67
+ function Tile<T>({ tile, selected, onSelect, onActivate }: {
68
+ tile: MetricTreemapTile<T>;
69
+ selected: boolean;
70
+ onSelect: () => void;
71
+ onActivate?: () => void;
72
+ }) {
73
+ const renderWidth = Math.max(1, tile.width - (tile.width > 2 ? 1 : 0));
74
+ const renderHeight = Math.max(1, tile.height - (tile.height > 2 ? 1 : 0));
75
+ const innerWidth = Math.max(1, renderWidth - 1);
76
+ const backgroundColor = selected ? colors.selected : metricTileColor(tile.item);
77
+ const textColor = tileTextColor(backgroundColor);
78
+ const attributes = selected ? TextAttributes.BOLD : TextAttributes.NONE;
79
+ const lines = visibleLines(tile.item);
80
+
81
+ return (
82
+ <Box
83
+ position="absolute"
84
+ left={tile.x}
85
+ top={tile.y}
86
+ width={renderWidth}
87
+ height={renderHeight}
88
+ backgroundColor={backgroundColor}
89
+ onMouseDown={(event: PreventableMouseEvent) => {
90
+ event.preventDefault();
91
+ onSelect();
92
+ }}
93
+ onMouseOver={onSelect}
94
+ onMouseMove={onSelect}
95
+ onDoubleClick={onActivate}
96
+ >
97
+ {lines.slice(0, renderHeight).map((line, index) => (
98
+ <Text key={`${tile.item.id}:${index}`} fg={textColor} attributes={attributes}>
99
+ {padTo(line, innerWidth)}
100
+ </Text>
101
+ ))}
102
+ </Box>
103
+ );
104
+ }
105
+
106
+ function pct(value: number, total: number): string {
107
+ return `${total > 0 ? value / total * 100 : 0}%`;
108
+ }
109
+
110
+ function DesktopTile<T>({ tile, chartWidth, chartHeight, selected, hovered, onSelect, onActivate, onHover }: {
111
+ tile: FloatMetricTreemapTile<T>;
112
+ chartWidth: number;
113
+ chartHeight: number;
114
+ selected: boolean;
115
+ hovered: boolean;
116
+ onSelect: () => void;
117
+ onActivate?: () => void;
118
+ onHover: (hovered: boolean) => void;
119
+ }) {
120
+ const lines = visibleLines(tile.item);
121
+ const canShowDetails = tile.width >= 7 && tile.height >= 3;
122
+ const canShowExtra = tile.height >= 3.7;
123
+ const canShowLabel = tile.width >= 4 && tile.height >= 1.4;
124
+ const isTiny = tile.width < 5 || tile.height < 2;
125
+ const backgroundColor = desktopMetricTileColor(tile.item);
126
+ const textColor = tileTextColor(backgroundColor);
127
+ const style: CSSProperties = {
128
+ position: "absolute",
129
+ left: `calc(${pct(tile.x, chartWidth)} + 1px)`,
130
+ top: `calc(${pct(tile.y, chartHeight)} + 1px)`,
131
+ width: `max(1px, calc(${pct(tile.width, chartWidth)} - 2px))`,
132
+ height: `max(1px, calc(${pct(tile.height, chartHeight)} - 2px))`,
133
+ minWidth: 1,
134
+ minHeight: 1,
135
+ padding: isTiny ? "3px 4px" : "7px 8px",
136
+ overflow: "hidden",
137
+ borderRadius: 5,
138
+ border: selected
139
+ ? `1px solid ${colors.textBright}`
140
+ : hovered
141
+ ? `1px solid ${blendHex(colors.textBright, backgroundColor, 0.55)}`
142
+ : `1px solid ${blendHex(backgroundColor, colors.textBright, 0.11)}`,
143
+ boxShadow: selected
144
+ ? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.55)}`
145
+ : hovered
146
+ ? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.18)}`
147
+ : "none",
148
+ backgroundColor,
149
+ color: textColor,
150
+ display: "flex",
151
+ flexDirection: "column",
152
+ alignItems: "stretch",
153
+ justifyContent: "flex-start",
154
+ gap: 2,
155
+ cursor: "pointer",
156
+ transition: "border-color 120ms ease, box-shadow 120ms ease, filter 120ms ease",
157
+ filter: hovered ? "brightness(1.08)" : undefined,
158
+ };
159
+ const textStyle: CSSProperties = {
160
+ display: "block",
161
+ minWidth: 0,
162
+ width: "100%",
163
+ overflow: "hidden",
164
+ textOverflow: "ellipsis",
165
+ whiteSpace: "nowrap",
166
+ lineHeight: 1.1,
167
+ letterSpacing: 0,
168
+ textShadow: `0 1px 1px ${blendHex(backgroundColor, colors.bg, 0.35)}`,
169
+ };
170
+ const startHover = () => {
171
+ onHover(true);
172
+ onSelect();
173
+ };
174
+
175
+ return (
176
+ <Box
177
+ data-gloom-role="metric-treemap-desktop-tile"
178
+ style={style}
179
+ onMouseDown={(event: PreventableMouseEvent) => {
180
+ event.preventDefault();
181
+ onSelect();
182
+ }}
183
+ onDoubleClick={onActivate}
184
+ onMouseOver={startHover}
185
+ onMouseMove={startHover}
186
+ onMouseOut={() => onHover(false)}
187
+ >
188
+ {canShowLabel && (
189
+ <Text
190
+ fg={textColor}
191
+ attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}
192
+ style={{
193
+ ...textStyle,
194
+ fontSize: isTiny ? 11 : 13,
195
+ fontWeight: 700,
196
+ }}
197
+ >
198
+ {lines[0]}
199
+ </Text>
200
+ )}
201
+ {canShowDetails && lines.slice(1, canShowExtra ? 4 : 3).map((line, index) => (
202
+ <Text key={`${tile.item.id}:detail:${index}`} fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>
203
+ {line}
204
+ </Text>
205
+ ))}
206
+ </Box>
207
+ );
208
+ }
209
+
210
+ function DesktopMetricTreemapSurface<T>({ items, width, height, selectedId, onSelect, onActivate, cellAspect, emptyStateTitle }: MetricTreemapSurfaceProps<T> & {
211
+ cellAspect: number;
212
+ }) {
213
+ const [hoveredId, setHoveredId] = useState<string | null>(null);
214
+ const chartWidth = Math.max(1, width - 2);
215
+ const tiles = useMemo(() => buildMetricTreemapRects(items, chartWidth, height, cellAspect), [cellAspect, chartWidth, height, items]);
216
+
217
+ if (tiles.length === 0) {
218
+ return (
219
+ <Box width={width} height={height} paddingX={1} paddingY={1}>
220
+ <Text fg={colors.textDim}>{emptyStateTitle ?? "No chart data"}</Text>
221
+ </Box>
222
+ );
223
+ }
224
+
225
+ return (
226
+ <Box
227
+ width={width}
228
+ height={height}
229
+ paddingX={1}
230
+ style={{
231
+ backgroundColor: colors.bg,
232
+ overflow: "hidden",
233
+ }}
234
+ >
235
+ <Box
236
+ data-gloom-role="metric-treemap"
237
+ width={chartWidth}
238
+ height={height}
239
+ style={{
240
+ position: "relative",
241
+ width: "100%",
242
+ height: "100%",
243
+ backgroundColor: colors.bg,
244
+ overflow: "hidden",
245
+ }}
246
+ >
247
+ {tiles.map((tile) => (
248
+ <DesktopTile
249
+ key={tile.item.id}
250
+ tile={tile}
251
+ chartWidth={chartWidth}
252
+ chartHeight={height}
253
+ selected={tile.item.id === selectedId}
254
+ hovered={tile.item.id === hoveredId}
255
+ onSelect={() => onSelect(tile.item)}
256
+ onActivate={onActivate ? () => onActivate(tile.item) : undefined}
257
+ onHover={(isHovered) => setHoveredId((current) => (isHovered ? tile.item.id : current === tile.item.id ? null : current))}
258
+ />
259
+ ))}
260
+ </Box>
261
+ </Box>
262
+ );
263
+ }
264
+
265
+ export function MetricTreemapSurface<T>({
266
+ items,
267
+ width,
268
+ height,
269
+ selectedId,
270
+ onSelect,
271
+ onActivate,
272
+ emptyStateTitle,
273
+ }: MetricTreemapSurfaceProps<T>) {
274
+ const { cellWidthPx = 8, cellHeightPx = 18, nativePaneChrome } = useUiCapabilities();
275
+ const chartWidth = Math.max(1, width - 2);
276
+ const cellAspect = Math.max(0.5, Math.min(4, cellHeightPx / Math.max(1, cellWidthPx)));
277
+ const tiles = useMemo(() => buildMetricTreemap(items, chartWidth, height, cellAspect), [cellAspect, chartWidth, height, items]);
278
+
279
+ if (nativePaneChrome) {
280
+ return (
281
+ <DesktopMetricTreemapSurface
282
+ items={items}
283
+ width={width}
284
+ height={height}
285
+ selectedId={selectedId}
286
+ onSelect={onSelect}
287
+ onActivate={onActivate}
288
+ cellAspect={cellAspect}
289
+ emptyStateTitle={emptyStateTitle}
290
+ />
291
+ );
292
+ }
293
+
294
+ if (tiles.length === 0) {
295
+ return (
296
+ <Box width={width} height={height} paddingX={1} paddingY={1}>
297
+ <Text fg={colors.textDim}>{emptyStateTitle ?? "No chart data"}</Text>
298
+ </Box>
299
+ );
300
+ }
301
+
302
+ return (
303
+ <Box width={width} height={height} paddingX={1} backgroundColor={colors.bg}>
304
+ <Box position="relative" width={chartWidth} height={height} backgroundColor={colors.bg}>
305
+ {tiles.map((tile) => (
306
+ <Tile
307
+ key={tile.item.id}
308
+ tile={tile}
309
+ selected={tile.item.id === selectedId}
310
+ onSelect={() => onSelect(tile.item)}
311
+ onActivate={onActivate ? () => onActivate(tile.item) : undefined}
312
+ />
313
+ ))}
314
+ </Box>
315
+ </Box>
316
+ );
317
+ }