@alchemy.run/sigil 0.0.0-alpha.8 → 0.0.0-alpha.9

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/README.md CHANGED
@@ -164,6 +164,7 @@ _(PRs welcome. Append new entries at the end. Repos must have 100+ stars and sho
164
164
  - [`<Spacer>`](#spacer)
165
165
  - [`<Static>`](#static)
166
166
  - [`<Transform>`](#transform)
167
+ - [`<VirtualList>`](#virtuallist)
167
168
  - [Hooks](#hooks)
168
169
  - [`useInput`](#useinputinputhandler-options)
169
170
  - [`usePaste`](#usepastehandler-options)
@@ -171,6 +172,7 @@ _(PRs welcome. Append new entries at the end. Repos must have 100+ stars and sho
171
172
  - [`useStdin`](#usestdin)
172
173
  - [`useStdout`](#usestdout)
173
174
  - [`useBoxMetrics`](#useboxmetricsref)
175
+ - [`useVirtualScroll`](#usevirtualscrolloptions)
174
176
  - [`useStderr`](#usestderr)
175
177
  - [`useWindowSize`](#usewindowsize)
176
178
  - [`useFocus`](#usefocusoptions)
@@ -1655,6 +1657,87 @@ Type: `number`
1655
1657
 
1656
1658
  The zero-indexed line number of the line that's currently being transformed.
1657
1659
 
1660
+ ### `<VirtualList>`
1661
+
1662
+ A vertically windowed list. Only the items that intersect the viewport are rendered, inside a clipped box that scrolls by whole rows. Items may have different heights, and the item at the top edge may be partially visible.
1663
+
1664
+ Give it a fixed `height`, or omit it and bound an ancestor instead: the list then takes the height of its content and shrinks to whatever rows the container leaves over. Siblings that must keep their size need `flexShrink={0}`, because every `<Box>` shrinks by default.
1665
+
1666
+ ```jsx
1667
+ import { useState } from "react";
1668
+ import { render, Box, Text, VirtualList, useInput, useWindowSize } from "@alchemy.run/sigil";
1669
+
1670
+ const entries = Array.from({ length: 200 }, (_, index) => `entry ${index}`);
1671
+
1672
+ const Example = () => {
1673
+ const { rows } = useWindowSize();
1674
+ const [cursor, setCursor] = useState(0);
1675
+
1676
+ useInput((_, key) => {
1677
+ if (key.upArrow) setCursor((index) => Math.max(0, index - 1));
1678
+ if (key.downArrow) setCursor((index) => Math.min(entries.length - 1, index + 1));
1679
+ });
1680
+
1681
+ return (
1682
+ <Box flexDirection="column" maxHeight={rows}>
1683
+ <Box flexShrink={0}>
1684
+ <Text bold>Entries</Text>
1685
+ </Box>
1686
+ <VirtualList
1687
+ items={entries}
1688
+ itemHeight={() => 1}
1689
+ focusedIndex={cursor}
1690
+ renderItem={(entry, index) => <Text inverse={index === cursor}>{entry}</Text>}
1691
+ />
1692
+ <Box flexShrink={0}>
1693
+ <Text dimColor>↑/↓ move</Text>
1694
+ </Box>
1695
+ </Box>
1696
+ );
1697
+ };
1698
+
1699
+ render(<Example />);
1700
+ ```
1701
+
1702
+ #### items
1703
+
1704
+ Type: `ReadonlyArray<Item>`
1705
+
1706
+ Items to window over.
1707
+
1708
+ #### itemHeight
1709
+
1710
+ Type: `(item: Item, index: number) => number`
1711
+
1712
+ Height of an item in rows. It must match what `renderItem` produces for it: the window is computed from these numbers, never from the rendered output.
1713
+
1714
+ #### renderItem
1715
+
1716
+ Type: `(item: Item, index: number) => ReactNode`
1717
+
1718
+ Render one item. Only items intersecting the viewport are rendered.
1719
+
1720
+ #### getKey
1721
+
1722
+ Type: `(item: Item, index: number) => React.Key`
1723
+
1724
+ React key for an item. Defaults to its index.
1725
+
1726
+ #### focusedIndex
1727
+
1728
+ Type: `number`
1729
+
1730
+ Item to keep fully visible. When it changes, the list scrolls as little as necessary to show it. An item taller than the viewport is aligned to its top.
1731
+
1732
+ #### height
1733
+
1734
+ Type: `number`
1735
+
1736
+ Viewport height in rows. When omitted the list shrinks to the space its container leaves over, as described above.
1737
+
1738
+ > [!NOTE]
1739
+ > Until the first layout pass has measured the viewport, every item is rendered inside the clipped box so the first frame already looks right.
1740
+
1658
1741
  ## Hooks
1659
1742
 
1660
1743
  ### useInput(inputHandler, options?)
@@ -2174,6 +2257,99 @@ Whether the currently tracked element has been measured.
2174
2257
  > [!NOTE]
2175
2258
  > The hook returns `{width: 0, height: 0, left: 0, top: 0}` until the first layout pass completes. It also returns zeros when the tracked ref is detached.
2176
2259
 
2260
+ ### useVirtualScroll(options)
2261
+
2262
+ A React hook that owns the scroll position of a windowed list and returns which items to render for it. The position is clamped to the scrollable range and, while `focusedIndex` is set, moved as little as necessary to keep that item fully visible.
2263
+
2264
+ [`<VirtualList>`](#virtuallist) wraps this hook. Use it directly to draw your own chrome around the window, such as overflow markers or a scrollbar.
2265
+
2266
+ ```jsx
2267
+ import { Box, Text, useVirtualScroll } from "@alchemy.run/sigil";
2268
+
2269
+ const Example = ({ lines, cursor }) => {
2270
+ const { start, end, offset, hiddenAbove, hiddenBelow } = useVirtualScroll({
2271
+ count: lines.length,
2272
+ itemHeight: () => 1,
2273
+ viewportHeight: 10,
2274
+ focusedIndex: cursor,
2275
+ });
2276
+
2277
+ return (
2278
+ <Box flexDirection="column">
2279
+ <Text dimColor>{hiddenAbove > 0 ? `↑ ${hiddenAbove} more` : ""}</Text>
2280
+ <Box flexDirection="column" height={10} overflowY="hidden">
2281
+ <Box flexDirection="column" flexShrink={0} marginTop={offset}>
2282
+ {lines.slice(start, end).map((line, index) => (
2283
+ <Text key={start + index} inverse={start + index === cursor}>
2284
+ {line}
2285
+ </Text>
2286
+ ))}
2287
+ </Box>
2288
+ </Box>
2289
+ <Text dimColor>{hiddenBelow > 0 ? `↓ ${hiddenBelow} more` : ""}</Text>
2290
+ </Box>
2291
+ );
2292
+ };
2293
+ ```
2294
+
2295
+ #### options
2296
+
2297
+ ##### count
2298
+
2299
+ Type: `number`
2300
+
2301
+ Number of items in the list.
2302
+
2303
+ ##### itemHeight
2304
+
2305
+ Type: `(index: number) => number`
2306
+
2307
+ Height of the item at `index` in rows. It must match what the item renders.
2308
+
2309
+ ##### viewportHeight
2310
+
2311
+ Type: `number`
2312
+
2313
+ Rows available to show items.
2314
+
2315
+ ##### focusedIndex
2316
+
2317
+ Type: `number`
2318
+
2319
+ Item that must stay fully visible. An item taller than the viewport is aligned to the top. Out-of-range values are ignored.
2320
+
2321
+ #### Result
2322
+
2323
+ ##### start, end
2324
+
2325
+ Type: `number`
2326
+
2327
+ The items intersecting the viewport are `[start, end)`.
2328
+
2329
+ ##### offset
2330
+
2331
+ Type: `number`
2332
+
2333
+ Position of the `start` item relative to the top of the viewport. Zero or negative: a negative value means the item is partially scrolled out above. Apply it as `marginTop` on the box holding the rendered items.
2334
+
2335
+ ##### scrollTop, maxScrollTop, totalHeight
2336
+
2337
+ Type: `number`
2338
+
2339
+ The effective position, the largest position that still fills the viewport, and the height of every item combined, all in rows.
2340
+
2341
+ ##### hiddenAbove, hiddenBelow
2342
+
2343
+ Type: `number`
2344
+
2345
+ Rows scrolled out above the viewport and rows left below it.
2346
+
2347
+ ##### scrollTo(top), scrollBy(delta)
2348
+
2349
+ Type: `(rows: number) => void`
2350
+
2351
+ Move the viewport. While `focusedIndex` is set, a position that would hide it is corrected on the next render.
2352
+
2177
2353
  ### useStderr()
2178
2354
 
2179
2355
  A React hook that returns the stderr stream and stderr-related utilities.
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ import { n as Text, r as Styles, t as Props$8 } from "./Text-DV9CuzAT.js";
8
8
  import { i as Node } from "./index-DDVME65c.js";
9
9
  import { t as CursorPosition } from "./cursor-position-D2LAkRG0.js";
10
10
  import { Writable } from "node:stream";
11
- import { PropsWithChildren, ReactNode, Ref, RefObject } from "react";
11
+ import { Key as Key$1, PropsWithChildren, ReactNode, Ref, RefObject } from "react";
12
12
  import { EventEmitter } from "node:events";
13
13
  //#region src/kitty-keyboard.d.ts
14
14
  declare const kittyFlags: {
@@ -634,6 +634,48 @@ It's useful as a shortcut for filling all the available space between elements.
634
634
  */
635
635
  declare function Spacer(): import("react").JSX.Element;
636
636
  //#endregion
637
+ //#region src/components/VirtualList.d.ts
638
+ type Props$10<Item> = {
639
+ /**
640
+ Items to window over.
641
+ */
642
+ readonly items: ReadonlyArray<Item>;
643
+ /**
644
+ Height of an item in rows. It must match what `renderItem` produces for it:
645
+ the window is computed from these numbers, never from the rendered output.
646
+ */
647
+ readonly itemHeight: (item: Item, index: number) => number;
648
+ /**
649
+ Render one item. Only items intersecting the viewport are rendered.
650
+ */
651
+ readonly renderItem: (item: Item, index: number) => ReactNode;
652
+ /**
653
+ React key for an item. Defaults to its index.
654
+ */
655
+ readonly getKey?: (item: Item, index: number) => Key$1;
656
+ /**
657
+ Item to keep fully visible. When it changes, the list scrolls as little as
658
+ necessary to show it.
659
+ */
660
+ readonly focusedIndex?: number;
661
+ /**
662
+ Viewport height in rows. When omitted the list takes the height of its
663
+ content and shrinks to whatever space its container leaves: bound an
664
+ ancestor (`height` or `maxHeight`) and give the siblings that must keep
665
+ their size `flexShrink={0}`.
666
+ */
667
+ readonly height?: number;
668
+ };
669
+ /**
670
+ A vertically windowed list: only the items intersecting the viewport are
671
+ rendered, inside a clipped box that scrolls by whole rows. Items may have
672
+ different heights, and the item at the top edge may be partially visible.
673
+
674
+ Until the first layout pass has measured the viewport, every item is rendered
675
+ inside the clipped box so the first frame already looks right.
676
+ */
677
+ declare function VirtualList<Item>({ items, itemHeight, renderItem, getKey, focusedIndex, height }: Props$10<Item>): import("react").JSX.Element;
678
+ //#endregion
637
679
  //#region src/hooks/use-capabilities.d.ts
638
680
  /**
639
681
  Returns everything knowable about the terminal: size, identity, platform,
@@ -891,7 +933,7 @@ A component that uses the `useFocus` hook becomes "focusable" to Ink, so when th
891
933
  declare const useFocus: ({ isActive, autoFocus, id: customId }?: Input) => Output$2;
892
934
  //#endregion
893
935
  //#region src/components/FocusContext.d.ts
894
- type Props$10 = {
936
+ type Props$11 = {
895
937
  readonly activeId?: string;
896
938
  readonly add: (id: string, options: {
897
939
  autoFocus: boolean;
@@ -911,23 +953,23 @@ type Output$1 = {
911
953
  /**
912
954
  Enable focus management for all components.
913
955
  */
914
- enableFocus: Props$10["enableFocus"];
956
+ enableFocus: Props$11["enableFocus"];
915
957
  /**
916
958
  Disable focus management for all components. The currently active component (if there's one) will lose its focus.
917
959
  */
918
- disableFocus: Props$10["disableFocus"];
960
+ disableFocus: Props$11["disableFocus"];
919
961
  /**
920
962
  Switch focus to the next focusable component. If there's no active component right now, focus will be given to the first focusable component. If the active component is the last in the list of focusable components, focus will be switched to the first focusable component.
921
963
  */
922
- focusNext: Props$10["focusNext"];
964
+ focusNext: Props$11["focusNext"];
923
965
  /**
924
966
  Switch focus to the previous focusable component. If there's no active component right now, focus will be given to the first focusable component. If the active component is the first in the list of focusable components, focus will be switched to the last focusable component.
925
967
  */
926
- focusPrevious: Props$10["focusPrevious"];
968
+ focusPrevious: Props$11["focusPrevious"];
927
969
  /**
928
970
  Switch focus to the element with provided `id`. If there's no element with that `id`, focus is not changed.
929
971
  */
930
- focus: Props$10["focus"];
972
+ focus: Props$11["focus"];
931
973
  /**
932
974
  The ID of the currently focused component, or `undefined` if no component is focused.
933
975
 
@@ -942,7 +984,7 @@ type Output$1 = {
942
984
  };
943
985
  ```
944
986
  */
945
- activeId: Props$10["activeId"];
987
+ activeId: Props$11["activeId"];
946
988
  };
947
989
  /**
948
990
  A React hook that returns methods to enable or disable focus management for all components or manually switch focus to the next or previous components.
@@ -1111,6 +1153,129 @@ const Example = () => {
1111
1153
  */
1112
1154
  declare const useBoxMetrics: (ref: RefObject<DOMElement | null>) => UseBoxMetricsResult;
1113
1155
  //#endregion
1156
+ //#region src/virtual-scroll.d.ts
1157
+ /**
1158
+ Windowing math shared by `useVirtualScroll` and `<VirtualList>`.
1159
+
1160
+ Items are stacked vertically and measured in terminal rows. The viewport shows
1161
+ `viewportHeight` rows of that stack starting `scrollTop` rows from the top.
1162
+ */
1163
+ type VirtualScrollOptions = {
1164
+ /**
1165
+ Number of items in the list.
1166
+ */
1167
+ readonly count: number;
1168
+ /**
1169
+ Height of the item at `index` in rows. It must match what the item renders:
1170
+ the window is computed from these numbers, never from the rendered output.
1171
+ */
1172
+ readonly itemHeight: (index: number) => number;
1173
+ /**
1174
+ Rows available to show items.
1175
+ */
1176
+ readonly viewportHeight: number;
1177
+ /**
1178
+ Requested distance of the viewport from the top of the list in rows. It is
1179
+ clamped to the scrollable range and then moved as little as necessary to keep
1180
+ `focusedIndex` fully visible.
1181
+ */
1182
+ readonly scrollTop: number;
1183
+ /**
1184
+ Item that must stay fully visible. An item taller than the viewport is aligned
1185
+ to the top. Out-of-range values are ignored.
1186
+ */
1187
+ readonly focusedIndex?: number;
1188
+ };
1189
+ type VirtualScrollWindow = {
1190
+ /**
1191
+ Index of the first item that intersects the viewport.
1192
+ */
1193
+ readonly start: number;
1194
+ /**
1195
+ Index after the last item that intersects the viewport.
1196
+ */
1197
+ readonly end: number;
1198
+ /**
1199
+ Effective distance of the viewport from the top of the list in rows.
1200
+ */
1201
+ readonly scrollTop: number;
1202
+ /**
1203
+ Largest `scrollTop` that still fills the viewport.
1204
+ */
1205
+ readonly maxScrollTop: number;
1206
+ /**
1207
+ Height of every item combined in rows.
1208
+ */
1209
+ readonly totalHeight: number;
1210
+ /**
1211
+ Position of the `start` item relative to the top of the viewport. Zero or
1212
+ negative: a negative value means the item is partially scrolled out above.
1213
+ */
1214
+ readonly offset: number;
1215
+ /**
1216
+ Rows scrolled out above the viewport.
1217
+ */
1218
+ readonly hiddenAbove: number;
1219
+ /**
1220
+ Rows left below the viewport.
1221
+ */
1222
+ readonly hiddenBelow: number;
1223
+ };
1224
+ //#endregion
1225
+ //#region src/hooks/use-virtual-scroll.d.ts
1226
+ type UseVirtualScrollOptions = Omit<VirtualScrollOptions, "scrollTop">;
1227
+ type UseVirtualScrollResult = VirtualScrollWindow & {
1228
+ /**
1229
+ Scroll so the viewport starts `top` rows from the top of the list.
1230
+ */
1231
+ readonly scrollTo: (top: number) => void;
1232
+ /**
1233
+ Scroll by `delta` rows; negative values scroll up.
1234
+ */
1235
+ readonly scrollBy: (delta: number) => void;
1236
+ };
1237
+ /**
1238
+ A React hook that owns the scroll position of a windowed list and returns which
1239
+ items to render for it. The position is clamped to the scrollable range and,
1240
+ while `focusedIndex` is set, moved as little as necessary to keep that item
1241
+ fully visible. Render the items in `[start, end)` inside an `overflowY="hidden"`
1242
+ box of `viewportHeight` rows, shifted up by `offset` rows.
1243
+
1244
+ `<VirtualList>` wraps this hook; use it directly to draw your own chrome such
1245
+ as overflow markers or a scrollbar around the window.
1246
+
1247
+ @example
1248
+ ```tsx
1249
+ import { Box, Text, useVirtualScroll } from "@alchemy.run/sigil";
1250
+
1251
+ const Example = ({ lines, cursor }: { lines: string[]; cursor: number }) => {
1252
+ const { start, end, offset, hiddenAbove, hiddenBelow } = useVirtualScroll({
1253
+ count: lines.length,
1254
+ itemHeight: () => 1,
1255
+ viewportHeight: 10,
1256
+ focusedIndex: cursor,
1257
+ });
1258
+
1259
+ return (
1260
+ <Box flexDirection="column">
1261
+ <Text dimColor>{hiddenAbove > 0 ? `↑ ${hiddenAbove} more` : ""}</Text>
1262
+ <Box flexDirection="column" height={10} overflowY="hidden">
1263
+ <Box flexDirection="column" flexShrink={0} marginTop={offset}>
1264
+ {lines.slice(start, end).map((line, index) => (
1265
+ <Text key={start + index} inverse={start + index === cursor}>
1266
+ {line}
1267
+ </Text>
1268
+ ))}
1269
+ </Box>
1270
+ </Box>
1271
+ <Text dimColor>{hiddenBelow > 0 ? `↓ ${hiddenBelow} more` : ""}</Text>
1272
+ </Box>
1273
+ );
1274
+ };
1275
+ ```
1276
+ */
1277
+ declare const useVirtualScroll: (options: UseVirtualScrollOptions) => UseVirtualScrollResult;
1278
+ //#endregion
1114
1279
  //#region src/measure-element.d.ts
1115
1280
  type Output = {
1116
1281
  /**
@@ -1140,4 +1305,4 @@ Note: `measureElement()` returns `{x: 0, y: 0, width: 0, height: 0}` when called
1140
1305
  */
1141
1306
  declare const measureElement: (node: DOMElement) => Output;
1142
1307
  //#endregion
1143
- export { type AnimationResult, AnsiText, type Props as AnsiTextProps, type Props$1 as AppProps, Box, type BoxMetrics, type Props$2 as BoxProps, type Capabilities, type CapabilitiesStore, type CapturedOutputSource, type ColorInfo, type ColorSupport, type ColorSupportLevel, type CursorPosition, type DOMElement, type Output as ElementMetrics, Hyperlink, type Props$3 as HyperlinkProps, type Instance, type Key, type KittyFlagName, type KittyKeyboardOptions, type Multiplexer, Newline, type Props$4 as NewlineProps, type PixelGeometry, type PixelSize, type ProgressOptions, type RenderOptions, type RenderToStringOptions, type RgbColor, Spacer, Static, type Props$5 as StaticProps, type Props$6 as StderrProps, type PublicProps as StdinProps, type Props$7 as StdoutProps, type SuspendTerminal, type TerminalAppearance, type TerminalIdentity, type TerminalQueryOptions, type TerminalQueryResult, type TerminalSuspension, Text, type Props$8 as TextProps, Transform, type Props$9 as TransformProps, type UseBoxMetricsResult, type WindowSize, applyTerminalQuery, capabilities, createSupportsColor, detectCapabilities, detectColorLevel, detectHyperlinkSupport, detectTerminal, detectUnicodeSupport, getCapabilities, getTerminalQuery, kittyFlags, kittyModifiers, measureElement, queryTerminal, refreshTerminalQuery, render, renderToString, useAnimation, useApp, useBoxMetrics, useCapabilities, useCapabilitiesChange, useClipboard, useCursor, useFocus, useFocusManager, useInput, useIsScreenReaderEnabled, useNotification, usePaste, usePointerShape, useProgress, useStderr, useStdin, useStdout, useTitle, useWindowSize, useWorkingDirectory };
1308
+ export { type AnimationResult, AnsiText, type Props as AnsiTextProps, type Props$1 as AppProps, Box, type BoxMetrics, type Props$2 as BoxProps, type Capabilities, type CapabilitiesStore, type CapturedOutputSource, type ColorInfo, type ColorSupport, type ColorSupportLevel, type CursorPosition, type DOMElement, type Output as ElementMetrics, Hyperlink, type Props$3 as HyperlinkProps, type Instance, type Key, type KittyFlagName, type KittyKeyboardOptions, type Multiplexer, Newline, type Props$4 as NewlineProps, type PixelGeometry, type PixelSize, type ProgressOptions, type RenderOptions, type RenderToStringOptions, type RgbColor, Spacer, Static, type Props$5 as StaticProps, type Props$6 as StderrProps, type PublicProps as StdinProps, type Props$7 as StdoutProps, type SuspendTerminal, type TerminalAppearance, type TerminalIdentity, type TerminalQueryOptions, type TerminalQueryResult, type TerminalSuspension, Text, type Props$8 as TextProps, Transform, type Props$9 as TransformProps, type UseBoxMetricsResult, type UseVirtualScrollOptions, type UseVirtualScrollResult, VirtualList, type Props$10 as VirtualListProps, type VirtualScrollWindow, type WindowSize, applyTerminalQuery, capabilities, createSupportsColor, detectCapabilities, detectColorLevel, detectHyperlinkSupport, detectTerminal, detectUnicodeSupport, getCapabilities, getTerminalQuery, kittyFlags, kittyModifiers, measureElement, queryTerminal, refreshTerminalQuery, render, renderToString, useAnimation, useApp, useBoxMetrics, useCapabilities, useCapabilitiesChange, useClipboard, useCursor, useFocus, useFocusManager, useInput, useIsScreenReaderEnabled, useNotification, usePaste, usePointerShape, useProgress, useStderr, useStdin, useStdout, useTitle, useVirtualScroll, useWindowSize, useWorkingDirectory };
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { C as bsu, S as ansiEscapes, V as esu, W as link, _ as CSI } from "./sgr
2
2
  import { h as tokenizeAnsi, p as graphemes } from "./tokenize-AjqbvtiT.js";
3
3
  import { t as stringWidth } from "./string-width-CijQwpIk.js";
4
4
  import { n as sliceAnsi, r as wrapAnsi } from "./truncate-D31fhU6i.js";
5
- import { _ as squashTextNodes, a as reconciler, c as kittyModifiers, d as FocusContext, f as Text, g as transformAnsiLine, h as emitLayoutListeners, i as useStdinContext, l as resolveFlags, m as createNode, n as useInput, o as detectKittySupport, p as addLayoutListener, r as useStdin, s as kittyFlags, t as useFocus, u as StdinContext, v as accessibilityContext } from "./use-focus-BNG0xsb7.js";
5
+ import { _ as squashTextNodes, a as reconciler, c as kittyModifiers, d as FocusContext, f as Text, g as transformAnsiLine, h as emitLayoutListeners, i as useStdinContext, l as resolveFlags, m as createNode, n as useInput, o as detectKittySupport, p as addLayoutListener, r as useStdin, s as kittyFlags, t as useFocus, u as StdinContext, v as accessibilityContext } from "./use-focus-Basd0ksv.js";
6
6
  import { a as isTty, i as isSigilDev, o as isWindows, r as isScreenReader, t as isInCi } from "./env-YVw64yZS.js";
7
7
  import { a as detectTerminal, i as detectHyperlinkSupport, n as detectCapabilities, o as detectUnicodeSupport, r as detectColorLevel, s as signalExit, t as createSupportsColor } from "./detect-B3dL4Q11.js";
8
8
  import { u as cliCursor } from "./osc-BFKKSqpg.js";
@@ -2602,6 +2602,181 @@ function Spacer() {
2602
2602
  return /* @__PURE__ */ jsx(Box, { flexGrow: 1 });
2603
2603
  }
2604
2604
  //#endregion
2605
+ //#region src/virtual-scroll.ts
2606
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
2607
+ const wholeRows = (value) => Math.max(0, Math.floor(value)) || 0;
2608
+ /**
2609
+ Compute which items intersect a viewport over a vertical stack of items.
2610
+ */
2611
+ const virtualScrollWindow = (options) => {
2612
+ const count = wholeRows(options.count);
2613
+ const viewportHeight = wholeRows(options.viewportHeight);
2614
+ const tops = [];
2615
+ let totalHeight = 0;
2616
+ for (let index = 0; index < count; index++) {
2617
+ tops.push(totalHeight);
2618
+ totalHeight += wholeRows(options.itemHeight(index));
2619
+ }
2620
+ const bottomOf = (index) => index + 1 < count ? tops[index + 1] : totalHeight;
2621
+ const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
2622
+ let scrollTop = clamp(Math.floor(options.scrollTop) || 0, 0, maxScrollTop);
2623
+ const focused = options.focusedIndex;
2624
+ if (focused !== void 0 && Number.isInteger(focused) && focused >= 0 && focused < count) {
2625
+ const top = tops[focused];
2626
+ const bottom = bottomOf(focused);
2627
+ if (top < scrollTop) scrollTop = top;
2628
+ else if (bottom > scrollTop + viewportHeight) scrollTop = Math.min(top, bottom - viewportHeight);
2629
+ scrollTop = clamp(scrollTop, 0, maxScrollTop);
2630
+ }
2631
+ const viewportBottom = scrollTop + viewportHeight;
2632
+ let start = 0;
2633
+ while (start < count && bottomOf(start) <= scrollTop) start++;
2634
+ let end = start;
2635
+ while (end < count && tops[end] < viewportBottom) end++;
2636
+ return {
2637
+ start,
2638
+ end,
2639
+ scrollTop,
2640
+ maxScrollTop,
2641
+ totalHeight,
2642
+ offset: start < end ? tops[start] - scrollTop : 0,
2643
+ hiddenAbove: scrollTop,
2644
+ hiddenBelow: Math.max(0, totalHeight - viewportBottom)
2645
+ };
2646
+ };
2647
+ //#endregion
2648
+ //#region src/hooks/use-virtual-scroll.ts
2649
+ /**
2650
+ A React hook that owns the scroll position of a windowed list and returns which
2651
+ items to render for it. The position is clamped to the scrollable range and,
2652
+ while `focusedIndex` is set, moved as little as necessary to keep that item
2653
+ fully visible. Render the items in `[start, end)` inside an `overflowY="hidden"`
2654
+ box of `viewportHeight` rows, shifted up by `offset` rows.
2655
+
2656
+ `<VirtualList>` wraps this hook; use it directly to draw your own chrome such
2657
+ as overflow markers or a scrollbar around the window.
2658
+
2659
+ @example
2660
+ ```tsx
2661
+ import { Box, Text, useVirtualScroll } from "@alchemy.run/sigil";
2662
+
2663
+ const Example = ({ lines, cursor }: { lines: string[]; cursor: number }) => {
2664
+ const { start, end, offset, hiddenAbove, hiddenBelow } = useVirtualScroll({
2665
+ count: lines.length,
2666
+ itemHeight: () => 1,
2667
+ viewportHeight: 10,
2668
+ focusedIndex: cursor,
2669
+ });
2670
+
2671
+ return (
2672
+ <Box flexDirection="column">
2673
+ <Text dimColor>{hiddenAbove > 0 ? `↑ ${hiddenAbove} more` : ""}</Text>
2674
+ <Box flexDirection="column" height={10} overflowY="hidden">
2675
+ <Box flexDirection="column" flexShrink={0} marginTop={offset}>
2676
+ {lines.slice(start, end).map((line, index) => (
2677
+ <Text key={start + index} inverse={start + index === cursor}>
2678
+ {line}
2679
+ </Text>
2680
+ ))}
2681
+ </Box>
2682
+ </Box>
2683
+ <Text dimColor>{hiddenBelow > 0 ? `↓ ${hiddenBelow} more` : ""}</Text>
2684
+ </Box>
2685
+ );
2686
+ };
2687
+ ```
2688
+ */
2689
+ const useVirtualScroll = (options) => {
2690
+ const [position, setPosition] = useState(0);
2691
+ const window = virtualScrollWindow({
2692
+ ...options,
2693
+ scrollTop: position
2694
+ });
2695
+ useEffect(() => {
2696
+ if (window.scrollTop !== position) setPosition(window.scrollTop);
2697
+ }, [window.scrollTop, position]);
2698
+ const scrollTo = useCallback((top) => {
2699
+ setPosition(top);
2700
+ }, []);
2701
+ const scrollBy = useCallback((delta) => {
2702
+ setPosition((current) => current + delta);
2703
+ }, []);
2704
+ return {
2705
+ ...window,
2706
+ scrollTo,
2707
+ scrollBy
2708
+ };
2709
+ };
2710
+ //#endregion
2711
+ //#region src/components/VirtualList.tsx
2712
+ /** @jsxImportSource react */
2713
+ const rootOf = (node) => {
2714
+ let current = node;
2715
+ while (current?.parentNode) current = current.parentNode;
2716
+ return current?.nodeName === "ink-root" ? current : void 0;
2717
+ };
2718
+ /**
2719
+ A vertically windowed list: only the items intersecting the viewport are
2720
+ rendered, inside a clipped box that scrolls by whole rows. Items may have
2721
+ different heights, and the item at the top edge may be partially visible.
2722
+
2723
+ Until the first layout pass has measured the viewport, every item is rendered
2724
+ inside the clipped box so the first frame already looks right.
2725
+ */
2726
+ function VirtualList({ items, itemHeight, renderItem, getKey, focusedIndex, height }) {
2727
+ const ref = useRef(null);
2728
+ const [measured, setMeasured] = useState();
2729
+ const measure = () => {
2730
+ const next = ref.current?.yogaNode?.getComputedHeight();
2731
+ if (next !== void 0) setMeasured((previous) => previous === next ? previous : next);
2732
+ };
2733
+ useLayoutEffect(() => {
2734
+ if (height === void 0) measure();
2735
+ });
2736
+ useEffect(() => {
2737
+ if (height !== void 0) return;
2738
+ const root = rootOf(ref.current);
2739
+ return root ? addLayoutListener(root, measure) : void 0;
2740
+ });
2741
+ const viewportHeight = height ?? measured;
2742
+ const windowed = viewportHeight !== void 0;
2743
+ const window = useVirtualScroll({
2744
+ count: items.length,
2745
+ itemHeight: (index) => itemHeight(items[index], index),
2746
+ viewportHeight: viewportHeight ?? 0,
2747
+ focusedIndex: windowed ? focusedIndex : void 0
2748
+ });
2749
+ const start = windowed ? window.start : 0;
2750
+ const end = windowed ? window.end : items.length;
2751
+ const offset = windowed ? window.offset : 0;
2752
+ return /* @__PURE__ */ jsx(Box, {
2753
+ ref,
2754
+ flexDirection: "column",
2755
+ overflowY: "hidden",
2756
+ ...height === void 0 ? {
2757
+ height: window.totalHeight,
2758
+ minHeight: 0,
2759
+ flexShrink: 1
2760
+ } : {
2761
+ height,
2762
+ flexShrink: 0
2763
+ },
2764
+ children: /* @__PURE__ */ jsx(Box, {
2765
+ flexDirection: "column",
2766
+ flexShrink: 0,
2767
+ marginTop: offset,
2768
+ children: items.slice(start, end).map((item, sliceIndex) => {
2769
+ const index = start + sliceIndex;
2770
+ return /* @__PURE__ */ jsx(Box, {
2771
+ flexDirection: "column",
2772
+ flexShrink: 0,
2773
+ children: renderItem(item, index)
2774
+ }, getKey ? getKey(item, index) : index);
2775
+ })
2776
+ })
2777
+ });
2778
+ }
2779
+ //#endregion
2605
2780
  //#region src/hooks/use-capabilities.ts
2606
2781
  /**
2607
2782
  Returns everything knowable about the terminal: size, identity, platform,
@@ -3026,4 +3201,4 @@ const measureElement = (node) => {
3026
3201
  };
3027
3202
  };
3028
3203
  //#endregion
3029
- export { AnsiText, Box, Hyperlink, Newline, Spacer, Static, Text, Transform, applyTerminalQuery, capabilities, createSupportsColor, detectCapabilities, detectColorLevel, detectHyperlinkSupport, detectTerminal, detectUnicodeSupport, getCapabilities, getTerminalQuery, kittyFlags, kittyModifiers, measureElement, queryTerminal, refreshTerminalQuery, render, renderToString, useAnimation, useApp, useBoxMetrics, useCapabilities, useCapabilitiesChange, useClipboard, useCursor, useFocus, useFocusManager, useInput, useIsScreenReaderEnabled, useNotification, usePaste, usePointerShape, useProgress, useStderr, useStdin, useStdout, useTitle, useWindowSize, useWorkingDirectory };
3204
+ export { AnsiText, Box, Hyperlink, Newline, Spacer, Static, Text, Transform, VirtualList, applyTerminalQuery, capabilities, createSupportsColor, detectCapabilities, detectColorLevel, detectHyperlinkSupport, detectTerminal, detectUnicodeSupport, getCapabilities, getTerminalQuery, kittyFlags, kittyModifiers, measureElement, queryTerminal, refreshTerminalQuery, render, renderToString, useAnimation, useApp, useBoxMetrics, useCapabilities, useCapabilitiesChange, useClipboard, useCursor, useFocus, useFocusManager, useInput, useIsScreenReaderEnabled, useNotification, usePaste, usePointerShape, useProgress, useStderr, useStdin, useStdout, useTitle, useVirtualScroll, useWindowSize, useWorkingDirectory };
package/dist/router.js CHANGED
@@ -1,4 +1,4 @@
1
- import { f as Text, n as useInput, t as useFocus } from "./use-focus-BNG0xsb7.js";
1
+ import { f as Text, n as useInput, t as useFocus } from "./use-focus-Basd0ksv.js";
2
2
  import { Children, Fragment, createContext, isValidElement, startTransition, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
4
4
  //#region src/router/history.ts
@@ -564,7 +564,7 @@ const detectKittySupport = (stdin, stdout, onSupported) => {
564
564
  //#endregion
565
565
  //#region package.json
566
566
  var name = "@alchemy.run/sigil";
567
- var version = "0.0.0-alpha.8";
567
+ var version = "0.0.0-alpha.9";
568
568
  //#endregion
569
569
  //#region src/reconciler.ts
570
570
  if (isSigilDev) await import("./devtools-DbthxoD1.js").catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alchemy.run/sigil",
3
- "version": "0.0.0-alpha.8",
3
+ "version": "0.0.0-alpha.9",
4
4
  "description": "React for CLIs. A self-contained fork of Ink with an integrated TypeScript Yoga layout engine.",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,128 @@
1
+ /** @jsxImportSource react */
2
+ import { useEffect, useLayoutEffect, useRef, useState, type Key, type ReactNode } from "react";
3
+
4
+ import { Box } from "#/components/Box.tsx";
5
+ import { addLayoutListener, type DOMElement } from "#/dom.ts";
6
+ import { useVirtualScroll } from "#/hooks/use-virtual-scroll.ts";
7
+
8
+ export type Props<Item> = {
9
+ /**
10
+ Items to window over.
11
+ */
12
+ readonly items: ReadonlyArray<Item>;
13
+
14
+ /**
15
+ Height of an item in rows. It must match what `renderItem` produces for it:
16
+ the window is computed from these numbers, never from the rendered output.
17
+ */
18
+ readonly itemHeight: (item: Item, index: number) => number;
19
+
20
+ /**
21
+ Render one item. Only items intersecting the viewport are rendered.
22
+ */
23
+ readonly renderItem: (item: Item, index: number) => ReactNode;
24
+
25
+ /**
26
+ React key for an item. Defaults to its index.
27
+ */
28
+ readonly getKey?: (item: Item, index: number) => Key;
29
+
30
+ /**
31
+ Item to keep fully visible. When it changes, the list scrolls as little as
32
+ necessary to show it.
33
+ */
34
+ readonly focusedIndex?: number;
35
+
36
+ /**
37
+ Viewport height in rows. When omitted the list takes the height of its
38
+ content and shrinks to whatever space its container leaves: bound an
39
+ ancestor (`height` or `maxHeight`) and give the siblings that must keep
40
+ their size `flexShrink={0}`.
41
+ */
42
+ readonly height?: number;
43
+ };
44
+
45
+ const rootOf = (node: DOMElement | null): DOMElement | undefined => {
46
+ let current = node;
47
+ while (current?.parentNode) {
48
+ current = current.parentNode;
49
+ }
50
+ return current?.nodeName === "ink-root" ? current : undefined;
51
+ };
52
+
53
+ /**
54
+ A vertically windowed list: only the items intersecting the viewport are
55
+ rendered, inside a clipped box that scrolls by whole rows. Items may have
56
+ different heights, and the item at the top edge may be partially visible.
57
+
58
+ Until the first layout pass has measured the viewport, every item is rendered
59
+ inside the clipped box so the first frame already looks right.
60
+ */
61
+ export function VirtualList<Item>({
62
+ items,
63
+ itemHeight,
64
+ renderItem,
65
+ getKey,
66
+ focusedIndex,
67
+ height,
68
+ }: Props<Item>) {
69
+ const ref = useRef<DOMElement>(null);
70
+ const [measured, setMeasured] = useState<number>();
71
+
72
+ const measure = () => {
73
+ const next = ref.current?.yogaNode?.getComputedHeight();
74
+ if (next !== undefined) {
75
+ setMeasured((previous) => (previous === next ? previous : next));
76
+ }
77
+ };
78
+
79
+ // Yoga has laid out the tree by the time layout effects run, so the height
80
+ // this box ended up with is readable synchronously after every commit.
81
+ useLayoutEffect(() => {
82
+ if (height === undefined) measure();
83
+ });
84
+
85
+ // Sibling-driven changes (a resize, chrome growing) re-layout without
86
+ // re-rendering this component; follow the root's layout commits for those.
87
+ useEffect(() => {
88
+ if (height !== undefined) return;
89
+ const root = rootOf(ref.current);
90
+ return root ? addLayoutListener(root, measure) : undefined;
91
+ });
92
+
93
+ const viewportHeight = height ?? measured;
94
+ const windowed = viewportHeight !== undefined;
95
+ const window = useVirtualScroll({
96
+ count: items.length,
97
+ itemHeight: (index) => itemHeight(items[index]!, index),
98
+ viewportHeight: viewportHeight ?? 0,
99
+ // Following the focus against an unmeasured (zero-row) viewport would pin
100
+ // the item to the top; wait for the real height so it moves minimally.
101
+ focusedIndex: windowed ? focusedIndex : undefined,
102
+ });
103
+ const start = windowed ? window.start : 0;
104
+ const end = windowed ? window.end : items.length;
105
+ const offset = windowed ? window.offset : 0;
106
+
107
+ return (
108
+ <Box
109
+ ref={ref}
110
+ flexDirection="column"
111
+ overflowY="hidden"
112
+ {...(height === undefined
113
+ ? { height: window.totalHeight, minHeight: 0, flexShrink: 1 }
114
+ : { height, flexShrink: 0 })}
115
+ >
116
+ <Box flexDirection="column" flexShrink={0} marginTop={offset}>
117
+ {items.slice(start, end).map((item, sliceIndex) => {
118
+ const index = start + sliceIndex;
119
+ return (
120
+ <Box key={getKey ? getKey(item, index) : index} flexDirection="column" flexShrink={0}>
121
+ {renderItem(item, index)}
122
+ </Box>
123
+ );
124
+ })}
125
+ </Box>
126
+ </Box>
127
+ );
128
+ }
@@ -0,0 +1,84 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ import {
4
+ virtualScrollWindow,
5
+ type VirtualScrollOptions,
6
+ type VirtualScrollWindow,
7
+ } from "#/virtual-scroll.ts";
8
+
9
+ export type UseVirtualScrollOptions = Omit<VirtualScrollOptions, "scrollTop">;
10
+
11
+ export type UseVirtualScrollResult = VirtualScrollWindow & {
12
+ /**
13
+ Scroll so the viewport starts `top` rows from the top of the list.
14
+ */
15
+ readonly scrollTo: (top: number) => void;
16
+
17
+ /**
18
+ Scroll by `delta` rows; negative values scroll up.
19
+ */
20
+ readonly scrollBy: (delta: number) => void;
21
+ };
22
+
23
+ /**
24
+ A React hook that owns the scroll position of a windowed list and returns which
25
+ items to render for it. The position is clamped to the scrollable range and,
26
+ while `focusedIndex` is set, moved as little as necessary to keep that item
27
+ fully visible. Render the items in `[start, end)` inside an `overflowY="hidden"`
28
+ box of `viewportHeight` rows, shifted up by `offset` rows.
29
+
30
+ `<VirtualList>` wraps this hook; use it directly to draw your own chrome such
31
+ as overflow markers or a scrollbar around the window.
32
+
33
+ @example
34
+ ```tsx
35
+ import { Box, Text, useVirtualScroll } from "@alchemy.run/sigil";
36
+
37
+ const Example = ({ lines, cursor }: { lines: string[]; cursor: number }) => {
38
+ const { start, end, offset, hiddenAbove, hiddenBelow } = useVirtualScroll({
39
+ count: lines.length,
40
+ itemHeight: () => 1,
41
+ viewportHeight: 10,
42
+ focusedIndex: cursor,
43
+ });
44
+
45
+ return (
46
+ <Box flexDirection="column">
47
+ <Text dimColor>{hiddenAbove > 0 ? `↑ ${hiddenAbove} more` : ""}</Text>
48
+ <Box flexDirection="column" height={10} overflowY="hidden">
49
+ <Box flexDirection="column" flexShrink={0} marginTop={offset}>
50
+ {lines.slice(start, end).map((line, index) => (
51
+ <Text key={start + index} inverse={start + index === cursor}>
52
+ {line}
53
+ </Text>
54
+ ))}
55
+ </Box>
56
+ </Box>
57
+ <Text dimColor>{hiddenBelow > 0 ? `↓ ${hiddenBelow} more` : ""}</Text>
58
+ </Box>
59
+ );
60
+ };
61
+ ```
62
+ */
63
+ export const useVirtualScroll = (options: UseVirtualScrollOptions): UseVirtualScrollResult => {
64
+ const [position, setPosition] = useState(0);
65
+ const window = virtualScrollWindow({ ...options, scrollTop: position });
66
+
67
+ // Persist clamping and follow-focus corrections so the next scroll starts
68
+ // from where the viewport actually is, not from the stale request.
69
+ useEffect(() => {
70
+ if (window.scrollTop !== position) {
71
+ setPosition(window.scrollTop);
72
+ }
73
+ }, [window.scrollTop, position]);
74
+
75
+ const scrollTo = useCallback((top: number) => {
76
+ setPosition(top);
77
+ }, []);
78
+
79
+ const scrollBy = useCallback((delta: number) => {
80
+ setPosition((current) => current + delta);
81
+ }, []);
82
+
83
+ return { ...window, scrollTo, scrollBy };
84
+ };
package/src/index.ts CHANGED
@@ -22,6 +22,8 @@ export { Hyperlink } from "#/components/Hyperlink.tsx";
22
22
  export type { Props as NewlineProps } from "#/components/Newline.tsx";
23
23
  export { Newline } from "#/components/Newline.tsx";
24
24
  export { Spacer } from "#/components/Spacer.tsx";
25
+ export type { Props as VirtualListProps } from "#/components/VirtualList.tsx";
26
+ export { VirtualList } from "#/components/VirtualList.tsx";
25
27
  // Keep the Ink-compatible root surface fixed. New terminal-core APIs live on
26
28
  // their focused subpaths rather than leaking through this entry point.
27
29
  export type {
@@ -80,6 +82,12 @@ export type { WindowSize } from "#/hooks/use-window-size.ts";
80
82
  export { useWindowSize } from "#/hooks/use-window-size.ts";
81
83
  export type { BoxMetrics, UseBoxMetricsResult } from "#/hooks/use-box-metrics.ts";
82
84
  export { useBoxMetrics } from "#/hooks/use-box-metrics.ts";
85
+ export type { VirtualScrollWindow } from "#/virtual-scroll.ts";
86
+ export type {
87
+ UseVirtualScrollOptions,
88
+ UseVirtualScrollResult,
89
+ } from "#/hooks/use-virtual-scroll.ts";
90
+ export { useVirtualScroll } from "#/hooks/use-virtual-scroll.ts";
83
91
  export type { CursorPosition } from "#/cursor-position.ts";
84
92
  export { measureElement } from "#/measure-element.ts";
85
93
  export type { ElementMetrics } from "#/measure-element.ts";
@@ -0,0 +1,133 @@
1
+ /**
2
+ Windowing math shared by `useVirtualScroll` and `<VirtualList>`.
3
+
4
+ Items are stacked vertically and measured in terminal rows. The viewport shows
5
+ `viewportHeight` rows of that stack starting `scrollTop` rows from the top.
6
+ */
7
+ export type VirtualScrollOptions = {
8
+ /**
9
+ Number of items in the list.
10
+ */
11
+ readonly count: number;
12
+
13
+ /**
14
+ Height of the item at `index` in rows. It must match what the item renders:
15
+ the window is computed from these numbers, never from the rendered output.
16
+ */
17
+ readonly itemHeight: (index: number) => number;
18
+
19
+ /**
20
+ Rows available to show items.
21
+ */
22
+ readonly viewportHeight: number;
23
+
24
+ /**
25
+ Requested distance of the viewport from the top of the list in rows. It is
26
+ clamped to the scrollable range and then moved as little as necessary to keep
27
+ `focusedIndex` fully visible.
28
+ */
29
+ readonly scrollTop: number;
30
+
31
+ /**
32
+ Item that must stay fully visible. An item taller than the viewport is aligned
33
+ to the top. Out-of-range values are ignored.
34
+ */
35
+ readonly focusedIndex?: number;
36
+ };
37
+
38
+ export type VirtualScrollWindow = {
39
+ /**
40
+ Index of the first item that intersects the viewport.
41
+ */
42
+ readonly start: number;
43
+
44
+ /**
45
+ Index after the last item that intersects the viewport.
46
+ */
47
+ readonly end: number;
48
+
49
+ /**
50
+ Effective distance of the viewport from the top of the list in rows.
51
+ */
52
+ readonly scrollTop: number;
53
+
54
+ /**
55
+ Largest `scrollTop` that still fills the viewport.
56
+ */
57
+ readonly maxScrollTop: number;
58
+
59
+ /**
60
+ Height of every item combined in rows.
61
+ */
62
+ readonly totalHeight: number;
63
+
64
+ /**
65
+ Position of the `start` item relative to the top of the viewport. Zero or
66
+ negative: a negative value means the item is partially scrolled out above.
67
+ */
68
+ readonly offset: number;
69
+
70
+ /**
71
+ Rows scrolled out above the viewport.
72
+ */
73
+ readonly hiddenAbove: number;
74
+
75
+ /**
76
+ Rows left below the viewport.
77
+ */
78
+ readonly hiddenBelow: number;
79
+ };
80
+
81
+ const clamp = (value: number, min: number, max: number): number =>
82
+ Math.min(Math.max(value, min), max);
83
+
84
+ const wholeRows = (value: number): number => Math.max(0, Math.floor(value)) || 0;
85
+
86
+ /**
87
+ Compute which items intersect a viewport over a vertical stack of items.
88
+ */
89
+ export const virtualScrollWindow = (options: VirtualScrollOptions): VirtualScrollWindow => {
90
+ const count = wholeRows(options.count);
91
+ const viewportHeight = wholeRows(options.viewportHeight);
92
+
93
+ const tops: number[] = [];
94
+ let totalHeight = 0;
95
+ for (let index = 0; index < count; index++) {
96
+ tops.push(totalHeight);
97
+ totalHeight += wholeRows(options.itemHeight(index));
98
+ }
99
+ const bottomOf = (index: number): number => (index + 1 < count ? tops[index + 1]! : totalHeight);
100
+
101
+ const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
102
+ let scrollTop = clamp(Math.floor(options.scrollTop) || 0, 0, maxScrollTop);
103
+
104
+ const focused = options.focusedIndex;
105
+ if (focused !== undefined && Number.isInteger(focused) && focused >= 0 && focused < count) {
106
+ const top = tops[focused]!;
107
+ const bottom = bottomOf(focused);
108
+ if (top < scrollTop) {
109
+ scrollTop = top;
110
+ } else if (bottom > scrollTop + viewportHeight) {
111
+ // Align the bottom edge; an item taller than the viewport shows its top.
112
+ scrollTop = Math.min(top, bottom - viewportHeight);
113
+ }
114
+ scrollTop = clamp(scrollTop, 0, maxScrollTop);
115
+ }
116
+
117
+ const viewportBottom = scrollTop + viewportHeight;
118
+ let start = 0;
119
+ while (start < count && bottomOf(start) <= scrollTop) start++;
120
+ let end = start;
121
+ while (end < count && tops[end]! < viewportBottom) end++;
122
+
123
+ return {
124
+ start,
125
+ end,
126
+ scrollTop,
127
+ maxScrollTop,
128
+ totalHeight,
129
+ offset: start < end ? tops[start]! - scrollTop : 0,
130
+ hiddenAbove: scrollTop,
131
+ hiddenBelow: Math.max(0, totalHeight - viewportBottom),
132
+ };
133
+ };