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,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Jimp } from "jimp";
|
|
3
|
+
import { resizeImageToBitmap, type JimpImageLike } from "./image-loader";
|
|
4
|
+
|
|
5
|
+
describe("resizeImageToBitmap", () => {
|
|
6
|
+
test("preserves aspect ratio for contained images", () => {
|
|
7
|
+
const source = new Jimp({ width: 4, height: 2, color: 0xff0000ff }) as unknown as JimpImageLike;
|
|
8
|
+
const bitmap = resizeImageToBitmap(source, { width: 4, height: 4, objectFit: "contain" });
|
|
9
|
+
|
|
10
|
+
expect(bitmap.width).toBe(4);
|
|
11
|
+
expect(bitmap.height).toBe(4);
|
|
12
|
+
expect([...bitmap.pixels.slice(0, 4)]).toEqual([0, 0, 0, 0]);
|
|
13
|
+
expect([...bitmap.pixels.slice(4 * 4, 4 * 4 + 4)]).toEqual([255, 0, 0, 255]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("fills the target for covered images", () => {
|
|
17
|
+
const source = new Jimp({ width: 4, height: 2, color: 0xff0000ff }) as unknown as JimpImageLike;
|
|
18
|
+
const bitmap = resizeImageToBitmap(source, { width: 4, height: 4, objectFit: "cover" });
|
|
19
|
+
|
|
20
|
+
expect(bitmap.width).toBe(4);
|
|
21
|
+
expect(bitmap.height).toBe(4);
|
|
22
|
+
expect([...bitmap.pixels.slice(0, 4)]).toEqual([255, 0, 0, 255]);
|
|
23
|
+
expect([...bitmap.pixels.slice((bitmap.pixels.length - 4), bitmap.pixels.length)]).toEqual([255, 0, 0, 255]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Jimp } from "jimp";
|
|
2
|
+
import type { NativeChartBitmap } from "../../components/chart/native/chart-rasterizer";
|
|
3
|
+
|
|
4
|
+
type ImageObjectFit = "contain" | "cover";
|
|
5
|
+
|
|
6
|
+
export interface JimpImageLike {
|
|
7
|
+
bitmap: {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
data: Uint8Array;
|
|
11
|
+
};
|
|
12
|
+
clone(): JimpImageLike;
|
|
13
|
+
contain(options: { w: number; h: number }): JimpImageLike;
|
|
14
|
+
cover(options: { w: number; h: number }): JimpImageLike;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ImageBitmapOptions {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
objectFit: ImageObjectFit;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MAX_REMOTE_IMAGE_BYTES = 12 * 1024 * 1024;
|
|
24
|
+
const IMAGE_CACHE_LIMIT = 48;
|
|
25
|
+
|
|
26
|
+
const sourceImageCache = new Map<string, Promise<JimpImageLike>>();
|
|
27
|
+
const bitmapCache = new Map<string, Promise<NativeChartBitmap>>();
|
|
28
|
+
|
|
29
|
+
function remember<K, V>(cache: Map<K, V>, key: K, value: V) {
|
|
30
|
+
cache.set(key, value);
|
|
31
|
+
while (cache.size > IMAGE_CACHE_LIMIT) {
|
|
32
|
+
const firstKey = cache.keys().next().value as K | undefined;
|
|
33
|
+
if (firstKey === undefined) break;
|
|
34
|
+
cache.delete(firstKey);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function fetchImageBytes(src: string): Promise<Buffer> {
|
|
39
|
+
const response = await fetch(src, {
|
|
40
|
+
headers: {
|
|
41
|
+
"User-Agent": "Gloomberb/0.6 image renderer",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(`Image request failed with HTTP ${response.status}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const data = await response.arrayBuffer();
|
|
49
|
+
if (data.byteLength > MAX_REMOTE_IMAGE_BYTES) {
|
|
50
|
+
throw new Error("Image is too large to render inline");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return Buffer.from(data);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadSourceImage(src: string): Promise<JimpImageLike> {
|
|
57
|
+
const cached = sourceImageCache.get(src);
|
|
58
|
+
if (cached) return cached;
|
|
59
|
+
|
|
60
|
+
const promise = fetchImageBytes(src)
|
|
61
|
+
.then((bytes) => Jimp.read(bytes) as Promise<JimpImageLike>)
|
|
62
|
+
.catch((error) => {
|
|
63
|
+
sourceImageCache.delete(src);
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
remember(sourceImageCache, src, promise);
|
|
68
|
+
return promise;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resizeImageToBitmap(source: JimpImageLike, options: ImageBitmapOptions): NativeChartBitmap {
|
|
72
|
+
const width = Math.max(1, Math.round(options.width));
|
|
73
|
+
const height = Math.max(1, Math.round(options.height));
|
|
74
|
+
const image = source.clone();
|
|
75
|
+
|
|
76
|
+
if (options.objectFit === "cover") {
|
|
77
|
+
image.cover({ w: width, h: height });
|
|
78
|
+
} else {
|
|
79
|
+
image.contain({ w: width, h: height });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
width: image.bitmap.width,
|
|
84
|
+
height: image.bitmap.height,
|
|
85
|
+
pixels: new Uint8Array(image.bitmap.data),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function loadOpenTuiImageBitmap(src: string, options: ImageBitmapOptions): Promise<NativeChartBitmap> {
|
|
90
|
+
const width = Math.max(1, Math.round(options.width));
|
|
91
|
+
const height = Math.max(1, Math.round(options.height));
|
|
92
|
+
const key = `${src}\n${options.objectFit}\n${width}x${height}`;
|
|
93
|
+
const cached = bitmapCache.get(key);
|
|
94
|
+
if (cached) return cached;
|
|
95
|
+
|
|
96
|
+
const promise = loadSourceImage(src)
|
|
97
|
+
.then((image) => resizeImageToBitmap(image, { width, height, objectFit: options.objectFit }))
|
|
98
|
+
.catch((error) => {
|
|
99
|
+
bitmapCache.delete(key);
|
|
100
|
+
throw error;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
remember(bitmapCache, key, promise);
|
|
104
|
+
return promise;
|
|
105
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createElement, forwardRef, useCallback, useEffect, useMemo, useRef, useState, type ForwardedRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
computeBitmapSize,
|
|
4
|
+
intersectCellRects,
|
|
5
|
+
type CellRect,
|
|
6
|
+
type NativeChartBitmap,
|
|
7
|
+
} from "../../components/chart/native/chart-rasterizer";
|
|
8
|
+
import { getCachedKittySupport, ensureKittySupport } from "../../components/chart/native/kitty-support";
|
|
9
|
+
import { getNativeSurfaceManager } from "../../components/chart/native/surface-manager";
|
|
10
|
+
import {
|
|
11
|
+
getRenderableCellRect,
|
|
12
|
+
resolveNativeSurfaceVisibleRect,
|
|
13
|
+
type NativeSurfaceRenderableNode,
|
|
14
|
+
} from "../../components/chart/native/surface-visibility";
|
|
15
|
+
import { useOptionalPaneInstanceId } from "../../state/app-context";
|
|
16
|
+
import { useNativeRenderer, type BoxRenderable, type ImageSurfaceProps } from "../../ui";
|
|
17
|
+
import { loadOpenTuiImageBitmap } from "./image-loader";
|
|
18
|
+
|
|
19
|
+
interface NativeRenderableNode extends BoxRenderable, NativeSurfaceRenderableNode {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
parent: NativeRenderableNode | null;
|
|
25
|
+
onLifecyclePass: (() => void) | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SurfaceTarget {
|
|
29
|
+
rect: CellRect;
|
|
30
|
+
visibleRect: CellRect | null;
|
|
31
|
+
pixelWidth: number;
|
|
32
|
+
pixelHeight: number;
|
|
33
|
+
bitmapKey: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CellInsets {
|
|
37
|
+
top: number;
|
|
38
|
+
right: number;
|
|
39
|
+
bottom: number;
|
|
40
|
+
left: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let nextImageSurfaceId = 1;
|
|
44
|
+
|
|
45
|
+
function assignRef(ref: ForwardedRef<unknown>, value: unknown) {
|
|
46
|
+
if (typeof ref === "function") {
|
|
47
|
+
ref(value);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (ref) {
|
|
51
|
+
(ref as { current: unknown }).current = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readInset(value: unknown): number {
|
|
56
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveInsets(props: Record<string, unknown>): CellInsets {
|
|
60
|
+
const border = props.border ? 1 : 0;
|
|
61
|
+
const padding = readInset(props.padding);
|
|
62
|
+
const paddingX = readInset(props.paddingX ?? padding);
|
|
63
|
+
const paddingY = readInset(props.paddingY ?? padding);
|
|
64
|
+
return {
|
|
65
|
+
top: border + readInset(props.paddingTop ?? paddingY),
|
|
66
|
+
right: border + readInset(props.paddingRight ?? paddingX),
|
|
67
|
+
bottom: border + readInset(props.paddingBottom ?? paddingY),
|
|
68
|
+
left: border + readInset(props.paddingLeft ?? paddingX),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sameRect(left: CellRect | null, right: CellRect | null): boolean {
|
|
73
|
+
if (left === right) return true;
|
|
74
|
+
if (!left || !right) return false;
|
|
75
|
+
return left.x === right.x
|
|
76
|
+
&& left.y === right.y
|
|
77
|
+
&& left.width === right.width
|
|
78
|
+
&& left.height === right.height;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sameTarget(left: SurfaceTarget | null, right: SurfaceTarget | null): boolean {
|
|
82
|
+
if (left === right) return true;
|
|
83
|
+
if (!left || !right) return false;
|
|
84
|
+
return left.bitmapKey === right.bitmapKey
|
|
85
|
+
&& left.pixelWidth === right.pixelWidth
|
|
86
|
+
&& left.pixelHeight === right.pixelHeight
|
|
87
|
+
&& sameRect(left.rect, right.rect)
|
|
88
|
+
&& sameRect(left.visibleRect, right.visibleRect);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function insetRect(rect: CellRect, insets: CellInsets): CellRect | null {
|
|
92
|
+
const width = rect.width - insets.left - insets.right;
|
|
93
|
+
const height = rect.height - insets.top - insets.bottom;
|
|
94
|
+
if (width <= 0 || height <= 0) return null;
|
|
95
|
+
return {
|
|
96
|
+
x: rect.x + insets.left,
|
|
97
|
+
y: rect.y + insets.top,
|
|
98
|
+
width,
|
|
99
|
+
height,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const OpenTuiImageSurface = forwardRef<unknown, ImageSurfaceProps>(function OpenTuiImageSurface(
|
|
104
|
+
{ children, src, alt: _alt, objectFit = "contain", ...props },
|
|
105
|
+
forwardedRef,
|
|
106
|
+
) {
|
|
107
|
+
const renderer = useNativeRenderer();
|
|
108
|
+
const paneId = useOptionalPaneInstanceId();
|
|
109
|
+
const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
|
|
110
|
+
const surfaceId = useRef(`opentui-image:${nextImageSurfaceId++}`).current;
|
|
111
|
+
const renderableRef = useRef<NativeRenderableNode | null>(null);
|
|
112
|
+
const [kittySupport, setKittySupport] = useState<boolean | null>(() => getCachedKittySupport(renderer));
|
|
113
|
+
const [target, setTarget] = useState<SurfaceTarget | null>(null);
|
|
114
|
+
const [bitmapState, setBitmapState] = useState<{ key: string; bitmap: NativeChartBitmap } | null>(null);
|
|
115
|
+
const [loadFailed, setLoadFailed] = useState(false);
|
|
116
|
+
const imageSrc = typeof src === "string" ? src.trim() : "";
|
|
117
|
+
const resolvedObjectFit = objectFit === "cover" ? "cover" : "contain";
|
|
118
|
+
const insets = useMemo(() => resolveInsets(props), [
|
|
119
|
+
props.border,
|
|
120
|
+
props.padding,
|
|
121
|
+
props.paddingX,
|
|
122
|
+
props.paddingY,
|
|
123
|
+
props.paddingTop,
|
|
124
|
+
props.paddingRight,
|
|
125
|
+
props.paddingBottom,
|
|
126
|
+
props.paddingLeft,
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const setRenderableRef = useCallback((node: unknown) => {
|
|
130
|
+
renderableRef.current = node as NativeRenderableNode | null;
|
|
131
|
+
assignRef(forwardedRef, node);
|
|
132
|
+
}, [forwardedRef]);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
let cancelled = false;
|
|
136
|
+
setKittySupport(getCachedKittySupport(renderer));
|
|
137
|
+
ensureKittySupport(renderer).then((supported) => {
|
|
138
|
+
if (!cancelled) setKittySupport(supported);
|
|
139
|
+
});
|
|
140
|
+
return () => {
|
|
141
|
+
cancelled = true;
|
|
142
|
+
};
|
|
143
|
+
}, [renderer]);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
setLoadFailed(false);
|
|
147
|
+
setBitmapState(null);
|
|
148
|
+
}, [imageSrc]);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const renderable = renderableRef.current;
|
|
152
|
+
if (!renderable || !imageSrc || kittySupport !== true) {
|
|
153
|
+
setTarget(null);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let mountTimer: Timer | null = null;
|
|
158
|
+
const previousLifecyclePass = renderable.onLifecyclePass;
|
|
159
|
+
const syncTarget = () => {
|
|
160
|
+
const outerRect = getRenderableCellRect(renderable);
|
|
161
|
+
const rect = insetRect(outerRect, insets);
|
|
162
|
+
if (!rect || !renderer.resolution || renderer.terminalWidth <= 0 || renderer.terminalHeight <= 0) {
|
|
163
|
+
setTarget((current) => (current === null ? current : null));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const outerVisibleRect = resolveNativeSurfaceVisibleRect(renderable, renderer.terminalWidth, renderer.terminalHeight);
|
|
168
|
+
const visibleRect = outerVisibleRect ? intersectCellRects(rect, outerVisibleRect) : null;
|
|
169
|
+
const bitmapSize = computeBitmapSize(rect, renderer.resolution, renderer.terminalWidth, renderer.terminalHeight);
|
|
170
|
+
const bitmapKey = `${imageSrc}\n${resolvedObjectFit}\n${bitmapSize.pixelWidth}x${bitmapSize.pixelHeight}`;
|
|
171
|
+
const nextTarget: SurfaceTarget = {
|
|
172
|
+
rect,
|
|
173
|
+
visibleRect,
|
|
174
|
+
pixelWidth: bitmapSize.pixelWidth,
|
|
175
|
+
pixelHeight: bitmapSize.pixelHeight,
|
|
176
|
+
bitmapKey,
|
|
177
|
+
};
|
|
178
|
+
setTarget((current) => (sameTarget(current, nextTarget) ? current : nextTarget));
|
|
179
|
+
};
|
|
180
|
+
const lifecyclePass = () => {
|
|
181
|
+
previousLifecyclePass?.();
|
|
182
|
+
syncTarget();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
renderable.onLifecyclePass = lifecyclePass;
|
|
186
|
+
renderer.registerLifecyclePass(renderable);
|
|
187
|
+
syncTarget();
|
|
188
|
+
mountTimer = setTimeout(() => {
|
|
189
|
+
syncTarget();
|
|
190
|
+
renderer.requestRender();
|
|
191
|
+
}, 0);
|
|
192
|
+
|
|
193
|
+
return () => {
|
|
194
|
+
if (mountTimer) clearTimeout(mountTimer);
|
|
195
|
+
if (renderable.onLifecyclePass === lifecyclePass) {
|
|
196
|
+
renderable.onLifecyclePass = previousLifecyclePass;
|
|
197
|
+
}
|
|
198
|
+
renderer.unregisterLifecyclePass(renderable);
|
|
199
|
+
setTarget(null);
|
|
200
|
+
};
|
|
201
|
+
}, [imageSrc, insets, kittySupport, renderer, resolvedObjectFit]);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (!target || !imageSrc || kittySupport !== true) {
|
|
205
|
+
setBitmapState(null);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let cancelled = false;
|
|
210
|
+
setBitmapState((current) => (current?.key === target.bitmapKey ? current : null));
|
|
211
|
+
loadOpenTuiImageBitmap(imageSrc, {
|
|
212
|
+
width: target.pixelWidth,
|
|
213
|
+
height: target.pixelHeight,
|
|
214
|
+
objectFit: resolvedObjectFit,
|
|
215
|
+
}).then((bitmap) => {
|
|
216
|
+
if (!cancelled) {
|
|
217
|
+
setLoadFailed(false);
|
|
218
|
+
setBitmapState({ key: target.bitmapKey, bitmap });
|
|
219
|
+
}
|
|
220
|
+
}).catch(() => {
|
|
221
|
+
if (!cancelled) {
|
|
222
|
+
setLoadFailed(true);
|
|
223
|
+
setBitmapState(null);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return () => {
|
|
228
|
+
cancelled = true;
|
|
229
|
+
};
|
|
230
|
+
}, [imageSrc, kittySupport, resolvedObjectFit, target]);
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
return () => {
|
|
234
|
+
nativeSurfaceManager.removeSurface(surfaceId);
|
|
235
|
+
};
|
|
236
|
+
}, [nativeSurfaceManager, surfaceId]);
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (kittySupport !== true
|
|
240
|
+
|| loadFailed
|
|
241
|
+
|| !target?.visibleRect
|
|
242
|
+
|| !bitmapState
|
|
243
|
+
|| bitmapState.key !== target.bitmapKey) {
|
|
244
|
+
nativeSurfaceManager.removeSurface(surfaceId);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
nativeSurfaceManager.upsertSurface({
|
|
249
|
+
id: surfaceId,
|
|
250
|
+
paneId: paneId ?? "__global__",
|
|
251
|
+
rect: target.rect,
|
|
252
|
+
visibleRect: target.visibleRect,
|
|
253
|
+
bitmap: bitmapState.bitmap,
|
|
254
|
+
bitmapKey: bitmapState.key,
|
|
255
|
+
});
|
|
256
|
+
renderer.requestRender();
|
|
257
|
+
}, [bitmapState, kittySupport, loadFailed, nativeSurfaceManager, paneId, renderer, surfaceId, target]);
|
|
258
|
+
|
|
259
|
+
const showFallback = kittySupport !== true
|
|
260
|
+
|| loadFailed
|
|
261
|
+
|| !target
|
|
262
|
+
|| !bitmapState
|
|
263
|
+
|| bitmapState.key !== target.bitmapKey;
|
|
264
|
+
|
|
265
|
+
return (createElement as any)("box", { ...props, ref: setRenderableRef }, showFallback ? children : null);
|
|
266
|
+
});
|
|
@@ -1,14 +1,53 @@
|
|
|
1
|
-
import { useMemo, type ReactNode } from "react";
|
|
2
|
-
import { InputHostProvider, type InputHost } from "../../react/input";
|
|
1
|
+
import { useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
|
|
2
|
+
import { InputHostProvider, type InputHost, type KeyEventLike, type ShortcutOptions } from "../../react/input";
|
|
3
3
|
import { toKeyEventLike, useKeyboard, useTerminalDimensions } from "./host";
|
|
4
4
|
|
|
5
|
+
interface ShortcutEntry {
|
|
6
|
+
handlerRef: { current: (event: KeyEventLike) => void };
|
|
7
|
+
enabledRef: { current: boolean };
|
|
8
|
+
phase: NonNullable<ShortcutOptions["phase"]>;
|
|
9
|
+
order: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let nextShortcutOrder = 1;
|
|
13
|
+
|
|
5
14
|
export function OpenTuiInputHostProvider({ children }: { children: ReactNode }) {
|
|
15
|
+
const shortcutsRef = useRef<ShortcutEntry[]>([]);
|
|
16
|
+
const dispatchShortcut = (event: KeyEventLike) => {
|
|
17
|
+
const shortcuts = shortcutsRef.current;
|
|
18
|
+
for (const phase of ["normal", "after"] as const) {
|
|
19
|
+
if (phase === "after" && (event.defaultPrevented || event.propagationStopped)) return;
|
|
20
|
+
for (const entry of shortcuts) {
|
|
21
|
+
if (entry.phase !== phase || !entry.enabledRef.current) continue;
|
|
22
|
+
entry.handlerRef.current(event);
|
|
23
|
+
if (event.propagationStopped) return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
useKeyboard((event) => {
|
|
29
|
+
dispatchShortcut(toKeyEventLike(event));
|
|
30
|
+
});
|
|
31
|
+
|
|
6
32
|
const host = useMemo<InputHost>(() => ({
|
|
7
33
|
useShortcut(handler, options) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
34
|
+
const handlerRef = useRef(handler);
|
|
35
|
+
const enabledRef = useRef(options?.enabled !== false);
|
|
36
|
+
handlerRef.current = handler;
|
|
37
|
+
enabledRef.current = options?.enabled !== false;
|
|
38
|
+
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
const entry: ShortcutEntry = {
|
|
41
|
+
handlerRef,
|
|
42
|
+
enabledRef,
|
|
43
|
+
phase: options?.phase ?? "normal",
|
|
44
|
+
order: nextShortcutOrder++,
|
|
45
|
+
};
|
|
46
|
+
shortcutsRef.current = [...shortcutsRef.current, entry].sort((a, b) => a.order - b.order);
|
|
47
|
+
return () => {
|
|
48
|
+
shortcutsRef.current = shortcutsRef.current.filter((current) => current !== entry);
|
|
49
|
+
};
|
|
50
|
+
}, [options?.phase, options?.scope]);
|
|
12
51
|
},
|
|
13
52
|
useViewport() {
|
|
14
53
|
const dimensions = useTerminalDimensions();
|
|
@@ -3,6 +3,7 @@ import { createElement } from "react";
|
|
|
3
3
|
import "opentui-spinner/react";
|
|
4
4
|
import { TextAttributes, type UiHost, type TextProps } from "../../ui/host";
|
|
5
5
|
import { renderAsciiText } from "../../ui/ascii-font";
|
|
6
|
+
import { OpenTuiImageSurface } from "./image-surface";
|
|
6
7
|
|
|
7
8
|
function mapTextAttributes(appAttributes: number | undefined, props?: TextProps): number | undefined {
|
|
8
9
|
const flags = typeof appAttributes === "number" ? appAttributes : 0;
|
|
@@ -66,7 +67,7 @@ export const openTuiUiHost: UiHost = {
|
|
|
66
67
|
Input: ({ children, ...props }) => createElement("input" as any, props, children),
|
|
67
68
|
Textarea: ({ children, ...props }) => createElement("textarea" as any, props, children),
|
|
68
69
|
ChartSurface: ({ children, bitmap: _bitmap, bitmaps: _bitmaps, crosshair: _crosshair, ...props }) => <box {...props}>{children}</box>,
|
|
69
|
-
ImageSurface:
|
|
70
|
+
ImageSurface: OpenTuiImageSurface,
|
|
70
71
|
SpinnerMark: ({ children, ...props }) => createElement("spinner" as any, props, children),
|
|
71
72
|
AsciiText: ({ text, font = "tiny", color, fg, bg, backgroundColor, selectable = false, ...props }) => {
|
|
72
73
|
const resolvedColor = color ?? fg;
|
|
@@ -183,6 +183,10 @@ export function usePaneInstanceId(): string {
|
|
|
183
183
|
return paneId;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
export function useOptionalPaneInstanceId(): string | null {
|
|
187
|
+
return useContext(PaneContext);
|
|
188
|
+
}
|
|
189
|
+
|
|
186
190
|
export function usePaneInstance(): PaneInstanceConfig | null {
|
|
187
191
|
const paneId = useContext(PaneContext);
|
|
188
192
|
return useAppSelector((state) => (paneId ? findPaneInstance(state.config.layout, paneId) ?? null : null));
|