@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 +176 -0
- package/dist/index.d.ts +174 -9
- package/dist/index.js +177 -2
- package/dist/router.js +1 -1
- package/dist/{use-focus-BNG0xsb7.js → use-focus-Basd0ksv.js} +1 -1
- package/package.json +1 -1
- package/src/components/VirtualList.tsx +128 -0
- package/src/hooks/use-virtual-scroll.ts +84 -0
- package/src/index.ts +8 -0
- package/src/virtual-scroll.ts +133 -0
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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-
|
|
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-
|
|
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.
|
|
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
|
@@ -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
|
+
};
|