@xincli/opentui-core 0.4.6

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 (476) hide show
  1. package/README.md +63 -0
  2. package/dev/keypress-debug-renderer.ts +148 -0
  3. package/dev/keypress-debug.ts +43 -0
  4. package/dev/print-env-vars.ts +32 -0
  5. package/dev/test-tmux-graphics-334.sh +68 -0
  6. package/dev/test-tmux-theme-queries.sh +151 -0
  7. package/dev/thai-debug-test.ts +68 -0
  8. package/dev/theme-mode.ts +290 -0
  9. package/docs/development.md +144 -0
  10. package/package.json +102 -0
  11. package/prebuilt/aarch64-android/libopentui.so +0 -0
  12. package/scripts/build-android.ts +226 -0
  13. package/scripts/build-native-termux.sh +525 -0
  14. package/scripts/build.ts +530 -0
  15. package/scripts/dist-test.ts +312 -0
  16. package/scripts/package-prebuilt.ts +110 -0
  17. package/scripts/publish.ts +60 -0
  18. package/scripts/test-node-hook.mjs +16 -0
  19. package/scripts/test-node.ts +241 -0
  20. package/scripts/vendor-deps.sh +73 -0
  21. package/src/NativeSpanFeed.ts +375 -0
  22. package/src/Renderable.ts +1878 -0
  23. package/src/__snapshots__/buffer.test.ts.snap +28 -0
  24. package/src/animation/Timeline.test.ts +2709 -0
  25. package/src/animation/Timeline.ts +598 -0
  26. package/src/ansi.ts +22 -0
  27. package/src/audio.ts +479 -0
  28. package/src/benchmark/attenuation-benchmark.ts +81 -0
  29. package/src/benchmark/audio-playback-benchmark.ts +344 -0
  30. package/src/benchmark/box-draw-benchmark.ts +1042 -0
  31. package/src/benchmark/colormatrix-benchmark.ts +123 -0
  32. package/src/benchmark/gain-benchmark.ts +80 -0
  33. package/src/benchmark/latest-all-bench-run.json +707 -0
  34. package/src/benchmark/latest-async-bench-run.json +336 -0
  35. package/src/benchmark/latest-default-bench-run.json +657 -0
  36. package/src/benchmark/latest-large-bench-run.json +707 -0
  37. package/src/benchmark/latest-quick-bench-run.json +207 -0
  38. package/src/benchmark/layout-benchmark.ts +2547 -0
  39. package/src/benchmark/markdown-benchmark.ts +1796 -0
  40. package/src/benchmark/native-span-feed-async-benchmark.ts +355 -0
  41. package/src/benchmark/native-span-feed-benchmark.md +56 -0
  42. package/src/benchmark/native-span-feed-benchmark.ts +596 -0
  43. package/src/benchmark/native-span-feed-compare.ts +280 -0
  44. package/src/benchmark/render-traversal-benchmark.ts +928 -0
  45. package/src/benchmark/text-buffer-render-benchmark.ts +874 -0
  46. package/src/benchmark/text-table-benchmark.ts +948 -0
  47. package/src/buffer.test.ts +292 -0
  48. package/src/buffer.ts +564 -0
  49. package/src/console.test.ts +629 -0
  50. package/src/console.ts +1253 -0
  51. package/src/edit-buffer.test.ts +1852 -0
  52. package/src/edit-buffer.ts +417 -0
  53. package/src/editor-view.test.ts +1056 -0
  54. package/src/editor-view.ts +290 -0
  55. package/src/index.ts +25 -0
  56. package/src/lib/KeyHandler.integration.test.ts +292 -0
  57. package/src/lib/KeyHandler.stopPropagation.test.ts +289 -0
  58. package/src/lib/KeyHandler.test.ts +662 -0
  59. package/src/lib/KeyHandler.ts +222 -0
  60. package/src/lib/RGBA.test.ts +60 -0
  61. package/src/lib/RGBA.ts +343 -0
  62. package/src/lib/ascii.font.ts +330 -0
  63. package/src/lib/border.test.ts +83 -0
  64. package/src/lib/border.ts +170 -0
  65. package/src/lib/bunfs.test.ts +27 -0
  66. package/src/lib/bunfs.ts +18 -0
  67. package/src/lib/clipboard.test.ts +63 -0
  68. package/src/lib/clipboard.ts +41 -0
  69. package/src/lib/clock.ts +35 -0
  70. package/src/lib/data-paths.test.ts +133 -0
  71. package/src/lib/data-paths.ts +109 -0
  72. package/src/lib/debounce.ts +106 -0
  73. package/src/lib/detect-links.test.ts +98 -0
  74. package/src/lib/detect-links.ts +56 -0
  75. package/src/lib/env.test.ts +228 -0
  76. package/src/lib/env.ts +209 -0
  77. package/src/lib/extmarks-history.ts +51 -0
  78. package/src/lib/extmarks-multiwidth.test.ts +324 -0
  79. package/src/lib/extmarks.test.ts +3547 -0
  80. package/src/lib/extmarks.ts +861 -0
  81. package/src/lib/fonts/block.json +405 -0
  82. package/src/lib/fonts/grid.json +265 -0
  83. package/src/lib/fonts/huge.json +741 -0
  84. package/src/lib/fonts/pallet.json +314 -0
  85. package/src/lib/fonts/shade.json +591 -0
  86. package/src/lib/fonts/slick.json +321 -0
  87. package/src/lib/fonts/tiny.json +69 -0
  88. package/src/lib/hast-styled-text.ts +59 -0
  89. package/src/lib/index.ts +22 -0
  90. package/src/lib/keybinding.internal.test.ts +342 -0
  91. package/src/lib/keybinding.internal.ts +182 -0
  92. package/src/lib/objects-in-viewport.test.ts +789 -0
  93. package/src/lib/objects-in-viewport.ts +153 -0
  94. package/src/lib/output.capture.ts +58 -0
  95. package/src/lib/parse.keypress-kitty.protocol.test.ts +347 -0
  96. package/src/lib/parse.keypress-kitty.test.ts +712 -0
  97. package/src/lib/parse.keypress-kitty.ts +470 -0
  98. package/src/lib/parse.keypress.test.ts +2020 -0
  99. package/src/lib/parse.keypress.ts +480 -0
  100. package/src/lib/parse.mouse.test.ts +552 -0
  101. package/src/lib/parse.mouse.ts +232 -0
  102. package/src/lib/paste.ts +18 -0
  103. package/src/lib/queue.ts +65 -0
  104. package/src/lib/render-geometry.ts +36 -0
  105. package/src/lib/renderable.validations.test.ts +87 -0
  106. package/src/lib/renderable.validations.ts +83 -0
  107. package/src/lib/scroll-acceleration.ts +98 -0
  108. package/src/lib/selection.ts +261 -0
  109. package/src/lib/singleton.ts +34 -0
  110. package/src/lib/stdin-parser.test.ts +2439 -0
  111. package/src/lib/stdin-parser.ts +2007 -0
  112. package/src/lib/styled-text.ts +178 -0
  113. package/src/lib/terminal-capability-detection.test.ts +226 -0
  114. package/src/lib/terminal-capability-detection.ts +98 -0
  115. package/src/lib/terminal-palette.test.ts +954 -0
  116. package/src/lib/terminal-palette.ts +441 -0
  117. package/src/lib/tree-sitter/assets/README.md +119 -0
  118. package/src/lib/tree-sitter/assets/javascript/highlights.scm +205 -0
  119. package/src/lib/tree-sitter/assets/javascript/tree-sitter-javascript.wasm +0 -0
  120. package/src/lib/tree-sitter/assets/markdown/highlights.scm +150 -0
  121. package/src/lib/tree-sitter/assets/markdown/injections.scm +27 -0
  122. package/src/lib/tree-sitter/assets/markdown/tree-sitter-markdown.wasm +0 -0
  123. package/src/lib/tree-sitter/assets/markdown_inline/highlights.scm +115 -0
  124. package/src/lib/tree-sitter/assets/markdown_inline/tree-sitter-markdown_inline.wasm +0 -0
  125. package/src/lib/tree-sitter/assets/typescript/highlights.scm +604 -0
  126. package/src/lib/tree-sitter/assets/typescript/tree-sitter-typescript.wasm +0 -0
  127. package/src/lib/tree-sitter/assets/update.ts +360 -0
  128. package/src/lib/tree-sitter/assets/zig/highlights.scm +284 -0
  129. package/src/lib/tree-sitter/assets/zig/tree-sitter-zig.wasm +0 -0
  130. package/src/lib/tree-sitter/assets.d.ts +9 -0
  131. package/src/lib/tree-sitter/cache.test.ts +291 -0
  132. package/src/lib/tree-sitter/client.test.ts +1434 -0
  133. package/src/lib/tree-sitter/client.ts +847 -0
  134. package/src/lib/tree-sitter/default-parsers.ts +133 -0
  135. package/src/lib/tree-sitter/download-utils.ts +148 -0
  136. package/src/lib/tree-sitter/index.ts +75 -0
  137. package/src/lib/tree-sitter/parser.worker.ts +1098 -0
  138. package/src/lib/tree-sitter/parsers-config.ts +81 -0
  139. package/src/lib/tree-sitter/resolve-ft.test.ts +55 -0
  140. package/src/lib/tree-sitter/resolve-ft.ts +189 -0
  141. package/src/lib/tree-sitter/types.ts +132 -0
  142. package/src/lib/tree-sitter/update-assets.ts +10 -0
  143. package/src/lib/tree-sitter-styled-text.test.ts +1253 -0
  144. package/src/lib/tree-sitter-styled-text.ts +314 -0
  145. package/src/lib/validate-dir-name.ts +55 -0
  146. package/src/lib/yoga.options.test.ts +628 -0
  147. package/src/lib/yoga.options.ts +346 -0
  148. package/src/native-event-worker-repro.test.ts +48 -0
  149. package/src/native-event-worker-repro.worker.ts +9 -0
  150. package/src/native-handle.test.ts +221 -0
  151. package/src/platform/ffi.test.ts +516 -0
  152. package/src/platform/ffi.ts +653 -0
  153. package/src/platform/runtime.test.ts +52 -0
  154. package/src/platform/runtime.ts +121 -0
  155. package/src/platform/test.ts +45 -0
  156. package/src/platform/worker-handler-cleanup.fixture.ts +12 -0
  157. package/src/platform/worker-onmessage-startup.fixture.ts +10 -0
  158. package/src/platform/worker-startup.fixture.ts +9 -0
  159. package/src/platform/worker.node-test.ts +153 -0
  160. package/src/platform/worker.ts +425 -0
  161. package/src/plugins/core-slot.ts +579 -0
  162. package/src/plugins/registry.ts +402 -0
  163. package/src/plugins/types.ts +46 -0
  164. package/src/post/effects.ts +948 -0
  165. package/src/post/filters.ts +512 -0
  166. package/src/post/matrices.ts +288 -0
  167. package/src/renderables/ASCIIFont.ts +219 -0
  168. package/src/renderables/Box.test.ts +474 -0
  169. package/src/renderables/Box.ts +353 -0
  170. package/src/renderables/Code.test.ts +2388 -0
  171. package/src/renderables/Code.ts +555 -0
  172. package/src/renderables/Diff.regression.test.ts +381 -0
  173. package/src/renderables/Diff.test.ts +3212 -0
  174. package/src/renderables/Diff.ts +1278 -0
  175. package/src/renderables/EditBufferRenderable.test.ts +288 -0
  176. package/src/renderables/EditBufferRenderable.ts +1152 -0
  177. package/src/renderables/FrameBuffer.ts +47 -0
  178. package/src/renderables/Input.test.ts +1310 -0
  179. package/src/renderables/Input.ts +273 -0
  180. package/src/renderables/LineNumberRenderable.ts +725 -0
  181. package/src/renderables/Markdown.ts +2144 -0
  182. package/src/renderables/ScrollBar.ts +423 -0
  183. package/src/renderables/ScrollBox.ts +903 -0
  184. package/src/renderables/Select.test.ts +1134 -0
  185. package/src/renderables/Select.ts +535 -0
  186. package/src/renderables/Slider.test.ts +456 -0
  187. package/src/renderables/Slider.ts +342 -0
  188. package/src/renderables/TabSelect.test.ts +238 -0
  189. package/src/renderables/TabSelect.ts +447 -0
  190. package/src/renderables/Text.selection-buffer.test.ts +118 -0
  191. package/src/renderables/Text.test.ts +2662 -0
  192. package/src/renderables/Text.ts +147 -0
  193. package/src/renderables/TextBufferRenderable.ts +504 -0
  194. package/src/renderables/TextNode.test.ts +1137 -0
  195. package/src/renderables/TextNode.ts +379 -0
  196. package/src/renderables/TextTable.test.ts +1411 -0
  197. package/src/renderables/TextTable.ts +1434 -0
  198. package/src/renderables/Textarea.ts +425 -0
  199. package/src/renderables/TimeToFirstDraw.ts +89 -0
  200. package/src/renderables/__snapshots__/Code.test.ts.snap +13 -0
  201. package/src/renderables/__snapshots__/Diff.test.ts.snap +785 -0
  202. package/src/renderables/__snapshots__/Text.test.ts.snap +421 -0
  203. package/src/renderables/__snapshots__/TextTable.test.ts.snap +215 -0
  204. package/src/renderables/__tests__/Code.test.ts +55 -0
  205. package/src/renderables/__tests__/LineNumberRenderable.scrollbox-simple.test.ts +144 -0
  206. package/src/renderables/__tests__/LineNumberRenderable.scrollbox.test.ts +827 -0
  207. package/src/renderables/__tests__/LineNumberRenderable.test.ts +1906 -0
  208. package/src/renderables/__tests__/LineNumberRenderable.wrapping.test.ts +85 -0
  209. package/src/renderables/__tests__/Markdown.code-colors.test.ts +288 -0
  210. package/src/renderables/__tests__/Markdown.test.ts +4175 -0
  211. package/src/renderables/__tests__/MultiRenderable.selection.test.ts +87 -0
  212. package/src/renderables/__tests__/Textarea.buffer.test.ts +683 -0
  213. package/src/renderables/__tests__/Textarea.destroyed-events.test.ts +675 -0
  214. package/src/renderables/__tests__/Textarea.editing.test.ts +2120 -0
  215. package/src/renderables/__tests__/Textarea.error-handling.test.ts +35 -0
  216. package/src/renderables/__tests__/Textarea.events.test.ts +738 -0
  217. package/src/renderables/__tests__/Textarea.highlights.test.ts +590 -0
  218. package/src/renderables/__tests__/Textarea.keybinding.test.ts +3275 -0
  219. package/src/renderables/__tests__/Textarea.paste.test.ts +357 -0
  220. package/src/renderables/__tests__/Textarea.rendering.test.ts +1927 -0
  221. package/src/renderables/__tests__/Textarea.scroll.test.ts +733 -0
  222. package/src/renderables/__tests__/Textarea.selection.test.ts +1589 -0
  223. package/src/renderables/__tests__/Textarea.stress.test.ts +670 -0
  224. package/src/renderables/__tests__/Textarea.undo-redo.test.ts +383 -0
  225. package/src/renderables/__tests__/Textarea.visual-lines.test.ts +310 -0
  226. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.code.test.ts.snap +221 -0
  227. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.snap +89 -0
  228. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.snap +457 -0
  229. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.snap +158 -0
  230. package/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.snap +387 -0
  231. package/src/renderables/__tests__/markdown-parser.test.ts +241 -0
  232. package/src/renderables/__tests__/renderable-test-utils.ts +60 -0
  233. package/src/renderables/composition/README.md +8 -0
  234. package/src/renderables/composition/VRenderable.ts +32 -0
  235. package/src/renderables/composition/constructs.ts +127 -0
  236. package/src/renderables/composition/vnode.ts +277 -0
  237. package/src/renderables/index.ts +23 -0
  238. package/src/renderables/markdown-parser.ts +79 -0
  239. package/src/renderer-theme-mode.ts +199 -0
  240. package/src/renderer.ts +4975 -0
  241. package/src/runtime-plugin-support-configure.ts +63 -0
  242. package/src/runtime-plugin-support.ts +13 -0
  243. package/src/runtime-plugin.ts +626 -0
  244. package/src/specs/terminal-startup.md +64 -0
  245. package/src/syntax-style.test.ts +841 -0
  246. package/src/syntax-style.ts +274 -0
  247. package/src/testing/README.md +219 -0
  248. package/src/testing/bun-test-node.ts +880 -0
  249. package/src/testing/capture-spans.test.ts +216 -0
  250. package/src/testing/integration.test.ts +276 -0
  251. package/src/testing/manual-clock.ts +117 -0
  252. package/src/testing/mock-keys.test.ts +1378 -0
  253. package/src/testing/mock-keys.ts +457 -0
  254. package/src/testing/mock-mouse.test.ts +218 -0
  255. package/src/testing/mock-mouse.ts +247 -0
  256. package/src/testing/mock-tree-sitter-client.ts +81 -0
  257. package/src/testing/spy.ts +13 -0
  258. package/src/testing/terminal-capabilities.ts +47 -0
  259. package/src/testing/test-recorder.test.ts +410 -0
  260. package/src/testing/test-recorder.ts +133 -0
  261. package/src/testing/test-renderer.ts +375 -0
  262. package/src/testing/test-renderer.wait.test.ts +278 -0
  263. package/src/testing/test-streams.ts +31 -0
  264. package/src/testing.ts +9 -0
  265. package/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.snap +481 -0
  266. package/src/tests/__snapshots__/renderable.snapshot.test.ts.snap +19 -0
  267. package/src/tests/__snapshots__/scrollbox.test.ts.snap +29 -0
  268. package/src/tests/absolute-positioning.snapshot.test.ts +638 -0
  269. package/src/tests/allocator-stats.test.ts +53 -0
  270. package/src/tests/audio.test.ts +303 -0
  271. package/src/tests/destroy-during-render.test.ts +200 -0
  272. package/src/tests/destroy-on-exit.fixture.ts +36 -0
  273. package/src/tests/destroy-on-exit.test.ts +62 -0
  274. package/src/tests/ffi-borrowed-pointer-callsites.test.ts +153 -0
  275. package/src/tests/hover-cursor.test.ts +98 -0
  276. package/src/tests/native-backed-measurement-lifecycle.test.ts +133 -0
  277. package/src/tests/native-backed-measurement-parity.test.ts +322 -0
  278. package/src/tests/native-span-feed-async.test.ts +173 -0
  279. package/src/tests/native-span-feed-close.test.ts +120 -0
  280. package/src/tests/native-span-feed-coverage.test.ts +227 -0
  281. package/src/tests/native-span-feed-edge-cases.test.ts +384 -0
  282. package/src/tests/native-span-feed-use-after-free.test.ts +45 -0
  283. package/src/tests/opacity.test.ts +123 -0
  284. package/src/tests/renderable.snapshot.test.ts +524 -0
  285. package/src/tests/renderable.test.ts +1851 -0
  286. package/src/tests/renderer.clock.test.ts +270 -0
  287. package/src/tests/renderer.console-startup.test.ts +2151 -0
  288. package/src/tests/renderer.control.test.ts +458 -0
  289. package/src/tests/renderer.core-slot-binding.test.ts +962 -0
  290. package/src/tests/renderer.cursor.test.ts +26 -0
  291. package/src/tests/renderer.custom-stdout.test.ts +1112 -0
  292. package/src/tests/renderer.destroy-during-render.test.ts +147 -0
  293. package/src/tests/renderer.focus-restore.test.ts +257 -0
  294. package/src/tests/renderer.focus.test.ts +329 -0
  295. package/src/tests/renderer.idle.test.ts +219 -0
  296. package/src/tests/renderer.input.test.ts +2615 -0
  297. package/src/tests/renderer.kitty-flags.test.ts +195 -0
  298. package/src/tests/renderer.lifecycle.test.ts +34 -0
  299. package/src/tests/renderer.mouse.test.ts +1315 -0
  300. package/src/tests/renderer.notifications.test.ts +27 -0
  301. package/src/tests/renderer.palette.test.ts +1199 -0
  302. package/src/tests/renderer.remote.test.ts +247 -0
  303. package/src/tests/renderer.render-stats.test.ts +146 -0
  304. package/src/tests/renderer.scrollback-surface.test.ts +474 -0
  305. package/src/tests/renderer.selection.test.ts +138 -0
  306. package/src/tests/renderer.slot-registry.test.ts +684 -0
  307. package/src/tests/renderer.tracker.test.ts +202 -0
  308. package/src/tests/renderer.useMouse.test.ts +83 -0
  309. package/src/tests/runtime-plugin-node-modules-cycle.fixture.ts +76 -0
  310. package/src/tests/runtime-plugin-node-modules-import-like-string.fixture.ts +48 -0
  311. package/src/tests/runtime-plugin-node-modules-mjs.fixture.ts +43 -0
  312. package/src/tests/runtime-plugin-node-modules-no-bare-rewrite.fixture.ts +67 -0
  313. package/src/tests/runtime-plugin-node-modules-package-type-cache.fixture.ts +72 -0
  314. package/src/tests/runtime-plugin-node-modules-runtime-specifier.fixture.ts +44 -0
  315. package/src/tests/runtime-plugin-node-modules-scoped-package-bare-rewrite.fixture.ts +85 -0
  316. package/src/tests/runtime-plugin-path-alias.fixture.ts +43 -0
  317. package/src/tests/runtime-plugin-resolve-roots.fixture.ts +65 -0
  318. package/src/tests/runtime-plugin-support-configure.fixture.ts +31 -0
  319. package/src/tests/runtime-plugin-support-late-addition.fixture.ts +17 -0
  320. package/src/tests/runtime-plugin-support.fixture.ts +11 -0
  321. package/src/tests/runtime-plugin-support.test.ts +54 -0
  322. package/src/tests/runtime-plugin-windows-file-url.fixture.ts +30 -0
  323. package/src/tests/runtime-plugin.fixture.ts +40 -0
  324. package/src/tests/runtime-plugin.test.ts +369 -0
  325. package/src/tests/scrollbox-culling-bug.test.ts +114 -0
  326. package/src/tests/scrollbox-hitgrid-resize.test.ts +136 -0
  327. package/src/tests/scrollbox-hitgrid.test.ts +909 -0
  328. package/src/tests/scrollbox.test.ts +1895 -0
  329. package/src/tests/wrap-resize-perf.test.ts +276 -0
  330. package/src/tests/yoga-api-coverage.test.ts +409 -0
  331. package/src/tests/yoga-callback-stress.test.ts +122 -0
  332. package/src/tests/yoga-ffi.test.ts +117 -0
  333. package/src/tests/yoga-setters.test.ts +921 -0
  334. package/src/tests/yoga-upstream/YGAlignBaselineTest.test.ts +124 -0
  335. package/src/tests/yoga-upstream/YGComputedBorderTest.test.ts +27 -0
  336. package/src/tests/yoga-upstream/YGComputedMarginTest.test.ts +27 -0
  337. package/src/tests/yoga-upstream/YGComputedPaddingTest.test.ts +27 -0
  338. package/src/tests/yoga-upstream/YGDirtiedTest.test.ts +157 -0
  339. package/src/tests/yoga-upstream/YGErrataTest.test.ts +40 -0
  340. package/src/tests/yoga-upstream/YGFlexBasisAuto.test.ts +23 -0
  341. package/src/tests/yoga-upstream/YGHasNewLayout.test.ts +81 -0
  342. package/src/tests/yoga-upstream/YGMeasureCacheTest.test.ts +31 -0
  343. package/src/tests/yoga-upstream/YGMeasureTest.test.ts +70 -0
  344. package/src/tests/yoga-upstream/generated/YGAbsolutePositionTest.test.ts +1864 -0
  345. package/src/tests/yoga-upstream/generated/YGAlignContentTest.test.ts +5829 -0
  346. package/src/tests/yoga-upstream/generated/YGAlignItemsTest.test.ts +2444 -0
  347. package/src/tests/yoga-upstream/generated/YGAlignSelfTest.test.ts +293 -0
  348. package/src/tests/yoga-upstream/generated/YGAndroidNewsFeed.test.ts +315 -0
  349. package/src/tests/yoga-upstream/generated/YGAspectRatioTest.test.ts +231 -0
  350. package/src/tests/yoga-upstream/generated/YGAutoTest.test.ts +347 -0
  351. package/src/tests/yoga-upstream/generated/YGBorderTest.test.ts +256 -0
  352. package/src/tests/yoga-upstream/generated/YGBoxSizingTest.test.ts +2747 -0
  353. package/src/tests/yoga-upstream/generated/YGDimensionTest.test.ts +129 -0
  354. package/src/tests/yoga-upstream/generated/YGDisplayContentsTest.test.ts +109 -0
  355. package/src/tests/yoga-upstream/generated/YGDisplayTest.test.ts +1097 -0
  356. package/src/tests/yoga-upstream/generated/YGFlexDirectionTest.test.ts +4562 -0
  357. package/src/tests/yoga-upstream/generated/YGFlexTest.test.ts +677 -0
  358. package/src/tests/yoga-upstream/generated/YGFlexWrapTest.test.ts +2071 -0
  359. package/src/tests/yoga-upstream/generated/YGGapTest.test.ts +3303 -0
  360. package/src/tests/yoga-upstream/generated/YGIntrinsicSizeTest.test.ts +582 -0
  361. package/src/tests/yoga-upstream/generated/YGJustifyContentTest.test.ts +2150 -0
  362. package/src/tests/yoga-upstream/generated/YGMarginTest.test.ts +1963 -0
  363. package/src/tests/yoga-upstream/generated/YGMinMaxDimensionTest.test.ts +1446 -0
  364. package/src/tests/yoga-upstream/generated/YGPaddingTest.test.ts +354 -0
  365. package/src/tests/yoga-upstream/generated/YGPercentageTest.test.ts +1760 -0
  366. package/src/tests/yoga-upstream/generated/YGRoundingTest.test.ts +1161 -0
  367. package/src/tests/yoga-upstream/generated/YGSizeOverflowTest.test.ts +207 -0
  368. package/src/tests/yoga-upstream/generated/YGStaticPositionTest.test.ts +6338 -0
  369. package/src/tests/yoga-upstream/tools/MeasureCounter.ts +54 -0
  370. package/src/tests/yoga-upstream/tools/utils.ts +81 -0
  371. package/src/text-buffer-view.test.ts +722 -0
  372. package/src/text-buffer-view.ts +193 -0
  373. package/src/text-buffer.test.ts +512 -0
  374. package/src/text-buffer.ts +250 -0
  375. package/src/types.ts +199 -0
  376. package/src/utils.ts +88 -0
  377. package/src/yoga.ts +1146 -0
  378. package/src/zig/ansi.zig +502 -0
  379. package/src/zig/audio.zig +1206 -0
  380. package/src/zig/bench/README.md +50 -0
  381. package/src/zig/bench/buffer-color-blending_bench.zig +350 -0
  382. package/src/zig/bench/buffer-draw-box_bench.zig +786 -0
  383. package/src/zig/bench/buffer-draw-text-buffer_bench.zig +894 -0
  384. package/src/zig/bench/edit-buffer_bench.zig +476 -0
  385. package/src/zig/bench/editor-view_bench.zig +169 -0
  386. package/src/zig/bench/native-span-feed_bench.zig +107 -0
  387. package/src/zig/bench/rope-markers_bench.zig +713 -0
  388. package/src/zig/bench/rope_bench.zig +514 -0
  389. package/src/zig/bench/styled-text_bench.zig +476 -0
  390. package/src/zig/bench/text-buffer-coords_bench.zig +362 -0
  391. package/src/zig/bench/text-buffer-view_bench.zig +459 -0
  392. package/src/zig/bench/text-chunk-graphemes_bench.zig +272 -0
  393. package/src/zig/bench/utf8_bench.zig +799 -0
  394. package/src/zig/bench-utils.zig +431 -0
  395. package/src/zig/bench.zig +223 -0
  396. package/src/zig/buffer-methods.zig +215 -0
  397. package/src/zig/buffer.zig +2515 -0
  398. package/src/zig/build.zig +700 -0
  399. package/src/zig/build.zig.zon +19 -0
  400. package/src/zig/edit-buffer.zig +900 -0
  401. package/src/zig/editor-view.zig +812 -0
  402. package/src/zig/event-bus.zig +28 -0
  403. package/src/zig/event-emitter.zig +65 -0
  404. package/src/zig/file-logger.zig +92 -0
  405. package/src/zig/grapheme.zig +838 -0
  406. package/src/zig/handles.zig +267 -0
  407. package/src/zig/lib.zig +2813 -0
  408. package/src/zig/link.zig +349 -0
  409. package/src/zig/logger.zig +43 -0
  410. package/src/zig/mem-registry.zig +126 -0
  411. package/src/zig/miniaudio_shim.c +21 -0
  412. package/src/zig/native-renderable.zig +112 -0
  413. package/src/zig/native-span-feed-bench-lib.zig +7 -0
  414. package/src/zig/native-span-feed.zig +747 -0
  415. package/src/zig/renderer-output.zig +677 -0
  416. package/src/zig/renderer.zig +2106 -0
  417. package/src/zig/rope.zig +1220 -0
  418. package/src/zig/split-scrollback.zig +88 -0
  419. package/src/zig/syntax-style.zig +174 -0
  420. package/src/zig/terminal.zig +1652 -0
  421. package/src/zig/test.zig +82 -0
  422. package/src/zig/tests/README.md +18 -0
  423. package/src/zig/tests/ansi_test.zig +18 -0
  424. package/src/zig/tests/audio_test.zig +439 -0
  425. package/src/zig/tests/buffer-methods_test.zig +1111 -0
  426. package/src/zig/tests/buffer_test.zig +2926 -0
  427. package/src/zig/tests/edit-buffer-history_test.zig +299 -0
  428. package/src/zig/tests/edit-buffer_test.zig +1688 -0
  429. package/src/zig/tests/editor-view_test.zig +3454 -0
  430. package/src/zig/tests/event-emitter_test.zig +249 -0
  431. package/src/zig/tests/grapheme_test.zig +1329 -0
  432. package/src/zig/tests/handles_test.zig +123 -0
  433. package/src/zig/tests/link_test.zig +220 -0
  434. package/src/zig/tests/mem-registry_test.zig +473 -0
  435. package/src/zig/tests/memory_leak_regression_test.zig +234 -0
  436. package/src/zig/tests/native-renderable_test.zig +16 -0
  437. package/src/zig/tests/native-span-feed_test.zig +1266 -0
  438. package/src/zig/tests/renderer_test.zig +2604 -0
  439. package/src/zig/tests/rope-nested_test.zig +712 -0
  440. package/src/zig/tests/rope_fuzz_test.zig +238 -0
  441. package/src/zig/tests/rope_test.zig +2362 -0
  442. package/src/zig/tests/segment-merge.test.zig +148 -0
  443. package/src/zig/tests/split-scrollback_test.zig +70 -0
  444. package/src/zig/tests/syntax-style_test.zig +558 -0
  445. package/src/zig/tests/terminal_test.zig +1489 -0
  446. package/src/zig/tests/test-renderer.zig +126 -0
  447. package/src/zig/tests/text-buffer-drawing_test.zig +3291 -0
  448. package/src/zig/tests/text-buffer-highlights_test.zig +706 -0
  449. package/src/zig/tests/text-buffer-iterators_test.zig +776 -0
  450. package/src/zig/tests/text-buffer-segment_test.zig +320 -0
  451. package/src/zig/tests/text-buffer-selection_test.zig +1056 -0
  452. package/src/zig/tests/text-buffer-selection_viewport_test.zig +358 -0
  453. package/src/zig/tests/text-buffer-view_test.zig +3672 -0
  454. package/src/zig/tests/text-buffer_test.zig +2191 -0
  455. package/src/zig/tests/unicode-width-map.zon +3909 -0
  456. package/src/zig/tests/utf8_no_zwj_test.zig +260 -0
  457. package/src/zig/tests/utf8_test.zig +4060 -0
  458. package/src/zig/tests/utf8_wcwidth_cursor_test.zig +267 -0
  459. package/src/zig/tests/utf8_wcwidth_test.zig +357 -0
  460. package/src/zig/tests/word-wrap-editing_test.zig +498 -0
  461. package/src/zig/tests/wrap-cache-perf_test.zig +113 -0
  462. package/src/zig/tests/yoga_test.zig +65 -0
  463. package/src/zig/text-buffer-iterators.zig +496 -0
  464. package/src/zig/text-buffer-segment.zig +403 -0
  465. package/src/zig/text-buffer-view.zig +1428 -0
  466. package/src/zig/text-buffer.zig +1272 -0
  467. package/src/zig/utf8.zig +1954 -0
  468. package/src/zig/utils.zig +12 -0
  469. package/src/zig/uucode-stub.zig +76 -0
  470. package/src/zig/vendor/miniaudio/miniaudio.h +93468 -0
  471. package/src/zig/yoga.zig +658 -0
  472. package/src/zig-structs.ts +381 -0
  473. package/src/zig.ts +5260 -0
  474. package/tsconfig.build.json +17 -0
  475. package/tsconfig.json +27 -0
  476. package/tsconfig.node-test.json +138 -0
@@ -0,0 +1,1895 @@
1
+ import { test, expect, beforeEach, afterEach, describe, spyOn } from "bun:test"
2
+ import { createTestRenderer, type TestRenderer, type MockMouse, MockTreeSitterClient } from "../testing.js"
3
+ import { ScrollBoxRenderable } from "../renderables/ScrollBox.js"
4
+ import { BoxRenderable } from "../renderables/Box.js"
5
+ import { TextRenderable } from "../renderables/Text.js"
6
+ import { CodeRenderable } from "../renderables/Code.js"
7
+ import { MarkdownRenderable } from "../renderables/Markdown.js"
8
+ import { LinearScrollAccel, MacOSScrollAccel, type ScrollAcceleration } from "../lib/scroll-acceleration.js"
9
+ import { SyntaxStyle } from "../syntax-style.js"
10
+
11
+ // Test accelerator that returns a constant multiplier
12
+ class ConstantScrollAccel implements ScrollAcceleration {
13
+ constructor(private multiplier: number) {}
14
+ tick(_now?: number): number {
15
+ return this.multiplier
16
+ }
17
+ reset(): void {}
18
+ }
19
+
20
+ let testRenderer: TestRenderer
21
+ let mockMouse: MockMouse
22
+ let renderOnce: () => Promise<void>
23
+ let captureCharFrame: () => string
24
+ let mockTreeSitterClient: MockTreeSitterClient
25
+ let mockTreeSitterClients: MockTreeSitterClient[]
26
+
27
+ beforeEach(async () => {
28
+ ;({
29
+ renderer: testRenderer,
30
+ mockMouse,
31
+ renderOnce,
32
+ captureCharFrame,
33
+ } = await createTestRenderer({ width: 80, height: 24 }))
34
+ mockTreeSitterClients = []
35
+ mockTreeSitterClient = createMockTreeSitterClient()
36
+ })
37
+
38
+ afterEach(async () => {
39
+ testRenderer.destroy()
40
+ for (const client of mockTreeSitterClients) {
41
+ client.resolveAllHighlightOnce()
42
+ await client.destroy()
43
+ }
44
+ })
45
+
46
+ function createMockTreeSitterClient(): MockTreeSitterClient {
47
+ const client = new MockTreeSitterClient()
48
+ client.setMockResult({ highlights: [] })
49
+ mockTreeSitterClients.push(client)
50
+ return client
51
+ }
52
+
53
+ describe("ScrollBoxRenderable - child delegation", () => {
54
+ test("delegates add to content wrapper", () => {
55
+ const scrollbox = new ScrollBoxRenderable(testRenderer, { id: "scrollbox" })
56
+ const child = new BoxRenderable(testRenderer, { id: "child" })
57
+
58
+ scrollbox.add(child)
59
+
60
+ const children = scrollbox.getChildren()
61
+ expect(children.length).toBe(1)
62
+ expect(children[0].id).toBe("child")
63
+ expect(child.parent).toBe(scrollbox.content)
64
+ })
65
+
66
+ test("delegates remove to content wrapper", () => {
67
+ const scrollbox = new ScrollBoxRenderable(testRenderer, { id: "scrollbox" })
68
+ const child = new BoxRenderable(testRenderer, { id: "child" })
69
+
70
+ scrollbox.add(child)
71
+ expect(scrollbox.getChildren().length).toBe(1)
72
+
73
+ scrollbox.remove(child)
74
+ expect(scrollbox.getChildren().length).toBe(0)
75
+ })
76
+
77
+ test("delegates insertBefore to content wrapper", () => {
78
+ const scrollbox = new ScrollBoxRenderable(testRenderer, { id: "scrollbox" })
79
+ const child1 = new BoxRenderable(testRenderer, { id: "child1" })
80
+ const child2 = new BoxRenderable(testRenderer, { id: "child2" })
81
+ const child3 = new BoxRenderable(testRenderer, { id: "child3" })
82
+
83
+ scrollbox.add(child1)
84
+ scrollbox.add(child2)
85
+ scrollbox.insertBefore(child3, child2)
86
+
87
+ const children = scrollbox.getChildren()
88
+ expect(children.length).toBe(3)
89
+ expect(children[0].id).toBe("child1")
90
+ expect(children[1].id).toBe("child3")
91
+ expect(children[2].id).toBe("child2")
92
+ })
93
+
94
+ test("destroyRecursively fully detaches internal parts without warnings", () => {
95
+ const scrollbox = new ScrollBoxRenderable(testRenderer, { id: "scrollbox" })
96
+ const child = new BoxRenderable(testRenderer, { id: "child" })
97
+ scrollbox.add(child)
98
+
99
+ const wrapper = scrollbox.wrapper
100
+ const verticalScrollBar = scrollbox.verticalScrollBar
101
+
102
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
103
+ try {
104
+ scrollbox.destroyRecursively()
105
+ expect(warnSpy).not.toHaveBeenCalled()
106
+ } finally {
107
+ warnSpy.mockRestore()
108
+ }
109
+
110
+ expect(wrapper.isDestroyed).toBe(true)
111
+ expect(wrapper.parent).toBeNull()
112
+ expect(verticalScrollBar.isDestroyed).toBe(true)
113
+ expect(verticalScrollBar.parent).toBeNull()
114
+ expect(child.isDestroyed).toBe(true)
115
+ expect(child.parent).toBeNull()
116
+ })
117
+
118
+ test("removing an internal part by reference detaches it from its real parent", () => {
119
+ const scrollbox = new ScrollBoxRenderable(testRenderer, { id: "scrollbox" })
120
+
121
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
122
+ try {
123
+ scrollbox.remove(scrollbox.verticalScrollBar)
124
+ expect(warnSpy).not.toHaveBeenCalled()
125
+ } finally {
126
+ warnSpy.mockRestore()
127
+ }
128
+
129
+ expect(scrollbox.verticalScrollBar.parent).toBeNull()
130
+ })
131
+ })
132
+
133
+ describe("ScrollBoxRenderable - culled content layout freshness", () => {
134
+ test("rows revealed after off-screen relayouts render at fresh positions", async () => {
135
+ const scrollbox = new ScrollBoxRenderable(testRenderer, {
136
+ id: "reveal-scrollbox",
137
+ width: 30,
138
+ height: 6,
139
+ viewportCulling: true,
140
+ })
141
+ testRenderer.root.add(scrollbox)
142
+
143
+ const rows: BoxRenderable[] = []
144
+ for (let i = 0; i < 40; i++) {
145
+ const row = new BoxRenderable(testRenderer, { id: `reveal-row-${i}`, height: 1, flexShrink: 0 })
146
+ row.add(new TextRenderable(testRenderer, { content: `row-${i}` }))
147
+ scrollbox.add(row)
148
+ rows.push(row)
149
+ }
150
+ await renderOnce()
151
+
152
+ scrollbox.scrollTo(scrollbox.scrollHeight)
153
+ await renderOnce()
154
+
155
+ // While the top rows are culled (their subtrees skipped by traversal),
156
+ // relayout several times so any staleness in skipped subtrees would have
157
+ // multiple chances to accumulate before the rows are revealed again.
158
+ for (const rowHeight of [2, 3, 2]) {
159
+ rows[0].height = rowHeight
160
+ await renderOnce()
161
+ }
162
+
163
+ scrollbox.scrollTo(0)
164
+ await renderOnce()
165
+
166
+ // row-0 is now 2 tall, so the following rows must have shifted down and
167
+ // their text subtrees must render at fresh absolute positions.
168
+ const lines = captureCharFrame().split("\n")
169
+ expect(lines[0]).toContain("row-0")
170
+ expect(lines[2]).toContain("row-1")
171
+ expect(lines[3]).toContain("row-2")
172
+ expect(lines[4]).toContain("row-3")
173
+ })
174
+ })
175
+
176
+ describe("ScrollBoxRenderable - clipping", () => {
177
+ test("clips nested scrollbox content to inner viewport (see issue #388)", async () => {
178
+ const root = new BoxRenderable(testRenderer, {
179
+ flexDirection: "column",
180
+ gap: 0,
181
+ width: 32,
182
+ height: 16,
183
+ })
184
+
185
+ const outer = new ScrollBoxRenderable(testRenderer, {
186
+ width: 30,
187
+ height: 10,
188
+ border: true,
189
+ overflow: "hidden",
190
+ scrollY: true,
191
+ })
192
+
193
+ const inner = new ScrollBoxRenderable(testRenderer, {
194
+ width: 26,
195
+ height: 6,
196
+ border: true,
197
+ overflow: "hidden",
198
+ scrollY: true,
199
+ })
200
+
201
+ for (let index = 0; index < 6; index += 1) {
202
+ inner.add(new TextRenderable(testRenderer, { content: `LEAK-${index}` }))
203
+ }
204
+
205
+ outer.add(inner)
206
+ root.add(outer)
207
+ testRenderer.root.add(root)
208
+
209
+ await renderOnce()
210
+
211
+ const frame = captureCharFrame()
212
+ const innerViewportHeight = 4 // height 6 minus top/bottom border
213
+ const visibleLines = frame.split("\n").filter((line) => line.includes("LEAK-"))
214
+
215
+ expect(visibleLines.length).toBeLessThanOrEqual(innerViewportHeight)
216
+ })
217
+ })
218
+
219
+ describe("ScrollBoxRenderable - padding behavior", () => {
220
+ test("applies scrollbox padding to content while keeping scrollbar docked", async () => {
221
+ const noPadding = new ScrollBoxRenderable(testRenderer, {
222
+ left: 0,
223
+ top: 0,
224
+ width: 26,
225
+ height: 9,
226
+ border: true,
227
+ scrollY: true,
228
+ })
229
+
230
+ const padded = new ScrollBoxRenderable(testRenderer, {
231
+ left: 0,
232
+ top: 11,
233
+ width: 26,
234
+ height: 9,
235
+ border: true,
236
+ scrollY: true,
237
+ padding: 2,
238
+ })
239
+
240
+ for (let index = 0; index < 24; index += 1) {
241
+ noPadding.add(new TextRenderable(testRenderer, { content: `NP-${index}` }))
242
+ padded.add(new TextRenderable(testRenderer, { content: `PD-${index}` }))
243
+ }
244
+
245
+ testRenderer.root.add(noPadding)
246
+ testRenderer.root.add(padded)
247
+
248
+ await renderOnce()
249
+
250
+ expect(noPadding.verticalScrollBar.visible).toBe(true)
251
+ expect(padded.verticalScrollBar.visible).toBe(true)
252
+ expect(noPadding.verticalScrollBar.x).toBe(noPadding.x + noPadding.width - 2)
253
+ expect(padded.verticalScrollBar.x).toBe(padded.x + padded.width - 2)
254
+
255
+ const frameLines = captureCharFrame().split("\n")
256
+ const noPaddingRow = frameLines.find((line) => line.includes("NP-0"))
257
+ const paddedRow = frameLines.find((line) => line.includes("PD-0"))
258
+
259
+ expect(noPaddingRow).toBeDefined()
260
+ expect(paddedRow).toBeDefined()
261
+
262
+ const noPaddingTextX = noPaddingRow?.indexOf("NP-0") ?? -1
263
+ const paddedTextX = paddedRow?.indexOf("PD-0") ?? -1
264
+
265
+ expect(paddedTextX).toBeGreaterThan(noPaddingTextX)
266
+ })
267
+
268
+ test("padding setter updates content inset without moving scrollbar", async () => {
269
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
270
+ width: 26,
271
+ height: 9,
272
+ border: true,
273
+ scrollY: true,
274
+ })
275
+
276
+ for (let index = 0; index < 24; index += 1) {
277
+ scrollBox.add(new TextRenderable(testRenderer, { content: `PX-${index}` }))
278
+ }
279
+
280
+ testRenderer.root.add(scrollBox)
281
+ await renderOnce()
282
+
283
+ const beforeScrollbarX = scrollBox.verticalScrollBar.x
284
+ const beforeFrameLines = captureCharFrame().split("\n")
285
+ const beforeRow = beforeFrameLines.find((line) => line.includes("PX-0"))
286
+ const beforeTextX = beforeRow?.indexOf("PX-0") ?? -1
287
+
288
+ scrollBox.padding = 2
289
+ await renderOnce()
290
+
291
+ const afterFrameLines = captureCharFrame().split("\n")
292
+ const afterRow = afterFrameLines.find((line) => line.includes("PX-0"))
293
+ const afterTextX = afterRow?.indexOf("PX-0") ?? -1
294
+
295
+ expect(scrollBox.verticalScrollBar.x).toBe(beforeScrollbarX)
296
+ expect(afterTextX).toBeGreaterThan(beforeTextX)
297
+ })
298
+ })
299
+
300
+ describe("ScrollBoxRenderable - destroyRecursively", () => {
301
+ test("destroys internal ScrollBox components", () => {
302
+ const parent = new ScrollBoxRenderable(testRenderer, { id: "scroll-parent" })
303
+ const child = new BoxRenderable(testRenderer, { id: "child" })
304
+
305
+ parent.add(child)
306
+
307
+ const wrapper = parent.wrapper
308
+ const viewport = parent.viewport
309
+ const content = parent.content
310
+ const horizontalScrollBar = parent.horizontalScrollBar
311
+ const verticalScrollBar = parent.verticalScrollBar
312
+
313
+ expect(parent.isDestroyed).toBe(false)
314
+ expect(child.isDestroyed).toBe(false)
315
+ expect(wrapper.isDestroyed).toBe(false)
316
+ expect(viewport.isDestroyed).toBe(false)
317
+ expect(content.isDestroyed).toBe(false)
318
+ expect(horizontalScrollBar.isDestroyed).toBe(false)
319
+ expect(verticalScrollBar.isDestroyed).toBe(false)
320
+
321
+ parent.destroyRecursively()
322
+
323
+ expect(parent.isDestroyed).toBe(true)
324
+ expect(child.isDestroyed).toBe(true)
325
+ expect(wrapper.isDestroyed).toBe(true)
326
+ expect(viewport.isDestroyed).toBe(true)
327
+ expect(content.isDestroyed).toBe(true)
328
+ expect(horizontalScrollBar.isDestroyed).toBe(true)
329
+ expect(verticalScrollBar.isDestroyed).toBe(true)
330
+ })
331
+ })
332
+
333
+ describe("ScrollBoxRenderable - Mouse interaction", () => {
334
+ test("scrolls with mouse wheel", async () => {
335
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
336
+ width: 50,
337
+ height: 20,
338
+ scrollAcceleration: new MacOSScrollAccel({ A: 0 }),
339
+ })
340
+ for (let i = 0; i < 50; i++) scrollBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
341
+ testRenderer.root.add(scrollBox)
342
+ await renderOnce()
343
+
344
+ await mockMouse.scroll(25, 10, "down")
345
+ await renderOnce()
346
+ expect(scrollBox.scrollTop).toBeGreaterThan(0)
347
+ })
348
+
349
+ test("single isolated scroll has same distance as linear", async () => {
350
+ const linearBox = new ScrollBoxRenderable(testRenderer, {
351
+ width: 50,
352
+ height: 20,
353
+ scrollAcceleration: new LinearScrollAccel(),
354
+ })
355
+
356
+ for (let i = 0; i < 100; i++) linearBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
357
+ testRenderer.root.add(linearBox)
358
+ await renderOnce()
359
+
360
+ await mockMouse.scroll(25, 10, "down")
361
+ await renderOnce()
362
+ const linearDistance = linearBox.scrollTop
363
+
364
+ testRenderer.destroy()
365
+ ;({
366
+ renderer: testRenderer,
367
+ mockMouse,
368
+ renderOnce,
369
+ captureCharFrame,
370
+ } = await createTestRenderer({ width: 80, height: 24 }))
371
+
372
+ const accelBox = new ScrollBoxRenderable(testRenderer, {
373
+ width: 50,
374
+ height: 20,
375
+ scrollAcceleration: new MacOSScrollAccel(),
376
+ })
377
+
378
+ for (let i = 0; i < 100; i++) accelBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
379
+ testRenderer.root.add(accelBox)
380
+ await renderOnce()
381
+
382
+ await mockMouse.scroll(25, 10, "down")
383
+ await renderOnce()
384
+
385
+ expect(accelBox.scrollTop).toBe(linearDistance)
386
+ })
387
+
388
+ test("acceleration makes rapid scrolls cover more distance", async () => {
389
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
390
+ width: 50,
391
+ height: 20,
392
+ scrollAcceleration: new MacOSScrollAccel({ A: 0.8, tau: 3, maxMultiplier: 6 }),
393
+ })
394
+ for (let i = 0; i < 200; i++) scrollBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
395
+ testRenderer.root.add(scrollBox)
396
+ await renderOnce()
397
+
398
+ await mockMouse.scroll(25, 10, "down")
399
+ await renderOnce()
400
+ const slowScrollDistance = scrollBox.scrollTop
401
+
402
+ scrollBox.scrollTop = 0
403
+
404
+ for (let i = 0; i < 5; i++) {
405
+ await mockMouse.scroll(25, 10, "down")
406
+ await renderOnce()
407
+ }
408
+ const rapidScrollDistance = scrollBox.scrollTop
409
+
410
+ expect(rapidScrollDistance).toBeGreaterThan(slowScrollDistance * 3)
411
+ })
412
+
413
+ test("multiplier < 1 slows down scroll distance", async () => {
414
+ // Test with slowdown using a constant multiplier < 1
415
+ const slowdownBox = new ScrollBoxRenderable(testRenderer, {
416
+ width: 50,
417
+ height: 20,
418
+ scrollAcceleration: new ConstantScrollAccel(0.5),
419
+ })
420
+ for (let i = 0; i < 200; i++) slowdownBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
421
+ testRenderer.root.add(slowdownBox)
422
+ await renderOnce()
423
+
424
+ // ConstantScrollAccel ignores timing, so no delay needed
425
+ for (let i = 0; i < 5; i++) {
426
+ await mockMouse.scroll(25, 10, "down")
427
+ await renderOnce()
428
+ }
429
+ const slowdownDistance = slowdownBox.scrollTop
430
+
431
+ testRenderer.destroy()
432
+ ;({
433
+ renderer: testRenderer,
434
+ mockMouse,
435
+ renderOnce,
436
+ captureCharFrame,
437
+ } = await createTestRenderer({
438
+ width: 80,
439
+ height: 24,
440
+ }))
441
+
442
+ // Compare with linear (no slowdown)
443
+ const linearBox = new ScrollBoxRenderable(testRenderer, {
444
+ width: 50,
445
+ height: 20,
446
+ scrollAcceleration: new LinearScrollAccel(),
447
+ })
448
+ for (let i = 0; i < 200; i++) linearBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
449
+ testRenderer.root.add(linearBox)
450
+ await renderOnce()
451
+
452
+ // LinearScrollAccel ignores timing, so no delay needed
453
+ for (let i = 0; i < 5; i++) {
454
+ await mockMouse.scroll(25, 10, "down")
455
+ await renderOnce()
456
+ }
457
+ const linearDistance = linearBox.scrollTop
458
+
459
+ expect(slowdownDistance).toBeLessThan(linearDistance)
460
+ expect(slowdownDistance).toBeGreaterThan(0)
461
+ })
462
+
463
+ test("multiplier < 1 accumulates fractional scroll amounts", async () => {
464
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
465
+ width: 50,
466
+ height: 20,
467
+ scrollAcceleration: new ConstantScrollAccel(0.3),
468
+ })
469
+ for (let i = 0; i < 200; i++) scrollBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
470
+ testRenderer.root.add(scrollBox)
471
+ await renderOnce()
472
+
473
+ // With multiplier < 1, fractional amounts accumulate
474
+ // It should take multiple scroll events to accumulate enough to scroll 1 full unit
475
+ let scrolled = false
476
+ for (let i = 0; i < 5; i++) {
477
+ await mockMouse.scroll(25, 10, "down")
478
+ await renderOnce()
479
+ if (scrollBox.scrollTop > 0) {
480
+ scrolled = true
481
+ break
482
+ }
483
+ }
484
+
485
+ expect(scrolled).toBe(true)
486
+ expect(scrollBox.scrollTop).toBeGreaterThan(0)
487
+ })
488
+
489
+ test("horizontal scroll with multiplier < 1 works correctly", async () => {
490
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
491
+ width: 50,
492
+ height: 20,
493
+ scrollX: true,
494
+ scrollAcceleration: new ConstantScrollAccel(0.4),
495
+ })
496
+
497
+ const wideBox = new BoxRenderable(testRenderer, { width: 300, height: 10 })
498
+ scrollBox.add(wideBox)
499
+ testRenderer.root.add(scrollBox)
500
+ await renderOnce()
501
+
502
+ await mockMouse.scroll(25, 10, "right")
503
+ await renderOnce()
504
+
505
+ // Should eventually scroll after multiple events due to accumulation
506
+ let scrolled = false
507
+ for (let i = 0; i < 5; i++) {
508
+ await mockMouse.scroll(25, 10, "right")
509
+ await renderOnce()
510
+ if (scrollBox.scrollLeft > 0) {
511
+ scrolled = true
512
+ break
513
+ }
514
+ }
515
+
516
+ expect(scrolled).toBe(true)
517
+ })
518
+
519
+ test("multiplier < 1 with acceleration work together", async () => {
520
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
521
+ width: 50,
522
+ height: 20,
523
+ scrollAcceleration: new ConstantScrollAccel(0.3),
524
+ })
525
+ for (let i = 0; i < 200; i++) scrollBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
526
+ testRenderer.root.add(scrollBox)
527
+ await renderOnce()
528
+
529
+ // Multiple scrolls should accumulate fractional amounts
530
+ for (let i = 0; i < 10; i++) {
531
+ await mockMouse.scroll(25, 10, "down")
532
+ await renderOnce()
533
+ }
534
+ const scrollDistance = scrollBox.scrollTop
535
+
536
+ // With 0.3 multiplier and 10 scrolls: 10 * 1 * 0.3 = 3 pixels total
537
+ // Math.trunc applied each time, so we get 2 pixels actually scrolled
538
+ expect(scrollDistance).toBeGreaterThan(0)
539
+ expect(scrollDistance).toBeLessThan(5)
540
+ })
541
+ })
542
+
543
+ describe("ScrollBoxRenderable - Content Visibility", () => {
544
+ test("renders markdown prose in scrollbox before highlighting completes", async () => {
545
+ const syntaxStyle = SyntaxStyle.fromTheme([])
546
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
547
+ width: 100,
548
+ height: 20,
549
+ viewportCulling: true,
550
+ })
551
+
552
+ testRenderer.root.add(scrollBox)
553
+
554
+ for (let i = 0; i < 20; i++) {
555
+ const wrapper = new BoxRenderable(testRenderer, {
556
+ id: `text-${i}`,
557
+ paddingLeft: 3,
558
+ marginTop: 1,
559
+ flexShrink: 0,
560
+ })
561
+ const markdown = new MarkdownRenderable(testRenderer, {
562
+ syntaxStyle,
563
+ streaming: true,
564
+ internalBlockMode: "top-level",
565
+ content: `PARAGRAPH_${i}\n\n\`\`\`ts\nconst CODE_${i} = ${i}\n\`\`\``,
566
+ treeSitterClient: mockTreeSitterClient,
567
+ })
568
+ wrapper.add(markdown)
569
+ scrollBox.add(wrapper)
570
+ }
571
+
572
+ await renderOnce()
573
+
574
+ const frame = captureCharFrame()
575
+ expect(frame).toContain("PARAGRAPH_0")
576
+ })
577
+
578
+ test("maintains visibility when scrolling with many Code elements", async () => {
579
+ const syntaxStyle = SyntaxStyle.fromTheme([])
580
+
581
+ const parent = new BoxRenderable(testRenderer, {
582
+ flexDirection: "column",
583
+ gap: 1,
584
+ })
585
+
586
+ const header = new BoxRenderable(testRenderer, { flexShrink: 0 })
587
+ header.add(new TextRenderable(testRenderer, { content: "Header Content" }))
588
+
589
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
590
+ flexGrow: 1,
591
+ stickyScroll: true,
592
+ stickyStart: "bottom",
593
+ })
594
+
595
+ const footer = new BoxRenderable(testRenderer, { flexShrink: 0 })
596
+ footer.add(new TextRenderable(testRenderer, { content: "Footer Content" }))
597
+
598
+ parent.add(header)
599
+ parent.add(scrollBox)
600
+ parent.add(footer)
601
+ testRenderer.root.add(parent)
602
+
603
+ await renderOnce()
604
+ const initialFrame = captureCharFrame()
605
+ expect(initialFrame).toContain("Header Content")
606
+ expect(initialFrame).toContain("Footer Content")
607
+
608
+ const codeContent = `
609
+ # HELLO
610
+
611
+ world
612
+
613
+ ## HELLO World
614
+
615
+ \`\`\`html
616
+ <div class="example">
617
+ <p>Content</p>
618
+ </div>
619
+ \`\`\`
620
+ `
621
+
622
+ const codes: CodeRenderable[] = []
623
+ for (let i = 0; i < 100; i++) {
624
+ const wrapper = new BoxRenderable(testRenderer, {
625
+ marginTop: 2,
626
+ marginBottom: 2,
627
+ })
628
+ const code = new CodeRenderable(testRenderer, {
629
+ content: codeContent,
630
+ filetype: "markdown",
631
+ syntaxStyle,
632
+ drawUnstyledText: false,
633
+ treeSitterClient: mockTreeSitterClient,
634
+ })
635
+ codes.push(code)
636
+ wrapper.add(code)
637
+ scrollBox.add(wrapper)
638
+ }
639
+
640
+ await renderOnce()
641
+
642
+ mockTreeSitterClient.resolveAllHighlightOnce()
643
+ await Promise.all(codes.map((c) => c.highlightingDone))
644
+ await renderOnce()
645
+
646
+ scrollBox.scrollTo(scrollBox.scrollHeight)
647
+ await renderOnce()
648
+
649
+ mockTreeSitterClient.resolveAllHighlightOnce()
650
+ await Promise.all(codes.map((c) => c.highlightingDone))
651
+ await renderOnce()
652
+
653
+ const frameAfterScroll = captureCharFrame()
654
+
655
+ expect(frameAfterScroll).toContain("Header Content")
656
+ expect(frameAfterScroll).toContain("Footer Content")
657
+
658
+ const hasCodeContent =
659
+ frameAfterScroll.includes("HELLO") ||
660
+ frameAfterScroll.includes("world") ||
661
+ frameAfterScroll.includes("<div") ||
662
+ frameAfterScroll.includes("```")
663
+
664
+ expect(hasCodeContent).toBe(true)
665
+
666
+ const nonWhitespaceChars = frameAfterScroll.replace(/\s/g, "").length
667
+ expect(nonWhitespaceChars).toBeGreaterThan(50)
668
+ })
669
+
670
+ test("maintains visibility when scrolling with many Code elements (setter-based, like SolidJS)", async () => {
671
+ const syntaxStyle = SyntaxStyle.fromTheme([])
672
+
673
+ const parent = new BoxRenderable(testRenderer, {
674
+ flexDirection: "column",
675
+ gap: 1,
676
+ })
677
+
678
+ const header = new BoxRenderable(testRenderer, { flexShrink: 0 })
679
+ header.add(new TextRenderable(testRenderer, { content: "Header Content" }))
680
+
681
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
682
+ flexGrow: 1,
683
+ stickyScroll: true,
684
+ stickyStart: "bottom",
685
+ })
686
+
687
+ const footer = new BoxRenderable(testRenderer, { flexShrink: 0 })
688
+ footer.add(new TextRenderable(testRenderer, { content: "Footer Content" }))
689
+
690
+ parent.add(header)
691
+ parent.add(scrollBox)
692
+ parent.add(footer)
693
+ testRenderer.root.add(parent)
694
+
695
+ await renderOnce()
696
+ const initialFrame = captureCharFrame()
697
+ expect(initialFrame).toContain("Header Content")
698
+ expect(initialFrame).toContain("Footer Content")
699
+
700
+ const codeContent = `
701
+ # HELLO
702
+
703
+ world
704
+
705
+ ## HELLO World
706
+
707
+ \`\`\`html
708
+ <div class="example">
709
+ <p>Content</p>
710
+ </div>
711
+ \`\`\`
712
+ `
713
+
714
+ for (let i = 0; i < 100; i++) {
715
+ const wrapper = new BoxRenderable(testRenderer, { id: `wrapper-${i}` })
716
+ wrapper.marginTop = 2
717
+ wrapper.marginBottom = 2
718
+
719
+ const code = new CodeRenderable(testRenderer, {
720
+ id: `code-${i}`,
721
+ syntaxStyle,
722
+ drawUnstyledText: false,
723
+ treeSitterClient: mockTreeSitterClient,
724
+ })
725
+
726
+ wrapper.add(code)
727
+ code.content = codeContent
728
+ code.filetype = "markdown"
729
+
730
+ scrollBox.add(wrapper)
731
+ }
732
+
733
+ await testRenderer.idle()
734
+
735
+ mockTreeSitterClient.resolveAllHighlightOnce()
736
+ await Promise.resolve()
737
+
738
+ await testRenderer.idle()
739
+
740
+ scrollBox.scrollTo(scrollBox.scrollHeight)
741
+ await testRenderer.idle()
742
+
743
+ const frameAfterScroll = captureCharFrame()
744
+
745
+ expect(frameAfterScroll).toContain("Header Content")
746
+ expect(frameAfterScroll).toContain("Footer Content")
747
+
748
+ const hasCodeContent =
749
+ frameAfterScroll.includes("HELLO") ||
750
+ frameAfterScroll.includes("world") ||
751
+ frameAfterScroll.includes("<div") ||
752
+ frameAfterScroll.includes("```")
753
+
754
+ expect(hasCodeContent).toBe(true)
755
+
756
+ const nonWhitespaceChars = frameAfterScroll.replace(/\s/g, "").length
757
+ expect(nonWhitespaceChars).toBeGreaterThan(50)
758
+ })
759
+
760
+ test("maintains visibility with simple Code elements (constructor)", async () => {
761
+ const syntaxStyle = SyntaxStyle.fromTheme([])
762
+
763
+ const parent = new BoxRenderable(testRenderer, {
764
+ flexDirection: "column",
765
+ gap: 1,
766
+ })
767
+
768
+ const header = new BoxRenderable(testRenderer, { flexShrink: 0 })
769
+ header.add(new TextRenderable(testRenderer, { content: "Header" }))
770
+
771
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
772
+ flexGrow: 1,
773
+ stickyScroll: true,
774
+ stickyStart: "bottom",
775
+ })
776
+
777
+ const footer = new BoxRenderable(testRenderer, { flexShrink: 0 })
778
+ footer.add(new TextRenderable(testRenderer, { content: "Footer" }))
779
+
780
+ parent.add(header)
781
+ parent.add(scrollBox)
782
+ parent.add(footer)
783
+ testRenderer.root.add(parent)
784
+
785
+ await renderOnce()
786
+
787
+ const codes: CodeRenderable[] = []
788
+ for (let i = 0; i < 50; i++) {
789
+ const wrapper = new BoxRenderable(testRenderer, {
790
+ marginTop: 1,
791
+ marginBottom: 1,
792
+ })
793
+ const code = new CodeRenderable(testRenderer, {
794
+ content: `Item ${i}`,
795
+ filetype: "markdown",
796
+ syntaxStyle,
797
+ drawUnstyledText: false,
798
+ treeSitterClient: mockTreeSitterClient,
799
+ })
800
+ codes.push(code)
801
+ wrapper.add(code)
802
+ scrollBox.add(wrapper)
803
+ }
804
+
805
+ await renderOnce()
806
+
807
+ mockTreeSitterClient.resolveAllHighlightOnce()
808
+ await Promise.all(codes.map((c) => c.highlightingDone))
809
+ await renderOnce()
810
+
811
+ scrollBox.scrollTo(scrollBox.scrollHeight)
812
+ await renderOnce()
813
+
814
+ mockTreeSitterClient.resolveAllHighlightOnce()
815
+ await Promise.all(codes.map((c) => c.highlightingDone))
816
+ await renderOnce()
817
+
818
+ const frame = captureCharFrame()
819
+
820
+ expect(frame).toContain("Header")
821
+ expect(frame).toContain("Footer")
822
+
823
+ const hasItems = /Item \d+/.test(frame)
824
+ expect(hasItems).toBe(true)
825
+
826
+ const nonWhitespaceChars = frame.replace(/\s/g, "").length
827
+ expect(nonWhitespaceChars).toBeGreaterThan(18)
828
+ })
829
+
830
+ test("maintains visibility with simple Code elements (setter-based, like SolidJS)", async () => {
831
+ const syntaxStyle = SyntaxStyle.fromTheme([])
832
+
833
+ const parent = new BoxRenderable(testRenderer, {
834
+ flexDirection: "column",
835
+ gap: 1,
836
+ })
837
+
838
+ const header = new BoxRenderable(testRenderer, { flexShrink: 0 })
839
+ header.add(new TextRenderable(testRenderer, { content: "Header" }))
840
+
841
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
842
+ flexGrow: 1,
843
+ stickyScroll: true,
844
+ stickyStart: "bottom",
845
+ })
846
+
847
+ const footer = new BoxRenderable(testRenderer, { flexShrink: 0 })
848
+ footer.add(new TextRenderable(testRenderer, { content: "Footer" }))
849
+
850
+ parent.add(header)
851
+ parent.add(scrollBox)
852
+ parent.add(footer)
853
+ testRenderer.root.add(parent)
854
+
855
+ await renderOnce()
856
+
857
+ const codes: CodeRenderable[] = []
858
+ for (let i = 0; i < 50; i++) {
859
+ const wrapper = new BoxRenderable(testRenderer, { id: `wrapper-${i}` })
860
+ wrapper.marginTop = 1
861
+ wrapper.marginBottom = 1
862
+
863
+ const code = new CodeRenderable(testRenderer, {
864
+ id: `code-${i}`,
865
+ syntaxStyle,
866
+ drawUnstyledText: false,
867
+ treeSitterClient: mockTreeSitterClient,
868
+ })
869
+
870
+ wrapper.add(code)
871
+ code.content = `Item ${i}`
872
+ code.filetype = "markdown"
873
+ codes.push(code)
874
+
875
+ scrollBox.add(wrapper)
876
+ }
877
+
878
+ await renderOnce()
879
+ mockTreeSitterClient.resolveAllHighlightOnce()
880
+ await Promise.all(codes.map((c) => c.highlightingDone))
881
+ await renderOnce()
882
+
883
+ scrollBox.scrollTo(scrollBox.scrollHeight)
884
+ await renderOnce()
885
+
886
+ const frame = captureCharFrame()
887
+
888
+ expect(frame).toContain("Header")
889
+ expect(frame).toContain("Footer")
890
+
891
+ const hasItems = /Item \d+/.test(frame)
892
+ expect(hasItems).toBe(true)
893
+
894
+ const nonWhitespaceChars = frame.replace(/\s/g, "").length
895
+ expect(nonWhitespaceChars).toBeGreaterThan(18)
896
+ })
897
+
898
+ test("maintains visibility with TextRenderable elements", async () => {
899
+ const parent = new BoxRenderable(testRenderer, {
900
+ flexDirection: "column",
901
+ gap: 1,
902
+ })
903
+
904
+ const header = new BoxRenderable(testRenderer, { flexShrink: 0 })
905
+ header.add(new TextRenderable(testRenderer, { content: "Header" }))
906
+
907
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
908
+ flexGrow: 1,
909
+ stickyScroll: true,
910
+ stickyStart: "bottom",
911
+ })
912
+
913
+ const footer = new BoxRenderable(testRenderer, { flexShrink: 0 })
914
+ footer.add(new TextRenderable(testRenderer, { content: "Footer" }))
915
+
916
+ parent.add(header)
917
+ parent.add(scrollBox)
918
+ parent.add(footer)
919
+ testRenderer.root.add(parent)
920
+
921
+ await renderOnce()
922
+
923
+ for (let i = 0; i < 50; i++) {
924
+ const wrapper = new BoxRenderable(testRenderer, {
925
+ marginTop: 1,
926
+ marginBottom: 1,
927
+ })
928
+ wrapper.add(new TextRenderable(testRenderer, { content: `Item ${i}` }))
929
+ scrollBox.add(wrapper)
930
+ }
931
+
932
+ await renderOnce()
933
+
934
+ scrollBox.scrollTo(scrollBox.scrollHeight)
935
+ await renderOnce()
936
+
937
+ const frame = captureCharFrame()
938
+
939
+ expect(frame).toContain("Header")
940
+ expect(frame).toContain("Footer")
941
+
942
+ const hasItems = /Item \d+/.test(frame)
943
+ expect(hasItems).toBe(true)
944
+
945
+ const nonWhitespaceChars = frame.replace(/\s/g, "").length
946
+ expect(nonWhitespaceChars).toBeGreaterThan(20)
947
+ })
948
+
949
+ test("stays scrolled to bottom with growing code renderables in sticky scroll mode", async () => {
950
+ const syntaxStyle = SyntaxStyle.fromTheme([])
951
+ // Use manual-resolving mock client for deterministic behavior
952
+ const autoResolvingClient = createMockTreeSitterClient()
953
+
954
+ const parent = new BoxRenderable(testRenderer, {
955
+ flexDirection: "column",
956
+ gap: 1,
957
+ })
958
+
959
+ const header = new BoxRenderable(testRenderer, { flexShrink: 0 })
960
+ header.add(new TextRenderable(testRenderer, { content: "Header" }))
961
+
962
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
963
+ flexGrow: 1,
964
+ stickyScroll: true,
965
+ stickyStart: "bottom",
966
+ })
967
+
968
+ const footer = new BoxRenderable(testRenderer, { flexShrink: 0 })
969
+ footer.add(new TextRenderable(testRenderer, { content: "Footer" }))
970
+
971
+ parent.add(header)
972
+ parent.add(scrollBox)
973
+ parent.add(footer)
974
+ testRenderer.root.add(parent)
975
+
976
+ const scrollPositions: number[] = []
977
+ const maxScrollPositions: number[] = []
978
+ const wrapper1 = new BoxRenderable(testRenderer, {
979
+ marginTop: 1,
980
+ marginBottom: 1,
981
+ })
982
+ const code1 = new CodeRenderable(testRenderer, {
983
+ content: "console.log('hello')",
984
+ filetype: "javascript",
985
+ syntaxStyle,
986
+ drawUnstyledText: false,
987
+ treeSitterClient: autoResolvingClient,
988
+ })
989
+ wrapper1.add(code1)
990
+ scrollBox.add(wrapper1)
991
+
992
+ await renderOnce()
993
+ autoResolvingClient.resolveAllHighlightOnce()
994
+ await code1.highlightingDone
995
+ await renderOnce()
996
+
997
+ scrollPositions.push(scrollBox.scrollTop)
998
+ maxScrollPositions.push(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height))
999
+ expect(scrollBox.scrollTop).toBe(maxScrollPositions[0])
1000
+
1001
+ code1.content = `console.log('hello')
1002
+ const foo = 'bar'
1003
+ const baz = 'qux'
1004
+ function test() {
1005
+ return 42
1006
+ }
1007
+ console.log(test())`
1008
+
1009
+ await renderOnce()
1010
+ autoResolvingClient.resolveAllHighlightOnce()
1011
+ await code1.highlightingDone
1012
+ await renderOnce()
1013
+
1014
+ scrollPositions.push(scrollBox.scrollTop)
1015
+ maxScrollPositions.push(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height))
1016
+ expect(scrollBox.scrollTop).toBe(maxScrollPositions[1])
1017
+
1018
+ const wrapper2 = new BoxRenderable(testRenderer, {
1019
+ marginTop: 1,
1020
+ marginBottom: 1,
1021
+ })
1022
+ const code2 = new CodeRenderable(testRenderer, {
1023
+ content: "const x = 10\nconst y = 20",
1024
+ filetype: "javascript",
1025
+ syntaxStyle,
1026
+ drawUnstyledText: false,
1027
+ treeSitterClient: autoResolvingClient,
1028
+ })
1029
+ wrapper2.add(code2)
1030
+ scrollBox.add(wrapper2)
1031
+
1032
+ await renderOnce()
1033
+ autoResolvingClient.resolveAllHighlightOnce()
1034
+ await code2.highlightingDone
1035
+ await renderOnce()
1036
+
1037
+ scrollPositions.push(scrollBox.scrollTop)
1038
+ maxScrollPositions.push(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height))
1039
+ expect(scrollBox.scrollTop).toBe(maxScrollPositions[2])
1040
+
1041
+ code2.content = `const x = 10
1042
+ const y = 20
1043
+ const z = x + y
1044
+ console.log(z)
1045
+ function multiply(a, b) {
1046
+ return a * b
1047
+ }
1048
+ const result = multiply(x, y)
1049
+ console.log('Result:', result)`
1050
+
1051
+ await renderOnce()
1052
+ autoResolvingClient.resolveAllHighlightOnce()
1053
+ await code2.highlightingDone
1054
+ await renderOnce()
1055
+
1056
+ scrollPositions.push(scrollBox.scrollTop)
1057
+ maxScrollPositions.push(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height))
1058
+ expect(scrollBox.scrollTop).toBe(maxScrollPositions[3])
1059
+
1060
+ const wrapper3 = new BoxRenderable(testRenderer, {
1061
+ marginTop: 1,
1062
+ marginBottom: 1,
1063
+ })
1064
+ const code3 = new CodeRenderable(testRenderer, {
1065
+ content: "// Final code block\nconst final = 'done'",
1066
+ filetype: "javascript",
1067
+ syntaxStyle,
1068
+ drawUnstyledText: false,
1069
+ treeSitterClient: autoResolvingClient,
1070
+ })
1071
+ wrapper3.add(code3)
1072
+ scrollBox.add(wrapper3)
1073
+
1074
+ await renderOnce()
1075
+ autoResolvingClient.resolveAllHighlightOnce()
1076
+ await code3.highlightingDone
1077
+ await renderOnce()
1078
+
1079
+ scrollPositions.push(scrollBox.scrollTop)
1080
+ maxScrollPositions.push(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height))
1081
+ expect(scrollBox.scrollTop).toBe(maxScrollPositions[4])
1082
+
1083
+ code3.content = `// Final code block
1084
+ const final = 'done'
1085
+
1086
+ class DataProcessor {
1087
+ constructor(data) {
1088
+ this.data = data
1089
+ }
1090
+
1091
+ process() {
1092
+ return this.data.map(item => item * 2)
1093
+ }
1094
+
1095
+ filter(predicate) {
1096
+ return this.data.filter(predicate)
1097
+ }
1098
+
1099
+ reduce(fn, initial) {
1100
+ return this.data.reduce(fn, initial)
1101
+ }
1102
+ }
1103
+
1104
+ const processor = new DataProcessor([1, 2, 3, 4, 5])
1105
+ console.log(processor.process())
1106
+ console.log(processor.filter(x => x > 2))
1107
+ console.log(processor.reduce((acc, val) => acc + val, 0))`
1108
+
1109
+ await renderOnce()
1110
+ autoResolvingClient.resolveAllHighlightOnce()
1111
+ await code3.highlightingDone
1112
+ await renderOnce()
1113
+
1114
+ scrollPositions.push(scrollBox.scrollTop)
1115
+ maxScrollPositions.push(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height))
1116
+ expect(scrollBox.scrollTop).toBe(maxScrollPositions[5])
1117
+
1118
+ const frame = captureCharFrame()
1119
+ expect(frame).toContain("Header")
1120
+ expect(frame).toContain("Footer")
1121
+
1122
+ const hasCodeContent =
1123
+ frame.includes("console") ||
1124
+ frame.includes("function") ||
1125
+ frame.includes("const") ||
1126
+ frame.includes("DataProcessor") ||
1127
+ frame.includes("processor")
1128
+
1129
+ expect(hasCodeContent).toBe(true)
1130
+
1131
+ const nonWhitespaceChars = frame.replace(/\s/g, "").length
1132
+ expect(nonWhitespaceChars).toBeGreaterThan(50)
1133
+
1134
+ for (let i = 0; i < scrollPositions.length; i++) {
1135
+ expect(scrollPositions[i]).toBe(maxScrollPositions[i])
1136
+ }
1137
+ })
1138
+
1139
+ test("sticky scroll bottom stays at bottom after scrollBy/scrollTo is called", async () => {
1140
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1141
+ width: 40,
1142
+ height: 10,
1143
+ stickyScroll: true,
1144
+ stickyStart: "bottom",
1145
+ })
1146
+
1147
+ testRenderer.root.add(scrollBox)
1148
+ await renderOnce()
1149
+
1150
+ scrollBox.add(new TextRenderable(testRenderer, { content: `Line 0` }))
1151
+ await renderOnce()
1152
+
1153
+ scrollBox.scrollBy(100000)
1154
+ await renderOnce()
1155
+
1156
+ scrollBox.scrollTo(scrollBox.scrollHeight)
1157
+ await renderOnce()
1158
+
1159
+ for (let i = 1; i < 30; i++) {
1160
+ scrollBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
1161
+ await renderOnce()
1162
+
1163
+ const maxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1164
+
1165
+ if (i === 16) {
1166
+ expect(scrollBox.scrollTop).toBe(maxScroll)
1167
+ }
1168
+ }
1169
+ })
1170
+
1171
+ test("scrolls CodeRenderable with LineNumberRenderable using mouse wheel", async () => {
1172
+ const syntaxStyle = SyntaxStyle.fromTheme([])
1173
+
1174
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1175
+ width: 40,
1176
+ height: 10,
1177
+ scrollY: true,
1178
+ scrollX: false,
1179
+ })
1180
+
1181
+ // Create long code content that needs scrolling
1182
+ let code = "Line 1\n"
1183
+ for (let i = 2; i <= 30; i++) {
1184
+ code += `Line ${i}\n`
1185
+ }
1186
+
1187
+ const { LineNumberRenderable } = await import("../renderables/LineNumberRenderable.js")
1188
+ const codeRenderable = new CodeRenderable(testRenderer, {
1189
+ content: code,
1190
+ filetype: "javascript",
1191
+ syntaxStyle,
1192
+ drawUnstyledText: true,
1193
+ treeSitterClient: mockTreeSitterClient,
1194
+ width: "100%",
1195
+ })
1196
+
1197
+ const codeWithLines = new LineNumberRenderable(testRenderer, {
1198
+ target: codeRenderable,
1199
+ width: "100%",
1200
+ })
1201
+
1202
+ scrollBox.add(codeWithLines)
1203
+ testRenderer.root.add(scrollBox)
1204
+
1205
+ await renderOnce()
1206
+
1207
+ mockTreeSitterClient.resolveAllHighlightOnce()
1208
+ await codeRenderable.highlightingDone
1209
+ await renderOnce()
1210
+
1211
+ // Capture initial frame (should show top lines)
1212
+ const frameTop = captureCharFrame()
1213
+ expect(frameTop).toContain("Line 1")
1214
+ expect(frameTop).not.toContain("Line 30")
1215
+
1216
+ // Scroll down to bottom
1217
+ for (let i = 0; i < 25; i++) {
1218
+ await mockMouse.scroll(20, 5, "down")
1219
+ await renderOnce()
1220
+ }
1221
+
1222
+ // Capture after scroll (should show bottom lines)
1223
+ const frameBottom = captureCharFrame()
1224
+ expect(frameBottom).toMatchSnapshot()
1225
+ expect(frameBottom).toContain("Line 30")
1226
+ expect(frameBottom).not.toContain("Line 1")
1227
+ })
1228
+
1229
+ test("sticky scroll bottom stays at bottom when gradually filled with code renderables", async () => {
1230
+ const syntaxStyle = SyntaxStyle.fromTheme([])
1231
+
1232
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1233
+ width: 40,
1234
+ height: 10,
1235
+ stickyScroll: true,
1236
+ stickyStart: "bottom",
1237
+ })
1238
+
1239
+ testRenderer.root.add(scrollBox)
1240
+ await renderOnce()
1241
+
1242
+ const scrollPositions: number[] = []
1243
+ const maxScrollPositions: number[] = []
1244
+
1245
+ scrollPositions.push(scrollBox.scrollTop)
1246
+ maxScrollPositions.push(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height))
1247
+
1248
+ for (let i = 0; i < 10; i++) {
1249
+ const code = new CodeRenderable(testRenderer, {
1250
+ syntaxStyle,
1251
+ drawUnstyledText: false,
1252
+ treeSitterClient: mockTreeSitterClient,
1253
+ })
1254
+
1255
+ let content = `// Block ${i}\n`
1256
+ for (let j = 0; j <= i; j++) {
1257
+ content += `const var${j} = ${j}\n`
1258
+ }
1259
+ code.content = content
1260
+ code.filetype = "javascript"
1261
+
1262
+ scrollBox.add(code)
1263
+
1264
+ await renderOnce()
1265
+ mockTreeSitterClient.resolveAllHighlightOnce()
1266
+ await code.highlightingDone
1267
+ await renderOnce()
1268
+
1269
+ const maxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1270
+ scrollPositions.push(scrollBox.scrollTop)
1271
+ maxScrollPositions.push(maxScroll)
1272
+ }
1273
+
1274
+ for (let i = 0; i < scrollPositions.length; i++) {
1275
+ expect(scrollPositions[i]).toBe(maxScrollPositions[i])
1276
+ }
1277
+ })
1278
+
1279
+ test("clips nested scrollboxes when multiple stacked children overflow (app-style tool blocks)", async () => {
1280
+ const custom = await createTestRenderer({ width: 120, height: 40 })
1281
+ const { renderer, renderOnce, captureCharFrame } = custom
1282
+
1283
+ const root = new BoxRenderable(renderer, { flexDirection: "column", width: 118, height: 38, gap: 0 })
1284
+ const header = new BoxRenderable(renderer, { height: 3, border: true })
1285
+ header.add(new TextRenderable(testRenderer, { content: "HEADER" }))
1286
+ root.add(header)
1287
+
1288
+ const outer = new ScrollBoxRenderable(renderer, { height: 25, border: true, overflow: "hidden", scrollY: true })
1289
+ expect((outer as any)._overflow).toBe("hidden")
1290
+
1291
+ const addToolBlock = (id: number) => {
1292
+ const wrapper = new BoxRenderable(renderer, { border: true, padding: 0, marginTop: 0, marginBottom: 0 })
1293
+ const inner = new ScrollBoxRenderable(renderer, {
1294
+ height: 10,
1295
+ border: true,
1296
+ overflow: "hidden",
1297
+ scrollY: true,
1298
+ contentOptions: { paddingTop: 0, paddingBottom: 0, paddingLeft: 0, paddingRight: 0 },
1299
+ })
1300
+ expect((inner as any)._overflow).toBe("hidden")
1301
+ for (let i = 0; i < 15; i += 1) {
1302
+ inner.add(new TextRenderable(renderer, { content: `[tool ${id}] line ${i}` }))
1303
+ }
1304
+ wrapper.add(inner)
1305
+ outer.add(wrapper)
1306
+ }
1307
+
1308
+ addToolBlock(1)
1309
+ addToolBlock(2)
1310
+ addToolBlock(3)
1311
+
1312
+ root.add(outer)
1313
+
1314
+ const footer = new BoxRenderable(renderer, { height: 3, border: true })
1315
+ footer.add(new TextRenderable(renderer, { content: "FOOTER" }))
1316
+ root.add(footer)
1317
+
1318
+ renderer.root.add(root)
1319
+ await renderer.idle()
1320
+ expect(outer.width).toBeGreaterThan(0)
1321
+ expect(outer.height).toBeGreaterThan(0)
1322
+
1323
+ const frame = captureCharFrame()
1324
+
1325
+ // The third tool block should be clipped entirely (outer height fits ~two blocks).
1326
+ expect(frame).not.toMatch(/\[tool 3\] line 1/)
1327
+
1328
+ renderer.destroy()
1329
+ })
1330
+
1331
+ test("does not overdraw above header when scrolling nested tool blocks upward", async () => {
1332
+ const custom = await createTestRenderer({ width: 120, height: 24 })
1333
+ const { renderer, renderOnce, captureCharFrame } = custom
1334
+
1335
+ const root = new BoxRenderable(renderer, { flexDirection: "column", width: 118, height: 22, gap: 0 })
1336
+ const header = new BoxRenderable(renderer, { height: 3, border: true })
1337
+ header.add(new TextRenderable(renderer, { content: "HEADER" }))
1338
+ root.add(header)
1339
+
1340
+ const outer = new ScrollBoxRenderable(renderer, { height: 14, border: true, overflow: "hidden", scrollY: true })
1341
+ const inner = new ScrollBoxRenderable(renderer, { height: 10, border: true, overflow: "hidden", scrollY: true })
1342
+ for (let i = 0; i < 12; i += 1) {
1343
+ inner.add(new TextRenderable(renderer, { content: `[tool] line ${i}` }))
1344
+ }
1345
+ outer.add(inner)
1346
+ root.add(outer)
1347
+
1348
+ const footer = new BoxRenderable(renderer, { height: 3, border: true })
1349
+ footer.add(new TextRenderable(renderer, { content: "FOOTER" }))
1350
+ root.add(footer)
1351
+
1352
+ renderer.root.add(root)
1353
+ await renderOnce()
1354
+
1355
+ // Scroll up to try to draw above header
1356
+ inner.scrollTo({ x: 0, y: -100 })
1357
+ outer.scrollTo({ x: 0, y: -100 })
1358
+ await renderOnce()
1359
+
1360
+ const frame = captureCharFrame()
1361
+ const headerIndex = frame.indexOf("HEADER")
1362
+ const firstToolIndex = frame.indexOf("[tool] line 0")
1363
+
1364
+ expect(headerIndex).toBeGreaterThan(-1)
1365
+ expect(firstToolIndex).toBeGreaterThan(headerIndex)
1366
+
1367
+ renderer.destroy()
1368
+ })
1369
+
1370
+ // Regression test for issue #530: sticky scroll jumps to top after manual scroll
1371
+ test("resets _hasManualScroll when user scrolls back to sticky position (issue #530)", async () => {
1372
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1373
+ width: 40,
1374
+ height: 10,
1375
+ stickyScroll: true,
1376
+ stickyStart: "bottom",
1377
+ })
1378
+
1379
+ testRenderer.root.add(scrollBox)
1380
+
1381
+ // Add enough content to overflow the viewport
1382
+ for (let i = 0; i < 20; i++) {
1383
+ scrollBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
1384
+ }
1385
+ await renderOnce()
1386
+
1387
+ const maxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1388
+ expect(scrollBox.scrollTop).toBe(maxScroll)
1389
+ expect((scrollBox as any)._hasManualScroll).toBe(false)
1390
+
1391
+ // User scrolls up manually - this sets _hasManualScroll = true
1392
+ scrollBox.scrollTo(5)
1393
+ await renderOnce()
1394
+
1395
+ expect(scrollBox.scrollTop).toBe(5)
1396
+ expect((scrollBox as any)._hasManualScroll).toBe(true)
1397
+
1398
+ // User scrolls back to bottom - this should reset _hasManualScroll = false
1399
+ const newMaxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1400
+ scrollBox.scrollTo(newMaxScroll)
1401
+ await renderOnce()
1402
+
1403
+ expect(scrollBox.scrollTop).toBe(newMaxScroll)
1404
+ // This is the fix: _hasManualScroll should be reset when back at sticky position
1405
+ expect((scrollBox as any)._hasManualScroll).toBe(false)
1406
+
1407
+ // Add more content - should stay at bottom because sticky scroll is re-enabled
1408
+ for (let i = 20; i < 30; i++) {
1409
+ scrollBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
1410
+ await renderOnce()
1411
+
1412
+ const expectedMaxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1413
+ // Without the fix, this would fail: scroll would jump to top
1414
+ expect(scrollBox.scrollTop).toBe(expectedMaxScroll)
1415
+ }
1416
+ })
1417
+
1418
+ // Regression test for issue #709: content size recalculation should not clear manual scroll state
1419
+ test("does not reset _hasManualScroll during content size recalculation (issue #709)", async () => {
1420
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1421
+ width: 40,
1422
+ height: 10,
1423
+ stickyScroll: true,
1424
+ stickyStart: "bottom",
1425
+ })
1426
+
1427
+ testRenderer.root.add(scrollBox)
1428
+
1429
+ for (let i = 0; i < 30; i++) {
1430
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1431
+ }
1432
+ await renderOnce()
1433
+
1434
+ const initialMaxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1435
+ expect(scrollBox.scrollTop).toBe(initialMaxScroll)
1436
+ expect((scrollBox as any)._hasManualScroll).toBe(false)
1437
+
1438
+ scrollBox.scrollTo(5)
1439
+ await renderOnce()
1440
+
1441
+ expect(scrollBox.scrollTop).toBe(5)
1442
+ expect((scrollBox as any)._hasManualScroll).toBe(true)
1443
+
1444
+ // Force a size recalculation that programmatically clamps scrollTop to 0.
1445
+ // This must not be treated as a user returning to sticky position.
1446
+ for (let i = 0; i < 28; i++) {
1447
+ const line = scrollBox.getRenderable(`line-${i}`)
1448
+ if (line) scrollBox.remove(line)
1449
+ }
1450
+ await renderOnce()
1451
+
1452
+ expect(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)).toBe(0)
1453
+ expect(scrollBox.scrollTop).toBe(0)
1454
+ expect((scrollBox as any)._hasManualScroll).toBe(true)
1455
+
1456
+ // When content grows again, we should keep manual-scroll mode and stay away from sticky bottom.
1457
+ for (let i = 30; i < 50; i++) {
1458
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1459
+ }
1460
+ await renderOnce()
1461
+
1462
+ const newMaxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1463
+ expect(newMaxScroll).toBeGreaterThan(0)
1464
+ expect((scrollBox as any)._hasManualScroll).toBe(true)
1465
+ expect(scrollBox.scrollTop).toBe(0)
1466
+ })
1467
+
1468
+ // Regression test for issue #1087: recalculateBarProps else branch must not force scroll
1469
+ // when _hasManualScroll and _stickyScrollBottom are both true (race during streaming)
1470
+ test("recalculateBarProps does not force scroll when _hasManualScroll and _stickyScrollBottom are both true (issue #1087)", async () => {
1471
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1472
+ width: 40,
1473
+ height: 10,
1474
+ stickyScroll: true,
1475
+ stickyStart: "bottom",
1476
+ })
1477
+
1478
+ testRenderer.root.add(scrollBox)
1479
+
1480
+ for (let i = 0; i < 30; i++) {
1481
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1482
+ }
1483
+ await renderOnce()
1484
+
1485
+ // User scrolls up manually
1486
+ scrollBox.scrollTo(5)
1487
+ await renderOnce()
1488
+
1489
+ expect(scrollBox.scrollTop).toBe(5)
1490
+ expect((scrollBox as any)._hasManualScroll).toBe(true)
1491
+
1492
+ // Simulate the race condition: _stickyScrollBottom is still true from before
1493
+ // updateStickyState had a chance to clear it (state during streaming)
1494
+ ;(scrollBox as any)._stickyScrollBottom = true
1495
+
1496
+ // Add more content to trigger recalculateBarProps via onSizeChange
1497
+ for (let i = 30; i < 50; i++) {
1498
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1499
+ }
1500
+ await renderOnce()
1501
+
1502
+ // With the fix: scrollTop stays at 5 because _hasManualScroll guards the else branch
1503
+ // Without the fix: scrollTop would be forced to newMaxScrollTop (bottom)
1504
+ expect(scrollBox.scrollTop).toBe(5)
1505
+ expect((scrollBox as any)._hasManualScroll).toBe(true)
1506
+ })
1507
+
1508
+ test("recalculateBarProps does not re-engage top sticky when manually scrolled to bottom during content growth", async () => {
1509
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1510
+ width: 40,
1511
+ height: 10,
1512
+ stickyScroll: true,
1513
+ stickyStart: "top",
1514
+ })
1515
+
1516
+ testRenderer.root.add(scrollBox)
1517
+
1518
+ for (let i = 0; i < 30; i++) {
1519
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1520
+ }
1521
+ await renderOnce()
1522
+
1523
+ const maxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1524
+ scrollBox.scrollTo(maxScroll)
1525
+ await renderOnce()
1526
+
1527
+ expect(scrollBox.scrollTop).toBe(maxScroll)
1528
+ expect((scrollBox as any)._hasManualScroll).toBe(true)
1529
+
1530
+ scrollBox.add(new TextRenderable(testRenderer, { id: "line-30", content: "Line 30" }))
1531
+ await renderOnce()
1532
+
1533
+ expect(scrollBox.scrollTop).toBe(maxScroll)
1534
+ expect((scrollBox as any)._hasManualScroll).toBe(true)
1535
+ })
1536
+
1537
+ // Regression test for issue #1087: re-engagement path resets _hasManualScroll when
1538
+ // user scrolls back to bottom during streaming, allowing sticky to resume.
1539
+ // NOTE: This test passes on pre-1088 code by accident - the else branch forces
1540
+ // scroll to bottom which satisfies the final assertion. The fix adds the proper
1541
+ // re-engagement path via _hasManualScroll reset instead of forced scrolling.
1542
+ test("recalculateBarProps re-engages sticky when user scrolls to bottom during content growth (issue #1087)", async () => {
1543
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1544
+ width: 40,
1545
+ height: 10,
1546
+ stickyScroll: true,
1547
+ stickyStart: "bottom",
1548
+ })
1549
+
1550
+ testRenderer.root.add(scrollBox)
1551
+
1552
+ for (let i = 0; i < 30; i++) {
1553
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1554
+ }
1555
+ await renderOnce()
1556
+
1557
+ // User scrolls up manually
1558
+ scrollBox.scrollTo(5)
1559
+ await renderOnce()
1560
+
1561
+ expect((scrollBox as any)._hasManualScroll).toBe(true)
1562
+
1563
+ // Simulate the race condition: both flags true, user at bottom
1564
+ ;(scrollBox as any)._stickyScrollBottom = true
1565
+
1566
+ // Set scroll position to bottom directly (bypassing setter to keep _hasManualScroll true)
1567
+ // When one line is added, newMaxScrollTop = oldMaxScrollTop + 1,
1568
+ // so scrollTop = oldMaxScrollTop satisfies scrollTop >= newMaxScrollTop - 1
1569
+ const maxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1570
+ scrollBox.verticalScrollBar.scrollPosition = maxScroll
1571
+ ;(scrollBox as any)._hasManualScroll = true
1572
+
1573
+ // Add one line — triggers recalculateBarProps which sees scrollTop >= newMaxScrollTop - 1
1574
+ scrollBox.add(new TextRenderable(testRenderer, { id: "line-50", content: "Line 50" }))
1575
+ await renderOnce()
1576
+
1577
+ // With the fix: _hasManualScroll resets to false, sticky re-engages
1578
+ expect((scrollBox as any)._hasManualScroll).toBe(false)
1579
+
1580
+ // More content should now follow to bottom because sticky is re-engaged
1581
+ for (let i = 51; i < 60; i++) {
1582
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1583
+ }
1584
+ await renderOnce()
1585
+
1586
+ const newMaxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1587
+ expect(scrollBox.scrollTop).toBe(newMaxScroll)
1588
+ })
1589
+
1590
+ for (const stickyStart of ["top", "left", "right"] as const) {
1591
+ test(`recalculateBarProps re-engages ${stickyStart} sticky when already back at the sticky edge during content growth`, async () => {
1592
+ const isVertical = stickyStart === "top"
1593
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1594
+ width: isVertical ? 40 : 20,
1595
+ height: isVertical ? 10 : 5,
1596
+ scrollX: !isVertical,
1597
+ scrollY: isVertical,
1598
+ stickyScroll: true,
1599
+ stickyStart,
1600
+ ...(isVertical
1601
+ ? {}
1602
+ : {
1603
+ contentOptions: {
1604
+ flexDirection: "row",
1605
+ },
1606
+ }),
1607
+ })
1608
+
1609
+ testRenderer.root.add(scrollBox)
1610
+
1611
+ if (isVertical) {
1612
+ for (let i = 0; i < 30; i++) {
1613
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1614
+ }
1615
+ } else {
1616
+ for (let i = 0; i < 10; i++) {
1617
+ scrollBox.add(new BoxRenderable(testRenderer, { id: `box-${i}`, width: 10, height: 2 }))
1618
+ }
1619
+ }
1620
+ await renderOnce()
1621
+
1622
+ const initialMaxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1623
+ const initialMaxScrollLeft = Math.max(0, scrollBox.scrollWidth - scrollBox.viewport.width)
1624
+
1625
+ if (stickyStart === "top") {
1626
+ scrollBox.scrollTo(initialMaxScrollTop)
1627
+ await renderOnce()
1628
+ scrollBox.verticalScrollBar.scrollPosition = 0
1629
+ } else if (stickyStart === "left") {
1630
+ scrollBox.scrollTo({ x: initialMaxScrollLeft, y: 0 })
1631
+ await renderOnce()
1632
+ scrollBox.horizontalScrollBar.scrollPosition = 0
1633
+ } else {
1634
+ scrollBox.scrollTo({ x: 5, y: 0 })
1635
+ await renderOnce()
1636
+ scrollBox.horizontalScrollBar.scrollPosition = initialMaxScrollLeft
1637
+ }
1638
+
1639
+ ;(scrollBox as any)._hasManualScroll = true
1640
+
1641
+ if (isVertical) {
1642
+ scrollBox.add(new TextRenderable(testRenderer, { id: "line-new", content: "Line new" }))
1643
+ } else {
1644
+ scrollBox.add(new BoxRenderable(testRenderer, { id: "box-new", width: 1, height: 2 }))
1645
+ }
1646
+ await renderOnce()
1647
+
1648
+ const newMaxScrollLeft = Math.max(0, scrollBox.scrollWidth - scrollBox.viewport.width)
1649
+
1650
+ expect((scrollBox as any)._hasManualScroll).toBe(false)
1651
+ if (stickyStart === "right") {
1652
+ expect(scrollBox.scrollLeft).toBe(newMaxScrollLeft)
1653
+ } else if (stickyStart === "left") {
1654
+ expect(scrollBox.scrollLeft).toBe(0)
1655
+ } else {
1656
+ expect(scrollBox.scrollTop).toBe(0)
1657
+ }
1658
+ })
1659
+ }
1660
+
1661
+ test("scrollChildIntoView does nothing when child is already visible", async () => {
1662
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1663
+ width: 40,
1664
+ height: 10,
1665
+ })
1666
+
1667
+ for (let i = 0; i < 10; i++) {
1668
+ scrollBox.add(new BoxRenderable(testRenderer, { id: `child-${i}`, height: 1 }))
1669
+ }
1670
+ testRenderer.root.add(scrollBox)
1671
+ await renderOnce()
1672
+
1673
+ expect(scrollBox.scrollTop).toBe(0)
1674
+
1675
+ scrollBox.scrollChildIntoView("child-2")
1676
+ expect(scrollBox.scrollTop).toBe(0)
1677
+ })
1678
+
1679
+ test("scrollChildIntoView scrolls down to reveal child below viewport", async () => {
1680
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1681
+ width: 40,
1682
+ height: 10,
1683
+ })
1684
+
1685
+ for (let i = 0; i < 30; i++) {
1686
+ scrollBox.add(new BoxRenderable(testRenderer, { id: `child-${i}`, height: 1 }))
1687
+ }
1688
+ testRenderer.root.add(scrollBox)
1689
+ await renderOnce()
1690
+
1691
+ scrollBox.scrollChildIntoView("child-25")
1692
+ await renderOnce()
1693
+
1694
+ const child = scrollBox.content.findDescendantById("child-25")!
1695
+ expect(child.y).toBeGreaterThanOrEqual(scrollBox.viewport.y)
1696
+ expect(child.y + child.height).toBe(scrollBox.viewport.y + scrollBox.viewport.height)
1697
+ })
1698
+
1699
+ test("scrollChildIntoView scrolls up to reveal child above viewport", async () => {
1700
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1701
+ width: 40,
1702
+ height: 10,
1703
+ })
1704
+
1705
+ for (let i = 0; i < 30; i++) {
1706
+ scrollBox.add(new BoxRenderable(testRenderer, { id: `child-${i}`, height: 1 }))
1707
+ }
1708
+ testRenderer.root.add(scrollBox)
1709
+ await renderOnce()
1710
+
1711
+ scrollBox.scrollTo(20)
1712
+ await renderOnce()
1713
+
1714
+ scrollBox.scrollChildIntoView("child-15")
1715
+ await renderOnce()
1716
+
1717
+ const child = scrollBox.content.findDescendantById("child-15")!
1718
+ expect(child.y).toBe(scrollBox.viewport.y)
1719
+ expect(child.y + child.height).toBeLessThanOrEqual(scrollBox.viewport.y + scrollBox.viewport.height)
1720
+ })
1721
+
1722
+ test("scrollChildIntoView finds nested descendants by id", async () => {
1723
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1724
+ width: 40,
1725
+ height: 10,
1726
+ })
1727
+
1728
+ for (let i = 0; i < 30; i++) {
1729
+ const wrapper = new BoxRenderable(testRenderer, { id: `wrapper-${i}`, height: 1 })
1730
+ wrapper.add(new BoxRenderable(testRenderer, { id: `nested-${i}`, height: 1 }))
1731
+ scrollBox.add(wrapper)
1732
+ }
1733
+ testRenderer.root.add(scrollBox)
1734
+ await renderOnce()
1735
+
1736
+ scrollBox.scrollChildIntoView("nested-25")
1737
+ await renderOnce()
1738
+
1739
+ const child = scrollBox.content.findDescendantById("nested-25")!
1740
+ expect(child.y).toBeGreaterThanOrEqual(scrollBox.viewport.y)
1741
+ expect(child.y + child.height).toBeLessThanOrEqual(scrollBox.viewport.y + scrollBox.viewport.height)
1742
+ })
1743
+
1744
+ test("scrollChildIntoView does nothing for nonexistent child", async () => {
1745
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1746
+ width: 40,
1747
+ height: 10,
1748
+ })
1749
+
1750
+ for (let i = 0; i < 20; i++) {
1751
+ scrollBox.add(new BoxRenderable(testRenderer, { id: `child-${i}`, height: 1 }))
1752
+ }
1753
+ testRenderer.root.add(scrollBox)
1754
+ await renderOnce()
1755
+
1756
+ scrollBox.scrollTo(5)
1757
+ await renderOnce()
1758
+
1759
+ const scrollTopBefore = scrollBox.scrollTop
1760
+ scrollBox.scrollChildIntoView("does-not-exist")
1761
+ expect(scrollBox.scrollTop).toBe(scrollTopBefore)
1762
+ })
1763
+
1764
+ test("scrollChildIntoView handles horizontal scrolling", async () => {
1765
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1766
+ width: 20,
1767
+ height: 10,
1768
+ scrollX: true,
1769
+ contentOptions: {
1770
+ flexDirection: "row",
1771
+ },
1772
+ })
1773
+
1774
+ for (let i = 0; i < 6; i++) {
1775
+ scrollBox.add(new BoxRenderable(testRenderer, { id: `child-${i}`, width: 10, height: 2 }))
1776
+ }
1777
+
1778
+ testRenderer.root.add(scrollBox)
1779
+ await renderOnce()
1780
+
1781
+ scrollBox.scrollLeft = 30
1782
+ await renderOnce()
1783
+
1784
+ scrollBox.scrollChildIntoView("child-0")
1785
+ await renderOnce()
1786
+
1787
+ const child = scrollBox.content.findDescendantById("child-0")!
1788
+ expect(child.x).toBe(scrollBox.viewport.x)
1789
+ expect(child.x + child.width).toBeLessThanOrEqual(scrollBox.viewport.x + scrollBox.viewport.width)
1790
+ })
1791
+
1792
+ test("scrollChildIntoView follows nearest behavior for oversized children", async () => {
1793
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1794
+ width: 40,
1795
+ height: 10,
1796
+ })
1797
+
1798
+ scrollBox.add(new BoxRenderable(testRenderer, { id: "oversized", height: 30 }))
1799
+ testRenderer.root.add(scrollBox)
1800
+ await renderOnce()
1801
+
1802
+ scrollBox.scrollChildIntoView("oversized")
1803
+ expect(scrollBox.scrollTop).toBe(0)
1804
+
1805
+ scrollBox.scrollTo(10)
1806
+ await renderOnce()
1807
+
1808
+ scrollBox.scrollChildIntoView("oversized")
1809
+ expect(scrollBox.scrollTop).toBe(10)
1810
+ })
1811
+
1812
+ // Regression test for issue #530: edge case when content fits in viewport
1813
+ test("resets _hasManualScroll for stickyStart=bottom when content fits in viewport (issue #530)", async () => {
1814
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1815
+ width: 40,
1816
+ height: 20,
1817
+ stickyScroll: true,
1818
+ stickyStart: "bottom",
1819
+ })
1820
+
1821
+ testRenderer.root.add(scrollBox)
1822
+
1823
+ // Add content that fits in viewport (no actual scrolling needed)
1824
+ scrollBox.add(new TextRenderable(testRenderer, { content: "Line 0" }))
1825
+ scrollBox.add(new TextRenderable(testRenderer, { content: "Line 1" }))
1826
+ await renderOnce()
1827
+
1828
+ // maxScrollTop should be effectively zero since content fits; ScrollBox treats <= 1 as layout noise.
1829
+ const maxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1830
+ expect(maxScroll).toBeLessThanOrEqual(1)
1831
+
1832
+ // Simulate accidental scroll attempts (common with trackpads)
1833
+ scrollBox.scrollTo(0)
1834
+ await renderOnce()
1835
+
1836
+ // Even though we're at scrollTop=0, for stickyStart="bottom" with maxScrollTop=0,
1837
+ // we're effectively at both top AND bottom, so _hasManualScroll should be false
1838
+ expect((scrollBox as any)._hasManualScroll).toBe(false)
1839
+
1840
+ // Add more content that causes overflow - should stay at bottom
1841
+ for (let i = 2; i < 40; i++) {
1842
+ scrollBox.add(new TextRenderable(testRenderer, { content: `Line ${i}` }))
1843
+ await renderOnce()
1844
+
1845
+ const expectedMaxScroll = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1846
+ if (expectedMaxScroll > 0) {
1847
+ expect(scrollBox.scrollTop).toBe(expectedMaxScroll)
1848
+ }
1849
+ }
1850
+ })
1851
+
1852
+ test("updateStickyState resets _hasManualScroll when reaching bottom even during concurrent sticky operations", async () => {
1853
+ const scrollBox = new ScrollBoxRenderable(testRenderer, {
1854
+ width: 40,
1855
+ height: 10,
1856
+ stickyScroll: true,
1857
+ stickyStart: "bottom",
1858
+ })
1859
+
1860
+ testRenderer.root.add(scrollBox)
1861
+
1862
+ // Add enough content to make scrolling possible
1863
+ for (let i = 0; i < 30; i++) {
1864
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1865
+ }
1866
+ await renderOnce()
1867
+
1868
+ const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1869
+
1870
+ // Simulate: user scrolled to bottom while concurrent sticky operation was running
1871
+ scrollBox.verticalScrollBar.scrollPosition = maxScrollTop
1872
+ ;(scrollBox as any)._hasManualScroll = true
1873
+ ;(scrollBox as any)._isApplyingStickyScroll = true
1874
+
1875
+ // Trigger updateStickyState (called when scrollTop changes)
1876
+ const updateStickyState = (scrollBox as any).updateStickyState.bind(scrollBox)
1877
+ updateStickyState()
1878
+
1879
+ // With fix: flag should reset even though _isApplyingStickyScroll was true
1880
+ expect((scrollBox as any)._hasManualScroll).toBe(false)
1881
+ expect((scrollBox as any)._stickyScrollBottom).toBe(true)
1882
+
1883
+ // Clear the flag for subsequent behavior check
1884
+ ;(scrollBox as any)._isApplyingStickyScroll = false
1885
+
1886
+ // Adding more content should now cause viewport to stay locked to bottom
1887
+ for (let i = 30; i < 40; i++) {
1888
+ scrollBox.add(new TextRenderable(testRenderer, { id: `line-${i}`, content: `Line ${i}` }))
1889
+ }
1890
+ await renderOnce()
1891
+
1892
+ const newMaxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height)
1893
+ expect(scrollBox.scrollTop).toBe(newMaxScrollTop)
1894
+ })
1895
+ })