@fairyhunter13/opentui-core 0.1.113 → 0.1.114

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 (591) hide show
  1. package/dev/keypress-debug-renderer.ts +148 -0
  2. package/dev/keypress-debug.ts +43 -0
  3. package/dev/print-env-vars.ts +32 -0
  4. package/dev/test-tmux-graphics-334.sh +68 -0
  5. package/dev/thai-debug-test.ts +68 -0
  6. package/docs/development.md +144 -0
  7. package/package.json +62 -53
  8. package/scripts/build.ts +400 -0
  9. package/scripts/publish.ts +60 -0
  10. package/src/3d/SpriteResourceManager.ts +286 -0
  11. package/src/3d/SpriteUtils.ts +70 -0
  12. package/src/3d/TextureUtils.ts +196 -0
  13. package/src/3d/ThreeRenderable.ts +197 -0
  14. package/src/3d/WGPURenderer.ts +294 -0
  15. package/src/3d/animation/ExplodingSpriteEffect.ts +513 -0
  16. package/src/3d/animation/PhysicsExplodingSpriteEffect.ts +429 -0
  17. package/src/3d/animation/SpriteAnimator.ts +633 -0
  18. package/src/3d/animation/SpriteParticleGenerator.ts +435 -0
  19. package/src/3d/canvas.ts +464 -0
  20. package/src/3d/index.ts +12 -0
  21. package/src/3d/physics/PlanckPhysicsAdapter.ts +72 -0
  22. package/src/3d/physics/RapierPhysicsAdapter.ts +66 -0
  23. package/src/3d/physics/physics-interface.ts +31 -0
  24. package/src/3d/shaders/supersampling.wgsl +201 -0
  25. package/src/3d.ts +3 -0
  26. package/src/NativeSpanFeed.ts +300 -0
  27. package/src/Renderable.ts +1704 -0
  28. package/src/__snapshots__/buffer.test.ts.snap +28 -0
  29. package/src/animation/Timeline.test.ts +2709 -0
  30. package/src/animation/Timeline.ts +598 -0
  31. package/src/ansi.ts +18 -0
  32. package/src/benchmark/attenuation-benchmark.ts +81 -0
  33. package/src/benchmark/colormatrix-benchmark.ts +128 -0
  34. package/src/benchmark/gain-benchmark.ts +80 -0
  35. package/src/benchmark/latest-all-bench-run.json +707 -0
  36. package/src/benchmark/latest-async-bench-run.json +336 -0
  37. package/src/benchmark/latest-default-bench-run.json +657 -0
  38. package/src/benchmark/latest-large-bench-run.json +707 -0
  39. package/src/benchmark/latest-quick-bench-run.json +207 -0
  40. package/src/benchmark/markdown-benchmark.ts +1796 -0
  41. package/src/benchmark/native-span-feed-async-benchmark.ts +355 -0
  42. package/src/benchmark/native-span-feed-benchmark.md +56 -0
  43. package/src/benchmark/native-span-feed-benchmark.ts +596 -0
  44. package/src/benchmark/native-span-feed-compare.ts +280 -0
  45. package/src/benchmark/renderer-benchmark.ts +754 -0
  46. package/src/benchmark/text-table-benchmark.ts +948 -0
  47. package/src/buffer.test.ts +291 -0
  48. package/src/buffer.ts +554 -0
  49. package/src/console.test.ts +612 -0
  50. package/src/console.ts +1254 -0
  51. package/src/edit-buffer.test.ts +1769 -0
  52. package/src/edit-buffer.ts +411 -0
  53. package/src/editor-view.test.ts +1032 -0
  54. package/src/editor-view.ts +284 -0
  55. package/src/examples/ascii-font-selection-demo.ts +245 -0
  56. package/src/examples/assets/Water_2_M_Normal.jpg +0 -0
  57. package/src/examples/assets/concrete.png +0 -0
  58. package/src/examples/assets/crate.png +0 -0
  59. package/src/examples/assets/crate_emissive.png +0 -0
  60. package/src/examples/assets/forrest_background.png +0 -0
  61. package/src/examples/assets/hast-example.json +1018 -0
  62. package/src/examples/assets/heart.png +0 -0
  63. package/src/examples/assets/main_char_heavy_attack.png +0 -0
  64. package/src/examples/assets/main_char_idle.png +0 -0
  65. package/src/examples/assets/main_char_jump_end.png +0 -0
  66. package/src/examples/assets/main_char_jump_landing.png +0 -0
  67. package/src/examples/assets/main_char_jump_start.png +0 -0
  68. package/src/examples/assets/main_char_run_loop.png +0 -0
  69. package/src/examples/assets/roughness_map.jpg +0 -0
  70. package/src/examples/build.ts +115 -0
  71. package/src/examples/code-demo.ts +924 -0
  72. package/src/examples/console-demo.ts +358 -0
  73. package/src/examples/core-plugin-slots-demo.ts +759 -0
  74. package/src/examples/diff-demo.ts +701 -0
  75. package/src/examples/draggable-three-demo.ts +259 -0
  76. package/src/examples/editor-demo.ts +322 -0
  77. package/src/examples/extmarks-demo.ts +196 -0
  78. package/src/examples/focus-restore-demo.ts +310 -0
  79. package/src/examples/fonts.ts +245 -0
  80. package/src/examples/fractal-shader-demo.ts +268 -0
  81. package/src/examples/framebuffer-demo.ts +674 -0
  82. package/src/examples/full-unicode-demo.ts +241 -0
  83. package/src/examples/golden-star-demo.ts +933 -0
  84. package/src/examples/grayscale-buffer-demo.ts +249 -0
  85. package/src/examples/hast-syntax-highlighting-demo.ts +129 -0
  86. package/src/examples/index.ts +926 -0
  87. package/src/examples/input-demo.ts +377 -0
  88. package/src/examples/input-select-layout-demo.ts +425 -0
  89. package/src/examples/install.sh +143 -0
  90. package/src/examples/keypress-debug-demo.ts +452 -0
  91. package/src/examples/lib/HexList.ts +122 -0
  92. package/src/examples/lib/PaletteGrid.ts +125 -0
  93. package/src/examples/lib/standalone-keys.ts +25 -0
  94. package/src/examples/lib/tab-controller.ts +243 -0
  95. package/src/examples/lights-phong-demo.ts +290 -0
  96. package/src/examples/link-demo.ts +220 -0
  97. package/src/examples/live-state-demo.ts +480 -0
  98. package/src/examples/markdown-demo.ts +725 -0
  99. package/src/examples/mouse-interaction-demo.ts +428 -0
  100. package/src/examples/nested-zindex-demo.ts +357 -0
  101. package/src/examples/opacity-example.ts +235 -0
  102. package/src/examples/opentui-demo.ts +1057 -0
  103. package/src/examples/physx-planck-2d-demo.ts +623 -0
  104. package/src/examples/physx-rapier-2d-demo.ts +655 -0
  105. package/src/examples/relative-positioning-demo.ts +323 -0
  106. package/src/examples/scroll-example.ts +214 -0
  107. package/src/examples/scrollbox-mouse-test.ts +112 -0
  108. package/src/examples/scrollbox-overlay-hit-test.ts +206 -0
  109. package/src/examples/select-demo.ts +237 -0
  110. package/src/examples/shader-cube-demo.ts +1015 -0
  111. package/src/examples/simple-layout-example.ts +591 -0
  112. package/src/examples/slider-demo.ts +617 -0
  113. package/src/examples/split-mode-demo.ts +453 -0
  114. package/src/examples/sprite-animation-demo.ts +443 -0
  115. package/src/examples/sprite-particle-generator-demo.ts +486 -0
  116. package/src/examples/static-sprite-demo.ts +193 -0
  117. package/src/examples/sticky-scroll-example.ts +308 -0
  118. package/src/examples/styled-text-demo.ts +282 -0
  119. package/src/examples/tab-select-demo.ts +219 -0
  120. package/src/examples/terminal-title.ts +29 -0
  121. package/src/examples/terminal.ts +305 -0
  122. package/src/examples/text-node-demo.ts +416 -0
  123. package/src/examples/text-selection-demo.ts +377 -0
  124. package/src/examples/text-table-demo.ts +503 -0
  125. package/src/examples/text-truncation-demo.ts +481 -0
  126. package/src/examples/text-wrap.ts +757 -0
  127. package/src/examples/texture-loading-demo.ts +259 -0
  128. package/src/examples/timeline-example.ts +670 -0
  129. package/src/examples/transparency-demo.ts +400 -0
  130. package/src/examples/vnode-composition-demo.ts +404 -0
  131. package/src/examples/wide-grapheme-overlay-demo.ts +280 -0
  132. package/src/index.ts +24 -0
  133. package/src/lib/KeyHandler.integration.test.ts +292 -0
  134. package/src/lib/KeyHandler.stopPropagation.test.ts +289 -0
  135. package/src/lib/KeyHandler.test.ts +662 -0
  136. package/src/lib/KeyHandler.ts +222 -0
  137. package/src/lib/RGBA.test.ts +984 -0
  138. package/src/lib/RGBA.ts +204 -0
  139. package/src/lib/ascii.font.ts +330 -0
  140. package/src/lib/border.test.ts +83 -0
  141. package/src/lib/border.ts +170 -0
  142. package/src/lib/bunfs.test.ts +27 -0
  143. package/src/lib/bunfs.ts +18 -0
  144. package/src/lib/clipboard.test.ts +41 -0
  145. package/src/lib/clipboard.ts +47 -0
  146. package/src/lib/clock.ts +35 -0
  147. package/src/lib/data-paths.test.ts +133 -0
  148. package/src/lib/data-paths.ts +109 -0
  149. package/src/lib/debounce.ts +106 -0
  150. package/src/lib/detect-links.test.ts +98 -0
  151. package/src/lib/detect-links.ts +56 -0
  152. package/src/lib/env.test.ts +228 -0
  153. package/src/lib/env.ts +209 -0
  154. package/src/lib/extmarks-history.ts +51 -0
  155. package/src/lib/extmarks-multiwidth.test.ts +322 -0
  156. package/src/lib/extmarks.test.ts +3457 -0
  157. package/src/lib/extmarks.ts +843 -0
  158. package/src/lib/fonts/block.json +405 -0
  159. package/src/lib/fonts/grid.json +265 -0
  160. package/src/lib/fonts/huge.json +741 -0
  161. package/src/lib/fonts/pallet.json +314 -0
  162. package/src/lib/fonts/shade.json +591 -0
  163. package/src/lib/fonts/slick.json +321 -0
  164. package/src/lib/fonts/tiny.json +69 -0
  165. package/src/lib/hast-styled-text.ts +59 -0
  166. package/src/lib/index.ts +21 -0
  167. package/src/lib/keymapping.test.ts +317 -0
  168. package/src/lib/keymapping.ts +115 -0
  169. package/src/lib/objects-in-viewport.test.ts +787 -0
  170. package/src/lib/objects-in-viewport.ts +153 -0
  171. package/src/lib/output.capture.ts +58 -0
  172. package/src/lib/parse.keypress-kitty.protocol.test.ts +340 -0
  173. package/src/lib/parse.keypress-kitty.test.ts +663 -0
  174. package/src/lib/parse.keypress-kitty.ts +439 -0
  175. package/src/lib/parse.keypress.test.ts +1849 -0
  176. package/src/lib/parse.keypress.ts +397 -0
  177. package/src/lib/parse.mouse.test.ts +552 -0
  178. package/src/lib/parse.mouse.ts +232 -0
  179. package/src/lib/paste.ts +16 -0
  180. package/src/lib/queue.ts +65 -0
  181. package/src/lib/renderable.validations.test.ts +87 -0
  182. package/src/lib/renderable.validations.ts +83 -0
  183. package/src/lib/scroll-acceleration.ts +98 -0
  184. package/src/lib/selection.ts +240 -0
  185. package/src/lib/singleton.ts +28 -0
  186. package/src/lib/stdin-parser.test.ts +2290 -0
  187. package/src/lib/stdin-parser.ts +1810 -0
  188. package/src/lib/styled-text.ts +178 -0
  189. package/src/lib/terminal-capability-detection.test.ts +202 -0
  190. package/src/lib/terminal-capability-detection.ts +79 -0
  191. package/src/lib/terminal-palette.test.ts +878 -0
  192. package/src/lib/terminal-palette.ts +383 -0
  193. package/src/lib/tree-sitter/assets/README.md +118 -0
  194. package/src/lib/tree-sitter/assets/update.ts +334 -0
  195. package/src/lib/tree-sitter/assets.d.ts +9 -0
  196. package/src/lib/tree-sitter/cache.test.ts +273 -0
  197. package/src/lib/tree-sitter/client.test.ts +1165 -0
  198. package/src/lib/tree-sitter/client.ts +607 -0
  199. package/src/lib/tree-sitter/default-parsers.ts +86 -0
  200. package/src/lib/tree-sitter/download-utils.ts +148 -0
  201. package/src/lib/tree-sitter/index.ts +28 -0
  202. package/src/lib/tree-sitter/parser.worker.ts +1042 -0
  203. package/src/lib/tree-sitter/parsers-config.ts +81 -0
  204. package/src/lib/tree-sitter/resolve-ft.test.ts +55 -0
  205. package/src/lib/tree-sitter/resolve-ft.ts +189 -0
  206. package/src/lib/tree-sitter/types.ts +82 -0
  207. package/src/lib/tree-sitter-styled-text.test.ts +1253 -0
  208. package/src/lib/tree-sitter-styled-text.ts +306 -0
  209. package/src/lib/validate-dir-name.ts +55 -0
  210. package/src/lib/yoga.options.test.ts +628 -0
  211. package/src/lib/yoga.options.ts +346 -0
  212. package/src/plugins/core-slot.ts +579 -0
  213. package/src/plugins/registry.ts +402 -0
  214. package/src/plugins/types.ts +46 -0
  215. package/src/post/effects.ts +930 -0
  216. package/src/post/filters.ts +489 -0
  217. package/src/post/matrices.ts +288 -0
  218. package/src/renderables/ASCIIFont.ts +219 -0
  219. package/src/renderables/Box.test.ts +205 -0
  220. package/src/renderables/Box.ts +326 -0
  221. package/src/renderables/Code.test.ts +2062 -0
  222. package/src/renderables/Code.ts +357 -0
  223. package/src/renderables/Diff.regression.test.ts +226 -0
  224. package/src/renderables/Diff.test.ts +3101 -0
  225. package/src/renderables/Diff.ts +1211 -0
  226. package/src/renderables/EditBufferRenderable.test.ts +288 -0
  227. package/src/renderables/EditBufferRenderable.ts +1166 -0
  228. package/src/renderables/FrameBuffer.ts +47 -0
  229. package/src/renderables/Input.test.ts +1228 -0
  230. package/src/renderables/Input.ts +247 -0
  231. package/src/renderables/LineNumberRenderable.ts +724 -0
  232. package/src/renderables/Markdown.ts +1393 -0
  233. package/src/renderables/ScrollBar.ts +422 -0
  234. package/src/renderables/ScrollBox.ts +883 -0
  235. package/src/renderables/Select.test.ts +1033 -0
  236. package/src/renderables/Select.ts +524 -0
  237. package/src/renderables/Slider.test.ts +456 -0
  238. package/src/renderables/Slider.ts +342 -0
  239. package/src/renderables/TabSelect.test.ts +197 -0
  240. package/src/renderables/TabSelect.ts +455 -0
  241. package/src/renderables/Text.selection-buffer.test.ts +123 -0
  242. package/src/renderables/Text.test.ts +2660 -0
  243. package/src/renderables/Text.ts +147 -0
  244. package/src/renderables/TextBufferRenderable.ts +518 -0
  245. package/src/renderables/TextNode.test.ts +1058 -0
  246. package/src/renderables/TextNode.ts +325 -0
  247. package/src/renderables/TextTable.test.ts +1421 -0
  248. package/src/renderables/TextTable.ts +1344 -0
  249. package/src/renderables/Textarea.ts +430 -0
  250. package/src/renderables/TimeToFirstDraw.ts +89 -0
  251. package/src/renderables/__snapshots__/Code.test.ts.snap +13 -0
  252. package/src/renderables/__snapshots__/Diff.test.ts.snap +785 -0
  253. package/src/renderables/__snapshots__/Text.test.ts.snap +421 -0
  254. package/src/renderables/__snapshots__/TextTable.test.ts.snap +215 -0
  255. package/src/renderables/__tests__/LineNumberRenderable.scrollbox-simple.test.ts +144 -0
  256. package/src/renderables/__tests__/LineNumberRenderable.scrollbox.test.ts +816 -0
  257. package/src/renderables/__tests__/LineNumberRenderable.test.ts +1865 -0
  258. package/src/renderables/__tests__/LineNumberRenderable.wrapping.test.ts +85 -0
  259. package/src/renderables/__tests__/Markdown.code-colors.test.ts +242 -0
  260. package/src/renderables/__tests__/Markdown.test.ts +2518 -0
  261. package/src/renderables/__tests__/MultiRenderable.selection.test.ts +87 -0
  262. package/src/renderables/__tests__/Textarea.buffer.test.ts +682 -0
  263. package/src/renderables/__tests__/Textarea.destroyed-events.test.ts +675 -0
  264. package/src/renderables/__tests__/Textarea.editing.test.ts +2041 -0
  265. package/src/renderables/__tests__/Textarea.error-handling.test.ts +35 -0
  266. package/src/renderables/__tests__/Textarea.events.test.ts +738 -0
  267. package/src/renderables/__tests__/Textarea.highlights.test.ts +590 -0
  268. package/src/renderables/__tests__/Textarea.keybinding.test.ts +3149 -0
  269. package/src/renderables/__tests__/Textarea.paste.test.ts +357 -0
  270. package/src/renderables/__tests__/Textarea.rendering.test.ts +1866 -0
  271. package/src/renderables/__tests__/Textarea.scroll.test.ts +733 -0
  272. package/src/renderables/__tests__/Textarea.selection.test.ts +1590 -0
  273. package/src/renderables/__tests__/Textarea.stress.test.ts +670 -0
  274. package/src/renderables/__tests__/Textarea.undo-redo.test.ts +383 -0
  275. package/src/renderables/__tests__/Textarea.visual-lines.test.ts +310 -0
  276. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.code.test.ts.snap +221 -0
  277. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.snap +89 -0
  278. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.snap +457 -0
  279. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.snap +158 -0
  280. package/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.snap +387 -0
  281. package/src/renderables/__tests__/markdown-parser.test.ts +217 -0
  282. package/src/renderables/__tests__/renderable-test-utils.ts +60 -0
  283. package/src/renderables/composition/README.md +8 -0
  284. package/src/renderables/composition/VRenderable.ts +32 -0
  285. package/src/renderables/composition/constructs.ts +127 -0
  286. package/src/renderables/composition/vnode.ts +289 -0
  287. package/src/renderables/index.ts +23 -0
  288. package/src/renderables/markdown-parser.ts +66 -0
  289. package/src/renderer.ts +2681 -0
  290. package/src/runtime-plugin-support.ts +39 -0
  291. package/src/runtime-plugin.ts +615 -0
  292. package/src/syntax-style.test.ts +841 -0
  293. package/src/syntax-style.ts +257 -0
  294. package/src/testing/README.md +210 -0
  295. package/src/testing/capture-spans.test.ts +194 -0
  296. package/src/testing/integration.test.ts +276 -0
  297. package/src/testing/manual-clock.ts +117 -0
  298. package/src/testing/mock-keys.test.ts +1378 -0
  299. package/src/testing/mock-keys.ts +457 -0
  300. package/src/testing/mock-mouse.test.ts +218 -0
  301. package/src/testing/mock-mouse.ts +247 -0
  302. package/src/testing/mock-tree-sitter-client.ts +73 -0
  303. package/src/testing/spy.ts +13 -0
  304. package/src/testing/test-recorder.test.ts +415 -0
  305. package/src/testing/test-recorder.ts +145 -0
  306. package/src/testing/test-renderer.ts +132 -0
  307. package/src/testing.ts +7 -0
  308. package/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.snap +481 -0
  309. package/src/tests/__snapshots__/renderable.snapshot.test.ts.snap +19 -0
  310. package/src/tests/__snapshots__/scrollbox.test.ts.snap +29 -0
  311. package/src/tests/absolute-positioning.snapshot.test.ts +638 -0
  312. package/src/tests/allocator-stats.test.ts +38 -0
  313. package/src/tests/destroy-during-render.test.ts +200 -0
  314. package/src/tests/destroy-on-exit.fixture.ts +36 -0
  315. package/src/tests/destroy-on-exit.test.ts +41 -0
  316. package/src/tests/hover-cursor.test.ts +98 -0
  317. package/src/tests/native-span-feed-async.test.ts +173 -0
  318. package/src/tests/native-span-feed-close.test.ts +120 -0
  319. package/src/tests/native-span-feed-coverage.test.ts +227 -0
  320. package/src/tests/native-span-feed-edge-cases.test.ts +352 -0
  321. package/src/tests/native-span-feed-use-after-free.test.ts +45 -0
  322. package/src/tests/opacity.test.ts +123 -0
  323. package/src/tests/renderable.snapshot.test.ts +524 -0
  324. package/src/tests/renderable.test.ts +1281 -0
  325. package/src/tests/renderer.clock.test.ts +158 -0
  326. package/src/tests/renderer.console-startup.test.ts +185 -0
  327. package/src/tests/renderer.control.test.ts +425 -0
  328. package/src/tests/renderer.core-slot-binding.test.ts +952 -0
  329. package/src/tests/renderer.cursor.test.ts +26 -0
  330. package/src/tests/renderer.destroy-during-render.test.ts +147 -0
  331. package/src/tests/renderer.focus-restore.test.ts +257 -0
  332. package/src/tests/renderer.focus.test.ts +294 -0
  333. package/src/tests/renderer.idle.test.ts +219 -0
  334. package/src/tests/renderer.input.test.ts +2237 -0
  335. package/src/tests/renderer.kitty-flags.test.ts +195 -0
  336. package/src/tests/renderer.mouse.test.ts +1274 -0
  337. package/src/tests/renderer.palette.test.ts +629 -0
  338. package/src/tests/renderer.selection.test.ts +49 -0
  339. package/src/tests/renderer.slot-registry.test.ts +684 -0
  340. package/src/tests/renderer.useMouse.test.ts +47 -0
  341. package/src/tests/runtime-plugin-node-modules-cycle.fixture.ts +76 -0
  342. package/src/tests/runtime-plugin-node-modules-mjs.fixture.ts +43 -0
  343. package/src/tests/runtime-plugin-node-modules-no-bare-rewrite.fixture.ts +67 -0
  344. package/src/tests/runtime-plugin-node-modules-package-type-cache.fixture.ts +72 -0
  345. package/src/tests/runtime-plugin-node-modules-runtime-specifier.fixture.ts +44 -0
  346. package/src/tests/runtime-plugin-node-modules-scoped-package-bare-rewrite.fixture.ts +85 -0
  347. package/src/tests/runtime-plugin-path-alias.fixture.ts +43 -0
  348. package/src/tests/runtime-plugin-resolve-roots.fixture.ts +65 -0
  349. package/src/tests/runtime-plugin-support.fixture.ts +11 -0
  350. package/src/tests/runtime-plugin-support.test.ts +19 -0
  351. package/src/tests/runtime-plugin-windows-file-url.fixture.ts +30 -0
  352. package/src/tests/runtime-plugin.fixture.ts +40 -0
  353. package/src/tests/runtime-plugin.test.ts +354 -0
  354. package/src/tests/scrollbox-culling-bug.test.ts +114 -0
  355. package/src/tests/scrollbox-hitgrid-resize.test.ts +136 -0
  356. package/src/tests/scrollbox-hitgrid.test.ts +909 -0
  357. package/src/tests/scrollbox.test.ts +1530 -0
  358. package/src/tests/wrap-resize-perf.test.ts +276 -0
  359. package/src/tests/yoga-setters.test.ts +921 -0
  360. package/src/text-buffer-view.test.ts +705 -0
  361. package/src/text-buffer-view.ts +189 -0
  362. package/src/text-buffer.test.ts +347 -0
  363. package/src/text-buffer.ts +250 -0
  364. package/src/types.ts +161 -0
  365. package/src/utils.ts +88 -0
  366. package/src/zig/ansi.zig +268 -0
  367. package/src/zig/bench/README.md +50 -0
  368. package/src/zig/bench/buffer-draw-text-buffer_bench.zig +887 -0
  369. package/src/zig/bench/edit-buffer_bench.zig +476 -0
  370. package/src/zig/bench/native-span-feed_bench.zig +100 -0
  371. package/src/zig/bench/rope-markers_bench.zig +713 -0
  372. package/src/zig/bench/rope_bench.zig +514 -0
  373. package/src/zig/bench/styled-text_bench.zig +470 -0
  374. package/src/zig/bench/text-buffer-coords_bench.zig +362 -0
  375. package/src/zig/bench/text-buffer-view_bench.zig +459 -0
  376. package/src/zig/bench/text-chunk-graphemes_bench.zig +273 -0
  377. package/src/zig/bench/utf8_bench.zig +799 -0
  378. package/src/zig/bench-utils.zig +431 -0
  379. package/src/zig/bench.zig +217 -0
  380. package/src/zig/buffer-methods.zig +211 -0
  381. package/src/zig/buffer.zig +2281 -0
  382. package/src/zig/build.zig +289 -0
  383. package/src/zig/build.zig.zon +16 -0
  384. package/src/zig/edit-buffer.zig +825 -0
  385. package/src/zig/editor-view.zig +802 -0
  386. package/src/zig/event-bus.zig +13 -0
  387. package/src/zig/event-emitter.zig +65 -0
  388. package/src/zig/file-logger.zig +92 -0
  389. package/src/zig/grapheme.zig +599 -0
  390. package/src/zig/lib.zig +1854 -0
  391. package/src/zig/link.zig +333 -0
  392. package/src/zig/logger.zig +43 -0
  393. package/src/zig/mem-registry.zig +125 -0
  394. package/src/zig/native-span-feed-bench-lib.zig +7 -0
  395. package/src/zig/native-span-feed.zig +708 -0
  396. package/src/zig/renderer.zig +1393 -0
  397. package/src/zig/rope.zig +1220 -0
  398. package/src/zig/syntax-style.zig +161 -0
  399. package/src/zig/terminal.zig +987 -0
  400. package/src/zig/test.zig +72 -0
  401. package/src/zig/tests/README.md +18 -0
  402. package/src/zig/tests/buffer-methods_test.zig +1109 -0
  403. package/src/zig/tests/buffer_test.zig +2557 -0
  404. package/src/zig/tests/edit-buffer-history_test.zig +271 -0
  405. package/src/zig/tests/edit-buffer_test.zig +1689 -0
  406. package/src/zig/tests/editor-view_test.zig +3299 -0
  407. package/src/zig/tests/event-emitter_test.zig +249 -0
  408. package/src/zig/tests/grapheme_test.zig +1304 -0
  409. package/src/zig/tests/link_test.zig +190 -0
  410. package/src/zig/tests/mem-registry_test.zig +473 -0
  411. package/src/zig/tests/memory_leak_regression_test.zig +159 -0
  412. package/src/zig/tests/native-span-feed_test.zig +1264 -0
  413. package/src/zig/tests/renderer_test.zig +1017 -0
  414. package/src/zig/tests/rope-nested_test.zig +712 -0
  415. package/src/zig/tests/rope_fuzz_test.zig +238 -0
  416. package/src/zig/tests/rope_test.zig +2362 -0
  417. package/src/zig/tests/segment-merge.test.zig +148 -0
  418. package/src/zig/tests/syntax-style_test.zig +557 -0
  419. package/src/zig/tests/terminal_test.zig +754 -0
  420. package/src/zig/tests/text-buffer-drawing_test.zig +3237 -0
  421. package/src/zig/tests/text-buffer-highlights_test.zig +666 -0
  422. package/src/zig/tests/text-buffer-iterators_test.zig +776 -0
  423. package/src/zig/tests/text-buffer-segment_test.zig +320 -0
  424. package/src/zig/tests/text-buffer-selection_test.zig +1035 -0
  425. package/src/zig/tests/text-buffer-selection_viewport_test.zig +358 -0
  426. package/src/zig/tests/text-buffer-view_test.zig +3649 -0
  427. package/src/zig/tests/text-buffer_test.zig +2191 -0
  428. package/src/zig/tests/unicode-width-map.zon +3909 -0
  429. package/src/zig/tests/utf8_no_zwj_test.zig +260 -0
  430. package/src/zig/tests/utf8_test.zig +4057 -0
  431. package/src/zig/tests/utf8_wcwidth_cursor_test.zig +267 -0
  432. package/src/zig/tests/utf8_wcwidth_test.zig +357 -0
  433. package/src/zig/tests/word-wrap-editing_test.zig +498 -0
  434. package/src/zig/tests/wrap-cache-perf_test.zig +113 -0
  435. package/src/zig/text-buffer-iterators.zig +499 -0
  436. package/src/zig/text-buffer-segment.zig +404 -0
  437. package/src/zig/text-buffer-view.zig +1371 -0
  438. package/src/zig/text-buffer.zig +1180 -0
  439. package/src/zig/utf8.zig +1948 -0
  440. package/src/zig/utils.zig +9 -0
  441. package/src/zig-structs.ts +261 -0
  442. package/src/zig.ts +3884 -0
  443. package/tsconfig.build.json +24 -0
  444. package/tsconfig.json +27 -0
  445. package/3d/SpriteResourceManager.d.ts +0 -74
  446. package/3d/SpriteUtils.d.ts +0 -13
  447. package/3d/TextureUtils.d.ts +0 -24
  448. package/3d/ThreeRenderable.d.ts +0 -40
  449. package/3d/WGPURenderer.d.ts +0 -61
  450. package/3d/animation/ExplodingSpriteEffect.d.ts +0 -71
  451. package/3d/animation/PhysicsExplodingSpriteEffect.d.ts +0 -76
  452. package/3d/animation/SpriteAnimator.d.ts +0 -124
  453. package/3d/animation/SpriteParticleGenerator.d.ts +0 -62
  454. package/3d/canvas.d.ts +0 -44
  455. package/3d/index.d.ts +0 -12
  456. package/3d/physics/PlanckPhysicsAdapter.d.ts +0 -19
  457. package/3d/physics/RapierPhysicsAdapter.d.ts +0 -19
  458. package/3d/physics/physics-interface.d.ts +0 -27
  459. package/3d.d.ts +0 -2
  460. package/3d.js +0 -34041
  461. package/3d.js.map +0 -155
  462. package/LICENSE +0 -21
  463. package/NativeSpanFeed.d.ts +0 -41
  464. package/Renderable.d.ts +0 -334
  465. package/animation/Timeline.d.ts +0 -126
  466. package/ansi.d.ts +0 -13
  467. package/buffer.d.ts +0 -111
  468. package/console.d.ts +0 -144
  469. package/edit-buffer.d.ts +0 -98
  470. package/editor-view.d.ts +0 -73
  471. package/index-9vwc3fg6.js +0 -12260
  472. package/index-9vwc3fg6.js.map +0 -42
  473. package/index-dcj62y8t.js +0 -20614
  474. package/index-dcj62y8t.js.map +0 -67
  475. package/index-f7n39gpy.js +0 -411
  476. package/index-f7n39gpy.js.map +0 -10
  477. package/index.d.ts +0 -23
  478. package/index.js +0 -478
  479. package/index.js.map +0 -9
  480. package/lib/KeyHandler.d.ts +0 -61
  481. package/lib/RGBA.d.ts +0 -25
  482. package/lib/ascii.font.d.ts +0 -508
  483. package/lib/border.d.ts +0 -51
  484. package/lib/bunfs.d.ts +0 -7
  485. package/lib/clipboard.d.ts +0 -17
  486. package/lib/clock.d.ts +0 -15
  487. package/lib/data-paths.d.ts +0 -26
  488. package/lib/debounce.d.ts +0 -42
  489. package/lib/detect-links.d.ts +0 -6
  490. package/lib/env.d.ts +0 -42
  491. package/lib/extmarks-history.d.ts +0 -17
  492. package/lib/extmarks.d.ts +0 -89
  493. package/lib/hast-styled-text.d.ts +0 -17
  494. package/lib/index.d.ts +0 -21
  495. package/lib/keymapping.d.ts +0 -25
  496. package/lib/objects-in-viewport.d.ts +0 -24
  497. package/lib/output.capture.d.ts +0 -24
  498. package/lib/parse.keypress-kitty.d.ts +0 -2
  499. package/lib/parse.keypress.d.ts +0 -26
  500. package/lib/parse.mouse.d.ts +0 -30
  501. package/lib/paste.d.ts +0 -7
  502. package/lib/queue.d.ts +0 -15
  503. package/lib/renderable.validations.d.ts +0 -12
  504. package/lib/scroll-acceleration.d.ts +0 -43
  505. package/lib/selection.d.ts +0 -63
  506. package/lib/singleton.d.ts +0 -7
  507. package/lib/stdin-parser.d.ts +0 -87
  508. package/lib/styled-text.d.ts +0 -63
  509. package/lib/terminal-capability-detection.d.ts +0 -30
  510. package/lib/terminal-palette.d.ts +0 -50
  511. package/lib/tree-sitter/assets/update.d.ts +0 -11
  512. package/lib/tree-sitter/client.d.ts +0 -47
  513. package/lib/tree-sitter/default-parsers.d.ts +0 -2
  514. package/lib/tree-sitter/download-utils.d.ts +0 -21
  515. package/lib/tree-sitter/index.d.ts +0 -8
  516. package/lib/tree-sitter/parser.worker.d.ts +0 -1
  517. package/lib/tree-sitter/parsers-config.d.ts +0 -53
  518. package/lib/tree-sitter/resolve-ft.d.ts +0 -5
  519. package/lib/tree-sitter/types.d.ts +0 -82
  520. package/lib/tree-sitter-styled-text.d.ts +0 -14
  521. package/lib/validate-dir-name.d.ts +0 -1
  522. package/lib/yoga.options.d.ts +0 -32
  523. package/parser.worker.js +0 -899
  524. package/parser.worker.js.map +0 -12
  525. package/plugins/core-slot.d.ts +0 -72
  526. package/plugins/registry.d.ts +0 -42
  527. package/plugins/types.d.ts +0 -34
  528. package/post/effects.d.ts +0 -147
  529. package/post/filters.d.ts +0 -65
  530. package/post/matrices.d.ts +0 -20
  531. package/renderables/ASCIIFont.d.ts +0 -52
  532. package/renderables/Box.d.ts +0 -81
  533. package/renderables/Code.d.ts +0 -78
  534. package/renderables/Diff.d.ts +0 -142
  535. package/renderables/EditBufferRenderable.d.ts +0 -237
  536. package/renderables/FrameBuffer.d.ts +0 -16
  537. package/renderables/Input.d.ts +0 -67
  538. package/renderables/LineNumberRenderable.d.ts +0 -78
  539. package/renderables/Markdown.d.ts +0 -185
  540. package/renderables/ScrollBar.d.ts +0 -77
  541. package/renderables/ScrollBox.d.ts +0 -124
  542. package/renderables/Select.d.ts +0 -115
  543. package/renderables/Slider.d.ts +0 -47
  544. package/renderables/TabSelect.d.ts +0 -96
  545. package/renderables/Text.d.ts +0 -36
  546. package/renderables/TextBufferRenderable.d.ts +0 -105
  547. package/renderables/TextNode.d.ts +0 -91
  548. package/renderables/TextTable.d.ts +0 -140
  549. package/renderables/Textarea.d.ts +0 -63
  550. package/renderables/TimeToFirstDraw.d.ts +0 -24
  551. package/renderables/__tests__/renderable-test-utils.d.ts +0 -12
  552. package/renderables/composition/VRenderable.d.ts +0 -16
  553. package/renderables/composition/constructs.d.ts +0 -35
  554. package/renderables/composition/vnode.d.ts +0 -46
  555. package/renderables/index.d.ts +0 -23
  556. package/renderables/markdown-parser.d.ts +0 -10
  557. package/renderer.d.ts +0 -419
  558. package/runtime-plugin-support.d.ts +0 -3
  559. package/runtime-plugin-support.js +0 -29
  560. package/runtime-plugin-support.js.map +0 -10
  561. package/runtime-plugin.d.ts +0 -16
  562. package/runtime-plugin.js +0 -16
  563. package/runtime-plugin.js.map +0 -9
  564. package/syntax-style.d.ts +0 -54
  565. package/testing/manual-clock.d.ts +0 -17
  566. package/testing/mock-keys.d.ts +0 -81
  567. package/testing/mock-mouse.d.ts +0 -38
  568. package/testing/mock-tree-sitter-client.d.ts +0 -23
  569. package/testing/spy.d.ts +0 -7
  570. package/testing/test-recorder.d.ts +0 -61
  571. package/testing/test-renderer.d.ts +0 -23
  572. package/testing.d.ts +0 -6
  573. package/testing.js +0 -697
  574. package/testing.js.map +0 -15
  575. package/text-buffer-view.d.ts +0 -42
  576. package/text-buffer.d.ts +0 -67
  577. package/types.d.ts +0 -139
  578. package/utils.d.ts +0 -14
  579. package/zig-structs.d.ts +0 -155
  580. package/zig.d.ts +0 -353
  581. /package/{assets → src/lib/tree-sitter/assets}/javascript/highlights.scm +0 -0
  582. /package/{assets → src/lib/tree-sitter/assets}/javascript/tree-sitter-javascript.wasm +0 -0
  583. /package/{assets → src/lib/tree-sitter/assets}/markdown/highlights.scm +0 -0
  584. /package/{assets → src/lib/tree-sitter/assets}/markdown/injections.scm +0 -0
  585. /package/{assets → src/lib/tree-sitter/assets}/markdown/tree-sitter-markdown.wasm +0 -0
  586. /package/{assets → src/lib/tree-sitter/assets}/markdown_inline/highlights.scm +0 -0
  587. /package/{assets → src/lib/tree-sitter/assets}/markdown_inline/tree-sitter-markdown_inline.wasm +0 -0
  588. /package/{assets → src/lib/tree-sitter/assets}/typescript/highlights.scm +0 -0
  589. /package/{assets → src/lib/tree-sitter/assets}/typescript/tree-sitter-typescript.wasm +0 -0
  590. /package/{assets → src/lib/tree-sitter/assets}/zig/highlights.scm +0 -0
  591. /package/{assets → src/lib/tree-sitter/assets}/zig/tree-sitter-zig.wasm +0 -0
@@ -0,0 +1,2660 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from "bun:test"
2
+ import { TextRenderable, type TextOptions } from "./Text.js"
3
+ import { TextNodeRenderable } from "./TextNode.js"
4
+ import { RGBA } from "../lib/RGBA.js"
5
+ import { stringToStyledText, StyledText } from "../lib/styled-text.js"
6
+ import { createTestRenderer, type MockMouse, type TestRenderer } from "../testing/test-renderer.js"
7
+ import { BoxRenderable } from "./Box.js"
8
+
9
+ let currentRenderer: TestRenderer
10
+ let renderOnce: () => Promise<void>
11
+ let currentMouse: MockMouse
12
+ let captureFrame: () => string
13
+ let resize: (width: number, height: number) => void
14
+
15
+ async function createTextRenderable(
16
+ renderer: TestRenderer,
17
+ options: TextOptions,
18
+ ): Promise<{ text: TextRenderable; root: any }> {
19
+ const textRenderable = new TextRenderable(renderer, { left: 0, top: 0, ...options })
20
+ renderer.root.add(textRenderable)
21
+ await renderOnce()
22
+
23
+ return { text: textRenderable, root: renderer.root }
24
+ }
25
+
26
+ describe("TextRenderable Selection", () => {
27
+ describe("Native getSelectedText", () => {
28
+ it("should use native implementation", async () => {
29
+ const { text } = await createTextRenderable(currentRenderer, {
30
+ content: "Hello World",
31
+ selectable: true,
32
+ })
33
+
34
+ await currentMouse.drag(text.x, text.y, text.x + 5, text.y)
35
+ await renderOnce()
36
+
37
+ const selectedText = text.getSelectedText()
38
+ expect(selectedText).toBe("Hello")
39
+ })
40
+
41
+ it("should handle graphemes correctly", async () => {
42
+ const { text } = await createTextRenderable(currentRenderer, {
43
+ content: "Hello 🌍 World",
44
+ selectable: true,
45
+ })
46
+
47
+ // Select "Hello 🌍" (7 characters: H,e,l,l,o, ,🌍)
48
+ await currentMouse.drag(text.x, text.y, text.x + 7, text.y)
49
+ await renderOnce()
50
+
51
+ const selectedText = text.getSelectedText()
52
+ expect(selectedText).toBe("Hello 🌍")
53
+ })
54
+ })
55
+
56
+ beforeEach(async () => {
57
+ ;({
58
+ renderer: currentRenderer,
59
+ renderOnce,
60
+ mockMouse: currentMouse,
61
+ captureCharFrame: captureFrame,
62
+ resize,
63
+ } = await createTestRenderer({
64
+ width: 20,
65
+ height: 5,
66
+ }))
67
+ })
68
+
69
+ afterEach(() => {
70
+ currentRenderer.destroy()
71
+ })
72
+
73
+ describe("Initialization", () => {
74
+ it("should initialize properly", async () => {
75
+ const { text } = await createTextRenderable(currentRenderer, {
76
+ content: "Hello World",
77
+ selectable: true,
78
+ })
79
+
80
+ expect(text.x).toBeDefined()
81
+ expect(text.y).toBeDefined()
82
+ expect(text.width).toBeGreaterThan(0)
83
+ expect(text.height).toBeGreaterThan(0)
84
+ })
85
+ })
86
+
87
+ describe("Basic Selection Flow", () => {
88
+ it("should handle selection from start to end", async () => {
89
+ const { text } = await createTextRenderable(currentRenderer, {
90
+ content: "Hello World",
91
+ selectable: true,
92
+ })
93
+
94
+ expect(text.hasSelection()).toBe(false)
95
+ expect(text.getSelection()).toBe(null)
96
+ expect(text.getSelectedText()).toBe("")
97
+
98
+ expect(text.shouldStartSelection(6, 0)).toBe(true)
99
+
100
+ await currentMouse.drag(text.x + 6, text.y, text.x + 11, text.y)
101
+ await renderOnce()
102
+
103
+ expect(text.hasSelection()).toBe(true)
104
+
105
+ const selection = text.getSelection()
106
+ expect(selection).not.toBe(null)
107
+ expect(selection!.start).toBe(6)
108
+ expect(selection!.end).toBe(11)
109
+
110
+ expect(text.getSelectedText()).toBe("World")
111
+ })
112
+
113
+ it("should handle selection with newline characters", async () => {
114
+ const { text } = await createTextRenderable(currentRenderer, {
115
+ content: "Line 1\nLine 2\nLine 3",
116
+ selectable: true,
117
+ })
118
+
119
+ // Select from middle of line 2 to middle of line 3
120
+ await currentMouse.drag(text.x + 2, text.y + 1, text.x + 4, text.y + 2)
121
+ await renderOnce()
122
+
123
+ const selection = text.getSelection()
124
+ expect(selection).not.toBe(null)
125
+ // With newline-aware offsets: Line 0 (0-5) + newline (6) + Line 1 starts at 7
126
+ // Position "n" in "Line 2" is at 7 + 2 = 9
127
+ expect(selection!.start).toBe(9)
128
+ // Line 2 starts at 14, position after "Line" is 14 + 4 = 18
129
+ expect(selection!.end).toBe(18)
130
+
131
+ expect(text.getSelectedText()).toBe("ne 2\nLine")
132
+ })
133
+
134
+ it("should handle selection across empty lines", async () => {
135
+ const { text } = await createTextRenderable(currentRenderer, {
136
+ content: "Line 1\nLine 2\n\nLine 4",
137
+ selectable: true,
138
+ })
139
+
140
+ // Select from start of line 1 to position 2 on empty line 3
141
+ await currentMouse.drag(text.x, text.y, text.x + 2, text.y + 2)
142
+ await renderOnce()
143
+
144
+ const selection = text.getSelection()
145
+ expect(selection).not.toBe(null)
146
+ // With newline-aware offsets: Line 0 (0-5) + newline (6) + Line 1 (7-12) + newline (13) + Line 2 empty (14)
147
+ // Selecting to (col=2, row=2) on empty line clamps to col=0, so end=14
148
+ expect(selection!.start).toBe(0)
149
+ expect(selection!.end).toBe(14)
150
+ expect(text.getSelectedText()).toBe("Line 1\nLine 2")
151
+ })
152
+
153
+ it("should handle selection ending in empty line", async () => {
154
+ const { text } = await createTextRenderable(currentRenderer, {
155
+ content: "Line 1\n\nLine 3",
156
+ selectable: true,
157
+ })
158
+
159
+ // Select from start of line 1 into the empty line 2
160
+ await currentMouse.drag(text.x, text.y, text.x + 3, text.y + 1)
161
+ await renderOnce()
162
+
163
+ const selection = text.getSelection()
164
+ expect(selection).not.toBe(null)
165
+ // With newline-aware offsets: Line 0 (0-5) + newline (6) + Line 1 empty (7)
166
+ // Selecting to (col=3, row=1) on empty line clamps to col=0, so end=7
167
+ expect(selection!.start).toBe(0)
168
+ expect(selection!.end).toBe(7)
169
+ expect(text.getSelectedText()).toBe("Line 1")
170
+ })
171
+
172
+ it("should handle selection spanning multiple lines completely", async () => {
173
+ const { text } = await createTextRenderable(currentRenderer, {
174
+ content: "First\nSecond\nThird",
175
+ selectable: true,
176
+ })
177
+
178
+ // Select from start of line 1 to end of line 2 (actually selecting Second)
179
+ await currentMouse.drag(text.x, text.y + 1, text.x + 6, text.y + 1)
180
+ await renderOnce()
181
+
182
+ const selection = text.getSelection()
183
+ expect(selection).not.toBe(null)
184
+ expect(text.getSelectedText()).toBe("Second")
185
+ })
186
+
187
+ it("should handle selection including multiple line breaks", async () => {
188
+ const { text } = await createTextRenderable(currentRenderer, {
189
+ content: "A\nB\nC\nD",
190
+ selectable: true,
191
+ })
192
+
193
+ // Select from middle of first line to middle of last line
194
+ await currentMouse.drag(text.x, text.y + 1, text.x + 1, text.y + 2)
195
+ await renderOnce()
196
+
197
+ const selection = text.getSelection()
198
+ expect(selection).not.toBe(null)
199
+ const selectedText = text.getSelectedText()
200
+ expect(selectedText).toContain("\n")
201
+ expect(selectedText).toContain("B")
202
+ expect(selectedText).toContain("C")
203
+ })
204
+
205
+ it("should handle selection that includes line breaks at boundaries", async () => {
206
+ const { text } = await createTextRenderable(currentRenderer, {
207
+ content: "Line1\nLine2\nLine3",
208
+ selectable: true,
209
+ })
210
+
211
+ // Select across line boundaries
212
+ await currentMouse.drag(text.x + 4, text.y, text.x + 2, text.y + 1)
213
+ await renderOnce()
214
+
215
+ const selection = text.getSelection()
216
+ expect(selection).not.toBe(null)
217
+ const selectedText = text.getSelectedText()
218
+ expect(selectedText).toContain("1")
219
+ expect(selectedText).toContain("\n")
220
+ expect(selectedText).toContain("Li")
221
+ })
222
+
223
+ it("should handle reverse selection (end before start)", async () => {
224
+ const { text } = await createTextRenderable(currentRenderer, {
225
+ content: "Hello World",
226
+ selectable: true,
227
+ })
228
+
229
+ await currentMouse.drag(text.x + 11, text.y, text.x + 6, text.y)
230
+ await renderOnce()
231
+
232
+ const selection = text.getSelection()
233
+ expect(selection).not.toBe(null)
234
+ expect(selection!.start).toBe(6)
235
+ expect(selection!.end).toBe(11)
236
+
237
+ expect(text.getSelectedText()).toBe("World")
238
+ })
239
+ })
240
+
241
+ describe("Selection Edge Cases", () => {
242
+ it("should handle empty text", async () => {
243
+ const { text } = await createTextRenderable(currentRenderer, {
244
+ content: "",
245
+ selectable: true,
246
+ })
247
+
248
+ await currentMouse.drag(text.x, text.y, text.x, text.y)
249
+ await renderOnce()
250
+
251
+ expect(text.hasSelection()).toBe(false)
252
+ expect(text.getSelection()).toBe(null)
253
+ expect(text.getSelectedText()).toBe("")
254
+ })
255
+
256
+ it("should handle single character selection", async () => {
257
+ const { text } = await createTextRenderable(currentRenderer, {
258
+ content: "A",
259
+ selectable: true,
260
+ })
261
+
262
+ await currentMouse.drag(text.x, text.y, text.x + 1, text.y)
263
+ await renderOnce()
264
+
265
+ const selection = text.getSelection()
266
+ expect(selection).not.toBe(null)
267
+ expect(selection!.start).toBe(0)
268
+ expect(selection!.end).toBe(1)
269
+
270
+ expect(text.getSelectedText()).toBe("A")
271
+ })
272
+
273
+ it("should handle zero-width selection", async () => {
274
+ const { text } = await createTextRenderable(currentRenderer, {
275
+ content: "Hello World",
276
+ selectable: true,
277
+ })
278
+
279
+ await currentMouse.drag(text.x + 5, text.y, text.x + 5, text.y)
280
+ await renderOnce()
281
+
282
+ expect(text.hasSelection()).toBe(false)
283
+ expect(text.getSelection()).toBe(null)
284
+ expect(text.getSelectedText()).toBe("")
285
+ })
286
+
287
+ it("should handle selection beyond text bounds", async () => {
288
+ const { text } = await createTextRenderable(currentRenderer, {
289
+ content: "Hi",
290
+ selectable: true,
291
+ })
292
+
293
+ await currentMouse.drag(text.x, text.y, text.x + 10, text.y)
294
+ await renderOnce()
295
+
296
+ const selection = text.getSelection()
297
+ expect(selection).not.toBe(null)
298
+ expect(selection!.start).toBe(0)
299
+ expect(selection!.end).toBe(2)
300
+
301
+ expect(text.getSelectedText()).toBe("Hi")
302
+ })
303
+ })
304
+
305
+ describe("Selection with Styled Text", () => {
306
+ it("should handle styled text selection", async () => {
307
+ const styledText = stringToStyledText("Hello World")
308
+ styledText.chunks[0].fg = RGBA.fromValues(1, 0, 0, 1) // Red text
309
+
310
+ const { text } = await createTextRenderable(currentRenderer, {
311
+ content: styledText,
312
+ selectable: true,
313
+ })
314
+
315
+ await currentMouse.drag(text.x + 6, text.y, text.x + 11, text.y)
316
+ await renderOnce()
317
+
318
+ const selection = text.getSelection()
319
+ expect(selection).not.toBe(null)
320
+ expect(selection!.start).toBe(6)
321
+ expect(selection!.end).toBe(11)
322
+
323
+ expect(text.getSelectedText()).toBe("World")
324
+ })
325
+
326
+ it("should handle selection with different text colors", async () => {
327
+ const { text } = await createTextRenderable(currentRenderer, {
328
+ content: "Red and Blue",
329
+ selectable: true,
330
+ selectionBg: RGBA.fromValues(1, 1, 0, 1),
331
+ selectionFg: RGBA.fromValues(0, 0, 0, 1),
332
+ })
333
+
334
+ await currentMouse.drag(text.x + 8, text.y, text.x + 12, text.y)
335
+ await renderOnce()
336
+
337
+ const selection = text.getSelection()
338
+ expect(selection).not.toBe(null)
339
+ expect(selection!.start).toBe(8)
340
+ expect(selection!.end).toBe(12)
341
+
342
+ expect(text.getSelectedText()).toBe("Blue")
343
+ })
344
+ })
345
+
346
+ describe("Selection State Management", () => {
347
+ it("should clear selection when selection is cleared", async () => {
348
+ const { text } = await createTextRenderable(currentRenderer, {
349
+ content: "Hello World",
350
+ selectable: true,
351
+ })
352
+
353
+ await currentMouse.drag(text.x + 6, text.y, text.x + 11, text.y)
354
+ await renderOnce()
355
+ expect(text.hasSelection()).toBe(true)
356
+
357
+ currentRenderer.clearSelection()
358
+ await renderOnce()
359
+
360
+ expect(text.hasSelection()).toBe(false)
361
+ expect(text.getSelection()).toBe(null)
362
+ expect(text.getSelectedText()).toBe("")
363
+ })
364
+
365
+ it("should handle multiple selection changes", async () => {
366
+ const { text } = await createTextRenderable(currentRenderer, {
367
+ content: "Hello World Test",
368
+ selectable: true,
369
+ })
370
+
371
+ await currentMouse.drag(text.x + 0, text.y, text.x + 5, text.y)
372
+ await renderOnce()
373
+ expect(text.getSelectedText()).toBe("Hello")
374
+ expect(text.getSelection()).toEqual({ start: 0, end: 5 })
375
+
376
+ await currentMouse.drag(text.x + 6, text.y, text.x + 11, text.y)
377
+ await renderOnce()
378
+ expect(text.getSelectedText()).toBe("World")
379
+ expect(text.getSelection()).toEqual({ start: 6, end: 11 })
380
+
381
+ await currentMouse.drag(text.x + 12, text.y, text.x + 16, text.y)
382
+ await renderOnce()
383
+ expect(text.getSelectedText()).toBe("Test")
384
+ expect(text.getSelection()).toEqual({ start: 12, end: 16 })
385
+ })
386
+ })
387
+
388
+ describe("shouldStartSelection", () => {
389
+ it("should return false for non-selectable text", async () => {
390
+ const { text } = await createTextRenderable(currentRenderer, {
391
+ content: "Hello World",
392
+ selectable: false,
393
+ })
394
+
395
+ expect(text.shouldStartSelection(0, 0)).toBe(false)
396
+ expect(text.shouldStartSelection(5, 0)).toBe(false)
397
+ })
398
+
399
+ it("should return true for selectable text within bounds", async () => {
400
+ const { text } = await createTextRenderable(currentRenderer, {
401
+ content: "Hello World",
402
+ selectable: true,
403
+ })
404
+
405
+ expect(text.shouldStartSelection(0, 0)).toBe(true) // Start of text
406
+ expect(text.shouldStartSelection(5, 0)).toBe(true) // Middle of text
407
+ expect(text.shouldStartSelection(10, 0)).toBe(true) // End of text
408
+ })
409
+
410
+ it("should handle shouldStartSelection with multi-line text", async () => {
411
+ const { text } = await createTextRenderable(currentRenderer, {
412
+ content: "Line 1\nLine 2\nLine 3",
413
+ selectable: true,
414
+ })
415
+
416
+ expect(text.shouldStartSelection(0, 0)).toBe(true) // Line 1 start
417
+ expect(text.shouldStartSelection(2, 1)).toBe(true) // Line 2 middle
418
+ expect(text.shouldStartSelection(5, 2)).toBe(true) // Line 3 end
419
+ })
420
+ })
421
+
422
+ describe("Selection with Custom Dimensions", () => {
423
+ it("should handle selection in constrained width", async () => {
424
+ const { text } = await createTextRenderable(currentRenderer, {
425
+ content: "This is a very long text that should wrap to multiple lines",
426
+ width: 10,
427
+ selectable: true,
428
+ })
429
+
430
+ await currentMouse.drag(text.x, text.y, text.x + 10, text.y + 2)
431
+ await renderOnce()
432
+
433
+ const selection = text.getSelection()
434
+ expect(selection).not.toBe(null)
435
+ expect(selection!.start).toBeGreaterThanOrEqual(0)
436
+ expect(selection!.end).toBeGreaterThan(selection!.start)
437
+ expect(text.getSelectedText().length).toBeGreaterThan(0)
438
+ })
439
+ })
440
+
441
+ describe("Cross-Renderable Selection in Nested Boxes", () => {
442
+ it("should handle selection across multiple nested text renderables in boxes", async () => {
443
+ const { text: statusText } = await createTextRenderable(currentRenderer, {
444
+ content: "Selected 5 chars:",
445
+ selectable: true,
446
+ fg: "#f0f6fc",
447
+ top: 0,
448
+ })
449
+
450
+ const { text: selectionStartText } = await createTextRenderable(currentRenderer, {
451
+ content: '"Hello"',
452
+ selectable: true,
453
+ fg: "#7dd3fc",
454
+ top: 1,
455
+ })
456
+
457
+ const { text: selectionMiddleText } = await createTextRenderable(currentRenderer, {
458
+ content: "",
459
+ selectable: true,
460
+ fg: "#94a3b8",
461
+ top: 2,
462
+ })
463
+
464
+ const { text: selectionEndText } = await createTextRenderable(currentRenderer, {
465
+ content: "",
466
+ selectable: true,
467
+ fg: "#7dd3fc",
468
+ top: 3,
469
+ })
470
+
471
+ const { text: debugText } = await createTextRenderable(currentRenderer, {
472
+ content: "Selected renderables: 2/5",
473
+ selectable: true,
474
+ fg: "#e6edf3",
475
+ top: 4,
476
+ })
477
+
478
+ // Simulate starting selection above the box and ending below/right of the box
479
+ // This should cover all renderables in the "box"
480
+ const allRenderables = [statusText, selectionStartText, selectionMiddleText, selectionEndText, debugText]
481
+
482
+ await currentMouse.drag(0, 0, 50, 10)
483
+ await renderOnce()
484
+
485
+ expect(statusText.hasSelection()).toBe(true)
486
+ expect(statusText.getSelectedText()).toBe("Selected 5 chars:")
487
+
488
+ expect(selectionStartText.hasSelection()).toBe(true)
489
+ expect(selectionStartText.getSelectedText()).toBe('"Hello"')
490
+
491
+ // Empty text renderables should not have selections since there's no content to select
492
+ expect(selectionMiddleText.hasSelection()).toBe(false)
493
+ expect(selectionMiddleText.getSelectedText()).toBe("")
494
+
495
+ expect(selectionEndText.hasSelection()).toBe(false)
496
+ expect(selectionEndText.getSelectedText()).toBe("")
497
+
498
+ expect(debugText.hasSelection()).toBe(true)
499
+ expect(debugText.getSelectedText()).toBe("Selected renderables: 2/5")
500
+
501
+ const globalSelectedText = currentRenderer.getSelection()?.getSelectedText()
502
+
503
+ expect(globalSelectedText).toContain("Selected 5 chars:")
504
+ expect(globalSelectedText).toContain('"Hello"')
505
+ expect(globalSelectedText).toContain("Selected renderables: 2/5")
506
+ })
507
+
508
+ it("should automatically update selection when text content changes within covered area", async () => {
509
+ const { text: statusText } = await createTextRenderable(currentRenderer, {
510
+ content: "Selected 5 chars:",
511
+ selectable: true,
512
+ fg: "#f0f6fc",
513
+ top: 0,
514
+ wrapMode: "none",
515
+ })
516
+
517
+ const { text: selectionStartText } = await createTextRenderable(currentRenderer, {
518
+ top: 1,
519
+ content: '"Hello"',
520
+ selectable: true,
521
+ fg: "#7dd3fc",
522
+ wrapMode: "none",
523
+ })
524
+
525
+ const { text: debugText } = await createTextRenderable(currentRenderer, {
526
+ top: 2,
527
+ content: "Selected renderables: 2/5",
528
+ selectable: true,
529
+ fg: "#e6edf3",
530
+ wrapMode: "none",
531
+ })
532
+
533
+ await currentMouse.drag(0, 0, 50, 5)
534
+ await renderOnce()
535
+
536
+ expect(statusText.getSelectedText()).toBe("Selected 5 chars:")
537
+ expect(selectionStartText.getSelectedText()).toBe('"Hello"')
538
+ expect(debugText.getSelectedText()).toBe("Selected renderables: 2/5")
539
+
540
+ selectionStartText.content = '"Hello World Extended Selection"'
541
+
542
+ expect(statusText.getSelectedText()).toBe("Selected 5 chars:")
543
+ expect(selectionStartText.getSelectedText()).toBe('"Hello World Extended Selection"')
544
+ expect(debugText.getSelectedText()).toBe("Selected renderables: 2/5")
545
+
546
+ const updatedGlobalSelectedText = currentRenderer.getSelection()?.getSelectedText()
547
+
548
+ expect(updatedGlobalSelectedText).toContain('"Hello World Extended Selection"')
549
+ expect(updatedGlobalSelectedText).toContain("Selected 5 chars:")
550
+ expect(updatedGlobalSelectedText).toContain("Selected renderables: 2/5")
551
+
552
+ debugText.content = "Selected renderables: 3/5 | Container: statusBox"
553
+
554
+ expect(debugText.getSelectedText()).toBe("Selected renderables: 3/5 | Container: statusBox")
555
+
556
+ const finalGlobalSelectedText = currentRenderer.getSelection()?.getSelectedText()
557
+
558
+ expect(finalGlobalSelectedText).toContain("Selected renderables: 3/5 | Container: statusBox")
559
+ })
560
+
561
+ it("should automatically update selection when text node content changes with clear and add", async () => {
562
+ const { text: statusText } = await createTextRenderable(currentRenderer, {
563
+ content: "",
564
+ selectable: true,
565
+ fg: "#f0f6fc",
566
+ top: 0,
567
+ wrapMode: "none",
568
+ })
569
+
570
+ const statusNode = new TextNodeRenderable({})
571
+ statusNode.add("Selected 5 chars:")
572
+ statusText.add(statusNode)
573
+
574
+ const { text: selectionStartText } = await createTextRenderable(currentRenderer, {
575
+ content: "",
576
+ selectable: true,
577
+ fg: "#7dd3fc",
578
+ top: 1,
579
+ wrapMode: "none",
580
+ })
581
+
582
+ const selectionNode = new TextNodeRenderable({})
583
+ selectionNode.add('"Hello"')
584
+ selectionStartText.add(selectionNode)
585
+
586
+ const { text: debugText } = await createTextRenderable(currentRenderer, {
587
+ content: "",
588
+ selectable: true,
589
+ fg: "#e6edf3",
590
+ top: 2,
591
+ wrapMode: "none",
592
+ })
593
+
594
+ const debugNode = new TextNodeRenderable({})
595
+ debugNode.add("Selected renderables: 2/5")
596
+ debugText.add(debugNode)
597
+
598
+ await currentMouse.drag(0, 0, 50, 5)
599
+ await renderOnce()
600
+
601
+ expect(statusText.getSelectedText()).toBe("Selected 5 chars:")
602
+ expect(selectionStartText.getSelectedText()).toBe('"Hello"')
603
+ expect(debugText.getSelectedText()).toBe("Selected renderables: 2/5")
604
+
605
+ // Clear and add new content to the selection node
606
+ selectionNode.clear()
607
+ selectionNode.add('"Hello World Extended Selection"')
608
+ await renderOnce()
609
+
610
+ expect(statusText.getSelectedText()).toBe("Selected 5 chars:")
611
+ expect(selectionStartText.getSelectedText()).toBe('"Hello World Extended Selection"')
612
+ expect(debugText.getSelectedText()).toBe("Selected renderables: 2/5")
613
+
614
+ const updatedGlobalSelectedText = currentRenderer.getSelection()?.getSelectedText()
615
+
616
+ expect(updatedGlobalSelectedText).toContain('"Hello World Extended Selection"')
617
+ expect(updatedGlobalSelectedText).toContain("Selected 5 chars:")
618
+ expect(updatedGlobalSelectedText).toContain("Selected renderables: 2/5")
619
+
620
+ // Clear and add new content to the debug node
621
+ debugNode.clear()
622
+ debugNode.add("Selected renderables: 3/5 | Container: statusBox")
623
+ await renderOnce()
624
+
625
+ expect(debugText.getSelectedText()).toBe("Selected renderables: 3/5 | Container: statusBox")
626
+
627
+ const finalGlobalSelectedText = currentRenderer.getSelection()?.getSelectedText()
628
+
629
+ expect(finalGlobalSelectedText).toContain("Selected renderables: 3/5 | Container: statusBox")
630
+ })
631
+
632
+ it("should handle selection that starts above box and ends below/right of box", async () => {
633
+ const { text: statusText } = await createTextRenderable(currentRenderer, {
634
+ content: "Status: Selection active",
635
+ selectable: true,
636
+ fg: "#f0f6fc",
637
+ top: 2,
638
+ wrapMode: "none",
639
+ })
640
+
641
+ const { text: selectionStartText } = await createTextRenderable(currentRenderer, {
642
+ content: "Start: (10,5)",
643
+ selectable: true,
644
+ fg: "#7dd3fc",
645
+ top: 3,
646
+ wrapMode: "none",
647
+ })
648
+
649
+ const { text: selectionEndText } = await createTextRenderable(currentRenderer, {
650
+ content: "End: (45,12)",
651
+ selectable: true,
652
+ fg: "#7dd3fc",
653
+ top: 4,
654
+ wrapMode: "none",
655
+ })
656
+
657
+ const { text: debugText } = await createTextRenderable(currentRenderer, {
658
+ content: "Debug: Cross-renderable selection spanning 3 elements",
659
+ selectable: true,
660
+ fg: "#e6edf3",
661
+ top: 5,
662
+ wrapMode: "none",
663
+ })
664
+
665
+ const allRenderables = [statusText, selectionStartText, selectionEndText, debugText]
666
+
667
+ await currentMouse.drag(statusText.x, statusText.y, 60, 10)
668
+ await renderOnce()
669
+
670
+ allRenderables.forEach((renderable) => {
671
+ expect(renderable.hasSelection()).toBe(true)
672
+ })
673
+
674
+ expect(statusText.getSelectedText()).toBe("Status: Selection active")
675
+ expect(selectionStartText.getSelectedText()).toBe("Start: (10,5)")
676
+ expect(selectionEndText.getSelectedText()).toBe("End: (45,12)")
677
+ expect(debugText.getSelectedText()).toBe("Debug: Cross-renderable selection spanning 3 elements")
678
+
679
+ const globalSelectedText = currentRenderer.getSelection()?.getSelectedText()
680
+
681
+ expect(globalSelectedText).toContain("Status: Selection active")
682
+ expect(globalSelectedText).toContain("Start: (10,5)")
683
+ expect(globalSelectedText).toContain("End: (45,12)")
684
+ expect(globalSelectedText).toContain("Debug: Cross-renderable selection spanning 3 elements")
685
+ })
686
+ })
687
+
688
+ describe("TextNode Integration with getPlainText", () => {
689
+ it("should render correct plain text after adding TextNodes", async () => {
690
+ const { text, root } = await createTextRenderable(currentRenderer, {
691
+ content: "",
692
+ selectable: true,
693
+ })
694
+
695
+ const node1 = new TextNodeRenderable({
696
+ fg: RGBA.fromValues(1, 0, 0, 1),
697
+ bg: RGBA.fromValues(0, 0, 0, 1),
698
+ })
699
+ node1.add("Hello")
700
+
701
+ const node2 = new TextNodeRenderable({
702
+ fg: RGBA.fromValues(0, 1, 0, 1),
703
+ bg: RGBA.fromValues(0, 0, 0, 1),
704
+ })
705
+ node2.add(" World")
706
+
707
+ text.add(node1)
708
+ text.add(node2)
709
+
710
+ await renderOnce()
711
+
712
+ expect(text.plainText).toBe("Hello World")
713
+ })
714
+
715
+ it("should render correct plain text after inserting TextNodes", async () => {
716
+ const { text, root } = await createTextRenderable(currentRenderer, {
717
+ content: "",
718
+ selectable: true,
719
+ })
720
+
721
+ const node1 = new TextNodeRenderable({})
722
+ node1.add("Hello")
723
+
724
+ const node2 = new TextNodeRenderable({})
725
+ node2.add(" World")
726
+
727
+ const node3 = new TextNodeRenderable({})
728
+ node3.add("!")
729
+
730
+ text.add(node1)
731
+ text.add(node2)
732
+
733
+ text.insertBefore(node3, node2)
734
+
735
+ await renderOnce()
736
+
737
+ expect(text.plainText).toBe("Hello! World")
738
+ })
739
+
740
+ it("should render correct plain text after removing TextNodes", async () => {
741
+ const { text, root } = await createTextRenderable(currentRenderer, {
742
+ content: "",
743
+ selectable: true,
744
+ })
745
+
746
+ const node1 = new TextNodeRenderable({})
747
+ node1.add("Hello")
748
+
749
+ const node2 = new TextNodeRenderable({})
750
+ node2.add(" Cruel")
751
+
752
+ const node3 = new TextNodeRenderable({})
753
+ node3.add(" World")
754
+
755
+ text.add(node1)
756
+ text.add(node2)
757
+ text.add(node3)
758
+
759
+ await renderOnce()
760
+ expect(text.plainText).toBe("Hello Cruel World")
761
+
762
+ text.remove(node2.id)
763
+
764
+ await renderOnce()
765
+
766
+ expect(text.plainText).toBe("Hello World")
767
+ })
768
+
769
+ it("should handle simple add and remove operations", async () => {
770
+ const { text, root } = await createTextRenderable(currentRenderer, {
771
+ content: "",
772
+ selectable: true,
773
+ })
774
+
775
+ const node = new TextNodeRenderable({})
776
+ node.add("Test")
777
+
778
+ text.add(node)
779
+
780
+ await renderOnce()
781
+ expect(text.plainText).toBe("Test")
782
+
783
+ text.remove(node.id)
784
+
785
+ await renderOnce()
786
+ expect(text.plainText).toBe("")
787
+ })
788
+
789
+ it("should render correct plain text after clearing all TextNodes", async () => {
790
+ const { text, root } = await createTextRenderable(currentRenderer, {
791
+ content: "",
792
+ selectable: true,
793
+ })
794
+
795
+ const node1 = new TextNodeRenderable({})
796
+ node1.add("Hello")
797
+
798
+ const node2 = new TextNodeRenderable({})
799
+ node2.add(" World")
800
+
801
+ text.add(node1)
802
+ text.add(node2)
803
+
804
+ await renderOnce()
805
+ expect(text.plainText).toBe("Hello World")
806
+
807
+ text.clear()
808
+
809
+ await renderOnce()
810
+
811
+ expect(text.plainText).toBe("")
812
+ })
813
+
814
+ it("should handle nested TextNode structures correctly", async () => {
815
+ const { text, root } = await createTextRenderable(currentRenderer, {
816
+ content: "",
817
+ selectable: true,
818
+ })
819
+
820
+ // Create nested structure: Parent -> [Child1, Child2]
821
+ const parent = new TextNodeRenderable({
822
+ fg: RGBA.fromValues(1, 1, 0, 1),
823
+ })
824
+
825
+ const child1 = new TextNodeRenderable({
826
+ fg: RGBA.fromValues(1, 0, 0, 1),
827
+ })
828
+ child1.add("Red")
829
+
830
+ const child2 = new TextNodeRenderable({
831
+ fg: RGBA.fromValues(0, 1, 0, 1),
832
+ })
833
+ child2.add(" Green")
834
+
835
+ parent.add(child1)
836
+ parent.add(child2)
837
+
838
+ const standalone = new TextNodeRenderable({
839
+ fg: RGBA.fromValues(0, 0, 1, 1),
840
+ })
841
+ standalone.add(" Blue")
842
+
843
+ text.add(parent)
844
+ text.add(standalone)
845
+
846
+ await renderOnce()
847
+
848
+ expect(text.plainText).toBe("Red Green Blue")
849
+ })
850
+
851
+ it("should handle mixed string and TextNode content", async () => {
852
+ const { text, root } = await createTextRenderable(currentRenderer, {
853
+ content: "",
854
+ selectable: true,
855
+ })
856
+
857
+ const startNode = new TextNodeRenderable({})
858
+ startNode.add("Start ")
859
+
860
+ const node1 = new TextNodeRenderable({})
861
+ node1.add("middle")
862
+
863
+ const node2 = new TextNodeRenderable({})
864
+ node2.add(" end")
865
+
866
+ text.add(startNode)
867
+ text.add(node1)
868
+ text.add(node2)
869
+
870
+ await renderOnce()
871
+
872
+ expect(text.plainText).toBe("Start middle end")
873
+ })
874
+
875
+ it("should handle TextNode operations with inherited styles", async () => {
876
+ const { text, root } = await createTextRenderable(currentRenderer, {
877
+ content: "",
878
+ selectable: true,
879
+ fg: RGBA.fromValues(1, 1, 1, 1), // White default
880
+ })
881
+
882
+ const redParent = new TextNodeRenderable({
883
+ fg: RGBA.fromValues(1, 0, 0, 1), // Red
884
+ })
885
+
886
+ const redChild = new TextNodeRenderable({})
887
+
888
+ const greenGrandchild = new TextNodeRenderable({
889
+ fg: RGBA.fromValues(0, 1, 0, 1), // Green
890
+ })
891
+ greenGrandchild.add("Green")
892
+
893
+ redChild.add(greenGrandchild)
894
+ redParent.add(redChild)
895
+
896
+ const blueNode = new TextNodeRenderable({
897
+ fg: RGBA.fromValues(0, 0, 1, 1), // Blue
898
+ })
899
+ blueNode.add(" Blue")
900
+
901
+ text.add(redParent)
902
+ text.add(blueNode)
903
+
904
+ await renderOnce()
905
+
906
+ expect(text.plainText).toBe("Green Blue")
907
+ })
908
+
909
+ it("should handle empty TextNodes correctly", async () => {
910
+ const { text, root } = await createTextRenderable(currentRenderer, {
911
+ content: "",
912
+ selectable: true,
913
+ })
914
+
915
+ const emptyNode1 = new TextNodeRenderable({})
916
+ const nodeWithText = new TextNodeRenderable({})
917
+ nodeWithText.add("Text")
918
+ const emptyNode2 = new TextNodeRenderable({})
919
+
920
+ text.add(emptyNode1)
921
+ text.add(nodeWithText)
922
+ text.add(emptyNode2)
923
+
924
+ await renderOnce()
925
+
926
+ expect(text.plainText).toBe("Text")
927
+ })
928
+
929
+ it("should handle complex TextNode operations sequence", async () => {
930
+ const { text, root } = await createTextRenderable(currentRenderer, {
931
+ content: "",
932
+ selectable: true,
933
+ })
934
+
935
+ const initialNode = new TextNodeRenderable({})
936
+ initialNode.add("Initial")
937
+
938
+ const nodeA = new TextNodeRenderable({})
939
+ nodeA.add(" A")
940
+
941
+ const nodeB = new TextNodeRenderable({})
942
+ nodeB.add(" B")
943
+
944
+ const nodeC = new TextNodeRenderable({})
945
+ nodeC.add(" C")
946
+
947
+ const nodeD = new TextNodeRenderable({})
948
+ nodeD.add(" D")
949
+
950
+ text.add(initialNode)
951
+ text.add(nodeA)
952
+ text.add(nodeB)
953
+ text.add(nodeC)
954
+ text.add(nodeD)
955
+
956
+ await renderOnce()
957
+ expect(text.plainText).toBe("Initial A B C D")
958
+
959
+ text.remove(nodeB.id)
960
+
961
+ await renderOnce()
962
+ expect(text.plainText).toBe("Initial A C D")
963
+
964
+ const nodeX = new TextNodeRenderable({})
965
+ nodeX.add(" X")
966
+ text.insertBefore(nodeX, nodeC)
967
+
968
+ await renderOnce()
969
+ expect(text.plainText).toBe("Initial A X C D")
970
+
971
+ nodeX.add(" Y")
972
+
973
+ await renderOnce()
974
+ expect(text.plainText).toBe("Initial A X Y C D")
975
+ })
976
+
977
+ it("should inherit fg/bg colors from TextRenderable to TextNode children", async () => {
978
+ const { text, root } = await createTextRenderable(currentRenderer, {
979
+ content: "",
980
+ selectable: true,
981
+ fg: RGBA.fromValues(1, 0, 0, 1),
982
+ bg: RGBA.fromValues(0, 0, 1, 1),
983
+ })
984
+
985
+ const child1 = new TextNodeRenderable({})
986
+ child1.add("Child1")
987
+
988
+ const child2 = new TextNodeRenderable({})
989
+ child2.add(" Child2")
990
+
991
+ text.add(child1)
992
+ text.add(child2)
993
+
994
+ await renderOnce()
995
+
996
+ expect(text.plainText).toBe("Child1 Child2")
997
+
998
+ const chunks = text.textNode.gatherWithInheritedStyle()
999
+
1000
+ expect(chunks).toHaveLength(2)
1001
+
1002
+ chunks.forEach((chunk) => {
1003
+ expect(chunk.fg).toEqual(RGBA.fromValues(1, 0, 0, 1))
1004
+ expect(chunk.bg).toEqual(RGBA.fromValues(0, 0, 1, 1))
1005
+ expect(chunk.attributes).toBe(0)
1006
+ })
1007
+
1008
+ expect(chunks[0].text).toBe("Child1")
1009
+ expect(chunks[1].text).toBe(" Child2")
1010
+ })
1011
+
1012
+ it("should allow TextNode children to override parent TextRenderable colors", async () => {
1013
+ const { text, root } = await createTextRenderable(currentRenderer, {
1014
+ content: "",
1015
+ selectable: true,
1016
+ fg: RGBA.fromValues(1, 0, 0, 1),
1017
+ bg: RGBA.fromValues(0, 0, 1, 1),
1018
+ })
1019
+
1020
+ const inheritingChild = new TextNodeRenderable({})
1021
+ inheritingChild.add("Inherit")
1022
+
1023
+ const overridingChild = new TextNodeRenderable({
1024
+ fg: RGBA.fromValues(0, 1, 0, 1),
1025
+ bg: RGBA.fromValues(1, 1, 0, 1),
1026
+ })
1027
+ overridingChild.add(" Override")
1028
+
1029
+ const partialOverrideChild = new TextNodeRenderable({
1030
+ fg: RGBA.fromValues(0, 0, 1, 1),
1031
+ })
1032
+ partialOverrideChild.add(" Partial")
1033
+
1034
+ text.add(inheritingChild)
1035
+ text.add(overridingChild)
1036
+ text.add(partialOverrideChild)
1037
+
1038
+ await renderOnce()
1039
+
1040
+ expect(text.plainText).toBe("Inherit Override Partial")
1041
+
1042
+ const chunks = text.textNode.gatherWithInheritedStyle()
1043
+
1044
+ expect(chunks).toHaveLength(3)
1045
+
1046
+ // First child: inherits both fg and bg from parent
1047
+ expect(chunks[0].text).toBe("Inherit")
1048
+ expect(chunks[0].fg).toEqual(RGBA.fromValues(1, 0, 0, 1))
1049
+ expect(chunks[0].bg).toEqual(RGBA.fromValues(0, 0, 1, 1))
1050
+
1051
+ // Second child: overrides both fg and bg
1052
+ expect(chunks[1].text).toBe(" Override")
1053
+ expect(chunks[1].fg).toEqual(RGBA.fromValues(0, 1, 0, 1))
1054
+ expect(chunks[1].bg).toEqual(RGBA.fromValues(1, 1, 0, 1))
1055
+
1056
+ // Third child: overrides fg, inherits bg
1057
+ expect(chunks[2].text).toBe(" Partial")
1058
+ expect(chunks[2].fg).toEqual(RGBA.fromValues(0, 0, 1, 1))
1059
+ expect(chunks[2].bg).toEqual(RGBA.fromValues(0, 0, 1, 1))
1060
+ })
1061
+
1062
+ it("should inherit TextRenderable colors through nested TextNode hierarchies", async () => {
1063
+ const { text, root } = await createTextRenderable(currentRenderer, {
1064
+ content: "",
1065
+ selectable: true,
1066
+ fg: RGBA.fromValues(0, 1, 0, 1),
1067
+ bg: RGBA.fromValues(0, 0, 0, 1),
1068
+ })
1069
+
1070
+ const grandparent = new TextNodeRenderable({})
1071
+ const parent = new TextNodeRenderable({})
1072
+ const child = new TextNodeRenderable({})
1073
+
1074
+ child.add("Deep")
1075
+ parent.add("Nested ")
1076
+ parent.add(child)
1077
+ grandparent.add("Very ")
1078
+ grandparent.add(parent)
1079
+
1080
+ text.add(grandparent)
1081
+
1082
+ await renderOnce()
1083
+
1084
+ expect(text.plainText).toBe("Very Nested Deep")
1085
+
1086
+ const chunks = text.textNode.gatherWithInheritedStyle()
1087
+
1088
+ expect(chunks).toHaveLength(3)
1089
+
1090
+ // All chunks should inherit the TextRenderable's green fg and black bg
1091
+ chunks.forEach((chunk) => {
1092
+ expect(chunk.fg).toEqual(RGBA.fromValues(0, 1, 0, 1))
1093
+ expect(chunk.bg).toEqual(RGBA.fromValues(0, 0, 0, 1))
1094
+ expect(chunk.attributes).toBe(0)
1095
+ })
1096
+
1097
+ expect(chunks[0].text).toBe("Very ")
1098
+ expect(chunks[1].text).toBe("Nested ")
1099
+ expect(chunks[2].text).toBe("Deep")
1100
+ })
1101
+
1102
+ it("should handle TextRenderable color changes affecting existing TextNode children", async () => {
1103
+ const { text, root } = await createTextRenderable(currentRenderer, {
1104
+ content: "",
1105
+ selectable: true,
1106
+ fg: RGBA.fromValues(1, 0, 0, 1),
1107
+ bg: RGBA.fromValues(0, 0, 0, 1),
1108
+ })
1109
+
1110
+ const child1 = new TextNodeRenderable({})
1111
+ child1.add("Before")
1112
+
1113
+ const child2 = new TextNodeRenderable({})
1114
+ child2.add(" Change")
1115
+
1116
+ text.add(child1)
1117
+ text.add(child2)
1118
+
1119
+ await renderOnce()
1120
+ expect(text.plainText).toBe("Before Change")
1121
+
1122
+ text.fg = RGBA.fromValues(0, 0, 1, 1)
1123
+ text.bg = RGBA.fromValues(1, 1, 1, 1)
1124
+
1125
+ await renderOnce()
1126
+
1127
+ const chunks = text.textNode.gatherWithInheritedStyle()
1128
+
1129
+ expect(chunks).toHaveLength(2)
1130
+
1131
+ chunks.forEach((chunk) => {
1132
+ expect(chunk.fg).toEqual(RGBA.fromValues(0, 0, 1, 1))
1133
+ expect(chunk.bg).toEqual(RGBA.fromValues(1, 1, 1, 1))
1134
+ })
1135
+
1136
+ expect(chunks[0].text).toBe("Before")
1137
+ expect(chunks[1].text).toBe(" Change")
1138
+ })
1139
+
1140
+ it("should handle TextNode commands with multiple operations per render", async () => {
1141
+ const { text, root } = await createTextRenderable(currentRenderer, {
1142
+ content: "",
1143
+ selectable: true,
1144
+ })
1145
+
1146
+ const node1 = new TextNodeRenderable({})
1147
+ node1.add("First")
1148
+
1149
+ const node2 = new TextNodeRenderable({})
1150
+ node2.add("Second")
1151
+
1152
+ const node3 = new TextNodeRenderable({})
1153
+ node3.add("Third")
1154
+
1155
+ text.add(node1)
1156
+ text.add(node2)
1157
+ text.insertBefore(node3, node1)
1158
+
1159
+ node2.add(" Modified")
1160
+
1161
+ await renderOnce()
1162
+
1163
+ expect(text.plainText).toBe("ThirdFirstSecond Modified")
1164
+ })
1165
+ })
1166
+
1167
+ describe("StyledText Integration", () => {
1168
+ it("should render StyledText content correctly", async () => {
1169
+ const styledText = stringToStyledText("Hello World")
1170
+
1171
+ styledText.chunks[0].fg = RGBA.fromValues(1, 0, 0, 1) // Red text
1172
+ styledText.chunks[0].bg = RGBA.fromValues(0, 0, 0, 1) // Black background
1173
+
1174
+ const { text } = await createTextRenderable(currentRenderer, {
1175
+ content: styledText,
1176
+ selectable: true,
1177
+ })
1178
+
1179
+ await renderOnce()
1180
+
1181
+ expect(text.plainText).toBe("Hello World")
1182
+ expect(text.width).toBeGreaterThan(0)
1183
+ expect(text.height).toBeGreaterThan(0)
1184
+ })
1185
+
1186
+ it("should handle selection with StyledText content", async () => {
1187
+ const styledText = stringToStyledText("Hello World")
1188
+ styledText.chunks[0].fg = RGBA.fromValues(1, 0, 0, 1) // Red text
1189
+
1190
+ const { text } = await createTextRenderable(currentRenderer, {
1191
+ content: styledText,
1192
+ selectable: true,
1193
+ })
1194
+
1195
+ await currentMouse.drag(text.x + 6, text.y, text.x + 11, text.y)
1196
+ await renderOnce()
1197
+
1198
+ const selection = text.getSelection()
1199
+ expect(selection).not.toBe(null)
1200
+ expect(selection!.start).toBe(6)
1201
+ expect(selection!.end).toBe(11)
1202
+ expect(text.getSelectedText()).toBe("World")
1203
+ })
1204
+
1205
+ it("should handle empty StyledText", async () => {
1206
+ const emptyStyledText = stringToStyledText("")
1207
+
1208
+ const { text, root } = await createTextRenderable(currentRenderer, {
1209
+ content: emptyStyledText,
1210
+ selectable: true,
1211
+ })
1212
+
1213
+ await renderOnce()
1214
+
1215
+ expect(text.plainText).toBe("")
1216
+ expect(text.hasSelection()).toBe(false)
1217
+ expect(text.getSelectedText()).toBe("")
1218
+ })
1219
+
1220
+ it("should handle StyledText with multiple chunks", async () => {
1221
+ const styledText = new StyledText([
1222
+ { __isChunk: true, text: "Red", fg: RGBA.fromValues(1, 0, 0, 1), attributes: 1 },
1223
+ { __isChunk: true, text: " ", fg: undefined, attributes: 0 },
1224
+ { __isChunk: true, text: "Green", fg: RGBA.fromValues(0, 1, 0, 1), attributes: 2 },
1225
+ { __isChunk: true, text: " ", fg: undefined, attributes: 0 },
1226
+ { __isChunk: true, text: "Blue", fg: RGBA.fromValues(0, 0, 1, 1), attributes: 0 },
1227
+ ])
1228
+
1229
+ const { text } = await createTextRenderable(currentRenderer, {
1230
+ content: styledText,
1231
+ selectable: true,
1232
+ })
1233
+
1234
+ await renderOnce()
1235
+
1236
+ expect(text.plainText).toBe("Red Green Blue")
1237
+
1238
+ await currentMouse.drag(text.x + 4, text.y, text.x + 9, text.y)
1239
+ await renderOnce()
1240
+
1241
+ expect(text.getSelectedText()).toBe("Green")
1242
+ })
1243
+
1244
+ it("should handle StyledText with TextNodeRenderable children", async () => {
1245
+ const { text } = await createTextRenderable(currentRenderer, {
1246
+ content: "",
1247
+ selectable: true,
1248
+ })
1249
+
1250
+ const baseNode = new TextNodeRenderable({})
1251
+ baseNode.add("Base ")
1252
+ text.add(baseNode)
1253
+
1254
+ const styledNode = new TextNodeRenderable({
1255
+ fg: RGBA.fromValues(1, 0, 0, 1),
1256
+ })
1257
+
1258
+ const nodeStyledText = new StyledText([
1259
+ { __isChunk: true, text: "Styled", fg: RGBA.fromValues(0, 1, 0, 1), attributes: 1 },
1260
+ ])
1261
+
1262
+ styledNode.add(nodeStyledText)
1263
+ text.add(styledNode)
1264
+
1265
+ await renderOnce()
1266
+
1267
+ expect(text.plainText).toBe("Base Styled")
1268
+
1269
+ await currentMouse.drag(text.x + 5, text.y, text.x + 11, text.y)
1270
+ await renderOnce()
1271
+ expect(text.getSelectedText()).toBe("Styled")
1272
+ })
1273
+ })
1274
+
1275
+ describe("Text Selection with Truncation", () => {
1276
+ it("should not extend selection across ellipsis in single line", async () => {
1277
+ const buffer = currentRenderer.currentRenderBuffer
1278
+ const { text } = await createTextRenderable(currentRenderer, {
1279
+ content: "0123456789ABCDEFGHIJ",
1280
+ width: 10,
1281
+ height: 1,
1282
+ selectable: true,
1283
+ selectionBg: RGBA.fromValues(1, 0, 0, 1),
1284
+ truncate: true,
1285
+ })
1286
+
1287
+ await currentMouse.drag(text.x + 6, text.y, text.x + 3, text.y)
1288
+ await renderOnce()
1289
+
1290
+ expect(text.hasSelection()).toBe(true)
1291
+
1292
+ const { bg } = buffer.buffers
1293
+ const bufferWidth = buffer.width
1294
+
1295
+ const ellipsisIdx = text.y * bufferWidth + text.x + 3
1296
+ const ellipsisBgR = bg[ellipsisIdx * 4 + 0]
1297
+ const ellipsisBgG = bg[ellipsisIdx * 4 + 1]
1298
+ const ellipsisBgB = bg[ellipsisIdx * 4 + 2]
1299
+
1300
+ expect(Math.abs(ellipsisBgR - 1.0)).toBeLessThan(0.05)
1301
+ expect(Math.abs(ellipsisBgG - 0.0)).toBeLessThan(0.05)
1302
+ expect(Math.abs(ellipsisBgB - 0.0)).toBeLessThan(0.05)
1303
+ })
1304
+
1305
+ it("should render selection end correctly across ellipsis in last line", async () => {
1306
+ const buffer = currentRenderer.currentRenderBuffer
1307
+ const { text } = await createTextRenderable(currentRenderer, {
1308
+ content: "Line 1: This is a long line without wrapping\nLine 2: Another very long line that will be truncated",
1309
+ width: 10,
1310
+ height: 2,
1311
+ selectable: true,
1312
+ selectionBg: RGBA.fromValues(1, 0, 0, 1),
1313
+ truncate: true,
1314
+ wrapMode: "none",
1315
+ })
1316
+
1317
+ await currentMouse.drag(text.x + 6, text.y, text.x + 2, text.y + 1)
1318
+ await renderOnce()
1319
+
1320
+ expect(text.hasSelection()).toBe(true)
1321
+
1322
+ const { bg } = buffer.buffers
1323
+ const bufferWidth = buffer.width
1324
+
1325
+ const ellipsisIdx = (text.y + 1) * bufferWidth + text.x + 3
1326
+ const ellipsisBgR = bg[ellipsisIdx * 4 + 0]
1327
+ const ellipsisBgG = bg[ellipsisIdx * 4 + 1]
1328
+ const ellipsisBgB = bg[ellipsisIdx * 4 + 2]
1329
+
1330
+ expect(Math.abs(ellipsisBgR - 1.0)).toBeGreaterThan(0.05)
1331
+ expect(Math.abs(ellipsisBgG - 0.0)).toBeLessThan(0.05)
1332
+ expect(Math.abs(ellipsisBgB - 0.0)).toBeLessThan(0.05)
1333
+ })
1334
+ })
1335
+
1336
+ describe("Text Content Snapshots", () => {
1337
+ it("should render basic text content correctly", async () => {
1338
+ await createTextRenderable(currentRenderer, {
1339
+ content: "Hello World",
1340
+ left: 5,
1341
+ top: 3,
1342
+ })
1343
+
1344
+ const frame = captureFrame()
1345
+ expect(frame).toMatchSnapshot()
1346
+ })
1347
+
1348
+ it("should render multiline text content correctly", async () => {
1349
+ await createTextRenderable(currentRenderer, {
1350
+ content: "Line 1: Hello\nLine 2: World\nLine 3: Testing\nLine 4: Multiline",
1351
+ left: 1,
1352
+ top: 1,
1353
+ })
1354
+
1355
+ const frame = captureFrame()
1356
+ expect(frame).toMatchSnapshot()
1357
+ })
1358
+
1359
+ it("should render text with graphemes/emojis correctly", async () => {
1360
+ await createTextRenderable(currentRenderer, {
1361
+ content: "Hello 🌍 World 👋\n Test 🚀 Emoji",
1362
+ left: 0,
1363
+ top: 2,
1364
+ })
1365
+
1366
+ const frame = captureFrame()
1367
+ expect(frame).toMatchSnapshot()
1368
+ })
1369
+
1370
+ it("should render TextNode text composition correctly", async () => {
1371
+ const { text } = await createTextRenderable(currentRenderer, {
1372
+ content: "",
1373
+ left: 0,
1374
+ top: 0,
1375
+ })
1376
+
1377
+ const node1 = new TextNodeRenderable({})
1378
+ node1.add("First")
1379
+
1380
+ const node2 = new TextNodeRenderable({})
1381
+ node2.add(" Second")
1382
+
1383
+ const node3 = new TextNodeRenderable({})
1384
+ node3.add(" Third")
1385
+
1386
+ text.add(node1)
1387
+ text.add(node2)
1388
+ text.add(node3)
1389
+
1390
+ await renderOnce()
1391
+
1392
+ const frame = captureFrame()
1393
+ expect(frame).toMatchSnapshot()
1394
+ })
1395
+
1396
+ it("should render text positioning correctly", async () => {
1397
+ await createTextRenderable(currentRenderer, {
1398
+ content: "Top",
1399
+ position: "absolute",
1400
+ left: 0,
1401
+ top: 0,
1402
+ })
1403
+
1404
+ await createTextRenderable(currentRenderer, {
1405
+ content: "Mid",
1406
+ position: "absolute",
1407
+ left: 8,
1408
+ top: 2,
1409
+ })
1410
+
1411
+ await createTextRenderable(currentRenderer, {
1412
+ content: "Bot",
1413
+ position: "absolute",
1414
+ left: 16,
1415
+ top: 4,
1416
+ })
1417
+
1418
+ const frame = captureFrame()
1419
+ expect(frame).toMatchSnapshot()
1420
+ })
1421
+
1422
+ it("should render empty buffer correctly", async () => {
1423
+ currentRenderer.currentRenderBuffer.clear()
1424
+ const frame = captureFrame()
1425
+ expect(frame).toMatchSnapshot()
1426
+ })
1427
+
1428
+ it("should render text with character wrapping correctly", async () => {
1429
+ const { text } = await createTextRenderable(currentRenderer, {
1430
+ content: "This is a very long text that should wrap to multiple lines when wrap is enabled",
1431
+ wrapMode: "char", // Explicitly test character wrapping
1432
+ width: 15, // Force wrapping at 15 characters width
1433
+ left: 0,
1434
+ top: 0,
1435
+ })
1436
+
1437
+ const frame = captureFrame()
1438
+ expect(frame).toMatchSnapshot()
1439
+ })
1440
+
1441
+ it("should render wrapped text with different content", async () => {
1442
+ await createTextRenderable(currentRenderer, {
1443
+ content: "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789",
1444
+ wrapMode: "char", // Explicitly test character wrapping
1445
+ width: 10, // Force wrapping at 10 characters width
1446
+ left: 2,
1447
+ top: 1,
1448
+ })
1449
+
1450
+ const frame = captureFrame()
1451
+ expect(frame).toMatchSnapshot()
1452
+ })
1453
+
1454
+ it("should render wrapped text with emojis and graphemes", async () => {
1455
+ await createTextRenderable(currentRenderer, {
1456
+ content: "Hello 🌍 World 👋 This is a test with emojis 🚀 that should wrap properly",
1457
+ wrapMode: "char", // Explicitly test character wrapping
1458
+ width: 12, // Force wrapping at 12 characters width
1459
+ left: 1,
1460
+ top: 0,
1461
+ })
1462
+
1463
+ const frame = captureFrame()
1464
+ expect(frame).toMatchSnapshot()
1465
+ })
1466
+
1467
+ it("should render wrapped multiline text correctly", async () => {
1468
+ await createTextRenderable(currentRenderer, {
1469
+ content: "First line with long content\nSecond line also with content\nThird line",
1470
+ wrapMode: "char", // Explicitly test character wrapping
1471
+ width: 8, // Force wrapping at 8 characters width
1472
+ left: 0,
1473
+ top: 1,
1474
+ })
1475
+
1476
+ const frame = captureFrame()
1477
+ expect(frame).toMatchSnapshot()
1478
+ })
1479
+
1480
+ it("should render text with tab indicator correctly", async () => {
1481
+ await createTextRenderable(currentRenderer, {
1482
+ content: "Line 1\tTabbed\nLine 2\t\tDouble tab",
1483
+ tabIndicator: "→",
1484
+ tabIndicatorColor: RGBA.fromValues(0.5, 0.5, 0.5, 1),
1485
+ left: 0,
1486
+ top: 0,
1487
+ })
1488
+
1489
+ const frame = captureFrame()
1490
+ expect(frame).toMatchSnapshot()
1491
+ })
1492
+
1493
+ it("should render word wrapped text with CJK and English correctly", async () => {
1494
+ resize(60, 10)
1495
+
1496
+ const { text } = await createTextRenderable(currentRenderer, {
1497
+ content: "🌟 Unicode test: こんにちは世界 Hello World 你好世界",
1498
+ wrapMode: "word",
1499
+ width: 35,
1500
+ left: 0,
1501
+ top: 0,
1502
+ })
1503
+
1504
+ await renderOnce()
1505
+
1506
+ const frame = captureFrame()
1507
+ const lines = frame.split("\n").filter((l) => l.trim().length > 0)
1508
+
1509
+ // Verify no character duplication - each character should appear only once
1510
+ const line0 = lines[0] || ""
1511
+ const line1 = lines[1] || ""
1512
+
1513
+ const line0_ends_with_kai = line0.trimEnd().endsWith("界")
1514
+ const line1_starts_with_kai = line1.trimStart().startsWith("界")
1515
+
1516
+ // "界" should not appear on both lines (would indicate duplication bug)
1517
+ expect(line0_ends_with_kai && line1_starts_with_kai).toBe(false)
1518
+ })
1519
+
1520
+ it("should not split English word 'Hello' in middle when word wrapping with CJK characters", async () => {
1521
+ // This test reproduces the exact issue from text-truncation-demo.ts where "Hello"
1522
+ // is incorrectly split as "Hell" on first line and "o World" on second line
1523
+ // when word wrapping is enabled with CJK/emoji characters before it.
1524
+ resize(60, 10)
1525
+
1526
+ const { text } = await createTextRenderable(currentRenderer, {
1527
+ content: "🌟 Unicode test: こんにちは世界 Hello World 你好世界 안녕하세요 🚀 More emoji: 🎨🎭🎪🎬🎮🎯",
1528
+ wrapMode: "word",
1529
+ width: 50, // Width that causes wrapping in the demo
1530
+ left: 0,
1531
+ top: 0,
1532
+ })
1533
+
1534
+ await renderOnce()
1535
+
1536
+ const frame = captureFrame()
1537
+
1538
+ const lines = frame.split("\n").filter((l) => l.trim().length > 0)
1539
+
1540
+ // The word "Hello" should NOT be split in the middle
1541
+ // Check for the specific incorrect split: "Hell" on one line, "o" starting the next
1542
+ let foundIncorrectSplit = false
1543
+ for (let i = 0; i < lines.length - 1; i++) {
1544
+ const currentLine = lines[i] || ""
1545
+ const nextLine = lines[i + 1] || ""
1546
+
1547
+ // Check if current line ends with "Hell" (incorrect split)
1548
+ if (currentLine.trimEnd().endsWith("Hell")) {
1549
+ // And next line starts with "o" (the rest of "Hello")
1550
+ if (nextLine.trimStart().startsWith("o")) {
1551
+ foundIncorrectSplit = true
1552
+ }
1553
+ }
1554
+ }
1555
+
1556
+ // Verify "Hello" is not split as "Hell" + "o"
1557
+ expect(foundIncorrectSplit).toBe(false)
1558
+
1559
+ // Verify the word "Hello" appears complete on a single line
1560
+ const fullText = lines.join(" ")
1561
+ expect(fullText).toContain("Hello")
1562
+
1563
+ // Verify "Hello" is not split in the middle
1564
+ const helloLineIndex = lines.findIndex((line) => line.includes("Hello"))
1565
+ expect(helloLineIndex).toBeGreaterThanOrEqual(0) // "Hello" should be found
1566
+
1567
+ const helloLine = lines[helloLineIndex] || ""
1568
+ // Verify "Hello" appears as a complete word on this line
1569
+ expect(helloLine).toMatch(/Hello/)
1570
+
1571
+ // Verify no previous line ends with "Hell" without "o"
1572
+ if (helloLineIndex > 0) {
1573
+ const prevLine = lines[helloLineIndex - 1] || ""
1574
+ expect(prevLine.trimEnd().endsWith("Hell")).toBe(false)
1575
+ }
1576
+
1577
+ // Additional verification: "Hello World" should ideally be together
1578
+ // (this is a nice-to-have, showing improved wrapping behavior)
1579
+ expect(helloLine).toContain("Hello World")
1580
+ })
1581
+ })
1582
+
1583
+ describe("Text Node Dimension Updates", () => {
1584
+ it("should update dimensions and reposition subsequent elements when text nodes expand", async () => {
1585
+ const { text: firstText } = await createTextRenderable(currentRenderer, {
1586
+ content: "",
1587
+ width: 20,
1588
+ wrapMode: "char",
1589
+ })
1590
+
1591
+ const shortNode = new TextNodeRenderable({})
1592
+ shortNode.add("Short")
1593
+ firstText.add(shortNode)
1594
+
1595
+ const { text: secondText } = await createTextRenderable(currentRenderer, {
1596
+ content: "Second text",
1597
+ })
1598
+
1599
+ await renderOnce()
1600
+ const initialFrame = captureFrame()
1601
+ expect(initialFrame).toMatchSnapshot()
1602
+
1603
+ expect(firstText.height).toEqual(1)
1604
+ expect(secondText.y).toEqual(1)
1605
+
1606
+ shortNode.add(" text that will definitely wrap")
1607
+
1608
+ await renderOnce()
1609
+
1610
+ const finalFrame = captureFrame()
1611
+
1612
+ expect(firstText.height).toEqual(2)
1613
+ expect(secondText.y).toEqual(2)
1614
+
1615
+ expect(finalFrame).not.toBe(initialFrame)
1616
+ expect(finalFrame).toMatchSnapshot()
1617
+ })
1618
+
1619
+ it("should handle multiple text node updates with complex layout changes", async () => {
1620
+ resize(20, 10)
1621
+ const { text: firstText } = await createTextRenderable(currentRenderer, {
1622
+ width: 10,
1623
+ wrapMode: "word",
1624
+ })
1625
+
1626
+ const node1 = TextNodeRenderable.fromString("First")
1627
+ const node2 = TextNodeRenderable.fromString(" part")
1628
+
1629
+ firstText.add(node1)
1630
+ firstText.add(node2)
1631
+
1632
+ const { text: secondText } = await createTextRenderable(currentRenderer, {
1633
+ width: 12,
1634
+ wrapMode: "word",
1635
+ })
1636
+ secondText.add("Middle text")
1637
+
1638
+ const { text: thirdText } = await createTextRenderable(currentRenderer, {})
1639
+ thirdText.add("Bottom text")
1640
+
1641
+ await renderOnce()
1642
+ const initialFrame = captureFrame()
1643
+ expect(initialFrame).toMatchSnapshot()
1644
+
1645
+ // Record initial positions
1646
+ expect(firstText.height).toEqual(1)
1647
+ expect(secondText.y).toEqual(1)
1648
+ expect(thirdText.y).toEqual(2)
1649
+
1650
+ node1.add(" of a sentence")
1651
+ node2.add("that will wrap")
1652
+
1653
+ await renderOnce()
1654
+
1655
+ const finalFrame = captureFrame()
1656
+ expect(finalFrame).toMatchSnapshot()
1657
+
1658
+ expect(firstText.height).toEqual(5)
1659
+ expect(secondText.y).toEqual(5)
1660
+ expect(thirdText.y).toEqual(6)
1661
+ })
1662
+ })
1663
+
1664
+ describe("Height and Width Measurement", () => {
1665
+ it("should grow height for multiline text without wrapping", async () => {
1666
+ const { text } = await createTextRenderable(currentRenderer, {
1667
+ content: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
1668
+ wrapMode: "none",
1669
+ })
1670
+
1671
+ await renderOnce()
1672
+
1673
+ expect(text.height).toBe(5)
1674
+ expect(text.width).toBeGreaterThanOrEqual(6)
1675
+ })
1676
+
1677
+ it("should grow height for wrapped text when wrapping enabled", async () => {
1678
+ const { text } = await createTextRenderable(currentRenderer, {
1679
+ content: "This is a very long line that will definitely wrap to multiple lines",
1680
+ wrapMode: "word",
1681
+ width: 15,
1682
+ })
1683
+
1684
+ await renderOnce()
1685
+
1686
+ expect(text.height).toBeGreaterThan(1)
1687
+ expect(text.width).toBeLessThanOrEqual(15)
1688
+ })
1689
+
1690
+ it("should measure full width when wrapping is disabled and not constrained by parent", async () => {
1691
+ const longLine = "This is a very long line that would wrap but wrapping is disabled"
1692
+ const { text } = await createTextRenderable(currentRenderer, {
1693
+ content: longLine,
1694
+ wrapMode: "none",
1695
+ position: "absolute",
1696
+ })
1697
+
1698
+ await renderOnce()
1699
+
1700
+ expect(text.height).toBe(1)
1701
+ expect(text.width).toBe(longLine.length)
1702
+ })
1703
+
1704
+ it("should update height when content changes from single to multiline", async () => {
1705
+ const { text } = await createTextRenderable(currentRenderer, {
1706
+ content: "Single line",
1707
+ wrapMode: "none",
1708
+ })
1709
+
1710
+ await renderOnce()
1711
+ expect(text.height).toBe(1)
1712
+
1713
+ text.content = "Line 1\nLine 2\nLine 3"
1714
+ await renderOnce()
1715
+
1716
+ expect(text.height).toBe(3)
1717
+ })
1718
+
1719
+ it("should update height when wrapping mode changes", async () => {
1720
+ const { text } = await createTextRenderable(currentRenderer, {
1721
+ content: "This is a long line that will wrap to multiple lines",
1722
+ wrapMode: "none",
1723
+ width: 15,
1724
+ })
1725
+
1726
+ await renderOnce()
1727
+ const unwrappedHeight = text.height
1728
+ expect(unwrappedHeight).toBe(1)
1729
+ expect(text.width).toBe(15)
1730
+
1731
+ text.wrapMode = "word"
1732
+ await renderOnce()
1733
+
1734
+ const wrappedHeight = text.height
1735
+
1736
+ expect(wrappedHeight).toBeGreaterThan(unwrappedHeight)
1737
+ expect(wrappedHeight).toBeGreaterThanOrEqual(3)
1738
+ })
1739
+
1740
+ it("should shrink height when content changes from multi-line to single line", async () => {
1741
+ const { text } = await createTextRenderable(currentRenderer, {
1742
+ content: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
1743
+ wrapMode: "none",
1744
+ })
1745
+
1746
+ await renderOnce()
1747
+ expect(text.height).toBe(5)
1748
+
1749
+ text.content = "Single line"
1750
+ await renderOnce()
1751
+
1752
+ expect(text.height).toBe(1)
1753
+ })
1754
+
1755
+ it("should shrink width when replacing long line with shorter (wrapMode: none, position: absolute)", async () => {
1756
+ const { text } = await createTextRenderable(currentRenderer, {
1757
+ content: "This is a very long line with many characters",
1758
+ wrapMode: "none",
1759
+ position: "absolute",
1760
+ })
1761
+
1762
+ await renderOnce()
1763
+ const initialWidth = text.width
1764
+ expect(initialWidth).toBe(45) // length of the long line
1765
+
1766
+ text.content = "Short"
1767
+ await renderOnce()
1768
+
1769
+ expect(text.width).toBe(5)
1770
+ expect(text.width).toBeLessThan(initialWidth)
1771
+ })
1772
+ })
1773
+
1774
+ describe("Width/Height Setter Layout Tests", () => {
1775
+ it("should not shrink box when width is set via setter", async () => {
1776
+ resize(40, 10)
1777
+
1778
+ const container = new BoxRenderable(currentRenderer, { border: true, width: 30 })
1779
+ currentRenderer.root.add(container)
1780
+
1781
+ const row = new BoxRenderable(currentRenderer, { flexDirection: "row", width: "100%" })
1782
+ container.add(row)
1783
+
1784
+ const indicator = new BoxRenderable(currentRenderer, { backgroundColor: "#f00" })
1785
+ row.add(indicator)
1786
+
1787
+ const indicatorText = new TextRenderable(currentRenderer, { content: ">" })
1788
+ indicator.add(indicatorText)
1789
+
1790
+ const content = new BoxRenderable(currentRenderer, { backgroundColor: "#0f0", flexGrow: 1 })
1791
+ row.add(content)
1792
+
1793
+ const contentText = new TextRenderable(currentRenderer, { content: "Content that takes up space" })
1794
+ content.add(contentText)
1795
+
1796
+ await renderOnce()
1797
+
1798
+ const initialIndicatorWidth = indicator.width
1799
+
1800
+ indicator.width = 5
1801
+ await renderOnce()
1802
+
1803
+ const frame = captureFrame()
1804
+ expect(frame).toMatchSnapshot()
1805
+
1806
+ expect(indicator.width).toBe(5)
1807
+ expect(content.width).toBeGreaterThan(0)
1808
+ expect(content.width).toBeLessThan(30) // Should be compressed but not zero
1809
+ })
1810
+
1811
+ it("should not shrink box when height is set via setter in column layout with text", async () => {
1812
+ resize(30, 15)
1813
+
1814
+ const outerBox = new BoxRenderable(currentRenderer, { border: true, width: 25, height: 10 })
1815
+ currentRenderer.root.add(outerBox)
1816
+
1817
+ const column = new BoxRenderable(currentRenderer, { flexDirection: "column", height: "100%" })
1818
+ outerBox.add(column)
1819
+
1820
+ const header = new BoxRenderable(currentRenderer, { backgroundColor: "#f00" })
1821
+ column.add(header)
1822
+
1823
+ const headerText = new TextRenderable(currentRenderer, { content: "Header" })
1824
+ header.add(headerText)
1825
+
1826
+ const mainContent = new BoxRenderable(currentRenderer, { backgroundColor: "#0f0", flexGrow: 1 })
1827
+ column.add(mainContent)
1828
+
1829
+ const mainText = new TextRenderable(currentRenderer, {
1830
+ content: "Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8",
1831
+ })
1832
+ mainContent.add(mainText)
1833
+
1834
+ const footer = new BoxRenderable(currentRenderer, { height: 2, backgroundColor: "#00f" })
1835
+ column.add(footer)
1836
+
1837
+ const footerText = new TextRenderable(currentRenderer, { content: "Footer" })
1838
+ footer.add(footerText)
1839
+
1840
+ await renderOnce()
1841
+
1842
+ header.height = 3
1843
+ await renderOnce()
1844
+
1845
+ const frame = captureFrame()
1846
+ expect(frame).toMatchSnapshot()
1847
+
1848
+ expect(header.height).toBe(3)
1849
+ expect(mainContent.height).toBeGreaterThan(0)
1850
+ expect(footer.height).toBe(2)
1851
+ })
1852
+
1853
+ it("should not shrink box when minWidth is set via setter", async () => {
1854
+ resize(40, 10)
1855
+
1856
+ const container = new BoxRenderable(currentRenderer, { border: true, width: 30 })
1857
+ currentRenderer.root.add(container)
1858
+
1859
+ const row = new BoxRenderable(currentRenderer, { flexDirection: "row", width: "100%" })
1860
+ container.add(row)
1861
+
1862
+ const indicator = new BoxRenderable(currentRenderer, { backgroundColor: "#f00", flexShrink: 1 })
1863
+ row.add(indicator)
1864
+
1865
+ const indicatorText = new TextRenderable(currentRenderer, { content: ">" })
1866
+ indicator.add(indicatorText)
1867
+
1868
+ const content = new BoxRenderable(currentRenderer, { backgroundColor: "#0f0", flexGrow: 1 })
1869
+ row.add(content)
1870
+
1871
+ const contentText = new TextRenderable(currentRenderer, { content: "Content that takes up space" })
1872
+ content.add(contentText)
1873
+
1874
+ await renderOnce()
1875
+
1876
+ indicator.minWidth = 5
1877
+ await renderOnce()
1878
+
1879
+ const frame = captureFrame()
1880
+ expect(frame).toMatchSnapshot()
1881
+
1882
+ expect(indicator.width).toBeGreaterThanOrEqual(5)
1883
+ expect(content.width).toBeGreaterThan(0)
1884
+ })
1885
+
1886
+ it("should not shrink box when minHeight is set via setter in column layout with text", async () => {
1887
+ resize(30, 15)
1888
+
1889
+ const outerBox = new BoxRenderable(currentRenderer, { border: true, width: 25, height: 10 })
1890
+ currentRenderer.root.add(outerBox)
1891
+
1892
+ const column = new BoxRenderable(currentRenderer, { flexDirection: "column", height: "100%" })
1893
+ outerBox.add(column)
1894
+
1895
+ const header = new BoxRenderable(currentRenderer, { backgroundColor: "#f00", flexShrink: 1 })
1896
+ column.add(header)
1897
+
1898
+ const headerText = new TextRenderable(currentRenderer, { content: "Header" })
1899
+ header.add(headerText)
1900
+
1901
+ const mainContent = new BoxRenderable(currentRenderer, { backgroundColor: "#0f0", flexGrow: 1 })
1902
+ column.add(mainContent)
1903
+
1904
+ const mainText = new TextRenderable(currentRenderer, {
1905
+ content: "Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8",
1906
+ })
1907
+ mainContent.add(mainText)
1908
+
1909
+ const footer = new BoxRenderable(currentRenderer, { height: 2, backgroundColor: "#00f" })
1910
+ column.add(footer)
1911
+
1912
+ const footerText = new TextRenderable(currentRenderer, { content: "Footer" })
1913
+ footer.add(footerText)
1914
+
1915
+ await renderOnce()
1916
+
1917
+ header.minHeight = 3
1918
+ await renderOnce()
1919
+
1920
+ const frame = captureFrame()
1921
+ expect(frame).toMatchSnapshot()
1922
+
1923
+ expect(header.height).toBeGreaterThanOrEqual(3)
1924
+ expect(mainContent.height).toBeGreaterThan(0)
1925
+ expect(footer.height).toBe(2)
1926
+ })
1927
+
1928
+ it("should not shrink box when width is set from undefined via setter", async () => {
1929
+ resize(40, 10)
1930
+
1931
+ const container = new BoxRenderable(currentRenderer, { border: true, width: 30 })
1932
+ currentRenderer.root.add(container)
1933
+
1934
+ const row = new BoxRenderable(currentRenderer, { flexDirection: "row", width: "100%" })
1935
+ container.add(row)
1936
+
1937
+ const indicator = new BoxRenderable(currentRenderer, { backgroundColor: "#f00", flexShrink: 1 })
1938
+ row.add(indicator)
1939
+
1940
+ const indicatorText = new TextRenderable(currentRenderer, { content: ">" })
1941
+ indicator.add(indicatorText)
1942
+
1943
+ const content = new BoxRenderable(currentRenderer, { backgroundColor: "#0f0", flexGrow: 1 })
1944
+ row.add(content)
1945
+
1946
+ const contentText = new TextRenderable(currentRenderer, { content: "Content that takes up space" })
1947
+ content.add(contentText)
1948
+
1949
+ await renderOnce()
1950
+
1951
+ indicator.width = 5
1952
+ await renderOnce()
1953
+
1954
+ const frame = captureFrame()
1955
+ expect(frame).toMatchSnapshot()
1956
+
1957
+ expect(indicator.width).toBe(5)
1958
+ expect(content.width).toBeGreaterThan(0)
1959
+ })
1960
+ })
1961
+
1962
+ describe("Absolute Positioned Box with Text", () => {
1963
+ it("should render text in absolute positioned box with padding and borders correctly", async () => {
1964
+ resize(80, 20)
1965
+
1966
+ const notificationBox = new BoxRenderable(currentRenderer, {
1967
+ position: "absolute",
1968
+ justifyContent: "center",
1969
+ alignItems: "flex-start",
1970
+ top: 2,
1971
+ right: 2,
1972
+ width: Math.min(60, 80 - 6),
1973
+ paddingLeft: 2,
1974
+ paddingRight: 2,
1975
+ paddingTop: 1,
1976
+ paddingBottom: 1,
1977
+ backgroundColor: "#1e293b",
1978
+ borderColor: "#3b82f6",
1979
+ border: ["left", "right"],
1980
+ })
1981
+
1982
+ currentRenderer.root.add(notificationBox)
1983
+
1984
+ // Wrap content in nested boxes with row layout and gap
1985
+ const outerWrapperBox = new BoxRenderable(currentRenderer, {
1986
+ flexDirection: "row",
1987
+ paddingBottom: 1,
1988
+ paddingTop: 1,
1989
+ paddingLeft: 2,
1990
+ paddingRight: 2,
1991
+ gap: 2,
1992
+ })
1993
+ notificationBox.add(outerWrapperBox)
1994
+
1995
+ const innerContentBox = new BoxRenderable(currentRenderer, {
1996
+ flexGrow: 1,
1997
+ gap: 1,
1998
+ })
1999
+ outerWrapperBox.add(innerContentBox)
2000
+
2001
+ const titleText = new TextRenderable(currentRenderer, {
2002
+ content: "Important Notification",
2003
+ attributes: 1, // BOLD
2004
+ marginBottom: 1,
2005
+ fg: "#f8fafc",
2006
+ })
2007
+ innerContentBox.add(titleText)
2008
+
2009
+ const messageText = new TextRenderable(currentRenderer, {
2010
+ content:
2011
+ "This is a longer message that should wrap properly within the absolutely positioned box with appropriate width constraints and padding applied.",
2012
+ fg: "#e2e8f0",
2013
+ wrapMode: "word",
2014
+ width: "100%",
2015
+ })
2016
+ innerContentBox.add(messageText)
2017
+
2018
+ await renderOnce()
2019
+
2020
+ const frame = captureFrame()
2021
+ expect(frame).toMatchSnapshot()
2022
+
2023
+ // Verify the box is positioned correctly
2024
+ expect(notificationBox.x).toBeGreaterThan(0)
2025
+ expect(notificationBox.y).toBe(2)
2026
+ expect(notificationBox.width).toBe(60)
2027
+
2028
+ // Note: With current Yoga behavior, nested flex boxes with width:"100%" inside
2029
+ // an absolutely positioned parent with only maxWidth (no explicit width) causes
2030
+ // the children to grow to their intrinsic size rather than being constrained
2031
+ // This is Yoga's shrink-to-fit behavior with the circular dependency
2032
+ // See: https://github.com/facebook/yoga/issues/1409
2033
+ expect(outerWrapperBox.width).toBeGreaterThan(100)
2034
+ expect(innerContentBox.width).toBeGreaterThan(100)
2035
+ expect(messageText.width).toBeGreaterThan(100)
2036
+ expect(messageText.height).toBe(1)
2037
+ expect(messageText.plainText).toBe(
2038
+ "This is a longer message that should wrap properly within the absolutely positioned box with appropriate width constraints and padding applied.",
2039
+ )
2040
+ })
2041
+
2042
+ it("should render text fully visible in absolute positioned box at various positions", async () => {
2043
+ resize(100, 25)
2044
+
2045
+ // Top-right positioned box
2046
+ const topRightBox = new BoxRenderable(currentRenderer, {
2047
+ position: "absolute",
2048
+ top: 1,
2049
+ right: 1,
2050
+ maxWidth: 40,
2051
+ paddingLeft: 1,
2052
+ paddingRight: 1,
2053
+ paddingTop: 0,
2054
+ paddingBottom: 0,
2055
+ backgroundColor: "#fef2f2",
2056
+ borderColor: "#ef4444",
2057
+ border: true,
2058
+ })
2059
+ currentRenderer.root.add(topRightBox)
2060
+
2061
+ const topRightText = new TextRenderable(currentRenderer, {
2062
+ content: "Error: File not found in the specified directory path",
2063
+ fg: "#991b1b",
2064
+ wrapMode: "word",
2065
+ width: "100%",
2066
+ })
2067
+ topRightBox.add(topRightText)
2068
+
2069
+ // Bottom-left positioned box
2070
+ const bottomLeftBox = new BoxRenderable(currentRenderer, {
2071
+ position: "absolute",
2072
+ bottom: 1,
2073
+ left: 1,
2074
+ maxWidth: 35,
2075
+ paddingLeft: 1,
2076
+ paddingRight: 1,
2077
+ backgroundColor: "#f0fdf4",
2078
+ borderColor: "#22c55e",
2079
+ border: ["top", "bottom"],
2080
+ })
2081
+ currentRenderer.root.add(bottomLeftBox)
2082
+
2083
+ const bottomLeftText = new TextRenderable(currentRenderer, {
2084
+ content: "Success: Operation completed successfully!",
2085
+ fg: "#166534",
2086
+ wrapMode: "word",
2087
+ width: "100%",
2088
+ })
2089
+ bottomLeftBox.add(bottomLeftText)
2090
+
2091
+ await renderOnce()
2092
+
2093
+ const frame = captureFrame()
2094
+ expect(frame).toMatchSnapshot()
2095
+
2096
+ // Verify top-right box positioning and dimensions
2097
+ expect(topRightBox.y).toBe(1)
2098
+ expect(topRightBox.x).toBeGreaterThan(50)
2099
+ expect(topRightBox.width).toBeGreaterThan(30)
2100
+ expect(topRightBox.width).toBeLessThanOrEqual(40)
2101
+
2102
+ // Verify top-right text renders with proper width
2103
+ expect(topRightText.plainText).toBe("Error: File not found in the specified directory path")
2104
+ expect(topRightText.width).toBeGreaterThan(25)
2105
+ expect(topRightText.width).toBeLessThanOrEqual(38)
2106
+ expect(topRightText.height).toBeGreaterThan(1)
2107
+
2108
+ // Verify bottom-left box positioning and dimensions
2109
+ expect(bottomLeftBox.x).toBe(1)
2110
+ expect(bottomLeftBox.y).toBeGreaterThan(15)
2111
+ expect(bottomLeftBox.width).toBeGreaterThan(25)
2112
+ expect(bottomLeftBox.width).toBeLessThanOrEqual(35)
2113
+
2114
+ // Verify bottom-left text renders with proper width
2115
+ expect(bottomLeftText.plainText).toBe("Success: Operation completed successfully!")
2116
+ expect(bottomLeftText.width).toBeGreaterThan(25)
2117
+ expect(bottomLeftText.width).toBeLessThanOrEqual(33)
2118
+ expect(bottomLeftText.height).toBeGreaterThan(1)
2119
+ expect(bottomLeftText.width).toBeGreaterThan(0)
2120
+ expect(bottomLeftText.width).toBeLessThanOrEqual(33) // maxWidth 35 - padding 2
2121
+ })
2122
+
2123
+ it("should handle width:100% text in absolute positioned box with constrained maxWidth", async () => {
2124
+ resize(70, 15)
2125
+
2126
+ const constrainedBox = new BoxRenderable(currentRenderer, {
2127
+ position: "absolute",
2128
+ top: 5,
2129
+ left: 10,
2130
+ maxWidth: 50,
2131
+ paddingLeft: 3,
2132
+ paddingRight: 3,
2133
+ paddingTop: 2,
2134
+ paddingBottom: 2,
2135
+ backgroundColor: "#1e1e2e",
2136
+ })
2137
+ currentRenderer.root.add(constrainedBox)
2138
+
2139
+ const longText = new TextRenderable(currentRenderer, {
2140
+ content:
2141
+ "This is an extremely long piece of text that needs to wrap multiple times within the constrained width of the absolutely positioned container box with significant padding on all sides.",
2142
+ fg: "#cdd6f4",
2143
+ wrapMode: "word",
2144
+ width: "100%",
2145
+ })
2146
+ constrainedBox.add(longText)
2147
+
2148
+ await renderOnce()
2149
+
2150
+ const frame = captureFrame()
2151
+ expect(frame).toMatchSnapshot()
2152
+
2153
+ // Verify the box respects maxWidth
2154
+ expect(constrainedBox.width).toBeLessThanOrEqual(50)
2155
+ expect(constrainedBox.width).toBeGreaterThan(40)
2156
+ expect(constrainedBox.x).toBe(10)
2157
+ expect(constrainedBox.y).toBe(5)
2158
+
2159
+ // Verify text wraps and fills available width
2160
+ expect(longText.width).toBeGreaterThan(35)
2161
+ expect(longText.width).toBeLessThanOrEqual(44)
2162
+ expect(longText.height).toBeGreaterThanOrEqual(5)
2163
+ expect(longText.plainText).toBe(
2164
+ "This is an extremely long piece of text that needs to wrap multiple times within the constrained width of the absolutely positioned container box with significant padding on all sides.",
2165
+ )
2166
+ })
2167
+
2168
+ it("should render multiple text elements in absolute positioned box with proper spacing", async () => {
2169
+ resize(90, 20)
2170
+
2171
+ const infoBox = new BoxRenderable(currentRenderer, {
2172
+ position: "absolute",
2173
+ justifyContent: "flex-start",
2174
+ alignItems: "flex-start",
2175
+ top: 3,
2176
+ right: 5,
2177
+ maxWidth: 45,
2178
+ paddingLeft: 2,
2179
+ paddingRight: 2,
2180
+ paddingTop: 1,
2181
+ paddingBottom: 1,
2182
+ backgroundColor: "#eff6ff",
2183
+ borderColor: "#3b82f6",
2184
+ border: true,
2185
+ })
2186
+ currentRenderer.root.add(infoBox)
2187
+
2188
+ const headerText = new TextRenderable(currentRenderer, {
2189
+ content: "System Update",
2190
+ attributes: 1, // BOLD
2191
+ fg: "#1e40af",
2192
+ })
2193
+ infoBox.add(headerText)
2194
+
2195
+ const bodyText = new TextRenderable(currentRenderer, {
2196
+ content: "A new version is available with bug fixes and performance improvements.",
2197
+ fg: "#1e3a8a",
2198
+ wrapMode: "word",
2199
+ width: "100%",
2200
+ marginTop: 1,
2201
+ })
2202
+ infoBox.add(bodyText)
2203
+
2204
+ const footerText = new TextRenderable(currentRenderer, {
2205
+ content: "Click to install",
2206
+ fg: "#60a5fa",
2207
+ marginTop: 1,
2208
+ })
2209
+ infoBox.add(footerText)
2210
+
2211
+ await renderOnce()
2212
+
2213
+ const frame = captureFrame()
2214
+ expect(frame).toMatchSnapshot()
2215
+
2216
+ // Verify all texts are rendered with correct content
2217
+ expect(headerText.plainText).toBe("System Update")
2218
+ expect(bodyText.plainText).toBe("A new version is available with bug fixes and performance improvements.")
2219
+ expect(footerText.plainText).toBe("Click to install")
2220
+
2221
+ // Verify box dimensions are reasonable
2222
+ expect(infoBox.width).toBeGreaterThan(35)
2223
+ expect(infoBox.width).toBeLessThanOrEqual(45)
2224
+
2225
+ // Verify header text renders properly
2226
+ expect(headerText.width).toBeGreaterThan(10)
2227
+ expect(headerText.height).toBe(1)
2228
+
2229
+ // Verify body text fills width and wraps
2230
+ expect(bodyText.width).toBeGreaterThan(30)
2231
+ expect(bodyText.height).toBeGreaterThanOrEqual(2)
2232
+
2233
+ // Verify footer text renders properly
2234
+ expect(footerText.width).toBeGreaterThan(10)
2235
+ expect(footerText.height).toBe(1)
2236
+
2237
+ // Verify vertical spacing
2238
+ expect(bodyText.y).toBeGreaterThan(headerText.y)
2239
+ expect(footerText.y).toBeGreaterThan(bodyText.y)
2240
+ })
2241
+ })
2242
+
2243
+ describe("Word Wrapping", () => {
2244
+ it("should default to word wrap mode", async () => {
2245
+ const { text } = await createTextRenderable(currentRenderer, {
2246
+ content: "Hello World",
2247
+ })
2248
+
2249
+ expect(text.wrapMode).toBe("word")
2250
+ })
2251
+
2252
+ it("should wrap at word boundaries when using word mode", async () => {
2253
+ await createTextRenderable(currentRenderer, {
2254
+ content: "The quick brown fox jumps over the lazy dog",
2255
+ wrapMode: "word",
2256
+ width: 15,
2257
+ left: 0,
2258
+ top: 0,
2259
+ })
2260
+
2261
+ const frame = captureFrame()
2262
+ expect(frame).toMatchSnapshot()
2263
+ })
2264
+
2265
+ it("should wrap at character boundaries when using char mode", async () => {
2266
+ const { text } = await createTextRenderable(currentRenderer, {
2267
+ content: "The quick brown fox jumps over the lazy dog",
2268
+ wrapMode: "char",
2269
+ width: 15,
2270
+ left: 0,
2271
+ top: 0,
2272
+ })
2273
+
2274
+ const frame = captureFrame()
2275
+ expect(frame).toMatchSnapshot()
2276
+ })
2277
+
2278
+ it("should handle word wrapping with punctuation", async () => {
2279
+ await createTextRenderable(currentRenderer, {
2280
+ content: "Hello,World.Test-Example/Path",
2281
+ wrapMode: "word",
2282
+ width: 10,
2283
+ left: 0,
2284
+ top: 0,
2285
+ })
2286
+
2287
+ const frame = captureFrame()
2288
+ expect(frame).toMatchSnapshot()
2289
+ })
2290
+
2291
+ it("should handle word wrapping with hyphens and dashes", async () => {
2292
+ await createTextRenderable(currentRenderer, {
2293
+ content: "self-contained multi-line text-wrapping example",
2294
+ wrapMode: "word",
2295
+ width: 12,
2296
+ left: 0,
2297
+ top: 0,
2298
+ })
2299
+
2300
+ const frame = captureFrame()
2301
+ expect(frame).toMatchSnapshot()
2302
+ })
2303
+
2304
+ it("regression #651: should keep multi-byte UTF-8 words intact when wrapping in word mode", async () => {
2305
+ resize(80, 24)
2306
+
2307
+ await createTextRenderable(currentRenderer, {
2308
+ content: "gyorskiszolgáló éttermek közül. Azóta alapjaiban értelmeztük újra a vendéglátást",
2309
+ wrapMode: "word",
2310
+ width: 40,
2311
+ left: 0,
2312
+ top: 0,
2313
+ })
2314
+
2315
+ const lines = captureFrame()
2316
+ .split("\n")
2317
+ .map((line) => line.trimEnd())
2318
+ .filter((line) => line.length > 0)
2319
+
2320
+ const expectedLines = ["gyorskiszolgáló éttermek közül. Azóta", "alapjaiban értelmeztük újra a", "vendéglátást"]
2321
+
2322
+ expect(lines).toEqual(expectedLines)
2323
+ })
2324
+
2325
+ it("should dynamically change wrap mode", async () => {
2326
+ const { text } = await createTextRenderable(currentRenderer, {
2327
+ content: "The quick brown fox jumps",
2328
+ wrapMode: "char",
2329
+ width: 10,
2330
+ left: 0,
2331
+ top: 0,
2332
+ })
2333
+
2334
+ expect(text.wrapMode).toBe("char")
2335
+
2336
+ // Change to word mode
2337
+ text.wrapMode = "word"
2338
+ await renderOnce()
2339
+
2340
+ expect(text.wrapMode).toBe("word")
2341
+ const frame = captureFrame()
2342
+ expect(frame).toMatchSnapshot()
2343
+ })
2344
+
2345
+ it("should handle long words that exceed wrap width in word mode", async () => {
2346
+ await createTextRenderable(currentRenderer, {
2347
+ content: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
2348
+ wrapMode: "word",
2349
+ width: 10,
2350
+ left: 0,
2351
+ top: 0,
2352
+ })
2353
+
2354
+ // Since there's no word boundary, it should fall back to character wrapping
2355
+ const frame = captureFrame()
2356
+ expect(frame).toMatchSnapshot()
2357
+ })
2358
+
2359
+ it("should preserve empty lines with word wrapping", async () => {
2360
+ await createTextRenderable(currentRenderer, {
2361
+ content: "First line\n\nThird line",
2362
+ wrapMode: "word",
2363
+ width: 8,
2364
+ left: 0,
2365
+ top: 0,
2366
+ })
2367
+
2368
+ const frame = captureFrame()
2369
+ expect(frame).toMatchSnapshot()
2370
+ })
2371
+
2372
+ it("should handle word wrapping with single character words", async () => {
2373
+ await createTextRenderable(currentRenderer, {
2374
+ content: "a b c d e f g h i j k l m n o p",
2375
+ wrapMode: "word",
2376
+ width: 8,
2377
+ left: 0,
2378
+ top: 0,
2379
+ })
2380
+
2381
+ const frame = captureFrame()
2382
+ expect(frame).toMatchSnapshot()
2383
+ })
2384
+
2385
+ it("should compare char vs word wrapping with same content", async () => {
2386
+ const content = "Hello wonderful world of text wrapping"
2387
+
2388
+ // Test with char mode
2389
+ const { text: charText } = await createTextRenderable(currentRenderer, {
2390
+ content,
2391
+ wrapMode: "char",
2392
+ width: 12,
2393
+ left: 0,
2394
+ top: 0,
2395
+ })
2396
+
2397
+ const charFrame = captureFrame()
2398
+
2399
+ // Remove the char text and add word text
2400
+ currentRenderer.root.remove(charText.id)
2401
+ await renderOnce()
2402
+
2403
+ await createTextRenderable(currentRenderer, {
2404
+ content,
2405
+ wrapMode: "word",
2406
+ width: 12,
2407
+ left: 0,
2408
+ top: 0,
2409
+ })
2410
+
2411
+ const wordFrame = captureFrame()
2412
+
2413
+ // The frames should be different as word wrapping preserves word boundaries
2414
+ expect(charFrame).not.toBe(wordFrame)
2415
+ expect(wordFrame).toMatchSnapshot()
2416
+ })
2417
+
2418
+ it("should correctly wrap text when updating content via text.content", async () => {
2419
+ const { text } = await createTextRenderable(currentRenderer, {
2420
+ content: "Short text",
2421
+ wrapMode: "word",
2422
+ left: 0,
2423
+ top: 0,
2424
+ })
2425
+
2426
+ await renderOnce()
2427
+ const initialFrame = captureFrame()
2428
+ expect(initialFrame).toMatchSnapshot()
2429
+
2430
+ text.content = "This is a much longer text that should definitely wrap to multiple lines"
2431
+
2432
+ await renderOnce()
2433
+ const updatedFrame = captureFrame()
2434
+ expect(updatedFrame).toMatchSnapshot()
2435
+ })
2436
+ })
2437
+
2438
+ describe("Mouse Scrolling", () => {
2439
+ it("should receive mouse scroll events", async () => {
2440
+ resize(20, 10)
2441
+
2442
+ const longText = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10"
2443
+ const { text } = await createTextRenderable(currentRenderer, {
2444
+ content: longText,
2445
+ wrapMode: "none",
2446
+ })
2447
+
2448
+ await renderOnce()
2449
+
2450
+ let scrollEventReceived = false
2451
+ let scrollInfo: any = null
2452
+
2453
+ // Override the handler to capture events
2454
+ const originalHandler = text.onMouseScroll
2455
+ text.onMouseScroll = (event: any) => {
2456
+ scrollEventReceived = true
2457
+ scrollInfo = event.scroll
2458
+ // Call original handler
2459
+ if (originalHandler) {
2460
+ originalHandler.call(text, event)
2461
+ }
2462
+ }
2463
+
2464
+ await currentMouse.scroll(text.x + 1, text.y + 1, "down")
2465
+ await renderOnce()
2466
+
2467
+ expect(scrollEventReceived).toBe(true)
2468
+ expect(scrollInfo).toBeDefined()
2469
+ expect(scrollInfo?.direction).toBe("down")
2470
+ })
2471
+
2472
+ it("should handle mouse scroll events for vertical scrolling", async () => {
2473
+ resize(20, 5)
2474
+
2475
+ const longText = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10"
2476
+ const { text } = await createTextRenderable(currentRenderer, {
2477
+ content: longText,
2478
+ wrapMode: "none",
2479
+ })
2480
+
2481
+ await renderOnce()
2482
+
2483
+ // Initially should be at scroll position 0
2484
+ expect(text.scrollY).toBe(0)
2485
+ expect(text.scrollX).toBe(0)
2486
+
2487
+ // Scroll down (each scroll event typically moves by 1)
2488
+ await currentMouse.scroll(text.x + 1, text.y + 1, "down")
2489
+ await currentMouse.scroll(text.x + 1, text.y + 1, "down")
2490
+ await currentMouse.scroll(text.x + 1, text.y + 1, "down")
2491
+ await renderOnce()
2492
+
2493
+ expect(text.scrollY).toBe(3)
2494
+
2495
+ // Scroll up
2496
+ await currentMouse.scroll(text.x + 1, text.y + 1, "up")
2497
+ await renderOnce()
2498
+
2499
+ expect(text.scrollY).toBe(2)
2500
+ })
2501
+
2502
+ it("should handle mouse scroll events for horizontal scrolling with unwrapped text", async () => {
2503
+ resize(80, 5)
2504
+
2505
+ const wideText =
2506
+ "This is a very long line that extends way beyond the visible area and should definitely need scrolling"
2507
+ const { text } = await createTextRenderable(currentRenderer, {
2508
+ content: wideText,
2509
+ wrapMode: "none",
2510
+ width: 20,
2511
+ maxWidth: 20,
2512
+ })
2513
+
2514
+ await renderOnce()
2515
+
2516
+ expect(text.scrollX).toBe(0)
2517
+ expect(text.scrollY).toBe(0)
2518
+
2519
+ // Scroll right
2520
+ for (let i = 0; i < 5; i++) {
2521
+ await currentMouse.scroll(text.x + 1, text.y, "right")
2522
+ }
2523
+ await renderOnce()
2524
+
2525
+ expect(text.scrollX).toBe(5)
2526
+
2527
+ // Scroll left
2528
+ await currentMouse.scroll(text.x + 1, text.y, "left")
2529
+ await currentMouse.scroll(text.x + 1, text.y, "left")
2530
+ await renderOnce()
2531
+
2532
+ expect(text.scrollX).toBe(3)
2533
+ })
2534
+
2535
+ it("should not allow horizontal scrolling when text is wrapped", async () => {
2536
+ resize(20, 5)
2537
+
2538
+ const longText =
2539
+ "Line 1 text\nLine 2 text\nLine 3 text\nLine 4 text\nLine 5 text\nLine 6 text\nLine 7 text\nLine 8 text"
2540
+ const { text } = await createTextRenderable(currentRenderer, {
2541
+ content: longText,
2542
+ wrapMode: "word",
2543
+ width: 15,
2544
+ height: 3, // Constrain height to enable vertical scrolling
2545
+ })
2546
+
2547
+ await renderOnce()
2548
+
2549
+ // Try to scroll horizontally
2550
+ for (let i = 0; i < 5; i++) {
2551
+ await currentMouse.scroll(text.x + 1, text.y + 1, "right")
2552
+ }
2553
+ await renderOnce()
2554
+
2555
+ // Should not scroll horizontally when wrapped
2556
+ expect(text.scrollX).toBe(0)
2557
+
2558
+ // But vertical scrolling should still work if there's content
2559
+ if (text.maxScrollY > 0) {
2560
+ await currentMouse.scroll(text.x + 1, text.y + 1, "down")
2561
+ await currentMouse.scroll(text.x + 1, text.y + 1, "down")
2562
+ await renderOnce()
2563
+
2564
+ expect(text.scrollY).toBe(2)
2565
+ }
2566
+ })
2567
+
2568
+ it("should clamp scroll position to valid bounds", async () => {
2569
+ resize(20, 5)
2570
+
2571
+ const shortText = "Line 1\nLine 2\nLine 3"
2572
+ const { text } = await createTextRenderable(currentRenderer, {
2573
+ content: shortText,
2574
+ wrapMode: "none",
2575
+ })
2576
+
2577
+ await renderOnce()
2578
+
2579
+ // Try to scroll beyond content
2580
+ for (let i = 0; i < 10; i++) {
2581
+ await currentMouse.scroll(text.x + 1, text.y + 1, "down")
2582
+ }
2583
+ await renderOnce()
2584
+
2585
+ // Should be clamped to maxScrollY
2586
+ expect(text.scrollY).toBeLessThanOrEqual(text.maxScrollY)
2587
+ expect(text.scrollY).toBeGreaterThanOrEqual(0)
2588
+
2589
+ // Try to scroll up beyond 0
2590
+ for (let i = 0; i < 20; i++) {
2591
+ await currentMouse.scroll(text.x + 1, text.y + 1, "up")
2592
+ }
2593
+ await renderOnce()
2594
+
2595
+ expect(text.scrollY).toBe(0)
2596
+ })
2597
+
2598
+ it("should expose scrollWidth and scrollHeight getters", async () => {
2599
+ resize(20, 5)
2600
+
2601
+ const text = "Line 1\nLine 2 with more content\nLine 3"
2602
+ const { text: textRenderable } = await createTextRenderable(currentRenderer, {
2603
+ content: text,
2604
+ wrapMode: "none",
2605
+ })
2606
+
2607
+ await renderOnce()
2608
+
2609
+ expect(textRenderable.scrollHeight).toBe(3) // 3 lines
2610
+ expect(textRenderable.scrollWidth).toBeGreaterThan(0) // Max width of lines
2611
+ })
2612
+
2613
+ it("should calculate maxScrollY and maxScrollX correctly", async () => {
2614
+ resize(20, 5)
2615
+
2616
+ const text = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8"
2617
+ const { text: textRenderable } = await createTextRenderable(currentRenderer, {
2618
+ content: text,
2619
+ wrapMode: "none",
2620
+ height: 5,
2621
+ })
2622
+
2623
+ await renderOnce()
2624
+
2625
+ // maxScrollY should be scrollHeight - viewport height
2626
+ expect(textRenderable.maxScrollY).toBe(Math.max(0, textRenderable.scrollHeight - textRenderable.height))
2627
+
2628
+ // maxScrollX should be scrollWidth - viewport width
2629
+ expect(textRenderable.maxScrollX).toBe(Math.max(0, textRenderable.scrollWidth - textRenderable.width))
2630
+ })
2631
+
2632
+ it("should update scroll position via setters", async () => {
2633
+ resize(20, 5)
2634
+
2635
+ const longText =
2636
+ "Line 1 with some extra content\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10"
2637
+ const { text } = await createTextRenderable(currentRenderer, {
2638
+ content: longText,
2639
+ wrapMode: "none",
2640
+ width: 20, // Constrain width
2641
+ height: 5, // Constrain height
2642
+ })
2643
+
2644
+ await renderOnce()
2645
+
2646
+ // Set scroll position directly
2647
+ text.scrollY = 3
2648
+ await renderOnce()
2649
+
2650
+ expect(text.scrollY).toBe(3)
2651
+
2652
+ // Set scrollX (only works if there's horizontal scrollable content)
2653
+ if (text.maxScrollX > 0) {
2654
+ text.scrollX = 2
2655
+ await renderOnce()
2656
+ expect(text.scrollX).toBe(2)
2657
+ }
2658
+ })
2659
+ })
2660
+ })