gloomberb 0.10.4 → 0.10.5

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.
@@ -1,103 +1,302 @@
1
- import type { ReactNode } from "react";
2
- import { useMemo } from "react";
1
+ /** @jsxImportSource @opentui/react */
2
+ import { RGBA, type Renderable } from "@opentui/core";
3
+ import { flushSync, useKeyboard, useRenderer } from "@opentui/react";
3
4
  import {
4
- DialogProvider as OpenTuiDialogProvider,
5
- type AlertContext as OpenTuiAlertContext,
6
- type AlertOptions as OpenTuiAlertOptions,
7
- type PromptContext as OpenTuiPromptContext,
8
- type PromptOptions as OpenTuiPromptOptions,
9
- useDialog as useOpenTuiDialog,
10
- useDialogState as useOpenTuiDialogState,
11
- } from "@opentui-ui/dialog/react";
12
- import { DialogHostProvider, type DialogApi } from "../../ui/dialog";
13
-
14
- function renderDialogContent(content: unknown, context: Record<string, unknown>): ReactNode {
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { colors } from "../../theme/colors";
13
+ import {
14
+ DialogHostProvider,
15
+ type AlertContext,
16
+ type DialogApi,
17
+ type PromptContext,
18
+ } from "../../ui/dialog";
19
+
20
+ type DialogSize = "small" | "medium" | "large" | "full";
21
+
22
+ interface DialogStyle {
23
+ width?: number;
24
+ maxWidth?: number;
25
+ maxHeight?: number;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ interface DialogRecord {
30
+ id: string;
31
+ content: unknown;
32
+ kind: "alert" | "prompt";
33
+ size?: DialogSize;
34
+ style?: DialogStyle;
35
+ closeOnEscape?: boolean;
36
+ closeOnClickOutside?: boolean;
37
+ settle(value: unknown): void;
38
+ }
39
+
40
+ export interface OpenTuiDialogHostProviderProps {
41
+ children: ReactNode;
42
+ size?: DialogSize;
43
+ sizePresets?: Partial<Record<DialogSize, number>>;
44
+ closeOnEscape?: boolean;
45
+ closeOnClickOutside?: boolean;
46
+ dialogOptions?: { style?: DialogStyle };
47
+ backdropColor?: string;
48
+ backdropOpacity?: number;
49
+ }
50
+
51
+ let nextDialogId = 1;
52
+
53
+ function renderDialogContent(content: unknown, context: AlertContext | PromptContext<unknown>): ReactNode {
15
54
  return typeof content === "function"
16
- ? (content as (context: Record<string, unknown>) => ReactNode)(context)
55
+ ? (content as (context: AlertContext | PromptContext<unknown>) => ReactNode)(context)
17
56
  : content as ReactNode;
18
57
  }
19
58
 
20
- function normalizeDialogId(value: unknown): string | undefined {
21
- return typeof value === "string" || typeof value === "number" ? String(value) : undefined;
59
+ function dialogWidth(
60
+ size: DialogSize,
61
+ terminalWidth: number,
62
+ sizePresets: OpenTuiDialogHostProviderProps["sizePresets"],
63
+ ): number {
64
+ const customWidth = sizePresets?.[size];
65
+ const requestedWidth = customWidth && customWidth > 0
66
+ ? customWidth
67
+ : size === "small"
68
+ ? 40
69
+ : size === "large"
70
+ ? 80
71
+ : size === "full"
72
+ ? terminalWidth - 4
73
+ : 60;
74
+ return Math.max(1, Math.min(requestedWidth, terminalWidth - 2));
22
75
  }
23
76
 
24
- function OpenTuiDialogContentBridge({
77
+ function backdrop(color: string, opacity: number): RGBA {
78
+ const value = RGBA.fromHex(color);
79
+ value.a = Math.max(0, Math.min(opacity, 1));
80
+ return value;
81
+ }
82
+
83
+ function DialogLayer({
84
+ api,
85
+ close,
86
+ containerOptions,
25
87
  dialog,
26
- dialogId,
27
- children,
88
+ dimensions,
89
+ index,
90
+ isTopmost,
28
91
  }: {
29
- dialog: DialogApi;
30
- dialogId: string | undefined;
31
- children: ReactNode;
92
+ api: DialogApi;
93
+ close(id: string, value?: unknown): void;
94
+ containerOptions: Omit<OpenTuiDialogHostProviderProps, "children">;
95
+ dialog: DialogRecord;
96
+ dimensions: { width: number; height: number };
97
+ index: number;
98
+ isTopmost: boolean;
32
99
  }) {
33
- const isTopmost = useOpenTuiDialogState(
34
- (state) => normalizeDialogId(state.topDialog?.id) === dialogId,
35
- );
36
- return (
37
- <DialogHostProvider
38
- dialog={dialog}
39
- isOpen={true}
40
- dialogId={dialogId}
41
- keyboardEnabled={isTopmost}
42
- >
43
- {children}
44
- </DialogHostProvider>
45
- );
46
- }
100
+ const effectiveSize = dialog.size ?? containerOptions.size ?? "medium";
101
+ const providerStyle = containerOptions.dialogOptions?.style ?? {};
102
+ const style = { ...providerStyle, ...dialog.style };
103
+ const viewportWidth = Math.max(1, dimensions.width - 2);
104
+ const viewportHeight = Math.max(1, dimensions.height - 2);
105
+ const width = typeof style.width === "number"
106
+ ? Math.max(1, Math.min(style.width, viewportWidth))
107
+ : dialogWidth(effectiveSize, dimensions.width, containerOptions.sizePresets);
108
+ const maxWidth = typeof style.maxWidth === "number"
109
+ ? Math.max(1, Math.min(style.maxWidth, viewportWidth))
110
+ : viewportWidth;
111
+ const maxHeight = typeof style.maxHeight === "number"
112
+ ? Math.max(1, Math.min(style.maxHeight, viewportHeight))
113
+ : viewportHeight;
114
+ const closeOnEscape = dialog.closeOnEscape ?? containerOptions.closeOnEscape ?? true;
115
+ const closeOnClickOutside = dialog.closeOnClickOutside
116
+ ?? containerOptions.closeOnClickOutside
117
+ ?? false;
118
+ const zIndex = 9_998 + index * 2;
119
+
120
+ useKeyboard((event) => {
121
+ if (!isTopmost || !closeOnEscape || event.name !== "escape") return;
122
+ event.preventDefault?.();
123
+ event.stopPropagation?.();
124
+ close(dialog.id);
125
+ });
47
126
 
48
- function OpenTuiDialogBridge({ children }: { children: ReactNode }) {
49
- const openTuiDialog = useOpenTuiDialog();
50
- const isOpen = useOpenTuiDialogState((state) => state.isOpen);
51
- const dialog = useMemo<DialogApi>(() => {
52
- const wrapAlertOptions = (options: Record<string, unknown>): OpenTuiAlertOptions => ({
53
- ...options,
54
- content: (context: OpenTuiAlertContext) => {
55
- const dialogId = normalizeDialogId(context.dialogId);
56
- return (
57
- <OpenTuiDialogContentBridge dialog={dialog} dialogId={dialogId}>
58
- {renderDialogContent(options.content, { ...context, dialogId })}
59
- </OpenTuiDialogContentBridge>
60
- );
61
- },
62
- });
63
-
64
- const wrapPromptOptions = <T,>(options: Record<string, unknown>): OpenTuiPromptOptions<T> => ({
65
- ...options,
66
- content: (context: OpenTuiPromptContext<T>) => {
67
- const dialogId = normalizeDialogId(context.dialogId);
68
- return (
69
- <OpenTuiDialogContentBridge dialog={dialog} dialogId={dialogId}>
70
- {renderDialogContent(options.content, { ...context, dialogId })}
71
- </OpenTuiDialogContentBridge>
72
- );
73
- },
74
- });
75
-
76
- return {
77
- alert: (options) => openTuiDialog.alert(wrapAlertOptions(options)),
78
- prompt: <T,>(options: Record<string, unknown>) => openTuiDialog.prompt<T>(wrapPromptOptions<T>(options)),
127
+ const context = dialog.kind === "prompt"
128
+ ? {
129
+ dialogId: dialog.id,
130
+ dismiss: () => close(dialog.id),
131
+ resolve: (value: unknown) => close(dialog.id, value),
132
+ }
133
+ : {
134
+ dialogId: dialog.id,
135
+ dismiss: () => close(dialog.id),
79
136
  };
80
- }, [openTuiDialog]);
81
137
 
82
138
  return (
83
- <DialogHostProvider dialog={dialog} isOpen={isOpen}>
84
- {children}
85
- </DialogHostProvider>
139
+ <>
140
+ <box
141
+ position="absolute"
142
+ left={0}
143
+ top={0}
144
+ width={dimensions.width}
145
+ height={dimensions.height}
146
+ zIndex={zIndex}
147
+ backgroundColor={backdrop(
148
+ containerOptions.backdropColor ?? colors.bg,
149
+ containerOptions.backdropOpacity ?? 0.8,
150
+ )}
151
+ />
152
+ <box
153
+ position="absolute"
154
+ left={0}
155
+ top={0}
156
+ width={dimensions.width}
157
+ height={dimensions.height}
158
+ zIndex={zIndex + 1}
159
+ alignItems="center"
160
+ justifyContent="center"
161
+ onMouseDown={() => {
162
+ if (isTopmost && closeOnClickOutside) close(dialog.id);
163
+ }}
164
+ >
165
+ <box
166
+ {...style as any}
167
+ width={width}
168
+ maxWidth={maxWidth}
169
+ maxHeight={maxHeight}
170
+ border={style.border ?? true}
171
+ borderStyle={style.borderStyle ?? "single"}
172
+ borderColor={style.borderColor ?? colors.borderFocused}
173
+ backgroundColor={style.backgroundColor ?? colors.bg}
174
+ paddingX={style.paddingX ?? 2}
175
+ paddingY={style.paddingY ?? 1}
176
+ onMouseDown={(event: { stopPropagation(): void }) => event.stopPropagation()}
177
+ >
178
+ <DialogHostProvider
179
+ dialog={api}
180
+ isOpen={true}
181
+ dialogId={dialog.id}
182
+ keyboardEnabled={isTopmost}
183
+ >
184
+ {renderDialogContent(dialog.content, context)}
185
+ </DialogHostProvider>
186
+ </box>
187
+ </box>
188
+ </>
86
189
  );
87
190
  }
88
191
 
89
192
  export function OpenTuiDialogHostProvider({
90
193
  children,
91
- ...props
92
- }: {
93
- children: ReactNode;
94
- [key: string]: unknown;
95
- }) {
194
+ ...containerOptions
195
+ }: OpenTuiDialogHostProviderProps) {
196
+ const renderer = useRenderer();
197
+ const [dimensions, setDimensions] = useState(() => ({
198
+ width: renderer.width,
199
+ height: renderer.height,
200
+ }));
201
+ const [dialogs, setDialogs] = useState<DialogRecord[]>([]);
202
+ const dialogsRef = useRef<DialogRecord[]>([]);
203
+ const savedFocusRef = useRef<Renderable | null>(null);
204
+ const mountedRef = useRef(true);
205
+
206
+ const publish = useCallback((next: DialogRecord[]) => {
207
+ dialogsRef.current = next;
208
+ if (mountedRef.current) flushSync(() => setDialogs(next));
209
+ }, []);
210
+
211
+ const close = useCallback((id: string, value?: unknown) => {
212
+ const current = dialogsRef.current;
213
+ const target = current.find((dialog) => dialog.id === id);
214
+ if (!target) return;
215
+ const next = current.filter((dialog) => dialog.id !== id);
216
+ publish(next);
217
+ target.settle(value);
218
+ if (next.length === 0) {
219
+ const savedFocus = savedFocusRef.current;
220
+ savedFocusRef.current = null;
221
+ queueMicrotask(() => {
222
+ try {
223
+ savedFocus?.focus();
224
+ } catch {
225
+ // The previously focused renderable may have unmounted with the dialog.
226
+ }
227
+ });
228
+ }
229
+ }, [publish]);
230
+
231
+ const open = useCallback(<T,>(kind: DialogRecord["kind"], options: Record<string, unknown>) => (
232
+ new Promise<T | undefined>((resolve) => {
233
+ if (dialogsRef.current.length === 0) {
234
+ savedFocusRef.current = renderer.currentFocusedRenderable;
235
+ savedFocusRef.current?.blur();
236
+ }
237
+ let settled = false;
238
+ const record: DialogRecord = {
239
+ id: `gloom-dialog-${nextDialogId++}`,
240
+ kind,
241
+ content: options.content,
242
+ size: options.size as DialogSize | undefined,
243
+ style: options.style as DialogStyle | undefined,
244
+ closeOnEscape: options.closeOnEscape as boolean | undefined,
245
+ closeOnClickOutside: options.closeOnClickOutside as boolean | undefined,
246
+ settle(value) {
247
+ if (settled) return;
248
+ settled = true;
249
+ resolve(value as T | undefined);
250
+ },
251
+ };
252
+ publish([...dialogsRef.current, record]);
253
+ })
254
+ ), [publish, renderer]);
255
+
256
+ const api = useMemo<DialogApi>(() => ({
257
+ alert: async (options) => {
258
+ await open<void>("alert", options);
259
+ },
260
+ prompt: <T,>(options: Record<string, unknown>) => open<T>("prompt", options),
261
+ }), [open]);
262
+
263
+ useEffect(() => {
264
+ const updateDimensions = () => {
265
+ setDimensions({ width: renderer.width, height: renderer.height });
266
+ };
267
+ renderer.on("resize", updateDimensions);
268
+ return () => {
269
+ renderer.off("resize", updateDimensions);
270
+ };
271
+ }, [renderer]);
272
+
273
+ useEffect(() => {
274
+ mountedRef.current = true;
275
+ return () => {
276
+ mountedRef.current = false;
277
+ for (const dialog of dialogsRef.current) dialog.settle(undefined);
278
+ dialogsRef.current = [];
279
+ savedFocusRef.current = null;
280
+ };
281
+ }, []);
282
+
96
283
  return (
97
- <OpenTuiDialogProvider {...props}>
98
- <OpenTuiDialogBridge>
284
+ <DialogHostProvider dialog={api} isOpen={dialogs.length > 0}>
285
+ <box position="relative" width={dimensions.width} height={dimensions.height}>
99
286
  {children}
100
- </OpenTuiDialogBridge>
101
- </OpenTuiDialogProvider>
287
+ {dialogs.map((dialog, index) => (
288
+ <DialogLayer
289
+ key={dialog.id}
290
+ api={api}
291
+ close={close}
292
+ containerOptions={containerOptions}
293
+ dialog={dialog}
294
+ dimensions={dimensions}
295
+ index={index}
296
+ isTopmost={index === dialogs.length - 1}
297
+ />
298
+ ))}
299
+ </box>
300
+ </DialogHostProvider>
102
301
  );
103
302
  }
@@ -1,5 +1,4 @@
1
1
  import { useMemo, type ReactNode } from "react";
2
- import { DialogProvider } from "@opentui-ui/dialog/react";
3
2
  import { testRender as openTuiTestRender } from "@opentui/react/test-utils";
4
3
  import { createRoot as openTuiCreateRoot, useRenderer } from "@opentui/react";
5
4
  import { UiHostProvider, type NativeRendererHost, type RendererHost } from "../../ui";
@@ -12,7 +11,7 @@ import { openTuiToastHost } from "./toast-host";
12
11
 
13
12
  export function TestDialogProvider({ children }: { children: ReactNode }) {
14
13
  return (
15
- <DialogProvider
14
+ <OpenTuiDialogHostProvider
16
15
  dialogOptions={{
17
16
  style: {
18
17
  backgroundColor: colors.bg,
@@ -22,7 +21,7 @@ export function TestDialogProvider({ children }: { children: ReactNode }) {
22
21
  }}
23
22
  >
24
23
  {children}
25
- </DialogProvider>
24
+ </OpenTuiDialogHostProvider>
26
25
  );
27
26
  }
28
27
 
@@ -55,9 +54,13 @@ function OpenTuiTestProviders({ children }: { children: ReactNode }) {
55
54
  <OpenTuiInputHostProvider>
56
55
  <ToastHostProvider host={openTuiToastHost}>
57
56
  <OpenTuiDialogHostProvider
58
- backgroundColor={colors.bg}
59
- containerBorderColor={colors.border}
60
- focusedBorderColor={colors.borderFocused}
57
+ dialogOptions={{
58
+ style: {
59
+ backgroundColor: colors.bg,
60
+ borderColor: colors.borderFocused,
61
+ borderStyle: "single",
62
+ },
63
+ }}
61
64
  >
62
65
  {children}
63
66
  </OpenTuiDialogHostProvider>
@@ -1,10 +1,139 @@
1
- import { Toaster, toast } from "@opentui-ui/toast/react";
2
- import type { ToastHost } from "../../ui/toast";
1
+ /** @jsxImportSource @opentui/react */
2
+ import { useTerminalDimensions } from "@opentui/react";
3
+ import { useSyncExternalStore } from "react";
4
+ import { colors } from "../../theme/colors";
5
+ import type { ToastHost, ToastOptions } from "../../ui/toast";
6
+
7
+ type ToastTone = "success" | "error" | "info";
8
+
9
+ interface ToastRecord {
10
+ id: number;
11
+ body: string;
12
+ tone: ToastTone;
13
+ options?: ToastOptions;
14
+ }
15
+
16
+ let nextToastId = 1;
17
+ let toasts: ToastRecord[] = [];
18
+ const listeners = new Set<() => void>();
19
+ const timers = new Map<number, ReturnType<typeof setTimeout>>();
20
+
21
+ function publish(): void {
22
+ for (const listener of listeners) listener();
23
+ }
24
+
25
+ function dismissToast(id: string | number): void {
26
+ const numericId = typeof id === "number" ? id : Number(id);
27
+ if (!Number.isFinite(numericId)) return;
28
+ const timer = timers.get(numericId);
29
+ if (timer) clearTimeout(timer);
30
+ timers.delete(numericId);
31
+ const next = toasts.filter((toast) => toast.id !== numericId);
32
+ if (next.length === toasts.length) return;
33
+ toasts = next;
34
+ publish();
35
+ }
36
+
37
+ function addToast(tone: ToastTone, body: string, options?: ToastOptions): number {
38
+ const id = nextToastId++;
39
+ toasts = [...toasts, { id, body, tone, options }];
40
+ publish();
41
+ const duration = options?.duration ?? 4_000;
42
+ if (Number.isFinite(duration) && duration > 0) {
43
+ timers.set(id, setTimeout(() => dismissToast(id), duration));
44
+ }
45
+ return id;
46
+ }
47
+
48
+ function subscribe(listener: () => void): () => void {
49
+ listeners.add(listener);
50
+ return () => listeners.delete(listener);
51
+ }
52
+
53
+ function getSnapshot(): ToastRecord[] {
54
+ return toasts;
55
+ }
56
+
57
+ function toneColor(tone: ToastTone): string {
58
+ if (tone === "success") return colors.positive;
59
+ if (tone === "error") return colors.negative;
60
+ return colors.neutral;
61
+ }
62
+
63
+ function toneIcon(tone: ToastTone): string {
64
+ if (tone === "success") return "✓";
65
+ if (tone === "error") return "!";
66
+ return "i";
67
+ }
68
+
69
+ function ToastViewport({ position = "bottom-right" }: { position?: string }) {
70
+ const dimensions = useTerminalDimensions();
71
+ const visibleToasts = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
72
+ const maxWidth = Math.max(1, Math.min(60, dimensions.width - 4));
73
+ const placement = position.startsWith("top") ? { top: 1 } : { bottom: 1 };
74
+ const horizontal = position.endsWith("left")
75
+ ? { left: 2 }
76
+ : position.endsWith("center")
77
+ ? { left: Math.max(0, Math.floor((dimensions.width - maxWidth) / 2)) }
78
+ : { right: 2 };
79
+
80
+ if (visibleToasts.length === 0) return null;
81
+
82
+ return (
83
+ <box
84
+ position="absolute"
85
+ {...placement}
86
+ {...horizontal}
87
+ width={maxWidth}
88
+ zIndex={9_900}
89
+ flexDirection="column"
90
+ gap={1}
91
+ >
92
+ {visibleToasts.slice(-4).map((toast) => (
93
+ <box
94
+ key={toast.id}
95
+ width="100%"
96
+ border
97
+ borderStyle="single"
98
+ borderColor={toneColor(toast.tone)}
99
+ backgroundColor={colors.panel}
100
+ paddingX={1}
101
+ flexDirection="row"
102
+ gap={1}
103
+ >
104
+ <text fg={toneColor(toast.tone)}>{toneIcon(toast.tone)}</text>
105
+ <text fg={colors.text} flexGrow={1} wrapMode="word">{toast.body}</text>
106
+ {toast.options?.action && (
107
+ <text
108
+ fg={colors.textBright}
109
+ attributes={1}
110
+ onMouseDown={(event) => {
111
+ event.stopPropagation();
112
+ toast.options?.action?.onClick();
113
+ }}
114
+ >
115
+ {`[${toast.options.action.label}]`}
116
+ </text>
117
+ )}
118
+ <text
119
+ fg={colors.textMuted}
120
+ onMouseDown={(event) => {
121
+ event.stopPropagation();
122
+ dismissToast(toast.id);
123
+ }}
124
+ >
125
+ ×
126
+ </text>
127
+ </box>
128
+ ))}
129
+ </box>
130
+ );
131
+ }
3
132
 
4
133
  export const openTuiToastHost: ToastHost = {
5
- Viewport: Toaster as ToastHost["Viewport"],
6
- success: toast.success,
7
- error: toast.error,
8
- info: toast.info,
9
- dismiss: toast.dismiss,
134
+ Viewport: ToastViewport,
135
+ success: (body, options) => addToast("success", body, options),
136
+ error: (body, options) => addToast("error", body, options),
137
+ info: (body, options) => addToast("info", body, options),
138
+ dismiss: dismissToast,
10
139
  };
@@ -1,6 +1,5 @@
1
1
  import { RGBA, StyledText as OpenTuiStyledText, SyntaxStyle, TextAttributes as OpenTuiTextAttributes } from "@opentui/core";
2
- import { createElement, forwardRef, type ReactNode } from "react";
3
- import "opentui-spinner/react";
2
+ import { createElement, forwardRef, useEffect, useState, type ReactNode } from "react";
4
3
  import { TextAttributes, type UiHost, type TextProps } from "../../ui/host";
5
4
  import { renderAsciiText } from "../../ui/ascii-font";
6
5
  import { OpenTuiImageSurface } from "./image/surface";
@@ -60,9 +59,26 @@ const OpenTuiBox = createOpenTuiPrimitive("box");
60
59
  const OpenTuiScrollBox = createOpenTuiPrimitive("scrollbox");
61
60
  const OpenTuiInput = createOpenTuiPrimitive("input");
62
61
  const OpenTuiTextarea = createOpenTuiPrimitive("textarea");
63
- const OpenTuiSpinnerMark = createOpenTuiPrimitive("spinner");
64
62
  const OpenTuiMediaSurface = createOpenTuiPrimitive("box");
65
63
 
64
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
65
+
66
+ const OpenTuiSpinnerMark = forwardRef<unknown, OpenTuiPrimitiveProps & { name?: string; color?: string }>(
67
+ function OpenTuiSpinnerMark({ name: _name, color, ...props }, ref) {
68
+ const [frame, setFrame] = useState(0);
69
+ useEffect(() => {
70
+ const timer = setInterval(() => setFrame((current) => (current + 1) % SPINNER_FRAMES.length), 80);
71
+ return () => clearInterval(timer);
72
+ }, []);
73
+ return createElement("text" as any, {
74
+ ...props,
75
+ ref,
76
+ fg: color,
77
+ content: SPINNER_FRAMES[frame],
78
+ });
79
+ },
80
+ );
81
+
66
82
  const OpenTuiText = forwardRef<unknown, TextProps>(function OpenTuiText({ children, ...props }, ref) {
67
83
  const textProps = stripTextProps(props);
68
84
  return createElement("text" as any, {