gloomberb 0.10.2 → 0.10.4

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 (164) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +2 -2
  3. package/package.json +2 -1
  4. package/src/api-client/auth.ts +8 -0
  5. package/src/api-client/index.ts +70 -0
  6. package/src/api-client/types.ts +49 -0
  7. package/src/app/runtime/desktop-deeplink.test.ts +8 -0
  8. package/src/app/runtime/desktop-deeplink.ts +14 -1
  9. package/src/components/chart/composite/composite-chart.test.tsx +340 -17
  10. package/src/components/chart/composite/composite-chart.tsx +859 -68
  11. package/src/components/chart/composite/interactions.test.ts +78 -22
  12. package/src/components/chart/composite/interactions.ts +45 -17
  13. package/src/components/chart/composite/rasterizer.ts +8 -2
  14. package/src/components/chart/composite/renderers.test.ts +61 -1
  15. package/src/components/chart/composite/scene.ts +14 -4
  16. package/src/components/chart/composite/tools.test.ts +371 -0
  17. package/src/components/chart/composite/tools.ts +508 -0
  18. package/src/components/chart/composite/types.ts +4 -0
  19. package/src/components/chart/core/pointer.ts +2 -0
  20. package/src/components/chart/native/raster/primitives.ts +18 -0
  21. package/src/components/chart/price-axis-labels.tsx +42 -2
  22. package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
  23. package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
  24. package/src/components/command-bar/action-runtime.ts +3 -0
  25. package/src/components/command-bar/assist/inventory.test.ts +93 -0
  26. package/src/components/command-bar/assist/inventory.ts +92 -0
  27. package/src/components/command-bar/assist/model.test.ts +104 -0
  28. package/src/components/command-bar/assist/model.ts +169 -0
  29. package/src/components/command-bar/assist/runtime.ts +185 -0
  30. package/src/components/command-bar/commands/direct/index.ts +1 -1
  31. package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
  32. package/src/components/command-bar/list/model.ts +15 -2
  33. package/src/components/command-bar/list/navigation.ts +6 -0
  34. package/src/components/command-bar/list/view.tsx +16 -3
  35. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  36. package/src/components/command-bar/panel/body.tsx +2 -0
  37. package/src/components/command-bar/panel/index.tsx +3 -0
  38. package/src/components/command-bar/panel/runtime.ts +6 -0
  39. package/src/components/command-bar/panel/types.ts +1 -0
  40. package/src/components/command-bar/routes/root/results.ts +28 -2
  41. package/src/components/command-bar/routes/root/runtime.ts +66 -36
  42. package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
  43. package/src/components/command-bar/routing/navigation-state.ts +17 -0
  44. package/src/components/command-bar/selection-runtime.ts +26 -0
  45. package/src/components/command-bar/surface/assist.test.tsx +276 -0
  46. package/src/components/command-bar/surface/index.tsx +93 -2
  47. package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
  48. package/src/components/command-bar/view-model.ts +7 -1
  49. package/src/components/command-bar/workflow/broker.test.ts +22 -0
  50. package/src/components/command-bar/workflow/broker.ts +1 -0
  51. package/src/components/index.ts +1 -1
  52. package/src/components/layout/detached-pane-shell.tsx +40 -2
  53. package/src/components/layout/floating-pane.tsx +4 -1
  54. package/src/components/layout/pane/header.tsx +64 -6
  55. package/src/components/layout/pane/index.tsx +4 -1
  56. package/src/components/layout/shell/index.tsx +23 -0
  57. package/src/components/layout/shell/pane/layers.tsx +5 -0
  58. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  59. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  60. package/src/components/onboarding/account-step/index.tsx +58 -0
  61. package/src/components/onboarding/account-step/model.test.ts +67 -0
  62. package/src/components/onboarding/account-step/model.ts +126 -0
  63. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  64. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  65. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  66. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  67. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  68. package/src/components/onboarding/wizard-account.ts +235 -0
  69. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  70. package/src/components/onboarding/wizard-model.ts +2 -2
  71. package/src/components/ui/data-table/index.tsx +1 -0
  72. package/src/components/ui/data-table/opentui/index.tsx +44 -2
  73. package/src/components/ui/data-table/types.ts +10 -0
  74. package/src/components/ui/data-table/visible-range.test.ts +29 -0
  75. package/src/components/ui/data-table/visible-range.ts +21 -0
  76. package/src/components/ui/ui.test.tsx +23 -1
  77. package/src/i18n/es.ts +1093 -0
  78. package/src/i18n/index.test.ts +5 -2
  79. package/src/i18n/index.ts +3 -0
  80. package/src/i18n/ja.ts +66 -3
  81. package/src/i18n/ko.ts +66 -3
  82. package/src/i18n/languages.test.ts +4 -1
  83. package/src/i18n/languages.ts +12 -1
  84. package/src/i18n/zh-cn.ts +66 -3
  85. package/src/i18n/zh-tw.ts +66 -3
  86. package/src/market-data/coordinator/index.ts +1 -1
  87. package/src/market-data/coordinator/quotes.ts +64 -1
  88. package/src/market-data/coordinator/subscriptions.test.ts +72 -0
  89. package/src/market-data/quotes/cadence.ts +2 -0
  90. package/src/plugins/builtin/account-management/footer.ts +9 -3
  91. package/src/plugins/builtin/account-management/model.ts +53 -1
  92. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  93. package/src/plugins/builtin/ai/index.tsx +7 -2
  94. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  95. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  96. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  97. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  98. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  99. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  100. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  101. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  102. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  103. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  104. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  105. package/src/plugins/builtin/help/index.tsx +38 -0
  106. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  107. package/src/plugins/builtin/options/footer.test.ts +20 -112
  108. package/src/plugins/builtin/options/footer.ts +35 -78
  109. package/src/plugins/builtin/options/index.tsx +6 -0
  110. package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
  111. package/src/plugins/builtin/options/live-quotes.ts +93 -39
  112. package/src/plugins/builtin/options/view.test.tsx +5 -2
  113. package/src/plugins/builtin/options/view.tsx +49 -9
  114. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  115. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  116. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  117. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  118. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  119. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  120. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  121. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  122. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  123. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  124. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  125. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  126. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  127. package/src/plugins/registry/index.test.ts +51 -0
  128. package/src/plugins/registry/index.ts +12 -0
  129. package/src/plugins/registry/pane-settings.ts +29 -0
  130. package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
  131. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  132. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  133. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  134. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  135. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  136. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  137. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  138. package/src/renderers/electrobun/view/host/style.ts +1 -1
  139. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  140. package/src/renderers/electrobun/view/main.tsx +2 -0
  141. package/src/renderers/opentui/chart-surface.tsx +2 -0
  142. package/src/renderers/opentui/host.tsx +3 -0
  143. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  144. package/src/sources/gloomberb-cloud/index.ts +4 -4
  145. package/src/sources/provider-router/history.test.ts +3 -3
  146. package/src/sources/provider-router/history.ts +15 -4
  147. package/src/sources/yahoo-finance/history.test.ts +47 -0
  148. package/src/sources/yahoo-finance/history.ts +5 -1
  149. package/src/state/hooks/quote-streaming.test.tsx +181 -1
  150. package/src/state/hooks/quote-streaming.ts +93 -7
  151. package/src/theme/colors.ts +20 -1
  152. package/src/theme/themes.test.ts +3 -0
  153. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  154. package/src/time-series/resolution.test.ts +51 -0
  155. package/src/time-series/resolution.ts +41 -0
  156. package/src/time-series/resolve.test.ts +207 -1
  157. package/src/time-series/resolve.ts +196 -29
  158. package/src/time-series/spec-studies.test.ts +4 -0
  159. package/src/time-series/spec.ts +15 -1
  160. package/src/time-series/types.ts +7 -0
  161. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  162. package/src/time-series/use-chart-resolution.ts +17 -1
  163. package/src/types/plugin.ts +9 -0
  164. package/src/ui/host.tsx +17 -0
@@ -15,7 +15,9 @@ import { measurePerf } from "../../../../utils/perf-marks";
15
15
  import type {
16
16
  DataTableColumn,
17
17
  DataTableProps,
18
+ DataTableVisibleRange,
18
19
  } from "../../../../components/ui/data-table";
20
+ import { resolveDataTableVisibleRange } from "../../../../components/ui/data-table/visible-range";
19
21
  import {
20
22
  buildTableGridTemplateColumns,
21
23
  getTableWidth,
@@ -57,6 +59,8 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
57
59
  onSelect,
58
60
  onActivate,
59
61
  onTableMouseDown,
62
+ visibleRangeKey,
63
+ onVisibleRangeChange,
60
64
  onRowMouseDown,
61
65
  onRowContextMenu,
62
66
  rowContextMenuSurface = false,
@@ -80,6 +84,10 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
80
84
  const dispatch = useAppDispatch();
81
85
  const paneInstanceId = usePaneInstance()?.instanceId ?? null;
82
86
  const bodyElementRef = useRef<HTMLDivElement | null>(null);
87
+ const lastVisibleRangeRef = useRef<{
88
+ key: string | number | undefined;
89
+ range: DataTableVisibleRange;
90
+ } | null>(null);
83
91
  const headerHorizontal = useScrollbarState(false);
84
92
  const headerVertical = useScrollbarState(false);
85
93
  const bodyHorizontal = useScrollbarState(showHorizontalScrollbar);
@@ -106,10 +114,32 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
106
114
  dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
107
115
  }, [dispatch, paneInstanceId]);
108
116
 
117
+ const emitVisibleRange = useCallback(() => {
118
+ if (!onVisibleRangeChange) return;
119
+ const element = bodyElementRef.current;
120
+ if (!element) return;
121
+ const range = resolveDataTableVisibleRange({
122
+ itemCount: items.length,
123
+ rowSize: WEB_CELL_HEIGHT,
124
+ scrollOffset: element.scrollTop,
125
+ viewportSize: Math.max(0, element.clientHeight - WEB_CELL_HEIGHT),
126
+ });
127
+ const previous = lastVisibleRangeRef.current;
128
+ if (
129
+ previous !== null
130
+ && previous.key === visibleRangeKey
131
+ && previous.range.start === range.start
132
+ && previous.range.end === range.end
133
+ ) return;
134
+ lastVisibleRangeRef.current = { key: visibleRangeKey, range };
135
+ onVisibleRangeChange(range);
136
+ }, [items.length, onVisibleRangeChange, visibleRangeKey]);
109
137
  const handleBodyScrollActivity = useCallback(() => {
110
138
  onBodyScrollActivity();
111
- }, [onBodyScrollActivity]);
139
+ emitVisibleRange();
140
+ }, [emitVisibleRange, onBodyScrollActivity]);
112
141
  const scheduleBodyScrollActivity = useRafCallback(handleBodyScrollActivity);
142
+ const scheduleVisibleRangeMeasure = useRafCallback(emitVisibleRange);
113
143
 
114
144
  const rowVirtualizer = useVirtualizer({
115
145
  count: items.length,
@@ -154,12 +184,20 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
154
184
 
155
185
  useEffect(() => {
156
186
  measureViewportWidth();
187
+ scheduleVisibleRangeMeasure();
157
188
  const element = bodyElementRef.current;
158
189
  if (!element || typeof ResizeObserver === "undefined") return;
159
- const observer = new ResizeObserver(scheduleViewportWidthMeasure);
190
+ const observer = new ResizeObserver(() => {
191
+ scheduleViewportWidthMeasure();
192
+ scheduleVisibleRangeMeasure();
193
+ });
160
194
  observer.observe(element);
161
195
  return () => observer.disconnect();
162
- }, [measureViewportWidth, scheduleViewportWidthMeasure]);
196
+ }, [measureViewportWidth, scheduleViewportWidthMeasure, scheduleVisibleRangeMeasure]);
197
+
198
+ useEffect(() => {
199
+ scheduleVisibleRangeMeasure();
200
+ }, [items.length, scheduleVisibleRangeMeasure, visibleRangeKey]);
163
201
 
164
202
  useEffect(() => {
165
203
  if (scrollToIndex == null || items.length === 0) return;
@@ -168,6 +206,7 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
168
206
  rowVirtualizer.scrollToIndex(targetIndex, {
169
207
  align: scrollToIndexAlign === "center" ? "center" : "auto",
170
208
  });
209
+ scheduleVisibleRangeMeasure();
171
210
  return;
172
211
  }
173
212
  const element = bodyElementRef.current;
@@ -185,12 +224,14 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
185
224
  if (nextTop !== currentTop) {
186
225
  element.scrollTop = nextTop * WEB_CELL_HEIGHT;
187
226
  }
227
+ scheduleVisibleRangeMeasure();
188
228
  }, [
189
229
  items.length,
190
230
  rowVirtualizer,
191
231
  scrollToIndex,
192
232
  scrollToIndexAlign,
193
233
  scrollToIndexVersion,
234
+ scheduleVisibleRangeMeasure,
194
235
  virtualize,
195
236
  ]);
196
237
 
@@ -0,0 +1,97 @@
1
+ /** @jsxImportSource react */
2
+ import { Window } from "happy-dom";
3
+
4
+ // The desktop host is the only renderer with a real DOM underneath it, so its
5
+ // mouse contract needs a DOM to be tested against.
6
+ const testWindow = new Window({ url: "http://localhost" });
7
+ Object.assign(globalThis, {
8
+ window: testWindow,
9
+ document: testWindow.document,
10
+ navigator: testWindow.navigator,
11
+ MouseEvent: testWindow.MouseEvent,
12
+ Event: testWindow.Event,
13
+ HTMLElement: testWindow.HTMLElement,
14
+ Node: testWindow.Node,
15
+ requestAnimationFrame: (callback: (time: number) => void) => setTimeout(() => callback(Date.now()), 8),
16
+ cancelAnimationFrame: (id: number) => clearTimeout(id),
17
+ });
18
+
19
+ import { expect, test } from "bun:test";
20
+ import { act } from "react";
21
+ import { createRoot } from "react-dom/client";
22
+ import { WebChartSurface } from "./chart-surface";
23
+
24
+ async function settle() {
25
+ await new Promise((resolve) => setTimeout(resolve, 30));
26
+ }
27
+
28
+ test("reports a drag as a drag, never as a move, and keeps its modifiers", async () => {
29
+ const seen: string[] = [];
30
+ const modifiers: Array<{ shift: boolean; alt: boolean }> = [];
31
+ const container = testWindow.document.createElement("div");
32
+ testWindow.document.body.appendChild(container);
33
+ const root = createRoot(container as unknown as HTMLElement);
34
+ await act(async () => {
35
+ root.render(
36
+ <WebChartSurface
37
+ width={40}
38
+ height={10}
39
+ onMouseMove={() => seen.push("move")}
40
+ onMouseDown={(event: { modifiers: { shift: boolean; alt: boolean } }) => {
41
+ seen.push("down");
42
+ modifiers.push(event.modifiers);
43
+ }}
44
+ onMouseDrag={(event: { modifiers: { shift: boolean; alt: boolean } }) => {
45
+ seen.push("drag");
46
+ modifiers.push(event.modifiers);
47
+ }}
48
+ onMouseUp={() => seen.push("up")}
49
+ />,
50
+ );
51
+ });
52
+ const surface = container.firstElementChild as unknown as HTMLElement;
53
+ const mouse = (type: string, target: { dispatchEvent: (event: unknown) => unknown }, clientX: number) => {
54
+ target.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0, clientX, clientY: 24, shiftKey: true }));
55
+ };
56
+
57
+ await act(async () => {
58
+ mouse("mousemove", surface, 10);
59
+ await settle();
60
+ });
61
+ await act(async () => mouse("mousedown", surface, 10));
62
+ await act(async () => {
63
+ // The DOM delivers both while a button is held; only the drag may surface.
64
+ mouse("mousemove", surface, 80);
65
+ mouse("mousemove", testWindow.document as never, 80);
66
+ await settle();
67
+ });
68
+ await act(async () => mouse("mouseup", testWindow.document as never, 80));
69
+
70
+ expect(seen).toEqual(["move", "down", "drag", "up"]);
71
+ expect(modifiers.every((entry) => entry.shift)).toBe(true);
72
+ });
73
+
74
+ test("a tab bar occupies exactly the one row panes reserve for it", async () => {
75
+ const { WebTabs } = await import("./tabs");
76
+ const { WEB_CELL_HEIGHT } = await import("../input-host");
77
+ const container = testWindow.document.createElement("div");
78
+ testWindow.document.body.appendChild(container);
79
+ const root = createRoot(container as unknown as HTMLElement);
80
+ await act(async () => {
81
+ root.render(
82
+ <WebTabs
83
+ tabs={[{ label: "Overview", value: "overview" }, { label: "Chart", value: "chart" }]}
84
+ activeValue="chart"
85
+ onSelect={() => {}}
86
+ palette={{} as never}
87
+ />,
88
+ );
89
+ });
90
+ await settle();
91
+ const list = container.querySelector('[data-gloom-role="tab-list"]') as unknown as HTMLElement;
92
+ // Panes size their content as `height - 1` for the tab bar. Any extra pixel
93
+ // here is a pixel the content is told it owns and the pane then clips.
94
+ expect(list.style.height).toBe(`${WEB_CELL_HEIGHT}px`);
95
+ expect(list.style.marginBottom === "" || list.style.marginBottom === "0px").toBe(true);
96
+ await act(async () => root.unmount());
97
+ });
@@ -65,6 +65,9 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
65
65
  };
66
66
 
67
67
  const scheduleFrameMouseHandler = (event: MouseLikeEvent, type: "move" | "drag") => {
68
+ // The DOM keeps firing mousemove while a button is held. A move means no
69
+ // button anywhere else in the app, so drags must not surface as moves.
70
+ if (type === "move" && draggingRef.current) return;
68
71
  if (type === "move" && typeof propsRef.current.onMouseMove !== "function") return;
69
72
  if (type === "drag"
70
73
  && typeof propsRef.current.onMouse !== "function"
@@ -8,7 +8,12 @@ import {
8
8
  type ReactNode,
9
9
  type Ref,
10
10
  } from "react";
11
- import type { BitmapSurface, BoxRenderable, ChartCrosshairOverlay } from "../../../../ui/host";
11
+ import type {
12
+ BitmapSurface,
13
+ BoxRenderable,
14
+ ChartCrosshairOverlay,
15
+ ChartVectorShape,
16
+ } from "../../../../ui/host";
12
17
  import { WebBox } from "./box";
13
18
 
14
19
  const CanvasBitmap = memo(function CanvasBitmap({ bitmap }: { bitmap: BitmapSurface }) {
@@ -54,6 +59,83 @@ const BoxLayer = memo(function BoxLayer({ bitmap, index }: { bitmap: BitmapSurfa
54
59
  );
55
60
  });
56
61
 
62
+ const ChartVectors = memo(function ChartVectors({ vectors }: { vectors: readonly ChartVectorShape[] }) {
63
+ if (vectors.length === 0) return null;
64
+ return (
65
+ <>
66
+ <svg
67
+ viewBox="0 0 1 1"
68
+ preserveAspectRatio="none"
69
+ // An SVG is a replaced element: without an explicit size it falls back
70
+ // to its intrinsic box and squeezes the whole overlay into a corner.
71
+ style={{
72
+ position: "absolute",
73
+ inset: 0,
74
+ width: "100%",
75
+ height: "100%",
76
+ pointerEvents: "none",
77
+ zIndex: 9,
78
+ }}
79
+ >
80
+ {vectors.map((shape) => {
81
+ const strokeWidth = shape.strokeWidth ?? 1.4;
82
+ const [first, second] = shape.points;
83
+ if (shape.box && first && second) {
84
+ return (
85
+ <rect
86
+ key={shape.id}
87
+ x={Math.min(first.x, second.x)}
88
+ y={Math.min(first.y, second.y)}
89
+ width={Math.abs(second.x - first.x)}
90
+ height={Math.abs(second.y - first.y)}
91
+ fill={shape.color}
92
+ fillOpacity={shape.fillOpacity ?? 0.18}
93
+ stroke={shape.color}
94
+ strokeWidth={strokeWidth}
95
+ vectorEffect="non-scaling-stroke"
96
+ />
97
+ );
98
+ }
99
+ return (
100
+ <polyline
101
+ key={shape.id}
102
+ points={shape.points.map((point) => `${point.x},${point.y}`).join(" ")}
103
+ fill="none"
104
+ stroke={shape.color}
105
+ strokeWidth={strokeWidth}
106
+ strokeLinecap="round"
107
+ strokeLinejoin="round"
108
+ vectorEffect="non-scaling-stroke"
109
+ />
110
+ );
111
+ })}
112
+ </svg>
113
+ {/* Ratio space is anisotropic, so handles are placed instead of scaled. */}
114
+ {vectors.flatMap((shape) => shape.handles
115
+ ? shape.points.map((point, index) => (
116
+ <div
117
+ key={`${shape.id}:${index}`}
118
+ style={{
119
+ position: "absolute",
120
+ left: `${point.x * 100}%`,
121
+ top: `${point.y * 100}%`,
122
+ width: 9,
123
+ height: 9,
124
+ borderRadius: 999,
125
+ border: `2px solid ${shape.color}`,
126
+ backgroundColor: "var(--gloom-bg)",
127
+ boxSizing: "border-box",
128
+ transform: "translate(-50%, -50%)",
129
+ pointerEvents: "none",
130
+ zIndex: 10,
131
+ }}
132
+ />
133
+ ))
134
+ : [])}
135
+ </>
136
+ );
137
+ });
138
+
57
139
  function ChartCrosshair({
58
140
  bitmap,
59
141
  crosshair,
@@ -122,6 +204,7 @@ export const WebChartSurface = forwardRef<BoxRenderable, Record<string, unknown>
122
204
  const bitmaps = (props.bitmaps ?? null) as readonly BitmapSurface[] | null;
123
205
  const layers = bitmaps ?? (bitmap ? [bitmap] : []);
124
206
  const crosshair = (props.crosshair ?? null) as ChartCrosshairOverlay | null;
207
+ const vectors = (props.vectors ?? null) as readonly ChartVectorShape[] | null;
125
208
  const baseLayer = layers[0] ?? null;
126
209
  return (
127
210
  <WebBox
@@ -135,6 +218,7 @@ export const WebChartSurface = forwardRef<BoxRenderable, Record<string, unknown>
135
218
  <BoxLayer key={`layer:${index}`} index={index} bitmap={layer} />
136
219
  ))
137
220
  : children as ReactNode}
221
+ <ChartVectors vectors={vectors ?? []} />
138
222
  <ChartCrosshair bitmap={baseLayer} crosshair={crosshair} />
139
223
  </WebBox>
140
224
  );
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isEditableTarget, shouldReleaseFocus } from "./focus-scope";
3
+
4
+ function field(scope?: { contains: (node: unknown) => boolean }) {
5
+ return {
6
+ tagName: "INPUT",
7
+ closest: () => scope ?? null,
8
+ contains: (node: unknown) => node === undefined,
9
+ };
10
+ }
11
+
12
+ describe("focus scope release", () => {
13
+ test("releases focus for a press outside the widget that owns it", () => {
14
+ expect(shouldReleaseFocus(field(), { tagName: "DIV" })).toBe(true);
15
+ });
16
+
17
+ test("keeps focus for a press on the owning widget's own chrome", () => {
18
+ const suggestion = { tagName: "DIV" };
19
+ expect(shouldReleaseFocus(field({ contains: (n) => n === suggestion }), suggestion)).toBe(false);
20
+ });
21
+
22
+ test("ignores presses while nothing editable holds focus", () => {
23
+ expect(shouldReleaseFocus({ tagName: "DIV", closest: () => null }, {})).toBe(false);
24
+ expect(shouldReleaseFocus(null, {})).toBe(false);
25
+ });
26
+
27
+ test("leaves a press inside a dialog alone, which owns the pointer itself", () => {
28
+ const inDialog = { tagName: "INPUT", closest: (selector: string) => selector === ".gloom-dialog" ? {} : null };
29
+ expect(shouldReleaseFocus(field(), inDialog)).toBe(false);
30
+ });
31
+
32
+ test("recognises the field a press is about to focus", () => {
33
+ expect(isEditableTarget({ tagName: "textarea" })).toBe(true);
34
+ expect(isEditableTarget({ isContentEditable: true })).toBe(true);
35
+ expect(isEditableTarget({ tagName: "canvas" })).toBe(false);
36
+ });
37
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The browser gives up a field's focus when you press somewhere else. Panes,
3
+ * charts and rows all consume their own mousedown, which is exactly what
4
+ * suppresses that, so a field can hold the keyboard long after it looks done.
5
+ *
6
+ * One rule restores the default: a press outside the widget that owns the focus
7
+ * releases it. Widgets whose own chrome must not take focus from their field,
8
+ * such as a suggestion list under an input, mark it with the scope attribute.
9
+ *
10
+ * A dialog is exempt. It owns the pointer while it is open, and releasing a
11
+ * field underneath it re-renders whatever hosts the dialog, which costs the
12
+ * dialog the focus it just took.
13
+ */
14
+ const FOCUS_SCOPE_ATTRIBUTE = "data-gloom-focus-scope";
15
+ const DIALOG_CLASS_SELECTOR = ".gloom-dialog";
16
+
17
+ interface FocusableLike {
18
+ tagName?: string;
19
+ isContentEditable?: boolean;
20
+ blur?: () => void;
21
+ focus?: () => void;
22
+ closest?: (selector: string) => { contains?: (node: unknown) => boolean } | null;
23
+ contains?: (node: unknown) => boolean;
24
+ }
25
+
26
+ function asFocusable(node: unknown): FocusableLike | null {
27
+ return node && typeof node === "object" ? node as FocusableLike : null;
28
+ }
29
+
30
+ export function isEditableTarget(node: unknown): boolean {
31
+ const element = asFocusable(node);
32
+ if (!element) return false;
33
+ if (element.isContentEditable === true) return true;
34
+ const tag = element.tagName?.toUpperCase();
35
+ return tag === "INPUT" || tag === "TEXTAREA";
36
+ }
37
+
38
+ export function shouldReleaseFocus(active: unknown, target: unknown): boolean {
39
+ if (!isEditableTarget(active) || !target || active === target) return false;
40
+ if (asFocusable(target)?.closest?.(DIALOG_CLASS_SELECTOR)) return false;
41
+ const element = asFocusable(active)!;
42
+ const scope = element.closest?.(`[${FOCUS_SCOPE_ATTRIBUTE}]`) ?? element;
43
+ return !scope?.contains?.(target);
44
+ }
45
+
46
+ export function installFocusScopeRelease(): () => void {
47
+ const doc = (globalThis as { document?: Document }).document;
48
+ if (!doc) return () => {};
49
+ const handleMouseDown = (event: MouseEvent) => {
50
+ const active = doc.activeElement;
51
+ if (!shouldReleaseFocus(active, event.target)) return;
52
+ asFocusable(active)?.blur?.();
53
+ };
54
+ doc.addEventListener("mousedown", handleMouseDown, true);
55
+ return () => doc.removeEventListener("mousedown", handleMouseDown, true);
56
+ }
@@ -54,11 +54,25 @@ function callTextHandler(handler: unknown, value: string): void {
54
54
  }
55
55
  }
56
56
 
57
+ /**
58
+ * A press on a field must focus it, even when the owner's controlled flag never
59
+ * changed. Panes and charts consume their own mousedown, which suppresses the
60
+ * default focus move, and a field that lost focus externally would otherwise
61
+ * stay dead until its flag happened to toggle.
62
+ */
63
+ function focusOnPress(element: HTMLInputElement | HTMLTextAreaElement | null): void {
64
+ if (!element || document.activeElement === element) return;
65
+ element.focus();
66
+ }
67
+
57
68
  function applyDomCursorOffset(
58
69
  element: HTMLInputElement | HTMLTextAreaElement | null,
59
70
  offset: number,
60
71
  ) {
61
- if (!element) return;
72
+ // Placing a caret in a field nobody is typing in means nothing, and WebKit
73
+ // hands the field the focus back when you try, which silently undoes a
74
+ // release that happened a frame earlier.
75
+ if (!element || document.activeElement !== element) return;
62
76
  const clampedOffset = Math.max(0, Math.min(offset, element.value.length));
63
77
  element.setSelectionRange(clampedOffset, clampedOffset);
64
78
  }
@@ -262,6 +276,7 @@ export const WebInput = forwardRef<InputRenderable, Record<string, unknown>>(fun
262
276
  placeholder={getStringProp(props, "placeholder")}
263
277
  onInput={(event) => handleValueChange(event.currentTarget.value)}
264
278
  onChange={(event) => handleValueChange(event.currentTarget.value)}
279
+ onMouseDown={() => focusOnPress(elementRef.current)}
265
280
  onFocus={() => {
266
281
  setDomFocused(true);
267
282
  callTextHandler(propsRef.current.onFocus, elementRef.current?.value ?? valueRef.current);
@@ -52,6 +52,7 @@ export function cellMouseEvent(event: MouseLikeEvent, type?: string) {
52
52
  pixelX: event.clientX,
53
53
  pixelY: event.clientY,
54
54
  button: event.button,
55
+ target: (event as { target?: unknown }).target,
55
56
  detail: "detail" in event && typeof event.detail === "number" ? event.detail : 0,
56
57
  timeStamp: event.timeStamp,
57
58
  modifiers: {
@@ -103,7 +103,7 @@ export function cleanDomProps(props: Record<string, unknown>): Record<string, un
103
103
  "focused", "focusedBackgroundColor", "textColor", "focusedTextColor", "placeholderColor",
104
104
  "cursorColor", "selectionBg", "selectionFg", "showCursor", "keyBindings", "wrapText", "wrapMode",
105
105
  "initialValue", "value", "onInput", "onChange", "onSubmit", "onEscape", "onCursorChange", "onMouse",
106
- "scrollX", "scrollY", "focusable", "bitmap", "bitmaps", "crosshair", "text", "font",
106
+ "scrollX", "scrollY", "focusable", "bitmap", "bitmaps", "crosshair", "vectors", "text", "font",
107
107
  "hoverBackgroundColor",
108
108
  ]) {
109
109
  delete next[key];
@@ -27,7 +27,10 @@ export function WebTabs({
27
27
  const activeTabRef = useRef<HTMLButtonElement | null>(null);
28
28
  const [hoveredValue, setHoveredValue] = useState<string | null>(null);
29
29
  const showUnderline = variant === "underline" && !compact;
30
- const listHeight = showUnderline ? 28 : compact ? WEB_CELL_HEIGHT : "100%";
30
+ // A tab bar occupies exactly the one row every caller reserves for it. Any
31
+ // extra pixels here are pixels the pane's children are told they own and the
32
+ // pane then clips, which is how a chart's time axis ends up behind a footer.
33
+ const listHeight = showUnderline || compact ? WEB_CELL_HEIGHT : "100%";
31
34
  const tabFontSize = compact || showUnderline ? 12 : 13;
32
35
  const tabPaddingInline = dense ? 5 : showUnderline ? 10 : 8;
33
36
  const tabPaddingBlock = variant === "bare" || variant === "pill" ? 2 : 0;
@@ -83,7 +86,6 @@ export function WebTabs({
83
86
  overflowY: "hidden",
84
87
  paddingInline: variant === "underline" || dense ? 0 : 4,
85
88
  paddingBlock: tabPaddingBlock,
86
- marginBottom: showUnderline ? 4 : 0,
87
89
  boxSizing: "border-box",
88
90
  }}
89
91
  >
@@ -11,6 +11,7 @@ import {
11
11
  setElectrobunRemoteRequestHandler,
12
12
  } from "./backend-rpc";
13
13
  import { installElectrobunAiHost } from "./ai-host";
14
+ import { installFocusScopeRelease } from "./host/focus-scope";
14
15
  import { installElectrobunBrokerRemoteClient } from "./broker-remote-client";
15
16
  import { installElectrobunConfigStoreHost } from "./config-host";
16
17
  import { WebDialogHostProvider } from "./dialog-host";
@@ -88,6 +89,7 @@ async function boot() {
88
89
  installElectrobunUpdateHost();
89
90
  const init = await measurePerfAsync("startup.electrobun.backend-init", () => backendInitPromise);
90
91
  installElectrobunAiHost();
92
+ installFocusScopeRelease();
91
93
  const desktopSnapshot = init.windowKind === "detached" && init.paneId && init.desktopSnapshot
92
94
  ? prepareDetachedSnapshot(init.desktopSnapshot, init.paneId)
93
95
  : init.desktopSnapshot;
@@ -96,6 +96,8 @@ export const OpenTuiChartSurface = forwardRef<unknown, ChartSurfaceProps>(functi
96
96
  const surfaceId = useRef(`opentui-chart:${nextChartSurfaceId++}`).current;
97
97
  const renderableRef = useRef<NativeRenderableNode | null>(null);
98
98
  const [target, setTarget] = useState<SurfaceTarget | null>(null);
99
+ // Only the base layer reaches the terminal: each extra layer would need its own
100
+ // kitty surface and z-index. Composite overlays into the bitmap you pass here.
99
101
  const firstBitmap = Array.isArray(bitmaps) ? bitmaps[0] : null;
100
102
  const nativeBitmap = (firstBitmap ?? bitmap ?? null) as NativeChartBitmap | null;
101
103
  const nativeCrosshair = (crosshair ?? null) as ChartCrosshairOverlay | null;
@@ -145,6 +145,9 @@ export async function createOpenTuiHost(): Promise<OpenTuiHost> {
145
145
  "--profile=sw-fast",
146
146
  "--vo=kitty",
147
147
  "--vo-kitty-auto-multiplexer-passthrough=yes",
148
+ "--demuxer-lavf-probe-info=yes",
149
+ "--demuxer-lavf-analyzeduration=10",
150
+ "--demuxer-lavf-probesize=25000000",
148
151
  "--ytdl=no",
149
152
  `--mute=${options?.muted === false ? "no" : "yes"}`,
150
153
  ...(title ? [`--title=${title}`] : []),
@@ -339,6 +339,14 @@ describe("GloomberbCloudProvider", () => {
339
339
  test("does not advertise unsupported 45 minute chart resolution", () => {
340
340
  const provider = new GloomberbCloudProvider();
341
341
  expect(provider.getChartResolutionCapabilities()).not.toContain("45m");
342
+ expect(provider.getChartResolutionSupport()).toContainEqual({
343
+ resolution: "1d",
344
+ maxRange: "5Y",
345
+ });
346
+ expect(provider.getChartResolutionSupport()).toContainEqual({
347
+ resolution: "1wk",
348
+ maxRange: "5Y",
349
+ });
342
350
  });
343
351
 
344
352
  test("normalizes sub-unit cloud quotes to their main currency", async () => {
@@ -29,7 +29,7 @@ import { resolvePriceHistoryCurrencyUnit } from "../../utils/currency-units";
29
29
  import { canonicalTickerKey } from "../../utils/exchanges";
30
30
  import { normalizePriceHistory } from "../../utils/price-history";
31
31
  import { createProviderMiss } from "../provider-errors";
32
- import { hasMalformedCloudIntradayHistory } from "./history-quality";
32
+ import { hasMalformedIntradayHistory } from "../../time-series/history-quality";
33
33
  import {
34
34
  cloudNewsParams,
35
35
  mapCloudNewsArticle,
@@ -55,8 +55,8 @@ const CLOUD_RESOLUTION_SUPPORT = normalizeChartResolutionSupport([
55
55
  { resolution: "15m", maxRange: "3M" },
56
56
  { resolution: "30m", maxRange: "6M" },
57
57
  { resolution: "1h", maxRange: "1Y" },
58
- { resolution: "1d", maxRange: "ALL" },
59
- { resolution: "1wk", maxRange: "ALL" },
58
+ { resolution: "1d", maxRange: "5Y" },
59
+ { resolution: "1wk", maxRange: "5Y" },
60
60
  { resolution: "1mo", maxRange: "ALL" },
61
61
  ]);
62
62
  const CLOUD_PROVIDER_MISS_PATTERNS = [
@@ -113,7 +113,7 @@ function mapCloudPriceHistory(
113
113
  if (
114
114
  /(min|h)$/i.test(interval)
115
115
  && upstream !== "yahoo"
116
- && hasMalformedCloudIntradayHistory(points)
116
+ && hasMalformedIntradayHistory(points)
117
117
  ) {
118
118
  throw createProviderMiss(`Cloud chart data failed OHLC validation for ${ticker}`);
119
119
  }
@@ -146,7 +146,7 @@ describe("AssetDataRouter chart history", () => {
146
146
  namespace: "market",
147
147
  kind: "price-history",
148
148
  entityKey: "META",
149
- variantKey: "exchange=NASDAQ;range=1M;resolution=5m;version=2",
149
+ variantKey: "exchange=NASDAQ;range=1M;resolution=5m;version=3",
150
150
  sourceKey: "provider:gloomberb-cloud",
151
151
  },
152
152
  [
@@ -181,8 +181,8 @@ describe("AssetDataRouter chart history", () => {
181
181
  .query("SELECT variant_key FROM resource_cache WHERE namespace = ? AND kind = ? AND entity_key = ? ORDER BY variant_key")
182
182
  .all("market", "price-history", "META") as Array<{ variant_key: string }>;
183
183
  expect(cachedRows.map((row) => row.variant_key)).toEqual([
184
- "exchange=NASDAQ;range=1M;resolution=5m;version=2",
185
184
  "exchange=NASDAQ;range=1M;resolution=5m;version=3",
185
+ "exchange=NASDAQ;range=1M;resolution=5m;version=4",
186
186
  ]);
187
187
 
188
188
  persistence.close();
@@ -233,7 +233,7 @@ describe("AssetDataRouter chart history", () => {
233
233
  .all("market", "price-history", "FTC") as Array<{ variant_key: string }>;
234
234
  expect(cachedRows.map((row) => row.variant_key)).toEqual([
235
235
  "exchange=LSE;range=ALL;resolution=1wk",
236
- "exchange=LSE;range=ALL;resolution=1wk;version=3;unit=GBP",
236
+ "exchange=LSE;range=ALL;resolution=1wk;version=4;unit=GBP",
237
237
  ]);
238
238
 
239
239
  persistence.close();