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.
- package/LICENSE +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +3 -5
- package/src/cli/entry.ts +11 -1
- package/src/cli/native-smoke.ts +5 -0
- package/src/components/command-bar/routes/root/provider-search.ts +7 -3
- package/src/components/command-bar/routes/ticker-search/results.test.ts +34 -0
- package/src/components/command-bar/routes/ticker-search/results.ts +4 -6
- package/src/components/command-bar/routes/ticker-search/route.ts +2 -2
- package/src/components/command-bar/routing/list-state.ts +2 -1
- package/src/components/command-bar/surface/index.test.tsx +65 -0
- package/src/components/command-bar/view-model.test.ts +24 -0
- package/src/components/command-bar/view-model.ts +2 -1
- package/src/plugins/builtin/econ/fred-cache.test.ts +8 -1
- package/src/plugins/builtin/help/index.tsx +1 -1
- package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
- package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
- package/src/renderers/opentui/dialog-host.tsx +279 -80
- package/src/renderers/opentui/test-utils.tsx +9 -6
- package/src/renderers/opentui/toast-host.tsx +136 -7
- package/src/renderers/opentui/ui-host.tsx +19 -3
- package/src/tickers/search/index.test.ts +284 -2
- package/src/tickers/search/index.ts +55 -18
- package/src/tickers/search/ranking.ts +143 -18
- package/src/tickers/search/types.ts +1 -0
- package/src/updater/github-releases.test.ts +3 -3
- package/src/updater/github-releases.ts +1 -1
|
@@ -1,103 +1,302 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
1
|
+
/** @jsxImportSource @opentui/react */
|
|
2
|
+
import { RGBA, type Renderable } from "@opentui/core";
|
|
3
|
+
import { flushSync, useKeyboard, useRenderer } from "@opentui/react";
|
|
3
4
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
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:
|
|
55
|
+
? (content as (context: AlertContext | PromptContext<unknown>) => ReactNode)(context)
|
|
17
56
|
: content as ReactNode;
|
|
18
57
|
}
|
|
19
58
|
|
|
20
|
-
function
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
88
|
+
dimensions,
|
|
89
|
+
index,
|
|
90
|
+
isTopmost,
|
|
28
91
|
}: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
...
|
|
92
|
-
}: {
|
|
93
|
-
|
|
94
|
-
[
|
|
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
|
-
<
|
|
98
|
-
<
|
|
284
|
+
<DialogHostProvider dialog={api} isOpen={dialogs.length > 0}>
|
|
285
|
+
<box position="relative" width={dimensions.width} height={dimensions.height}>
|
|
99
286
|
{children}
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
2
|
-
import
|
|
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:
|
|
6
|
-
success:
|
|
7
|
-
error:
|
|
8
|
-
info:
|
|
9
|
-
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, {
|