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.
@@ -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
- useKeyboard((event) => {
9
- if (options?.enabled === false) return;
10
- handler(toKeyEventLike(event));
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: ({ children, src: _src, alt: _alt, objectFit: _objectFit, ...props }) => <box {...props}>{children}</box>,
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));