| 1 | /** @jsxImportSource @alchemy.run/sigil */ |
| 2 | import { Box as SigilBox, type DOMElement } from "@alchemy.run/sigil"; |
| 3 | import { forwardRef } from "@alchemy.run/sigil/react"; |
| 4 | import type { |
| 5 | ComponentProps, |
| 6 | ForwardRefExoticComponent, |
| 7 | PropsWithoutRef, |
| 8 | ReactNode, |
| 9 | RefAttributes, |
| 10 | } from "react"; |
| 11 | import { theme } from "../../../Util/Theme.ts"; |
| 12 | import { useCliEnvironment, useGlyphs } from "./Environment.tsx"; |
| 13 | import { Text } from "./Typography.tsx"; |
| 14 | |
| 15 | // Windowed list that clips to the rows its container leaves over; it draws no |
| 16 | // colors of its own, so it needs no theme-aware wrapper. |
| 17 | export { VirtualList } from "@alchemy.run/sigil"; |
| 18 | |
| 19 | export type BoxProps = ComponentProps<typeof SigilBox>; |
| 20 | |
| 21 | /** Theme-aware Sigil container used by CliKit layouts. */ |
| 22 | export const Box: ForwardRefExoticComponent< |
| 23 | PropsWithoutRef<BoxProps> & RefAttributes<DOMElement> |
| 24 | > = forwardRef<DOMElement, BoxProps>(function Box(props, ref) { |
| 25 | const { colors } = useCliEnvironment(); |
| 26 | const { |
| 27 | borderColor: _borderColor, |
| 28 | borderTopColor: _borderTopColor, |
| 29 | borderBottomColor: _borderBottomColor, |
| 30 | borderLeftColor: _borderLeftColor, |
| 31 | borderRightColor: _borderRightColor, |
| 32 | borderBackgroundColor: _borderBackgroundColor, |
| 33 | borderTopBackgroundColor: _borderTopBackgroundColor, |
| 34 | borderBottomBackgroundColor: _borderBottomBackgroundColor, |
| 35 | borderLeftBackgroundColor: _borderLeftBackgroundColor, |
| 36 | borderRightBackgroundColor: _borderRightBackgroundColor, |
| 37 | backgroundColor: _backgroundColor, |
| 38 | borderDimColor: _borderDimColor, |
| 39 | borderTopDimColor: _borderTopDimColor, |
| 40 | borderBottomDimColor: _borderBottomDimColor, |
| 41 | borderLeftDimColor: _borderLeftDimColor, |
| 42 | borderRightDimColor: _borderRightDimColor, |
| 43 | ...colorless |
| 44 | } = props; |
| 45 | return <SigilBox {...(colors ? props : colorless)} ref={ref} />; |
| 46 | }); |
| 47 | |
| 48 | export interface StackProps extends Omit<BoxProps, "flexDirection"> { |
| 49 | readonly gap?: number; |
| 50 | } |
| 51 | |
| 52 | /** Vertical layout primitive. */ |
| 53 | export function Stack({ children, gap = 0, ...props }: StackProps) { |
| 54 | return ( |
| 55 | <Box flexDirection="column" gap={gap} {...props}> |
| 56 | {children} |
| 57 | </Box> |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | export interface RowProps extends Omit< |
| 62 | BoxProps, |
| 63 | "flexDirection" | "alignItems" | "justifyContent" |
| 64 | > { |
| 65 | readonly gap?: number; |
| 66 | readonly align?: "flex-start" | "center" | "flex-end"; |
| 67 | readonly justify?: |
| 68 | | "flex-start" |
| 69 | | "center" |
| 70 | | "flex-end" |
| 71 | | "space-between" |
| 72 | | "space-around"; |
| 73 | } |
| 74 | |
| 75 | /** Horizontal layout primitive. */ |
| 76 | export function Row({ |
| 77 | children, |
| 78 | gap = 1, |
| 79 | align = "flex-start", |
| 80 | justify = "flex-start", |
| 81 | ...props |
| 82 | }: RowProps) { |
| 83 | return ( |
| 84 | <Box |
| 85 | flexDirection="row" |
| 86 | gap={gap} |
| 87 | alignItems={align} |
| 88 | justifyContent={justify} |
| 89 | {...props} |
| 90 | > |
| 91 | {children} |
| 92 | </Box> |
| 93 | ); |
| 94 | } |
| 95 | |
| 96 | type HeadingProps = { |
| 97 | readonly children?: ReactNode; |
| 98 | /** Set false to drop the section glyph prefix (e.g. help-screen headings). */ |
| 99 | readonly glyph?: boolean; |
| 100 | }; |
| 101 | |
| 102 | export function Heading({ children, glyph = true }: HeadingProps) { |
| 103 | const glyphs = useGlyphs(); |
| 104 | return ( |
| 105 | <Box> |
| 106 | <Text bold color={theme.color.brand}> |
| 107 | {glyph ? `${glyphs.section} ` : null} |
| 108 | {children} |
| 109 | </Text> |
| 110 | </Box> |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | type SectionHeadingProps = { |
| 115 | readonly children?: ReactNode; |
| 116 | readonly annotation?: ReactNode; |
| 117 | }; |
| 118 | |
| 119 | export function SectionHeading({ children, annotation }: SectionHeadingProps) { |
| 120 | return ( |
| 121 | <Text> |
| 122 | <Text bold color={theme.color.brand}> |
| 123 | {children} |
| 124 | </Text> |
| 125 | {annotation === undefined ? null : ( |
| 126 | <Text tone="muted"> · {annotation}</Text> |
| 127 | )} |
| 128 | </Text> |
| 129 | ); |
| 130 | } |
| 131 | |
| 132 | type GutterProps = { |
| 133 | readonly depth?: number; |
| 134 | readonly children?: ReactNode; |
| 135 | }; |
| 136 | |
| 137 | export function Gutter({ depth = 1, children }: GutterProps) { |
| 138 | return ( |
| 139 | <Box paddingLeft={Math.max(0, depth) * theme.space.indent}> |
| 140 | <Box flexDirection="column" flexGrow={1}> |
| 141 | {children} |
| 142 | </Box> |
| 143 | </Box> |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * The centering clamp shared by every windowed list: a window of `count` rows |
| 149 | * over `length` items keeping `cursor` as close to the middle as the list |
| 150 | * bounds allow. |
| 151 | */ |
| 152 | export const listWindow = ( |
| 153 | length: number, |
| 154 | cursor: number, |
| 155 | count: number, |
| 156 | ): { readonly start: number; readonly end: number } => { |
| 157 | const start = Math.max( |
| 158 | 0, |
| 159 | Math.min(cursor - Math.floor(count / 2), length - count), |
| 160 | ); |
| 161 | return { start, end: Math.min(length, start + count) }; |
| 162 | }; |
| 163 | |
| 164 | /** |
| 165 | * Horizontal window over variable-width items (tab chips): expands around |
| 166 | * `active` — alternating right/left so the selection stays roughly centered — |
| 167 | * until `available` columns are exhausted. Returns `[start, end)` like |
| 168 | * {@link listWindow}; `active` is always inside the window. |
| 169 | */ |
| 170 | export const tabsWindow = ( |
| 171 | widths: ReadonlyArray<number>, |
| 172 | active: number, |
| 173 | available: number, |
| 174 | gap = 1, |
| 175 | ): { readonly start: number; readonly end: number } => { |
| 176 | if (widths.length === 0) return { start: 0, end: 0 }; |
| 177 | const cursor = Math.max(0, Math.min(active, widths.length - 1)); |
| 178 | let start = cursor; |
| 179 | let end = cursor + 1; |
| 180 | let used = widths[cursor]!; |
| 181 | let takeRight = true; |
| 182 | while (true) { |
| 183 | const canRight = |
| 184 | end < widths.length && used + gap + widths[end]! <= available; |
| 185 | const canLeft = start > 0 && used + gap + widths[start - 1]! <= available; |
| 186 | if (!canRight && !canLeft) break; |
| 187 | if ((takeRight && canRight) || !canLeft) { |
| 188 | used += gap + widths[end]!; |
| 189 | end++; |
| 190 | } else { |
| 191 | start--; |
| 192 | used += gap + widths[start]!; |
| 193 | } |
| 194 | takeRight = !takeRight; |
| 195 | } |
| 196 | return { start, end }; |
| 197 | }; |
| 198 | |
| 199 | /** |
| 200 | * Window for prompt lists that render "↑ N more"/"↓ N more" indicator rows: |
| 201 | * reserves two of `visibleCount`'s rows for the indicators when overflowing so |
| 202 | * the widget stays within the requested height. |
| 203 | */ |
| 204 | export const overflowListWindow = ( |
| 205 | length: number, |
| 206 | cursor: number, |
| 207 | visibleCount: number, |
| 208 | ): { readonly start: number; readonly end: number } => |
| 209 | listWindow( |
| 210 | length, |
| 211 | cursor, |
| 212 | Math.max(1, length > visibleCount ? visibleCount - 2 : visibleCount), |
| 213 | ); |
| 214 | |
| 215 | /** Windowed list keeping `cursor` centered; each item renders as a block. */ |
| 216 | type ViewportProps<Item> = { |
| 217 | readonly items: ReadonlyArray<Item>; |
| 218 | readonly cursor?: number; |
| 219 | readonly height: number; |
| 220 | readonly renderItem: (item: Item, index: number) => ReactNode; |
| 221 | readonly getKey: (item: Item, index: number) => string; |
| 222 | readonly empty?: ReactNode; |
| 223 | }; |
| 224 | |
| 225 | export function Viewport<Item>({ |
| 226 | items, |
| 227 | cursor = 0, |
| 228 | height, |
| 229 | renderItem, |
| 230 | getKey, |
| 231 | empty, |
| 232 | }: ViewportProps<Item>) { |
| 233 | if (items.length === 0) return <>{empty}</>; |
| 234 | const selected = Math.max(0, Math.min(cursor, items.length - 1)); |
| 235 | const { start, end } = listWindow( |
| 236 | items.length, |
| 237 | selected, |
| 238 | Math.max(1, height), |
| 239 | ); |
| 240 | return ( |
| 241 | <Stack> |
| 242 | {items.slice(start, end).map((item, offset) => { |
| 243 | const index = start + offset; |
| 244 | return ( |
| 245 | <Box key={getKey(item, index)} flexDirection="column"> |
| 246 | {renderItem(item, index)} |
| 247 | </Box> |
| 248 | ); |
| 249 | })} |
| 250 | </Stack> |
| 251 | ); |
| 252 | } |