| 1 | /** @jsxImportSource @alchemy.run/sigil */ |
| 2 | import { useAnimation } from "@alchemy.run/sigil"; |
| 3 | import type { ReactNode } from "react"; |
| 4 | import { stringWidth } from "@alchemy.run/sigil/ansi"; |
| 5 | import { |
| 6 | spinnerFramesFor, |
| 7 | statusColor, |
| 8 | theme, |
| 9 | type StatusVariant, |
| 10 | } from "../../../Util/Theme.ts"; |
| 11 | import { |
| 12 | useBorderStyle, |
| 13 | useCliEnvironment, |
| 14 | useGlyphs, |
| 15 | } from "./Environment.tsx"; |
| 16 | import { Box, tabsWindow } from "./Layout.tsx"; |
| 17 | import { Text } from "./Typography.tsx"; |
| 18 | |
| 19 | export interface StatusProps { |
| 20 | readonly variant?: StatusVariant; |
| 21 | readonly children?: ReactNode; |
| 22 | readonly detail?: ReactNode; |
| 23 | } |
| 24 | |
| 25 | export 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 | |
| 38 | export 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 | */ |
| 45 | export function Toast({ variant = "info", children, detail }: ToastProps) { |
| 46 | return ( |
| 47 | <Status variant={variant} detail={detail}> |
| 48 | {children} |
| 49 | </Status> |
| 50 | ); |
| 51 | } |
| 52 | |
| 53 | export interface AlertProps extends StatusProps { |
| 54 | readonly title?: ReactNode; |
| 55 | } |
| 56 | |
| 57 | /** Glyph + bold title on one row, body indented beneath it. */ |
| 58 | export 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 | |
| 81 | export 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 | |
| 93 | export 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 | |
| 156 | export 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 | */ |
| 167 | type SpinnerGlyphProps = { readonly color?: string }; |
| 168 | |
| 169 | export function SpinnerGlyph({ color }: SpinnerGlyphProps) { |
| 170 | return <Text color={color ?? theme.color.info}>{useSpinnerFrame()}</Text>; |
| 171 | } |
| 172 | |
| 173 | type SpinnerProps = { |
| 174 | readonly label: ReactNode; |
| 175 | readonly detail?: ReactNode; |
| 176 | }; |
| 177 | |
| 178 | export 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 | |
| 188 | type TabsProps = { |
| 189 | readonly tabs: ReadonlyArray<{ |
| 190 | readonly id: string; |
| 191 | readonly label: string; |
| 192 | readonly marked?: boolean; |
| 193 | }>; |
| 194 | readonly active: string; |
| 195 | }; |
| 196 | |
| 197 | export 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 | } |