gloomberb 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +85 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +101 -5
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +14 -2
  118. package/src/ui/index.tsx +1 -0
  119. package/src/utils/api-client.test.ts +32 -0
  120. package/src/utils/api-client.ts +119 -18
  121. package/src/utils/double-escape-close.test.ts +33 -0
  122. package/src/utils/double-escape-close.ts +40 -0
  123. package/src/utils/throttled-fetch.test.ts +22 -0
  124. package/src/utils/throttled-fetch.ts +3 -1
  125. package/src/utils/ticker-search.ts +53 -9
  126. package/src/version.ts +1 -1
@@ -6,6 +6,7 @@ import { act, createElement, useReducer } from "react";
6
6
  import { AppContext, appReducer, createInitialState } from "../../state/app-context";
7
7
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
8
8
  import { setSharedMarketDataForTests } from "../../plugins/registry";
9
+ import { useShortcut } from "../../react/input";
9
10
  import { cloneLayout, createDefaultConfig } from "../../types/config";
10
11
  import type { PluginRegistry } from "../../plugins/registry";
11
12
  import { setSharedRegistryForTests } from "../../plugins/registry";
@@ -16,6 +17,7 @@ import type { PaneProps } from "../../types/plugin";
16
17
  import { StockChart } from "../chart/stock-chart";
17
18
  import { StatusBar } from "./status-bar";
18
19
  import { Header } from "./header";
20
+ import { DetachedPaneShell } from "./detached-pane-shell";
19
21
  import {
20
22
  buildNativeWindowState,
21
23
  constrainFloatingRectToBounds,
@@ -211,6 +213,31 @@ function createChartShellDataProvider(historyBySymbol: Record<string, PricePoint
211
213
  };
212
214
  }
213
215
 
216
+ async function emitKeypress(event: { name?: string; sequence?: string }) {
217
+ await act(async () => {
218
+ const keyEvent = {
219
+ ctrl: false,
220
+ meta: false,
221
+ option: false,
222
+ shift: false,
223
+ eventType: "press",
224
+ repeated: false,
225
+ defaultPrevented: false,
226
+ propagationStopped: false,
227
+ preventDefault() {
228
+ this.defaultPrevented = true;
229
+ },
230
+ stopPropagation() {
231
+ this.propagationStopped = true;
232
+ },
233
+ ...event,
234
+ };
235
+ testSetup!.renderer.keyInput.emit("keypress", keyEvent as any);
236
+ await testSetup!.renderOnce();
237
+ await testSetup!.renderOnce();
238
+ });
239
+ }
240
+
214
241
  function HeaderHarness({
215
242
  updateAvailable = null,
216
243
  updateProgress = null,
@@ -935,6 +962,177 @@ describe("Shell", () => {
935
962
  expect(updateLayout?.layout.dockRoot).toBeNull();
936
963
  });
937
964
 
965
+ test("closes the focused pane after double Escape", async () => {
966
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
967
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
968
+ if (!mainPane) throw new Error("missing default portfolio pane");
969
+
970
+ const singlePaneLayout = {
971
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
972
+ instances: [{ ...mainPane }],
973
+ floating: [],
974
+ };
975
+ const state = {
976
+ ...createInitialState({
977
+ ...config,
978
+ layout: cloneLayout(singlePaneLayout),
979
+ layouts: [{ name: "Default", layout: cloneLayout(singlePaneLayout) }],
980
+ }),
981
+ focusedPaneId: "portfolio-list:main",
982
+ };
983
+ const actions: Array<any> = [];
984
+
985
+ testSetup = await testRender(
986
+ <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
987
+ <TestDialogProvider>
988
+ <Shell pluginRegistry={createShellPluginRegistry()} />
989
+ </TestDialogProvider>
990
+ </AppContext>,
991
+ { width: 40, height: 10 },
992
+ );
993
+
994
+ await testSetup.renderOnce();
995
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
996
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
997
+
998
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
999
+
1000
+ const updateLayout = actions.find((action) => action.type === "UPDATE_LAYOUT");
1001
+ expect(actions).toContainEqual({ type: "PUSH_LAYOUT_HISTORY" });
1002
+ expect(updateLayout?.layout.instances).toEqual([]);
1003
+ expect(updateLayout?.layout.floating).toEqual([]);
1004
+ expect(updateLayout?.layout.dockRoot).toBeNull();
1005
+ });
1006
+
1007
+ test("closes on double Escape even when the focused pane handles the first Escape", async () => {
1008
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
1009
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
1010
+ if (!mainPane) throw new Error("missing default portfolio pane");
1011
+
1012
+ let consumedEscapes = 0;
1013
+ function EscapeConsumerPane() {
1014
+ useShortcut((event) => {
1015
+ if (event.name !== "escape") return;
1016
+ consumedEscapes += 1;
1017
+ event.preventDefault();
1018
+ event.stopPropagation();
1019
+ });
1020
+ return <text>Escape Consumer</text>;
1021
+ }
1022
+
1023
+ const singlePaneLayout = {
1024
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
1025
+ instances: [{ ...mainPane }],
1026
+ floating: [],
1027
+ };
1028
+ const state = {
1029
+ ...createInitialState({
1030
+ ...config,
1031
+ layout: cloneLayout(singlePaneLayout),
1032
+ layouts: [{ name: "Default", layout: cloneLayout(singlePaneLayout) }],
1033
+ }),
1034
+ focusedPaneId: "portfolio-list:main",
1035
+ };
1036
+ const actions: Array<any> = [];
1037
+
1038
+ testSetup = await testRender(
1039
+ <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
1040
+ <TestDialogProvider>
1041
+ <Shell
1042
+ pluginRegistry={createShellPluginRegistry({
1043
+ portfolioListComponent: EscapeConsumerPane,
1044
+ })}
1045
+ />
1046
+ </TestDialogProvider>
1047
+ </AppContext>,
1048
+ { width: 40, height: 10 },
1049
+ );
1050
+
1051
+ await testSetup.renderOnce();
1052
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1053
+ expect(consumedEscapes).toBe(1);
1054
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
1055
+
1056
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1057
+ const updateLayout = actions.find((action) => action.type === "UPDATE_LAYOUT");
1058
+ expect(consumedEscapes).toBe(1);
1059
+ expect(updateLayout?.layout.dockRoot).toBeNull();
1060
+ });
1061
+
1062
+ test("closes on double Escape while pane input is captured", async () => {
1063
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
1064
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
1065
+ if (!mainPane) throw new Error("missing default portfolio pane");
1066
+
1067
+ const singlePaneLayout = {
1068
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
1069
+ instances: [{ ...mainPane }],
1070
+ floating: [],
1071
+ };
1072
+ const state = {
1073
+ ...createInitialState({
1074
+ ...config,
1075
+ layout: cloneLayout(singlePaneLayout),
1076
+ layouts: [{ name: "Default", layout: cloneLayout(singlePaneLayout) }],
1077
+ }),
1078
+ focusedPaneId: "portfolio-list:main",
1079
+ inputCaptured: true,
1080
+ };
1081
+ const actions: Array<any> = [];
1082
+
1083
+ testSetup = await testRender(
1084
+ <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
1085
+ <TestDialogProvider>
1086
+ <Shell pluginRegistry={createShellPluginRegistry()} />
1087
+ </TestDialogProvider>
1088
+ </AppContext>,
1089
+ { width: 40, height: 10 },
1090
+ );
1091
+
1092
+ await testSetup.renderOnce();
1093
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1094
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
1095
+
1096
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1097
+ const updateLayout = actions.find((action) => action.type === "UPDATE_LAYOUT");
1098
+ expect(updateLayout?.layout.dockRoot).toBeNull();
1099
+ });
1100
+
1101
+ test("closes a detached pane window after double Escape", async () => {
1102
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
1103
+ const state = {
1104
+ ...createInitialState(config),
1105
+ focusedPaneId: "portfolio-list:main",
1106
+ };
1107
+ const closedPaneIds: string[] = [];
1108
+
1109
+ testSetup = await testRender(
1110
+ <AppContext value={{ state, dispatch: () => {} }}>
1111
+ <TestDialogProvider>
1112
+ <DetachedPaneShell
1113
+ pluginRegistry={createShellPluginRegistry()}
1114
+ desktopWindowBridge={{
1115
+ kind: "detached",
1116
+ paneId: "portfolio-list:main",
1117
+ subscribeState: () => () => {},
1118
+ closeDetachedPane: async (paneId) => {
1119
+ closedPaneIds.push(paneId);
1120
+ },
1121
+ }}
1122
+ />
1123
+ </TestDialogProvider>
1124
+ </AppContext>,
1125
+ { width: 40, height: 10 },
1126
+ );
1127
+
1128
+ await testSetup.renderOnce();
1129
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1130
+ expect(closedPaneIds).toEqual([]);
1131
+
1132
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1133
+ expect(closedPaneIds).toEqual(["portfolio-list:main"]);
1134
+ });
1135
+
938
1136
  test("closes the focused floating pane with Ctrl+W", async () => {
939
1137
  const config = createDefaultConfig("/tmp/gloomberb-shell-test");
940
1138
  const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
@@ -43,6 +43,7 @@ import {
43
43
  selectStatusBarVisible,
44
44
  } from "../../state/selectors-ui";
45
45
  import { colors } from "../../theme/colors";
46
+ import { useThemeColors } from "../../theme/theme-context";
46
47
  import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
47
48
  import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
48
49
  import { FloatingPaneWrapper } from "./floating-pane";
@@ -58,6 +59,11 @@ import {
58
59
  getShortcutDisplayMode,
59
60
  type ShortcutDisplayMode,
60
61
  } from "../../utils/shortcut-labels";
62
+ import {
63
+ createDoubleEscapeCloseState,
64
+ recordDoubleEscapeClose,
65
+ resetDoubleEscapeClose,
66
+ } from "../../utils/double-escape-close";
61
67
 
62
68
  interface ShellProps {
63
69
  pluginRegistry: PluginRegistry;
@@ -777,6 +783,7 @@ function resolveExternalDockPreview(
777
783
  }
778
784
 
779
785
  export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview }: ShellProps) {
786
+ useThemeColors();
780
787
  const dispatch = useAppDispatch();
781
788
  const config = useAppSelector((state) => state.config);
782
789
  const paneState = useAppSelector((state) => state.paneState);
@@ -808,6 +815,7 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
808
815
  const overlayOpen = commandBarOpen || dialogOpen || !!menuState;
809
816
 
810
817
  const dragRef = useRef<DragMode | null>(null);
818
+ const doubleEscapeCloseRef = useRef(createDoubleEscapeCloseState());
811
819
  const [dragFloatingRect, setDragFloatingRect] = useState<{ paneId: string; rect: FloatingRect } | null>(null);
812
820
  const [dragCursor, setDragCursor] = useState<{ x: number; y: number } | null>(null);
813
821
  const [dividerPreview, setDividerPreview] = useState<DividerPreviewState | null>(null);
@@ -964,13 +972,36 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
964
972
  return true;
965
973
  }, [contentHeight, persistLayout, visibleLayout, width]);
966
974
 
975
+ useEffect(() => {
976
+ if (overlayOpen) {
977
+ resetDoubleEscapeClose(doubleEscapeCloseRef.current);
978
+ }
979
+ }, [overlayOpen]);
980
+
967
981
  useShortcut((event) => {
968
- if (event.name === "escape") {
982
+ const isEscape = event.name === "escape" || event.name === "esc";
983
+ if (isEscape) {
984
+ const doubleEscapeState = doubleEscapeCloseRef.current;
985
+ if (!dragRef.current && !overlayOpen) {
986
+ if (recordDoubleEscapeClose(doubleEscapeState, focusedPaneId, Date.now()) && closeFocusedPane()) {
987
+ event.preventDefault();
988
+ event.stopPropagation();
989
+ return;
990
+ }
991
+ } else {
992
+ resetDoubleEscapeClose(doubleEscapeState);
993
+ }
994
+
969
995
  if (!dragRef.current) return;
970
996
  cancelActiveDrag();
971
- return;
997
+ event.preventDefault();
998
+ event.stopPropagation();
999
+ } else {
1000
+ resetDoubleEscapeClose(doubleEscapeCloseRef.current);
972
1001
  }
1002
+ }, { phase: "before" });
973
1003
 
1004
+ useShortcut((event) => {
974
1005
  const shortcut = resolvePaneManagementShortcut(event);
975
1006
  if (!shortcut || dragRef.current || overlayOpen) return;
976
1007
  if (inputCaptured && !inputCaptureAllowsPaneManagementShortcut(shortcut, event)) return;
@@ -1,6 +1,7 @@
1
1
  import { Box, Span, Text, TextAttributes, contextMenuDivider, useContextMenu, useUiCapabilities } from "../../ui";
2
2
  import { useCallback, useEffect, useState } from "react";
3
3
  import { colors, hoverBg } from "../../theme/colors";
4
+ import { useThemeColors } from "../../theme/theme-context";
4
5
  import { useAppDispatch, useAppSelector, useFocusedTicker } from "../../state/app-context";
5
6
  import {
6
7
  selectActiveLayoutIndex,
@@ -57,6 +58,7 @@ function truncate(text: string, width: number): string {
57
58
  }
58
59
 
59
60
  export function StatusBar() {
61
+ useThemeColors();
60
62
  const { nativePaneChrome, nativeContextMenu } = useUiCapabilities();
61
63
  const { showContextMenu } = useContextMenu();
62
64
  const registry = getSharedRegistry();
@@ -2,6 +2,7 @@ import { createElement, useMemo, type SVGProps } from "react";
2
2
  import { Box, ChartSurface, Span, Text, TextAttributes, useNativeRenderer, useUiCapabilities, useUiHost } from "../ui";
3
3
  import { computeBitmapSize, type NativeChartBitmap } from "./chart/native/chart-rasterizer";
4
4
  import { colors } from "../theme/colors";
5
+ import { useThemeColors } from "../theme/theme-context";
5
6
 
6
7
  export interface SpeedometerSegment {
7
8
  from: number;
@@ -20,6 +21,7 @@ export interface SpeedometerGaugeProps {
20
21
  currentLabel?: string;
21
22
  minWidth?: number;
22
23
  maxWidth?: number;
24
+ compact?: boolean;
23
25
  }
24
26
 
25
27
  interface GaugeCell {
@@ -43,23 +45,17 @@ const DEFAULT_MIN_WIDTH = 34;
43
45
  const DEFAULT_MAX_WIDTH = 50;
44
46
  const DESKTOP_VIEWBOX_WIDTH = 520;
45
47
  const DESKTOP_VIEWBOX_HEIGHT = 232;
48
+ const DESKTOP_COMPACT_VIEWBOX_HEIGHT = 214;
46
49
  const DESKTOP_CENTER_X = 260;
47
50
  const DESKTOP_CENTER_Y = 182;
48
51
  const DESKTOP_ARC_RADIUS = 138;
49
52
  const DESKTOP_NEEDLE_RADIUS = 104;
50
53
  const DESKTOP_LABEL_POSITIONS = [
51
- { x: 90, y: 84 },
52
- { x: 172, y: 44 },
53
- { x: 260, y: 30 },
54
- { x: 348, y: 44 },
55
- { x: 430, y: 84 },
56
- ];
57
- const DESKTOP_TICK_LABEL_POSITIONS = [
58
- { x: 78, y: 224 },
59
- { x: 170, y: 224 },
60
- { x: 260, y: 224 },
61
- { x: 350, y: 224 },
62
- { x: 442, y: 224 },
54
+ { x: 76, y: 84 },
55
+ { x: 154, y: 44 },
56
+ { x: 260, y: 18 },
57
+ { x: 366, y: 44 },
58
+ { x: 444, y: 84 },
63
59
  ];
64
60
 
65
61
  function SvgText(props: SVGProps<SVGTextElement>) {
@@ -358,21 +354,24 @@ function DesktopGauge({
358
354
  currentLabel,
359
355
  minWidth,
360
356
  maxWidth,
357
+ compact,
361
358
  }: Required<SpeedometerGaugeProps>) {
362
359
  const gaugeWidth = Math.min(Math.max(width - 2, minWidth), maxWidth);
360
+ const gaugeHeight = compact ? 9 : 12;
361
+ const viewBoxHeight = compact ? DESKTOP_COMPACT_VIEWBOX_HEIGHT : DESKTOP_VIEWBOX_HEIGHT;
363
362
  const needleAngle = valueToDegrees(value, min, max);
364
363
  const needleEnd = polarToCartesian(DESKTOP_CENTER_X, DESKTOP_CENTER_Y, DESKTOP_NEEDLE_RADIUS, needleAngle);
365
364
 
366
365
  return (
367
366
  <Box
368
367
  width={gaugeWidth}
369
- height={12}
370
- marginTop={1}
368
+ height={gaugeHeight}
369
+ marginTop={compact ? 0 : 1}
371
370
  overflow="hidden"
372
371
  style={{ alignSelf: "center", maxWidth: 420 }}
373
372
  >
374
373
  <svg
375
- viewBox={`0 0 ${DESKTOP_VIEWBOX_WIDTH} ${DESKTOP_VIEWBOX_HEIGHT}`}
374
+ viewBox={`0 0 ${DESKTOP_VIEWBOX_WIDTH} ${viewBoxHeight}`}
376
375
  width="100%"
377
376
  height="100%"
378
377
  role="img"
@@ -418,19 +417,12 @@ function DesktopGauge({
418
417
  </SvgText>
419
418
  );
420
419
  })}
421
- {[min, min + (max - min) * 0.25, min + (max - min) * 0.5, min + (max - min) * 0.75, max].map((tick, index) => {
420
+ {[min, min + (max - min) * 0.25, min + (max - min) * 0.5, min + (max - min) * 0.75, max].map((tick) => {
422
421
  const angle = valueToDegrees(tick, min, max);
423
422
  const outer = polarToCartesian(DESKTOP_CENTER_X, DESKTOP_CENTER_Y, DESKTOP_ARC_RADIUS + 16, angle);
424
423
  const inner = polarToCartesian(DESKTOP_CENTER_X, DESKTOP_CENTER_Y, DESKTOP_ARC_RADIUS - 10, angle);
425
- const label = DESKTOP_TICK_LABEL_POSITIONS[index]
426
- ?? polarToCartesian(DESKTOP_CENTER_X, DESKTOP_CENTER_Y, DESKTOP_ARC_RADIUS + 34, angle);
427
424
  return (
428
- <g key={tick}>
429
- <line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} stroke={colors.textDim} strokeWidth="2" />
430
- <SvgText x={label.x} y={label.y} fill={colors.textDim} textAnchor="middle" fontFamily="inherit" fontSize="13">
431
- {formatGaugeValue(tick)}
432
- </SvgText>
433
- </g>
425
+ <line key={tick} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} stroke={colors.textDim} strokeWidth="2" />
434
426
  );
435
427
  })}
436
428
  <line
@@ -443,7 +435,16 @@ function DesktopGauge({
443
435
  strokeLinecap="round"
444
436
  />
445
437
  <circle cx={DESKTOP_CENTER_X} cy={DESKTOP_CENTER_Y} r="27" fill={colors.bg} />
446
- <SvgText x={DESKTOP_CENTER_X} y={DESKTOP_CENTER_Y + 18} fill={colors.textBright} textAnchor="middle" fontFamily="inherit" fontSize="31" fontWeight="800">
438
+ <SvgText
439
+ x={DESKTOP_CENTER_X}
440
+ y={DESKTOP_CENTER_Y + 2}
441
+ fill={colors.textBright}
442
+ textAnchor="middle"
443
+ dominantBaseline="central"
444
+ fontFamily="inherit"
445
+ fontSize="31"
446
+ fontWeight="800"
447
+ >
447
448
  {formatGaugeValue(value)}
448
449
  </SvgText>
449
450
  </svg>
@@ -540,7 +541,9 @@ export function SpeedometerGauge({
540
541
  currentLabel = "Current reading",
541
542
  minWidth = DEFAULT_MIN_WIDTH,
542
543
  maxWidth = DEFAULT_MAX_WIDTH,
544
+ compact = false,
543
545
  }: SpeedometerGaugeProps) {
546
+ useThemeColors();
544
547
  const props = {
545
548
  value,
546
549
  valueLabel,
@@ -551,6 +554,7 @@ export function SpeedometerGauge({
551
554
  currentLabel,
552
555
  minWidth,
553
556
  maxWidth,
557
+ compact,
554
558
  };
555
559
  return useUiHost().kind === "desktop-web"
556
560
  ? <DesktopGauge {...props} />
@@ -8,6 +8,22 @@ import {
8
8
  } from "react";
9
9
  import { Box, type ScrollBoxRenderable } from "../ui";
10
10
 
11
+ function listenToScrollBarChange(
12
+ scrollBar: ScrollBoxRenderable["verticalScrollBar"],
13
+ handler: () => void,
14
+ ): (() => void) | null {
15
+ if (
16
+ !scrollBar
17
+ || typeof scrollBar.on !== "function"
18
+ || typeof scrollBar.off !== "function"
19
+ ) {
20
+ return null;
21
+ }
22
+
23
+ scrollBar.on("change", handler);
24
+ return () => scrollBar.off?.("change", handler);
25
+ }
26
+
11
27
  export interface TableViewKeyEvent {
12
28
  name?: string;
13
29
  ctrl?: boolean;
@@ -129,6 +145,66 @@ export function useTableBodyScrollActivity({
129
145
  }, [afterScroll, onBodyScrollActivity, syncHeaderScroll]);
130
146
  }
131
147
 
148
+ export function useScrollBoxScrollActivity({
149
+ scrollRef,
150
+ onVerticalScroll,
151
+ onHorizontalScroll,
152
+ }: {
153
+ scrollRef: RefObject<ScrollBoxRenderable | null>;
154
+ onVerticalScroll?: () => void;
155
+ onHorizontalScroll?: () => void;
156
+ }) {
157
+ const onVerticalScrollRef = useRef(onVerticalScroll);
158
+ const onHorizontalScrollRef = useRef(onHorizontalScroll);
159
+ const verticalScrollScheduledRef = useRef(false);
160
+ const horizontalScrollScheduledRef = useRef(false);
161
+
162
+ useEffect(() => {
163
+ onVerticalScrollRef.current = onVerticalScroll;
164
+ onHorizontalScrollRef.current = onHorizontalScroll;
165
+ }, [onHorizontalScroll, onVerticalScroll]);
166
+
167
+ useEffect(() => {
168
+ const scrollBox = scrollRef.current;
169
+ if (!scrollBox) return;
170
+ let active = true;
171
+
172
+ const handleVerticalChange = () => {
173
+ if (verticalScrollScheduledRef.current) return;
174
+ verticalScrollScheduledRef.current = true;
175
+ queueMicrotask(() => {
176
+ verticalScrollScheduledRef.current = false;
177
+ if (!active) return;
178
+ onVerticalScrollRef.current?.();
179
+ });
180
+ };
181
+ const handleHorizontalChange = () => {
182
+ if (horizontalScrollScheduledRef.current) return;
183
+ horizontalScrollScheduledRef.current = true;
184
+ queueMicrotask(() => {
185
+ horizontalScrollScheduledRef.current = false;
186
+ if (!active) return;
187
+ onHorizontalScrollRef.current?.();
188
+ });
189
+ };
190
+
191
+ const removeVerticalListener = listenToScrollBarChange(
192
+ scrollBox.verticalScrollBar,
193
+ handleVerticalChange,
194
+ );
195
+ const removeHorizontalListener = listenToScrollBarChange(
196
+ scrollBox.horizontalScrollBar,
197
+ handleHorizontalChange,
198
+ );
199
+
200
+ return () => {
201
+ active = false;
202
+ removeVerticalListener?.();
203
+ removeHorizontalListener?.();
204
+ };
205
+ }, [scrollRef]);
206
+ }
207
+
132
208
  export function useResetTableScroll({
133
209
  headerScrollRef,
134
210
  scrollRef,
@@ -140,6 +216,12 @@ export function useResetTableScroll({
140
216
  resetScrollKey?: unknown;
141
217
  afterReset?: () => void;
142
218
  }) {
219
+ const afterResetRef = useRef(afterReset);
220
+
221
+ useEffect(() => {
222
+ afterResetRef.current = afterReset;
223
+ }, [afterReset]);
224
+
143
225
  useEffect(() => {
144
226
  if (resetScrollKey === undefined) return;
145
227
  const body = scrollRef.current;
@@ -151,8 +233,8 @@ export function useResetTableScroll({
151
233
  if (header) {
152
234
  header.scrollLeft = 0;
153
235
  }
154
- if (afterReset) {
155
- queueMicrotask(afterReset);
236
+ if (afterResetRef.current) {
237
+ queueMicrotask(afterResetRef.current);
156
238
  }
157
- }, [afterReset, headerScrollRef, resetScrollKey, scrollRef]);
239
+ }, [headerScrollRef, resetScrollKey, scrollRef]);
158
240
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  useCallback,
3
3
  useEffect,
4
+ useRef,
4
5
  type ReactNode,
5
6
  type RefObject,
6
7
  } from "react";
@@ -93,6 +94,7 @@ export function TickerListTableView({
93
94
  const selectedIndex = tableProps.tickers.findIndex(
94
95
  (ticker) => ticker.metadata.ticker === tableProps.cursorSymbol,
95
96
  );
97
+ const lastScrollCursorSymbolRef = useRef<string | null | undefined>(undefined);
96
98
 
97
99
  const emitVisibleRange = useCallback(() => {
98
100
  if (!onVisibleRangeChange) return;
@@ -185,7 +187,19 @@ export function TickerListTableView({
185
187
 
186
188
  useEffect(() => {
187
189
  const scrollBox = effectiveScrollRef.current;
188
- if (!scrollBox?.viewport || selectedIndex < 0) return;
190
+ if (!scrollBox?.viewport || selectedIndex < 0) {
191
+ queueMicrotask(emitVisibleRange);
192
+ return;
193
+ }
194
+
195
+ const shouldScrollToCursor =
196
+ lastScrollCursorSymbolRef.current !== tableProps.cursorSymbol;
197
+ lastScrollCursorSymbolRef.current = tableProps.cursorSymbol;
198
+
199
+ if (!shouldScrollToCursor) {
200
+ queueMicrotask(emitVisibleRange);
201
+ return;
202
+ }
189
203
 
190
204
  const viewportHeight = Math.max(scrollBox.viewport.height, 1);
191
205
  if (selectedIndex < scrollBox.scrollTop) {
@@ -194,7 +208,7 @@ export function TickerListTableView({
194
208
  scrollBox.scrollTo(selectedIndex - viewportHeight + 1);
195
209
  }
196
210
  queueMicrotask(emitVisibleRange);
197
- }, [effectiveScrollRef, emitVisibleRange, selectedIndex]);
211
+ }, [effectiveScrollRef, emitVisibleRange, selectedIndex, tableProps.cursorSymbol]);
198
212
 
199
213
  return (
200
214
  <TableViewFrame