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
@@ -11,11 +11,7 @@ import {
11
11
  zoomCompositeViewport,
12
12
  type CompositeViewportRange,
13
13
  } from "./interactions";
14
- import {
15
- buildCompositeTimeScale,
16
- projectCompositeTimestamp,
17
- } from "./time-scale";
18
-
14
+ import { buildCompositeTimeScale, projectCompositeTimestamp } from "./time-scale";
19
15
  const DAY_MS = 24 * 60 * 60 * 1000;
20
16
 
21
17
  function point(day: number): TimeSeriesPoint {
@@ -69,6 +65,18 @@ function intradayMarketSeries(): ResolvedSeries {
69
65
  };
70
66
  }
71
67
 
68
+ function marketSlotSpan(
69
+ data: ResolvedSeries,
70
+ bounds: CompositeViewportRange,
71
+ view: CompositeViewportRange,
72
+ ): number {
73
+ const scale = buildCompositeTimeScale([data], bounds.start.getTime(), bounds.end.getTime());
74
+ const start = projectCompositeTimestamp(scale, view.start.getTime())!.ratio;
75
+ const end = projectCompositeTimestamp(scale, view.end.getTime())!.ratio;
76
+ const positions = scale.kind === "market" ? scale.endPosition - scale.startPosition : 1;
77
+ return (end - start) * positions;
78
+ }
79
+
72
80
  describe("composite chart interactions", () => {
73
81
  test("zooms around the right edge and clamps zoom-out to loaded bounds", () => {
74
82
  const bounds = viewport(1, 11);
@@ -91,27 +99,21 @@ describe("composite chart interactions", () => {
91
99
  expect(clampedNewer).toEqual(viewport(7, 11));
92
100
  });
93
101
 
94
- test("keeps the same market-slot span while panning across a closed session", () => {
102
+ test("never changes the visible slot count while panning across a closed session", () => {
95
103
  const data = intradayMarketSeries();
96
104
  const bounds = resolveCompositeNavigationBounds([data])!;
97
- const visible = {
105
+ let visible: CompositeViewportRange = {
98
106
  start: new Date("2025-01-03T13:30:00.000Z"),
99
107
  end: new Date("2025-01-03T17:00:00.000Z"),
100
108
  };
101
- const panned = panCompositeViewport(visible, bounds, 0.5, [data]);
102
- const scale = buildCompositeTimeScale(
103
- [data],
104
- bounds.start.getTime(),
105
- bounds.end.getTime(),
106
- );
107
- const projectedSpan = (window: CompositeViewportRange) => (
108
- projectCompositeTimestamp(scale, window.end.getTime())!.ratio
109
- - projectCompositeTimestamp(scale, window.start.getTime())!.ratio
110
- );
109
+ const slots = marketSlotSpan(data, bounds, visible);
111
110
 
112
- expect(panned.end.getTime() - panned.start.getTime())
113
- .not.toBe(visible.end.getTime() - visible.start.getTime());
114
- expect(Math.abs(projectedSpan(panned) - projectedSpan(visible))).toBeLessThan(1e-12);
111
+ // A wall-clock shift changes how many market slots stay in view, which
112
+ // renders as the plot squeezing shut instead of scrolling sideways.
113
+ for (let step = 0; step < 4; step += 1) {
114
+ visible = panCompositeViewport(visible, bounds, 0.5, [data]);
115
+ expect(marketSlotSpan(data, bounds, visible)).toBeCloseTo(slots, 6);
116
+ }
115
117
  });
116
118
 
117
119
  test("uses representative cadence instead of a stray fine gap as the zoom floor", () => {
@@ -126,11 +128,49 @@ describe("composite chart interactions", () => {
126
128
  expect(resolveCompositeNavigationBounds([data], viewport(5, 9))).toEqual(viewport(1, 9));
127
129
  });
128
130
 
129
- test("clamps overlapping requested bounds to real data but preserves disjoint requests", () => {
131
+ test("reserves an older unloaded window without extending into the future", () => {
132
+ const data = series([1, 2, 3, 4, 5, 6, 7, 8, 9]);
133
+ const requested = viewport(5, 9);
134
+ const bounds = resolveCompositeNavigationBounds(
135
+ [data],
136
+ requested,
137
+ { historicalPaddingRatio: 1 },
138
+ );
139
+
140
+ expect(bounds).toEqual(viewport(-3, 9));
141
+ expect(panCompositeViewport(requested, bounds!, 1, [data], true))
142
+ .toEqual(viewport(1, 5));
143
+ expect(panCompositeViewport(requested, bounds!, -10, [data], true))
144
+ .toEqual(viewport(5, 9));
145
+ });
146
+
147
+ test("enters unloaded history from a full market-session viewport without changing span", () => {
148
+ const data = intradayMarketSeries();
149
+ const requested = {
150
+ start: data.points[0]!.date,
151
+ end: data.points.at(-1)!.date,
152
+ };
153
+ const bounds = resolveCompositeNavigationBounds(
154
+ [data],
155
+ requested,
156
+ { historicalPaddingRatio: 1 },
157
+ )!;
158
+
159
+ const older = panCompositeViewport(requested, bounds, 1, [data], true);
160
+ expect(older).not.toEqual(requested);
161
+ expect(older.end.getTime()).toBe(requested.start.getTime());
162
+ expect(marketSlotSpan(data, bounds, older))
163
+ .toBeCloseTo(marketSlotSpan(data, bounds, requested), 6);
164
+ expect(panCompositeViewport(older, bounds, -1, [data], true)).toEqual(requested);
165
+ });
166
+
167
+ test("clamps overlapping requested bounds to real data and keeps disjoint ones reachable", () => {
130
168
  const data = series([1, 2, 3, 4, 5, 6, 7, 8, 9]);
131
169
 
132
170
  expect(resolveCompositeNavigationBounds([data], viewport(5, 12))).toEqual(viewport(1, 9));
133
- expect(resolveCompositeNavigationBounds([data], viewport(20, 30))).toEqual(viewport(20, 30));
171
+ // Bounds equal to an observation-free window would strand pan and zoom
172
+ // inside it, so a disjoint request stays unioned with the loaded data.
173
+ expect(resolveCompositeNavigationBounds([data], viewport(20, 30))).toEqual(viewport(1, 30));
134
174
  });
135
175
 
136
176
  test("requires distinct renderable observations inside a viewport", () => {
@@ -187,6 +227,22 @@ describe("composite chart interactions", () => {
187
227
  expect(resolveCompositeChartInteraction({ name: "-", targetEditable: true })).toBeNull();
188
228
  });
189
229
 
230
+ test("arms chart tools from either shifted key encoding", () => {
231
+ // Kitty keyboard protocol reports the modifier; legacy terminals only send
232
+ // the uppercase letter.
233
+ expect(resolveCompositeChartInteraction({ name: "m", shift: true })).toBe("arm-measure");
234
+ expect(resolveCompositeChartInteraction({ name: "M", sequence: "M" })).toBe("arm-measure");
235
+ expect(resolveCompositeChartInteraction({ name: "z", shift: true })).toBe("arm-zoom");
236
+ expect(resolveCompositeChartInteraction({ name: "Z", sequence: "Z" })).toBe("arm-zoom");
237
+ expect(resolveCompositeChartInteraction({ name: "d", shift: true })).toBe("arm-line");
238
+ expect(resolveCompositeChartInteraction({ name: "P", sequence: "P" })).toBe("arm-pencil");
239
+ expect(resolveCompositeChartInteraction({ name: "backspace" })).toBe("delete-drawing");
240
+ expect(resolveCompositeChartInteraction({ name: "c" })).toBe("cycle-colour");
241
+ // Unshifted m and z stay with the pane that already owns them.
242
+ expect(resolveCompositeChartInteraction({ name: "m" })).toBeNull();
243
+ expect(resolveCompositeChartInteraction({ name: "z" })).toBeNull();
244
+ });
245
+
190
246
  test("maps wheel directions to bounded horizontal pan increments", () => {
191
247
  expect(resolveCompositeWheelPanRatio("up", 1)).toBe(0.005);
192
248
  expect(resolveCompositeWheelPanRatio("left", 100)).toBe(0.04);
@@ -11,11 +11,17 @@ export interface CompositeViewportRange {
11
11
  }
12
12
 
13
13
  export type CompositeChartInteraction =
14
+ | "arm-line"
15
+ | "arm-measure"
16
+ | "arm-pencil"
17
+ | "arm-zoom"
18
+ | "cycle-colour"
14
19
  | "clear-cursor"
15
20
  | "cursor-left"
16
21
  | "cursor-right"
17
22
  | "pan-left"
18
23
  | "pan-right"
24
+ | "delete-drawing"
19
25
  | "reset"
20
26
  | "zoom-in"
21
27
  | "zoom-out";
@@ -100,6 +106,7 @@ export function compositeViewportHasObservations(
100
106
  export function resolveCompositeNavigationBounds(
101
107
  series: ResolvedSeries[],
102
108
  requestedViewport?: CompositeViewportRange | null,
109
+ options: { historicalPaddingRatio?: number } = {},
103
110
  ): CompositeViewportRange | null {
104
111
  const requested = normalizeViewport(requestedViewport);
105
112
  const timestamps = pointTimestamps(series);
@@ -113,9 +120,22 @@ export function resolveCompositeNavigationBounds(
113
120
  const overlapsData = requested.end.getTime() >= data.start.getTime()
114
121
  && requested.start.getTime() <= data.end.getTime();
115
122
  // Loaded series can include a buffer outside the authored viewport. Use the
116
- // complete real-data extent when the two ranges overlap, but do not clamp a
117
- // newly authored, disjoint request to stale observations.
118
- return overlapsData ? data : requested;
123
+ // complete real-data extent when the two ranges overlap. A disjoint request
124
+ // keeps its own window, unioned with the data so navigation can always get
125
+ // back: bounds equal to an observation-free window strand pan and zoom.
126
+ if (!overlapsData) {
127
+ return {
128
+ start: new Date(Math.min(data.start.getTime(), requested.start.getTime())),
129
+ end: new Date(Math.max(data.end.getTime(), requested.end.getTime())),
130
+ };
131
+ }
132
+ const paddingRatio = Math.max(options.historicalPaddingRatio ?? 0, 0);
133
+ if (paddingRatio === 0) return data;
134
+ const requestedSpan = Math.max(requested.end.getTime() - requested.start.getTime(), 1);
135
+ return {
136
+ start: new Date(Math.min(data.start.getTime(), requested.start.getTime()) - requestedSpan * paddingRatio),
137
+ end: data.end,
138
+ };
119
139
  }
120
140
 
121
141
  export function resolveCompositeMinimumSpanMs(
@@ -305,34 +325,32 @@ export function panCompositeViewport(
305
325
  bounds: CompositeViewportRange,
306
326
  shiftRatio: number,
307
327
  series?: ResolvedSeries[],
328
+ allowEmpty = false,
308
329
  ): CompositeViewportRange {
309
330
  const current = clampCompositeViewport(viewport, bounds);
310
331
  if (!Number.isFinite(shiftRatio) || shiftRatio === 0) return current;
311
332
  const marketProjection = marketViewportProjection(current, bounds, series);
333
+ // Market scales collapse overnight and weekend gaps, so a wall-clock shift
334
+ // changes how many slots stay visible and the plot accordions instead of
335
+ // translating. Shift in slot space so the visible span is preserved.
312
336
  const candidate = marketProjection
313
337
  ? (() => {
314
- const {
315
- scale,
316
- startRatio,
317
- endRatio,
318
- } = marketProjection;
319
- const span = endRatio - startRatio;
320
- const nextStart = clamp(
321
- startRatio - span * shiftRatio,
322
- 0,
323
- 1 - span,
338
+ const { scale, startRatio, endRatio } = marketProjection;
339
+ const marketSpan = endRatio - startRatio;
340
+ const nextStart = clamp(startRatio - marketSpan * shiftRatio, 0, 1 - marketSpan);
341
+ return clampCompositeViewport(
342
+ viewportFromMarketRatios(scale, nextStart, nextStart + marketSpan),
343
+ bounds,
324
344
  );
325
- return viewportFromMarketRatios(scale, nextStart, nextStart + span);
326
345
  })()
327
346
  : (() => {
328
- const span = Math.max(current.end.getTime() - current.start.getTime(), 1);
329
- const shift = span * shiftRatio;
347
+ const shift = Math.max(current.end.getTime() - current.start.getTime(), 1) * shiftRatio;
330
348
  return clampCompositeViewport({
331
349
  start: new Date(current.start.getTime() - shift),
332
350
  end: new Date(current.end.getTime() - shift),
333
351
  }, bounds);
334
352
  })();
335
- if (!series) return candidate;
353
+ if (!series || allowEmpty) return candidate;
336
354
 
337
355
  const timestamps = pointTimestamps(series)
338
356
  .filter((timestamp) => timestamp >= bounds.start.getTime() && timestamp <= bounds.end.getTime());
@@ -403,6 +421,16 @@ export function resolveCompositeChartInteraction(event: {
403
421
  const key = (name || sequence).toLowerCase();
404
422
  if (key === "left") return event.shift ? "pan-left" : "cursor-left";
405
423
  if (key === "right") return event.shift ? "pan-right" : "cursor-right";
424
+ // Terminals reserve shift-drag and option-drag for their own selection, so the
425
+ // pointer tools also need a keyboard path that always reaches the app. Without
426
+ // the kitty keyboard protocol a shifted letter arrives uppercase and unflagged.
427
+ const shifted = event.shift || name === name.toUpperCase() && name !== key;
428
+ if (shifted && key === "m") return "arm-measure";
429
+ if (shifted && key === "z") return "arm-zoom";
430
+ if (shifted && key === "d") return "arm-line";
431
+ if (shifted && key === "p") return "arm-pencil";
432
+ if (key === "backspace") return "delete-drawing";
433
+ if (key === "c" && !event.shift) return "cycle-colour";
406
434
  if (event.shift) return null;
407
435
  if (key === "a") return "pan-left";
408
436
  if (key === "d") return "pan-right";
@@ -2,6 +2,7 @@ import type { NativeChartBitmap } from "../native/chart-rasterizer";
2
2
  import {
3
3
  drawCircle,
4
4
  drawLine,
5
+ fillOpaque,
5
6
  fillRect,
6
7
  parseHex,
7
8
  type RgbaColor,
@@ -250,7 +251,12 @@ function drawOhlc(
250
251
  const candleWidth = resolveCompositeOhlcWidth(series.points, width);
251
252
  for (const projected of series.points) {
252
253
  const source = projected.point;
253
- const x = pixelPoint(projected, width, height).x;
254
+ const halfWidth = Math.min(candleWidth / 2, Math.max(width - 1, 0) / 2);
255
+ const x = clamp(
256
+ pixelPoint(projected, width, height).x,
257
+ halfWidth,
258
+ Math.max(width - 1 - halfWidth, halfWidth),
259
+ );
254
260
  const close = source.close ?? projected.value;
255
261
  const open = source.open ?? close;
256
262
  const high = source.high ?? Math.max(open, close);
@@ -292,7 +298,7 @@ export function renderCompositePanelBitmap(
292
298
  const background = parseHex(options.colors.background);
293
299
  const grid = parseHex(options.colors.grid);
294
300
  const negative = parseHex(options.colors.negative);
295
- fillRect(data, width, height, 0, 0, width - 1, height - 1, background, 1);
301
+ fillOpaque(data, background);
296
302
 
297
303
  for (let index = 1; index <= 3; index += 1) {
298
304
  const y = (height - 1) * (index / 4);
@@ -7,7 +7,7 @@ import {
7
7
  resolveCompositeOhlcWidth,
8
8
  } from "./rasterizer";
9
9
  import { buildCompositeColumnLayout } from "./column-layout";
10
- import { buildCompositeChartScene } from "./scene";
10
+ import { buildCompositeChartScene, projectCompositeValue } from "./scene";
11
11
  import {
12
12
  renderCompositePanelText,
13
13
  resolveCompositeTextOhlcWidth,
@@ -678,6 +678,66 @@ describe("composite chart renderers", () => {
678
678
  expect(zoomedText).toContain("█████");
679
679
  });
680
680
 
681
+ test("keeps the first and last candle bodies fully inside the bitmap", () => {
682
+ const candles = series("price", "candles", [], "left", "#00ff66");
683
+ candles.points = ["2025-01-01", "2025-01-02", "2025-01-03"].map((date) => ({
684
+ ...point(date, 10),
685
+ open: 8,
686
+ high: 13,
687
+ low: 7,
688
+ close: 10,
689
+ }));
690
+ const scene = buildCompositeChartScene(
691
+ [candles],
692
+ [{ id: "main" }],
693
+ {
694
+ width: 81,
695
+ height: 9,
696
+ viewport: {
697
+ start: new Date("2025-01-01T00:00:00.000Z"),
698
+ end: new Date("2025-01-03T00:00:00.000Z"),
699
+ },
700
+ },
701
+ )!;
702
+ const panel = scene.panels[0]!;
703
+ const bitmap = renderCompositePanelBitmap(panel, {
704
+ pixelWidth: 101,
705
+ pixelHeight: 51,
706
+ colors: {
707
+ background: "#000000",
708
+ grid: "#202020",
709
+ crosshair: "#ffffff",
710
+ text: "#eeeeee",
711
+ textDim: "#999999",
712
+ negative: "#ff0000",
713
+ },
714
+ });
715
+ const domain = panel.axes.left!;
716
+ const bodyYRatio = (
717
+ projectCompositeValue(8, domain)!
718
+ + projectCompositeValue(10, domain)!
719
+ ) / 2;
720
+ const bodyY = Math.round(bodyYRatio * (bitmap.height - 1));
721
+ const coloredColumns = Array.from({ length: bitmap.width }, (_, x) => x)
722
+ .filter((x) => {
723
+ const offset = (bodyY * bitmap.width + x) * 4;
724
+ return bitmap.pixels[offset]! < 20
725
+ && bitmap.pixels[offset + 1]! > 220
726
+ && bitmap.pixels[offset + 2]! > 70;
727
+ });
728
+ const runs: number[][] = [];
729
+ for (const x of coloredColumns) {
730
+ const last = runs.at(-1);
731
+ if (last?.at(-1) === x - 1) last.push(x);
732
+ else runs.push([x]);
733
+ }
734
+
735
+ expect(runs).toHaveLength(3);
736
+ expect(runs.map((run) => run.length)).toEqual([13, 13, 13]);
737
+ expect(runs[0]?.[0]).toBe(0);
738
+ expect(runs.at(-1)?.at(-1)).toBe(bitmap.width - 1);
739
+ });
740
+
681
741
  test("marks a standalone line observation without extending it through time", () => {
682
742
  const scene = buildCompositeChartScene(
683
743
  [series("estimate", "line", [42], "left")],
@@ -431,10 +431,15 @@ export function buildCompositeChartScene(
431
431
  startTime,
432
432
  endTime,
433
433
  );
434
- const usableSeries = viewport
434
+ const scopedSeries = viewport
435
435
  ? dataSeries.flatMap((entry) => scopeSeriesToViewport(entry, startTime, endTime, timeScale) ?? [])
436
436
  : dataSeries;
437
- if (usableSeries.length === 0) return null;
437
+ // A range without observations still has a chart: keep the panels, axes and
438
+ // grid and simply draw no points, instead of blanking the whole surface.
439
+ const emptyRange = scopedSeries.length === 0;
440
+ const usableSeries = emptyRange
441
+ ? dataSeries.map((entry) => ({ ...entry, points: [] }))
442
+ : scopedSeries;
438
443
  const visibleTimes = uniqueTimes.filter((time) => time >= startTime && time <= endTime);
439
444
  const marketTimes = timeScale.kind === "market"
440
445
  ? timeScale.anchors
@@ -463,8 +468,13 @@ export function buildCompositeChartScene(
463
468
  const panelSeries = usableSeries.filter((entry) => entry.panelId === panel.id);
464
469
  if (panelSeries.length === 0) return [];
465
470
  const scale = panel.scale ?? "linear";
466
- const left = buildAxisDomain("left", panelSeries, scale);
467
- const right = buildAxisDomain("right", panelSeries, scale);
471
+ // An empty range has no in-view values, so scale its axes to the loaded
472
+ // history rather than the meaningless 0..1 fallback.
473
+ const domainSeries = emptyRange
474
+ ? dataSeries.filter((entry) => entry.panelId === panel.id)
475
+ : panelSeries;
476
+ const left = buildAxisDomain("left", domainSeries, scale);
477
+ const right = buildAxisDomain("right", domainSeries, scale);
468
478
  const axes: Partial<Record<CompositeAxisSide, CompositeAxisDomain>> = { left, right };
469
479
  return [{
470
480
  id: panel.id,