@alchemy.run/sigil 0.0.0-alpha.1 → 0.0.0-alpha.10

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.
Files changed (212) hide show
  1. package/README.md +499 -313
  2. package/THIRD_PARTY_NOTICES.md +70 -23
  3. package/dist/Text-DV9CuzAT.d.ts +452 -0
  4. package/dist/ansi.d.ts +217 -0
  5. package/dist/ansi.js +87 -0
  6. package/dist/capabilities.d.ts +5 -0
  7. package/dist/capabilities.js +3 -0
  8. package/dist/cell-_ZVhbfl0.js +44 -0
  9. package/dist/color-CkbalRqK.js +2 -0
  10. package/dist/color-policy-BAC9-TZX.js +592 -0
  11. package/dist/color-policy-CCxuHIdD.d.ts +22 -0
  12. package/dist/color-profile-CyeHnG1T.d.ts +97 -0
  13. package/dist/color-profile-DHhQHY55.js +36 -0
  14. package/dist/color.d.ts +21 -0
  15. package/dist/color.js +3 -0
  16. package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
  17. package/dist/detect-B3dL4Q11.js +374 -0
  18. package/dist/detect-Db6GbKOm.d.ts +195 -0
  19. package/dist/{devtools-QpCMm9JH.mjs → devtools-DbthxoD1.js} +23 -24
  20. package/dist/env-YVw64yZS.js +9 -0
  21. package/dist/escapes-CB_6CWOE.d.ts +72 -0
  22. package/dist/geometry-BxXOzJgo.d.ts +11 -0
  23. package/dist/index-D48vQhhe.d.ts +21 -0
  24. package/dist/index-DDVME65c.d.ts +919 -0
  25. package/dist/index.d.ts +1310 -0
  26. package/dist/index.js +3209 -0
  27. package/dist/osc-BFKKSqpg.js +71 -0
  28. package/dist/osc-Cn0fw77g.d.ts +23 -0
  29. package/dist/paint-Cx-zC_sX.d.ts +81 -0
  30. package/dist/query-BNc2B8GD.d.ts +152 -0
  31. package/dist/router.d.ts +392 -0
  32. package/dist/router.js +709 -0
  33. package/dist/sample-Cqw1bjUL.js +445 -0
  34. package/dist/screen-CgC2WlVM.d.ts +49 -0
  35. package/dist/screen-CiPytswf.js +342 -0
  36. package/dist/screen.d.ts +5 -0
  37. package/dist/screen.js +5 -0
  38. package/dist/semantic-text-style-DIMzC7xt.js +91 -0
  39. package/dist/serialize-BTkAZgw1.js +79 -0
  40. package/dist/session-DDQ5V300.js +723 -0
  41. package/dist/sgr-BhwaWAJB.js +246 -0
  42. package/dist/store-C1P5fOUi.d.ts +72 -0
  43. package/dist/string-width-CijQwpIk.js +69 -0
  44. package/dist/strip-BvU4toXG.js +6 -0
  45. package/dist/terminal.d.ts +121 -0
  46. package/dist/terminal.js +2 -0
  47. package/dist/tokenize-AjqbvtiT.js +1242 -0
  48. package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
  49. package/dist/truncate-D31fhU6i.js +562 -0
  50. package/dist/use-focus-CcCIdCWA.js +1337 -0
  51. package/dist/yoga-5jKhYCJC.js +3465 -0
  52. package/dist/yoga.d.ts +2 -0
  53. package/dist/yoga.js +2 -0
  54. package/package.json +70 -24
  55. package/src/ansi/chalk.ts +138 -0
  56. package/src/ansi/cursor.ts +46 -0
  57. package/src/ansi/east-asian-width.ts +259 -0
  58. package/src/ansi/escapes.ts +120 -0
  59. package/src/ansi/graphemes.ts +8 -0
  60. package/src/ansi/hyperlink.ts +44 -0
  61. package/src/ansi/index.ts +27 -0
  62. package/src/ansi/osc.ts +77 -0
  63. package/src/ansi/sgr.ts +234 -0
  64. package/src/ansi/slice.ts +43 -0
  65. package/src/ansi/string-width.ts +121 -0
  66. package/src/ansi/strip.ts +13 -0
  67. package/src/ansi/tokenize.ts +380 -0
  68. package/src/ansi/truncate.ts +196 -0
  69. package/src/ansi/wrap.ts +765 -0
  70. package/src/ansi-tokenizer.ts +510 -0
  71. package/src/capabilities/color-policy.ts +34 -0
  72. package/src/capabilities/detect.ts +608 -0
  73. package/src/capabilities/index.ts +37 -0
  74. package/src/capabilities/query.ts +679 -0
  75. package/src/capabilities/store.ts +394 -0
  76. package/src/code-excerpt.ts +39 -0
  77. package/src/color/index.ts +3 -0
  78. package/src/color/paint.ts +169 -0
  79. package/src/color/palette.ts +48 -0
  80. package/src/color/sample.ts +323 -0
  81. package/src/color.ts +1 -0
  82. package/src/components/AccessibilityContext.ts +5 -0
  83. package/src/components/AnimationContext.ts +24 -0
  84. package/src/components/AnsiText.tsx +42 -0
  85. package/src/components/App.tsx +878 -0
  86. package/src/components/AppContext.ts +111 -0
  87. package/src/components/BackgroundContext.ts +7 -0
  88. package/src/components/Box.tsx +100 -0
  89. package/src/components/CursorContext.ts +19 -0
  90. package/src/components/ErrorBoundary.tsx +39 -0
  91. package/src/components/ErrorOverview.tsx +135 -0
  92. package/src/components/FocusContext.ts +30 -0
  93. package/src/components/Hyperlink.tsx +56 -0
  94. package/src/components/Newline.tsx +16 -0
  95. package/src/components/Spacer.tsx +11 -0
  96. package/src/components/Static.tsx +60 -0
  97. package/src/components/StderrContext.ts +24 -0
  98. package/src/components/StdinContext.ts +48 -0
  99. package/src/components/StdoutContext.ts +26 -0
  100. package/src/components/TerminalOscContext.ts +25 -0
  101. package/src/components/Text.tsx +122 -0
  102. package/src/components/Transform.tsx +38 -0
  103. package/src/components/VirtualList.tsx +128 -0
  104. package/src/cursor-position.ts +103 -0
  105. package/src/devtools.ts +103 -0
  106. package/src/dom.ts +301 -0
  107. package/src/env.ts +12 -0
  108. package/src/get-max-width.ts +11 -0
  109. package/src/global.d.ts +38 -0
  110. package/src/glyphs.ts +99 -0
  111. package/src/hooks/use-animation.ts +142 -0
  112. package/src/hooks/use-app.ts +8 -0
  113. package/src/hooks/use-box-metrics.ts +134 -0
  114. package/src/hooks/use-capabilities.ts +73 -0
  115. package/src/hooks/use-cursor.ts +33 -0
  116. package/src/hooks/use-focus-manager.ts +62 -0
  117. package/src/hooks/use-focus.ts +82 -0
  118. package/src/hooks/use-input.ts +267 -0
  119. package/src/hooks/use-is-screen-reader-enabled.ts +12 -0
  120. package/src/hooks/use-paste.ts +78 -0
  121. package/src/hooks/use-stderr.ts +8 -0
  122. package/src/hooks/use-stdin.ts +10 -0
  123. package/src/hooks/use-stdout.ts +8 -0
  124. package/src/hooks/use-terminal-osc.ts +59 -0
  125. package/src/hooks/use-virtual-scroll.ts +84 -0
  126. package/src/hooks/use-window-size.ts +37 -0
  127. package/src/index.ts +96 -0
  128. package/src/ink.tsx +1452 -0
  129. package/src/input-parser.ts +303 -0
  130. package/src/instances.ts +9 -0
  131. package/src/kitty-keyboard.ts +185 -0
  132. package/src/measure-element.ts +62 -0
  133. package/src/measure-text.ts +31 -0
  134. package/src/paint-tree.ts +220 -0
  135. package/src/parse-keypress.ts +515 -0
  136. package/src/parse-stack-line.ts +138 -0
  137. package/src/patch-console.ts +106 -0
  138. package/src/quick-lru.ts +85 -0
  139. package/src/reconciler.ts +425 -0
  140. package/src/render-background.ts +59 -0
  141. package/src/render-border.ts +167 -0
  142. package/src/render-frame.ts +83 -0
  143. package/src/render-to-string.ts +146 -0
  144. package/src/render.ts +284 -0
  145. package/src/router/components.tsx +343 -0
  146. package/src/router/context.ts +41 -0
  147. package/src/router/history.ts +194 -0
  148. package/src/router/hooks.tsx +391 -0
  149. package/src/router/index.ts +34 -0
  150. package/src/router/matcher.ts +571 -0
  151. package/src/sanitize-ansi.ts +33 -0
  152. package/src/screen/ansi.ts +184 -0
  153. package/src/screen/canvas.ts +160 -0
  154. package/src/screen/cell.ts +138 -0
  155. package/src/screen/color-profile.ts +47 -0
  156. package/src/screen/geometry.ts +9 -0
  157. package/src/screen/index.ts +6 -0
  158. package/src/screen/screen.ts +305 -0
  159. package/src/screen/serialize.ts +129 -0
  160. package/src/screen.ts +1 -0
  161. package/src/semantic-text-style.ts +118 -0
  162. package/src/signal-exit.ts +106 -0
  163. package/src/squash-text-nodes.ts +37 -0
  164. package/src/stream.ts +28 -0
  165. package/src/structured-text.ts +325 -0
  166. package/src/styles.ts +753 -0
  167. package/src/terminal/index.ts +2 -0
  168. package/src/terminal/inline-presenter.ts +120 -0
  169. package/src/terminal/input.ts +92 -0
  170. package/src/terminal/render-scheduler.ts +37 -0
  171. package/src/terminal/screen-presenter.ts +242 -0
  172. package/src/terminal/session.ts +408 -0
  173. package/src/terminal-size.ts +58 -0
  174. package/src/terminal.ts +1 -0
  175. package/src/testing/browser.ts +588 -0
  176. package/src/testing/emulators.ts +205 -0
  177. package/src/testing/explorer-app/index.html +12 -0
  178. package/src/testing/explorer-app/main.ts +381 -0
  179. package/src/testing/explorer-app/style.css +194 -0
  180. package/src/testing/explorer-app/tsconfig.json +15 -0
  181. package/src/testing/explorer-app/vite-env.d.ts +1 -0
  182. package/src/testing/index.ts +26 -0
  183. package/src/testing/keys.ts +56 -0
  184. package/src/testing/live.ts +85 -0
  185. package/src/testing/matchers.ts +70 -0
  186. package/src/testing/public.ts +94 -0
  187. package/src/testing/terminal.ts +361 -0
  188. package/src/testing/vitest.ts +157 -0
  189. package/src/throttle.ts +73 -0
  190. package/src/transform-adapter.ts +14 -0
  191. package/src/types.ts +10 -0
  192. package/src/virtual-scroll.ts +133 -0
  193. package/src/wrap-text.ts +54 -0
  194. package/src/yoga/config.ts +57 -0
  195. package/src/yoga/core/absoluteLayout.ts +626 -0
  196. package/src/yoga/core/baseline.ts +66 -0
  197. package/src/yoga/core/cache.ts +136 -0
  198. package/src/yoga/core/calculateLayout.ts +2926 -0
  199. package/src/yoga/core/config.ts +104 -0
  200. package/src/yoga/core/flexLine.ts +177 -0
  201. package/src/yoga/core/helpers.ts +293 -0
  202. package/src/yoga/core/layoutResults.ts +167 -0
  203. package/src/yoga/core/node.ts +611 -0
  204. package/src/yoga/core/numeric.ts +44 -0
  205. package/src/yoga/core/pixelGrid.ts +151 -0
  206. package/src/yoga/core/style.ts +887 -0
  207. package/src/yoga/core/types.ts +224 -0
  208. package/src/yoga/generated/YGEnums.ts +263 -0
  209. package/src/yoga/index.ts +19 -0
  210. package/src/yoga/node.ts +1140 -0
  211. package/dist/index.d.mts +0 -2379
  212. package/dist/index.mjs +0 -10072
package/README.md CHANGED
@@ -1,7 +1,3 @@
1
- [![](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md)
2
-
3
- ---
4
-
5
1
  <div align="center">
6
2
  <br>
7
3
  <h1>Sigil</h1>
@@ -15,13 +11,29 @@
15
11
  - **Self-contained**: the only runtime dependencies are `react-reconciler` and `scheduler`. Everything else — the ANSI/terminal subsystem (`src/ansi/`), styling, width measurement, wrapping — is first-class TypeScript in this repository.
16
12
  - **Integrated layout engine**: layout is computed by a TypeScript port of [Yoga](https://github.com/facebook/yoga) (`src/yoga/`) instead of the Yoga WASM binary, continuously verified against an f64-patched reference Yoga build via differential fuzzing.
17
13
  - **Modern toolchain**: built with [Vite+](https://viteplus.dev) (Vitest, Oxlint, Oxfmt, tsdown) and TypeScript 7.
14
+ - **Structured terminal core**: cells retain semantic colors, gradients, links, and graphemes through layout and composition, then serialize for the target terminal profile.
15
+
16
+ Low-level integrations are available through focused `ansi`, `capabilities`, `color`, `router`, `screen`, `terminal`, and `yoga` subpaths. See the [architecture guide](docs/architecture.md), [API stability policy](docs/api-stability.md), [color semantics](docs/color-semantics.md), and [performance notes](docs/performance.md).
18
17
 
19
18
  Upstream Ink and Yoga are tracked as submodules in `.vendor/` — upstream's test suites run against this codebase as part of the regular test run. See `THIRD_PARTY_NOTICES.md` for attribution.
20
19
 
21
- The documentation below is inherited from Ink; the API is unchanged apart from imports
20
+ The familiar Ink component and hook surface remains available from the package root. Sigil also adds terminal-aware APIs described below. The longer API reference is inherited from Ink and uses Sigil imports where applicable.
21
+
22
+ ## Sigil extensions
23
+
24
+ - **Structured rendering** — layout produces a semantic `Screen` of grapheme-aware cells before terminal-specific ANSI serialization. Import low-level primitives from `@alchemy.run/sigil/screen`, `@alchemy.run/sigil/color`, and `@alchemy.run/sigil/terminal`.
25
+ - **Terminal capabilities** — `getCapabilities()` returns the current snapshot, while `capabilities.subscribe()` and `useCapabilitiesChange()` observe live terminal reports. The framework-free API is available from `@alchemy.run/sigil/capabilities`.
26
+ - **Terminal integrations** — `<Hyperlink>` provides OSC 8 links with a fallback. `useTitle`, `useWorkingDirectory`, `useProgress`, `useNotification`, `useClipboard`, and `usePointerShape` manage session-scoped OSC state.
27
+ - **ANSI-aware text** — `<AnsiText>` interprets ANSI-styled input without flattening its semantic colors before rendering.
28
+ - **Routing** — `@alchemy.run/sigil/router` provides an in-memory router designed for terminal applications. See the [routing recipe](recipes/routing.md).
29
+ - **Testing** — the repository includes an internal emulator-backed E2E harness and interactive explorer. These utilities are not part of the public API while their design is evolving.
30
+
31
+ Sigil requires Node.js 22 or newer and React 19.2 or newer.
22
32
 
23
33
  ---
24
34
 
35
+ [![](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md)
36
+
25
37
  Ink provides the same component-based UI building experience that React offers in the browser, but for command-line apps.
26
38
  It uses [Yoga](https://github.com/facebook/yoga) to build Flexbox layouts in the terminal, so most CSS-like properties are available in Ink as well.
27
39
  If you are already familiar with React, you already know Ink.
@@ -60,19 +72,19 @@ import React, { useState, useEffect } from "react";
60
72
  import { render, Text } from "@alchemy.run/sigil";
61
73
 
62
74
  const Counter = () => {
63
- const [counter, setCounter] = useState(0);
75
+ const [counter, setCounter] = useState(0);
64
76
 
65
- useEffect(() => {
66
- const timer = setInterval(() => {
67
- setCounter((previousCounter) => previousCounter + 1);
68
- }, 100);
77
+ useEffect(() => {
78
+ const timer = setInterval(() => {
79
+ setCounter((previousCounter) => previousCounter + 1);
80
+ }, 100);
69
81
 
70
- return () => {
71
- clearInterval(timer);
72
- };
73
- }, []);
82
+ return () => {
83
+ clearInterval(timer);
84
+ };
85
+ }, []);
74
86
 
75
- return <Text color="green">{counter} tests passed</Text>;
87
+ return <Text color="green">{counter} tests passed</Text>;
76
88
  };
77
89
 
78
90
  render(<Counter />);
@@ -152,6 +164,7 @@ _(PRs welcome. Append new entries at the end. Repos must have 100+ stars and sho
152
164
  - [`<Spacer>`](#spacer)
153
165
  - [`<Static>`](#static)
154
166
  - [`<Transform>`](#transform)
167
+ - [`<VirtualList>`](#virtuallist)
155
168
  - [Hooks](#hooks)
156
169
  - [`useInput`](#useinputinputhandler-options)
157
170
  - [`usePaste`](#usepastehandler-options)
@@ -159,6 +172,7 @@ _(PRs welcome. Append new entries at the end. Repos must have 100+ stars and sho
159
172
  - [`useStdin`](#usestdin)
160
173
  - [`useStdout`](#usestdout)
161
174
  - [`useBoxMetrics`](#useboxmetricsref)
175
+ - [`useVirtualScroll`](#usevirtualscrolloptions)
162
176
  - [`useStderr`](#usestderr)
163
177
  - [`useWindowSize`](#usewindowsize)
164
178
  - [`useFocus`](#usefocusoptions)
@@ -202,7 +216,7 @@ npm install --save-dev @babel/preset-react
202
216
 
203
217
  ```json
204
218
  {
205
- "presets": ["@babel/preset-react"]
219
+ "presets": ["@babel/preset-react"]
206
220
  }
207
221
  ```
208
222
 
@@ -266,18 +280,18 @@ This component can display text and change its style to make it bold, underlined
266
280
  import { render, Text } from "@alchemy.run/sigil";
267
281
 
268
282
  const Example = () => (
269
- <>
270
- <Text color="green">I am green</Text>
271
- <Text color="black" backgroundColor="white">
272
- I am black on white
273
- </Text>
274
- <Text color="#ffffff">I am white</Text>
275
- <Text bold>I am bold</Text>
276
- <Text italic>I am italic</Text>
277
- <Text underline>I am underline</Text>
278
- <Text strikethrough>I am strikethrough</Text>
279
- <Text inverse>I am inversed</Text>
280
- </>
283
+ <>
284
+ <Text color="green">I am green</Text>
285
+ <Text color="black" backgroundColor="white">
286
+ I am black on white
287
+ </Text>
288
+ <Text color="#ffffff">I am white</Text>
289
+ <Text bold>I am bold</Text>
290
+ <Text italic>I am italic</Text>
291
+ <Text underline>I am underline</Text>
292
+ <Text strikethrough>I am strikethrough</Text>
293
+ <Text inverse>I am inversed</Text>
294
+ </>
281
295
  );
282
296
 
283
297
  render(<Example />);
@@ -324,7 +338,7 @@ Dim the color (make it less bright).
324
338
 
325
339
  ```jsx
326
340
  <Text color="red" dimColor>
327
- Dimmed Red
341
+ Dimmed Red
328
342
  </Text>
329
343
  ```
330
344
 
@@ -367,7 +381,7 @@ Invert background and foreground colors.
367
381
 
368
382
  ```jsx
369
383
  <Text inverse color="yellow">
370
- Inversed Yellow
384
+ Inversed Yellow
371
385
  </Text>
372
386
  ```
373
387
 
@@ -421,9 +435,9 @@ It's like `<div style="display: flex">` in the browser.
421
435
  import { render, Box, Text } from "@alchemy.run/sigil";
422
436
 
423
437
  const Example = () => (
424
- <Box margin={2}>
425
- <Text>This is a box with margin</Text>
426
- </Box>
438
+ <Box margin={2}>
439
+ <Text>This is a box with margin</Text>
440
+ </Box>
427
441
  );
428
442
 
429
443
  render(<Example />);
@@ -440,17 +454,17 @@ You can also set it as a percentage, which will calculate the width based on the
440
454
 
441
455
  ```jsx
442
456
  <Box width={4}>
443
- <Text>X</Text>
457
+ <Text>X</Text>
444
458
  </Box>
445
459
  //=> 'X '
446
460
  ```
447
461
 
448
462
  ```jsx
449
463
  <Box width={10}>
450
- <Box width="50%">
451
- <Text>X</Text>
452
- </Box>
453
- <Text>Y</Text>
464
+ <Box width="50%">
465
+ <Text>X</Text>
466
+ </Box>
467
+ <Text>Y</Text>
454
468
  </Box>
455
469
  //=> 'X Y'
456
470
  ```
@@ -464,17 +478,17 @@ You can also set it as a percentage, which will calculate the height based on th
464
478
 
465
479
  ```jsx
466
480
  <Box height={4}>
467
- <Text>X</Text>
481
+ <Text>X</Text>
468
482
  </Box>
469
483
  //=> 'X\n\n\n'
470
484
  ```
471
485
 
472
486
  ```jsx
473
487
  <Box height={6} flexDirection="column">
474
- <Box height="50%">
475
- <Text>X</Text>
476
- </Box>
477
- <Text>Y</Text>
488
+ <Box height="50%">
489
+ <Text>X</Text>
490
+ </Box>
491
+ <Text>Y</Text>
478
492
  </Box>
479
493
  //=> 'X\n\n\nY\n\n'
480
494
  ```
@@ -648,9 +662,9 @@ Size of the gap between an element's columns and rows. A shorthand for `columnGa
648
662
 
649
663
  ```jsx
650
664
  <Box gap={1} width={3} flexWrap="wrap">
651
- <Text>A</Text>
652
- <Text>B</Text>
653
- <Text>C</Text>
665
+ <Text>A</Text>
666
+ <Text>B</Text>
667
+ <Text>C</Text>
654
668
  </Box>
655
669
  // A B
656
670
  //
@@ -666,8 +680,8 @@ Size of the gap between an element's columns.
666
680
 
667
681
  ```jsx
668
682
  <Box columnGap={1}>
669
- <Text>A</Text>
670
- <Text>B</Text>
683
+ <Text>A</Text>
684
+ <Text>B</Text>
671
685
  </Box>
672
686
  // A B
673
687
  ```
@@ -681,8 +695,8 @@ Size of the gap between an element's rows.
681
695
 
682
696
  ```jsx
683
697
  <Box flexDirection="column" rowGap={1}>
684
- <Text>A</Text>
685
- <Text>B</Text>
698
+ <Text>A</Text>
699
+ <Text>B</Text>
686
700
  </Box>
687
701
  // A
688
702
  //
@@ -700,10 +714,10 @@ See [flex-grow](https://css-tricks.com/almanac/properties/f/flex-grow/).
700
714
 
701
715
  ```jsx
702
716
  <Box>
703
- <Text>Label:</Text>
704
- <Box flexGrow={1}>
705
- <Text>Fills all remaining space</Text>
706
- </Box>
717
+ <Text>Label:</Text>
718
+ <Box flexGrow={1}>
719
+ <Text>Fills all remaining space</Text>
720
+ </Box>
707
721
  </Box>
708
722
  ```
709
723
 
@@ -716,12 +730,12 @@ See [flex-shrink](https://css-tricks.com/almanac/properties/f/flex-shrink/).
716
730
 
717
731
  ```jsx
718
732
  <Box width={20}>
719
- <Box flexShrink={2} width={10}>
720
- <Text>Will be 1/4</Text>
721
- </Box>
722
- <Box width={10}>
723
- <Text>Will be 3/4</Text>
724
- </Box>
733
+ <Box flexShrink={2} width={10}>
734
+ <Text>Will be 1/4</Text>
735
+ </Box>
736
+ <Box width={10}>
737
+ <Text>Will be 3/4</Text>
738
+ </Box>
725
739
  </Box>
726
740
  ```
727
741
 
@@ -733,20 +747,20 @@ See [flex-basis](https://css-tricks.com/almanac/properties/f/flex-basis/).
733
747
 
734
748
  ```jsx
735
749
  <Box width={6}>
736
- <Box flexBasis={3}>
737
- <Text>X</Text>
738
- </Box>
739
- <Text>Y</Text>
750
+ <Box flexBasis={3}>
751
+ <Text>X</Text>
752
+ </Box>
753
+ <Text>Y</Text>
740
754
  </Box>
741
755
  //=> 'X Y'
742
756
  ```
743
757
 
744
758
  ```jsx
745
759
  <Box width={6}>
746
- <Box flexBasis="50%">
747
- <Text>X</Text>
748
- </Box>
749
- <Text>Y</Text>
760
+ <Box flexBasis="50%">
761
+ <Text>X</Text>
762
+ </Box>
763
+ <Text>Y</Text>
750
764
  </Box>
751
765
  //=> 'X Y'
752
766
  ```
@@ -799,8 +813,8 @@ See [flex-wrap](https://css-tricks.com/almanac/properties/f/flex-wrap/).
799
813
 
800
814
  ```jsx
801
815
  <Box width={2} flexWrap="wrap">
802
- <Text>A</Text>
803
- <Text>BC</Text>
816
+ <Text>A</Text>
817
+ <Text>BC</Text>
804
818
  </Box>
805
819
  // A
806
820
  // B C
@@ -808,9 +822,9 @@ See [flex-wrap](https://css-tricks.com/almanac/properties/f/flex-wrap/).
808
822
 
809
823
  ```jsx
810
824
  <Box flexDirection="column" height={2} flexWrap="wrap">
811
- <Text>A</Text>
812
- <Text>B</Text>
813
- <Text>C</Text>
825
+ <Text>A</Text>
826
+ <Text>B</Text>
827
+ <Text>C</Text>
814
828
  </Box>
815
829
  // A C
816
830
  // B
@@ -1049,37 +1063,37 @@ Ink uses border styles from the [`cli-boxes`](https://github.com/sindresorhus/cl
1049
1063
 
1050
1064
  ```jsx
1051
1065
  <Box flexDirection="column">
1052
- <Box>
1053
- <Box borderStyle="single" marginRight={2}>
1054
- <Text>single</Text>
1055
- </Box>
1056
-
1057
- <Box borderStyle="double" marginRight={2}>
1058
- <Text>double</Text>
1059
- </Box>
1060
-
1061
- <Box borderStyle="round" marginRight={2}>
1062
- <Text>round</Text>
1063
- </Box>
1064
-
1065
- <Box borderStyle="bold">
1066
- <Text>bold</Text>
1067
- </Box>
1068
- </Box>
1069
-
1070
- <Box marginTop={1}>
1071
- <Box borderStyle="singleDouble" marginRight={2}>
1072
- <Text>singleDouble</Text>
1073
- </Box>
1074
-
1075
- <Box borderStyle="doubleSingle" marginRight={2}>
1076
- <Text>doubleSingle</Text>
1077
- </Box>
1078
-
1079
- <Box borderStyle="classic">
1080
- <Text>classic</Text>
1081
- </Box>
1082
- </Box>
1066
+ <Box>
1067
+ <Box borderStyle="single" marginRight={2}>
1068
+ <Text>single</Text>
1069
+ </Box>
1070
+
1071
+ <Box borderStyle="double" marginRight={2}>
1072
+ <Text>double</Text>
1073
+ </Box>
1074
+
1075
+ <Box borderStyle="round" marginRight={2}>
1076
+ <Text>round</Text>
1077
+ </Box>
1078
+
1079
+ <Box borderStyle="bold">
1080
+ <Text>bold</Text>
1081
+ </Box>
1082
+ </Box>
1083
+
1084
+ <Box marginTop={1}>
1085
+ <Box borderStyle="singleDouble" marginRight={2}>
1086
+ <Text>singleDouble</Text>
1087
+ </Box>
1088
+
1089
+ <Box borderStyle="doubleSingle" marginRight={2}>
1090
+ <Text>doubleSingle</Text>
1091
+ </Box>
1092
+
1093
+ <Box borderStyle="classic">
1094
+ <Text>classic</Text>
1095
+ </Box>
1096
+ </Box>
1083
1097
  </Box>
1084
1098
  ```
1085
1099
 
@@ -1089,18 +1103,18 @@ Alternatively, pass a custom border style like so:
1089
1103
 
1090
1104
  ```jsx
1091
1105
  <Box
1092
- borderStyle={{
1093
- topLeft: "↘",
1094
- top: "↓",
1095
- topRight: "↙",
1096
- left: "→",
1097
- bottomLeft: "↗",
1098
- bottom: "↑",
1099
- bottomRight: "↖",
1100
- right: "←",
1101
- }}
1106
+ borderStyle={{
1107
+ topLeft: "↘",
1108
+ top: "↓",
1109
+ topRight: "↙",
1110
+ left: "→",
1111
+ bottomLeft: "↗",
1112
+ bottom: "↑",
1113
+ bottomRight: "↖",
1114
+ right: "←",
1115
+ }}
1102
1116
  >
1103
- <Text>Custom</Text>
1117
+ <Text>Custom</Text>
1104
1118
  </Box>
1105
1119
  ```
1106
1120
 
@@ -1115,7 +1129,7 @@ A shorthand for setting `borderTopColor`, `borderRightColor`, `borderBottomColor
1115
1129
 
1116
1130
  ```jsx
1117
1131
  <Box borderStyle="round" borderColor="green">
1118
- <Text>Green Rounded Box</Text>
1132
+ <Text>Green Rounded Box</Text>
1119
1133
  </Box>
1120
1134
  ```
1121
1135
 
@@ -1130,7 +1144,7 @@ Accepts the same values as [`color`](#color) in `<Text>` component.
1130
1144
 
1131
1145
  ```jsx
1132
1146
  <Box borderStyle="round" borderTopColor="green">
1133
- <Text>Hello world</Text>
1147
+ <Text>Hello world</Text>
1134
1148
  </Box>
1135
1149
  ```
1136
1150
 
@@ -1143,7 +1157,7 @@ Accepts the same values as [`color`](#color) in `<Text>` component.
1143
1157
 
1144
1158
  ```jsx
1145
1159
  <Box borderStyle="round" borderRightColor="green">
1146
- <Text>Hello world</Text>
1160
+ <Text>Hello world</Text>
1147
1161
  </Box>
1148
1162
  ```
1149
1163
 
@@ -1156,7 +1170,7 @@ Accepts the same values as [`color`](#color) in `<Text>` component.
1156
1170
 
1157
1171
  ```jsx
1158
1172
  <Box borderStyle="round" borderBottomColor="green">
1159
- <Text>Hello world</Text>
1173
+ <Text>Hello world</Text>
1160
1174
  </Box>
1161
1175
  ```
1162
1176
 
@@ -1169,7 +1183,7 @@ Accepts the same values as [`color`](#color) in `<Text>` component.
1169
1183
 
1170
1184
  ```jsx
1171
1185
  <Box borderStyle="round" borderLeftColor="green">
1172
- <Text>Hello world</Text>
1186
+ <Text>Hello world</Text>
1173
1187
  </Box>
1174
1188
  ```
1175
1189
 
@@ -1183,7 +1197,7 @@ A shorthand for setting `borderTopDimColor`, `borderBottomDimColor`, `borderLeft
1183
1197
 
1184
1198
  ```jsx
1185
1199
  <Box borderStyle="round" borderDimColor>
1186
- <Text>Hello world</Text>
1200
+ <Text>Hello world</Text>
1187
1201
  </Box>
1188
1202
  ```
1189
1203
 
@@ -1196,7 +1210,7 @@ Dim the top border color.
1196
1210
 
1197
1211
  ```jsx
1198
1212
  <Box borderStyle="round" borderTopDimColor>
1199
- <Text>Hello world</Text>
1213
+ <Text>Hello world</Text>
1200
1214
  </Box>
1201
1215
  ```
1202
1216
 
@@ -1209,7 +1223,7 @@ Dim the bottom border color.
1209
1223
 
1210
1224
  ```jsx
1211
1225
  <Box borderStyle="round" borderBottomDimColor>
1212
- <Text>Hello world</Text>
1226
+ <Text>Hello world</Text>
1213
1227
  </Box>
1214
1228
  ```
1215
1229
 
@@ -1222,7 +1236,7 @@ Dim the left border color.
1222
1236
 
1223
1237
  ```jsx
1224
1238
  <Box borderStyle="round" borderLeftDimColor>
1225
- <Text>Hello world</Text>
1239
+ <Text>Hello world</Text>
1226
1240
  </Box>
1227
1241
  ```
1228
1242
 
@@ -1235,7 +1249,7 @@ Dim the right border color.
1235
1249
 
1236
1250
  ```jsx
1237
1251
  <Box borderStyle="round" borderRightDimColor>
1238
- <Text>Hello world</Text>
1252
+ <Text>Hello world</Text>
1239
1253
  </Box>
1240
1254
  ```
1241
1255
 
@@ -1249,7 +1263,7 @@ A shorthand for setting `borderTopBackgroundColor`, `borderRightBackgroundColor`
1249
1263
 
1250
1264
  ```jsx
1251
1265
  <Box borderStyle="round" borderColor="white" borderBackgroundColor="green">
1252
- <Text>Hello world</Text>
1266
+ <Text>Hello world</Text>
1253
1267
  </Box>
1254
1268
  ```
1255
1269
 
@@ -1263,7 +1277,7 @@ Falls back to `borderBackgroundColor` if not specified.
1263
1277
 
1264
1278
  ```jsx
1265
1279
  <Box borderStyle="round" borderColor="white" borderTopBackgroundColor="green">
1266
- <Text>Hello world</Text>
1280
+ <Text>Hello world</Text>
1267
1281
  </Box>
1268
1282
  ```
1269
1283
 
@@ -1277,7 +1291,7 @@ Falls back to `borderBackgroundColor` if not specified.
1277
1291
 
1278
1292
  ```jsx
1279
1293
  <Box borderStyle="round" borderColor="white" borderBottomBackgroundColor="green">
1280
- <Text>Hello world</Text>
1294
+ <Text>Hello world</Text>
1281
1295
  </Box>
1282
1296
  ```
1283
1297
 
@@ -1291,7 +1305,7 @@ Falls back to `borderBackgroundColor` if not specified.
1291
1305
 
1292
1306
  ```jsx
1293
1307
  <Box borderStyle="round" borderColor="white" borderRightBackgroundColor="green">
1294
- <Text>Hello world</Text>
1308
+ <Text>Hello world</Text>
1295
1309
  </Box>
1296
1310
  ```
1297
1311
 
@@ -1305,7 +1319,7 @@ Falls back to `borderBackgroundColor` if not specified.
1305
1319
 
1306
1320
  ```jsx
1307
1321
  <Box borderStyle="round" borderColor="white" borderLeftBackgroundColor="green">
1308
- <Text>Hello world</Text>
1322
+ <Text>Hello world</Text>
1309
1323
  </Box>
1310
1324
  ```
1311
1325
 
@@ -1349,27 +1363,29 @@ Accepts the same values as [`color`](#color) in the `<Text>` component.
1349
1363
 
1350
1364
  ```jsx
1351
1365
  <Box flexDirection="column">
1352
- <Box backgroundColor="red" width={20} height={5} alignSelf="flex-start">
1353
- <Text>Red background</Text>
1354
- </Box>
1366
+ <Box backgroundColor="red" width={20} height={5} alignSelf="flex-start">
1367
+ <Text>Red background</Text>
1368
+ </Box>
1355
1369
 
1356
- <Box backgroundColor="#FF8800" width={20} height={3} marginTop={1} alignSelf="flex-start">
1357
- <Text>Orange background</Text>
1358
- </Box>
1370
+ <Box backgroundColor="#FF8800" width={20} height={3} marginTop={1} alignSelf="flex-start">
1371
+ <Text>Orange background</Text>
1372
+ </Box>
1359
1373
 
1360
- <Box backgroundColor="rgb(0, 255, 0)" width={20} height={3} marginTop={1} alignSelf="flex-start">
1361
- <Text>Green background</Text>
1362
- </Box>
1374
+ <Box backgroundColor="rgb(0, 255, 0)" width={20} height={3} marginTop={1} alignSelf="flex-start">
1375
+ <Text>Green background</Text>
1376
+ </Box>
1363
1377
  </Box>
1364
1378
  ```
1365
1379
 
1366
1380
  The background color fills the entire `<Box>` area and is inherited by child `<Text>` components unless they specify their own `backgroundColor`.
1367
1381
 
1382
+ Use `backgroundColor=""` to paint the box with explicit blank cells on the terminal's default background. This is useful for opaque compositor layers: lower-layer glyphs are erased without assuming what color the user's terminal background is. Omitting `backgroundColor` keeps the box transparent.
1383
+
1368
1384
  ```jsx
1369
1385
  <Box backgroundColor="blue" alignSelf="flex-start">
1370
- <Text>Blue inherited </Text>
1371
- <Text backgroundColor="yellow">Yellow override </Text>
1372
- <Text>Blue inherited again</Text>
1386
+ <Text>Blue inherited </Text>
1387
+ <Text backgroundColor="yellow">Yellow override </Text>
1388
+ <Text>Blue inherited again</Text>
1373
1389
  </Box>
1374
1390
  ```
1375
1391
 
@@ -1377,7 +1393,7 @@ Background colors work with borders and padding:
1377
1393
 
1378
1394
  ```jsx
1379
1395
  <Box backgroundColor="cyan" borderStyle="round" padding={1} alignSelf="flex-start">
1380
- <Text>Background with border and padding</Text>
1396
+ <Text>Background with border and padding</Text>
1381
1397
  </Box>
1382
1398
  ```
1383
1399
 
@@ -1399,11 +1415,11 @@ Number of newlines to insert.
1399
1415
  import { render, Text, Newline } from "@alchemy.run/sigil";
1400
1416
 
1401
1417
  const Example = () => (
1402
- <Text>
1403
- <Text color="green">Hello</Text>
1404
- <Newline />
1405
- <Text color="red">World</Text>
1406
- </Text>
1418
+ <Text>
1419
+ <Text color="green">Hello</Text>
1420
+ <Newline />
1421
+ <Text color="red">World</Text>
1422
+ </Text>
1407
1423
  );
1408
1424
 
1409
1425
  render(<Example />);
@@ -1427,11 +1443,11 @@ For example, using `<Spacer>` in a `<Box>` with default flex direction (`row`) w
1427
1443
  import { render, Box, Text, Spacer } from "@alchemy.run/sigil";
1428
1444
 
1429
1445
  const Example = () => (
1430
- <Box>
1431
- <Text>Left</Text>
1432
- <Spacer />
1433
- <Text>Right</Text>
1434
- </Box>
1446
+ <Box>
1447
+ <Text>Left</Text>
1448
+ <Spacer />
1449
+ <Text>Right</Text>
1450
+ </Box>
1435
1451
  );
1436
1452
 
1437
1453
  render(<Example />);
@@ -1444,11 +1460,11 @@ Note that the container needs to be tall enough to see this in effect.
1444
1460
  import { render, Box, Text, Spacer } from "@alchemy.run/sigil";
1445
1461
 
1446
1462
  const Example = () => (
1447
- <Box flexDirection="column" height={10}>
1448
- <Text>Top</Text>
1449
- <Spacer />
1450
- <Text>Bottom</Text>
1451
- </Box>
1463
+ <Box flexDirection="column" height={10}>
1464
+ <Text>Top</Text>
1465
+ <Spacer />
1466
+ <Text>Bottom</Text>
1467
+ </Box>
1452
1468
  );
1453
1469
 
1454
1470
  render(<Example />);
@@ -1472,51 +1488,51 @@ import React, { useState, useEffect } from "react";
1472
1488
  import { render, Static, Box, Text } from "@alchemy.run/sigil";
1473
1489
 
1474
1490
  const Example = () => {
1475
- const [tests, setTests] = useState([]);
1476
-
1477
- useEffect(() => {
1478
- let completedTests = 0;
1479
- let timer;
1480
-
1481
- const run = () => {
1482
- // Fake 10 completed tests
1483
- if (completedTests++ < 10) {
1484
- setTests((previousTests) => [
1485
- ...previousTests,
1486
- {
1487
- id: previousTests.length,
1488
- title: `Test #${previousTests.length + 1}`,
1489
- },
1490
- ]);
1491
-
1492
- timer = setTimeout(run, 100);
1493
- }
1494
- };
1495
-
1496
- run();
1497
-
1498
- return () => {
1499
- clearTimeout(timer);
1500
- };
1501
- }, []);
1502
-
1503
- return (
1504
- <>
1505
- {/* This part will be rendered once to the terminal */}
1506
- <Static items={tests}>
1507
- {(test) => (
1508
- <Box key={test.id}>
1509
- <Text color="green">✔ {test.title}</Text>
1510
- </Box>
1511
- )}
1512
- </Static>
1513
-
1514
- {/* This part keeps updating as state changes */}
1515
- <Box marginTop={1}>
1516
- <Text dimColor>Completed tests: {tests.length}</Text>
1517
- </Box>
1518
- </>
1519
- );
1491
+ const [tests, setTests] = useState([]);
1492
+
1493
+ useEffect(() => {
1494
+ let completedTests = 0;
1495
+ let timer;
1496
+
1497
+ const run = () => {
1498
+ // Fake 10 completed tests
1499
+ if (completedTests++ < 10) {
1500
+ setTests((previousTests) => [
1501
+ ...previousTests,
1502
+ {
1503
+ id: previousTests.length,
1504
+ title: `Test #${previousTests.length + 1}`,
1505
+ },
1506
+ ]);
1507
+
1508
+ timer = setTimeout(run, 100);
1509
+ }
1510
+ };
1511
+
1512
+ run();
1513
+
1514
+ return () => {
1515
+ clearTimeout(timer);
1516
+ };
1517
+ }, []);
1518
+
1519
+ return (
1520
+ <>
1521
+ {/* This part will be rendered once to the terminal */}
1522
+ <Static items={tests}>
1523
+ {(test) => (
1524
+ <Box key={test.id}>
1525
+ <Text color="green">✔ {test.title}</Text>
1526
+ </Box>
1527
+ )}
1528
+ </Static>
1529
+
1530
+ {/* This part keeps updating as state changes */}
1531
+ <Box marginTop={1}>
1532
+ <Text dimColor>Completed tests: {tests.length}</Text>
1533
+ </Box>
1534
+ </>
1535
+ );
1520
1536
  };
1521
1537
 
1522
1538
  render(<Example />);
@@ -1558,16 +1574,16 @@ Note that a `key` must be assigned to the root component.
1558
1574
 
1559
1575
  ```jsx
1560
1576
  <Static items={["a", "b", "c"]}>
1561
- {(item, index) => {
1562
- // This function is called for every item in ['a', 'b', 'c']
1563
- // `item` is 'a', 'b', 'c'
1564
- // `index` is 0, 1, 2
1565
- return (
1566
- <Box key={index}>
1567
- <Text>Item: {item}</Text>
1568
- </Box>
1569
- );
1570
- }}
1577
+ {(item, index) => {
1578
+ // This function is called for every item in ['a', 'b', 'c']
1579
+ // `item` is 'a', 'b', 'c'
1580
+ // `index` is 0, 1, 2
1581
+ return (
1582
+ <Box key={index}>
1583
+ <Text>Item: {item}</Text>
1584
+ </Box>
1585
+ );
1586
+ }}
1571
1587
  </Static>
1572
1588
  ```
1573
1589
 
@@ -1588,9 +1604,9 @@ That's what the `<Transform>` component does: it gives you an output string of i
1588
1604
  import { render, Transform } from "@alchemy.run/sigil";
1589
1605
 
1590
1606
  const Example = () => (
1591
- <Transform transform={(output) => output.toUpperCase()}>
1592
- <Text>Hello World</Text>
1593
- </Transform>
1607
+ <Transform transform={(output) => output.toUpperCase()}>
1608
+ <Text>Hello World</Text>
1609
+ </Transform>
1594
1610
  );
1595
1611
 
1596
1612
  render(<Example />);
@@ -1606,18 +1622,18 @@ For example, to implement a hanging indent component, you can indent all the lin
1606
1622
  import { render, Transform } from "@alchemy.run/sigil";
1607
1623
 
1608
1624
  const HangingIndent = ({ indent = 4, children }) => (
1609
- <Transform transform={(line, index) => (index === 0 ? line : " ".repeat(indent) + line)}>
1610
- {children}
1611
- </Transform>
1625
+ <Transform transform={(line, index) => (index === 0 ? line : " ".repeat(indent) + line)}>
1626
+ {children}
1627
+ </Transform>
1612
1628
  );
1613
1629
 
1614
1630
  const text =
1615
- "WHEN I WROTE the following pages, or rather the bulk of them, " +
1616
- "I lived alone, in the woods, a mile from any neighbor, in a " +
1617
- "house which I had built myself, on the shore of Walden Pond, " +
1618
- "in Concord, Massachusetts, and earned my living by the labor " +
1619
- "of my hands only. I lived there two years and two months. At " +
1620
- "present I am a sojourner in civilized life again.";
1631
+ "WHEN I WROTE the following pages, or rather the bulk of them, " +
1632
+ "I lived alone, in the woods, a mile from any neighbor, in a " +
1633
+ "house which I had built myself, on the shore of Walden Pond, " +
1634
+ "in Concord, Massachusetts, and earned my living by the labor " +
1635
+ "of my hands only. I lived there two years and two months. At " +
1636
+ "present I am a sojourner in civilized life again.";
1621
1637
 
1622
1638
  render(<HangingIndent indent={4}>{text}</HangingIndent>);
1623
1639
  ```
@@ -1641,6 +1657,87 @@ Type: `number`
1641
1657
 
1642
1658
  The zero-indexed line number of the line that's currently being transformed.
1643
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
+
1644
1741
  ## Hooks
1645
1742
 
1646
1743
  ### useInput(inputHandler, options?)
@@ -1955,7 +2052,7 @@ import { useApp } from "@alchemy.run/sigil";
1955
2052
  const { suspendTerminal } = useApp();
1956
2053
 
1957
2054
  await suspendTerminal(async () => {
1958
- await runEditor();
2055
+ await runEditor();
1959
2056
  });
1960
2057
  ```
1961
2058
 
@@ -1965,9 +2062,9 @@ When called without a callback, it returns a suspension you resume yourself. `re
1965
2062
  const suspension = await suspendTerminal();
1966
2063
 
1967
2064
  try {
1968
- await runEditor();
2065
+ await runEditor();
1969
2066
  } finally {
1970
- await suspension.resume();
2067
+ await suspension.resume();
1971
2068
  }
1972
2069
  ```
1973
2070
 
@@ -2013,9 +2110,9 @@ A component using `setRawMode` might want to use `isRawModeSupported` to nicely
2013
2110
  import { useStdin } from "@alchemy.run/sigil";
2014
2111
 
2015
2112
  const Example = () => {
2016
- const { isRawModeSupported } = useStdin();
2113
+ const { isRawModeSupported } = useStdin();
2017
2114
 
2018
- return isRawModeSupported ? <MyInputComponent /> : <MyComponentThatDoesntUseInput />;
2115
+ return isRawModeSupported ? <MyInputComponent /> : <MyComponentThatDoesntUseInput />;
2019
2116
  };
2020
2117
  ```
2021
2118
 
@@ -2116,14 +2213,14 @@ import { useRef } from "react";
2116
2213
  import { Box, Text, useBoxMetrics } from "@alchemy.run/sigil";
2117
2214
 
2118
2215
  const Example = () => {
2119
- const ref = useRef(null);
2120
- const { width, height, left, top, hasMeasured } = useBoxMetrics(ref);
2121
-
2122
- return (
2123
- <Box ref={ref}>
2124
- <Text>{hasMeasured ? `${width}x${height} at ${left},${top}` : "Measuring..."}</Text>
2125
- </Box>
2126
- );
2216
+ const ref = useRef(null);
2217
+ const { width, height, left, top, hasMeasured } = useBoxMetrics(ref);
2218
+
2219
+ return (
2220
+ <Box ref={ref}>
2221
+ <Text>{hasMeasured ? `${width}x${height} at ${left},${top}` : "Measuring..."}</Text>
2222
+ </Box>
2223
+ );
2127
2224
  };
2128
2225
  ```
2129
2226
 
@@ -2160,6 +2257,99 @@ Whether the currently tracked element has been measured.
2160
2257
  > [!NOTE]
2161
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.
2162
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
+
2163
2353
  ### useStderr()
2164
2354
 
2165
2355
  A React hook that returns the stderr stream and stderr-related utilities.
@@ -2217,13 +2407,13 @@ A React hook that returns the current terminal dimensions and re-renders the com
2217
2407
  import { Text, useWindowSize } from "@alchemy.run/sigil";
2218
2408
 
2219
2409
  const Example = () => {
2220
- const { columns, rows } = useWindowSize();
2410
+ const { columns, rows } = useWindowSize();
2221
2411
 
2222
- return (
2223
- <Text>
2224
- {columns}x{rows}
2225
- </Text>
2226
- );
2412
+ return (
2413
+ <Text>
2414
+ {columns}x{rows}
2415
+ </Text>
2416
+ );
2227
2417
  };
2228
2418
  ```
2229
2419
 
@@ -2240,7 +2430,7 @@ Type: `number`
2240
2430
  Number of rows (vertical character cells).
2241
2431
 
2242
2432
  > [!NOTE]
2243
- > When the terminal is resized narrower, ghost lines may briefly appear depending on the terminal emulator's reflow behavior.
2433
+ > On terminals that send in-band size reports (mode 2048, e.g. Ghostty, kitty, foot) the frame is repainted as soon as the report arrives, since the emulator has already rewrapped by then. Elsewhere the size comes from the PTY, which can run ahead of or behind the emulator, so resize events are coalesced: the frame is erased and repainted about 50 ms after the size stops changing and is briefly absent during a window drag. Painting mid-rewrap is what leaves ghost rows behind.
2244
2434
 
2245
2435
  ### useFocus(options?)
2246
2436
 
@@ -2277,9 +2467,9 @@ Set a component's focus ID, which can be used to programmatically focus the comp
2277
2467
  import { render, useFocus, Text } from "@alchemy.run/sigil";
2278
2468
 
2279
2469
  const Example = () => {
2280
- const { isFocused } = useFocus();
2470
+ const { isFocused } = useFocus();
2281
2471
 
2282
- return <Text>{isFocused ? "I am focused" : "I am not focused"}</Text>;
2472
+ return <Text>{isFocused ? "I am focused" : "I am not focused"}</Text>;
2283
2473
  };
2284
2474
 
2285
2475
  render(<Example />);
@@ -2413,9 +2603,9 @@ The ID of the currently focused component, or `undefined` if no component is foc
2413
2603
  import { Text, useFocusManager } from "@alchemy.run/sigil";
2414
2604
 
2415
2605
  const Example = () => {
2416
- const { activeId } = useFocusManager();
2606
+ const { activeId } = useFocusManager();
2417
2607
 
2418
- return <Text>Focused: {activeId ?? "none"}</Text>;
2608
+ return <Text>Focused: {activeId ?? "none"}</Text>;
2419
2609
  };
2420
2610
  ```
2421
2611
 
@@ -2430,21 +2620,21 @@ import { Box, Text, useCursor } from "@alchemy.run/sigil";
2430
2620
  import stringWidth from "string-width";
2431
2621
 
2432
2622
  const TextInput = () => {
2433
- const [text, setText] = useState("");
2434
- const { setCursorPosition } = useCursor();
2435
-
2436
- const prompt = "> ";
2437
- setCursorPosition({ x: stringWidth(prompt + text), y: 1 });
2438
-
2439
- return (
2440
- <Box flexDirection="column">
2441
- <Text>Type here:</Text>
2442
- <Text>
2443
- {prompt}
2444
- {text}
2445
- </Text>
2446
- </Box>
2447
- );
2623
+ const [text, setText] = useState("");
2624
+ const { setCursorPosition } = useCursor();
2625
+
2626
+ const prompt = "> ";
2627
+ setCursorPosition({ x: stringWidth(prompt + text), y: 1 });
2628
+
2629
+ return (
2630
+ <Box flexDirection="column">
2631
+ <Text>Type here:</Text>
2632
+ <Text>
2633
+ {prompt}
2634
+ {text}
2635
+ </Text>
2636
+ </Box>
2637
+ );
2448
2638
  };
2449
2639
  ```
2450
2640
 
@@ -2481,11 +2671,11 @@ This is useful when you want to render different output for screen readers.
2481
2671
  import { useIsScreenReaderEnabled, Text } from "@alchemy.run/sigil";
2482
2672
 
2483
2673
  const Example = () => {
2484
- const isScreenReaderEnabled = useIsScreenReaderEnabled();
2674
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
2485
2675
 
2486
- return (
2487
- <Text>{isScreenReaderEnabled ? "Screen reader is enabled" : "Screen reader is disabled"}</Text>
2488
- );
2676
+ return (
2677
+ <Text>{isScreenReaderEnabled ? "Screen reader is enabled" : "Screen reader is disabled"}</Text>
2678
+ );
2489
2679
  };
2490
2680
  ```
2491
2681
 
@@ -2497,10 +2687,10 @@ A React hook that drives animations. Returns a frame counter, elapsed time, fram
2497
2687
  import { Text, useAnimation } from "@alchemy.run/sigil";
2498
2688
 
2499
2689
  const Spinner = () => {
2500
- const { frame } = useAnimation({ interval: 80 });
2501
- const characters = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2690
+ const { frame } = useAnimation({ interval: 80 });
2691
+ const characters = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2502
2692
 
2503
- return <Text>{characters[frame % characters.length]}</Text>;
2693
+ return <Text>{characters[frame % characters.length]}</Text>;
2504
2694
  };
2505
2695
  ```
2506
2696
 
@@ -2622,11 +2812,11 @@ Return `true` to take ownership of a chunk: Ink will not display it, letting the
2622
2812
  const [transcript, setTranscript] = useState([]);
2623
2813
 
2624
2814
  render(<App transcript={transcript} />, {
2625
- patchConsole: "stdio",
2626
- onCapturedOutput(stream, data) {
2627
- setTranscript((previous) => [...previous, { stream, data }]);
2628
- return true;
2629
- },
2815
+ patchConsole: "stdio",
2816
+ onCapturedOutput(stream, data) {
2817
+ setTranscript((previous) => [...previous, { stream, data }]);
2818
+ return true;
2819
+ },
2630
2820
  });
2631
2821
  ```
2632
2822
 
@@ -2642,7 +2832,7 @@ To run code after output is flushed, use [`waitUntilRenderFlush()`](#waituntilre
2642
2832
  ###### isScreenReaderEnabled
2643
2833
 
2644
2834
  Type: `boolean`\
2645
- Default: `process.env['INK_SCREEN_READER'] === 'true'`
2835
+ Default: `process.env['SIGIL_SCREEN_READER'] === 'true'`
2646
2836
 
2647
2837
  Enable screen reader support. See [Screen Reader Support](#screen-reader-support).
2648
2838
 
@@ -2663,14 +2853,6 @@ This controls how frequently the UI can update to prevent excessive re-rendering
2663
2853
  Higher values allow more frequent updates but may impact performance.
2664
2854
  Setting it to a lower value may be useful for components that update very frequently, to reduce CPU usage.
2665
2855
 
2666
- ###### incrementalRendering
2667
-
2668
- Type: `boolean`\
2669
- Default: `false`
2670
-
2671
- Enable incremental rendering mode which only updates changed lines instead of redrawing the entire output.
2672
- This can reduce flickering and improve performance for frequently updating UIs.
2673
-
2674
2856
  ###### concurrent
2675
2857
 
2676
2858
  Type: `boolean`\
@@ -2748,10 +2930,10 @@ render(<MyApp />, { kittyKeyboard: { mode: "auto" } });
2748
2930
  import { render } from "@alchemy.run/sigil";
2749
2931
 
2750
2932
  render(<MyApp />, {
2751
- kittyKeyboard: {
2752
- mode: "enabled",
2753
- flags: ["disambiguateEscapeCodes", "reportEventTypes"],
2754
- },
2933
+ kittyKeyboard: {
2934
+ mode: "enabled",
2935
+ flags: ["disambiguateEscapeCodes", "reportEventTypes"],
2936
+ },
2755
2937
  });
2756
2938
  ```
2757
2939
 
@@ -2803,9 +2985,9 @@ Useful for generating documentation, writing output to files, testing, or any sc
2803
2985
  import { renderToString, Text, Box } from "@alchemy.run/sigil";
2804
2986
 
2805
2987
  const output = renderToString(
2806
- <Box padding={1}>
2807
- <Text color="green">Hello World</Text>
2808
- </Box>,
2988
+ <Box padding={1}>
2989
+ <Text color="green">Hello World</Text>
2990
+ </Box>,
2809
2991
  );
2810
2992
 
2811
2993
  console.log(output);
@@ -2836,7 +3018,7 @@ Width of the virtual terminal in columns. Controls where text wrapping occurs.
2836
3018
 
2837
3019
  ```jsx
2838
3020
  const output = renderToString(<Text>{"A".repeat(100)}</Text>, {
2839
- columns: 40,
3021
+ columns: 40,
2840
3022
  });
2841
3023
  // Text wraps at 40 columns
2842
3024
  ```
@@ -2938,20 +3120,20 @@ See [Refs](https://reactjs.org/docs/refs-and-the-dom.html) for more information
2938
3120
  import { render, measureElement, Box, Text } from "@alchemy.run/sigil";
2939
3121
 
2940
3122
  const Example = () => {
2941
- const ref = useRef();
2942
-
2943
- useEffect(() => {
2944
- const { x, y, width, height } = measureElement(ref.current);
2945
- // x = 0, y = 0, width = 100, height = 1
2946
- }, []);
2947
-
2948
- return (
2949
- <Box width={100}>
2950
- <Box ref={ref}>
2951
- <Text>This box will stretch to 100 width</Text>
2952
- </Box>
2953
- </Box>
2954
- );
3123
+ const ref = useRef();
3124
+
3125
+ useEffect(() => {
3126
+ const { x, y, width, height } = measureElement(ref.current);
3127
+ // x = 0, y = 0, width = 100, height = 1
3128
+ }, []);
3129
+
3130
+ return (
3131
+ <Box width={100}>
3132
+ <Box ref={ref}>
3133
+ <Text>This box will stretch to 100 width</Text>
3134
+ </Box>
3135
+ </Box>
3136
+ );
2955
3137
  };
2956
3138
 
2957
3139
  render(<Example />);
@@ -2979,10 +3161,10 @@ Check out [ink-testing-library](https://github.com/vadimdemedes/ink-testing-libr
2979
3161
 
2980
3162
  ![](media/devtools.jpg)
2981
3163
 
2982
- Ink supports [React Devtools](https://github.com/facebook/react/tree/master/packages/react-devtools) out of the box. To enable integration with React Devtools in your Ink-based CLI, first ensure you have installed the optional `react-devtools-core` dependency, and then run your app with the `DEV=true` environment variable:
3164
+ Ink supports [React Devtools](https://github.com/facebook/react/tree/master/packages/react-devtools) out of the box. To enable integration with React Devtools in your Ink-based CLI, first ensure you have installed the optional `react-devtools-core` dependency, and then run your app with the `SIGIL_DEV=true` environment variable:
2983
3165
 
2984
3166
  ```sh
2985
- DEV=true my-cli
3167
+ SIGIL_DEV=true my-cli
2986
3168
  ```
2987
3169
 
2988
3170
  Then, start React Devtools itself:
@@ -3001,7 +3183,7 @@ You can even inspect and change the props of components, and see the results imm
3001
3183
 
3002
3184
  Ink has basic support for screen readers.
3003
3185
 
3004
- To enable it, you can either pass the `isScreenReaderEnabled` option to the `render` function or set the `INK_SCREEN_READER` environment variable to `true`.
3186
+ To enable it, you can either pass the `isScreenReaderEnabled` option to the `render` function or set the `SIGIL_SCREEN_READER` environment variable to `true`.
3005
3187
 
3006
3188
  Ink implements a small subset of functionality from the [ARIA specification](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA).
3007
3189
 
@@ -3015,7 +3197,7 @@ For example, for this code:
3015
3197
 
3016
3198
  ```jsx
3017
3199
  <Box aria-role="checkbox" aria-state={{ checked: true }}>
3018
- <Text>Accept terms and conditions</Text>
3200
+ <Text>Accept terms and conditions</Text>
3019
3201
  </Box>
3020
3202
  ```
3021
3203
 
@@ -3031,8 +3213,8 @@ For example, if you are building a progress bar, you can use `aria-label` to pro
3031
3213
 
3032
3214
  ```jsx
3033
3215
  <Box>
3034
- <Box width="50%" height={1} backgroundColor="green" />
3035
- <Text aria-label="Progress: 50%">50%</Text>
3216
+ <Box width="50%" height={1} backgroundColor="green" />
3217
+ <Text aria-label="Progress: 50%">50%</Text>
3036
3218
  </Box>
3037
3219
  ```
3038
3220
 
@@ -3168,6 +3350,10 @@ npm run example examples/[example name]
3168
3350
  - [Static](examples/static/static.tsx) - Use the `<Static>` component to render permanent output.
3169
3351
  - [Child process](examples/subprocess-output) - Renders output from a child process.
3170
3352
  - [Router](examples/router/router.tsx) - Navigate between routes using React Router's `MemoryRouter`.
3353
+ - [Charm brightness](examples/charm-brightness/brightness.tsx) - A native port of Lip Gloss's progressive lightening and darkening demo.
3354
+ - [Charm canvas](examples/charm-canvas/canvas.tsx) - Layered fields and gradient-bordered cards inspired by Lip Gloss's compositor demo.
3355
+ - [Charm layout](examples/charm-layout/layout.tsx) - The Lip Gloss layout showcase rebuilt with React, Yoga, gradients, and compositing.
3356
+ - [Charm profile](examples/charm-profile/profile.tsx) - The Lip Gloss SSH profile showcase adapted to Sigil's stream-scoped terminal capabilities.
3171
3357
 
3172
3358
  ## Continuous Integration
3173
3359