gloomberb 0.16.2 → 0.16.3

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 (124) hide show
  1. package/package.json +1 -1
  2. package/src/brokers/account-cache.ts +45 -0
  3. package/src/cli/command-options.ts +74 -0
  4. package/src/cli/commands/command-utils.ts +29 -0
  5. package/src/cli/commands/market.ts +170 -54
  6. package/src/cli/commands/overview.ts +62 -19
  7. package/src/cli/commands/ticker.ts +138 -43
  8. package/src/cli/desktop-pane-shot.ts +3 -0
  9. package/src/cli/help.ts +26 -6
  10. package/src/cli/history-rows.ts +187 -15
  11. package/src/cli/index.ts +12 -7
  12. package/src/cli/options.ts +5 -1
  13. package/src/cli/pane-functions/capabilities.ts +5 -0
  14. package/src/cli/pane-functions/dom.ts +69 -16
  15. package/src/cli/pane-functions/function-help.ts +4 -2
  16. package/src/cli/pane-functions/headless.ts +163 -31
  17. package/src/cli/pane-functions/index.ts +9 -2
  18. package/src/cli/pane-functions/report.ts +8 -1
  19. package/src/cli/pane-functions/resolver.ts +28 -2
  20. package/src/cli/pane-functions/screenshot.ts +4 -0
  21. package/src/cli/registry.ts +6 -3
  22. package/src/cli/report-tables.ts +460 -0
  23. package/src/cli/result.ts +13 -3
  24. package/src/components/data-table/export.ts +6 -51
  25. package/src/components/data-table/view.tsx +3 -1
  26. package/src/components/layout/shell/active-drag.ts +67 -35
  27. package/src/components/layout/shell/cursor-occlusion.ts +6 -5
  28. package/src/components/layout/shell/drag/index.ts +12 -7
  29. package/src/components/layout/shell/drag/live.ts +122 -0
  30. package/src/components/layout/shell/drag/overlays.tsx +179 -35
  31. package/src/components/layout/shell/drag/runtime.ts +62 -50
  32. package/src/components/layout/shell/drag/slide.ts +10 -0
  33. package/src/components/layout/shell/index.tsx +37 -51
  34. package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
  35. package/src/components/layout/shell/native/surfaces.ts +30 -14
  36. package/src/components/layout/shell/pane/layers.tsx +419 -179
  37. package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
  38. package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
  39. package/src/data/json-persistence.ts +17 -3
  40. package/src/data/json-storage.ts +2 -0
  41. package/src/data/memory-resource-store.ts +46 -0
  42. package/src/market-data/market/freshness.ts +45 -0
  43. package/src/market-data/quotes/freshness.ts +5 -0
  44. package/src/plugins/builtin/analytics/pane-model.ts +10 -6
  45. package/src/plugins/builtin/attention/pane.tsx +1 -1
  46. package/src/plugins/builtin/catalysts/detail.tsx +1 -1
  47. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  48. package/src/plugins/builtin/holders/client.ts +3 -6
  49. package/src/plugins/builtin/holders/format.ts +31 -1
  50. package/src/plugins/builtin/holders/headless.ts +26 -5
  51. package/src/plugins/builtin/holders/pane.tsx +22 -13
  52. package/src/plugins/builtin/holders/report-header.ts +46 -5
  53. package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
  54. package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
  55. package/src/plugins/builtin/members/pane.tsx +10 -0
  56. package/src/plugins/builtin/perps/pane.tsx +1 -1
  57. package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
  58. package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
  59. package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
  60. package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
  61. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  62. package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
  63. package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
  64. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
  65. package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
  66. package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
  67. package/src/plugins/builtin/shared/members-menu.tsx +260 -0
  68. package/src/plugins/builtin/short-volume/pane.tsx +1 -1
  69. package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
  70. package/src/plugins/builtin/themes/pane.tsx +3 -0
  71. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
  72. package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
  73. package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
  74. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
  75. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
  76. package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
  77. package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
  78. package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
  79. package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
  80. package/src/plugins/builtin/world-venue-map/index.ts +5 -5
  81. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
  82. package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
  83. package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
  84. package/src/plugins/builtin/world-venue-map/model.ts +16 -0
  85. package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
  86. package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
  87. package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
  88. package/src/renderers/browser/app-services.ts +3 -1
  89. package/src/renderers/dom/data-table/dom.ts +31 -0
  90. package/src/renderers/dom/data-table/index.tsx +5 -0
  91. package/src/renderers/dom/host/box.tsx +113 -17
  92. package/src/renderers/dom/host/drag-shield.ts +24 -0
  93. package/src/renderers/dom/host/live-frame.ts +54 -0
  94. package/src/renderers/dom/host/mouse.ts +8 -1
  95. package/src/renderers/dom/host/scroll-box.tsx +6 -5
  96. package/src/renderers/dom/host/tabs.tsx +3 -2
  97. package/src/renderers/dom/styles.css +13 -5
  98. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
  99. package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
  100. package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
  101. package/src/renderers/electrobun/shared/protocol.ts +10 -1
  102. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
  103. package/src/renderers/electrobun/view/host/native.ts +0 -10
  104. package/src/renderers/electrobun/view/host/window-move.ts +28 -0
  105. package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
  106. package/src/renderers/electrobun/view/ui-host.tsx +2 -5
  107. package/src/renderers/opentui/ui-host.tsx +7 -1
  108. package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
  109. package/src/sources/provider-router/batches.ts +3 -3
  110. package/src/sources/provider-router/financial-routes.ts +2 -2
  111. package/src/sources/provider-router/financials.ts +70 -7
  112. package/src/sources/provider-router/primary.ts +2 -2
  113. package/src/state/pane-scroll-registry.tsx +67 -35
  114. package/src/test-support/margin-account.ts +108 -0
  115. package/src/time-series/resolution.ts +15 -0
  116. package/src/types/financials.ts +32 -3
  117. package/src/ui/host.tsx +12 -0
  118. package/src/ui/index.tsx +8 -3
  119. package/src/ui/scroll-box-axis.ts +26 -0
  120. package/src/utils/currency-units.ts +8 -3
  121. package/src/utils/depositary-receipt.ts +54 -10
  122. package/src/utils/display-number.ts +63 -0
  123. package/src/utils/fundamentals.ts +17 -1
  124. package/src/utils/price-history-integrity.ts +19 -6
@@ -1,4 +1,4 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import {
3
3
  floatAtRect,
4
4
  getRememberedFloatingRect,
@@ -7,6 +7,7 @@ import {
7
7
  type DockGeometryOptions,
8
8
  type DockLeafLayout,
9
9
  type DropTarget,
10
+ type FloatingRect,
10
11
  type LayoutBounds,
11
12
  type ResolvedPane,
12
13
  } from "../../../layout/pane-manager";
@@ -23,6 +24,7 @@ import {
23
24
  resolveHoverOverlay,
24
25
  resolvePaneDragFloatingRect,
25
26
  resolveSnapGuide,
27
+ type DragPreview,
26
28
  } from "./drag";
27
29
  import type { ShellDragRuntimeState, ShellMouseEvent } from "./drag/runtime";
28
30
  import type { WindowEditState } from "../window-edit/mode";
@@ -65,37 +67,74 @@ export function useShellActiveDrag({
65
67
  windowMode,
66
68
  }: UseShellActiveDragOptions) {
67
69
  const {
68
- dividerPreviewRef,
69
- dockPreviewRef,
70
+ cancelActiveDrag,
70
71
  dragRef,
71
- setDragCursor,
72
+ live,
73
+ setHoverTarget,
72
74
  updateDividerPreview,
73
75
  updateDockPreview,
74
76
  updateDragFloatingRect,
75
77
  } = dragRuntime;
78
+ // What one drag keeps between moves: a docked pane's floating size, and the
79
+ // last drop it simulated, so a move over the same target computes nothing.
80
+ const memoRef = useRef<{
81
+ drag: unknown;
82
+ baseRect: FloatingRect | null;
83
+ dropKey: string | null;
84
+ drop: DragPreview | null;
85
+ }>({ drag: null, baseRect: null, dropKey: null, drop: null });
76
86
 
77
87
  return useCallback((event: ShellMouseEvent) => {
78
88
  const shellY = event.y - appHeaderHeight;
79
89
  const preciseX = event.preciseX ?? event.x;
80
90
  const preciseShellY = (event.preciseY ?? event.y) - appHeaderHeight;
91
+ // Drawn a moment ahead where the host predicts the pointer; hit tests and
92
+ // the release use where it is.
93
+ const drawX = event.predictedX ?? preciseX;
94
+ const drawShellY = event.predictedY != null ? event.predictedY - appHeaderHeight : preciseShellY;
81
95
  const hitX = precisePointer ? preciseX : event.x;
82
96
  const hitShellY = precisePointer ? preciseShellY : shellY;
83
97
  const dragThreshold = precisePointer ? PRECISE_PANE_DRAG_THRESHOLD : PANE_DRAG_THRESHOLD;
84
98
  const drag = dragRef.current;
85
99
  if (!drag) return;
86
100
  const baseLayout = windowMode?.previewLayout ?? visibleLayout;
101
+ const memo = memoRef.current;
102
+ if (memo.drag !== drag) {
103
+ memo.drag = drag;
104
+ memo.baseRect = null;
105
+ memo.dropKey = null;
106
+ memo.drop = null;
107
+ }
108
+ const dividerRatioAt = (x: number, y: number) => {
109
+ if (drag.type !== "divider") return 0;
110
+ const total = drag.axis === "horizontal" ? drag.bounds.width : drag.bounds.height;
111
+ const delta = drag.axis === "horizontal" ? x - drag.startX : y - drag.startY;
112
+ return Math.max(0.1, Math.min(0.9, drag.startRatio + (delta / Math.max(1, total))));
113
+ };
114
+ const dragBaseRect = () => {
115
+ if (drag.type !== "pane-drag") return null;
116
+ if (drag.mode === "floating") return drag.origRect;
117
+ memo.baseRect ??= getRememberedFloatingRect(baseLayout, drag.paneId, width, contentHeight, paneMap.get(drag.paneId)?.def);
118
+ return memo.baseRect;
119
+ };
120
+
121
+ if (event.type === "drag-cancel") {
122
+ // The window lost focus or the system took the pointer: put everything back.
123
+ cancelActiveDrag();
124
+ event.stopPropagation();
125
+ event.preventDefault();
126
+ return;
127
+ }
87
128
 
88
129
  if (event.type === "drag") {
89
130
  if (drag.type === "divider") {
90
- const total = drag.axis === "horizontal" ? drag.bounds.width : drag.bounds.height;
91
- const delta = drag.axis === "horizontal" ? preciseX - drag.startX : preciseShellY - drag.startY;
92
- const nextRatio = Math.max(0.1, Math.min(0.9, drag.startRatio + (delta / Math.max(1, total))));
131
+ const nextRatio = dividerRatioAt(drawX, drawShellY);
93
132
  const nextRect = resolveDividerPreviewRect(drag.axis, drag.bounds, nextRatio, nativePaneChrome === true);
94
133
  updateDividerPreview({ pathKey: drag.path.join("."), rect: nextRect, ratio: nextRatio });
95
134
  } else if (drag.type === "pane-drag") {
96
135
  if (!isMeaningfulPaneDrag(drag.startX, drag.startY, preciseX, preciseShellY, dragThreshold)) {
97
136
  updateDockPreview(null);
98
- setDragCursor(null);
137
+ setHoverTarget(null);
99
138
  if (drag.mode === "floating") {
100
139
  updateDragFloatingRect({ paneId: drag.paneId, rect: drag.origRect });
101
140
  }
@@ -104,25 +143,22 @@ export function useShellActiveDrag({
104
143
  return;
105
144
  }
106
145
 
107
- const pane = paneMap.get(drag.paneId);
108
- const baseRect = drag.mode === "docked"
109
- ? getRememberedFloatingRect(baseLayout, drag.paneId, width, contentHeight, pane?.def)
110
- : drag.origRect;
111
- const nextRect = resolvePaneDragFloatingRect(drag, baseRect, preciseX, preciseShellY, width, contentHeight);
146
+ const nextRect = resolvePaneDragFloatingRect(drag, dragBaseRect()!, drawX, drawShellY, width, contentHeight);
112
147
  updateDragFloatingRect({ paneId: drag.paneId, rect: nextRect });
113
- setDragCursor({ x: hitX, y: hitShellY });
114
148
 
115
149
  const hoveredOverlay = resolveHoverOverlay(hitX, hitShellY, dockLeafLayouts, drag.paneId);
150
+ setHoverTarget(hoveredOverlay?.targetId ?? null);
116
151
  if (hoveredOverlay) {
117
152
  const hoveredCell = hoveredOverlay.cells.find((cell) => pointInRect(cell.rect, hitX, hitShellY));
118
153
  if (hoveredCell) {
119
- const target: DropTarget = { kind: "leaf", targetId: hoveredOverlay.targetId, position: hoveredCell.position };
120
- const simulation = simulateDrop(baseLayout, drag.paneId, target, bounds, dockGeometryOptions);
121
- if (simulation.previewRect) {
122
- updateDockPreview({ kind: "dock", target, rect: simulation.previewRect });
123
- } else {
124
- updateDockPreview(null);
154
+ const dropKey = `${hoveredOverlay.targetId}:${hoveredCell.position}`;
155
+ if (memo.dropKey !== dropKey) {
156
+ const target: DropTarget = { kind: "leaf", targetId: hoveredOverlay.targetId, position: hoveredCell.position };
157
+ const simulation = simulateDrop(baseLayout, drag.paneId, target, bounds, dockGeometryOptions);
158
+ memo.dropKey = dropKey;
159
+ memo.drop = simulation.previewRect ? { kind: "dock", target, rect: simulation.previewRect } : null;
125
160
  }
161
+ updateDockPreview(memo.drop);
126
162
  } else {
127
163
  updateDockPreview(null);
128
164
  }
@@ -143,9 +179,8 @@ export function useShellActiveDrag({
143
179
 
144
180
  if (event.type === "up" || event.type === "drag-end") {
145
181
  if (drag.type === "divider") {
146
- const preview = dividerPreviewRef.current;
147
- if (preview) {
148
- const nextLayout = resizeSplitAtPath(baseLayout, drag.path, preview.ratio);
182
+ if (live.get().divider) {
183
+ const nextLayout = resizeSplitAtPath(baseLayout, drag.path, dividerRatioAt(preciseX, preciseShellY));
149
184
  if (windowMode) {
150
185
  updateWindowModePreviewLayout(nextLayout);
151
186
  } else {
@@ -157,15 +192,11 @@ export function useShellActiveDrag({
157
192
  const movedEnough = isMeaningfulPaneDrag(drag.startX, drag.startY, preciseX, preciseShellY, dragThreshold);
158
193
  if (!movedEnough) {
159
194
  updateDockPreview(null);
160
- setDragCursor(null);
195
+ setHoverTarget(null);
161
196
  updateDragFloatingRect(null);
162
197
  } else {
163
- const pane = paneMap.get(drag.paneId);
164
- const baseRect = drag.mode === "docked"
165
- ? getRememberedFloatingRect(baseLayout, drag.paneId, width, contentHeight, pane?.def)
166
- : drag.origRect;
167
- const releaseRect = resolvePaneDragFloatingRect(drag, baseRect, preciseX, preciseShellY, width, contentHeight);
168
- const nextLayout = finalizePaneDragRelease(baseLayout, drag.paneId, releaseRect, dockPreviewRef.current);
198
+ const releaseRect = resolvePaneDragFloatingRect(drag, dragBaseRect()!, preciseX, preciseShellY, width, contentHeight);
199
+ const nextLayout = finalizePaneDragRelease(baseLayout, drag.paneId, releaseRect, live.get().dockPreview);
169
200
  if (windowMode) {
170
201
  updateWindowModePreviewLayout(nextLayout, drag.paneId);
171
202
  } else {
@@ -173,7 +204,7 @@ export function useShellActiveDrag({
173
204
  }
174
205
  focusPane(drag.paneId);
175
206
  updateDockPreview(null);
176
- setDragCursor(null);
207
+ setHoverTarget(null);
177
208
  updateDragFloatingRect(null);
178
209
  }
179
210
  } else if (drag.type === "float-resize") {
@@ -185,27 +216,28 @@ export function useShellActiveDrag({
185
216
  persistLayout(nextLayout);
186
217
  }
187
218
  updateDragFloatingRect(null);
188
- setDragCursor(null);
219
+ setHoverTarget(null);
189
220
  }
190
221
  dragRef.current = null;
222
+ live.set({ paneDrag: null });
191
223
  event.stopPropagation();
192
224
  event.preventDefault();
193
225
  }
194
226
  }, [
195
227
  appHeaderHeight,
196
228
  bounds,
229
+ cancelActiveDrag,
197
230
  contentHeight,
198
231
  dockGeometryOptions,
199
- dividerPreviewRef,
200
232
  dockLeafLayouts,
201
- dockPreviewRef,
202
233
  dragRef,
203
234
  focusPane,
235
+ live,
204
236
  nativePaneChrome,
205
237
  paneMap,
206
238
  persistLayout,
207
239
  precisePointer,
208
- setDragCursor,
240
+ setHoverTarget,
209
241
  snapGuides,
210
242
  updateDividerPreview,
211
243
  updateDockPreview,
@@ -160,15 +160,16 @@ function shouldHideCursor({
160
160
  }
161
161
 
162
162
  export function useShellCursorOcclusionGuard({
163
- occlusionRects,
163
+ resolveOcclusionRects,
164
164
  shellRef,
165
165
  }: {
166
- occlusionRects: readonly ShellCursorOcclusionRect[];
166
+ /** Called on every terminal frame. */
167
+ resolveOcclusionRects: () => readonly ShellCursorOcclusionRect[];
167
168
  shellRef: RefObject<unknown>;
168
169
  }) {
169
170
  const nativeRenderer = useNativeRenderer();
170
- const stateRef = useRef({ occlusionRects, shellRef });
171
- stateRef.current = { occlusionRects, shellRef };
171
+ const stateRef = useRef({ resolveOcclusionRects, shellRef });
172
+ stateRef.current = { resolveOcclusionRects, shellRef };
172
173
 
173
174
  useEffect(() => {
174
175
  if (
@@ -186,7 +187,7 @@ export function useShellCursorOcclusionGuard({
186
187
  const shellBounds = readRenderableBounds(stateRef.current.shellRef.current);
187
188
  if (!shellBounds) return;
188
189
 
189
- const terminalRects = stateRef.current.occlusionRects.map((rect) => toTerminalRect(shellBounds, rect));
190
+ const terminalRects = stateRef.current.resolveOcclusionRects().map((rect) => toTerminalRect(shellBounds, rect));
190
191
  if (terminalRects.length === 0) return;
191
192
 
192
193
  const cursorOwnerPaneId = readRenderablePaneId(nativeRenderer.currentFocusedEditor);
@@ -212,6 +212,16 @@ function makeOverlayCellRects(rect: LayoutBounds): HoverOverlay["cells"] {
212
212
  return cells;
213
213
  }
214
214
 
215
+ /** The drop grid drawn over a docked pane while another pane is dragged onto it. */
216
+ export function hoverOverlayForLeaf(leaf: DockLeafLayout): HoverOverlay {
217
+ const overlayRect = compactOverlayRect(leaf.rect);
218
+ return {
219
+ targetId: leaf.instanceId,
220
+ rect: overlayRect,
221
+ cells: makeOverlayCellRects(overlayRect),
222
+ };
223
+ }
224
+
215
225
  export function resolveHoverOverlay(
216
226
  x: number,
217
227
  y: number,
@@ -220,13 +230,8 @@ export function resolveHoverOverlay(
220
230
  ): HoverOverlay | null {
221
231
  const targetLeaf = leaves.find((leaf) => leaf.instanceId !== draggedPaneId && pointInRect(leaf.rect, x, y));
222
232
  if (!targetLeaf) return null;
223
- const overlayRect = compactOverlayRect(targetLeaf.rect);
224
- if (!pointInRect(overlayRect, x, y)) return null;
225
- return {
226
- targetId: targetLeaf.instanceId,
227
- rect: overlayRect,
228
- cells: makeOverlayCellRects(overlayRect),
229
- };
233
+ const overlay = hoverOverlayForLeaf(targetLeaf);
234
+ return pointInRect(overlay.rect, x, y) ? overlay : null;
230
235
  }
231
236
 
232
237
  export function resolveDividerPreviewRect(
@@ -0,0 +1,122 @@
1
+ import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore, type RefObject } from "react";
2
+ import type { DockLeafLayout, FloatingRect } from "../../../../layout/pane-manager";
3
+ import type { BoxRenderable, LiveBoxFrame } from "../../../../ui";
4
+ import type { DividerPreviewState } from "../native/window-state";
5
+ import { hoverOverlayForLeaf, type DragPreview, type HoverOverlay } from "./index";
6
+
7
+ /**
8
+ * Where an in-progress drag is right now: the pane on the move, its floating
9
+ * rect, the divider line and the drop target. It lives outside React state,
10
+ * so the shell renders nothing while a drag follows the pointer.
11
+ */
12
+ export interface LiveDragGeometry {
13
+ /** The pane being moved, from mouse down to release. */
14
+ paneDrag: { paneId: string; mode: "docked" | "floating" } | null;
15
+ /** The floating rect being moved or resized, already kept inside the shell. */
16
+ floating: { paneId: string; rect: FloatingRect } | null;
17
+ divider: DividerPreviewState | null;
18
+ /** The tiled pane whose drop grid is under the pointer during a pane move. */
19
+ hoverTargetId: string | null;
20
+ /** Where the release would dock or snap. */
21
+ dockPreview: DragPreview | null;
22
+ }
23
+
24
+ /**
25
+ * Two kinds of change. `set` is something React draws: a drag starting or
26
+ * ending, or a new drop target; it reaches every subscriber. `move` is the
27
+ * desktop following the pointer: it reaches only the elements that restyle
28
+ * themselves to follow it (`subscribeMotion`), so a move renders nothing. The
29
+ * terminal redraws cells, so it sends every change through `set`.
30
+ */
31
+ export interface LiveDragStore {
32
+ get(): LiveDragGeometry;
33
+ set(patch: Partial<LiveDragGeometry>): void;
34
+ move(patch: Partial<LiveDragGeometry>): void;
35
+ subscribe(listener: () => void): () => void;
36
+ subscribeMotion(listener: () => void): () => void;
37
+ }
38
+
39
+ export const IDLE_DRAG: LiveDragGeometry = { paneDrag: null, floating: null, divider: null, hoverTargetId: null, dockPreview: null };
40
+
41
+ export function createLiveDragStore(): LiveDragStore {
42
+ let geometry = IDLE_DRAG;
43
+ const listeners = new Set<() => void>();
44
+ const motionListeners = new Set<() => void>();
45
+ const apply = (patch: Partial<LiveDragGeometry>): boolean => {
46
+ let changed = false;
47
+ for (const key of Object.keys(patch) as Array<keyof LiveDragGeometry>) {
48
+ if (patch[key] !== geometry[key]) changed = true;
49
+ }
50
+ if (changed) geometry = { ...geometry, ...patch };
51
+ return changed;
52
+ };
53
+ const notify = (targets: Set<() => void>) => {
54
+ for (const listener of targets) listener();
55
+ };
56
+ return {
57
+ get: () => geometry,
58
+ set(patch) {
59
+ if (!apply(patch)) return;
60
+ notify(motionListeners);
61
+ notify(listeners);
62
+ },
63
+ move(patch) {
64
+ if (apply(patch)) notify(motionListeners);
65
+ },
66
+ subscribe(listener) {
67
+ listeners.add(listener);
68
+ return () => { listeners.delete(listener); };
69
+ },
70
+ subscribeMotion(listener) {
71
+ motionListeners.add(listener);
72
+ return () => { motionListeners.delete(listener); };
73
+ },
74
+ };
75
+ }
76
+
77
+ /** Re-renders the caller only when `select` returns something new; keep its result stable. */
78
+ export function useLiveDrag<T>(store: LiveDragStore, select: (geometry: LiveDragGeometry) => T): T {
79
+ const getSnapshot = useCallback(() => select(store.get()), [select, store]);
80
+ return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
81
+ }
82
+
83
+ function sameFrame(a: LiveBoxFrame | null, b: LiveBoxFrame | null): boolean {
84
+ if (a === null || b === null) return a === b;
85
+ return a.dx === b.dx && a.dy === b.dy;
86
+ }
87
+
88
+ /**
89
+ * Desktop: keeps `box` where the drag has it by restyling its element on every
90
+ * move (`setLiveFrame`), with no render. `frameOf` must be stable while its
91
+ * inputs are; a new one redraws the box once.
92
+ */
93
+ export function useLiveBoxFrame(
94
+ store: LiveDragStore,
95
+ box: RefObject<BoxRenderable | null>,
96
+ frameOf: ((geometry: LiveDragGeometry) => LiveBoxFrame | null) | null,
97
+ ): void {
98
+ // What the element carries now, across re-subscriptions.
99
+ const drawn = useRef<LiveBoxFrame | null>(null);
100
+ useLayoutEffect(() => {
101
+ if (!frameOf) return;
102
+ const follow = () => {
103
+ const next = frameOf(store.get());
104
+ if (sameFrame(drawn.current, next)) return;
105
+ drawn.current = next;
106
+ box.current?.setLiveFrame?.(next);
107
+ };
108
+ follow();
109
+ return store.subscribeMotion(follow);
110
+ }, [box, frameOf, store]);
111
+ }
112
+
113
+ /** The drop grid under the pointer during a pane move. Re-renders only when the pointer enters another pane's grid. */
114
+ export function useLiveHoverOverlay(store: LiveDragStore, leaves: DockLeafLayout[]): HoverOverlay | null {
115
+ const targetId = useLiveDrag(store, selectHoverTarget);
116
+ return useMemo(() => {
117
+ const leaf = targetId ? leaves.find((entry) => entry.instanceId === targetId) : undefined;
118
+ return leaf ? hoverOverlayForLeaf(leaf) : null;
119
+ }, [leaves, targetId]);
120
+ }
121
+
122
+ const selectHoverTarget = (geometry: LiveDragGeometry) => (geometry.paneDrag ? geometry.hoverTargetId : null);
@@ -1,28 +1,76 @@
1
- import { Box } from "../../../../ui";
1
+ import { useRef, type CSSProperties, type RefObject } from "react";
2
+ import { Box, useUiCapabilities, type BoxRenderable, type LiveBoxFrame } from "../../../../ui";
2
3
  import { colors } from "../../../../theme/colors";
3
- import type { FloatingRect } from "../../../../layout/pane-manager";
4
+ import type { DockLeafLayout, LayoutBounds } from "../../../../layout/pane-manager";
4
5
  import type { DragPreview, HoverOverlay } from "./index";
6
+ import { useLiveBoxFrame, useLiveDrag, useLiveHoverOverlay, type LiveDragGeometry, type LiveDragStore } from "./live";
7
+ import { placeStyle } from "./slide";
5
8
 
6
- export function ShellDragOverlays({
7
- activeHoverOverlay,
8
- activePaneDrag,
9
- dockPreview,
10
- dragFloatingRect,
11
- effectiveDockPreview,
9
+ const CELL_POSITIONS = ["top", "left", "center", "right", "bottom"] as const;
10
+
11
+ function selectDockedOutline(geometry: LiveDragGeometry) {
12
+ const { paneDrag, floating } = geometry;
13
+ return paneDrag?.mode === "docked" && floating?.paneId === paneDrag.paneId ? floating.rect : null;
14
+ }
15
+
16
+ const selectDockPreview = (geometry: LiveDragGeometry) => geometry.dockPreview;
17
+ const selectPaneDragActive = (geometry: LiveDragGeometry) => geometry.paneDrag !== null;
18
+ const selectOutlineWidth = (geometry: LiveDragGeometry) => selectDockedOutline(geometry)?.width ?? null;
19
+ const selectOutlineHeight = (geometry: LiveDragGeometry) => selectDockedOutline(geometry)?.height ?? null;
20
+
21
+ function isActiveCell(dockPreview: DragPreview | null, overlay: HoverOverlay, position: string): boolean {
22
+ return dockPreview?.kind === "dock"
23
+ && dockPreview.target.kind === "leaf"
24
+ && dockPreview.target.targetId === overlay.targetId
25
+ && dockPreview.target.position === position;
26
+ }
27
+
28
+ export function ShellDragOverlays(props: {
29
+ dockLeafLayouts: DockLeafLayout[];
30
+ /** A pane from another window over this one's edge. */
31
+ externalDockPreview: DragPreview | null;
32
+ live: LiveDragStore;
33
+ }) {
34
+ const { nativePaneChrome } = useUiCapabilities();
35
+ return nativePaneChrome ? <DesktopDragOverlays {...props} /> : <TerminalDragOverlays {...props} />;
36
+ }
37
+
38
+ /** A docked pane on the move is drawn as an outline at its floating size, until it is over a drop target. */
39
+ function TerminalDockedDragOutline({ live }: { live: LiveDragStore }) {
40
+ const rect = useLiveDrag(live, selectDockedOutline);
41
+ if (!rect) return null;
42
+ return (
43
+ <Box
44
+ position="absolute"
45
+ left={rect.x}
46
+ top={rect.y}
47
+ width={rect.width}
48
+ height={rect.height}
49
+ border
50
+ borderStyle="single"
51
+ borderColor={colors.borderFocused}
52
+ backgroundColor={colors.panel}
53
+ zIndex={95}
54
+ />
55
+ );
56
+ }
57
+
58
+ function TerminalDragOverlays({
59
+ dockLeafLayouts,
60
+ externalDockPreview,
61
+ live,
12
62
  }: {
13
- activeHoverOverlay: HoverOverlay | null;
14
- activePaneDrag: { paneId: string; mode: "docked" | "floating" } | null;
15
- dockPreview: DragPreview | null;
16
- dragFloatingRect: { paneId: string; rect: FloatingRect } | null;
17
- effectiveDockPreview: DragPreview | null;
63
+ dockLeafLayouts: DockLeafLayout[];
64
+ externalDockPreview: DragPreview | null;
65
+ live: LiveDragStore;
18
66
  }) {
67
+ const activeHoverOverlay = useLiveHoverOverlay(live, dockLeafLayouts);
68
+ const dockPreview = useLiveDrag(live, selectDockPreview);
69
+ const effectiveDockPreview = dockPreview ?? externalDockPreview;
19
70
  return (
20
71
  <>
21
72
  {activeHoverOverlay && activeHoverOverlay.cells.map((cell) => {
22
- const active = dockPreview?.kind === "dock"
23
- && dockPreview.target.kind === "leaf"
24
- && dockPreview.target.targetId === activeHoverOverlay.targetId
25
- && dockPreview.target.position === cell.position;
73
+ const active = isActiveCell(dockPreview, activeHoverOverlay, cell.position);
26
74
  return (
27
75
  <Box
28
76
  key={`cell:${activeHoverOverlay.targetId}:${cell.position}`}
@@ -40,24 +88,7 @@ export function ShellDragOverlays({
40
88
  );
41
89
  })}
42
90
 
43
- {activePaneDrag
44
- && activePaneDrag.mode === "docked"
45
- && dragFloatingRect?.paneId === activePaneDrag.paneId
46
- && !effectiveDockPreview
47
- && (
48
- <Box
49
- position="absolute"
50
- left={dragFloatingRect.rect.x}
51
- top={dragFloatingRect.rect.y}
52
- width={dragFloatingRect.rect.width}
53
- height={dragFloatingRect.rect.height}
54
- border
55
- borderStyle="single"
56
- borderColor={colors.borderFocused}
57
- backgroundColor={colors.panel}
58
- zIndex={95}
59
- />
60
- )}
91
+ {!effectiveDockPreview && <TerminalDockedDragOutline live={live} />}
61
92
 
62
93
  {effectiveDockPreview && (
63
94
  <Box
@@ -76,3 +107,116 @@ export function ShellDragOverlays({
76
107
  </>
77
108
  );
78
109
  }
110
+
111
+ /**
112
+ * Desktop: an outline laid out at the shell's corner and drawn at `rect` by a
113
+ * transform, hidden by opacity, so a new drop target restyles it in place.
114
+ */
115
+ function OutlineBox({
116
+ rect,
117
+ zIndex,
118
+ borderColor = colors.borderFocused,
119
+ backgroundColor = colors.panel,
120
+ role,
121
+ boxRef,
122
+ follows = false,
123
+ }: {
124
+ rect: LayoutBounds | null;
125
+ zIndex: number;
126
+ borderColor?: string;
127
+ backgroundColor?: string;
128
+ role: string;
129
+ boxRef?: RefObject<BoxRenderable | null>;
130
+ /** Its position follows the pointer outside React (`useLiveBoxFrame`). */
131
+ follows?: boolean;
132
+ }) {
133
+ const style: CSSProperties = {
134
+ ...(rect && !follows ? placeStyle(rect.x, rect.y) : undefined),
135
+ opacity: rect ? 1 : 0,
136
+ pointerEvents: "none",
137
+ };
138
+ return (
139
+ <Box
140
+ ref={boxRef}
141
+ position="absolute"
142
+ left={0}
143
+ top={0}
144
+ width={rect?.width ?? 0}
145
+ height={rect?.height ?? 0}
146
+ border
147
+ borderStyle="single"
148
+ borderColor={borderColor}
149
+ backgroundColor={backgroundColor}
150
+ zIndex={zIndex}
151
+ data-gloom-role={role}
152
+ style={style}
153
+ />
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Desktop overlays of a pane move: the outline a docked pane drags around, the
159
+ * drop grid of the pane under the pointer and the dock or snap preview. They
160
+ * render when the drop target changes, never on a move: the outline follows
161
+ * the pointer by restyling itself.
162
+ */
163
+ function DesktopDragOverlays({
164
+ dockLeafLayouts,
165
+ externalDockPreview,
166
+ live,
167
+ }: {
168
+ dockLeafLayouts: DockLeafLayout[];
169
+ externalDockPreview: DragPreview | null;
170
+ live: LiveDragStore;
171
+ }) {
172
+ const dragging = useLiveDrag(live, selectPaneDragActive);
173
+ if (!dragging && !externalDockPreview) return null;
174
+ return <DesktopDragOverlayLayers dockLeafLayouts={dockLeafLayouts} externalDockPreview={externalDockPreview} live={live} />;
175
+ }
176
+
177
+ function DesktopDragOverlayLayers({
178
+ dockLeafLayouts,
179
+ externalDockPreview,
180
+ live,
181
+ }: {
182
+ dockLeafLayouts: DockLeafLayout[];
183
+ externalDockPreview: DragPreview | null;
184
+ live: LiveDragStore;
185
+ }) {
186
+ const activeHoverOverlay = useLiveHoverOverlay(live, dockLeafLayouts);
187
+ const dockPreview = useLiveDrag(live, selectDockPreview);
188
+ const effectiveDockPreview = dockPreview ?? externalDockPreview;
189
+ const outlineWidth = useLiveDrag(live, selectOutlineWidth);
190
+ const outlineHeight = useLiveDrag(live, selectOutlineHeight);
191
+ const outlineRef = useRef<BoxRenderable | null>(null);
192
+ useLiveBoxFrame(live, outlineRef, followDockedOutline);
193
+ const outline = outlineWidth !== null && outlineHeight !== null && !effectiveDockPreview
194
+ ? { x: 0, y: 0, width: outlineWidth, height: outlineHeight }
195
+ : null;
196
+ return (
197
+ <>
198
+ {CELL_POSITIONS.map((position) => {
199
+ const cell = activeHoverOverlay?.cells.find((entry) => entry.position === position) ?? null;
200
+ const active = !!activeHoverOverlay && isActiveCell(dockPreview, activeHoverOverlay, position);
201
+ return (
202
+ <OutlineBox
203
+ key={position}
204
+ role="drop-cell"
205
+ rect={cell?.rect ?? null}
206
+ borderColor={active ? colors.borderFocused : colors.border}
207
+ backgroundColor={active ? colors.header : colors.panel}
208
+ zIndex={position === "center" ? 98 : 97}
209
+ />
210
+ );
211
+ })}
212
+ <OutlineBox role="drag-outline" rect={outline} zIndex={95} boxRef={outlineRef} follows />
213
+ <OutlineBox role="drop-preview" rect={effectiveDockPreview?.rect ?? null} zIndex={96} />
214
+ </>
215
+ );
216
+ }
217
+
218
+ /** The docked pane's outline sits where the drag has it; laid out at the shell's corner. */
219
+ function followDockedOutline(geometry: LiveDragGeometry): LiveBoxFrame | null {
220
+ const rect = selectDockedOutline(geometry);
221
+ return rect ? { dx: rect.x, dy: rect.y } : null;
222
+ }