1/** @jsxImportSource @alchemy.run/sigil */
2import { useAnimation } from "@alchemy.run/sigil";
3import type { ReactNode } from "react";
4import { stringWidth } from "@alchemy.run/sigil/ansi";
5import {
6 spinnerFramesFor,
7 statusColor,
8 theme,
9 type StatusVariant,
10} from "../../../Util/Theme.ts";
11import {
12 useBorderStyle,
13 useCliEnvironment,
14 useGlyphs,
15} from "./Environment.tsx";
16import { Box, tabsWindow } from "./Layout.tsx";
17import { Text } from "./Typography.tsx";
18
19export interface StatusProps {
20 readonly variant?: StatusVariant;
21 readonly children?: ReactNode;
22 readonly detail?: ReactNode;
23}
24
25export function Status({ variant = "info", children, detail }: StatusProps) {
26 const glyphs = useGlyphs();
27 return (
28 <Box gap={1} flexWrap="wrap">
29 <Text color={statusColor(variant)}>{glyphs[variant]}</Text>
30 <Text color={variant === "error" ? statusColor(variant) : undefined}>
31 {children}
32 </Text>
33 {detail === undefined ? null : <Text tone="muted">· {detail}</Text>}
34 </Box>
35 );
36}
37
38export type ToastProps = StatusProps;
39
40/**
41 * Compact application notice. Severity is carried by the status glyph and
42 * its colour alone — the same vocabulary as transcript lines — so a notice
43 * row never needs a rail of its own.
44 */
45export function Toast({ variant = "info", children, detail }: ToastProps) {
46 return (
47 <Status variant={variant} detail={detail}>
48 {children}
49 </Status>
50 );
51}
52
53export interface AlertProps extends StatusProps {
54 readonly title?: ReactNode;
55}
56
57/** Glyph + bold title on one row, body indented beneath it. */
58export function Alert({
59 variant = "info",
60 title,
61 children,
62 detail,
63}: AlertProps) {
64 const glyphs = useGlyphs();
65 return (
66 <Box flexDirection="column">
67 <Box gap={1} alignItems="center">
68 <Text bold color={statusColor(variant)}>
69 {glyphs[variant]}
70 </Text>
71 {title === undefined ? null : <Text bold>{title}</Text>}
72 {detail === undefined ? null : <Text tone="muted">· {detail}</Text>}
73 </Box>
74 <Box paddingLeft={theme.space.indent}>
75 <Text tone="muted">{children}</Text>
76 </Box>
77 </Box>
78 );
79}
80
81export interface KeyBarProps {
82 readonly keys: ReadonlyArray<readonly [key: string, label: string]>;
83 readonly marginTop?: number;
84 readonly inline?: boolean;
85 /** Widget rendered before the key hints. */
86 readonly before?: ReactNode;
87 /** Widget rendered after the key hints. */
88 readonly after?: ReactNode;
89 /** Draw border rails between populated widget/key sections. */
90 readonly divider?: boolean;
91}
92
93export function KeyBar({
94 keys,
95 marginTop = 1,
96 inline = false,
97 before,
98 after,
99 divider = false,
100}: KeyBarProps) {
101 const borderStyle = useBorderStyle();
102 const sectionBorder = {
103 borderStyle,
104 borderLeft: true,
105 borderRight: false,
106 borderTop: false,
107 borderBottom: false,
108 borderColor: theme.color.muted,
109 borderDimColor: true,
110 marginLeft: 1,
111 paddingLeft: 1,
112 } as const;
113 return (
114 <Box
115 width={inline ? undefined : "100%"}
116 flexWrap="wrap"
117 marginTop={marginTop}
118 paddingLeft={before === undefined ? theme.space.indent : 0}
119 >
120 {before === undefined ? null : <Box>{before}</Box>}
121 <Box
122 flexWrap="wrap"
123 {...(divider && before !== undefined
124 ? sectionBorder
125 : before === undefined
126 ? {}
127 : { marginLeft: 1 })}
128 >
129 {keys.map(([key, label], index) => (
130 <Box key={`${key}:${label}`}>
131 {index === 0 ? null : <Text tone="muted"> • </Text>}
132 <Text>
133 <Text bold color={theme.color.brand}>
134 {key}
135 </Text>
136 <Text tone="muted"> {label}</Text>
137 </Text>
138 </Box>
139 ))}
140 </Box>
141 {after === undefined ? null : (
142 <Box
143 {...(divider
144 ? sectionBorder
145 : {
146 marginLeft: 1,
147 })}
148 >
149 {after}
150 </Box>
151 )}
152 </Box>
153 );
154}
155
156export const useSpinnerFrame = (): string => {
157 const { unicode } = useCliEnvironment();
158 const frames = spinnerFramesFor(unicode);
159 const { frame } = useAnimation({ interval: 80 });
160 return frames[frame % frames.length] ?? "-";
161};
162
163/**
164 * Spinner-as-status-icon: one animated frame, colorable so it can stand in
165 * for a status glyph in trees and progress rows.
166 */
167type SpinnerGlyphProps = { readonly color?: string };
168
169export function SpinnerGlyph({ color }: SpinnerGlyphProps) {
170 return <Text color={color ?? theme.color.info}>{useSpinnerFrame()}</Text>;
171}
172
173type SpinnerProps = {
174 readonly label: ReactNode;
175 readonly detail?: ReactNode;
176};
177
178export function Spinner({ label, detail }: SpinnerProps) {
179 return (
180 <Box gap={1}>
181 <SpinnerGlyph />
182 <Text>{label}</Text>
183 {detail === undefined ? null : <Text tone="muted">{detail}</Text>}
184 </Box>
185 );
186}
187
188type TabsProps = {
189 readonly tabs: ReadonlyArray<{
190 readonly id: string;
191 readonly label: string;
192 readonly marked?: boolean;
193 }>;
194 readonly active: string;
195};
196
197export function Tabs({ tabs, active }: TabsProps) {
198 const glyphs = useGlyphs();
199 const { columns } = useCliEnvironment();
200 const gap = 1;
201 const activeIndex = Math.max(
202 0,
203 tabs.findIndex((tab) => tab.id === active),
204 );
205 // chip width = paddingX (2) + optional marker glyph + space + label
206 const widths = tabs.map(
207 (tab) =>
208 2 +
209 stringWidth(tab.label) +
210 (tab.marked ? stringWidth(glyphs.selected) + 1 : 0),
211 );
212 const totalWidth =
213 widths.reduce((sum, width) => sum + width, 0) +
214 Math.max(0, tabs.length - 1) * gap;
215 const contentWidth = Math.max(1, columns - theme.space.indent);
216 const { start, end } =
217 totalWidth <= contentWidth
218 ? { start: 0, end: tabs.length }
219 : // reserve an arrow cell + gap on each side so the window stays put
220 // whether or not the edge arrows render
221 tabsWindow(
222 widths,
223 activeIndex,
224 Math.max(1, contentWidth - 2 * (1 + gap)),
225 gap,
226 );
227 return (
228 <Box
229 width="100%"
230 gap={gap}
231 paddingLeft={theme.space.indent}
232 marginBottom={1}
233 aria-role="tablist"
234 >
235 {start > 0 ? <Text tone="muted">{glyphs.overflowLeft}</Text> : null}
236 {tabs.slice(start, end).map((tab) => {
237 const selected = tab.id === active;
238 return (
239 <Box
240 key={tab.id}
241 paddingX={1}
242 backgroundColor={selected ? theme.paint.interactive : undefined}
243 aria-role="tab"
244 aria-label={tab.label}
245 aria-state={{ selected }}
246 >
247 <Text
248 bold={selected}
249 color={selected ? theme.color.onAccent : undefined}
250 dimColor={!selected}
251 >
252 {tab.marked ? (
253 <Text
254 color={selected ? theme.color.onAccent : theme.color.brand}
255 >
256 {glyphs.selected}{" "}
257 </Text>
258 ) : null}
259 {tab.label}
260 </Text>
261 </Box>
262 );
263 })}
264 {end < tabs.length ? (
265 <Text tone="muted">{glyphs.overflowRight}</Text>
266 ) : null}
267 </Box>
268 );
269}