1/** @jsxImportSource @alchemy.run/sigil */
2import { Box as SigilBox, type DOMElement } from "@alchemy.run/sigil";
3import { forwardRef } from "@alchemy.run/sigil/react";
4import type {
5 ComponentProps,
6 ForwardRefExoticComponent,
7 PropsWithoutRef,
8 ReactNode,
9 RefAttributes,
10} from "react";
11import { theme } from "../../../Util/Theme.ts";
12import { useCliEnvironment, useGlyphs } from "./Environment.tsx";
13import { 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.
17export { VirtualList } from "@alchemy.run/sigil";
18
19export type BoxProps = ComponentProps<typeof SigilBox>;
20
21/** Theme-aware Sigil container used by CliKit layouts. */
22export 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
48export interface StackProps extends Omit<BoxProps, "flexDirection"> {
49 readonly gap?: number;
50}
51
52/** Vertical layout primitive. */
53export function Stack({ children, gap = 0, ...props }: StackProps) {
54 return (
55 <Box flexDirection="column" gap={gap} {...props}>
56 {children}
57 </Box>
58 );
59}
60
61export 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. */
76export 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
96type 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
102export 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
114type SectionHeadingProps = {
115 readonly children?: ReactNode;
116 readonly annotation?: ReactNode;
117};
118
119export 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
132type GutterProps = {
133 readonly depth?: number;
134 readonly children?: ReactNode;
135};
136
137export 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 */
152export 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 */
170export 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 */
204export 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. */
216type 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
225export 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}