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
@@ -6,6 +6,7 @@ import {
6
6
  useRef,
7
7
  type CSSProperties,
8
8
  type MouseEvent,
9
+ type PointerEvent,
9
10
  type ReactNode,
10
11
  type Ref,
11
12
  } from "react";
@@ -19,8 +20,56 @@ import {
19
20
  cellMouseEvent,
20
21
  requestWebFrame,
21
22
  } from "./mouse";
23
+ import { hideDragShield, showDragShield } from "./drag-shield";
24
+ import { applyLiveFrame } from "./live-frame";
22
25
  import { cleanDomProps, commonStyle } from "./style";
23
26
 
27
+ /** Farther than this from the pointer, in px, a predicted point is noise. */
28
+ const MAX_PREDICTION_PX = 48;
29
+
30
+ type PointerMoveEvent = globalThis.MouseEvent & {
31
+ pointerId?: number;
32
+ getPredictedEvents?: () => Array<{ clientX: number; clientY: number }>;
33
+ };
34
+
35
+ function isPointerMove(event: globalThis.MouseEvent): boolean {
36
+ return typeof (event as PointerMoveEvent).pointerId === "number";
37
+ }
38
+
39
+ /**
40
+ * Where the browser predicts the pointer will be a moment after this move, when
41
+ * it predicts at all (Chrome does for the mouse; WebKit returns none). The
42
+ * event itself already sits at the last of its coalesced points.
43
+ */
44
+ function predictedPoint(event: globalThis.MouseEvent): { clientX: number; clientY: number } | null {
45
+ const predicted = (event as PointerMoveEvent).getPredictedEvents?.();
46
+ const next = predicted?.[0];
47
+ if (!next) return null;
48
+ const dx = next.clientX - event.clientX;
49
+ const dy = next.clientY - event.clientY;
50
+ return dx * dx + dy * dy <= MAX_PREDICTION_PX * MAX_PREDICTION_PX ? next : null;
51
+ }
52
+
53
+ function capturePointer(element: HTMLElement | null, pointerId: number | null): { element: HTMLElement; pointerId: number } | null {
54
+ if (!element || pointerId === null || typeof element.setPointerCapture !== "function") return null;
55
+ try {
56
+ element.setPointerCapture(pointerId);
57
+ return { element, pointerId };
58
+ } catch {
59
+ // The pointer is already up or belongs to another frame: drag uncaptured.
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function releasePointer(captured: { element: HTMLElement; pointerId: number } | null): void {
65
+ if (!captured) return;
66
+ try {
67
+ if (captured.element.hasPointerCapture?.(captured.pointerId)) captured.element.releasePointerCapture(captured.pointerId);
68
+ } catch {
69
+ // Released already, by the browser on pointerup.
70
+ }
71
+ }
72
+
24
73
  export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { children?: ReactNode }>(
25
74
  function WebBox({ children, ...props }, ref: Ref<HTMLDivElement>) {
26
75
  const elementRef = useRef<HTMLDivElement | null>(null);
@@ -34,7 +83,13 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
34
83
  ? props.hoverBackgroundColor
35
84
  : undefined;
36
85
 
37
- useImperativeHandle(ref, () => cellBoundsForElement(() => elementRef.current, () => propsRef.current) as unknown as HTMLDivElement, []);
86
+ useImperativeHandle(ref, () => {
87
+ const handle = cellBoundsForElement(() => elementRef.current, () => propsRef.current);
88
+ handle.setLiveFrame = (frame) => {
89
+ if (elementRef.current) applyLiveFrame(elementRef.current, frame, propsRef.current);
90
+ };
91
+ return handle as unknown as HTMLDivElement;
92
+ }, []);
38
93
 
39
94
  const cancelPendingFrame = () => {
40
95
  if (frameRef.current !== null) {
@@ -63,7 +118,11 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
63
118
  flushPendingFrameMouseHandlers();
64
119
  };
65
120
 
66
- const scheduleFrameMouseHandler = (event: MouseLikeEvent, type: "move" | "drag") => {
121
+ const scheduleFrameMouseHandler = (
122
+ event: MouseLikeEvent,
123
+ type: "move" | "drag",
124
+ ahead: { clientX: number; clientY: number } | null = null,
125
+ ) => {
67
126
  // The DOM keeps firing mousemove while a button is held. A move means no
68
127
  // button anywhere else in the app, so drags must not surface as moves.
69
128
  if (type === "move" && draggingRef.current) return;
@@ -74,7 +133,7 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
74
133
  return;
75
134
  }
76
135
 
77
- const nextEvent = cellMouseEvent(event, type);
136
+ const nextEvent = cellMouseEvent(event, type, ahead);
78
137
  if (type === "move") {
79
138
  pendingMoveRef.current = nextEvent;
80
139
  } else {
@@ -89,27 +148,42 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
89
148
  };
90
149
 
91
150
  const lastPointRef = useRef({ x: 0, y: 0 });
151
+ // The pointer that pressed this box, so a drag can capture it.
152
+ const pressedPointerRef = useRef<number | null>(null);
92
153
  const dragSessionRef = useRef<{
93
154
  move: (event: globalThis.MouseEvent) => void;
94
155
  up: (event: globalThis.MouseEvent) => void;
95
156
  cancel: () => void;
157
+ captured: { element: HTMLElement; pointerId: number } | null;
158
+ /** Pointer events are arriving, so the mousemove after each is a repeat. */
159
+ pointerMoves: boolean;
160
+ /** Moved far enough to be a drag: the drag shield is up. */
161
+ shielded: boolean;
96
162
  } | null>(null);
97
- const endDocumentDragRef = useRef<(event: globalThis.MouseEvent | null, notify: boolean) => void>(() => {});
98
- endDocumentDragRef.current = (event, notify) => {
163
+ /**
164
+ * Ends the press. "end" reports a release at `event` (or the last point);
165
+ * "cancel" reports the drag abandoned (the window lost focus or the system
166
+ * took the pointer), so a move can put things back.
167
+ */
168
+ const endDocumentDragRef = useRef<(event: globalThis.MouseEvent | null, outcome: "end" | "cancel") => void>(() => {});
169
+ endDocumentDragRef.current = (event, outcome) => {
99
170
  const session = dragSessionRef.current;
100
171
  const wasDragging = draggingRef.current;
101
172
  if (!session && !wasDragging) return;
102
173
  draggingRef.current = false;
174
+ const shielded = session?.shielded === true;
103
175
  dragSessionRef.current = null;
104
- document.body.classList.remove("gloom-dragging");
176
+ if (shielded) hideDragShield();
105
177
  if (session) {
178
+ document.removeEventListener("pointermove", session.move);
106
179
  document.removeEventListener("mousemove", session.move);
107
180
  document.removeEventListener("mouseup", session.up);
108
181
  document.removeEventListener("pointerup", session.up);
109
182
  document.removeEventListener("pointercancel", session.cancel);
110
183
  window.removeEventListener("blur", session.cancel);
184
+ releasePointer(session.captured);
111
185
  }
112
- if (!notify || !wasDragging) return;
186
+ if (!wasDragging) return;
113
187
  const point = event ?? new MouseEvent("mouseup", {
114
188
  clientX: lastPointRef.current.x,
115
189
  clientY: lastPointRef.current.y,
@@ -119,14 +193,14 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
119
193
  flushPendingFrameNow();
120
194
  callMouseHandler(propsRef.current.onMouse, point, "up");
121
195
  callMouseHandler(propsRef.current.onMouseUp, point, "up");
122
- callMouseHandler(propsRef.current.onMouseDragEnd, point, "drag-end");
196
+ callMouseHandler(propsRef.current.onMouseDragEnd, point, outcome === "cancel" ? "drag-cancel" : "drag-end");
123
197
  };
124
198
 
125
199
  useEffect(() => () => {
126
200
  cancelPendingFrame();
127
201
  pendingMoveRef.current = null;
128
202
  pendingDragRef.current = null;
129
- endDocumentDragRef.current(null, true);
203
+ endDocumentDragRef.current(null, "end");
130
204
  }, []);
131
205
 
132
206
  const handlesWheel = typeof props.onMouseScroll === "function";
@@ -146,7 +220,7 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
146
220
  pendingMoveRef.current = null;
147
221
  // A press that never saw mouseup (the window move took the pointer) is still tracked.
148
222
  // Finish it before the new press, so its drag-end cannot clear the drag this press starts.
149
- endDocumentDragRef.current(null, true);
223
+ endDocumentDragRef.current(null, "end");
150
224
  callMouseHandler(propsRef.current.onMouseDown, event, "down");
151
225
  if (event.button !== 0 || (event.isPropagationStopped() && !hasDirectDrag)) return;
152
226
  if (!hasSyntheticDrag && !hasDirectDrag) return;
@@ -157,27 +231,41 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
157
231
  const originY = event.clientY;
158
232
  const move = (moveEvent: globalThis.MouseEvent) => {
159
233
  if (!draggingRef.current) return;
234
+ const current = dragSessionRef.current;
235
+ if (isPointerMove(moveEvent)) {
236
+ if (current) current.pointerMoves = true;
237
+ } else if (current?.pointerMoves) {
238
+ return;
239
+ }
160
240
  lastPointRef.current = { x: moveEvent.clientX, y: moveEvent.clientY };
161
241
  // The button is up and mouseup never arrived. End the drag instead of following the cursor.
162
242
  if (moveEvent.buttons === 0) {
163
- endDocumentDragRef.current(moveEvent, true);
243
+ endDocumentDragRef.current(moveEvent, "end");
164
244
  return;
165
245
  }
166
246
  const dx = moveEvent.clientX - originX;
167
247
  const dy = moveEvent.clientY - originY;
168
- if (!document.body.classList.contains("gloom-dragging")) {
248
+ if (current && !current.shielded) {
169
249
  if (dx * dx + dy * dy < 9) return;
170
- document.body.classList.add("gloom-dragging");
250
+ current.shielded = true;
251
+ showDragShield();
252
+ // Captured once it is a drag (a click keeps its usual target), the
253
+ // moves skip hit testing and leave every other element's hover state
254
+ // alone: nothing under the pointer restyles or repaints while a pane,
255
+ // divider or chart is dragged across it. The document listeners still
256
+ // see every move.
257
+ if (!current.captured) current.captured = capturePointer(elementRef.current, pressedPointerRef.current);
171
258
  }
172
- scheduleFrameMouseHandler(moveEvent, "drag");
259
+ scheduleFrameMouseHandler(moveEvent, "drag", predictedPoint(moveEvent));
173
260
  };
174
261
  const up = (upEvent: globalThis.MouseEvent) => {
175
- endDocumentDragRef.current(upEvent, true);
262
+ endDocumentDragRef.current(upEvent, "end");
176
263
  };
177
264
  const cancel = () => {
178
- endDocumentDragRef.current(null, true);
265
+ endDocumentDragRef.current(null, "cancel");
179
266
  };
180
- dragSessionRef.current = { move, up, cancel };
267
+ dragSessionRef.current = { move, up, cancel, captured: null, pointerMoves: false, shielded: false };
268
+ document.addEventListener("pointermove", move);
181
269
  document.addEventListener("mousemove", move);
182
270
  document.addEventListener("mouseup", up);
183
271
  document.addEventListener("pointerup", up);
@@ -185,6 +273,13 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
185
273
  window.addEventListener("blur", cancel);
186
274
  };
187
275
 
276
+ const handlesDrag = typeof props.onMouse === "function"
277
+ || typeof props.onMouseDrag === "function"
278
+ || typeof props.onMouseDragEnd === "function";
279
+ const handlePointerDown = (event: PointerEvent) => {
280
+ pressedPointerRef.current = event.pointerId;
281
+ };
282
+
188
283
  const handleMouseDownCapture = (event: MouseEvent) => {
189
284
  callMouseHandler(propsRef.current.onMouseDownCapture, event, "down");
190
285
  };
@@ -204,6 +299,7 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
204
299
  data-gloom-hover-bg={hoverBackgroundColor ? "true" : undefined}
205
300
  ref={elementRef}
206
301
  onMouseDownCapture={typeof props.onMouseDownCapture === "function" ? handleMouseDownCapture : undefined}
302
+ onPointerDown={handlesDrag ? handlePointerDown : undefined}
207
303
  onMouseDown={handleMouseDown}
208
304
  onMouseOver={handleMouseOver}
209
305
  onMouseMove={(event) => scheduleFrameMouseHandler(event, "move")}
@@ -0,0 +1,24 @@
1
+ let shield: HTMLElement | null = null;
2
+
3
+ /**
4
+ * While a press drags, one transparent sheet covers the window: it keeps the
5
+ * grabbing cursor wherever the pointer goes and takes the hover, so nothing
6
+ * under the pointer restyles while a pane, divider or chart moves across it.
7
+ * It is a single element on purpose: a class on body matched by descendant
8
+ * rules restyled every element with a role (thousands on a busy layout) in
9
+ * the frame the drag began, and again when it ended.
10
+ */
11
+ export function showDragShield(): void {
12
+ document.body.classList.add("gloom-dragging");
13
+ if (shield?.isConnected) return;
14
+ shield = document.createElement("div");
15
+ shield.setAttribute("data-gloom-role", "drag-shield");
16
+ shield.setAttribute("aria-hidden", "true");
17
+ document.body.appendChild(shield);
18
+ }
19
+
20
+ export function hideDragShield(): void {
21
+ document.body.classList.remove("gloom-dragging");
22
+ shield?.remove();
23
+ shield = null;
24
+ }
@@ -0,0 +1,54 @@
1
+ import type { CSSProperties } from "react";
2
+ import type { LiveBoxFrame } from "../../../ui/host";
3
+ import { WEB_CELL_HEIGHT, WEB_CELL_WIDTH } from "../../../theme/font-scale";
4
+ import { commonStyle } from "./style";
5
+
6
+ /**
7
+ * Blink lays the whole page out into compositor layers again whenever a
8
+ * style transform changes, even on a `will-change` layer: 4 to 9 ms a frame
9
+ * on a ten-pane desktop. The same transform held by a running animation's
10
+ * keyframes only updates the compositor (under 1 ms), at the same latency.
11
+ * WebKit has no such pass, and there an animation per frame costs more than
12
+ * the plain style, so it keeps the style.
13
+ */
14
+ const ANIMATION_CARRIES_SLIDE = typeof navigator !== "undefined"
15
+ && /Chrome\//.test(navigator.userAgent)
16
+ && typeof Element !== "undefined"
17
+ && typeof Element.prototype.animate === "function";
18
+
19
+ const slides = new WeakMap<HTMLElement, Animation>();
20
+
21
+ function renderedValue(value: unknown): string {
22
+ return typeof value === "string" ? value : "";
23
+ }
24
+
25
+ function slide(element: HTMLElement, transform: string): void {
26
+ if (!ANIMATION_CARRIES_SLIDE) {
27
+ element.style.transform = transform;
28
+ return;
29
+ }
30
+ const keyframes = [{ transform }, { transform }];
31
+ const running = slides.get(element);
32
+ if (running) (running.effect as KeyframeEffect).setKeyframes(keyframes);
33
+ else slides.set(element, element.animate(keyframes, { duration: 1e9, fill: "both" }));
34
+ }
35
+
36
+ /**
37
+ * Draws a box where a drag has it this frame by restyling its element,
38
+ * outside React: an offset on its own compositor layer, with no layout or
39
+ * repaint. `null` hands the properties back to what React last rendered.
40
+ */
41
+ export function applyLiveFrame(element: HTMLElement, frame: LiveBoxFrame | null, props: Record<string, unknown>): void {
42
+ const style = element.style;
43
+ if (!frame) {
44
+ const rendered: CSSProperties = { ...commonStyle(props), ...(props.style as CSSProperties | undefined) };
45
+ slides.get(element)?.cancel();
46
+ slides.delete(element);
47
+ style.transform = renderedValue(rendered.transform);
48
+ style.willChange = renderedValue(rendered.willChange);
49
+ return;
50
+ }
51
+ // Promoted once, before the first frame of the drag.
52
+ if (style.willChange !== "transform") style.willChange = "transform";
53
+ slide(element, `translate3d(${frame.dx * WEB_CELL_WIDTH}px, ${frame.dy * WEB_CELL_HEIGHT}px, 0)`);
54
+ }
@@ -54,7 +54,12 @@ function resolveScroll(event: MouseLikeEvent) {
54
54
  };
55
55
  }
56
56
 
57
- export function cellMouseEvent(event: MouseLikeEvent, type?: string) {
57
+ /**
58
+ * `ahead` is where the browser predicts the pointer will be a moment later
59
+ * (`getPredictedEvents`), for drawing what follows it; hit tests and releases
60
+ * keep the real point.
61
+ */
62
+ export function cellMouseEvent(event: MouseLikeEvent, type?: string, ahead?: { clientX: number; clientY: number } | null) {
58
63
  const preciseX = event.clientX / WEB_CELL_WIDTH;
59
64
  const preciseY = event.clientY / WEB_CELL_HEIGHT;
60
65
  return {
@@ -63,6 +68,8 @@ export function cellMouseEvent(event: MouseLikeEvent, type?: string) {
63
68
  y: Math.max(0, Math.floor(preciseY)),
64
69
  preciseX: Math.max(0, preciseX),
65
70
  preciseY: Math.max(0, preciseY),
71
+ predictedX: ahead ? Math.max(0, ahead.clientX / WEB_CELL_WIDTH) : undefined,
72
+ predictedY: ahead ? Math.max(0, ahead.clientY / WEB_CELL_HEIGHT) : undefined,
66
73
  pixelX: event.clientX,
67
74
  pixelY: event.clientY,
68
75
  button: event.button,
@@ -13,6 +13,7 @@ import {
13
13
  } from "react";
14
14
  import type { ScrollBoxRenderable } from "../../../ui/host";
15
15
  import { WEB_CELL_HEIGHT, WEB_CELL_WIDTH } from "../../../theme/font-scale";
16
+ import { isHeaderStripScrollBox, scrollBoxScrollsVertically } from "../../../ui/scroll-box-axis";
16
17
  import { callMouseHandler } from "./mouse";
17
18
  import { cleanDomProps, commonStyle } from "./style";
18
19
  import { useScrollbarActivity } from "../scrollbar-activity";
@@ -48,15 +49,15 @@ function contentInsetStyle(props: Record<string, unknown>, contentOptions: unkno
48
49
  export const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & { children?: ReactNode }>(
49
50
  function WebScrollBox({ children, contentOptions, ...props }, ref) {
50
51
  const elementRef = useRef<HTMLDivElement | null>(null);
51
- const isHeaderLikeScroller = props.scrollX === true && props.scrollY !== true && props.height === 1;
52
+ const isHeaderLikeScroller = isHeaderStripScrollBox(props);
52
53
  /**
53
54
  * OpenTUI's ScrollBox scrolls vertically unless told otherwise, so this one
54
55
  * has to as well: a pane that renders a ScrollBox and nothing more expects
55
- * the wheel to work, and used to get an unscrollable box that only its own
56
- * arrow-key handler could move. The one-row horizontal strips (tab bars,
57
- * table headers, chart legends) are the exception and scroll on their axis.
56
+ * the wheel and the arrow keys to work (the pane keyboard scroll registry
57
+ * reads the same rule). The one-row horizontal strips (tab bars, table
58
+ * headers, chart legends) are the exception and scroll on their axis.
58
59
  */
59
- const scrollYEnabled = props.scrollY !== false && !isHeaderLikeScroller;
60
+ const scrollYEnabled = scrollBoxScrollsVertically(props);
60
61
  const [horizontalScrollBarVisible, setHorizontalScrollBarVisible] = useState(
61
62
  () => props.scrollX === true && !isHeaderLikeScroller,
62
63
  );
@@ -13,6 +13,7 @@ import { WebMenu } from "../desktop/menu";
13
13
  import { WebPopover } from "../desktop/popover";
14
14
  import { useHorizontalOverflow } from "./overflow-fade";
15
15
  import { useTopSurfaceColor } from "./top-surface";
16
+ import { hideDragShield, showDragShield } from "./drag-shield";
16
17
 
17
18
  type CssVars = CSSProperties & Record<`--${string}`, string>;
18
19
 
@@ -106,7 +107,7 @@ export function WebTabs({
106
107
  const cleanup = () => {
107
108
  document.removeEventListener("mousemove", handleMove);
108
109
  document.removeEventListener("mouseup", handleUp);
109
- document.body.classList.remove("gloom-dragging");
110
+ hideDragShield();
110
111
  dragCleanupRef.current = null;
111
112
  dragOffsetXRef.current = 0;
112
113
  dragSourceWidthRef.current = 0;
@@ -120,7 +121,7 @@ export function WebTabs({
120
121
  moveEvent.preventDefault();
121
122
  dragOffsetXRef.current = offsetX;
122
123
  sourceElement.style.transform = `translateX(${offsetX}px) scale(1.03)`;
123
- document.body.classList.add("gloom-dragging");
124
+ showDragShield();
124
125
  setDragTargetValue(resolveTarget(sourceBounds.left + offsetX + sourceBounds.width / 2));
125
126
  };
126
127
  const handleUp = (upEvent: MouseEvent) => {
@@ -159,10 +159,21 @@ button, [data-gloom-interactive="true"] { cursor: pointer; }
159
159
  [data-gloom-role="pane-restore"] {
160
160
  cursor: pointer;
161
161
  }
162
- body.gloom-dragging,
163
- body.gloom-dragging [data-gloom-role="pane-header"] {
162
+ /* Up for the length of a drag (see drag-shield.ts): the grabbing cursor
163
+ everywhere, and no hover under the pointer. */
164
+ [data-gloom-role="drag-shield"] {
165
+ position: fixed;
166
+ inset: 0;
167
+ z-index: 2147483647;
164
168
  cursor: grabbing;
165
169
  }
170
+ /* Surfaces that drag a pane, its size or a split keep the whole pointer
171
+ stream: a touch or pen press on them never starts a pan or zoom. */
172
+ [data-gloom-role="pane-header"][data-drag-surface="pane"],
173
+ [data-gloom-role="resize-handle"],
174
+ [data-gloom-role="dock-divider"] {
175
+ touch-action: none;
176
+ }
166
177
  [data-gloom-role="pane-title"] {
167
178
  overflow: hidden;
168
179
  text-overflow: ellipsis;
@@ -310,9 +321,6 @@ body.gloom-dragging [data-gloom-role="pane-header"] {
310
321
  [data-gloom-role="chart-surface"] {
311
322
  cursor: crosshair;
312
323
  }
313
- body.gloom-dragging [data-gloom-role="chart-surface"] {
314
- cursor: grabbing;
315
- }
316
324
  [data-gloom-role="tab-list"] {
317
325
  scrollbar-width: none;
318
326
  -webkit-overflow-scrolling: touch;
@@ -6,6 +6,7 @@ import {
6
6
  saveConfig,
7
7
  } from "../../../../data/config/store";
8
8
  import type { UpdateProgress } from "../../../../updater";
9
+ import { deleteBrokerAccountRecord, saveBrokerAccountRecord } from "../../../../brokers/account-cache";
9
10
  import type {
10
11
  DesktopBackendRequestResponse,
11
12
  DesktopCoreRequest,
@@ -105,6 +106,12 @@ export async function handleDesktopBackendRequest(
105
106
  case "session.delete":
106
107
  backend.requireServices().persistence.sessions.delete(request.payload.sessionId);
107
108
  return null;
109
+ case "resources.set":
110
+ saveBrokerAccountRecord(backend.requireServices().persistence.resources, request.payload.record);
111
+ return null;
112
+ case "resources.delete":
113
+ deleteBrokerAccountRecord(backend.requireServices().persistence.resources, request.payload.key);
114
+ return null;
108
115
  default: {
109
116
  const exhaustive: never = request;
110
117
  throw new Error(`Unknown core backend method: ${String(exhaustive)}`);
@@ -511,6 +511,8 @@ export class DesktopBackend {
511
511
  case "config.import":
512
512
  case "session.set":
513
513
  case "session.delete":
514
+ case "resources.set":
515
+ case "resources.delete":
514
516
  return handleDesktopBackendRequest(this, rpc, request);
515
517
  default: {
516
518
  const exhaustive: never = request;
@@ -8,6 +8,8 @@ import {
8
8
  initDataDir,
9
9
  } from "../../../../data/config/store";
10
10
  import type { AppConfig } from "../../../../types/config";
11
+ import { listPersistedBrokerAccountRecords } from "../../../../brokers/account-cache";
12
+ import type { CachedResourceRecord } from "../../../../data/resource-store";
11
13
  import {
12
14
  loadDesktopPluginState,
13
15
  } from "./plugin-state";
@@ -54,6 +56,16 @@ export function desktopRendererCapabilityManifests(registry: CapabilityRegistry)
54
56
  return registry.manifests({ rendererOnly: true, includeDisabled: true });
55
57
  }
56
58
 
59
+ /** The view's resource store lives in memory; it starts from the account snapshots saved here. */
60
+ function loadSavedResources(services: AppServices, config: AppConfig): CachedResourceRecord[] {
61
+ try {
62
+ return listPersistedBrokerAccountRecords(services.persistence.resources, config.brokerInstances);
63
+ } catch (error) {
64
+ console.warn("saved broker accounts could not be read", error);
65
+ return [];
66
+ }
67
+ }
68
+
57
69
  function buildInitializationPayload(
58
70
  backend: DesktopBackend,
59
71
  config: AppConfig,
@@ -66,6 +78,7 @@ function buildInitializationPayload(
66
78
  desktopSnapshot: backend.workspace?.getSnapshot() ?? null,
67
79
  desktopThemePreview: backend.stateBroadcaster.currentThemePreview,
68
80
  pluginState: loadDesktopPluginState(services.pluginRegistry),
81
+ savedResources: loadSavedResources(services, config),
69
82
  capabilityManifests: desktopRendererCapabilityManifests(services.pluginRegistry.capabilities),
70
83
  desktopPlatform: process.platform,
71
84
  windowKind: windowTarget.kind,
@@ -3,6 +3,7 @@ import type { PaneRuntimeState } from "../../../core/state/app/state";
3
3
  import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState } from "../../../types/desktop-window";
4
4
  import type { DesktopApplicationMenuCommand } from "../../../types/desktop-menu";
5
5
  import type { AppConfig } from "../../../types/config";
6
+ import type { CachedResourceRecord, ResourceCacheKey } from "../../../data/resource-store";
6
7
  import type { TickerRecord } from "../../../types/ticker";
7
8
  import type { ReleaseInfo, UpdateCheckResult, UpdateProgress } from "../../../updater";
8
9
  import type { CapabilityManifest } from "../../../capabilities";
@@ -18,6 +19,8 @@ export interface ElectrobunBackendInit {
18
19
  desktopSnapshot: DesktopSharedStateSnapshot | null;
19
20
  desktopThemePreview: DesktopThemePreviewState;
20
21
  pluginState: Record<string, Record<string, unknown>>;
22
+ /** Saved broker account snapshots, which the view's in-memory store starts from. */
23
+ savedResources?: CachedResourceRecord[];
21
24
  capabilityManifests: CapabilityManifest[];
22
25
  desktopPlatform: string;
23
26
  windowKind: "main" | "detached";
@@ -226,6 +229,9 @@ interface DesktopBackendRequestMap {
226
229
  response: null;
227
230
  };
228
231
  "session.delete": { request: { sessionId: string }; response: null };
232
+ /** Saves or drops a broker account snapshot in the cache database; nothing else is accepted. */
233
+ "resources.set": { request: { record: CachedResourceRecord }; response: null };
234
+ "resources.delete": { request: { key: ResourceCacheKey }; response: null };
229
235
  }
230
236
 
231
237
  export type DesktopBackendRequestMethod = keyof DesktopBackendRequestMap;
@@ -251,7 +257,10 @@ export type DesktopWorkspaceRequest = DesktopBackendRequestFor<Extract<DesktopBa
251
257
  export type DesktopPluginStateRequest = DesktopBackendRequestFor<Extract<DesktopBackendRequestMethod, `pluginState.${string}`>>;
252
258
  export type DesktopHostRequest = DesktopBackendRequestFor<Extract<DesktopBackendRequestMethod, `host.${string}`>>;
253
259
  export type DesktopCoreRequest = DesktopBackendRequestFor<
254
- Extract<DesktopBackendRequestMethod, `update.${string}` | `ticker.${string}` | `config.${string}` | `session.${string}`>
260
+ Extract<
261
+ DesktopBackendRequestMethod,
262
+ `update.${string}` | `ticker.${string}` | `config.${string}` | `session.${string}` | `resources.${string}`
263
+ >
255
264
  >;
256
265
 
257
266
  interface BackendRequestPayload {
@@ -397,6 +397,9 @@ function HydratePayload({
397
397
  tickers: new Map(payload.tickers.map((ticker) => [ticker.metadata.ticker, ticker])),
398
398
  });
399
399
  dispatch({ type: "HYDRATE_FINANCIALS", financials: new Map(payload.financials) });
400
+ for (const [instanceId, accounts] of Object.entries(payload.brokerAccounts ?? {})) {
401
+ dispatch({ type: "SET_BROKER_ACCOUNTS", instanceId, accounts });
402
+ }
400
403
  dispatch({ type: "SET_INITIALIZED" });
401
404
 
402
405
  return waitForShotReadiness();
@@ -14,16 +14,6 @@ const contextMenuActionScope = new DesktopContextMenuActionScope(
14
14
  CONTEXT_MENU_ACTION_TTL_MS,
15
15
  );
16
16
 
17
- export function startElectrobunWindowDrag(): void {
18
- window.__electrobunInternalBridge?.postMessage(JSON.stringify([
19
- JSON.stringify({
20
- type: "message",
21
- id: "startWindowMove",
22
- payload: { id: window.__electrobunWindowId },
23
- }),
24
- ]));
25
- }
26
-
27
17
  export async function showDesktopContextMenu(items: ContextMenuItem[]): Promise<boolean> {
28
18
  if (!NATIVE_CONTEXT_MENU_SUPPORTED) return false;
29
19
  contextMenuActionScope.clear();
@@ -0,0 +1,28 @@
1
+ function postWindowMove(id: "startWindowMove" | "stopWindowMove"): void {
2
+ window.__electrobunInternalBridge?.postMessage(JSON.stringify([
3
+ JSON.stringify({ type: "message", id, payload: { id: window.__electrobunWindowId } }),
4
+ ]));
5
+ }
6
+
7
+ let endWindowMove: (() => void) | null = null;
8
+
9
+ /**
10
+ * Moves the window with the pointer until the button comes up. Electrobun
11
+ * stops the move when its native monitor sees the release, but only if the
12
+ * start reached it first: the start goes through the Bun process, and a quick
13
+ * click releases before it lands, leaving the window following the bare
14
+ * pointer until the next click. The release is sent from here too, as
15
+ * Electrobun's own drag regions do; it always arrives after the start.
16
+ */
17
+ export function startElectrobunWindowDrag(): void {
18
+ // A press whose release never reached the page (it ended outside the window) is over.
19
+ endWindowMove?.();
20
+ postWindowMove("startWindowMove");
21
+ const end = () => {
22
+ window.removeEventListener("mouseup", end, true);
23
+ if (endWindowMove === end) endWindowMove = null;
24
+ postWindowMove("stopWindowMove");
25
+ };
26
+ endWindowMove = end;
27
+ window.addEventListener("mouseup", end, true);
28
+ }
@@ -4,7 +4,8 @@ import {
4
4
  SESSION_SAVE_DEBOUNCE_MS,
5
5
  } from "../../../../state/persist-scheduler";
6
6
  import { backendRequest, getElectrobunBackendInitSnapshot } from "../backend-rpc";
7
- import { MemoryResourceStore } from "../../../../data/memory-resource-store";
7
+ import { WriteThroughResourceStore } from "../../../../data/memory-resource-store";
8
+ import { isBrokerAccountSnapshotKey } from "../../../../brokers/account-cache";
8
9
 
9
10
  class RemoteSessionStore {
10
11
  private snapshot = getElectrobunBackendInitSnapshot()?.sessionSnapshot ?? null;
@@ -123,9 +124,21 @@ class RemotePluginStateStore {
123
124
  }
124
125
  }
125
126
 
127
+ /**
128
+ * Caches stay in the view's memory, except broker account snapshots: the Bun
129
+ * process saves those in the cache database, as the terminal does, so a
130
+ * broker portfolio opens with its last known equity and cash.
131
+ */
132
+ function createViewResourceStore(): WriteThroughResourceStore {
133
+ return new WriteThroughResourceStore(getElectrobunBackendInitSnapshot()?.savedResources ?? [], {
134
+ set: (record) => void backendRequest("resources.set", { record }).catch(() => {}),
135
+ delete: (key) => void backendRequest("resources.delete", { key }).catch(() => {}),
136
+ }, isBrokerAccountSnapshotKey);
137
+ }
138
+
126
139
  export class RemotePersistence {
127
140
  readonly tickers = {};
128
- readonly resources = new MemoryResourceStore();
141
+ readonly resources = createViewResourceStore();
129
142
  readonly pluginState = new RemotePluginStateStore(getElectrobunBackendInitSnapshot()?.pluginState ?? {});
130
143
  readonly sessions = new RemoteSessionStore();
131
144
 
@@ -2,11 +2,8 @@
2
2
  import type { RendererHost, UiHost } from "../../../ui/host";
3
3
  import { flushUsageCounts } from "../../../telemetry/usage-counts";
4
4
  import { backendRequest } from "./backend-rpc";
5
- import {
6
- NATIVE_CONTEXT_MENU_SUPPORTED,
7
- showDesktopContextMenu,
8
- startElectrobunWindowDrag,
9
- } from "./host/native";
5
+ import { NATIVE_CONTEXT_MENU_SUPPORTED, showDesktopContextMenu } from "./host/native";
6
+ import { startElectrobunWindowDrag } from "./host/window-move";
10
7
  import { createDomUiHost } from "../../dom/dom-ui-host";
11
8
 
12
9
  export function createWebUiHost(desktopPlatform?: string): UiHost {