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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gloomberb",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Bloomberg-style stock portfolio tracker for the terminal",
|
|
5
5
|
"module": "src/index.tsx",
|
|
6
6
|
"type": "module",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"@opentui/react": "^0.1.90",
|
|
54
54
|
"@stoqey/ib": "^1.5.3",
|
|
55
55
|
"@tanstack/react-virtual": "^3.13.23",
|
|
56
|
+
"jimp": "1.6.0",
|
|
56
57
|
"opentui-spinner": "^0.0.6",
|
|
57
58
|
"react": "19.2.5",
|
|
58
59
|
"rxjs": "^7.8.2"
|
|
@@ -82,7 +82,6 @@ import {
|
|
|
82
82
|
} from "./chart-types";
|
|
83
83
|
import {
|
|
84
84
|
computeBitmapSize,
|
|
85
|
-
intersectCellRects,
|
|
86
85
|
renderNativeComparisonChartBase,
|
|
87
86
|
renderNativeCrosshairOverlay,
|
|
88
87
|
type CellRect,
|
|
@@ -93,6 +92,11 @@ import { ensureKittySupport, getCachedKittySupport } from "./native/kitty-suppor
|
|
|
93
92
|
import { resolveChartRendererState } from "./native/renderer-selection";
|
|
94
93
|
import { getNativeSurfaceManager } from "./native/surface-manager";
|
|
95
94
|
import { syncCachedNativeSurface } from "./native/surface-sync";
|
|
95
|
+
import {
|
|
96
|
+
getRenderableCellRect,
|
|
97
|
+
resolveNativeSurfaceVisibleRect,
|
|
98
|
+
type NativeSurfaceRenderableNode,
|
|
99
|
+
} from "./native/surface-visibility";
|
|
96
100
|
import { formatAxisCell, formatDateShort, resolveChartAxisWidth, type StyledContent } from "./chart-renderer";
|
|
97
101
|
|
|
98
102
|
const MODE_CHIPS: Record<ComparisonChartRenderMode, string> = {
|
|
@@ -169,14 +173,6 @@ interface DisplayCursorState {
|
|
|
169
173
|
pixelY: number | null;
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
interface RenderableNode {
|
|
173
|
-
x: number;
|
|
174
|
-
y: number;
|
|
175
|
-
width: number;
|
|
176
|
-
height: number;
|
|
177
|
-
parent: RenderableNode | null;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
176
|
const EMPTY_DISPLAY_CURSOR: DisplayCursorState = {
|
|
181
177
|
cellX: null,
|
|
182
178
|
cellY: null,
|
|
@@ -384,41 +380,6 @@ function resolveCursorMotionKind(
|
|
|
384
380
|
: "cell";
|
|
385
381
|
}
|
|
386
382
|
|
|
387
|
-
function extractCellRect(renderable: RenderableNode): CellRect {
|
|
388
|
-
return {
|
|
389
|
-
x: renderable.x,
|
|
390
|
-
y: renderable.y,
|
|
391
|
-
width: renderable.width,
|
|
392
|
-
height: renderable.height,
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function resolveVisibleRect(
|
|
397
|
-
renderable: RenderableNode | null,
|
|
398
|
-
terminalWidth: number,
|
|
399
|
-
terminalHeight: number,
|
|
400
|
-
): CellRect | null {
|
|
401
|
-
if (!renderable) return null;
|
|
402
|
-
|
|
403
|
-
let visible: CellRect = {
|
|
404
|
-
x: 0,
|
|
405
|
-
y: 0,
|
|
406
|
-
width: terminalWidth,
|
|
407
|
-
height: terminalHeight,
|
|
408
|
-
};
|
|
409
|
-
let current: RenderableNode | null = renderable;
|
|
410
|
-
|
|
411
|
-
while (current) {
|
|
412
|
-
const currentRect = extractCellRect(current);
|
|
413
|
-
const nextVisible = intersectCellRects(visible, currentRect);
|
|
414
|
-
if (!nextVisible) return null;
|
|
415
|
-
visible = nextVisible;
|
|
416
|
-
current = current.parent;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
return visible;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
383
|
function buildBlankPlotLines(width: number, height: number): string[] {
|
|
423
384
|
return Array.from({ length: height }, () => " ".repeat(width));
|
|
424
385
|
}
|
|
@@ -1201,8 +1162,9 @@ function ComparisonStockChartView({
|
|
|
1201
1162
|
|
|
1202
1163
|
const syncPlacement = () => {
|
|
1203
1164
|
if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !plotRef.current) return;
|
|
1204
|
-
const
|
|
1205
|
-
const
|
|
1165
|
+
const plot = plotRef.current as NativeSurfaceRenderableNode;
|
|
1166
|
+
const rect = getRenderableCellRect(plot);
|
|
1167
|
+
const visibleRect = resolveNativeSurfaceVisibleRect(plot, renderer.terminalWidth, renderer.terminalHeight);
|
|
1206
1168
|
const previous = lastNativeGeometryRef.current;
|
|
1207
1169
|
if (previous
|
|
1208
1170
|
&& previous.rect.x === rect.x
|
|
@@ -1257,8 +1219,9 @@ function ComparisonStockChartView({
|
|
|
1257
1219
|
return;
|
|
1258
1220
|
}
|
|
1259
1221
|
|
|
1260
|
-
const
|
|
1261
|
-
const
|
|
1222
|
+
const plot = plotRef.current as NativeSurfaceRenderableNode;
|
|
1223
|
+
const plotRect = getRenderableCellRect(plot);
|
|
1224
|
+
const visibleRect = resolveNativeSurfaceVisibleRect(plot, renderer.terminalWidth, renderer.terminalHeight);
|
|
1262
1225
|
const bitmapSize = computeBitmapSize(plotRect, renderer.resolution, renderer.terminalWidth, renderer.terminalHeight);
|
|
1263
1226
|
const bitmapKey = buildComparisonNativeBitmapKey(
|
|
1264
1227
|
symbols.length,
|
|
@@ -1314,8 +1277,9 @@ function ComparisonStockChartView({
|
|
|
1314
1277
|
return;
|
|
1315
1278
|
}
|
|
1316
1279
|
|
|
1317
|
-
const
|
|
1318
|
-
const
|
|
1280
|
+
const plot = plotRef.current as NativeSurfaceRenderableNode;
|
|
1281
|
+
const plotRect = getRenderableCellRect(plot);
|
|
1282
|
+
const visibleRect = resolveNativeSurfaceVisibleRect(plot, renderer.terminalWidth, renderer.terminalHeight);
|
|
1319
1283
|
const bitmapSize = computeBitmapSize(plotRect, renderer.resolution, renderer.terminalWidth, renderer.terminalHeight);
|
|
1320
1284
|
const renderablePixelSize = getRenderablePixelSize(plotRef.current, renderer);
|
|
1321
1285
|
const overlayPixelX = scaleLocalPixelCoordinate(
|
|
@@ -17,6 +17,7 @@ import { chunkBase64Payload, encodeKittyTransmitRgba } from "./kitty-protocol";
|
|
|
17
17
|
import { resolveChartRendererState } from "./renderer-selection";
|
|
18
18
|
import { computeSurfaceVisibleFragments, NativeSurfaceManager } from "./surface-manager";
|
|
19
19
|
import { syncCachedNativeSurface } from "./surface-sync";
|
|
20
|
+
import { resolveNativeSurfaceVisibleRect, type NativeSurfaceRenderableNode } from "./surface-visibility";
|
|
20
21
|
|
|
21
22
|
describe("resolveChartRendererState", () => {
|
|
22
23
|
test("resolves auto and forced kitty correctly", () => {
|
|
@@ -98,6 +99,28 @@ describe("computeNativePlacement", () => {
|
|
|
98
99
|
});
|
|
99
100
|
});
|
|
100
101
|
|
|
102
|
+
describe("resolveNativeSurfaceVisibleRect", () => {
|
|
103
|
+
test("treats hidden ancestors as invisible for native graphics", () => {
|
|
104
|
+
const hiddenParent: NativeSurfaceRenderableNode = {
|
|
105
|
+
x: 0,
|
|
106
|
+
y: 0,
|
|
107
|
+
width: 80,
|
|
108
|
+
height: 20,
|
|
109
|
+
visible: false,
|
|
110
|
+
parent: null,
|
|
111
|
+
};
|
|
112
|
+
const child: NativeSurfaceRenderableNode = {
|
|
113
|
+
x: 2,
|
|
114
|
+
y: 3,
|
|
115
|
+
width: 20,
|
|
116
|
+
height: 8,
|
|
117
|
+
parent: hiddenParent,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
expect(resolveNativeSurfaceVisibleRect(child, 100, 30)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
101
124
|
describe("renderNativeChartBase", () => {
|
|
102
125
|
test("leaves untouched bitmap pixels transparent", () => {
|
|
103
126
|
const palette = resolveChartPalette({
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { intersectCellRects, type CellRect } from "./chart-rasterizer";
|
|
2
|
+
|
|
3
|
+
export interface NativeSurfaceRenderableNode {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
visible?: boolean;
|
|
9
|
+
parent: NativeSurfaceRenderableNode | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getRenderableCellRect(
|
|
13
|
+
renderable: Pick<NativeSurfaceRenderableNode, "x" | "y" | "width" | "height">,
|
|
14
|
+
): CellRect {
|
|
15
|
+
return {
|
|
16
|
+
x: renderable.x,
|
|
17
|
+
y: renderable.y,
|
|
18
|
+
width: renderable.width,
|
|
19
|
+
height: renderable.height,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveNativeSurfaceVisibleRect(
|
|
24
|
+
renderable: NativeSurfaceRenderableNode | null,
|
|
25
|
+
terminalWidth: number,
|
|
26
|
+
terminalHeight: number,
|
|
27
|
+
): CellRect | null {
|
|
28
|
+
if (!renderable) return null;
|
|
29
|
+
|
|
30
|
+
let visible: CellRect = {
|
|
31
|
+
x: 0,
|
|
32
|
+
y: 0,
|
|
33
|
+
width: terminalWidth,
|
|
34
|
+
height: terminalHeight,
|
|
35
|
+
};
|
|
36
|
+
let current: NativeSurfaceRenderableNode | null = renderable;
|
|
37
|
+
|
|
38
|
+
while (current) {
|
|
39
|
+
if (current.visible === false) return null;
|
|
40
|
+
|
|
41
|
+
const nextVisible = intersectCellRects(visible, getRenderableCellRect(current));
|
|
42
|
+
if (!nextVisible) return null;
|
|
43
|
+
visible = nextVisible;
|
|
44
|
+
current = current.parent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return visible;
|
|
48
|
+
}
|
|
@@ -110,7 +110,6 @@ import {
|
|
|
110
110
|
} from "./chart-types";
|
|
111
111
|
import {
|
|
112
112
|
computeBitmapSize,
|
|
113
|
-
intersectCellRects,
|
|
114
113
|
renderNativeChartBase,
|
|
115
114
|
renderNativeCrosshairOverlay,
|
|
116
115
|
type CellRect,
|
|
@@ -121,6 +120,11 @@ import { ensureKittySupport, getCachedKittySupport } from "./native/kitty-suppor
|
|
|
121
120
|
import { resolveChartRendererState } from "./native/renderer-selection";
|
|
122
121
|
import { getNativeSurfaceManager } from "./native/surface-manager";
|
|
123
122
|
import { syncCachedNativeSurface } from "./native/surface-sync";
|
|
123
|
+
import {
|
|
124
|
+
getRenderableCellRect,
|
|
125
|
+
resolveNativeSurfaceVisibleRect,
|
|
126
|
+
type NativeSurfaceRenderableNode,
|
|
127
|
+
} from "./native/surface-visibility";
|
|
124
128
|
import type { PricePoint, TickerFinancials } from "../../types/financials";
|
|
125
129
|
import type { TickerRecord } from "../../types/ticker";
|
|
126
130
|
|
|
@@ -275,14 +279,6 @@ interface DisplayCursorState {
|
|
|
275
279
|
pixelY: number | null;
|
|
276
280
|
}
|
|
277
281
|
|
|
278
|
-
interface RenderableNode {
|
|
279
|
-
x: number;
|
|
280
|
-
y: number;
|
|
281
|
-
width: number;
|
|
282
|
-
height: number;
|
|
283
|
-
parent: RenderableNode | null;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
282
|
const EMPTY_DISPLAY_CURSOR: DisplayCursorState = {
|
|
287
283
|
cellX: null,
|
|
288
284
|
cellY: null,
|
|
@@ -522,41 +518,6 @@ function resolveCursorMotionKind(
|
|
|
522
518
|
: "cell";
|
|
523
519
|
}
|
|
524
520
|
|
|
525
|
-
function extractCellRect(renderable: RenderableNode): CellRect {
|
|
526
|
-
return {
|
|
527
|
-
x: renderable.x,
|
|
528
|
-
y: renderable.y,
|
|
529
|
-
width: renderable.width,
|
|
530
|
-
height: renderable.height,
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function resolveVisibleRect(
|
|
535
|
-
renderable: RenderableNode | null,
|
|
536
|
-
terminalWidth: number,
|
|
537
|
-
terminalHeight: number,
|
|
538
|
-
): CellRect | null {
|
|
539
|
-
if (!renderable) return null;
|
|
540
|
-
|
|
541
|
-
let visible: CellRect = {
|
|
542
|
-
x: 0,
|
|
543
|
-
y: 0,
|
|
544
|
-
width: terminalWidth,
|
|
545
|
-
height: terminalHeight,
|
|
546
|
-
};
|
|
547
|
-
let current: RenderableNode | null = renderable;
|
|
548
|
-
|
|
549
|
-
while (current) {
|
|
550
|
-
const currentRect = extractCellRect(current);
|
|
551
|
-
const nextVisible = intersectCellRects(visible, currentRect);
|
|
552
|
-
if (!nextVisible) return null;
|
|
553
|
-
visible = nextVisible;
|
|
554
|
-
current = current.parent;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
return visible;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
521
|
function buildBlankPlotLines(width: number, height: number): string[] {
|
|
561
522
|
return Array.from({ length: height }, () => " ".repeat(width));
|
|
562
523
|
}
|
|
@@ -3236,8 +3197,9 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
|
|
|
3236
3197
|
|
|
3237
3198
|
const syncPlacement = () => {
|
|
3238
3199
|
if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !plotRef.current) return;
|
|
3239
|
-
const
|
|
3240
|
-
const
|
|
3200
|
+
const plot = plotRef.current as NativeSurfaceRenderableNode;
|
|
3201
|
+
const rect = getRenderableCellRect(plot);
|
|
3202
|
+
const visibleRect = resolveNativeSurfaceVisibleRect(plot, renderer.terminalWidth, renderer.terminalHeight);
|
|
3241
3203
|
const previous = lastNativeGeometryRef.current;
|
|
3242
3204
|
if (previous
|
|
3243
3205
|
&& previous.rect.x === rect.x
|
|
@@ -3292,8 +3254,9 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
|
|
|
3292
3254
|
return;
|
|
3293
3255
|
}
|
|
3294
3256
|
|
|
3295
|
-
const
|
|
3296
|
-
const
|
|
3257
|
+
const plot = plotRef.current as NativeSurfaceRenderableNode;
|
|
3258
|
+
const plotRect = getRenderableCellRect(plot);
|
|
3259
|
+
const visibleRect = resolveNativeSurfaceVisibleRect(plot, renderer.terminalWidth, renderer.terminalHeight);
|
|
3297
3260
|
const bitmapSize = computeBitmapSize(plotRect, renderer.resolution, renderer.terminalWidth, renderer.terminalHeight);
|
|
3298
3261
|
const bitmapKey = buildNativeBitmapKey(
|
|
3299
3262
|
projection.points.length,
|
|
@@ -3364,8 +3327,9 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
|
|
|
3364
3327
|
return;
|
|
3365
3328
|
}
|
|
3366
3329
|
|
|
3367
|
-
const
|
|
3368
|
-
const
|
|
3330
|
+
const plot = plotRef.current as NativeSurfaceRenderableNode;
|
|
3331
|
+
const plotRect = getRenderableCellRect(plot);
|
|
3332
|
+
const visibleRect = resolveNativeSurfaceVisibleRect(plot, renderer.terminalWidth, renderer.terminalHeight);
|
|
3369
3333
|
const bitmapSize = computeBitmapSize(plotRect, renderer.resolution, renderer.terminalWidth, renderer.terminalHeight);
|
|
3370
3334
|
const renderablePixelSize = getRenderablePixelSize(plotRef.current, renderer);
|
|
3371
3335
|
const overlayPixelX = scaleLocalPixelCoordinate(
|
package/src/components/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type { DataTableKeyEvent } from "./data-table-view";
|
|
|
10
10
|
export { DataTableStackView } from "./data-table-stack-view";
|
|
11
11
|
export { FeedDataTableStackView } from "./feed-data-table-stack-view";
|
|
12
12
|
export type { FeedDataTableItem } from "./feed-data-table-stack-view";
|
|
13
|
-
export { usePaneFooter } from "./layout/pane-footer";
|
|
13
|
+
export { PaneFooterScope, usePaneFooter } from "./layout/pane-footer";
|
|
14
14
|
export type { PaneFooterSegment, PaneHint } from "./layout/pane-footer";
|
|
15
15
|
export { useExternalLinkFooter } from "./use-external-link-footer";
|
|
16
16
|
export { Button } from "./ui/button";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useCallback } from "react";
|
|
2
2
|
import { PaneInstanceProvider } from "../../state/app-context";
|
|
3
|
+
import { PaneKeyboardScrollController } from "../../state/pane-scroll-registry";
|
|
3
4
|
import type { PaneDef } from "../../types/plugin";
|
|
4
5
|
|
|
5
6
|
interface PaneContentProps {
|
|
@@ -27,6 +28,7 @@ export const PaneContent = memo(function PaneContent({
|
|
|
27
28
|
|
|
28
29
|
return (
|
|
29
30
|
<PaneInstanceProvider paneId={paneId}>
|
|
31
|
+
<PaneKeyboardScrollController paneId={paneId} focused={focused} />
|
|
30
32
|
<Component
|
|
31
33
|
paneId={paneId}
|
|
32
34
|
paneType={paneType}
|
|
@@ -158,6 +158,21 @@ export function PaneFooterProvider({
|
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
export function PaneFooterScope({
|
|
162
|
+
active,
|
|
163
|
+
children,
|
|
164
|
+
}: {
|
|
165
|
+
active: boolean;
|
|
166
|
+
children: ReactNode;
|
|
167
|
+
}) {
|
|
168
|
+
const context = useContext(PaneFooterContext);
|
|
169
|
+
return (
|
|
170
|
+
<PaneFooterContext.Provider value={active ? context : null}>
|
|
171
|
+
{children}
|
|
172
|
+
</PaneFooterContext.Provider>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
161
176
|
export function usePaneFooter(
|
|
162
177
|
registrationId: string,
|
|
163
178
|
factory: () => PaneFooterRegistration | null | undefined,
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, useReducer, type ReactNode } from "react";
|
|
3
|
+
import { testRender } from "../../renderers/opentui/test-utils";
|
|
4
|
+
import { useShortcut } from "../../react/input";
|
|
5
|
+
import { AppContext, appReducer, createInitialState } from "../../state/app-context";
|
|
6
|
+
import { Box, ScrollBox, Text, type ScrollBoxRenderable } from "../../ui";
|
|
7
|
+
import { DataTableStackView } from "../data-table-stack-view";
|
|
8
|
+
import { type DataTableColumn } from "../ui";
|
|
9
|
+
import { createDefaultConfig } from "../../types/config";
|
|
10
|
+
import type { PaneProps } from "../../types/plugin";
|
|
11
|
+
import { PaneContent } from "./pane-content";
|
|
12
|
+
|
|
13
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
14
|
+
let scrollRef: ScrollBoxRenderable | null = null;
|
|
15
|
+
let hiddenScrollRef: ScrollBoxRenderable | null = null;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (testSetup) {
|
|
19
|
+
act(() => {
|
|
20
|
+
testSetup!.renderer.destroy();
|
|
21
|
+
});
|
|
22
|
+
testSetup = undefined;
|
|
23
|
+
}
|
|
24
|
+
scrollRef = null;
|
|
25
|
+
hiddenScrollRef = null;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function emitDownArrow() {
|
|
29
|
+
return act(async () => {
|
|
30
|
+
testSetup!.mockInput.pressArrow("down");
|
|
31
|
+
await testSetup!.renderOnce();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function renderHarness(component: ReactNode) {
|
|
36
|
+
await act(async () => {
|
|
37
|
+
testSetup = await testRender(component, { width: 30, height: 5 });
|
|
38
|
+
});
|
|
39
|
+
await act(async () => {
|
|
40
|
+
await testSetup!.renderOnce();
|
|
41
|
+
await testSetup!.renderOnce();
|
|
42
|
+
await testSetup!.renderOnce();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function LongScrollPane({ width, height }: PaneProps) {
|
|
47
|
+
return (
|
|
48
|
+
<Box width={width} height={height}>
|
|
49
|
+
<ScrollBox
|
|
50
|
+
ref={(node) => {
|
|
51
|
+
scrollRef = node;
|
|
52
|
+
}}
|
|
53
|
+
height={height}
|
|
54
|
+
scrollY
|
|
55
|
+
focusable={false}
|
|
56
|
+
>
|
|
57
|
+
<Box flexDirection="column">
|
|
58
|
+
{Array.from({ length: 20 }, (_, index) => (
|
|
59
|
+
<Box key={index} height={1}>
|
|
60
|
+
<Text>{`Row ${index}`}</Text>
|
|
61
|
+
</Box>
|
|
62
|
+
))}
|
|
63
|
+
</Box>
|
|
64
|
+
</ScrollBox>
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ConsumingScrollPane(props: PaneProps) {
|
|
70
|
+
useShortcut((event) => {
|
|
71
|
+
if (event.name !== "down") return;
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
event.stopPropagation();
|
|
74
|
+
});
|
|
75
|
+
return <LongScrollPane {...props} />;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function HiddenMountedTabPane({ width, height }: PaneProps) {
|
|
79
|
+
const renderRows = () => Array.from({ length: 20 }, (_, index) => (
|
|
80
|
+
<Box key={index} height={1}>
|
|
81
|
+
<Text>{`Tab row ${index}`}</Text>
|
|
82
|
+
</Box>
|
|
83
|
+
));
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Box width={width} height={height}>
|
|
87
|
+
<ScrollBox
|
|
88
|
+
ref={(node) => {
|
|
89
|
+
scrollRef = node;
|
|
90
|
+
}}
|
|
91
|
+
height={height}
|
|
92
|
+
scrollY
|
|
93
|
+
focusable={false}
|
|
94
|
+
>
|
|
95
|
+
<Box flexDirection="column">{renderRows()}</Box>
|
|
96
|
+
</ScrollBox>
|
|
97
|
+
<Box visible={false} height={height}>
|
|
98
|
+
<ScrollBox
|
|
99
|
+
ref={(node) => {
|
|
100
|
+
hiddenScrollRef = node;
|
|
101
|
+
}}
|
|
102
|
+
height={height}
|
|
103
|
+
scrollY
|
|
104
|
+
focusable={false}
|
|
105
|
+
>
|
|
106
|
+
<Box flexDirection="column">{renderRows()}</Box>
|
|
107
|
+
</ScrollBox>
|
|
108
|
+
</Box>
|
|
109
|
+
</Box>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface TestRow {
|
|
114
|
+
id: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const columns: DataTableColumn[] = [{ id: "label", label: "Label", width: 10, align: "left" }];
|
|
118
|
+
const rows: TestRow[] = [{ id: "row-1" }];
|
|
119
|
+
|
|
120
|
+
function StackDetailPane({ focused, width, height }: PaneProps) {
|
|
121
|
+
const detailContent = (
|
|
122
|
+
<ScrollBox
|
|
123
|
+
ref={(node) => {
|
|
124
|
+
scrollRef = node;
|
|
125
|
+
}}
|
|
126
|
+
flexGrow={1}
|
|
127
|
+
scrollY
|
|
128
|
+
focusable={false}
|
|
129
|
+
>
|
|
130
|
+
<Box flexDirection="column">
|
|
131
|
+
{Array.from({ length: 20 }, (_, index) => (
|
|
132
|
+
<Box key={index} height={1}>
|
|
133
|
+
<Text>{`Tweet detail row ${index}`}</Text>
|
|
134
|
+
</Box>
|
|
135
|
+
))}
|
|
136
|
+
</Box>
|
|
137
|
+
</ScrollBox>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<DataTableStackView<TestRow>
|
|
142
|
+
focused={focused}
|
|
143
|
+
detailOpen
|
|
144
|
+
onBack={() => {}}
|
|
145
|
+
detailContent={detailContent}
|
|
146
|
+
detailTitle="Tweet"
|
|
147
|
+
selectedIndex={0}
|
|
148
|
+
rootWidth={width}
|
|
149
|
+
rootHeight={height}
|
|
150
|
+
columns={columns}
|
|
151
|
+
items={rows}
|
|
152
|
+
sortColumnId={null}
|
|
153
|
+
sortDirection="asc"
|
|
154
|
+
onHeaderClick={() => {}}
|
|
155
|
+
getItemKey={(row) => row.id}
|
|
156
|
+
isSelected={(_row, index) => index === 0}
|
|
157
|
+
onSelect={() => {}}
|
|
158
|
+
renderCell={(row) => ({ text: row.id })}
|
|
159
|
+
emptyStateTitle="No rows"
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function Harness({
|
|
165
|
+
component = LongScrollPane,
|
|
166
|
+
focused = true,
|
|
167
|
+
}: {
|
|
168
|
+
component?: (props: PaneProps) => ReactNode;
|
|
169
|
+
focused?: boolean;
|
|
170
|
+
}) {
|
|
171
|
+
const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-pane-keyboard-scroll-test"));
|
|
172
|
+
const [state, dispatch] = useReducer(appReducer, initialState);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<AppContext value={{ state, dispatch }}>
|
|
176
|
+
<PaneContent
|
|
177
|
+
component={component}
|
|
178
|
+
paneId="test-pane:main"
|
|
179
|
+
paneType="test-pane"
|
|
180
|
+
focused={focused}
|
|
181
|
+
width={30}
|
|
182
|
+
height={5}
|
|
183
|
+
/>
|
|
184
|
+
</AppContext>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
describe("pane keyboard scrolling", () => {
|
|
189
|
+
test("scrolls the focused pane's vertical scrollbox with arrow keys", async () => {
|
|
190
|
+
await renderHarness(<Harness />);
|
|
191
|
+
|
|
192
|
+
expect(scrollRef?.scrollTop).toBe(0);
|
|
193
|
+
await emitDownArrow();
|
|
194
|
+
|
|
195
|
+
expect(scrollRef?.scrollTop).toBe(3);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("does not steal arrows from existing pane shortcut handlers", async () => {
|
|
199
|
+
await renderHarness(<Harness component={ConsumingScrollPane} />);
|
|
200
|
+
|
|
201
|
+
expect(scrollRef?.scrollTop).toBe(0);
|
|
202
|
+
await emitDownArrow();
|
|
203
|
+
|
|
204
|
+
expect(scrollRef?.scrollTop).toBe(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("ignores mounted scrollboxes inside hidden tabs", async () => {
|
|
208
|
+
await renderHarness(<Harness component={HiddenMountedTabPane} />);
|
|
209
|
+
|
|
210
|
+
expect(scrollRef?.scrollTop).toBe(0);
|
|
211
|
+
expect(hiddenScrollRef?.scrollTop).toBe(0);
|
|
212
|
+
await emitDownArrow();
|
|
213
|
+
|
|
214
|
+
expect(scrollRef?.scrollTop).toBe(3);
|
|
215
|
+
expect(hiddenScrollRef?.scrollTop).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("scrolls stack detail content without requiring a pane-local detail handler", async () => {
|
|
219
|
+
await renderHarness(<Harness component={StackDetailPane} />);
|
|
220
|
+
|
|
221
|
+
expect(scrollRef?.scrollTop).toBe(0);
|
|
222
|
+
await emitDownArrow();
|
|
223
|
+
|
|
224
|
+
expect(scrollRef?.scrollTop).toBe(3);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, ImageSurface, Text
|
|
1
|
+
import { Box, ImageSurface, Text } from "../../ui";
|
|
2
2
|
import { colors } from "../../theme/colors";
|
|
3
3
|
import { ExternalLinkText } from "./external-link";
|
|
4
4
|
import { normalizedHttpUrl } from "../../utils/url";
|
|
@@ -25,51 +25,26 @@ export function RemoteImage({
|
|
|
25
25
|
height = 12,
|
|
26
26
|
label = "image",
|
|
27
27
|
}: RemoteImageProps) {
|
|
28
|
-
const ui = useUiHost();
|
|
29
28
|
const resolvedWidth = Math.max(12, width);
|
|
30
29
|
const imageUrl = normalizedHttpUrl(src);
|
|
31
30
|
if (!imageUrl) return null;
|
|
32
31
|
|
|
33
|
-
if (ui.kind === "desktop-web") {
|
|
34
|
-
return (
|
|
35
|
-
<ImageSurface
|
|
36
|
-
src={imageUrl}
|
|
37
|
-
alt={alt}
|
|
38
|
-
width={resolvedWidth}
|
|
39
|
-
height={Math.max(4, height)}
|
|
40
|
-
border
|
|
41
|
-
borderColor={colors.border}
|
|
42
|
-
backgroundColor={colors.panel}
|
|
43
|
-
objectFit="contain"
|
|
44
|
-
>
|
|
45
|
-
<Box paddingX={1} paddingY={1} flexDirection="column" gap={1}>
|
|
46
|
-
<Text fg={colors.textDim}>{label}</Text>
|
|
47
|
-
<ExternalLinkText
|
|
48
|
-
url={imageUrl}
|
|
49
|
-
label={truncate(imageUrl, Math.max(1, resolvedWidth - 2))}
|
|
50
|
-
color={colors.textBright}
|
|
51
|
-
/>
|
|
52
|
-
</Box>
|
|
53
|
-
</ImageSurface>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
32
|
return (
|
|
58
|
-
<
|
|
33
|
+
<ImageSurface
|
|
34
|
+
src={imageUrl}
|
|
35
|
+
alt={alt}
|
|
59
36
|
width={resolvedWidth}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
borderColor={colors.border}
|
|
63
|
-
backgroundColor={colors.panel}
|
|
64
|
-
paddingX={1}
|
|
65
|
-
flexDirection="column"
|
|
37
|
+
height={Math.max(4, height)}
|
|
38
|
+
objectFit="contain"
|
|
66
39
|
>
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
40
|
+
<Box flexDirection="column" gap={1}>
|
|
41
|
+
<Text fg={colors.textDim}>{label}</Text>
|
|
42
|
+
<ExternalLinkText
|
|
43
|
+
url={imageUrl}
|
|
44
|
+
label={truncate(imageUrl, resolvedWidth)}
|
|
45
|
+
color={colors.textBright}
|
|
46
|
+
/>
|
|
47
|
+
</Box>
|
|
48
|
+
</ImageSurface>
|
|
74
49
|
);
|
|
75
50
|
}
|