@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,2547 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // Calibrated layout benchmark for the current JS Yoga-backed render tree.
4
+ // Scenarios deliberately mutate layout-affecting state before each measured
5
+ // operation, then validate that Yoga is dirty before the frame and clean after
6
+ // all required render passes settle.
7
+
8
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
9
+ import path from "node:path"
10
+
11
+ import { BoxRenderable, RGBA, ScrollBoxRenderable, TextRenderable } from "../index.js"
12
+ import type { Renderable, RenderCommand } from "../Renderable.js"
13
+ import { createTestRenderer, type TestRenderer } from "../testing.js"
14
+
15
+ const DEFAULT_ITERATIONS = 100
16
+ const DEFAULT_WARMUP = 50
17
+ const DEFAULT_ROUNDS = 7
18
+ const DEFAULT_MIN_SAMPLE_MS = 500
19
+ const DEFAULT_WIDTH = 140
20
+ const DEFAULT_HEIGHT = 44
21
+ const MAX_LAYOUT_SETTLE_PASSES = 4
22
+
23
+ type BenchmarkKind =
24
+ | "baseline"
25
+ | "layout-recalc"
26
+ | "text-measure"
27
+ | "tree-mutation"
28
+ | "scrollbox"
29
+ | "top-branch"
30
+ | "wide-shallow"
31
+ | "deep-chain"
32
+ | "root-resize"
33
+ type BenchmarkPhase = "calculate-layout" | "layout-update" | "full-render"
34
+ type RenderPassMode = "calculate-only" | "calculate-update" | "single-frame" | "settle-layout"
35
+
36
+ interface BenchmarkArgs {
37
+ iterations: number
38
+ warmupIterations: number
39
+ rounds: number
40
+ minSampleMs: number
41
+ width: number
42
+ height: number
43
+ scenarioNames?: Set<string>
44
+ jsonPath?: string
45
+ listScenarios: boolean
46
+ output: boolean
47
+ skipValidation: boolean
48
+ }
49
+
50
+ interface BenchmarkContext {
51
+ renderer: TestRenderer
52
+ renderOnce: () => Promise<void>
53
+ width: number
54
+ height: number
55
+ }
56
+
57
+ interface TreeStats {
58
+ renderables: number
59
+ layoutNodes: number
60
+ layoutOnlyBoxes: number
61
+ }
62
+
63
+ interface ScenarioRuntime {
64
+ kind: BenchmarkKind
65
+ phase: BenchmarkPhase
66
+ passMode: RenderPassMode
67
+ renderablesPerIteration: number
68
+ layoutNodesPerIteration: number
69
+ layoutOnlyBoxesPerIteration: number
70
+ layoutMutationsPerIteration: number
71
+ runIteration: (iteration: number) => unknown | Promise<unknown>
72
+ validate?: () => void | Promise<void>
73
+ resetMetrics?: () => void
74
+ readMetrics?: () => ScenarioMetricsSnapshot
75
+ cleanup: () => void | Promise<void>
76
+ }
77
+
78
+ interface ScenarioMetricsSnapshot {
79
+ measuredIterations: number
80
+ totalSettlePasses: number
81
+ maxSettlePasses: number
82
+ totalRenderCommands: number
83
+ maxRenderCommands: number
84
+ }
85
+
86
+ interface ScenarioMetricsTracker {
87
+ recordSettlePasses: (passes: number) => void
88
+ recordRenderCommands: (commands: number) => void
89
+ reset: () => void
90
+ snapshot: () => ScenarioMetricsSnapshot
91
+ }
92
+
93
+ interface BenchmarkScenario {
94
+ name: string
95
+ description: string
96
+ setup: (ctx: BenchmarkContext) => Promise<ScenarioRuntime>
97
+ }
98
+
99
+ interface BenchmarkSample {
100
+ round: number
101
+ iterations: number
102
+ durationMs: number
103
+ opsPerSecond: number
104
+ nsPerOperation: number
105
+ }
106
+
107
+ interface BenchmarkResult {
108
+ name: string
109
+ description: string
110
+ kind: BenchmarkKind
111
+ phase: BenchmarkPhase
112
+ passMode: RenderPassMode
113
+ iterations: number
114
+ warmupIterations: number
115
+ rounds: number
116
+ minSampleMs: number
117
+ batchIterations: number
118
+ totalMeasuredIterations: number
119
+ renderablesPerIteration: number
120
+ layoutNodesPerIteration: number
121
+ layoutOnlyBoxesPerIteration: number
122
+ layoutMutationsPerIteration: number
123
+ measuredMetricIterations: number
124
+ avgSettlePassesPerIteration: number
125
+ maxSettlePasses: number
126
+ avgUpdateCommandsPerIteration: number | null
127
+ maxUpdateCommands: number | null
128
+ medianDurationMs: number
129
+ bestDurationMs: number
130
+ medianOpsPerSecond: number
131
+ meanOpsPerSecond: number
132
+ medianNsPerOperation: number
133
+ p95NsPerOperation: number
134
+ stdDevNsPerOperation: number
135
+ rmePercent: number
136
+ samples: BenchmarkSample[]
137
+ }
138
+
139
+ interface BenchmarkSinkState {
140
+ value: unknown
141
+ checksum: number
142
+ }
143
+
144
+ interface OpencodeLayoutTreeState {
145
+ root: BoxRenderable
146
+ header: BoxRenderable
147
+ body: BoxRenderable
148
+ sidebar: BoxRenderable
149
+ main: BoxRenderable
150
+ footer: BoxRenderable
151
+ rows: BoxRenderable[]
152
+ badges: BoxRenderable[]
153
+ stats: TreeStats
154
+ }
155
+
156
+ interface WideShallowTreeState {
157
+ root: BoxRenderable
158
+ container: BoxRenderable
159
+ children: BoxRenderable[]
160
+ stats: TreeStats
161
+ }
162
+
163
+ interface DeepChainTreeState {
164
+ root: BoxRenderable
165
+ nodes: BoxRenderable[]
166
+ leaf: BoxRenderable
167
+ stats: TreeStats
168
+ }
169
+
170
+ interface TextReflowTreeState {
171
+ root: BoxRenderable
172
+ rows: BoxRenderable[]
173
+ texts: TextRenderable[]
174
+ stats: TreeStats
175
+ }
176
+
177
+ interface TreeMutationState {
178
+ root: BoxRenderable
179
+ list: BoxRenderable
180
+ rows: BoxRenderable[]
181
+ stats: TreeStats
182
+ nextRowId: number
183
+ }
184
+
185
+ interface ScrollboxReflowState {
186
+ root: BoxRenderable
187
+ scrollBox: ScrollBoxRenderable
188
+ items: BoxRenderable[]
189
+ stats: TreeStats
190
+ }
191
+
192
+ interface ValidationOptions {
193
+ expectProbeChange?: boolean
194
+ minimumSettlePasses?: number
195
+ }
196
+
197
+ const blackhole: BenchmarkSinkState = {
198
+ value: undefined,
199
+ checksum: 0,
200
+ }
201
+
202
+ const COLORS = {
203
+ transparent: RGBA.fromInts(0, 0, 0, 0),
204
+ panel: RGBA.fromInts(28, 32, 38),
205
+ element: RGBA.fromInts(40, 46, 56),
206
+ menu: RGBA.fromInts(35, 40, 48),
207
+ accent: RGBA.fromInts(84, 171, 224),
208
+ warning: RGBA.fromInts(219, 186, 96),
209
+ } as const
210
+
211
+ function parseNumberArg(value: string | undefined, fallback: number): number {
212
+ if (!value) {
213
+ return fallback
214
+ }
215
+
216
+ const parsed = Number.parseInt(value, 10)
217
+ if (!Number.isFinite(parsed) || parsed <= 0) {
218
+ throw new Error(`Invalid numeric benchmark argument: ${value}`)
219
+ }
220
+
221
+ return parsed
222
+ }
223
+
224
+ function parseArgs(argv: string[]): BenchmarkArgs {
225
+ let iterations = DEFAULT_ITERATIONS
226
+ let warmupIterations = DEFAULT_WARMUP
227
+ let rounds = DEFAULT_ROUNDS
228
+ let minSampleMs = DEFAULT_MIN_SAMPLE_MS
229
+ let width = DEFAULT_WIDTH
230
+ let height = DEFAULT_HEIGHT
231
+ let scenarioNames: Set<string> | undefined
232
+ let jsonPath: string | undefined
233
+ let listScenarios = false
234
+ let output = true
235
+ let skipValidation = false
236
+
237
+ for (const arg of argv) {
238
+ if (arg === "--list-scenarios") {
239
+ listScenarios = true
240
+ continue
241
+ }
242
+
243
+ if (arg === "--no-output") {
244
+ output = false
245
+ continue
246
+ }
247
+
248
+ if (arg === "--skip-validation") {
249
+ skipValidation = true
250
+ continue
251
+ }
252
+
253
+ if (arg === "--json") {
254
+ jsonPath = "latest-layout-bench-run.json"
255
+ continue
256
+ }
257
+
258
+ if (arg.startsWith("--iterations=")) {
259
+ iterations = parseNumberArg(arg.slice("--iterations=".length), DEFAULT_ITERATIONS)
260
+ continue
261
+ }
262
+
263
+ if (arg.startsWith("--warmup=")) {
264
+ warmupIterations = parseNumberArg(arg.slice("--warmup=".length), DEFAULT_WARMUP)
265
+ continue
266
+ }
267
+
268
+ if (arg.startsWith("--warmup-iterations=")) {
269
+ warmupIterations = parseNumberArg(arg.slice("--warmup-iterations=".length), DEFAULT_WARMUP)
270
+ continue
271
+ }
272
+
273
+ if (arg.startsWith("--rounds=")) {
274
+ rounds = parseNumberArg(arg.slice("--rounds=".length), DEFAULT_ROUNDS)
275
+ continue
276
+ }
277
+
278
+ if (arg.startsWith("--min-sample-ms=")) {
279
+ minSampleMs = parseNumberArg(arg.slice("--min-sample-ms=".length), DEFAULT_MIN_SAMPLE_MS)
280
+ continue
281
+ }
282
+
283
+ if (arg.startsWith("--width=")) {
284
+ width = Math.max(40, parseNumberArg(arg.slice("--width=".length), DEFAULT_WIDTH))
285
+ continue
286
+ }
287
+
288
+ if (arg.startsWith("--height=")) {
289
+ height = Math.max(20, parseNumberArg(arg.slice("--height=".length), DEFAULT_HEIGHT))
290
+ continue
291
+ }
292
+
293
+ if (arg.startsWith("--scenario=")) {
294
+ const names = arg
295
+ .slice("--scenario=".length)
296
+ .split(",")
297
+ .map((name) => name.trim())
298
+ .filter(Boolean)
299
+ scenarioNames = new Set(names)
300
+ continue
301
+ }
302
+
303
+ if (arg.startsWith("--json=")) {
304
+ const value = arg.slice("--json=".length)
305
+ jsonPath = value || "latest-layout-bench-run.json"
306
+ }
307
+ }
308
+
309
+ return {
310
+ iterations,
311
+ warmupIterations,
312
+ rounds,
313
+ minSampleMs,
314
+ width,
315
+ height,
316
+ scenarioNames,
317
+ jsonPath,
318
+ listScenarios,
319
+ output,
320
+ skipValidation,
321
+ }
322
+ }
323
+
324
+ function nowNs(): bigint {
325
+ return process.hrtime.bigint()
326
+ }
327
+
328
+ function nsToMs(ns: bigint): number {
329
+ return Number(ns) / 1_000_000
330
+ }
331
+
332
+ function median(values: number[]): number {
333
+ const sorted = [...values].sort((left, right) => left - right)
334
+ const middle = Math.floor(sorted.length / 2)
335
+ const value = sorted[middle]
336
+ if (value === undefined) {
337
+ return 0
338
+ }
339
+
340
+ if (sorted.length % 2 === 1) {
341
+ return value
342
+ }
343
+
344
+ const previous = sorted[middle - 1]
345
+ if (previous === undefined) {
346
+ return value
347
+ }
348
+
349
+ return (previous + value) / 2
350
+ }
351
+
352
+ function mean(values: readonly number[]): number {
353
+ if (values.length === 0) {
354
+ return 0
355
+ }
356
+
357
+ let total = 0
358
+ for (const value of values) {
359
+ total += value
360
+ }
361
+ return total / values.length
362
+ }
363
+
364
+ function percentile(values: readonly number[], p: number): number {
365
+ if (values.length === 0) {
366
+ return 0
367
+ }
368
+
369
+ const sorted = [...values].sort((left, right) => left - right)
370
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
371
+ return sorted[index] ?? 0
372
+ }
373
+
374
+ function sampleStdDev(values: readonly number[]): number {
375
+ if (values.length <= 1) {
376
+ return 0
377
+ }
378
+
379
+ const average = mean(values)
380
+ let total = 0
381
+ for (const value of values) {
382
+ const delta = value - average
383
+ total += delta * delta
384
+ }
385
+
386
+ return Math.sqrt(total / (values.length - 1))
387
+ }
388
+
389
+ function tCritical95(degreesOfFreedom: number): number {
390
+ const table = [12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228]
391
+ if (degreesOfFreedom <= 0) {
392
+ return 0
393
+ }
394
+
395
+ return table[degreesOfFreedom - 1] ?? 1.96
396
+ }
397
+
398
+ function relativeMarginOfError(values: readonly number[]): number {
399
+ if (values.length <= 1) {
400
+ return 0
401
+ }
402
+
403
+ const average = mean(values)
404
+ if (average === 0) {
405
+ return 0
406
+ }
407
+
408
+ const sem = sampleStdDev(values) / Math.sqrt(values.length)
409
+ return Math.abs((sem * tCritical95(values.length - 1) * 100) / average)
410
+ }
411
+
412
+ function consume(value: unknown): void {
413
+ blackhole.value = value
414
+
415
+ let contribution = 1
416
+ if (typeof value === "number") {
417
+ contribution = value | 0
418
+ } else if (typeof value === "string" || Array.isArray(value)) {
419
+ contribution = value.length
420
+ } else if (value instanceof Map || value instanceof Set) {
421
+ contribution = value.size
422
+ } else if (typeof value === "boolean") {
423
+ contribution = value ? 1 : 0
424
+ } else if (value === null || value === undefined) {
425
+ contribution = 0
426
+ }
427
+
428
+ blackhole.checksum = (blackhole.checksum + contribution) >>> 0
429
+ }
430
+
431
+ function roundIterations(value: number): number {
432
+ if (value <= 100) {
433
+ return Math.max(1, Math.ceil(value))
434
+ }
435
+
436
+ if (value <= 1_000) {
437
+ return Math.ceil(value / 10) * 10
438
+ }
439
+
440
+ if (value <= 10_000) {
441
+ return Math.ceil(value / 100) * 100
442
+ }
443
+
444
+ return Math.ceil(value / 1_000) * 1_000
445
+ }
446
+
447
+ function createMetricsTracker(): ScenarioMetricsTracker {
448
+ const metrics: ScenarioMetricsSnapshot = {
449
+ measuredIterations: 0,
450
+ totalSettlePasses: 0,
451
+ maxSettlePasses: 0,
452
+ totalRenderCommands: 0,
453
+ maxRenderCommands: 0,
454
+ }
455
+
456
+ return {
457
+ recordSettlePasses(passes) {
458
+ metrics.measuredIterations += 1
459
+ metrics.totalSettlePasses += passes
460
+ metrics.maxSettlePasses = Math.max(metrics.maxSettlePasses, passes)
461
+ },
462
+ recordRenderCommands(commands) {
463
+ metrics.totalRenderCommands += commands
464
+ metrics.maxRenderCommands = Math.max(metrics.maxRenderCommands, commands)
465
+ },
466
+ reset() {
467
+ metrics.measuredIterations = 0
468
+ metrics.totalSettlePasses = 0
469
+ metrics.maxSettlePasses = 0
470
+ metrics.totalRenderCommands = 0
471
+ metrics.maxRenderCommands = 0
472
+ },
473
+ snapshot() {
474
+ return { ...metrics }
475
+ },
476
+ }
477
+ }
478
+
479
+ function withMetrics(
480
+ runtime: Omit<ScenarioRuntime, "resetMetrics" | "readMetrics">,
481
+ metrics: ScenarioMetricsTracker,
482
+ ): ScenarioRuntime {
483
+ return {
484
+ ...runtime,
485
+ resetMetrics: metrics.reset,
486
+ readMetrics: metrics.snapshot,
487
+ }
488
+ }
489
+
490
+ function emptyMetrics(): ScenarioMetricsSnapshot {
491
+ return {
492
+ measuredIterations: 0,
493
+ totalSettlePasses: 0,
494
+ maxSettlePasses: 0,
495
+ totalRenderCommands: 0,
496
+ maxRenderCommands: 0,
497
+ }
498
+ }
499
+
500
+ function isRootLayoutDirty(ctx: BenchmarkContext): boolean {
501
+ return ctx.renderer.root.getLayoutNode().isDirty()
502
+ }
503
+
504
+ function assertLayoutDirty(ctx: BenchmarkContext, scenarioName: string): void {
505
+ if (!isRootLayoutDirty(ctx)) {
506
+ throw new Error(`${scenarioName}: expected root Yoga node to be dirty after the layout mutation`)
507
+ }
508
+ }
509
+
510
+ function assertLayoutClean(ctx: BenchmarkContext, scenarioName: string): void {
511
+ if (isRootLayoutDirty(ctx)) {
512
+ throw new Error(`${scenarioName}: expected root Yoga node to be clean after layout settling`)
513
+ }
514
+ }
515
+
516
+ async function renderUntilLayoutClean(ctx: BenchmarkContext, scenarioName: string): Promise<number> {
517
+ let passes = 0
518
+
519
+ do {
520
+ passes += 1
521
+ await ctx.renderOnce()
522
+ } while (isRootLayoutDirty(ctx) && passes < MAX_LAYOUT_SETTLE_PASSES)
523
+
524
+ if (isRootLayoutDirty(ctx)) {
525
+ throw new Error(`${scenarioName}: Yoga layout stayed dirty after ${passes} render passes`)
526
+ }
527
+
528
+ return passes
529
+ }
530
+
531
+ async function renderMeasuredUntilLayoutClean(
532
+ ctx: BenchmarkContext,
533
+ scenarioName: string,
534
+ metrics: ScenarioMetricsTracker,
535
+ ): Promise<number> {
536
+ const passes = await renderUntilLayoutClean(ctx, scenarioName)
537
+ metrics.recordSettlePasses(passes)
538
+ return passes
539
+ }
540
+
541
+ function calculateLayout(ctx: BenchmarkContext, scenarioName: string): number {
542
+ ctx.renderer.root.calculateLayout()
543
+ assertLayoutClean(ctx, scenarioName)
544
+ return 1
545
+ }
546
+
547
+ function advanceLayoutFrame(ctx: BenchmarkContext): void {
548
+ ;(ctx.renderer as unknown as { _frameId: number })._frameId += 1
549
+ }
550
+
551
+ function calculateAndUpdateLayout(
552
+ ctx: BenchmarkContext,
553
+ scenarioName: string,
554
+ metrics: ScenarioMetricsTracker,
555
+ ): number {
556
+ calculateLayout(ctx, scenarioName)
557
+ advanceLayoutFrame(ctx)
558
+
559
+ const renderList: RenderCommand[] = []
560
+ ctx.renderer.root.updateLayout(0, renderList)
561
+ assertLayoutClean(ctx, scenarioName)
562
+
563
+ metrics.recordSettlePasses(1)
564
+ metrics.recordRenderCommands(renderList.length)
565
+ return renderList.length
566
+ }
567
+
568
+ function assertProbeChanged(scenarioName: string, label: string, before: number, after: number): void {
569
+ if (before === after) {
570
+ throw new Error(`${scenarioName}: ${label} did not change the validation probe`)
571
+ }
572
+ }
573
+
574
+ async function validateFullRenderRecalculation(
575
+ ctx: BenchmarkContext,
576
+ scenarioName: string,
577
+ mutate: (iteration: number) => unknown,
578
+ readProbe: () => number,
579
+ options: ValidationOptions = {},
580
+ ): Promise<void> {
581
+ await renderUntilLayoutClean(ctx, scenarioName)
582
+ assertLayoutClean(ctx, scenarioName)
583
+
584
+ const before = readProbe()
585
+ consume(mutate(0))
586
+ assertLayoutDirty(ctx, scenarioName)
587
+ const firstPasses = await renderUntilLayoutClean(ctx, scenarioName)
588
+ const afterFirst = readProbe()
589
+
590
+ consume(mutate(1))
591
+ assertLayoutDirty(ctx, scenarioName)
592
+ const secondPasses = await renderUntilLayoutClean(ctx, scenarioName)
593
+ const afterSecond = readProbe()
594
+
595
+ const minimumSettlePasses = options.minimumSettlePasses ?? 1
596
+ if (firstPasses < minimumSettlePasses || secondPasses < minimumSettlePasses) {
597
+ throw new Error(`${scenarioName}: expected at least ${minimumSettlePasses} render pass per layout update`)
598
+ }
599
+
600
+ if (options.expectProbeChange !== false) {
601
+ assertProbeChanged(scenarioName, "first layout mutation", before, afterFirst)
602
+ assertProbeChanged(scenarioName, "second layout mutation", afterFirst, afterSecond)
603
+ }
604
+
605
+ consume(before + afterFirst + afterSecond + firstPasses + secondPasses)
606
+ }
607
+
608
+ async function validateContentRecalculation(
609
+ ctx: BenchmarkContext,
610
+ scenarioName: string,
611
+ mutate: (iteration: number) => unknown,
612
+ readContentProbe: () => number,
613
+ ): Promise<void> {
614
+ await renderUntilLayoutClean(ctx, scenarioName)
615
+ assertLayoutClean(ctx, scenarioName)
616
+
617
+ const before = readContentProbe()
618
+ consume(mutate(0))
619
+ assertLayoutDirty(ctx, scenarioName)
620
+ const firstPasses = await renderUntilLayoutClean(ctx, scenarioName)
621
+ const afterFirst = readContentProbe()
622
+
623
+ consume(mutate(1))
624
+ assertLayoutDirty(ctx, scenarioName)
625
+ const secondPasses = await renderUntilLayoutClean(ctx, scenarioName)
626
+ const afterSecond = readContentProbe()
627
+
628
+ assertProbeChanged(scenarioName, "first content mutation", before, afterFirst)
629
+ assertProbeChanged(scenarioName, "second content mutation", afterFirst, afterSecond)
630
+
631
+ consume(before + afterFirst + afterSecond + firstPasses + secondPasses)
632
+ }
633
+
634
+ function validateCalculateRecalculation(
635
+ ctx: BenchmarkContext,
636
+ scenarioName: string,
637
+ mutate: (iteration: number) => unknown,
638
+ readProbe: () => number,
639
+ ): void {
640
+ calculateLayout(ctx, scenarioName)
641
+ assertLayoutClean(ctx, scenarioName)
642
+
643
+ const before = readProbe()
644
+ consume(mutate(0))
645
+ assertLayoutDirty(ctx, scenarioName)
646
+ calculateLayout(ctx, scenarioName)
647
+ const afterFirst = readProbe()
648
+
649
+ consume(mutate(1))
650
+ assertLayoutDirty(ctx, scenarioName)
651
+ calculateLayout(ctx, scenarioName)
652
+ const afterSecond = readProbe()
653
+
654
+ assertProbeChanged(scenarioName, "first calculate-layout mutation", before, afterFirst)
655
+ assertProbeChanged(scenarioName, "second calculate-layout mutation", afterFirst, afterSecond)
656
+
657
+ consume(before + afterFirst + afterSecond)
658
+ }
659
+
660
+ function validateCalculateUpdateRecalculation(
661
+ ctx: BenchmarkContext,
662
+ scenarioName: string,
663
+ mutate: (iteration: number) => unknown,
664
+ readProbe: () => number,
665
+ ): void {
666
+ const metrics = createMetricsTracker()
667
+ calculateAndUpdateLayout(ctx, scenarioName, metrics)
668
+ assertLayoutClean(ctx, scenarioName)
669
+
670
+ const before = readProbe()
671
+ consume(mutate(0))
672
+ assertLayoutDirty(ctx, scenarioName)
673
+ calculateAndUpdateLayout(ctx, scenarioName, metrics)
674
+ const afterFirst = readProbe()
675
+
676
+ consume(mutate(1))
677
+ assertLayoutDirty(ctx, scenarioName)
678
+ calculateAndUpdateLayout(ctx, scenarioName, metrics)
679
+ const afterSecond = readProbe()
680
+
681
+ assertProbeChanged(scenarioName, "first calculate-update mutation", before, afterFirst)
682
+ assertProbeChanged(scenarioName, "second calculate-update mutation", afterFirst, afterSecond)
683
+
684
+ consume(before + afterFirst + afterSecond + metrics.snapshot().totalRenderCommands)
685
+ }
686
+
687
+ async function validateStaticFullRender(
688
+ ctx: BenchmarkContext,
689
+ scenarioName: string,
690
+ readProbe: () => number,
691
+ ): Promise<void> {
692
+ await renderUntilLayoutClean(ctx, scenarioName)
693
+ assertLayoutClean(ctx, scenarioName)
694
+
695
+ const before = readProbe()
696
+ await ctx.renderOnce()
697
+ assertLayoutClean(ctx, scenarioName)
698
+ const after = readProbe()
699
+
700
+ if (before !== after) {
701
+ throw new Error(`${scenarioName}: static full-render probe changed without a layout mutation`)
702
+ }
703
+
704
+ consume(before + after)
705
+ }
706
+
707
+ function layoutChecksum(renderables: readonly Renderable[]): number {
708
+ let checksum = 0
709
+
710
+ for (let index = 0; index < renderables.length; index += 1) {
711
+ const layout = renderables[index]!.getLayoutNode().getComputedLayout()
712
+ checksum =
713
+ (checksum +
714
+ (layout.left | 0) * 3 +
715
+ (layout.top | 0) * 5 +
716
+ (layout.width | 0) * 7 +
717
+ (layout.height | 0) * 11 +
718
+ index) |
719
+ 0
720
+ }
721
+
722
+ return checksum >>> 0
723
+ }
724
+
725
+ function renderableLayoutChecksum(renderables: readonly Renderable[]): number {
726
+ let checksum = 0
727
+
728
+ for (let index = 0; index < renderables.length; index += 1) {
729
+ const renderable = renderables[index]!
730
+ checksum =
731
+ (checksum +
732
+ (renderable.x | 0) * 3 +
733
+ (renderable.y | 0) * 5 +
734
+ (renderable.width | 0) * 7 +
735
+ (renderable.height | 0) * 11 +
736
+ index) |
737
+ 0
738
+ }
739
+
740
+ return checksum >>> 0
741
+ }
742
+
743
+ function renderableIdentityLayoutChecksum(renderables: readonly Renderable[]): number {
744
+ let checksum = renderableLayoutChecksum(renderables)
745
+
746
+ for (let index = 0; index < renderables.length; index += 1) {
747
+ checksum = (checksum + renderables[index]!.num * (index + 1)) | 0
748
+ }
749
+
750
+ return checksum >>> 0
751
+ }
752
+
753
+ function createScenarios(): BenchmarkScenario[] {
754
+ return [
755
+ {
756
+ name: "static_opencode_full_render",
757
+ description: "No Yoga dirties: full render pass over an OpenCode-like layout tree for update/render baseline",
758
+ setup: async (ctx) => {
759
+ const state = await buildOpencodeLayoutTree(ctx, {
760
+ messageCount: Math.max(48, ctx.height + 12),
761
+ includeVisualBoxes: true,
762
+ includeText: true,
763
+ })
764
+ const probeTargets = [...state.rows.slice(0, 16), ...state.badges.slice(0, 16)]
765
+ await renderUntilLayoutClean(ctx, "static_opencode_full_render")
766
+ const metrics = createMetricsTracker()
767
+
768
+ return withMetrics(
769
+ {
770
+ kind: "baseline",
771
+ phase: "full-render",
772
+ passMode: "single-frame",
773
+ renderablesPerIteration: state.stats.renderables,
774
+ layoutNodesPerIteration: state.stats.layoutNodes,
775
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
776
+ layoutMutationsPerIteration: 0,
777
+ runIteration: async () => {
778
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "static_opencode_full_render", metrics)
779
+ consume(passes)
780
+ return renderableLayoutChecksum(probeTargets)
781
+ },
782
+ validate: () =>
783
+ validateStaticFullRender(ctx, "static_opencode_full_render", () =>
784
+ renderableLayoutChecksum(probeTargets),
785
+ ),
786
+ cleanup: () => {
787
+ state.root.destroyRecursively()
788
+ },
789
+ },
790
+ metrics,
791
+ )
792
+ },
793
+ },
794
+ {
795
+ name: "opencode_leaf_width_calculate_only",
796
+ description: "Dirty one deep leaf width and run only root.calculateLayout()",
797
+ setup: async (ctx) => {
798
+ const state = await buildOpencodeLayoutTree(ctx, {
799
+ messageCount: Math.max(64, ctx.height + 24),
800
+ includeVisualBoxes: false,
801
+ includeText: false,
802
+ })
803
+ calculateLayout(ctx, "opencode_leaf_width_calculate_only")
804
+ const metrics = createMetricsTracker()
805
+
806
+ const badgeWidths = new Array<number>(state.badges.length).fill(0)
807
+ const mutate = (iteration: number): number => {
808
+ const target = state.badges[iteration % state.badges.length]!
809
+ const targetIndex = iteration % state.badges.length
810
+ const nextWidth = badgeWidths[targetIndex] === 6 ? 11 : 6
811
+ badgeWidths[targetIndex] = nextWidth
812
+ target.width = nextWidth
813
+ return target.width
814
+ }
815
+ const readProbe = () => layoutChecksum(state.badges.slice(0, 24))
816
+
817
+ return withMetrics(
818
+ {
819
+ kind: "layout-recalc",
820
+ phase: "calculate-layout",
821
+ passMode: "calculate-only",
822
+ renderablesPerIteration: state.stats.renderables,
823
+ layoutNodesPerIteration: state.stats.layoutNodes,
824
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
825
+ layoutMutationsPerIteration: 1,
826
+ runIteration(iteration) {
827
+ consume(mutate(iteration))
828
+ const passes = calculateLayout(ctx, "opencode_leaf_width_calculate_only")
829
+ metrics.recordSettlePasses(passes)
830
+ return readProbe() + passes
831
+ },
832
+ validate: () =>
833
+ validateCalculateRecalculation(ctx, "opencode_leaf_width_calculate_only", mutate, readProbe),
834
+ cleanup: () => {
835
+ state.root.destroyRecursively()
836
+ },
837
+ },
838
+ metrics,
839
+ )
840
+ },
841
+ },
842
+ {
843
+ name: "opencode_leaf_width_full_render",
844
+ description: "Dirty one deep leaf width and run the full renderer until layout is clean",
845
+ setup: async (ctx) => {
846
+ const state = await buildOpencodeLayoutTree(ctx, {
847
+ messageCount: Math.max(64, ctx.height + 24),
848
+ includeVisualBoxes: true,
849
+ includeText: false,
850
+ })
851
+ await renderUntilLayoutClean(ctx, "opencode_leaf_width_full_render")
852
+ const metrics = createMetricsTracker()
853
+
854
+ const badgeWidths = new Array<number>(state.badges.length).fill(0)
855
+ const mutate = (iteration: number): number => {
856
+ const target = state.badges[iteration % state.badges.length]!
857
+ const targetIndex = iteration % state.badges.length
858
+ const nextWidth = badgeWidths[targetIndex] === 5 ? 12 : 5
859
+ badgeWidths[targetIndex] = nextWidth
860
+ target.width = nextWidth
861
+ return target.width
862
+ }
863
+ const readProbe = () => renderableLayoutChecksum(state.badges.slice(0, 24))
864
+
865
+ return withMetrics(
866
+ {
867
+ kind: "layout-recalc",
868
+ phase: "full-render",
869
+ passMode: "settle-layout",
870
+ renderablesPerIteration: state.stats.renderables,
871
+ layoutNodesPerIteration: state.stats.layoutNodes,
872
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
873
+ layoutMutationsPerIteration: 1,
874
+ async runIteration(iteration) {
875
+ consume(mutate(iteration))
876
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "opencode_leaf_width_full_render", metrics)
877
+ return readProbe() + passes
878
+ },
879
+ validate: () => validateFullRenderRecalculation(ctx, "opencode_leaf_width_full_render", mutate, readProbe),
880
+ cleanup: () => {
881
+ state.root.destroyRecursively()
882
+ },
883
+ },
884
+ metrics,
885
+ )
886
+ },
887
+ },
888
+ {
889
+ name: "opencode_many_rows_full_render",
890
+ description: "Dirty a stripe of row heights and badge widths before a full settled render",
891
+ setup: async (ctx) => {
892
+ const state = await buildOpencodeLayoutTree(ctx, {
893
+ messageCount: Math.max(72, ctx.height * 2),
894
+ includeVisualBoxes: true,
895
+ includeText: false,
896
+ })
897
+ await renderUntilLayoutClean(ctx, "opencode_many_rows_full_render")
898
+ const metrics = createMetricsTracker()
899
+
900
+ const mutationsPerIteration = Math.min(16, state.rows.length)
901
+ const rowHeights = new Array<number>(state.rows.length).fill(0)
902
+ const badgeWidths = new Array<number>(state.badges.length).fill(0)
903
+ const mutate = (iteration: number): number => {
904
+ let checksum = 0
905
+ for (let offset = 0; offset < mutationsPerIteration; offset += 1) {
906
+ const rowIndex = (iteration * 7 + offset * 11) % state.rows.length
907
+ const row = state.rows[rowIndex]!
908
+ const badgeIndex = (rowIndex * 3 + offset) % state.badges.length
909
+ const badge = state.badges[badgeIndex]!
910
+ const nextHeight = rowHeights[rowIndex] === 2 ? 4 : 2
911
+ const nextWidth = badgeWidths[badgeIndex] === 4 ? 10 : 4
912
+ rowHeights[rowIndex] = nextHeight
913
+ badgeWidths[badgeIndex] = nextWidth
914
+ row.height = nextHeight
915
+ badge.width = nextWidth
916
+ checksum += row.height + badge.width
917
+ }
918
+ return checksum
919
+ }
920
+ const readProbe = () => renderableLayoutChecksum([...state.rows.slice(0, 20), ...state.badges.slice(0, 20)])
921
+
922
+ return withMetrics(
923
+ {
924
+ kind: "layout-recalc",
925
+ phase: "full-render",
926
+ passMode: "settle-layout",
927
+ renderablesPerIteration: state.stats.renderables,
928
+ layoutNodesPerIteration: state.stats.layoutNodes,
929
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
930
+ layoutMutationsPerIteration: mutationsPerIteration * 2,
931
+ async runIteration(iteration) {
932
+ consume(mutate(iteration))
933
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "opencode_many_rows_full_render", metrics)
934
+ return readProbe() + passes
935
+ },
936
+ validate: () => validateFullRenderRecalculation(ctx, "opencode_many_rows_full_render", mutate, readProbe),
937
+ cleanup: () => {
938
+ state.root.destroyRecursively()
939
+ },
940
+ },
941
+ metrics,
942
+ )
943
+ },
944
+ },
945
+ {
946
+ name: "top_branch_container_full_render",
947
+ description: "Dirty high-level header/body/sidebar/main layout props before a full settled render",
948
+ setup: async (ctx) => {
949
+ const state = await buildOpencodeLayoutTree(ctx, {
950
+ messageCount: Math.max(56, ctx.height + 16),
951
+ includeVisualBoxes: true,
952
+ includeText: false,
953
+ })
954
+ await renderUntilLayoutClean(ctx, "top_branch_container_full_render")
955
+ const metrics = createMetricsTracker()
956
+
957
+ let wideSidebar = false
958
+ let tallHeader = false
959
+ let paddedBody = false
960
+ let looseMain = false
961
+ const mutate = (): number => {
962
+ wideSidebar = !wideSidebar
963
+ tallHeader = !tallHeader
964
+ paddedBody = !paddedBody
965
+ looseMain = !looseMain
966
+
967
+ const sidebarWidth = wideSidebar ? 30 : 22
968
+ state.sidebar.width = sidebarWidth
969
+ state.sidebar.minWidth = sidebarWidth
970
+ state.sidebar.maxWidth = sidebarWidth
971
+ state.header.height = tallHeader ? 4 : 3
972
+ state.body.paddingLeft = paddedBody ? 3 : 1
973
+ state.body.paddingRight = paddedBody ? 2 : 1
974
+ state.main.gap = looseMain ? 2 : 1
975
+
976
+ return sidebarWidth + state.header.height + (paddedBody ? 5 : 2) + (looseMain ? 2 : 1)
977
+ }
978
+ const readProbe = () =>
979
+ renderableLayoutChecksum([
980
+ state.root,
981
+ state.header,
982
+ state.body,
983
+ state.sidebar,
984
+ state.main,
985
+ state.footer,
986
+ ...state.rows.slice(0, 12),
987
+ ])
988
+
989
+ return withMetrics(
990
+ {
991
+ kind: "top-branch",
992
+ phase: "full-render",
993
+ passMode: "settle-layout",
994
+ renderablesPerIteration: state.stats.renderables,
995
+ layoutNodesPerIteration: state.stats.layoutNodes,
996
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
997
+ layoutMutationsPerIteration: 7,
998
+ async runIteration() {
999
+ consume(mutate())
1000
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "top_branch_container_full_render", metrics)
1001
+ return readProbe() + passes
1002
+ },
1003
+ validate: () => validateFullRenderRecalculation(ctx, "top_branch_container_full_render", mutate, readProbe),
1004
+ cleanup: () => {
1005
+ state.root.destroyRecursively()
1006
+ },
1007
+ },
1008
+ metrics,
1009
+ )
1010
+ },
1011
+ },
1012
+ {
1013
+ name: "root_layout_size_full_render",
1014
+ description: "Dirty the RootRenderable Yoga size so percentage-based descendants recalculate from the top",
1015
+ setup: async (ctx) => {
1016
+ const state = await buildOpencodeLayoutTree(ctx, {
1017
+ messageCount: Math.max(56, ctx.height + 16),
1018
+ includeVisualBoxes: true,
1019
+ includeText: false,
1020
+ })
1021
+ await renderUntilLayoutClean(ctx, "root_layout_size_full_render")
1022
+ const metrics = createMetricsTracker()
1023
+
1024
+ let compact = false
1025
+ const compactWidth = Math.max(80, ctx.width - 18)
1026
+ const compactHeight = Math.max(24, ctx.height - 4)
1027
+ const mutate = (): number => {
1028
+ compact = !compact
1029
+ const nextWidth = compact ? compactWidth : ctx.width
1030
+ const nextHeight = compact ? compactHeight : ctx.height
1031
+ ctx.renderer.root.resize(nextWidth, nextHeight)
1032
+ return nextWidth + nextHeight
1033
+ }
1034
+ const readProbe = () =>
1035
+ renderableLayoutChecksum([state.root, state.body, state.main, ...state.rows.slice(0, 16)])
1036
+
1037
+ return withMetrics(
1038
+ {
1039
+ kind: "root-resize",
1040
+ phase: "full-render",
1041
+ passMode: "settle-layout",
1042
+ renderablesPerIteration: state.stats.renderables,
1043
+ layoutNodesPerIteration: state.stats.layoutNodes,
1044
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1045
+ layoutMutationsPerIteration: 2,
1046
+ async runIteration() {
1047
+ consume(mutate())
1048
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "root_layout_size_full_render", metrics)
1049
+ return readProbe() + passes
1050
+ },
1051
+ validate: () => validateFullRenderRecalculation(ctx, "root_layout_size_full_render", mutate, readProbe),
1052
+ cleanup: () => {
1053
+ ctx.renderer.root.resize(ctx.width, ctx.height)
1054
+ state.root.destroyRecursively()
1055
+ },
1056
+ },
1057
+ metrics,
1058
+ )
1059
+ },
1060
+ },
1061
+ {
1062
+ name: "opencode_many_rows_layout_update_only",
1063
+ description: "Dirty many rows, calculate Yoga, then only run updateLayout/render-command collection",
1064
+ setup: async (ctx) => {
1065
+ const state = await buildOpencodeLayoutTree(ctx, {
1066
+ messageCount: Math.max(72, ctx.height * 2),
1067
+ includeVisualBoxes: false,
1068
+ includeText: false,
1069
+ })
1070
+ calculateAndUpdateLayout(ctx, "opencode_many_rows_layout_update_only", createMetricsTracker())
1071
+ const metrics = createMetricsTracker()
1072
+
1073
+ const mutationsPerIteration = Math.min(16, state.rows.length)
1074
+ const rowHeights = new Array<number>(state.rows.length).fill(0)
1075
+ const badgeWidths = new Array<number>(state.badges.length).fill(0)
1076
+ const mutate = (iteration: number): number => {
1077
+ let checksum = 0
1078
+ for (let offset = 0; offset < mutationsPerIteration; offset += 1) {
1079
+ const rowIndex = (iteration * 5 + offset * 7) % state.rows.length
1080
+ const badgeIndex = (iteration * 11 + offset * 13) % state.badges.length
1081
+ const row = state.rows[rowIndex]!
1082
+ const badge = state.badges[badgeIndex]!
1083
+ const nextHeight = rowHeights[rowIndex] === 2 ? 4 : 2
1084
+ const nextWidth = badgeWidths[badgeIndex] === 4 ? 10 : 4
1085
+ rowHeights[rowIndex] = nextHeight
1086
+ badgeWidths[badgeIndex] = nextWidth
1087
+ row.height = nextHeight
1088
+ badge.width = nextWidth
1089
+ checksum += nextHeight + nextWidth
1090
+ }
1091
+ return checksum
1092
+ }
1093
+ const readProbe = () => renderableLayoutChecksum([...state.rows.slice(0, 20), ...state.badges.slice(0, 20)])
1094
+
1095
+ return withMetrics(
1096
+ {
1097
+ kind: "layout-recalc",
1098
+ phase: "layout-update",
1099
+ passMode: "calculate-update",
1100
+ renderablesPerIteration: state.stats.renderables,
1101
+ layoutNodesPerIteration: state.stats.layoutNodes,
1102
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1103
+ layoutMutationsPerIteration: mutationsPerIteration * 2,
1104
+ runIteration(iteration) {
1105
+ consume(mutate(iteration))
1106
+ const commands = calculateAndUpdateLayout(ctx, "opencode_many_rows_layout_update_only", metrics)
1107
+ return readProbe() + commands
1108
+ },
1109
+ validate: () =>
1110
+ validateCalculateUpdateRecalculation(ctx, "opencode_many_rows_layout_update_only", mutate, readProbe),
1111
+ cleanup: () => {
1112
+ state.root.destroyRecursively()
1113
+ },
1114
+ },
1115
+ metrics,
1116
+ )
1117
+ },
1118
+ },
1119
+ {
1120
+ name: "batched_leaf_props_full_render",
1121
+ description: "Dirty 128 mixed leaf and row props before one full settled render",
1122
+ setup: async (ctx) => {
1123
+ const state = await buildOpencodeLayoutTree(ctx, {
1124
+ messageCount: Math.max(96, ctx.height * 3),
1125
+ includeVisualBoxes: true,
1126
+ includeText: false,
1127
+ })
1128
+ await renderUntilLayoutClean(ctx, "batched_leaf_props_full_render")
1129
+ const metrics = createMetricsTracker()
1130
+
1131
+ const rowMutations = Math.min(32, state.rows.length)
1132
+ const badgeMutations = Math.min(96, state.badges.length)
1133
+ const rowHeights = new Array<number>(state.rows.length).fill(0)
1134
+ const badgeWidths = new Array<number>(state.badges.length).fill(0)
1135
+ const mutate = (iteration: number): number => {
1136
+ let checksum = 0
1137
+ for (let offset = 0; offset < rowMutations; offset += 1) {
1138
+ const rowIndex = (iteration * 17 + offset * 19) % state.rows.length
1139
+ const row = state.rows[rowIndex]!
1140
+ const nextHeight = rowHeights[rowIndex] === 2 ? 5 : 2
1141
+ rowHeights[rowIndex] = nextHeight
1142
+ row.height = nextHeight
1143
+ checksum += nextHeight
1144
+ }
1145
+ for (let offset = 0; offset < badgeMutations; offset += 1) {
1146
+ const badgeIndex = (iteration * 23 + offset * 29) % state.badges.length
1147
+ const badge = state.badges[badgeIndex]!
1148
+ const nextWidth = badgeWidths[badgeIndex] === 4 ? 12 : 4
1149
+ badgeWidths[badgeIndex] = nextWidth
1150
+ badge.width = nextWidth
1151
+ checksum += nextWidth
1152
+ }
1153
+ return checksum
1154
+ }
1155
+ const readProbe = () => renderableLayoutChecksum([...state.rows.slice(0, 40), ...state.badges.slice(0, 80)])
1156
+
1157
+ return withMetrics(
1158
+ {
1159
+ kind: "layout-recalc",
1160
+ phase: "full-render",
1161
+ passMode: "settle-layout",
1162
+ renderablesPerIteration: state.stats.renderables,
1163
+ layoutNodesPerIteration: state.stats.layoutNodes,
1164
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1165
+ layoutMutationsPerIteration: rowMutations + badgeMutations,
1166
+ async runIteration(iteration) {
1167
+ consume(mutate(iteration))
1168
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "batched_leaf_props_full_render", metrics)
1169
+ return readProbe() + passes
1170
+ },
1171
+ validate: () => validateFullRenderRecalculation(ctx, "batched_leaf_props_full_render", mutate, readProbe),
1172
+ cleanup: () => {
1173
+ state.root.destroyRecursively()
1174
+ },
1175
+ },
1176
+ metrics,
1177
+ )
1178
+ },
1179
+ },
1180
+ {
1181
+ name: "wide_shallow_siblings_calculate_only",
1182
+ description: "Dirty one direct child in a wide wrapping container and run only root.calculateLayout()",
1183
+ setup: async (ctx) => {
1184
+ const state = await buildWideShallowTree(ctx, 512)
1185
+ calculateLayout(ctx, "wide_shallow_siblings_calculate_only")
1186
+ const metrics = createMetricsTracker()
1187
+
1188
+ const childWidths = new Array<number>(state.children.length).fill(0)
1189
+ const mutate = (iteration: number): number => {
1190
+ const childIndex = iteration % state.children.length
1191
+ const child = state.children[childIndex]!
1192
+ const nextWidth = childWidths[childIndex] === 13 ? 3 : 13
1193
+ childWidths[childIndex] = nextWidth
1194
+ child.width = nextWidth
1195
+ return nextWidth + childIndex
1196
+ }
1197
+ const readProbe = () => layoutChecksum(state.children.slice(0, 96))
1198
+
1199
+ return withMetrics(
1200
+ {
1201
+ kind: "wide-shallow",
1202
+ phase: "calculate-layout",
1203
+ passMode: "calculate-only",
1204
+ renderablesPerIteration: state.stats.renderables,
1205
+ layoutNodesPerIteration: state.stats.layoutNodes,
1206
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1207
+ layoutMutationsPerIteration: 1,
1208
+ runIteration(iteration) {
1209
+ consume(mutate(iteration))
1210
+ const passes = calculateLayout(ctx, "wide_shallow_siblings_calculate_only")
1211
+ metrics.recordSettlePasses(passes)
1212
+ return readProbe() + passes
1213
+ },
1214
+ validate: () =>
1215
+ validateCalculateRecalculation(ctx, "wide_shallow_siblings_calculate_only", mutate, readProbe),
1216
+ cleanup: () => {
1217
+ state.root.destroyRecursively()
1218
+ },
1219
+ },
1220
+ metrics,
1221
+ )
1222
+ },
1223
+ },
1224
+ {
1225
+ name: "wide_shallow_siblings_full_render",
1226
+ description: "Dirty a batch of direct children in a wide wrapping container before a full settled render",
1227
+ setup: async (ctx) => {
1228
+ const state = await buildWideShallowTree(ctx, 512)
1229
+ await renderUntilLayoutClean(ctx, "wide_shallow_siblings_full_render")
1230
+ const metrics = createMetricsTracker()
1231
+
1232
+ const mutationsPerIteration = 32
1233
+ const childWidths = new Array<number>(state.children.length).fill(0)
1234
+ const mutate = (iteration: number): number => {
1235
+ let checksum = 0
1236
+ for (let offset = 0; offset < mutationsPerIteration; offset += 1) {
1237
+ const childIndex = (iteration * 31 + offset * 37) % state.children.length
1238
+ const child = state.children[childIndex]!
1239
+ const nextWidth = childWidths[childIndex] === 13 ? 3 : 13
1240
+ childWidths[childIndex] = nextWidth
1241
+ child.width = nextWidth
1242
+ checksum += nextWidth + childIndex
1243
+ }
1244
+ return checksum
1245
+ }
1246
+ const readProbe = () => renderableLayoutChecksum(state.children.slice(0, 128))
1247
+
1248
+ return withMetrics(
1249
+ {
1250
+ kind: "wide-shallow",
1251
+ phase: "full-render",
1252
+ passMode: "settle-layout",
1253
+ renderablesPerIteration: state.stats.renderables,
1254
+ layoutNodesPerIteration: state.stats.layoutNodes,
1255
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1256
+ layoutMutationsPerIteration: mutationsPerIteration,
1257
+ async runIteration(iteration) {
1258
+ consume(mutate(iteration))
1259
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "wide_shallow_siblings_full_render", metrics)
1260
+ return readProbe() + passes
1261
+ },
1262
+ validate: () =>
1263
+ validateFullRenderRecalculation(ctx, "wide_shallow_siblings_full_render", mutate, readProbe),
1264
+ cleanup: () => {
1265
+ state.root.destroyRecursively()
1266
+ },
1267
+ },
1268
+ metrics,
1269
+ )
1270
+ },
1271
+ },
1272
+ {
1273
+ name: "deep_chain_leaf_calculate_only",
1274
+ description: "Dirty the leaf of a long single-child chain and run only root.calculateLayout()",
1275
+ setup: async (ctx) => {
1276
+ const state = await buildDeepChainTree(ctx, 192)
1277
+ calculateLayout(ctx, "deep_chain_leaf_calculate_only")
1278
+ const metrics = createMetricsTracker()
1279
+
1280
+ let wideLeaf = false
1281
+ const mutate = (): number => {
1282
+ wideLeaf = !wideLeaf
1283
+ state.leaf.width = wideLeaf ? 22 : 7
1284
+ state.leaf.height = wideLeaf ? 2 : 1
1285
+ return state.leaf.width + state.leaf.height
1286
+ }
1287
+ const readProbe = () => layoutChecksum([state.leaf, ...state.nodes.slice(-16)])
1288
+
1289
+ return withMetrics(
1290
+ {
1291
+ kind: "deep-chain",
1292
+ phase: "calculate-layout",
1293
+ passMode: "calculate-only",
1294
+ renderablesPerIteration: state.stats.renderables,
1295
+ layoutNodesPerIteration: state.stats.layoutNodes,
1296
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1297
+ layoutMutationsPerIteration: 2,
1298
+ runIteration() {
1299
+ consume(mutate())
1300
+ const passes = calculateLayout(ctx, "deep_chain_leaf_calculate_only")
1301
+ metrics.recordSettlePasses(passes)
1302
+ return readProbe() + passes
1303
+ },
1304
+ validate: () => validateCalculateRecalculation(ctx, "deep_chain_leaf_calculate_only", mutate, readProbe),
1305
+ cleanup: () => {
1306
+ state.root.destroyRecursively()
1307
+ },
1308
+ },
1309
+ metrics,
1310
+ )
1311
+ },
1312
+ },
1313
+ {
1314
+ name: "deep_chain_leaf_full_render",
1315
+ description: "Dirty the leaf of a long single-child chain before a full settled render",
1316
+ setup: async (ctx) => {
1317
+ const state = await buildDeepChainTree(ctx, 192)
1318
+ await renderUntilLayoutClean(ctx, "deep_chain_leaf_full_render")
1319
+ const metrics = createMetricsTracker()
1320
+
1321
+ let wideLeaf = false
1322
+ const mutate = (): number => {
1323
+ wideLeaf = !wideLeaf
1324
+ state.leaf.width = wideLeaf ? 22 : 7
1325
+ state.leaf.height = wideLeaf ? 2 : 1
1326
+ return state.leaf.width + state.leaf.height
1327
+ }
1328
+ const readProbe = () => renderableLayoutChecksum([state.leaf, ...state.nodes.slice(-16)])
1329
+
1330
+ return withMetrics(
1331
+ {
1332
+ kind: "deep-chain",
1333
+ phase: "full-render",
1334
+ passMode: "settle-layout",
1335
+ renderablesPerIteration: state.stats.renderables,
1336
+ layoutNodesPerIteration: state.stats.layoutNodes,
1337
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1338
+ layoutMutationsPerIteration: 2,
1339
+ async runIteration() {
1340
+ consume(mutate())
1341
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "deep_chain_leaf_full_render", metrics)
1342
+ return readProbe() + passes
1343
+ },
1344
+ validate: () => validateFullRenderRecalculation(ctx, "deep_chain_leaf_full_render", mutate, readProbe),
1345
+ cleanup: () => {
1346
+ state.root.destroyRecursively()
1347
+ },
1348
+ },
1349
+ metrics,
1350
+ )
1351
+ },
1352
+ },
1353
+ {
1354
+ name: "text_measure_reflow_full_render",
1355
+ description: "Change wrapped text content so Yoga measure functions and parent row layout recalculate",
1356
+ setup: async (ctx) => {
1357
+ const state = await buildTextReflowTree(ctx, Math.max(36, ctx.height + 8))
1358
+ await renderUntilLayoutClean(ctx, "text_measure_reflow_full_render")
1359
+ const metrics = createMetricsTracker()
1360
+
1361
+ const textVersions = new Array<number>(state.texts.length).fill(0)
1362
+ const textWidths = new Array<number>(state.texts.length).fill(0)
1363
+ const mutate = (iteration: number): number => {
1364
+ const targetIndex = iteration % state.texts.length
1365
+ const text = state.texts[targetIndex]!
1366
+ const nextWidth = textWidths[targetIndex] === 22 ? 44 : 22
1367
+ textWidths[targetIndex] = nextWidth
1368
+ textVersions[targetIndex] += 1
1369
+ text.width = nextWidth
1370
+ text.content = createMeasuredText(textVersions[targetIndex] + iteration)
1371
+ return targetIndex + text.textLength + nextWidth
1372
+ }
1373
+ const readProbe = () => renderableLayoutChecksum([...state.rows.slice(0, 16), ...state.texts.slice(0, 16)])
1374
+
1375
+ return withMetrics(
1376
+ {
1377
+ kind: "text-measure",
1378
+ phase: "full-render",
1379
+ passMode: "settle-layout",
1380
+ renderablesPerIteration: state.stats.renderables,
1381
+ layoutNodesPerIteration: state.stats.layoutNodes,
1382
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1383
+ layoutMutationsPerIteration: 2,
1384
+ async runIteration(iteration) {
1385
+ consume(mutate(iteration))
1386
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "text_measure_reflow_full_render", metrics)
1387
+ return readProbe() + passes
1388
+ },
1389
+ validate: () => validateFullRenderRecalculation(ctx, "text_measure_reflow_full_render", mutate, readProbe),
1390
+ cleanup: () => {
1391
+ state.root.destroyRecursively()
1392
+ },
1393
+ },
1394
+ metrics,
1395
+ )
1396
+ },
1397
+ },
1398
+ {
1399
+ name: "text_content_measure_full_render",
1400
+ description: "Change wrapped text content only, validating dirty Yoga and text line-info changes",
1401
+ setup: async (ctx) => {
1402
+ const state = await buildTextReflowTree(ctx, Math.max(36, ctx.height + 8))
1403
+ await renderUntilLayoutClean(ctx, "text_content_measure_full_render")
1404
+ const metrics = createMetricsTracker()
1405
+
1406
+ const textVersions = new Array<number>(state.texts.length).fill(0)
1407
+ const mutate = (iteration: number): number => {
1408
+ const targetIndex = iteration % state.texts.length
1409
+ const text = state.texts[targetIndex]!
1410
+ textVersions[targetIndex] += 1
1411
+ text.content = createMeasuredText(textVersions[targetIndex] + iteration)
1412
+ return targetIndex + text.textLength + text.virtualLineCount
1413
+ }
1414
+ const readContentProbe = () => {
1415
+ let checksum = 0
1416
+ for (let index = 0; index < Math.min(16, state.texts.length); index += 1) {
1417
+ const text = state.texts[index]!
1418
+ checksum += text.textLength + text.virtualLineCount * 31 + index
1419
+ }
1420
+ return checksum
1421
+ }
1422
+
1423
+ return withMetrics(
1424
+ {
1425
+ kind: "text-measure",
1426
+ phase: "full-render",
1427
+ passMode: "settle-layout",
1428
+ renderablesPerIteration: state.stats.renderables,
1429
+ layoutNodesPerIteration: state.stats.layoutNodes,
1430
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1431
+ layoutMutationsPerIteration: 1,
1432
+ async runIteration(iteration) {
1433
+ consume(mutate(iteration))
1434
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "text_content_measure_full_render", metrics)
1435
+ return readContentProbe() + passes
1436
+ },
1437
+ validate: () =>
1438
+ validateContentRecalculation(ctx, "text_content_measure_full_render", mutate, readContentProbe),
1439
+ cleanup: () => {
1440
+ state.root.destroyRecursively()
1441
+ },
1442
+ },
1443
+ metrics,
1444
+ )
1445
+ },
1446
+ },
1447
+ {
1448
+ name: "insert_remove_rows_full_render",
1449
+ description: "Remove one row, allocate one replacement row, and settle the full render tree",
1450
+ setup: async (ctx) => {
1451
+ const state = await buildTreeMutationState(ctx, Math.max(48, ctx.height + 12))
1452
+ await renderUntilLayoutClean(ctx, "insert_remove_rows_full_render")
1453
+ const metrics = createMetricsTracker()
1454
+
1455
+ const mutate = (iteration: number): number => {
1456
+ const removed = state.rows.shift()
1457
+ if (removed) {
1458
+ removed.destroyRecursively()
1459
+ }
1460
+
1461
+ const row = createMutationRow(ctx, state.stats, state.nextRowId, iteration)
1462
+ state.nextRowId += 1
1463
+ state.list.add(row)
1464
+ state.rows.push(row)
1465
+ return state.rows.length + state.nextRowId
1466
+ }
1467
+ const readProbe = () => renderableIdentityLayoutChecksum(state.rows.slice(0, 24))
1468
+
1469
+ return withMetrics(
1470
+ {
1471
+ kind: "tree-mutation",
1472
+ phase: "full-render",
1473
+ passMode: "settle-layout",
1474
+ renderablesPerIteration: state.stats.renderables,
1475
+ layoutNodesPerIteration: state.stats.layoutNodes,
1476
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1477
+ layoutMutationsPerIteration: 2,
1478
+ async runIteration(iteration) {
1479
+ consume(mutate(iteration))
1480
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "insert_remove_rows_full_render", metrics)
1481
+ return readProbe() + passes
1482
+ },
1483
+ validate: () => validateFullRenderRecalculation(ctx, "insert_remove_rows_full_render", mutate, readProbe),
1484
+ cleanup: () => {
1485
+ state.root.destroyRecursively()
1486
+ },
1487
+ },
1488
+ metrics,
1489
+ )
1490
+ },
1491
+ },
1492
+ {
1493
+ name: "scrollbox_content_reflow_full_render",
1494
+ description:
1495
+ "Dirty scrollbox content item heights with viewport culling and sticky-bottom scrollbar recalculation",
1496
+ setup: async (ctx) => {
1497
+ const state = await buildScrollboxReflowState(ctx, Math.max(120, ctx.height * 6))
1498
+ await renderUntilLayoutClean(ctx, "scrollbox_content_reflow_full_render")
1499
+ const metrics = createMetricsTracker()
1500
+
1501
+ const mutationsPerIteration = Math.min(8, state.items.length)
1502
+ const itemHeights = new Array<number>(state.items.length).fill(0)
1503
+ const mutate = (iteration: number): number => {
1504
+ let checksum = 0
1505
+ for (let offset = 0; offset < mutationsPerIteration; offset += 1) {
1506
+ const itemIndex = (iteration * 13 + offset * 17) % state.items.length
1507
+ const item = state.items[itemIndex]!
1508
+ const nextHeight = itemHeights[itemIndex] === 1 ? 4 : 1
1509
+ itemHeights[itemIndex] = nextHeight
1510
+ item.height = nextHeight
1511
+ checksum += item.height + itemIndex
1512
+ }
1513
+ return checksum
1514
+ }
1515
+ const readProbe = () =>
1516
+ renderableLayoutChecksum(state.items.slice(0, 32)) + state.scrollBox.scrollHeight + state.scrollBox.scrollTop
1517
+
1518
+ return withMetrics(
1519
+ {
1520
+ kind: "scrollbox",
1521
+ phase: "full-render",
1522
+ passMode: "settle-layout",
1523
+ renderablesPerIteration: state.stats.renderables,
1524
+ layoutNodesPerIteration: state.stats.layoutNodes,
1525
+ layoutOnlyBoxesPerIteration: state.stats.layoutOnlyBoxes,
1526
+ layoutMutationsPerIteration: mutationsPerIteration,
1527
+ async runIteration(iteration) {
1528
+ consume(mutate(iteration))
1529
+ const passes = await renderMeasuredUntilLayoutClean(ctx, "scrollbox_content_reflow_full_render", metrics)
1530
+ return readProbe() + passes
1531
+ },
1532
+ validate: () =>
1533
+ validateFullRenderRecalculation(ctx, "scrollbox_content_reflow_full_render", mutate, readProbe),
1534
+ cleanup: () => {
1535
+ state.root.destroyRecursively()
1536
+ },
1537
+ },
1538
+ metrics,
1539
+ )
1540
+ },
1541
+ },
1542
+ ]
1543
+ }
1544
+
1545
+ async function buildOpencodeLayoutTree(
1546
+ ctx: BenchmarkContext,
1547
+ options: { messageCount: number; includeVisualBoxes: boolean; includeText: boolean },
1548
+ ): Promise<OpencodeLayoutTreeState> {
1549
+ clearRoot(ctx.renderer)
1550
+ resetBuffers(ctx.renderer)
1551
+
1552
+ const stats: TreeStats = {
1553
+ renderables: 1,
1554
+ layoutNodes: 1,
1555
+ layoutOnlyBoxes: 0,
1556
+ }
1557
+ const rows: BoxRenderable[] = []
1558
+ const badges: BoxRenderable[] = []
1559
+
1560
+ const root = trackLayoutBox(
1561
+ stats,
1562
+ new BoxRenderable(ctx.renderer, {
1563
+ id: "bench-layout-root",
1564
+ width: "100%",
1565
+ height: "100%",
1566
+ border: false,
1567
+ backgroundColor: COLORS.transparent,
1568
+ flexDirection: "column",
1569
+ }),
1570
+ )
1571
+ ctx.renderer.root.add(root)
1572
+
1573
+ const header = trackLayoutBox(
1574
+ stats,
1575
+ new BoxRenderable(ctx.renderer, {
1576
+ id: "bench-layout-header",
1577
+ width: "100%",
1578
+ height: 3,
1579
+ flexDirection: "row",
1580
+ paddingLeft: 1,
1581
+ paddingRight: 1,
1582
+ gap: 1,
1583
+ }),
1584
+ )
1585
+ root.add(header)
1586
+
1587
+ for (let i = 0; i < 5; i += 1) {
1588
+ const chip = trackLayoutBox(
1589
+ stats,
1590
+ new BoxRenderable(ctx.renderer, {
1591
+ id: `bench-layout-chip-${i}`,
1592
+ width: 8 + i,
1593
+ height: 1,
1594
+ flexShrink: 0,
1595
+ }),
1596
+ )
1597
+ header.add(chip)
1598
+ badges.push(chip)
1599
+ }
1600
+
1601
+ const body = trackLayoutBox(
1602
+ stats,
1603
+ new BoxRenderable(ctx.renderer, {
1604
+ id: "bench-layout-body",
1605
+ width: "100%",
1606
+ flexGrow: 1,
1607
+ flexDirection: "row",
1608
+ gap: 1,
1609
+ paddingLeft: 1,
1610
+ paddingRight: 1,
1611
+ }),
1612
+ )
1613
+ root.add(body)
1614
+
1615
+ const sidebar = trackLayoutBox(
1616
+ stats,
1617
+ new BoxRenderable(ctx.renderer, {
1618
+ id: "bench-layout-sidebar",
1619
+ width: 22,
1620
+ minWidth: 22,
1621
+ maxWidth: 22,
1622
+ flexShrink: 0,
1623
+ flexDirection: "column",
1624
+ gap: 1,
1625
+ }),
1626
+ )
1627
+ body.add(sidebar)
1628
+
1629
+ for (let i = 0; i < 12; i += 1) {
1630
+ sidebar.add(
1631
+ trackLayoutBox(
1632
+ stats,
1633
+ new BoxRenderable(ctx.renderer, {
1634
+ id: `bench-layout-sidebar-row-${i}`,
1635
+ height: 1,
1636
+ paddingLeft: 1,
1637
+ paddingRight: 1,
1638
+ }),
1639
+ ),
1640
+ )
1641
+ }
1642
+
1643
+ const main = trackLayoutBox(
1644
+ stats,
1645
+ new BoxRenderable(ctx.renderer, {
1646
+ id: "bench-layout-main",
1647
+ flexGrow: 1,
1648
+ flexDirection: "column",
1649
+ gap: 1,
1650
+ }),
1651
+ )
1652
+ body.add(main)
1653
+
1654
+ for (let i = 0; i < options.messageCount; i += 1) {
1655
+ const row = trackLayoutBox(
1656
+ stats,
1657
+ new BoxRenderable(ctx.renderer, {
1658
+ id: `bench-layout-row-${i}`,
1659
+ width: "100%",
1660
+ height: i % 4 === 0 ? 3 : 2,
1661
+ flexDirection: "row",
1662
+ }),
1663
+ )
1664
+ rows.push(row)
1665
+
1666
+ const rail = trackLayoutBox(
1667
+ stats,
1668
+ new BoxRenderable(ctx.renderer, {
1669
+ id: `bench-layout-rail-${i}`,
1670
+ width: 3,
1671
+ minWidth: 3,
1672
+ maxWidth: 3,
1673
+ flexShrink: 0,
1674
+ }),
1675
+ )
1676
+
1677
+ const content = trackLayoutBox(
1678
+ stats,
1679
+ new BoxRenderable(ctx.renderer, {
1680
+ id: `bench-layout-content-${i}`,
1681
+ flexGrow: 1,
1682
+ flexDirection: "column",
1683
+ }),
1684
+ )
1685
+
1686
+ const meta = trackLayoutBox(
1687
+ stats,
1688
+ new BoxRenderable(ctx.renderer, {
1689
+ id: `bench-layout-meta-${i}`,
1690
+ width: "100%",
1691
+ height: 1,
1692
+ flexDirection: "row",
1693
+ justifyContent: "space-between",
1694
+ }),
1695
+ )
1696
+
1697
+ const badgeGroup = trackLayoutBox(
1698
+ stats,
1699
+ new BoxRenderable(ctx.renderer, {
1700
+ id: `bench-layout-badges-${i}`,
1701
+ flexDirection: "row",
1702
+ gap: 1,
1703
+ }),
1704
+ )
1705
+
1706
+ for (let badgeIndex = 0; badgeIndex < 3; badgeIndex += 1) {
1707
+ const badge = trackLayoutBox(
1708
+ stats,
1709
+ new BoxRenderable(ctx.renderer, {
1710
+ id: `bench-layout-badge-${i}-${badgeIndex}`,
1711
+ width: 5 + badgeIndex,
1712
+ height: 1,
1713
+ flexShrink: 0,
1714
+ }),
1715
+ )
1716
+ badgeGroup.add(badge)
1717
+ badges.push(badge)
1718
+ }
1719
+
1720
+ const actions = trackLayoutBox(
1721
+ stats,
1722
+ new BoxRenderable(ctx.renderer, {
1723
+ id: `bench-layout-actions-${i}`,
1724
+ flexDirection: "row",
1725
+ gap: 1,
1726
+ flexShrink: 0,
1727
+ }),
1728
+ )
1729
+
1730
+ for (let actionIndex = 0; actionIndex < 2; actionIndex += 1) {
1731
+ const action = trackLayoutBox(
1732
+ stats,
1733
+ new BoxRenderable(ctx.renderer, {
1734
+ id: `bench-layout-action-${i}-${actionIndex}`,
1735
+ width: 4 + actionIndex,
1736
+ height: 1,
1737
+ flexShrink: 0,
1738
+ }),
1739
+ )
1740
+ actions.add(action)
1741
+ badges.push(action)
1742
+ }
1743
+
1744
+ meta.add(badgeGroup)
1745
+ meta.add(actions)
1746
+ content.add(meta)
1747
+
1748
+ if (options.includeVisualBoxes) {
1749
+ content.add(
1750
+ trackVisualBox(
1751
+ stats,
1752
+ new BoxRenderable(ctx.renderer, {
1753
+ id: `bench-layout-leaf-${i}`,
1754
+ width: "100%",
1755
+ height: i % 5 === 0 ? 3 : i % 2 === 0 ? 2 : 1,
1756
+ border: false,
1757
+ backgroundColor: i % 3 === 0 ? COLORS.menu : i % 2 === 0 ? COLORS.panel : COLORS.element,
1758
+ }),
1759
+ ),
1760
+ )
1761
+ }
1762
+
1763
+ if (options.includeText && i % 4 === 0) {
1764
+ content.add(
1765
+ trackText(
1766
+ stats,
1767
+ new TextRenderable(ctx.renderer, {
1768
+ id: `bench-layout-text-${i}`,
1769
+ content: `message-${i}`,
1770
+ height: 1,
1771
+ }),
1772
+ ),
1773
+ )
1774
+ }
1775
+
1776
+ row.add(rail)
1777
+ row.add(content)
1778
+ main.add(row)
1779
+ }
1780
+
1781
+ const footer = trackLayoutBox(
1782
+ stats,
1783
+ new BoxRenderable(ctx.renderer, {
1784
+ id: "bench-layout-footer",
1785
+ width: "100%",
1786
+ height: 4,
1787
+ flexDirection: "row",
1788
+ gap: 1,
1789
+ paddingLeft: 1,
1790
+ paddingRight: 1,
1791
+ }),
1792
+ )
1793
+ root.add(footer)
1794
+
1795
+ for (let i = 0; i < 6; i += 1) {
1796
+ const item = trackLayoutBox(
1797
+ stats,
1798
+ new BoxRenderable(ctx.renderer, {
1799
+ id: `bench-layout-footer-item-${i}`,
1800
+ width: 12 + i,
1801
+ height: 1,
1802
+ flexDirection: "row",
1803
+ gap: 1,
1804
+ }),
1805
+ )
1806
+ footer.add(item)
1807
+ badges.push(item)
1808
+ }
1809
+
1810
+ return {
1811
+ root,
1812
+ header,
1813
+ body,
1814
+ sidebar,
1815
+ main,
1816
+ footer,
1817
+ rows,
1818
+ badges,
1819
+ stats,
1820
+ }
1821
+ }
1822
+
1823
+ async function buildWideShallowTree(ctx: BenchmarkContext, childCount: number): Promise<WideShallowTreeState> {
1824
+ clearRoot(ctx.renderer)
1825
+ resetBuffers(ctx.renderer)
1826
+
1827
+ const stats: TreeStats = {
1828
+ renderables: 1,
1829
+ layoutNodes: 1,
1830
+ layoutOnlyBoxes: 0,
1831
+ }
1832
+ const children: BoxRenderable[] = []
1833
+
1834
+ const root = trackLayoutBox(
1835
+ stats,
1836
+ new BoxRenderable(ctx.renderer, {
1837
+ id: "bench-wide-root",
1838
+ width: "100%",
1839
+ height: "100%",
1840
+ flexDirection: "column",
1841
+ paddingLeft: 1,
1842
+ paddingRight: 1,
1843
+ }),
1844
+ )
1845
+ ctx.renderer.root.add(root)
1846
+
1847
+ const container = trackLayoutBox(
1848
+ stats,
1849
+ new BoxRenderable(ctx.renderer, {
1850
+ id: "bench-wide-container",
1851
+ width: "100%",
1852
+ flexGrow: 1,
1853
+ flexDirection: "row",
1854
+ flexWrap: "wrap",
1855
+ gap: 1,
1856
+ }),
1857
+ )
1858
+ root.add(container)
1859
+
1860
+ for (let i = 0; i < childCount; i += 1) {
1861
+ const child = trackLayoutBox(
1862
+ stats,
1863
+ new BoxRenderable(ctx.renderer, {
1864
+ id: `bench-wide-child-${i}`,
1865
+ width: 3 + (i % 5),
1866
+ height: 1,
1867
+ flexShrink: 0,
1868
+ }),
1869
+ )
1870
+ container.add(child)
1871
+ children.push(child)
1872
+ }
1873
+
1874
+ return {
1875
+ root,
1876
+ container,
1877
+ children,
1878
+ stats,
1879
+ }
1880
+ }
1881
+
1882
+ async function buildDeepChainTree(ctx: BenchmarkContext, depth: number): Promise<DeepChainTreeState> {
1883
+ clearRoot(ctx.renderer)
1884
+ resetBuffers(ctx.renderer)
1885
+
1886
+ const stats: TreeStats = {
1887
+ renderables: 1,
1888
+ layoutNodes: 1,
1889
+ layoutOnlyBoxes: 0,
1890
+ }
1891
+ const nodes: BoxRenderable[] = []
1892
+
1893
+ const root = trackLayoutBox(
1894
+ stats,
1895
+ new BoxRenderable(ctx.renderer, {
1896
+ id: "bench-deep-root",
1897
+ width: "100%",
1898
+ height: "100%",
1899
+ flexDirection: "column",
1900
+ }),
1901
+ )
1902
+ ctx.renderer.root.add(root)
1903
+
1904
+ let parent: BoxRenderable = root
1905
+ for (let i = 0; i < depth; i += 1) {
1906
+ const node = trackLayoutBox(
1907
+ stats,
1908
+ new BoxRenderable(ctx.renderer, {
1909
+ id: `bench-deep-node-${i}`,
1910
+ width: "100%",
1911
+ flexDirection: i % 2 === 0 ? "column" : "row",
1912
+ paddingLeft: i % 7 === 0 ? 1 : 0,
1913
+ }),
1914
+ )
1915
+ parent.add(node)
1916
+ nodes.push(node)
1917
+ parent = node
1918
+ }
1919
+
1920
+ const leaf = trackVisualBox(
1921
+ stats,
1922
+ new BoxRenderable(ctx.renderer, {
1923
+ id: "bench-deep-leaf",
1924
+ width: 7,
1925
+ height: 1,
1926
+ flexShrink: 0,
1927
+ backgroundColor: COLORS.panel,
1928
+ }),
1929
+ )
1930
+ parent.add(leaf)
1931
+
1932
+ return {
1933
+ root,
1934
+ nodes,
1935
+ leaf,
1936
+ stats,
1937
+ }
1938
+ }
1939
+
1940
+ async function buildTextReflowTree(ctx: BenchmarkContext, rowCount: number): Promise<TextReflowTreeState> {
1941
+ clearRoot(ctx.renderer)
1942
+ resetBuffers(ctx.renderer)
1943
+
1944
+ const stats: TreeStats = {
1945
+ renderables: 1,
1946
+ layoutNodes: 1,
1947
+ layoutOnlyBoxes: 0,
1948
+ }
1949
+ const rows: BoxRenderable[] = []
1950
+ const texts: TextRenderable[] = []
1951
+
1952
+ const root = trackLayoutBox(
1953
+ stats,
1954
+ new BoxRenderable(ctx.renderer, {
1955
+ id: "bench-text-root",
1956
+ width: "100%",
1957
+ height: "100%",
1958
+ flexDirection: "column",
1959
+ paddingLeft: 1,
1960
+ paddingRight: 1,
1961
+ gap: 1,
1962
+ }),
1963
+ )
1964
+ ctx.renderer.root.add(root)
1965
+
1966
+ for (let i = 0; i < rowCount; i += 1) {
1967
+ const row = trackLayoutBox(
1968
+ stats,
1969
+ new BoxRenderable(ctx.renderer, {
1970
+ id: `bench-text-row-${i}`,
1971
+ width: "100%",
1972
+ flexDirection: "row",
1973
+ gap: 1,
1974
+ }),
1975
+ )
1976
+ rows.push(row)
1977
+
1978
+ row.add(
1979
+ trackLayoutBox(
1980
+ stats,
1981
+ new BoxRenderable(ctx.renderer, {
1982
+ id: `bench-text-gutter-${i}`,
1983
+ width: 6,
1984
+ height: 1,
1985
+ flexShrink: 0,
1986
+ }),
1987
+ ),
1988
+ )
1989
+
1990
+ const text = trackText(
1991
+ stats,
1992
+ new TextRenderable(ctx.renderer, {
1993
+ id: `bench-text-${i}`,
1994
+ width: Math.max(18, Math.floor(ctx.width * 0.34)),
1995
+ content: createMeasuredText(i),
1996
+ wrapMode: "word",
1997
+ }),
1998
+ )
1999
+ texts.push(text)
2000
+ row.add(text)
2001
+ root.add(row)
2002
+ }
2003
+
2004
+ return {
2005
+ root,
2006
+ rows,
2007
+ texts,
2008
+ stats,
2009
+ }
2010
+ }
2011
+
2012
+ async function buildTreeMutationState(ctx: BenchmarkContext, rowCount: number): Promise<TreeMutationState> {
2013
+ clearRoot(ctx.renderer)
2014
+ resetBuffers(ctx.renderer)
2015
+
2016
+ const stats: TreeStats = {
2017
+ renderables: 1,
2018
+ layoutNodes: 1,
2019
+ layoutOnlyBoxes: 0,
2020
+ }
2021
+ const rows: BoxRenderable[] = []
2022
+
2023
+ const root = trackLayoutBox(
2024
+ stats,
2025
+ new BoxRenderable(ctx.renderer, {
2026
+ id: "bench-mutation-root",
2027
+ width: "100%",
2028
+ height: "100%",
2029
+ flexDirection: "column",
2030
+ gap: 1,
2031
+ }),
2032
+ )
2033
+ ctx.renderer.root.add(root)
2034
+
2035
+ const list = trackLayoutBox(
2036
+ stats,
2037
+ new BoxRenderable(ctx.renderer, {
2038
+ id: "bench-mutation-list",
2039
+ width: "100%",
2040
+ flexGrow: 1,
2041
+ flexDirection: "column",
2042
+ }),
2043
+ )
2044
+ root.add(list)
2045
+
2046
+ for (let i = 0; i < rowCount; i += 1) {
2047
+ const row = createMutationRow(ctx, stats, i, i)
2048
+ list.add(row)
2049
+ rows.push(row)
2050
+ }
2051
+
2052
+ return {
2053
+ root,
2054
+ list,
2055
+ rows,
2056
+ stats,
2057
+ nextRowId: rowCount,
2058
+ }
2059
+ }
2060
+
2061
+ function createMutationRow(ctx: BenchmarkContext, stats: TreeStats, id: number, variant: number): BoxRenderable {
2062
+ const row = trackLayoutBox(
2063
+ stats,
2064
+ new BoxRenderable(ctx.renderer, {
2065
+ id: `bench-mutation-row-${id}`,
2066
+ width: "100%",
2067
+ height: 1 + (variant % 3),
2068
+ flexDirection: "row",
2069
+ gap: 1,
2070
+ }),
2071
+ )
2072
+
2073
+ for (let i = 0; i < 4; i += 1) {
2074
+ row.add(
2075
+ trackLayoutBox(
2076
+ stats,
2077
+ new BoxRenderable(ctx.renderer, {
2078
+ id: `bench-mutation-cell-${id}-${i}`,
2079
+ width: i === 3 ? "auto" : 8 + ((variant + i) % 5),
2080
+ flexGrow: i === 3 ? 1 : 0,
2081
+ height: 1,
2082
+ }),
2083
+ ),
2084
+ )
2085
+ }
2086
+
2087
+ return row
2088
+ }
2089
+
2090
+ async function buildScrollboxReflowState(ctx: BenchmarkContext, itemCount: number): Promise<ScrollboxReflowState> {
2091
+ clearRoot(ctx.renderer)
2092
+ resetBuffers(ctx.renderer)
2093
+
2094
+ const stats: TreeStats = {
2095
+ renderables: 1,
2096
+ layoutNodes: 1,
2097
+ layoutOnlyBoxes: 0,
2098
+ }
2099
+ const items: BoxRenderable[] = []
2100
+
2101
+ const root = trackLayoutBox(
2102
+ stats,
2103
+ new BoxRenderable(ctx.renderer, {
2104
+ id: "bench-scrollbox-root",
2105
+ width: "100%",
2106
+ height: "100%",
2107
+ flexDirection: "column",
2108
+ }),
2109
+ )
2110
+ ctx.renderer.root.add(root)
2111
+
2112
+ const scrollBox = trackLayoutBox(
2113
+ stats,
2114
+ new ScrollBoxRenderable(ctx.renderer, {
2115
+ id: "bench-scrollbox",
2116
+ width: "100%",
2117
+ height: "100%",
2118
+ stickyScroll: true,
2119
+ stickyStart: "bottom",
2120
+ viewportCulling: true,
2121
+ contentOptions: {
2122
+ flexDirection: "column",
2123
+ },
2124
+ }),
2125
+ ) as ScrollBoxRenderable
2126
+ root.add(scrollBox)
2127
+
2128
+ // ScrollBoxRenderable owns wrapper, viewport, content, two scrollbars,
2129
+ // and each scrollbar owns a slider plus two arrows.
2130
+ // Count those internal renderables so per-node metrics match traversal scale.
2131
+ stats.renderables += 11
2132
+ stats.layoutNodes += 11
2133
+ stats.layoutOnlyBoxes += 3
2134
+
2135
+ for (let i = 0; i < itemCount; i += 1) {
2136
+ const item = trackLayoutBox(
2137
+ stats,
2138
+ new BoxRenderable(ctx.renderer, {
2139
+ id: `bench-scrollbox-item-${i}`,
2140
+ width: "100%",
2141
+ height: 1 + (i % 3),
2142
+ flexDirection: "row",
2143
+ gap: 1,
2144
+ paddingLeft: 1,
2145
+ paddingRight: 1,
2146
+ }),
2147
+ )
2148
+
2149
+ item.add(
2150
+ trackVisualBox(
2151
+ stats,
2152
+ new BoxRenderable(ctx.renderer, {
2153
+ id: `bench-scrollbox-item-marker-${i}`,
2154
+ width: 2,
2155
+ height: 1,
2156
+ flexShrink: 0,
2157
+ backgroundColor: i % 2 === 0 ? COLORS.accent : COLORS.warning,
2158
+ }),
2159
+ ),
2160
+ )
2161
+ item.add(
2162
+ trackLayoutBox(
2163
+ stats,
2164
+ new BoxRenderable(ctx.renderer, {
2165
+ id: `bench-scrollbox-item-fill-${i}`,
2166
+ flexGrow: 1,
2167
+ height: 1,
2168
+ }),
2169
+ ),
2170
+ )
2171
+
2172
+ scrollBox.add(item)
2173
+ items.push(item)
2174
+ }
2175
+
2176
+ return {
2177
+ root,
2178
+ scrollBox,
2179
+ items,
2180
+ stats,
2181
+ }
2182
+ }
2183
+
2184
+ function trackLayoutBox<T extends BoxRenderable>(stats: TreeStats, box: T): T {
2185
+ stats.renderables += 1
2186
+ stats.layoutNodes += 1
2187
+ stats.layoutOnlyBoxes += 1
2188
+ return box
2189
+ }
2190
+
2191
+ function trackVisualBox<T extends BoxRenderable>(stats: TreeStats, box: T): T {
2192
+ stats.renderables += 1
2193
+ stats.layoutNodes += 1
2194
+ return box
2195
+ }
2196
+
2197
+ function trackText<T extends TextRenderable>(stats: TreeStats, text: T): T {
2198
+ stats.renderables += 1
2199
+ stats.layoutNodes += 1
2200
+ return text
2201
+ }
2202
+
2203
+ function createMeasuredText(iteration: number): string {
2204
+ if (iteration % 3 === 0) {
2205
+ return `short layout line ${iteration}`
2206
+ }
2207
+
2208
+ if (iteration % 3 === 1) {
2209
+ return `medium wrapped layout text ${iteration} with enough words to cross the benchmark column and force a different measured height`
2210
+ }
2211
+
2212
+ return `long wrapped layout text ${iteration} with repeated terminal interface vocabulary layout render buffer yoga measurement viewport reflow update traversal native boundary `.repeat(
2213
+ 2,
2214
+ )
2215
+ }
2216
+
2217
+ async function runScenario(
2218
+ scenario: BenchmarkScenario,
2219
+ ctx: BenchmarkContext,
2220
+ args: BenchmarkArgs,
2221
+ ): Promise<BenchmarkResult> {
2222
+ const runtime = await scenario.setup(ctx)
2223
+
2224
+ try {
2225
+ if (!args.skipValidation) {
2226
+ await runtime.validate?.()
2227
+ }
2228
+
2229
+ let nextIteration = 0
2230
+ nextIteration = await runIterations(runtime, args.warmupIterations, nextIteration)
2231
+
2232
+ let batchIterations = args.iterations
2233
+ const calibration = await timeIterations(runtime, batchIterations, nextIteration)
2234
+ nextIteration = calibration.nextIteration
2235
+
2236
+ if (calibration.durationMs > 0 && calibration.durationMs < args.minSampleMs) {
2237
+ const scaledIterations = (batchIterations * args.minSampleMs) / calibration.durationMs
2238
+ batchIterations = roundIterations(scaledIterations)
2239
+ }
2240
+
2241
+ if (batchIterations !== args.iterations) {
2242
+ nextIteration = await runIterations(runtime, Math.min(batchIterations, args.warmupIterations), nextIteration)
2243
+ }
2244
+
2245
+ runtime.resetMetrics?.()
2246
+
2247
+ const samples: BenchmarkSample[] = []
2248
+ for (let round = 0; round < args.rounds; round += 1) {
2249
+ const start = nowNs()
2250
+ let sampleIterations = 0
2251
+ let durationMs = 0
2252
+
2253
+ do {
2254
+ nextIteration = await runIterations(runtime, batchIterations, nextIteration)
2255
+ sampleIterations += batchIterations
2256
+ durationMs = nsToMs(nowNs() - start)
2257
+ } while (durationMs < args.minSampleMs)
2258
+
2259
+ samples.push({
2260
+ round: round + 1,
2261
+ iterations: sampleIterations,
2262
+ durationMs,
2263
+ opsPerSecond: (sampleIterations * 1000) / durationMs,
2264
+ nsPerOperation: (durationMs * 1_000_000) / sampleIterations,
2265
+ })
2266
+ }
2267
+
2268
+ const durations = samples.map((sample) => sample.durationMs)
2269
+ const opsPerSecond = samples.map((sample) => sample.opsPerSecond)
2270
+ const nsPerOperation = samples.map((sample) => sample.nsPerOperation)
2271
+ const metrics = runtime.readMetrics?.() ?? emptyMetrics()
2272
+ const avgSettlePassesPerIteration =
2273
+ metrics.measuredIterations > 0 ? metrics.totalSettlePasses / metrics.measuredIterations : 0
2274
+ const hasUpdateCommandMetrics = runtime.phase === "layout-update"
2275
+ const avgUpdateCommandsPerIteration =
2276
+ hasUpdateCommandMetrics && metrics.measuredIterations > 0
2277
+ ? metrics.totalRenderCommands / metrics.measuredIterations
2278
+ : null
2279
+ const maxUpdateCommands = hasUpdateCommandMetrics ? metrics.maxRenderCommands : null
2280
+
2281
+ return {
2282
+ name: scenario.name,
2283
+ description: scenario.description,
2284
+ kind: runtime.kind,
2285
+ phase: runtime.phase,
2286
+ passMode: runtime.passMode,
2287
+ iterations: args.iterations,
2288
+ warmupIterations: args.warmupIterations,
2289
+ rounds: args.rounds,
2290
+ minSampleMs: args.minSampleMs,
2291
+ batchIterations,
2292
+ totalMeasuredIterations: samples.reduce((total, sample) => total + sample.iterations, 0),
2293
+ renderablesPerIteration: runtime.renderablesPerIteration,
2294
+ layoutNodesPerIteration: runtime.layoutNodesPerIteration,
2295
+ layoutOnlyBoxesPerIteration: runtime.layoutOnlyBoxesPerIteration,
2296
+ layoutMutationsPerIteration: runtime.layoutMutationsPerIteration,
2297
+ measuredMetricIterations: metrics.measuredIterations,
2298
+ avgSettlePassesPerIteration,
2299
+ maxSettlePasses: metrics.maxSettlePasses,
2300
+ avgUpdateCommandsPerIteration,
2301
+ maxUpdateCommands,
2302
+ medianDurationMs: median(durations),
2303
+ bestDurationMs: Math.min(...durations),
2304
+ medianOpsPerSecond: median(opsPerSecond),
2305
+ meanOpsPerSecond: mean(opsPerSecond),
2306
+ medianNsPerOperation: median(nsPerOperation),
2307
+ p95NsPerOperation: percentile(nsPerOperation, 95),
2308
+ stdDevNsPerOperation: sampleStdDev(nsPerOperation),
2309
+ rmePercent: relativeMarginOfError(nsPerOperation),
2310
+ samples,
2311
+ }
2312
+ } finally {
2313
+ await runtime.cleanup()
2314
+ clearRoot(ctx.renderer)
2315
+ resetBuffers(ctx.renderer)
2316
+ }
2317
+ }
2318
+
2319
+ async function timeIterations(
2320
+ runtime: ScenarioRuntime,
2321
+ count: number,
2322
+ startIteration: number,
2323
+ ): Promise<{ durationMs: number; nextIteration: number }> {
2324
+ const start = nowNs()
2325
+ const nextIteration = await runIterations(runtime, count, startIteration)
2326
+ return {
2327
+ durationMs: nsToMs(nowNs() - start),
2328
+ nextIteration,
2329
+ }
2330
+ }
2331
+
2332
+ async function runIterations(runtime: ScenarioRuntime, count: number, startIteration: number): Promise<number> {
2333
+ for (let iteration = 0; iteration < count; iteration += 1) {
2334
+ consume(await runtime.runIteration(startIteration + iteration))
2335
+ }
2336
+
2337
+ return startIteration + count
2338
+ }
2339
+
2340
+ function clearRoot(renderer: TestRenderer): void {
2341
+ for (const child of renderer.root.getChildren()) {
2342
+ child.destroyRecursively()
2343
+ }
2344
+ }
2345
+
2346
+ function resetBuffers(renderer: TestRenderer): void {
2347
+ const buffers = [renderer.currentRenderBuffer, renderer.nextRenderBuffer]
2348
+ for (const buffer of buffers) {
2349
+ buffer.clearScissorRects()
2350
+ buffer.clearOpacity()
2351
+ buffer.clear(COLORS.transparent)
2352
+ }
2353
+ }
2354
+
2355
+ function formatNumber(value: number): string {
2356
+ return value.toFixed(2)
2357
+ }
2358
+
2359
+ function formatOptionalNumber(value: number | null): string {
2360
+ return value === null ? "-" : formatNumber(value)
2361
+ }
2362
+
2363
+ function formatOptionalInteger(value: number | null): string {
2364
+ return value === null ? "-" : String(value)
2365
+ }
2366
+
2367
+ function writeLine(enabled: boolean, line: string): void {
2368
+ if (enabled) {
2369
+ console.log(line)
2370
+ }
2371
+ }
2372
+
2373
+ function printResults(results: BenchmarkResult[], args: BenchmarkArgs): void {
2374
+ console.log(
2375
+ `layout-benchmark iters=${args.iterations} warmup=${args.warmupIterations} rounds=${args.rounds} min_sample_ms=${args.minSampleMs} renderer=${args.width}x${args.height} scenarios=${results.length} checksum=${blackhole.checksum}`,
2376
+ )
2377
+ console.log("")
2378
+
2379
+ const header = [
2380
+ "scenario",
2381
+ "kind",
2382
+ "phase",
2383
+ "pass mode",
2384
+ "batch",
2385
+ "nodes",
2386
+ "mutations",
2387
+ "avg passes",
2388
+ "max passes",
2389
+ "avg update cmds",
2390
+ "max update cmds",
2391
+ "median ns/op",
2392
+ "p95 ns/op",
2393
+ "rme %",
2394
+ ]
2395
+ const rows = results.map((result) => [
2396
+ result.name,
2397
+ result.kind,
2398
+ result.phase,
2399
+ result.passMode,
2400
+ String(result.batchIterations),
2401
+ String(result.layoutNodesPerIteration),
2402
+ String(result.layoutMutationsPerIteration),
2403
+ formatNumber(result.avgSettlePassesPerIteration),
2404
+ String(result.maxSettlePasses),
2405
+ formatOptionalNumber(result.avgUpdateCommandsPerIteration),
2406
+ formatOptionalInteger(result.maxUpdateCommands),
2407
+ formatNumber(result.medianNsPerOperation),
2408
+ formatNumber(result.p95NsPerOperation),
2409
+ formatNumber(result.rmePercent),
2410
+ ])
2411
+
2412
+ const widths = header.map((title, index) => Math.max(title.length, ...rows.map((row) => row[index]?.length ?? 0)))
2413
+ const lines = [header, ...rows].map((row, rowIndex) => {
2414
+ const line = row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ")
2415
+ if (rowIndex !== 0) {
2416
+ return line
2417
+ }
2418
+
2419
+ const divider = widths.map((width) => "-".repeat(width)).join(" ")
2420
+ return `${line}\n${divider}`
2421
+ })
2422
+
2423
+ console.log(lines.join("\n"))
2424
+ console.log("")
2425
+
2426
+ for (const result of results) {
2427
+ console.log(`${result.name}: ${result.description}`)
2428
+ for (const sample of result.samples) {
2429
+ console.log(
2430
+ ` round ${sample.round}: ${sample.iterations} iters, ${formatNumber(sample.durationMs)} ms, ${formatNumber(sample.nsPerOperation)} ns/op (${formatNumber(sample.opsPerSecond)} ops/sec)`,
2431
+ )
2432
+ }
2433
+ }
2434
+ }
2435
+
2436
+ function writeResults(results: BenchmarkResult[], args: BenchmarkArgs, jsonPath: string): void {
2437
+ const absolutePath = path.isAbsolute(jsonPath) ? jsonPath : path.resolve(process.cwd(), jsonPath)
2438
+ mkdirSync(path.dirname(absolutePath), { recursive: true })
2439
+ writeFileSync(
2440
+ absolutePath,
2441
+ JSON.stringify(
2442
+ {
2443
+ meta: {
2444
+ timestamp: new Date().toISOString(),
2445
+ iterations: args.iterations,
2446
+ warmupIterations: args.warmupIterations,
2447
+ rounds: args.rounds,
2448
+ minSampleMs: args.minSampleMs,
2449
+ width: args.width,
2450
+ height: args.height,
2451
+ cwd: process.cwd(),
2452
+ args: process.argv.slice(2),
2453
+ runtime: {
2454
+ bun: typeof Bun !== "undefined" ? Bun.version : undefined,
2455
+ node: process.versions.node,
2456
+ v8: process.versions.v8,
2457
+ platform: process.platform,
2458
+ arch: process.arch,
2459
+ },
2460
+ blackholeChecksum: blackhole.checksum,
2461
+ },
2462
+ results,
2463
+ },
2464
+ null,
2465
+ 2,
2466
+ ),
2467
+ )
2468
+ }
2469
+
2470
+ async function main(): Promise<void> {
2471
+ const args = parseArgs(process.argv.slice(2))
2472
+ const scenarios = createScenarios()
2473
+
2474
+ if (args.listScenarios) {
2475
+ for (const scenario of scenarios) {
2476
+ console.log(`${scenario.name}\t${scenario.description}`)
2477
+ }
2478
+ return
2479
+ }
2480
+
2481
+ if (args.jsonPath) {
2482
+ const absolutePath = path.isAbsolute(args.jsonPath) ? args.jsonPath : path.resolve(process.cwd(), args.jsonPath)
2483
+ if (existsSync(absolutePath)) {
2484
+ throw new Error(`Benchmark output file already exists: ${absolutePath}`)
2485
+ }
2486
+ }
2487
+
2488
+ const selectedScenarios = args.scenarioNames
2489
+ ? scenarios.filter((scenario) => args.scenarioNames!.has(scenario.name))
2490
+ : scenarios
2491
+
2492
+ if (selectedScenarios.length === 0) {
2493
+ throw new Error("No benchmark scenarios matched the provided --scenario filter")
2494
+ }
2495
+
2496
+ writeLine(args.output, `layout benchmark renderer=${args.width}x${args.height}`)
2497
+ writeLine(
2498
+ args.output,
2499
+ `scenarios=${selectedScenarios.length} iterations=${args.iterations} warmup=${args.warmupIterations} rounds=${args.rounds} min_sample_ms=${args.minSampleMs}`,
2500
+ )
2501
+
2502
+ const { renderer, renderOnce } = await createTestRenderer({
2503
+ width: args.width,
2504
+ height: args.height,
2505
+ targetFps: 60,
2506
+ maxFps: 60,
2507
+ screenMode: "main-screen",
2508
+ externalOutputMode: "passthrough",
2509
+ consoleMode: "disabled",
2510
+ useMouse: false,
2511
+ })
2512
+
2513
+ renderer.requestRender = () => {}
2514
+
2515
+ const ctx: BenchmarkContext = {
2516
+ renderer,
2517
+ renderOnce,
2518
+ width: args.width,
2519
+ height: args.height,
2520
+ }
2521
+ const results: BenchmarkResult[] = []
2522
+
2523
+ try {
2524
+ for (const scenario of selectedScenarios) {
2525
+ writeLine(args.output, `Running ${scenario.name}...`)
2526
+ const result = await runScenario(scenario, ctx, args)
2527
+ results.push(result)
2528
+ writeLine(
2529
+ args.output,
2530
+ ` median=${formatNumber(result.medianNsPerOperation)}ns/op p95=${formatNumber(result.p95NsPerOperation)}ns/op rme=${formatNumber(result.rmePercent)}%`,
2531
+ )
2532
+ }
2533
+ } finally {
2534
+ renderer.destroy()
2535
+ }
2536
+
2537
+ if (args.output) {
2538
+ printResults(results, args)
2539
+ }
2540
+
2541
+ if (args.jsonPath) {
2542
+ writeResults(results, args, args.jsonPath)
2543
+ writeLine(args.output, `Wrote benchmark JSON: ${args.jsonPath}`)
2544
+ }
2545
+ }
2546
+
2547
+ await main()