gloomberb 0.6.0 → 0.6.1
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.
- package/package.json +2 -1
- package/src/components/chart/comparison-stock-chart.tsx +14 -50
- package/src/components/chart/native/kitty.test.ts +23 -0
- package/src/components/chart/native/surface-visibility.ts +48 -0
- package/src/components/chart/stock-chart.tsx +14 -50
- package/src/components/index.ts +1 -1
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-footer.tsx +15 -0
- package/src/components/layout/pane-keyboard-scroll.test.tsx +226 -0
- package/src/components/ui/remote-image.tsx +15 -40
- package/src/plugins/builtin/ticker-detail/pane.tsx +36 -28
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +66 -0
- package/src/plugins/builtin/ticker-detail.test.tsx +102 -0
- package/src/plugins/ibkr/trade-tab.test.tsx +53 -1
- package/src/plugins/ibkr/trade-tab.tsx +13 -11
- package/src/react/input.ts +10 -2
- package/src/renderers/electrobun/view/input-host.tsx +58 -11
- package/src/renderers/electrobun/view/ui-host.tsx +22 -3
- package/src/renderers/opentui/host.tsx +6 -0
- package/src/renderers/opentui/image-loader.test.ts +25 -0
- package/src/renderers/opentui/image-loader.ts +105 -0
- package/src/renderers/opentui/image-surface.tsx +266 -0
- package/src/renderers/opentui/input-host.tsx +45 -6
- package/src/renderers/opentui/ui-host.tsx +2 -1
- package/src/state/app-context.tsx +4 -0
- package/src/state/pane-scroll-registry.tsx +254 -0
- package/src/ui/host.tsx +3 -0
- package/src/ui/index.tsx +9 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { useImperativeHandle, useLayoutEffect, useRef, type ForwardedRef, type RefObject } from "react";
|
|
2
|
+
import { useShortcut } from "../react/input";
|
|
3
|
+
import { useNativeRenderer, type ScrollBoxRenderable } from "../ui/host";
|
|
4
|
+
import { useAppSelector, useOptionalPaneInstanceId } from "./app-context";
|
|
5
|
+
|
|
6
|
+
type ScrollDirection = "up" | "down";
|
|
7
|
+
|
|
8
|
+
interface PaneScrollAction {
|
|
9
|
+
direction: ScrollDirection;
|
|
10
|
+
resolveTarget(scrollBox: ScrollBoxRenderable): number | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PaneScrollEntry {
|
|
14
|
+
order: number;
|
|
15
|
+
ref: RefObject<ScrollBoxRenderable | null>;
|
|
16
|
+
onScrollActivity?: (event: { scroll: { direction: ScrollDirection; delta: number } }) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PaneScrollKeyEvent {
|
|
20
|
+
name?: string;
|
|
21
|
+
ctrl?: boolean;
|
|
22
|
+
meta?: boolean;
|
|
23
|
+
alt?: boolean;
|
|
24
|
+
option?: boolean;
|
|
25
|
+
defaultPrevented?: boolean;
|
|
26
|
+
propagationStopped?: boolean;
|
|
27
|
+
preventDefault?: () => void;
|
|
28
|
+
stopPropagation?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const paneScrollBoxes = new Map<string, Map<number, PaneScrollEntry>>();
|
|
32
|
+
let nextScrollEntryId = 1;
|
|
33
|
+
let nextScrollEntryOrder = 1;
|
|
34
|
+
const MIN_ARROW_SCROLL_LINES = 3;
|
|
35
|
+
|
|
36
|
+
function hasHiddenAncestor(scrollBox: ScrollBoxRenderable): boolean {
|
|
37
|
+
let node: unknown = scrollBox;
|
|
38
|
+
const seen = new Set<unknown>();
|
|
39
|
+
|
|
40
|
+
while (node && typeof node === "object" && !seen.has(node)) {
|
|
41
|
+
seen.add(node);
|
|
42
|
+
const renderable = node as { visible?: unknown; parent?: unknown };
|
|
43
|
+
if (renderable.visible === false) return true;
|
|
44
|
+
node = renderable.parent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasCollapsedBounds(scrollBox: ScrollBoxRenderable): boolean {
|
|
51
|
+
const bounds = scrollBox.getBoundingClientRect?.();
|
|
52
|
+
return bounds != null && (bounds.width <= 0 || bounds.height <= 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isVisibleScrollBox(scrollBox: ScrollBoxRenderable): boolean {
|
|
56
|
+
return !hasHiddenAncestor(scrollBox) && !hasCollapsedBounds(scrollBox);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clampScrollTop(scrollBox: ScrollBoxRenderable, target: number): number | null {
|
|
60
|
+
const viewportHeight = Math.max(0, scrollBox.viewport?.height ?? 0);
|
|
61
|
+
if (viewportHeight <= 0) return null;
|
|
62
|
+
const maxScrollTop = Math.max(0, scrollBox.scrollHeight - viewportHeight);
|
|
63
|
+
if (maxScrollTop <= 0) return null;
|
|
64
|
+
return Math.max(0, Math.min(maxScrollTop, target));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scrollTargetByDelta(scrollBox: ScrollBoxRenderable, delta: number): number | null {
|
|
68
|
+
const target = clampScrollTop(scrollBox, scrollBox.scrollTop + delta);
|
|
69
|
+
return target == null || target === scrollBox.scrollTop ? null : target;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function arrowScrollLines(scrollBox: ScrollBoxRenderable): number {
|
|
73
|
+
return Math.max(MIN_ARROW_SCROLL_LINES, Math.floor((scrollBox.viewport?.height ?? 0) / 4));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveKeyScrollAction(event: PaneScrollKeyEvent): PaneScrollAction | null {
|
|
77
|
+
if (event.ctrl || event.meta || event.alt || event.option) return null;
|
|
78
|
+
|
|
79
|
+
switch (event.name) {
|
|
80
|
+
case "down":
|
|
81
|
+
return {
|
|
82
|
+
direction: "down",
|
|
83
|
+
resolveTarget: (scrollBox) => scrollTargetByDelta(scrollBox, arrowScrollLines(scrollBox)),
|
|
84
|
+
};
|
|
85
|
+
case "up":
|
|
86
|
+
return {
|
|
87
|
+
direction: "up",
|
|
88
|
+
resolveTarget: (scrollBox) => scrollTargetByDelta(scrollBox, -arrowScrollLines(scrollBox)),
|
|
89
|
+
};
|
|
90
|
+
case "pagedown":
|
|
91
|
+
return {
|
|
92
|
+
direction: "down",
|
|
93
|
+
resolveTarget: (scrollBox) => scrollTargetByDelta(
|
|
94
|
+
scrollBox,
|
|
95
|
+
Math.max(1, (scrollBox.viewport?.height ?? 1) - 1),
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
case "pageup":
|
|
99
|
+
return {
|
|
100
|
+
direction: "up",
|
|
101
|
+
resolveTarget: (scrollBox) => scrollTargetByDelta(
|
|
102
|
+
scrollBox,
|
|
103
|
+
-Math.max(1, (scrollBox.viewport?.height ?? 1) - 1),
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
case "home":
|
|
107
|
+
return {
|
|
108
|
+
direction: "up",
|
|
109
|
+
resolveTarget: (scrollBox) => (
|
|
110
|
+
scrollBox.scrollTop > 0 ? 0 : null
|
|
111
|
+
),
|
|
112
|
+
};
|
|
113
|
+
case "end":
|
|
114
|
+
return {
|
|
115
|
+
direction: "down",
|
|
116
|
+
resolveTarget: (scrollBox) => {
|
|
117
|
+
const target = clampScrollTop(scrollBox, Number.MAX_SAFE_INTEGER);
|
|
118
|
+
return target == null || target === scrollBox.scrollTop ? null : target;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
default:
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getEntryScore(entry: PaneScrollEntry, scrollBox: ScrollBoxRenderable): number {
|
|
127
|
+
const viewportHeight = scrollBox.viewport?.height ?? 0;
|
|
128
|
+
const scrollHeight = scrollBox.scrollHeight;
|
|
129
|
+
return viewportHeight * 100_000 + Math.min(scrollHeight, 99_999) + entry.order / 100_000;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function findScrollableEntry(paneId: string, action: PaneScrollAction): {
|
|
133
|
+
entry: PaneScrollEntry;
|
|
134
|
+
scrollBox: ScrollBoxRenderable;
|
|
135
|
+
target: number;
|
|
136
|
+
} | null {
|
|
137
|
+
const entries = paneScrollBoxes.get(paneId);
|
|
138
|
+
if (!entries) return null;
|
|
139
|
+
|
|
140
|
+
let best: { entry: PaneScrollEntry; scrollBox: ScrollBoxRenderable; target: number; score: number } | null = null;
|
|
141
|
+
for (const entry of entries.values()) {
|
|
142
|
+
const scrollBox = entry.ref.current;
|
|
143
|
+
if (!scrollBox) continue;
|
|
144
|
+
if (!isVisibleScrollBox(scrollBox)) continue;
|
|
145
|
+
|
|
146
|
+
const target = action.resolveTarget(scrollBox);
|
|
147
|
+
if (target == null) continue;
|
|
148
|
+
|
|
149
|
+
const score = getEntryScore(entry, scrollBox);
|
|
150
|
+
if (!best || score > best.score) {
|
|
151
|
+
best = { entry, scrollBox, target, score };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!best) return null;
|
|
156
|
+
return { entry: best.entry, scrollBox: best.scrollBox, target: best.target };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function scrollPaneByKey(paneId: string, event: PaneScrollKeyEvent): boolean {
|
|
160
|
+
const action = resolveKeyScrollAction(event);
|
|
161
|
+
if (!action) return false;
|
|
162
|
+
|
|
163
|
+
const match = findScrollableEntry(paneId, action);
|
|
164
|
+
if (!match) return false;
|
|
165
|
+
|
|
166
|
+
const previousTop = match.scrollBox.scrollTop;
|
|
167
|
+
match.scrollBox.scrollTo(match.target);
|
|
168
|
+
if (match.scrollBox.scrollTop !== match.target) {
|
|
169
|
+
match.scrollBox.scrollTop = match.target;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const delta = Math.abs(match.scrollBox.scrollTop - previousTop);
|
|
173
|
+
if (delta > 0) {
|
|
174
|
+
match.entry.onScrollActivity?.({ scroll: { direction: action.direction, delta } });
|
|
175
|
+
}
|
|
176
|
+
return delta > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function useForwardedScrollBoxRef<T>(
|
|
180
|
+
forwardedRef: ForwardedRef<T>,
|
|
181
|
+
): RefObject<T | null> {
|
|
182
|
+
const localRef = useRef<T | null>(null);
|
|
183
|
+
useImperativeHandle(forwardedRef, () => localRef.current as T);
|
|
184
|
+
return localRef;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function useRegisterPaneScrollBox(
|
|
188
|
+
ref: RefObject<ScrollBoxRenderable | null>,
|
|
189
|
+
options: {
|
|
190
|
+
enabled: boolean;
|
|
191
|
+
onScrollActivity?: PaneScrollEntry["onScrollActivity"];
|
|
192
|
+
},
|
|
193
|
+
): void {
|
|
194
|
+
const paneId = useOptionalPaneInstanceId();
|
|
195
|
+
const entryIdRef = useRef<number | null>(null);
|
|
196
|
+
|
|
197
|
+
useLayoutEffect(() => {
|
|
198
|
+
if (!paneId || !options.enabled) return;
|
|
199
|
+
|
|
200
|
+
const entryId = nextScrollEntryId++;
|
|
201
|
+
entryIdRef.current = entryId;
|
|
202
|
+
let entries = paneScrollBoxes.get(paneId);
|
|
203
|
+
if (!entries) {
|
|
204
|
+
entries = new Map();
|
|
205
|
+
paneScrollBoxes.set(paneId, entries);
|
|
206
|
+
}
|
|
207
|
+
entries.set(entryId, {
|
|
208
|
+
order: nextScrollEntryOrder++,
|
|
209
|
+
ref,
|
|
210
|
+
onScrollActivity: options.onScrollActivity,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
const currentEntries = paneScrollBoxes.get(paneId);
|
|
215
|
+
currentEntries?.delete(entryId);
|
|
216
|
+
if (currentEntries?.size === 0) {
|
|
217
|
+
paneScrollBoxes.delete(paneId);
|
|
218
|
+
}
|
|
219
|
+
if (entryIdRef.current === entryId) {
|
|
220
|
+
entryIdRef.current = null;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}, [paneId, options.enabled, options.onScrollActivity, ref]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function PaneKeyboardScrollController({
|
|
227
|
+
paneId,
|
|
228
|
+
focused,
|
|
229
|
+
}: {
|
|
230
|
+
paneId: string;
|
|
231
|
+
focused: boolean;
|
|
232
|
+
}) {
|
|
233
|
+
const nativeRenderer = useNativeRenderer();
|
|
234
|
+
const inputCaptured = useAppSelector((state) => state.inputCaptured);
|
|
235
|
+
const focusedRef = useRef(focused);
|
|
236
|
+
const inputCapturedRef = useRef(inputCaptured);
|
|
237
|
+
const paneIdRef = useRef(paneId);
|
|
238
|
+
|
|
239
|
+
focusedRef.current = focused;
|
|
240
|
+
inputCapturedRef.current = inputCaptured;
|
|
241
|
+
paneIdRef.current = paneId;
|
|
242
|
+
|
|
243
|
+
useShortcut((event) => {
|
|
244
|
+
if (!focusedRef.current || inputCapturedRef.current) return;
|
|
245
|
+
if (event.defaultPrevented || event.propagationStopped) return;
|
|
246
|
+
if (!scrollPaneByKey(paneIdRef.current, event)) return;
|
|
247
|
+
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
event.stopPropagation();
|
|
250
|
+
nativeRenderer.requestRender();
|
|
251
|
+
}, { phase: "after" });
|
|
252
|
+
|
|
253
|
+
return null;
|
|
254
|
+
}
|
package/src/ui/host.tsx
CHANGED
|
@@ -99,6 +99,9 @@ export interface ScrollBoxRenderable {
|
|
|
99
99
|
scrollLeft?: number;
|
|
100
100
|
scrollHeight: number;
|
|
101
101
|
viewport?: { width: number; height: number };
|
|
102
|
+
visible?: boolean;
|
|
103
|
+
parent?: unknown;
|
|
104
|
+
getBoundingClientRect?: () => { x: number; y: number; width: number; height: number };
|
|
102
105
|
horizontalScrollBar?: { visible: boolean };
|
|
103
106
|
verticalScrollBar?: { visible: boolean };
|
|
104
107
|
scrollTo(target: number | { x?: number; y?: number }, y?: number): void;
|
package/src/ui/index.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createElement, forwardRef } from "react";
|
|
2
|
+
import { useForwardedScrollBoxRef, useRegisterPaneScrollBox } from "../state/pane-scroll-registry";
|
|
2
3
|
import { useUiHost } from "./host";
|
|
3
4
|
export {
|
|
4
5
|
RGBA,
|
|
@@ -85,7 +86,14 @@ Underline.displayName = "Underline";
|
|
|
85
86
|
|
|
86
87
|
export const ScrollBox = forwardRef<any, import("./host").ScrollBoxProps>((props, ref) => {
|
|
87
88
|
const { ScrollBox: HostScrollBox } = useUiHost();
|
|
88
|
-
|
|
89
|
+
const localRef = useForwardedScrollBoxRef<any>(ref);
|
|
90
|
+
useRegisterPaneScrollBox(localRef, {
|
|
91
|
+
enabled: props.scrollY === true,
|
|
92
|
+
onScrollActivity: typeof props.onMouseScroll === "function"
|
|
93
|
+
? props.onMouseScroll as (event: { scroll: { direction: "up" | "down"; delta: number } }) => void
|
|
94
|
+
: undefined,
|
|
95
|
+
});
|
|
96
|
+
return createElement(HostScrollBox as any, { ...props, ref: localRef });
|
|
89
97
|
});
|
|
90
98
|
ScrollBox.displayName = "ScrollBox";
|
|
91
99
|
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.6.
|
|
1
|
+
export const VERSION = "0.6.1";
|